mirror of
https://github.com/BEARlogin/max-telegram-bridge-bot.git
synced 2026-07-09 17:18:37 +00:00
crosspost: fix long album caption + strip custom emoji and whitespace padding
Some checks failed
Build / build (push) Has been cancelled
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
This commit is contained in:
parent
a7dab2896b
commit
f57105d2b6
29 changed files with 2043 additions and 314 deletions
|
|
@ -191,7 +191,8 @@ export MAX_TOKEN=your_token
|
||||||
| `DB_PATH` | Путь к SQLite базе | `bridge.db` |
|
| `DB_PATH` | Путь к SQLite базе | `bridge.db` |
|
||||||
| `DATABASE_URL` | DSN для PostgreSQL (если задана — SQLite игнорируется) | — |
|
| `DATABASE_URL` | DSN для PostgreSQL (если задана — SQLite игнорируется) | — |
|
||||||
| `TG_BOT_URL` | Ссылка на TG-бота (показывается в `/help`) | `https://t.me/MaxTelegramBridgeBot` |
|
| `TG_BOT_URL` | Ссылка на TG-бота (показывается в `/help`) | `https://t.me/MaxTelegramBridgeBot` |
|
||||||
| `MAX_BOT_URL` | Ссылка на MAX-бота (показывается в `/help`) | `https://max.ru/id710708943262_bot` |
|
| `MAX_BOT_URL` | Ссылка на основного MAX-бота (показывается в `/help`) | `https://max.ru/id710708943262_bot` |
|
||||||
|
| `MAX_BOT_URL_RESERVE` | Ссылка на запасного MAX-бота (failover; пусто — скрыть подсказку) | `https://max.ru/id710708943262_4_bot` |
|
||||||
| `MAX_WEBHOOK_URL` | Базовый URL для webhook, например `https://bridge.example.com` (если не задан — long polling). Эндпоинт: `/max-webhook` | — |
|
| `MAX_WEBHOOK_URL` | Базовый URL для webhook, например `https://bridge.example.com` (если не задан — long polling). Эндпоинт: `/max-webhook` | — |
|
||||||
| `TG_WEBHOOK_URL` | Базовый URL для webhook, например `https://bridge.example.com` (если не задан — long polling). Эндпоинты: `/tg-webhook` | — |
|
| `TG_WEBHOOK_URL` | Базовый URL для webhook, например `https://bridge.example.com` (если не задан — long polling). Эндпоинты: `/tg-webhook` | — |
|
||||||
| `MAX_WEBHOOK_PORT` | Порт для webhook сервера MAX. Если порт совпадает с Telegram, значит будет общий сервер | `8443` |
|
| `MAX_WEBHOOK_PORT` | Порт для webhook сервера MAX. Если порт совпадает с Telegram, значит будет общий сервер | `8443` |
|
||||||
|
|
|
||||||
91
addon.go
91
addon.go
|
|
@ -29,7 +29,19 @@ type Addon interface {
|
||||||
// сигнатура совпадала между main.Addon и приватным пакетом аддона (Go требует
|
// сигнатура совпадала между main.Addon и приватным пакетом аддона (Go требует
|
||||||
// идентичности типов в методах интерфейса). Возвращает true, если сообщение
|
// идентичности типов в методах интерфейса). Возвращает true, если сообщение
|
||||||
// обработано как нежелательное (удалено) — ядру для остановки дальнейшей пересылки.
|
// обработано как нежелательное (удалено) — ядру для остановки дальнейшей пересылки.
|
||||||
Moderate(ctx context.Context, platform string, chatID, userID int64, tgMsgID int, maxMid, text string, hasLink, isAdmin bool) (handled bool)
|
Moderate(ctx context.Context, platform string, chatID, userID int64, tgMsgID int, maxMid, text string, hasLink, isAdmin, extReply bool) (handled bool)
|
||||||
|
// WantImageCheck — нужно ли ядру вообще скачивать картинку для модерации в этом
|
||||||
|
// чате (антиспам включён). Дешёвая проверка ДО скачивания, чтобы не качать фото
|
||||||
|
// в чатах без антиспама.
|
||||||
|
WantImageCheck(platform string, chatID int64) bool
|
||||||
|
// DeleteServiceMsgs — удалять ли служебные сообщения (вошёл/вышел/смена названия/
|
||||||
|
// закреп) в этом чате (тоггл владельца). Ядро удаляет, аддон лишь хранит флаг.
|
||||||
|
DeleteServiceMsgs(platform string, chatID int64) bool
|
||||||
|
// ModerateImage вызывается на входящее групповое ФОТО (TG/MAX): ядро скачивает
|
||||||
|
// байты картинки и отдаёт аддону. Аддон сам решает (декод QR, мультимодальный
|
||||||
|
// LLM по конфигу и доверию) и исполняет действие через Deps. true ⇒ удалено.
|
||||||
|
// Generic: ядро не знает семантику, просто «проверь картинку».
|
||||||
|
ModerateImage(ctx context.Context, platform string, chatID, userID int64, tgMsgID int, maxMid string, img []byte, mime string, isAdmin bool) (handled bool)
|
||||||
// CrosspostAllowed вызывается ПЕРЕД созданием новой связки кросспоста. ok=false ⇒
|
// CrosspostAllowed вызывается ПЕРЕД созданием новой связки кросспоста. ok=false ⇒
|
||||||
// ядро не создаёт связку и показывает reason. Generic: ядро не знает семантику решения.
|
// ядро не создаёт связку и показывает reason. Generic: ядро не знает семантику решения.
|
||||||
CrosspostAllowed(ctx context.Context, ownerMaxID, ownerTgID int64) (ok bool, reason string)
|
CrosspostAllowed(ctx context.Context, ownerMaxID, ownerTgID int64) (ok bool, reason string)
|
||||||
|
|
@ -41,6 +53,21 @@ type Addon interface {
|
||||||
// HandleChatShared — пользователь выбрал чат нативной кнопкой (chat_shared в личке).
|
// HandleChatShared — пользователь выбрал чат нативной кнопкой (chat_shared в личке).
|
||||||
// requestID эхо запроса, sharedChatID — выбранный чат. Generic: ядро не знает семантику.
|
// requestID эхо запроса, sharedChatID — выбранный чат. Generic: ядро не знает семантику.
|
||||||
HandleChatShared(ctx context.Context, userID int64, requestID int, sharedChatID int64, title string) (handled bool)
|
HandleChatShared(ctx context.Context, userID int64, requestID int, sharedChatID int64, title string) (handled bool)
|
||||||
|
// HandleJoin вызывается на вступление участника в группу (капча / анти-рейд).
|
||||||
|
HandleJoin(ctx context.Context, platform string, chatID, userID int64, name, username string, isBot bool)
|
||||||
|
// ScreenContent — синхронный relay-гейт: можно ли пересылать этот текст ОТ ЛИЦА
|
||||||
|
// бота (в обе стороны TG↔MAX). block=true ⇒ ядро НЕ пересылает сообщение
|
||||||
|
// (запрещённый/токсичный контент — наркосбыт, скам-вакансии, гемблинг, игровой/
|
||||||
|
// финансовый скам, оружие, CSAM — ИЛИ обфусцированный «точно спам»). Это НЕ
|
||||||
|
// модерация групп: что юзеры пишут в своих группах — их дело; гейт лишь защищает
|
||||||
|
// бота от рассылки запрещёнки/спама от его имени (иначе платформа банит бота).
|
||||||
|
// Generic: ядро не знает семантику. reason — для лога/уведомления. platform/chatID/
|
||||||
|
// userID — метаданные для лога сообщений (накопление корпуса под тест/обучение).
|
||||||
|
ScreenContent(ctx context.Context, platform string, chatID, userID int64, text string) (block bool, reason string)
|
||||||
|
// HoldForModeration — на блок relay-гейта кладёт сообщение в очередь модерации и
|
||||||
|
// постит карточку с кнопками в чат. true ⇒ задержано (ядро не пересылает). payload —
|
||||||
|
// JSON исходного сообщения для ре-релея на «Пропустить».
|
||||||
|
HoldForModeration(ctx context.Context, platform string, srcChat int64, srcMsg string, target, authorID int64, authorName, text, caption, reason, payload string) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// GroupMessage — удобная обёртка для вызова Moderate из ядра (call-site читаемость).
|
// GroupMessage — удобная обёртка для вызова Moderate из ядра (call-site читаемость).
|
||||||
|
|
@ -53,6 +80,15 @@ type GroupMessage struct {
|
||||||
Text string
|
Text string
|
||||||
HasLink bool
|
HasLink bool
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
|
ExtReply bool // сообщение цитирует пост из другого чата/канала (external_reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTgServiceSender — служебные/каналовые отправители Telegram: это КОНТЕНТ КАНАЛА или
|
||||||
|
// системного бота, а НЕ пользовательский спам. Антиспам их трогать нельзя (удаление
|
||||||
|
// авто-форварда поста канала в обсуждении сносит корень треда → пропадают комментарии).
|
||||||
|
// 777000=Telegram, 1087968824=GroupAnonymousBot, 136817688=Channel_Bot.
|
||||||
|
func isTgServiceSender(userID int64) bool {
|
||||||
|
return userID == 777000 || userID == 1087968824 || userID == 136817688
|
||||||
}
|
}
|
||||||
|
|
||||||
// moderateGroupMessage прогоняет сообщение через аддон-модерацию (если подключён).
|
// moderateGroupMessage прогоняет сообщение через аддон-модерацию (если подключён).
|
||||||
|
|
@ -60,7 +96,51 @@ func (b *Bridge) moderateGroupMessage(ctx context.Context, m GroupMessage) bool
|
||||||
if b.addon == nil {
|
if b.addon == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return b.addon.Moderate(ctx, m.Platform, m.ChatID, m.UserID, m.TgMsgID, m.MaxMid, m.Text, m.HasLink, m.IsAdmin)
|
// Служебные/каналовые отправители (пост канала, анонимный админ, авто-форвард) — не спам.
|
||||||
|
if m.Platform == "tg" && isTgServiceSender(m.UserID) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return b.addon.Moderate(ctx, m.Platform, m.ChatID, m.UserID, m.TgMsgID, m.MaxMid, m.Text, m.HasLink, m.IsAdmin, m.ExtReply)
|
||||||
|
}
|
||||||
|
|
||||||
|
// screenRelay — relay-гейт: блокирует пересылку запрещённого/обфусцированного контента
|
||||||
|
// ОТ ЛИЦА бота (в обе стороны TG↔MAX). Без аддона (публичная сборка) — всегда пропускаем.
|
||||||
|
func (b *Bridge) screenRelay(ctx context.Context, platform string, chatID, userID int64, text string) (block bool, reason string) {
|
||||||
|
if b.addon == nil {
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
// Служебные/каналовые отправители Telegram — контент канала, не режем.
|
||||||
|
if platform == "tg" && isTgServiceSender(userID) {
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
return b.addon.ScreenContent(ctx, platform, chatID, userID, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// holdForModeration — на блок relay-гейта: отдать сообщение аддону в очередь модерации
|
||||||
|
// (он запостит карточку с кнопками). Без аддона — false (ядро дропает как обычно).
|
||||||
|
func (b *Bridge) holdForModeration(ctx context.Context, platform string, srcChat int64, srcMsg string, target, authorID int64, authorName, text, caption, reason, payload string) bool {
|
||||||
|
if b.addon == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return b.addon.HoldForModeration(ctx, platform, srcChat, srcMsg, target, authorID, authorName, text, caption, reason, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantImageCheck — включён ли антиспам в чате (решает аддон), стоит ли качать картинку.
|
||||||
|
func (b *Bridge) wantImageCheck(platform string, chatID int64) bool {
|
||||||
|
return b.addon != nil && b.addon.WantImageCheck(platform, chatID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// delServiceMsgs — удалять ли служебные сообщения в чате (решает аддон по конфигу).
|
||||||
|
func (b *Bridge) delServiceMsgs(platform string, chatID int64) bool {
|
||||||
|
return b.addon != nil && b.addon.DeleteServiceMsgs(platform, chatID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// moderateImage прогоняет картинку через аддон-модерацию (если подключён).
|
||||||
|
func (b *Bridge) moderateImage(ctx context.Context, platform string, chatID, userID int64, tgMsgID int, maxMid string, img []byte, mime string, isAdmin bool) bool {
|
||||||
|
if b.addon == nil || len(img) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return b.addon.ModerateImage(ctx, platform, chatID, userID, tgMsgID, maxMid, img, mime, isAdmin)
|
||||||
}
|
}
|
||||||
|
|
||||||
// crosspostAllowed — можно ли создать новую связку (решает аддон). Без аддона — да.
|
// crosspostAllowed — можно ли создать новую связку (решает аддон). Без аддона — да.
|
||||||
|
|
@ -81,6 +161,13 @@ func (b *Bridge) chatShared(ctx context.Context, userID int64, requestID int, sh
|
||||||
return b.addon != nil && b.addon.HandleChatShared(ctx, userID, requestID, sharedChatID, title)
|
return b.addon != nil && b.addon.HandleChatShared(ctx, userID, requestID, sharedChatID, title)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// memberJoined — уведомить аддон о вступлении участника (капча/анти-рейд).
|
||||||
|
func (b *Bridge) memberJoined(ctx context.Context, platform string, chatID, userID int64, name, username string, isBot bool) {
|
||||||
|
if b.addon != nil {
|
||||||
|
b.addon.HandleJoin(ctx, platform, chatID, userID, name, username, isBot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// crosspostDeliverable — доставлять ли пост связки (решает аддон). Без аддона — да.
|
// crosspostDeliverable — доставлять ли пост связки (решает аддон). Без аддона — да.
|
||||||
func (b *Bridge) crosspostDeliverable(ctx context.Context, maxChatID int64) bool {
|
func (b *Bridge) crosspostDeliverable(ctx context.Context, maxChatID int64) bool {
|
||||||
if b.addon == nil {
|
if b.addon == nil {
|
||||||
|
|
|
||||||
35
addon_errs_test.go
Normal file
35
addon_errs_test.go
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
//go:build addon
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestIsUnforwardable_RecipientRestrictions(t *testing.T) {
|
||||||
|
// Пост СУЩЕСТВУЕТ, но не форвардится в скретч-ЛС (приватность получателя) —
|
||||||
|
// должен трактоваться как unforwardable (exists=true), а не как фатал/«поста нет».
|
||||||
|
unforwardable := []string{
|
||||||
|
"telegram: bad request, Bad Request: VOICE_MESSAGES_FORBIDDEN (400)",
|
||||||
|
"Bad Request: VIDEO_MESSAGES_FORBIDDEN (400)",
|
||||||
|
"Bad Request: CHAT_FORWARDS_RESTRICTED",
|
||||||
|
"message can't be copied",
|
||||||
|
"This message can't be forwarded",
|
||||||
|
}
|
||||||
|
for _, s := range unforwardable {
|
||||||
|
if !isUnforwardable(s) {
|
||||||
|
t.Errorf("isUnforwardable(%q) = false, want true", s)
|
||||||
|
}
|
||||||
|
if isAddonMsgGone(s) {
|
||||||
|
t.Errorf("isAddonMsgGone(%q) = true, want false (пост есть)", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Реально отсутствующий пост — НЕ unforwardable, это «поста нет».
|
||||||
|
gone := []string{"Bad Request: message to forward not found", "MESSAGE_ID_INVALID"}
|
||||||
|
for _, s := range gone {
|
||||||
|
if isUnforwardable(s) {
|
||||||
|
t.Errorf("isUnforwardable(%q) = true, want false", s)
|
||||||
|
}
|
||||||
|
if !isAddonMsgGone(s) {
|
||||||
|
t.Errorf("isAddonMsgGone(%q) = false, want true", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
151
breaker_pause_test.go
Normal file
151
breaker_pause_test.go
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testRepo поднимает мигрированную in-memory(file) sqlite и возвращает sqliteRepo.
|
||||||
|
func testRepo(t *testing.T) *sqliteRepo {
|
||||||
|
t.Helper()
|
||||||
|
db, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "t.db")+"?_journal_mode=WAL")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
if err := runMigrations(db, "sqlite3"); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
return &sqliteRepo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsChatUnavailable(t *testing.T) {
|
||||||
|
unavail := []string{
|
||||||
|
"chat.denied", "chat.not.found", "not enough rights",
|
||||||
|
"Forbidden: bot was kicked from the group chat",
|
||||||
|
"Bad Request: not enough rights to send", "user is deactivated",
|
||||||
|
}
|
||||||
|
for _, s := range unavail {
|
||||||
|
if !isChatUnavailable(s) {
|
||||||
|
t.Errorf("isChatUnavailable(%q) = false, want true", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Глобальный бан аккаунта MAX (account.blocked / 403) и транзиенты — НЕ повод
|
||||||
|
// паузить связку (иначе встанет всё).
|
||||||
|
ok := []string{"account.blocked", "403", "timeout", "context deadline exceeded", ""}
|
||||||
|
for _, s := range ok {
|
||||||
|
if isChatUnavailable(s) {
|
||||||
|
t.Errorf("isChatUnavailable(%q) = true, want false", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCbPauseForChat_PairTransition(t *testing.T) {
|
||||||
|
repo := testRepo(t)
|
||||||
|
const tgChat, maxChat, owner = int64(111), int64(-999), int64(555)
|
||||||
|
if _, err := repo.db.Exec(
|
||||||
|
"INSERT INTO pairs (tg_chat_id, max_chat_id, prefix, created_at, tg_owner_id, max_owner_id) VALUES (?,?,?,?,?,?)",
|
||||||
|
tgChat, maxChat, 0, 0, owner, 0); err != nil {
|
||||||
|
t.Fatalf("insert pair: %v", err)
|
||||||
|
}
|
||||||
|
b := &Bridge{repo: repo}
|
||||||
|
|
||||||
|
// owner резолвится и по MAX-, и по TG-стороне.
|
||||||
|
if got := repo.TgOwnerForChat(maxChat); got != owner {
|
||||||
|
t.Fatalf("TgOwnerForChat(max) = %d, want %d", got, owner)
|
||||||
|
}
|
||||||
|
if got := repo.TgOwnerForChat(tgChat); got != owner {
|
||||||
|
t.Fatalf("TgOwnerForChat(tg) = %d, want %d", got, owner)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пауза по MAX-стороне (направление TG→MAX): первый раз — переход, второй — нет.
|
||||||
|
if !b.cbPauseForChat(maxChat) {
|
||||||
|
t.Fatal("first cbPauseForChat = false, want transition true")
|
||||||
|
}
|
||||||
|
if !repo.PairPaused(tgChat, maxChat) {
|
||||||
|
t.Fatal("pair not paused after cbPauseForChat")
|
||||||
|
}
|
||||||
|
if b.cbPauseForChat(maxChat) {
|
||||||
|
t.Fatal("second cbPauseForChat = true, want false (already paused, no re-notify)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// После /unpause — пауза по TG-стороне (направление MAX→TG) снова даёт переход.
|
||||||
|
if err := repo.SetPairPaused(tgChat, maxChat, false); err != nil {
|
||||||
|
t.Fatalf("unpause: %v", err)
|
||||||
|
}
|
||||||
|
if !b.cbPauseForChat(tgChat) {
|
||||||
|
t.Fatal("cbPauseForChat(tg) = false, want transition true")
|
||||||
|
}
|
||||||
|
if !repo.PairPaused(tgChat, maxChat) {
|
||||||
|
t.Fatal("pair not paused via tg side")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCbPauseForChat_NoPairing(t *testing.T) {
|
||||||
|
repo := testRepo(t)
|
||||||
|
b := &Bridge{repo: repo}
|
||||||
|
if b.cbPauseForChat(424242) {
|
||||||
|
t.Fatal("cbPauseForChat for unknown chat = true, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimCrosspost(t *testing.T) {
|
||||||
|
repo := testRepo(t)
|
||||||
|
// Первый claim того же сообщения — проходит (кросспостим); повтор — дубль (пропустить).
|
||||||
|
if !repo.ClaimCrosspost("tg", -100500, "42") {
|
||||||
|
t.Fatal("первый claim должен пройти")
|
||||||
|
}
|
||||||
|
if repo.ClaimCrosspost("tg", -100500, "42") {
|
||||||
|
t.Fatal("повторный claim того же (platform,chat,msg) должен быть дублем")
|
||||||
|
}
|
||||||
|
// Другой msg_id / чат / платформа — независимы, проходят.
|
||||||
|
if !repo.ClaimCrosspost("tg", -100500, "43") {
|
||||||
|
t.Fatal("другой msg_id должен пройти")
|
||||||
|
}
|
||||||
|
if !repo.ClaimCrosspost("tg", -999, "42") {
|
||||||
|
t.Fatal("другой чат должен пройти")
|
||||||
|
}
|
||||||
|
if !repo.ClaimCrosspost("max", -100500, "42") {
|
||||||
|
t.Fatal("другая платформа должна пройти")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsTgServiceSender(t *testing.T) {
|
||||||
|
for _, id := range []int64{777000, 1087968824, 136817688} {
|
||||||
|
if !isTgServiceSender(id) {
|
||||||
|
t.Errorf("id %d должен быть служебным (не модерируем)", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, id := range []int64{0, 123, 336903139, -1} {
|
||||||
|
if isTgServiceSender(id) {
|
||||||
|
t.Errorf("id %d НЕ служебный", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMaxTextHasLink(t *testing.T) {
|
||||||
|
b := &Bridge{cfg: Config{
|
||||||
|
MaxBotURL: "https://max.ru/id710708943262_bot",
|
||||||
|
MaxBotURLReserve: "https://max.ru/id710708943262_4_bot",
|
||||||
|
}}
|
||||||
|
cases := []struct {
|
||||||
|
text string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"@id710708943262_4_bot", false}, // упоминание самого (запасного) бота — НЕ ссылка
|
||||||
|
{"@id710708943262_bot", false}, // основной бот — НЕ ссылка
|
||||||
|
{"@id710708943262_bot привет", false}, // бот + текст
|
||||||
|
{"@scamchannel подпишись", true}, // чужой канал — ссылка
|
||||||
|
{"@id710708943262_bot и @scam", true}, // есть чужое упоминание
|
||||||
|
{"http://evil.example", true}, // http
|
||||||
|
{"загляни на t.me/foo", true}, // t.me
|
||||||
|
{"обычный текст без ссылок", false}, // нет ничего
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := b.maxTextHasLink(c.text); got != c.want {
|
||||||
|
t.Errorf("maxTextHasLink(%q) = %v, want %v", c.text, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
216
bridge.go
216
bridge.go
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
maxbot "github.com/max-messenger/max-bot-api-client-go"
|
maxbot "github.com/max-messenger/max-bot-api-client-go"
|
||||||
|
|
@ -16,13 +17,15 @@ import (
|
||||||
|
|
||||||
// Config — настройки bridge, читаемые из env.
|
// Config — настройки bridge, читаемые из env.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
MaxToken string // токен MAX API (нужен для direct-send/upload)
|
MaxToken string // токен MAX API (основной бот; нужен для direct-send/upload)
|
||||||
TgBotURL string // ссылка на TG-бота для /help
|
MaxTokenOld string // токен старого MAX-бота (резерв/failover; "" — выключено)
|
||||||
MaxBotURL string // ссылка на MAX-бота для /help
|
TgBotURL string // ссылка на TG-бота для /help
|
||||||
MaxWebhookURL string // базовый URL для webhook MAX (если пусто — long polling)
|
MaxBotURL string // ссылка на основного MAX-бота для /help (старый бот)
|
||||||
MaxWebhookPort string // порт HTTP-сервера для MAX webhook (по умолчанию 8443)
|
MaxBotURLReserve string // ссылка на запасного MAX-бота (для failover; "" — скрыть)
|
||||||
TgWebhookURL string // базовый URL для webhook TG (если пусто — long polling)
|
MaxWebhookURL string // базовый URL для webhook MAX (если пусто — long polling)
|
||||||
TgWebhookPort string // порт HTTP-сервера для TG webhook (по умолчанию 8444)
|
MaxWebhookPort string // порт HTTP-сервера для MAX webhook (по умолчанию 8443)
|
||||||
|
TgWebhookURL string // базовый URL для webhook TG (если пусто — long polling)
|
||||||
|
TgWebhookPort string // порт HTTP-сервера для TG webhook (по умолчанию 8444)
|
||||||
TgAPIURL string // custom TG Bot API URL (если пусто — api.telegram.org)
|
TgAPIURL string // custom TG Bot API URL (если пусто — api.telegram.org)
|
||||||
AllowedUsers []int64 // whitelist TG user IDs (empty = allow all)
|
AllowedUsers []int64 // whitelist TG user IDs (empty = allow all)
|
||||||
TgMaxFileSizeMB int // max file size TG->MAX in MB (0 = unlimited)
|
TgMaxFileSizeMB int // max file size TG->MAX in MB (0 = unlimited)
|
||||||
|
|
@ -40,25 +43,36 @@ type Config struct {
|
||||||
|
|
||||||
// chatBreaker хранит состояние circuit breaker для одного чата.
|
// chatBreaker хранит состояние circuit breaker для одного чата.
|
||||||
type chatBreaker struct {
|
type chatBreaker struct {
|
||||||
fails int
|
fails int
|
||||||
blockedAt time.Time
|
blockedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
cbMaxFails = 3 // после N фейлов — блокируем
|
cbMaxFails = 3 // после N фейлов — блокируем
|
||||||
cbCooldown = 5 * time.Minute // на сколько блокируем
|
cbCooldown = 5 * time.Minute // на сколько блокируем
|
||||||
)
|
)
|
||||||
|
|
||||||
// Bridge — основная структура, объединяющая зависимости.
|
// Bridge — основная структура, объединяющая зависимости.
|
||||||
type Bridge struct {
|
type Bridge struct {
|
||||||
cfg Config
|
cfg Config
|
||||||
repo Repository
|
repo Repository
|
||||||
tg TGSender
|
tg TGSender
|
||||||
maxApi *maxbot.Api
|
maxApi *maxbot.Api
|
||||||
maxBotUID int64 // MAX bot user ID (для фильтрации своих сообщений)
|
maxApiOld *maxbot.Api // второй (старый) MAX-бот: резерв + обслуживание чатов без нового
|
||||||
|
maxBotUID int64 // MAX bot user ID (для фильтрации своих сообщений)
|
||||||
|
maxOldUID int64 // user ID старого MAX-бота (для фильтрации своих сообщений)
|
||||||
|
// maxBotCache — кэш «какой бот в чате»: chatID → токен для отправки. Заполняется
|
||||||
|
// по членству (GetChatMembership) и по тому, через чей вебхук пришёл апдейт.
|
||||||
|
maxBotMu sync.Mutex
|
||||||
|
maxBotCache map[int64]string // chatID → token (новый по умолчанию, старый если нового нет)
|
||||||
|
// maxSeenMid — дедуп входящих: один и тот же mid приходит от ОБОИХ ботов (если оба
|
||||||
|
// в чате). Обрабатываем один раз.
|
||||||
|
maxSeenMu sync.Mutex
|
||||||
|
maxSeenMid map[string]int64 // mid → unix-время (TTL-очистка)
|
||||||
|
|
||||||
httpClient *http.Client // для скачивания/загрузки файлов (большой таймаут)
|
httpClient *http.Client // для скачивания/загрузки файлов (большой таймаут)
|
||||||
apiClient *http.Client // для коротких API-запросов (малый таймаут)
|
apiClient *http.Client // для коротких API-запросов (малый таймаут)
|
||||||
whSecret string // random path segment for webhook URLs
|
whSecret string // random path segment for webhook URLs
|
||||||
|
|
||||||
cpWaitMu sync.Mutex
|
cpWaitMu sync.Mutex
|
||||||
cpWait map[int64]int64 // MAX userId → TG channel ID (ожидание пересылки)
|
cpWait map[int64]int64 // MAX userId → TG channel ID (ожидание пересылки)
|
||||||
|
|
@ -66,8 +80,14 @@ type Bridge struct {
|
||||||
cpTgOwnerMu sync.Mutex
|
cpTgOwnerMu sync.Mutex
|
||||||
cpTgOwner map[int64]int64 // TG channel ID → TG user ID (кто переслал пост)
|
cpTgOwner map[int64]int64 // TG channel ID → TG user ID (кто переслал пост)
|
||||||
|
|
||||||
cbMu sync.Mutex
|
cbMu sync.Mutex
|
||||||
breakers map[int64]*chatBreaker // destination chatID → breaker
|
breakers map[int64]*chatBreaker // destination chatID → breaker
|
||||||
|
|
||||||
|
// maxBlockedUntil — unix-секунды, до которых считаем аккаунт MAX заблокированным
|
||||||
|
// (MAX вернул 403 account.blocked). Пока активно — глушим пользовательские
|
||||||
|
// уведомления о недоставке: это глобальный бан, слать каждому бессмысленно.
|
||||||
|
// Сбрасывается на первой успешной отправке в MAX.
|
||||||
|
maxBlockedUntil atomic.Int64
|
||||||
|
|
||||||
// Буферизация TG media groups (альбомы)
|
// Буферизация TG media groups (альбомы)
|
||||||
mgMu sync.Mutex
|
mgMu sync.Mutex
|
||||||
|
|
@ -86,28 +106,32 @@ type Bridge struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewBridge создаёт экземпляр Bridge.
|
// NewBridge создаёт экземпляр Bridge.
|
||||||
func NewBridge(cfg Config, repo Repository, tg TGSender, maxApi *maxbot.Api, maxBotUID int64) *Bridge {
|
func NewBridge(cfg Config, repo Repository, tg TGSender, maxApi *maxbot.Api, maxBotUID int64, maxApiOld *maxbot.Api, maxOldUID int64) *Bridge {
|
||||||
// Derive webhook secret from tokens (stable across restarts)
|
// Derive webhook secret from tokens (stable across restarts)
|
||||||
h := sha256.Sum256([]byte(cfg.MaxToken + tg.BotToken()))
|
h := sha256.Sum256([]byte(cfg.MaxToken + tg.BotToken()))
|
||||||
secret := hex.EncodeToString(h[:8])
|
secret := hex.EncodeToString(h[:8])
|
||||||
|
|
||||||
b := &Bridge{
|
b := &Bridge{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
repo: repo,
|
repo: repo,
|
||||||
tg: tg,
|
tg: tg,
|
||||||
maxApi: maxApi,
|
maxApi: maxApi,
|
||||||
|
maxApiOld: maxApiOld,
|
||||||
maxBotUID: maxBotUID,
|
maxBotUID: maxBotUID,
|
||||||
|
maxOldUID: maxOldUID,
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: 5 * time.Minute, // для download/upload больших файлов
|
Timeout: 5 * time.Minute, // для download/upload больших файлов
|
||||||
},
|
},
|
||||||
apiClient: &http.Client{
|
apiClient: &http.Client{
|
||||||
Timeout: 15 * time.Second, // для коротких API-запросов
|
Timeout: 15 * time.Second, // для коротких API-запросов
|
||||||
},
|
},
|
||||||
whSecret: secret,
|
whSecret: secret,
|
||||||
cpWait: make(map[int64]int64),
|
cpWait: make(map[int64]int64),
|
||||||
cpTgOwner: make(map[int64]int64),
|
cpTgOwner: make(map[int64]int64),
|
||||||
breakers: make(map[int64]*chatBreaker),
|
breakers: make(map[int64]*chatBreaker),
|
||||||
mgBuffers: make(map[string]*mediaGroupBuffer),
|
mgBuffers: make(map[string]*mediaGroupBuffer),
|
||||||
|
maxBotCache: make(map[int64]string),
|
||||||
|
maxSeenMid: make(map[string]int64),
|
||||||
}
|
}
|
||||||
b.addon = loadAddon(b)
|
b.addon = loadAddon(b)
|
||||||
return b
|
return b
|
||||||
|
|
@ -149,11 +173,129 @@ func (b *Bridge) cbFail(chatID int64) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// cbSuccess сбрасывает счётчик ошибок для чата.
|
// cbSuccess сбрасывает счётчик ошибок для чата. Любая успешная отправка в MAX также
|
||||||
|
// снимает глобальный флаг «аккаунт заблокирован» (бан, видимо, сняли).
|
||||||
func (b *Bridge) cbSuccess(chatID int64) {
|
func (b *Bridge) cbSuccess(chatID int64) {
|
||||||
b.cbMu.Lock()
|
b.cbMu.Lock()
|
||||||
defer b.cbMu.Unlock()
|
|
||||||
delete(b.breakers, chatID)
|
delete(b.breakers, chatID)
|
||||||
|
b.cbMu.Unlock()
|
||||||
|
b.maxBlockedUntil.Store(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatUnavailableMarkers — маркеры ПЕРМАНЕНТНОЙ недоступности чата: бот удалён,
|
||||||
|
// заблокирован или лишён прав в этом конкретном чате. Намеренно БЕЗ голых "403"/"404"
|
||||||
|
// и account.blocked — это глобальный бан аккаунта MAX (транзиент), его ловит
|
||||||
|
// maxAccountBlocked(), и связки на паузу по нему ставить нельзя.
|
||||||
|
var chatUnavailableMarkers = []string{
|
||||||
|
"chat.not.found", "chat.denied", "not enough rights",
|
||||||
|
"bot was kicked", "bot was blocked", "Forbidden",
|
||||||
|
"chat not found", "CHAT_WRITE_FORBIDDEN", "user is deactivated",
|
||||||
|
"need administrator rights", "PEER_ID_INVALID",
|
||||||
|
}
|
||||||
|
|
||||||
|
// isChatUnavailable — постоянная ошибка «бот недоступен в этом чате».
|
||||||
|
func isChatUnavailable(errStr string) bool {
|
||||||
|
for _, m := range chatUnavailableMarkers {
|
||||||
|
if strings.Contains(errStr, m) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// cbPauseForChat ставит на паузу связку(и), в которые входит недоступный chatID
|
||||||
|
// (пробуем все интерпретации: chatID как MAX- или TG-сторона пары/кросспоста).
|
||||||
|
// Возвращает true ТОЛЬКО при переходе unpaused→paused — чтобы уведомить владельца
|
||||||
|
// один раз, а не на каждом упавшем сообщении.
|
||||||
|
func (b *Bridge) cbPauseForChat(chatID int64) bool {
|
||||||
|
newly := false
|
||||||
|
if tg, ok := b.repo.GetTgChat(chatID); ok && !b.repo.PairPaused(tg, chatID) {
|
||||||
|
b.repo.SetPairPaused(tg, chatID, true)
|
||||||
|
newly = true
|
||||||
|
}
|
||||||
|
if mx, ok := b.repo.GetMaxChat(chatID); ok && !b.repo.PairPaused(chatID, mx) {
|
||||||
|
b.repo.SetPairPaused(chatID, mx, true)
|
||||||
|
newly = true
|
||||||
|
}
|
||||||
|
if _, _, ok := b.repo.GetCrosspostTgChat(chatID); ok && !b.repo.CrosspostPaused(chatID) {
|
||||||
|
b.repo.SetCrosspostPaused(chatID, true)
|
||||||
|
newly = true
|
||||||
|
}
|
||||||
|
if mx, _, ok := b.repo.GetCrosspostMaxChat(chatID); ok && !b.repo.CrosspostPaused(mx) {
|
||||||
|
b.repo.SetCrosspostPaused(mx, true)
|
||||||
|
newly = true
|
||||||
|
}
|
||||||
|
return newly
|
||||||
|
}
|
||||||
|
|
||||||
|
// cbPermanentFail вызывается на ПЕРМАНЕНТНОЙ ошибке доставки (бот заблокирован/
|
||||||
|
// удалён/лишён прав в чате chatID): ставит связку на паузу и DM-ит владельцу.
|
||||||
|
// Глобальный бан аккаунта MAX игнорируем — это транзиент, не повод паузить всё.
|
||||||
|
func (b *Bridge) cbPermanentFail(ctx context.Context, chatID int64) {
|
||||||
|
if b.maxAccountBlocked() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !b.cbPauseForChat(chatID) {
|
||||||
|
return // уже на паузе или связки нет — не дублируем
|
||||||
|
}
|
||||||
|
slog.Warn("связка на паузе: чат недоступен (бот заблокирован/удалён/лишён прав)", "chatID", chatID)
|
||||||
|
b.notifyChatPaused(ctx, chatID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyChatPaused DM-ит владельцу TG-стороны: чат недоступен, связка на паузе.
|
||||||
|
func (b *Bridge) notifyChatPaused(ctx context.Context, chatID int64) {
|
||||||
|
msg := "🚫 Чат недоступен — бот заблокирован, удалён или лишён прав.\n\n" +
|
||||||
|
"Связку поставил на паузу, чтобы не терять сообщения на бесконечных ретраях. " +
|
||||||
|
"Когда вернёте бота в чат (и сделаете администратором) — отправьте /unpause, " +
|
||||||
|
"чтобы возобновить пересылку."
|
||||||
|
owner := b.repo.TgOwnerForChat(chatID)
|
||||||
|
if owner == 0 {
|
||||||
|
slog.Warn("paused-notify: владелец TG не найден", "chatID", chatID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := b.tg.SendMessage(ctx, owner, msg, nil); err != nil {
|
||||||
|
slog.Warn("paused-notify: DM не отправилось", "err", err, "owner", owner, "chatID", chatID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// usernameFromMaxURL извлекает юзернейм бота из ссылки вида https://max.ru/id..._bot.
|
||||||
|
func usernameFromMaxURL(u string) string {
|
||||||
|
if i := strings.LastIndexByte(u, '/'); i >= 0 {
|
||||||
|
return u[i+1:]
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxTextHasLink — есть ли в MAX-тексте ссылка/внешнее упоминание (для правила link-new).
|
||||||
|
// http/t.me — всегда. Голый «@» считаем ссылкой ТОЛЬКО если упоминают НЕ нашего бота:
|
||||||
|
// иначе новичка ложно банило за @id..._bot (упоминание самого бота — не реклама).
|
||||||
|
func (b *Bridge) maxTextHasLink(text string) bool {
|
||||||
|
if strings.Contains(text, "http") || strings.Contains(text, "t.me") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !strings.Contains(text, "@") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cleaned := text
|
||||||
|
for _, u := range []string{usernameFromMaxURL(b.cfg.MaxBotURL), usernameFromMaxURL(b.cfg.MaxBotURLReserve)} {
|
||||||
|
if u != "" {
|
||||||
|
cleaned = strings.ReplaceAll(cleaned, "@"+u, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Contains(cleaned, "@")
|
||||||
|
}
|
||||||
|
|
||||||
|
// noteMaxSendErr выставляет глобальный флаг блокировки аккаунта MAX, если ошибка —
|
||||||
|
// account.blocked (403). Окно 10 мин, продлевается на каждой такой ошибке.
|
||||||
|
func (b *Bridge) noteMaxSendErr(errStr string) {
|
||||||
|
if strings.Contains(errStr, "account.blocked") {
|
||||||
|
b.maxBlockedUntil.Store(time.Now().Unix() + 600)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxAccountBlocked — активен ли глобальный бан аккаунта MAX прямо сейчас.
|
||||||
|
func (b *Bridge) maxAccountBlocked() bool {
|
||||||
|
return time.Now().Unix() < b.maxBlockedUntil.Load()
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxMaxFileBytes returns the MAX-to-TG file size limit in bytes (0 = unlimited).
|
// maxMaxFileBytes returns the MAX-to-TG file size limit in bytes (0 = unlimited).
|
||||||
|
|
@ -237,6 +379,12 @@ func (b *Bridge) hasPrefix(platform string, chatID int64) bool {
|
||||||
// Для crosspost — в ЛС владельцу связки (tg_owner_id), чтобы не мусорить в канал.
|
// Для crosspost — в ЛС владельцу связки (tg_owner_id), чтобы не мусорить в канал.
|
||||||
// Если владелец не задан (legacy) — уведомление дропается с warn-логом.
|
// Если владелец не задан (legacy) — уведомление дропается с warn-логом.
|
||||||
func (b *Bridge) notifyTgUser(ctx context.Context, srcChat *TGMessage, maxChatID int64, text string, isCrosspost bool) {
|
func (b *Bridge) notifyTgUser(ctx context.Context, srcChat *TGMessage, maxChatID int64, text string, isCrosspost bool) {
|
||||||
|
// Глобальный бан аккаунта MAX — не заваливаем юзеров уведомлениями о недоставке
|
||||||
|
// (бан общий для всех чатов; сообщения и так копятся в очереди).
|
||||||
|
if b.maxAccountBlocked() {
|
||||||
|
slog.Debug("notify suppressed: MAX account blocked", "text", text)
|
||||||
|
return
|
||||||
|
}
|
||||||
if isCrosspost {
|
if isCrosspost {
|
||||||
_, tgOwner := b.repo.GetCrosspostOwner(maxChatID)
|
_, tgOwner := b.repo.GetCrosspostOwner(maxChatID)
|
||||||
if tgOwner == 0 {
|
if tgOwner == 0 {
|
||||||
|
|
@ -296,6 +444,8 @@ func (b *Bridge) registerCommands(ctx context.Context) {
|
||||||
cmds := []BotCommand{
|
cmds := []BotCommand{
|
||||||
{Command: "bridge", Description: "Связать чат с MAX-чатом"},
|
{Command: "bridge", Description: "Связать чат с MAX-чатом"},
|
||||||
{Command: "bridge_update", Description: "Обновить связку группы (показать в кабинете)"},
|
{Command: "bridge_update", Description: "Обновить связку группы (показать в кабинете)"},
|
||||||
|
{Command: "pause", Description: "Поставить связку на паузу"},
|
||||||
|
{Command: "unpause", Description: "Возобновить пересылку"},
|
||||||
{Command: "unbridge", Description: "Удалить связку чатов"},
|
{Command: "unbridge", Description: "Удалить связку чатов"},
|
||||||
{Command: "thread", Description: "Установить топик для сообщений из MAX"},
|
{Command: "thread", Description: "Установить топик для сообщений из MAX"},
|
||||||
{Command: "thread_bridge", Description: "Связать тред с отдельным MAX-чатом"},
|
{Command: "thread_bridge", Description: "Связать тред с отдельным MAX-чатом"},
|
||||||
|
|
@ -315,7 +465,8 @@ func (b *Bridge) registerCommands(ctx context.Context) {
|
||||||
|
|
||||||
// Описание бота (экран "Что умеет этот бот"). Доп. строка от расширения (impl
|
// Описание бота (экран "Что умеет этот бот"). Доп. строка от расширения (impl
|
||||||
// в loadAddon) — без аддона пусто, в публичной сборке про импорт ни слова.
|
// в loadAddon) — без аддона пусто, в публичной сборке про импорт ни слова.
|
||||||
desc := "Мост между Telegram и MAX: зеркалит сообщения в связанных группах и кросспостит каналы." + b.extraDescription
|
desc := "Мост между Telegram и MAX: зеркалит сообщения в связанных группах и кросспостит каналы." + b.extraDescription +
|
||||||
|
"\n\n💬 Поддержка и новости: https://t.me/+0ucbOj4wBwQzMWNi"
|
||||||
if err := b.tg.SetMyDescription(ctx, desc); err != nil {
|
if err := b.tg.SetMyDescription(ctx, desc); err != nil {
|
||||||
slog.Error("TG setMyDescription failed", "err", err)
|
slog.Error("TG setMyDescription failed", "err", err)
|
||||||
}
|
}
|
||||||
|
|
@ -329,6 +480,15 @@ func (b *Bridge) registerCommands(ctx context.Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reserveBotHint — подсказка добавить запасного MAX-бота админом (для failover).
|
||||||
|
// Пусто, если запасной не настроен (MaxBotURLReserve == "").
|
||||||
|
func (b *Bridge) reserveBotHint() string {
|
||||||
|
if b.cfg.MaxBotURLReserve == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "\n\n🔁 Для надёжности добавьте админом и запасного бота (подменит основной, если тот недоступен): " + b.cfg.MaxBotURLReserve
|
||||||
|
}
|
||||||
|
|
||||||
// Run запускает TG и MAX listener'ы + периодическую очистку.
|
// Run запускает TG и MAX listener'ы + периодическую очистку.
|
||||||
func (b *Bridge) Run(ctx context.Context) {
|
func (b *Bridge) Run(ctx context.Context) {
|
||||||
b.registerCommands(ctx)
|
b.registerCommands(ctx)
|
||||||
|
|
|
||||||
62
docs/pikabu-post.md
Normal file
62
docs/pikabu-post.md
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
# Как мы делали мост между Telegram и MAX: боль, баги и 385 рестартов
|
||||||
|
|
||||||
|
Привет, Пикабу! Хочу рассказать про наш опыт создания бота-моста между Telegram и MAX (бывший VK Teams). Может кому-то пригодится — или хотя бы посмеётесь.
|
||||||
|
|
||||||
|
## Зачем это вообще нужно
|
||||||
|
|
||||||
|
MAX — это новый российский мессенджер от VK. Многие компании и сообщества начали туда переезжать, но часть людей осталась в Telegram. Нужно было связать чаты так, чтобы сообщения из одного мессенджера автоматически пересылались в другой. Написали бота на Go, задеплоили — и понеслось.
|
||||||
|
|
||||||
|
## Проблема 1: Видео приходит как «document» без расширения
|
||||||
|
|
||||||
|
Когда кто-то отправляет видео в Telegram без сжатия (как файл), оно приходит не как `Video`, а как `Document` с MIME-типом `video/mp4`. Наш бот честно пересылал его в MAX как файл с именем «document». Без расширения. MAX показывал это как непонятный файл, который нельзя посмотреть.
|
||||||
|
|
||||||
|
**Решение:** проверяем MIME-тип документа. Если `video/*` — загружаем как видео, а не как файл. Плюс генерируем нормальное имя из MIME-типа, если оригинальное отсутствует.
|
||||||
|
|
||||||
|
## Проблема 2: MAX API ложится, бот встаёт колом
|
||||||
|
|
||||||
|
MAX API периодически тайматутится. Причём не на всё, а на конкретные запросы — отправка сообщений зависает на минуту и возвращает timeout. Наш бот обрабатывал сообщения синхронно — один зависший запрос блокировал ВСЮ очередь. Пока один чат тайматутился, остальные 60+ чатов стояли. Даже /start не отвечал.
|
||||||
|
|
||||||
|
**Решение:** перенесли пересылку в горутины. Добавили circuit breaker — после 3 фейлов подряд чат блокируется на 5 минут. И retry-очередь в SQLite — если сообщение не отправилось, оно сохраняется в базу и отправляется позже.
|
||||||
|
|
||||||
|
## Проблема 3: 385 рестартов за ночь
|
||||||
|
|
||||||
|
Добавили подробное логирование для отладки. В логах писали `msg.From.ID` — ID отправителя. Всё работало... пока не пришёл пост из канала. У канальных постов в Telegram нет поля `From` — оно `nil`. `nil.ID` = panic. Бот упал, systemd перезапустил, пришёл следующий пост из канала — опять panic. И так 385 раз за ночь.
|
||||||
|
|
||||||
|
**Решение:** банальная проверка на nil. Теперь используем safe getter: `if msg.From != nil { return msg.From.ID }`. Стыдно, но факт — одна строка уронила продакшн.
|
||||||
|
|
||||||
|
## Проблема 4: Любой мог удалить чужие связки каналов
|
||||||
|
|
||||||
|
Команда `/crosspost` показывала ВСЕ связки каналов ВСЕМ пользователям. С кнопками «Удалить». Любой, кто написал боту в личку, мог увидеть и удалить чужие связки. Мы обнаружили это когда у пользователя «пропали» 5 каналов. Сначала думали — баг. Оказалось — кто-то просто нажал кнопку.
|
||||||
|
|
||||||
|
**Решение:** добавили `owner_id` — кто создал связку, тот ей и управляет. Причём пришлось хранить два ID (TG и MAX), потому что у одного человека разные ID в разных мессенджерах. Плюс soft delete — теперь удалённые связки не стираются, а помечаются. Всегда можно посмотреть кто и когда удалил.
|
||||||
|
|
||||||
|
## Проблема 5: Замедление Telegram API
|
||||||
|
|
||||||
|
Telegram API из России работает с задержками. Файлы скачиваются медленно, запросы тайматутятся. Подняли свой Telegram Bot API сервер (https://github.com/tdlib/telegram-bot-api) на зарубежном сервере. Но тут начались приключения:
|
||||||
|
|
||||||
|
- Без флага `--local` файлы не отдаёт (404)
|
||||||
|
- С флагом `--local` файлы сохраняются на диск, но API возвращает локальный путь вместо URL
|
||||||
|
- Docker создаёт файлы с ограниченными правами — nginx не может их прочитать
|
||||||
|
- Webhook зарегистрировался одновременно и на api.telegram.org, и на наш сервер — каждое сообщение приходило дважды
|
||||||
|
|
||||||
|
**Решение:** nginx на том же сервере раздаёт файлы с диска, Bot API на соседнем порту. Для MAX→TG пришлось скачивать файлы на наш сервер и загружать в TG как bytes — потому что наш TG Bot API сервер не видит MAX CDN.
|
||||||
|
|
||||||
|
## Проблема 6: MAX webhook не присылает сообщения
|
||||||
|
|
||||||
|
Переключились с polling на webhook для мгновенной доставки. Всё настроили, webhook зарегистрировался, nginx проксирует — а сообщения не приходят. Приходят только удаления и события входа/выхода. Оказалось, MAX API при подписке без явного списка типов обновлений НЕ включает `message_created` по умолчанию. Нужно явно указывать.
|
||||||
|
|
||||||
|
**Решение:** передаём полный список типов при подписке: `message_created`, `message_edited`, `message_removed`, `message_callback` и т.д.
|
||||||
|
|
||||||
|
## Что в итоге
|
||||||
|
|
||||||
|
Бот работает, пересылает текст, фото, видео, GIF, стикеры, голосовые, файлы, кружки. Поддерживает редактирование, удаление, ответы, форматирование. Кросспостинг каналов в обе стороны. 70+ связанных чатов.
|
||||||
|
|
||||||
|
Стек: Go, SQLite, MAX Bot API SDK, Telegram Bot API, systemd, nginx.
|
||||||
|
|
||||||
|
Исходники открыты: https://github.com/BEARlogin/max-telegram-bridge-bot (CC BY-NC 4.0)
|
||||||
|
|
||||||
|
Если кому нужно связать свои TG и MAX чаты — боты:
|
||||||
|
- Telegram: @MaxTelegramBridgeBot
|
||||||
|
- MAX: https://max.ru/id710708943262_bot
|
||||||
|
|
||||||
|
Главный вывод: мессенджер-мосты — это не «просто пересылка текста». Это зоопарк edge-кейсов, кривых API, таймаутов и nil pointer'ов. Но работает!
|
||||||
163
dualbot.go
Normal file
163
dualbot.go
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
maxbot "github.com/max-messenger/max-bot-api-client-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Дуал-бот MAX: основной (новый) + резервный (старый) ---
|
||||||
|
// Цель: обслуживать чаты, где есть только старый бот (новый туда не добавлен), и не
|
||||||
|
// дублировать в чатах, где сидят оба. Маршрутизация отправки — по членству бота в чате
|
||||||
|
// (кэш), дедуп входящих — по mid (оба бота присылают одно и то же сообщение).
|
||||||
|
|
||||||
|
// dualEnabled — поднят ли второй (старый) бот.
|
||||||
|
func (b *Bridge) dualEnabled() bool {
|
||||||
|
return b.maxApiOld != nil && b.cfg.MaxTokenOld != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxTokenFor возвращает токен бота, которым нужно слать в этот MAX-чат: по умолчанию
|
||||||
|
// основной (новый); если нового бота в чате НЕТ, а старый есть — токен старого.
|
||||||
|
// Результат кэшируется (членство дёргаем один раз на чат).
|
||||||
|
func (b *Bridge) maxTokenFor(ctx context.Context, chatID int64) string {
|
||||||
|
if !b.dualEnabled() {
|
||||||
|
return b.cfg.MaxToken
|
||||||
|
}
|
||||||
|
b.maxBotMu.Lock()
|
||||||
|
if t, ok := b.maxBotCache[chatID]; ok {
|
||||||
|
b.maxBotMu.Unlock()
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
b.maxBotMu.Unlock()
|
||||||
|
|
||||||
|
// Приоритет СТАРОМУ боту: он сидит почти везде (все каналы + большинство групп),
|
||||||
|
// так что где есть оба — работаем старым (по запросу). Новый — только там, где
|
||||||
|
// старого нет (чаты, откуда старого удалили) + горячий резерв.
|
||||||
|
tok := b.cfg.MaxToken
|
||||||
|
// На старого — только если он АДМИН (член-без-прав слать/модерировать не может).
|
||||||
|
if m, err := b.maxApiOld.Chats.GetChatMembership(ctx, chatID); err == nil && m.IsAdmin {
|
||||||
|
tok = b.cfg.MaxTokenOld // старый админ в чате — шлём старым
|
||||||
|
}
|
||||||
|
// Иначе остаётся новый (дефолт): чаты new-only (старого снесли) + где старый не админ.
|
||||||
|
b.maxBotMu.Lock()
|
||||||
|
b.maxBotCache[chatID] = tok
|
||||||
|
b.maxBotMu.Unlock()
|
||||||
|
return tok
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxClientFor — SDK-клиент бота, которым нужно слать в этот чат (для команд/ответов/
|
||||||
|
// карточек). Та же маршрутизация, что и maxTokenFor (приоритет старому где он есть).
|
||||||
|
func (b *Bridge) maxClientFor(ctx context.Context, chatID int64) *maxbot.Api {
|
||||||
|
if !b.dualEnabled() {
|
||||||
|
return b.maxApi
|
||||||
|
}
|
||||||
|
// Токен из ctx (бот-источник апдейта в MAX-цикле / выбранный бот релея) приоритетнее —
|
||||||
|
// гарантирует ответ ТЕМ ЖЕ ботом, что получил сообщение (критично для ЛС, где членство
|
||||||
|
// не определить). Если в ctx нет — по членству.
|
||||||
|
tok := b.maxTokenCtx(ctx)
|
||||||
|
if tok == "" {
|
||||||
|
tok = b.maxTokenFor(ctx, chatID)
|
||||||
|
}
|
||||||
|
if tok == b.cfg.MaxTokenOld {
|
||||||
|
return b.maxApiOld
|
||||||
|
}
|
||||||
|
return b.maxApi
|
||||||
|
}
|
||||||
|
|
||||||
|
// altMaxToken — «другой» бот относительно данного токена (для fallback при chat.not.found).
|
||||||
|
// "" — если дуал выключен или токен неизвестен.
|
||||||
|
func (b *Bridge) altMaxToken(tok string) string {
|
||||||
|
if !b.dualEnabled() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if tok == b.cfg.MaxTokenOld {
|
||||||
|
return b.cfg.MaxToken
|
||||||
|
}
|
||||||
|
return b.cfg.MaxTokenOld
|
||||||
|
}
|
||||||
|
|
||||||
|
// markChatBot прайм кэша: запомнить, что в чате есть бот с данным токеном (например,
|
||||||
|
// когда апдейт пришёл через его вебхук, или отправка прошла успешно).
|
||||||
|
func (b *Bridge) markChatBot(chatID int64, token string) {
|
||||||
|
if !b.dualEnabled() || token == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.maxBotMu.Lock()
|
||||||
|
b.maxBotCache[chatID] = token
|
||||||
|
b.maxBotMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// invalidateChatBot сбрасывает кэш бота для чата (например, send упал — пересчитаем).
|
||||||
|
func (b *Bridge) invalidateChatBot(chatID int64) {
|
||||||
|
b.maxBotMu.Lock()
|
||||||
|
delete(b.maxBotCache, chatID)
|
||||||
|
b.maxBotMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxDupMid — дедуп входящих MAX-сообщений по mid: при дуал-режиме один и тот же mid
|
||||||
|
// приходит от ОБОИХ ботов (если оба в чате). true ⇒ уже видели, пропускаем.
|
||||||
|
func (b *Bridge) maxDupMid(mid string) bool {
|
||||||
|
if mid == "" || !b.dualEnabled() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
now := time.Now().Unix()
|
||||||
|
b.maxSeenMu.Lock()
|
||||||
|
defer b.maxSeenMu.Unlock()
|
||||||
|
if _, ok := b.maxSeenMid[mid]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
b.maxSeenMid[mid] = now
|
||||||
|
if len(b.maxSeenMid) > 5000 { // редкая TTL-очистка (старше 10 мин)
|
||||||
|
for k, t := range b.maxSeenMid {
|
||||||
|
if now-t > 600 {
|
||||||
|
delete(b.maxSeenMid, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxTokenCtxKey — ключ контекста для проброса выбранного MAX-токена в аплоады без
|
||||||
|
// изменения десятков сигнатур. forwardTgToMax кладёт сюда токен бота, который в чате;
|
||||||
|
// customUpload*/sendMaxChunk его читают, чтобы заливать/слать тем же ботом.
|
||||||
|
type maxTokenCtxKeyT struct{}
|
||||||
|
|
||||||
|
func (b *Bridge) withMaxToken(ctx context.Context, token string) context.Context {
|
||||||
|
if token == "" {
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
return context.WithValue(ctx, maxTokenCtxKeyT{}, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxTokenCtx достаёт MAX-токен из контекста ("" если не задан).
|
||||||
|
func (b *Bridge) maxTokenCtx(ctx context.Context) string {
|
||||||
|
if v, ok := ctx.Value(maxTokenCtxKeyT{}).(string); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxTokenForSend — токен для отправки/аплоада: из контекста (если релей его задал),
|
||||||
|
// иначе по членству (maxTokenFor). Обеспечивает один бот на весь релей.
|
||||||
|
func (b *Bridge) maxTokenForSend(ctx context.Context, chatID int64) string {
|
||||||
|
if t := b.maxTokenCtx(ctx); t != "" {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
return b.maxTokenFor(ctx, chatID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxTokenCtxOr — токен из контекста (если релей задал), иначе основной. Для аплоадов
|
||||||
|
// (у них нет chatID — берут токен релея из ctx, чтобы заливать тем же ботом, что и шлёт).
|
||||||
|
func (b *Bridge) maxTokenCtxOr(ctx context.Context) string {
|
||||||
|
if t := b.maxTokenCtx(ctx); t != "" {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
return b.cfg.MaxToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSelfMaxBot — сообщение от любого из НАШИХ MAX-ботов (основного или старого).
|
||||||
|
// Нужен дедуп/фильтр: старый бот видит сообщения нового (и наоборот) в общих чатах.
|
||||||
|
func (b *Bridge) isSelfMaxBot(senderUID int64) bool {
|
||||||
|
return senderUID != 0 && (senderUID == b.maxBotUID || (b.maxOldUID != 0 && senderUID == b.maxOldUID))
|
||||||
|
}
|
||||||
54
format.go
54
format.go
|
|
@ -2,6 +2,7 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"html"
|
"html"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes"
|
maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes"
|
||||||
|
|
@ -48,6 +49,28 @@ func formatAttributionHTML(name, text string, newline bool) string {
|
||||||
return bold + ": " + text
|
return bold + ": " + text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tgForwardLine — метка «↪️ Переслано из X\n» (HTML, italic) для зеркалируемых форвардов.
|
||||||
|
// "" если сообщение не форвард. Подмешивается в начало тела (после атрибуции автора).
|
||||||
|
func tgForwardLine(msg *TGMessage) string {
|
||||||
|
if msg.ForwardFrom == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "↪️ <i>Переслано из " + html.EscapeString(msg.ForwardFrom) + "</i>\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// tgContactText — текстовое представление контакта (sharing телефона), чтобы зеркало
|
||||||
|
// не было пустым. "" если контакта нет.
|
||||||
|
func tgContactText(msg *TGMessage) string {
|
||||||
|
if msg.Contact == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(msg.Contact.FirstName + " " + msg.Contact.LastName)
|
||||||
|
if name == "" {
|
||||||
|
name = "Контакт"
|
||||||
|
}
|
||||||
|
return "📇 " + name + "\n📞 " + msg.Contact.PhoneNumber
|
||||||
|
}
|
||||||
|
|
||||||
// formatTgCaption — для пересылки (текст или caption)
|
// formatTgCaption — для пересылки (текст или caption)
|
||||||
func formatTgCaption(msg *TGMessage, prefix, newline bool) string {
|
func formatTgCaption(msg *TGMessage, prefix, newline bool) string {
|
||||||
name := tgName(msg)
|
name := tgName(msg)
|
||||||
|
|
@ -55,6 +78,9 @@ func formatTgCaption(msg *TGMessage, prefix, newline bool) string {
|
||||||
if text == "" {
|
if text == "" {
|
||||||
text = msg.Caption
|
text = msg.Caption
|
||||||
}
|
}
|
||||||
|
if text == "" {
|
||||||
|
text = tgContactText(msg)
|
||||||
|
}
|
||||||
if prefix {
|
if prefix {
|
||||||
return formatAttribution("[TG] "+name, text, newline)
|
return formatAttribution("[TG] "+name, text, newline)
|
||||||
}
|
}
|
||||||
|
|
@ -68,6 +94,9 @@ func formatTgMessage(msg *TGMessage, prefix, newline bool) string {
|
||||||
if text == "" {
|
if text == "" {
|
||||||
text = msg.Caption
|
text = msg.Caption
|
||||||
}
|
}
|
||||||
|
if text == "" {
|
||||||
|
text = tgContactText(msg)
|
||||||
|
}
|
||||||
if text == "" {
|
if text == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
@ -106,12 +135,33 @@ func formatTgCrosspostCaption(msg *TGMessage) string {
|
||||||
text = msg.Caption
|
text = msg.Caption
|
||||||
entities = msg.CaptionEntities
|
entities = msg.CaptionEntities
|
||||||
}
|
}
|
||||||
return tgEntitiesToHTML(text, entities)
|
return collapseWhitespace(tgEntitiesToHTML(text, entities))
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatMaxCrosspostCaption — для кросспостинга каналов (без attribution и префиксов)
|
// formatMaxCrosspostCaption — для кросспостинга каналов (без attribution и префиксов)
|
||||||
func formatMaxCrosspostCaption(upd *maxschemes.MessageCreatedUpdate) string {
|
func formatMaxCrosspostCaption(upd *maxschemes.MessageCreatedUpdate) string {
|
||||||
return upd.Message.Body.Text
|
return collapseWhitespace(upd.Message.Body.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// collapseWhitespace убирает «паддинг» из пересылаемого текста: длинные пробельные
|
||||||
|
// прогоны и лишние пустые строки (часто в постах-простынях), которые раздувают длину
|
||||||
|
// (лимит MAX ~4000) и уродуют вид. Схлопывает 2+ пробелов/табов/nbsp → 1, обрезает
|
||||||
|
// хвостовые пробелы строк, 3+ переносов → максимум одна пустая строка.
|
||||||
|
var (
|
||||||
|
reTrailSpace = regexp.MustCompile(`[ \t\x{00A0}]+\n`)
|
||||||
|
reManyBlank = regexp.MustCompile(`\n{3,}`)
|
||||||
|
reManySpace = regexp.MustCompile(`[ \t\x{00A0}]{2,}`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func collapseWhitespace(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||||
|
s = reTrailSpace.ReplaceAllString(s, "\n")
|
||||||
|
s = reManyBlank.ReplaceAllString(s, "\n\n")
|
||||||
|
s = reManySpace.ReplaceAllString(s, " ")
|
||||||
|
return strings.TrimSpace(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
// mimeToFilename генерирует имя файла из MIME-типа, если оригинальное имя отсутствует.
|
// mimeToFilename генерирует имя файла из MIME-типа, если оригинальное имя отсутствует.
|
||||||
|
|
|
||||||
163
helpmenu.go
Normal file
163
helpmenu.go
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- /start и /help как меню ---
|
||||||
|
// Вместо «портянки» по /start показываем короткий интро + inline-кнопки. Детали
|
||||||
|
// (как связать группы/каналы, команды, FAQ) — за кнопками (callback help:*),
|
||||||
|
// редактируем то же сообщение. Кнопки «добавить в группу/канал» — deep-link Telegram.
|
||||||
|
|
||||||
|
// tgStartMenu — короткий интро + главное меню.
|
||||||
|
func (b *Bridge) tgStartMenu() (string, *InlineKeyboardMarkup) {
|
||||||
|
text := "🔗 <b>Мост между Telegram и MAX</b>\n\n" +
|
||||||
|
"Пишете в одном мессенджере — сообщение автоматически появляется в другом. " +
|
||||||
|
"Группы и каналы: текст, фото, видео, голосовые, файлы.\n\n" +
|
||||||
|
"🔒 Бот не хранит тексты сообщений — только ID для синхронизации правок, " +
|
||||||
|
"и те удаляются через 30 дней.\n\n" +
|
||||||
|
"Выберите действие 👇"
|
||||||
|
|
||||||
|
kb := NewInlineKeyboard(
|
||||||
|
NewInlineRow(
|
||||||
|
InlineKeyboardButton{Text: "➕ В группу Telegram", URL: b.cfg.TgBotURL + "?startgroup=true"},
|
||||||
|
InlineKeyboardButton{Text: "➕ В канал Telegram", URL: b.cfg.TgBotURL + "?startchannel=true"},
|
||||||
|
),
|
||||||
|
NewInlineRow(InlineKeyboardButton{Text: "🤖 Добавить MAX-бота", URL: b.cfg.MaxBotURL}),
|
||||||
|
NewInlineRow(NewInlineButton("📋 Как связать группы", "help:groups")),
|
||||||
|
NewInlineRow(NewInlineButton("🧵 Темы форума", "help:threads")),
|
||||||
|
NewInlineRow(NewInlineButton("📡 Кросспостинг каналов", "help:channels")),
|
||||||
|
NewInlineRow(
|
||||||
|
NewInlineButton("⌨️ Команды", "help:cmds"),
|
||||||
|
NewInlineButton("❓ Вопросы", "help:faq"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// Расширенные функции (импорт/комменты/антиспам/PRO) приходят от расширения через
|
||||||
|
// extraHelp — в публичном коде их не хардкодим. Кнопка только если расширение есть.
|
||||||
|
if strings.TrimSpace(b.extraHelp) != "" {
|
||||||
|
kb.Rows = append(kb.Rows, NewInlineRow(NewInlineButton("✨ Ещё: импорт, PRO, антиспам", "help:more")))
|
||||||
|
}
|
||||||
|
// Если задана подробная инструкция (HELP_FILE/help.html) — кнопка на полный текст.
|
||||||
|
if customHelp() != "" {
|
||||||
|
kb.Rows = append(kb.Rows, NewInlineRow(NewInlineButton("📖 Полная инструкция", "help:full")))
|
||||||
|
}
|
||||||
|
return text, kb
|
||||||
|
}
|
||||||
|
|
||||||
|
// helpBackKb — клавиатура с одной кнопкой «назад в меню».
|
||||||
|
func helpBackKb() *InlineKeyboardMarkup {
|
||||||
|
return NewInlineKeyboard(NewInlineRow(NewInlineButton("⬅️ Назад", "help:home")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) helpGroupsText() string {
|
||||||
|
return "📋 <b>Как связать группы</b>\n\n" +
|
||||||
|
"1. Добавьте бота в обе группы:\n" +
|
||||||
|
" • Telegram — кнопка «➕ В группу Telegram»\n" +
|
||||||
|
" • MAX — " + b.cfg.MaxBotURL + "\n" +
|
||||||
|
" (если поиск в MAX не находит по полному имени — введите имя <b>без суффикса</b> <code>_bot</code>)\n" +
|
||||||
|
"2. В MAX сделайте бота администратором группы <b>с правом «Доступ к сообщениям»</b> " +
|
||||||
|
"(читать все сообщения). Без этого права бот не видит команды и сообщения — связка не сработает.\n" +
|
||||||
|
"3. В Telegram — администратором, только если группа с темами (форум). " +
|
||||||
|
"В обычных группах не требуется.\n" +
|
||||||
|
"4. В одной из групп отправьте <code>/bridge</code> — бот выдаст ключ.\n" +
|
||||||
|
"5. Отправьте <code>/bridge ключ</code> во второй группе.\n" +
|
||||||
|
"6. Готово — сообщения зеркалятся в обе стороны." + b.reserveBotHint()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) helpThreadsText() string {
|
||||||
|
return "🧵 <b>Темы форума (threads)</b>\n\n" +
|
||||||
|
"Если TG-группа — форум с темами (topics):\n\n" +
|
||||||
|
"<code>/thread</code> — выполните <b>внутри нужной темы</b>: сообщения из связанной " +
|
||||||
|
"MAX-группы будут приходить именно в эту тему.\n\n" +
|
||||||
|
"<code>/thread_bridge</code> — связать <b>отдельную</b> тему с <b>отдельной</b> MAX-группой: " +
|
||||||
|
"выполните в теме → бот выдаст ключ → в MAX-группе отправьте <code>/thread_bridge ключ</code>.\n\n" +
|
||||||
|
"<code>/thread_unbridge</code> — разорвать связку темы.\n\n" +
|
||||||
|
"⚠️ При обычном <code>/bridge</code> на всю группу в MAX уходят сообщения из ВСЕХ тем. " +
|
||||||
|
"Чтобы зеркалить только одну тему: <code>/unbridge</code> на группе → <code>/thread_bridge</code> в нужной теме."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) helpChannelsText() string {
|
||||||
|
return "📡 <b>Кросспостинг каналов</b>\n\n" +
|
||||||
|
"1. Добавьте бота администратором (с правом постинга) в оба канала:\n" +
|
||||||
|
" • Telegram — " + b.cfg.TgBotURL + "\n" +
|
||||||
|
" • MAX — " + b.cfg.MaxBotURL + "\n" +
|
||||||
|
"2. Перешлите пост из TG-канала в личку TG-боту.\n" +
|
||||||
|
"3. Бот покажет ID канала — скопируйте.\n" +
|
||||||
|
"4. В личке MAX-бота (" + b.cfg.MaxBotURL + ") отправьте: <code>/crosspost ID</code>\n" +
|
||||||
|
"5. Перешлите пост из MAX-канала туда же — готово!\n\n" +
|
||||||
|
"Управление: <code>/crosspost</code> — список связок с кнопками, либо перешлите " +
|
||||||
|
"пост из связанного канала." + b.reserveBotHint()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) helpCmdsText() string {
|
||||||
|
return "⌨️ <b>Команды</b>\n\n" +
|
||||||
|
"<code>/bridge</code> — создать ключ для связки групп\n" +
|
||||||
|
"<code>/bridge ключ</code> — связать эту группу по ключу\n" +
|
||||||
|
"<code>/bridge prefix on|off</code> — префикс [TG]/[MAX]\n" +
|
||||||
|
"<code>/pause</code> — поставить связку на паузу (не зеркалить, не удаляя)\n" +
|
||||||
|
"<code>/unpause</code> — возобновить пересылку\n" +
|
||||||
|
"<code>/unbridge</code> — удалить связку\n" +
|
||||||
|
"<code>/thread</code> — направить MAX → текущий топик (форум)\n" +
|
||||||
|
"<code>/thread_bridge</code> — связать тред с отдельной MAX-группой\n" +
|
||||||
|
"<code>/thread_unbridge</code> — разорвать связку треда\n" +
|
||||||
|
"<code>/crosspost</code> — связки каналов и управление"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) helpFaqText() string {
|
||||||
|
return "❓ <b>Частые вопросы</b>\n\n" +
|
||||||
|
"<b>Бот хранит мои сообщения?</b>\n" +
|
||||||
|
"Нет. Только ID для синхронизации правок, удаляются через 30 дней.\n\n" +
|
||||||
|
"<b>Что пересылается?</b>\n" +
|
||||||
|
"Текст, фото, видео, голосовые, документы, альбомы, правки.\n\n" +
|
||||||
|
"<b>Нужно ли делать бота админом?</b>\n" +
|
||||||
|
"В MAX — да. В Telegram — только в форум-группах и в каналах (для постинга).\n\n" +
|
||||||
|
"<b>Сообщения не доходят?</b>\n" +
|
||||||
|
"Проверьте, что бот добавлен в нужный чат и является администратором.\n\n" +
|
||||||
|
"💬 Поддержка: https://t.me/+0ucbOj4wBwQzMWNi"
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleHelpMenuCallback — навигация по меню /start (help:*). Редактирует сообщение.
|
||||||
|
// Возвращает true, если callback обработан здесь.
|
||||||
|
func (b *Bridge) handleHelpMenuCallback(ctx context.Context, query *TGCallback, data string) bool {
|
||||||
|
if !strings.HasPrefix(data, "help:") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
chatID := query.Message.Chat.ID
|
||||||
|
msgID := query.Message.MessageID
|
||||||
|
|
||||||
|
var text string
|
||||||
|
var kb *InlineKeyboardMarkup
|
||||||
|
switch data {
|
||||||
|
case "help:home":
|
||||||
|
text, kb = b.tgStartMenu()
|
||||||
|
case "help:groups":
|
||||||
|
text, kb = b.helpGroupsText(), helpBackKb()
|
||||||
|
case "help:threads":
|
||||||
|
text, kb = b.helpThreadsText(), helpBackKb()
|
||||||
|
case "help:channels":
|
||||||
|
text, kb = b.helpChannelsText(), helpBackKb()
|
||||||
|
case "help:cmds":
|
||||||
|
text, kb = b.helpCmdsText(), helpBackKb()
|
||||||
|
case "help:faq":
|
||||||
|
text, kb = b.helpFaqText(), helpBackKb()
|
||||||
|
case "help:more":
|
||||||
|
if h := strings.TrimSpace(b.extraHelp); h != "" {
|
||||||
|
text, kb = h, helpBackKb()
|
||||||
|
} else {
|
||||||
|
text, kb = b.tgStartMenu()
|
||||||
|
}
|
||||||
|
case "help:full":
|
||||||
|
if ch := customHelp(); ch != "" {
|
||||||
|
text, kb = ch, helpBackKb()
|
||||||
|
} else {
|
||||||
|
text, kb = b.tgStartMenu()
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
b.tg.AnswerCallback(ctx, query.ID, "")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
b.tg.EditMessageText(ctx, chatID, msgID, text, &SendOpts{ParseMode: "HTML", ReplyMarkup: kb})
|
||||||
|
b.tg.AnswerCallback(ctx, query.ID, "")
|
||||||
|
return true
|
||||||
|
}
|
||||||
37
main.go
37
main.go
|
|
@ -55,8 +55,10 @@ func main() {
|
||||||
|
|
||||||
cfg := Config{
|
cfg := Config{
|
||||||
MaxToken: mustEnv("MAX_TOKEN"),
|
MaxToken: mustEnv("MAX_TOKEN"),
|
||||||
|
MaxTokenOld: os.Getenv("MAX_TOKEN_OLD"),
|
||||||
TgBotURL: envOr("TG_BOT_URL", "https://t.me/MaxTelegramBridgeBot"),
|
TgBotURL: envOr("TG_BOT_URL", "https://t.me/MaxTelegramBridgeBot"),
|
||||||
MaxBotURL: envOr("MAX_BOT_URL", "https://max.ru/id710708943262_bot"),
|
MaxBotURL: envOr("MAX_BOT_URL", "https://max.ru/id710708943262_bot"), // основной (старый разбанен)
|
||||||
|
MaxBotURLReserve: envOr("MAX_BOT_URL_RESERVE", "https://max.ru/id710708943262_4_bot"), // запасной (failover)
|
||||||
MaxWebhookURL: os.Getenv("MAX_WEBHOOK_URL"),
|
MaxWebhookURL: os.Getenv("MAX_WEBHOOK_URL"),
|
||||||
MaxWebhookPort: envOr("MAX_WEBHOOK_PORT", "8443"),
|
MaxWebhookPort: envOr("MAX_WEBHOOK_PORT", "8443"),
|
||||||
TgWebhookURL: os.Getenv("TG_WEBHOOK_URL"),
|
TgWebhookURL: os.Getenv("TG_WEBHOOK_URL"),
|
||||||
|
|
@ -163,12 +165,37 @@ func main() {
|
||||||
slog.Error("MAX bot error", "err", err)
|
slog.Error("MAX bot error", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
var maxUID int64
|
||||||
maxInfo, err := maxApi.Bots.GetBot(context.Background())
|
maxInfo, err := maxApi.Bots.GetBot(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("MAX bot info error", "err", err)
|
// НЕ валим старт, если MAX недоступен или аккаунт бота заблокирован
|
||||||
os.Exit(1)
|
// (403 account.blocked) — поднимаемся в деградированном режиме: Telegram,
|
||||||
|
// polling и антиспам работают; MAX-отправки фейлятся и уходят в очередь.
|
||||||
|
// Иначе при бане/простое MAX любой рестарт убивал бы бота целиком (был
|
||||||
|
// прод-инцидент: GetBot → account.blocked → os.Exit → крэш-луп).
|
||||||
|
slog.Error("MAX bot info error — продолжаю в деградированном режиме (MAX недоступен/заблокирован)", "err", err)
|
||||||
|
} else {
|
||||||
|
maxUID = maxInfo.UserId
|
||||||
|
slog.Info("MAX bot started", "name", maxInfo.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Второй (старый) MAX-бот — резерв + обслуживание чатов, где нового бота нет.
|
||||||
|
// Best-effort: если токен не задан/невалиден — работаем одним ботом.
|
||||||
|
var maxApiOld *maxbot.Api
|
||||||
|
var maxOldUID int64
|
||||||
|
if cfg.MaxTokenOld != "" {
|
||||||
|
if old, err := maxbot.New(cfg.MaxTokenOld, maxbot.WithBaseURL("https://platform-api.max.ru/")); err != nil {
|
||||||
|
slog.Error("MAX old bot init error — работаю без резервного бота", "err", err)
|
||||||
|
} else {
|
||||||
|
maxApiOld = old
|
||||||
|
if info, err := old.Bots.GetBot(context.Background()); err != nil {
|
||||||
|
slog.Error("MAX old bot info error", "err", err)
|
||||||
|
} else {
|
||||||
|
maxOldUID = info.UserId
|
||||||
|
slog.Info("MAX old bot started", "name", info.Name, "uid", maxOldUID)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
slog.Info("MAX bot started", "name", maxInfo.Name)
|
|
||||||
|
|
||||||
sigCh := make(chan os.Signal, 1)
|
sigCh := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
@ -178,7 +205,7 @@ func main() {
|
||||||
cancel()
|
cancel()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
bridge := NewBridge(cfg, repo, tg, maxApi, maxInfo.UserId)
|
bridge := NewBridge(cfg, repo, tg, maxApi, maxUID, maxApiOld, maxOldUID)
|
||||||
bridge.Run(ctx)
|
bridge.Run(ctx)
|
||||||
slog.Info("Bridge stopped")
|
slog.Info("Bridge stopped")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
109
markup.go
109
markup.go
|
|
@ -137,6 +137,11 @@ func tgEntitiesToHTML(text string, entities []Entity) string {
|
||||||
runes := []rune(text)
|
runes := []rune(text)
|
||||||
utf16units := utf16.Encode(runes)
|
utf16units := utf16.Encode(runes)
|
||||||
|
|
||||||
|
// skip — код-юниты, которые НЕ выводим (кастомные эмодзи Telegram: MAX их не
|
||||||
|
// рендерит, а fallback-символ засоряет пересылку). Режем весь диапазон энтити.
|
||||||
|
skip := make([]bool, len(utf16units))
|
||||||
|
hasSkip := false
|
||||||
|
|
||||||
type tag struct {
|
type tag struct {
|
||||||
pos int
|
pos int
|
||||||
open bool
|
open bool
|
||||||
|
|
@ -146,6 +151,19 @@ func tgEntitiesToHTML(text string, entities []Entity) string {
|
||||||
|
|
||||||
var tags []tag
|
var tags []tag
|
||||||
for i, e := range entities {
|
for i, e := range entities {
|
||||||
|
if e.Type == "custom_emoji" {
|
||||||
|
end := e.Offset + e.Length
|
||||||
|
if end > len(utf16units) {
|
||||||
|
end = len(utf16units)
|
||||||
|
}
|
||||||
|
for p := e.Offset; p < end; p++ {
|
||||||
|
if p >= 0 {
|
||||||
|
skip[p] = true
|
||||||
|
hasSkip = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
var openTag, closeTag string
|
var openTag, closeTag string
|
||||||
switch e.Type {
|
switch e.Type {
|
||||||
case "bold":
|
case "bold":
|
||||||
|
|
@ -179,7 +197,7 @@ func tgEntitiesToHTML(text string, entities []Entity) string {
|
||||||
tags = append(tags, tag{pos: end, open: false, idx: i, text: closeTag})
|
tags = append(tags, tag{pos: end, open: false, idx: i, text: closeTag})
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(tags) == 0 {
|
if len(tags) == 0 && !hasSkip {
|
||||||
return html.EscapeString(text)
|
return html.EscapeString(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -205,10 +223,12 @@ func tgEntitiesToHTML(text string, entities []Entity) string {
|
||||||
}
|
}
|
||||||
if i < len(utf16units) {
|
if i < len(utf16units) {
|
||||||
if utf16.IsSurrogate(rune(utf16units[i])) && i+1 < len(utf16units) {
|
if utf16.IsSurrogate(rune(utf16units[i])) && i+1 < len(utf16units) {
|
||||||
r := utf16.DecodeRune(rune(utf16units[i]), rune(utf16units[i+1]))
|
if !skip[i] {
|
||||||
sb.WriteString(html.EscapeString(string(r)))
|
r := utf16.DecodeRune(rune(utf16units[i]), rune(utf16units[i+1]))
|
||||||
i++
|
sb.WriteString(html.EscapeString(string(r)))
|
||||||
} else {
|
}
|
||||||
|
i++ // суррогатная пара — пропускаем и низкий код-юнит
|
||||||
|
} else if !skip[i] {
|
||||||
sb.WriteString(html.EscapeString(string(rune(utf16units[i]))))
|
sb.WriteString(html.EscapeString(string(rune(utf16units[i]))))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -286,50 +306,69 @@ func maxMarkupsToHTML(text string, markups []maxschemes.MarkUp) string {
|
||||||
runes := []rune(text)
|
runes := []rune(text)
|
||||||
utf16units := utf16.Encode(runes)
|
utf16units := utf16.Encode(runes)
|
||||||
|
|
||||||
type tag struct {
|
// open/close HTML для типа маркапа (для ссылки URL входит в идентичность тега).
|
||||||
pos int
|
type tagPair struct{ open, close string }
|
||||||
open bool
|
tagFor := func(m maxschemes.MarkUp) (tagPair, bool) {
|
||||||
order int
|
|
||||||
tag string
|
|
||||||
}
|
|
||||||
|
|
||||||
var tags []tag
|
|
||||||
for _, m := range markups {
|
|
||||||
var openTag, closeTag string
|
|
||||||
switch m.Type {
|
switch m.Type {
|
||||||
case maxschemes.MarkupStrong:
|
case maxschemes.MarkupStrong:
|
||||||
openTag, closeTag = "<b>", "</b>"
|
return tagPair{"<b>", "</b>"}, true
|
||||||
case maxschemes.MarkupEmphasized:
|
case maxschemes.MarkupEmphasized:
|
||||||
openTag, closeTag = "<i>", "</i>"
|
return tagPair{"<i>", "</i>"}, true
|
||||||
case maxschemes.MarkupMonospaced:
|
case maxschemes.MarkupMonospaced:
|
||||||
openTag, closeTag = "<code>", "</code>"
|
return tagPair{"<code>", "</code>"}, true
|
||||||
case maxschemes.MarkupStrikethrough:
|
case maxschemes.MarkupStrikethrough:
|
||||||
openTag, closeTag = "<s>", "</s>"
|
return tagPair{"<s>", "</s>"}, true
|
||||||
case maxschemes.MarkupUnderline:
|
case maxschemes.MarkupUnderline:
|
||||||
openTag, closeTag = "<u>", "</u>"
|
return tagPair{"<u>", "</u>"}, true
|
||||||
case maxschemes.MarkupLink:
|
case maxschemes.MarkupLink:
|
||||||
openTag = `<a href="` + html.EscapeString(m.URL) + `">`
|
return tagPair{`<a href="` + html.EscapeString(m.URL) + `">`, "</a>"}, true
|
||||||
closeTag = "</a>"
|
|
||||||
default:
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
tags = append(tags, tag{pos: m.From, open: true, order: 0, tag: openTag})
|
return tagPair{}, false
|
||||||
tags = append(tags, tag{pos: m.From + m.Length, open: false, order: 1, tag: closeTag})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Slice(tags, func(i, j int) bool {
|
// activeAt — детерминированно упорядоченный набор тегов, активных на позиции i.
|
||||||
if tags[i].pos != tags[j].pos {
|
// MAX допускает ПЕРЕКРЫВАЮЩИЕСЯ маркапы (ссылка+жирный внахлёст), а HTML требует
|
||||||
return tags[i].pos < tags[j].pos
|
// строгой вложенности. Поэтому на каждой границе закрываем все открытые теги и
|
||||||
|
// переоткрываем актуальные — так HTML всегда валиден (Telegram не ругается).
|
||||||
|
activeAt := func(i int) []tagPair {
|
||||||
|
var out []tagPair
|
||||||
|
for _, m := range markups {
|
||||||
|
if m.From <= i && i < m.From+m.Length {
|
||||||
|
if tp, ok := tagFor(m); ok {
|
||||||
|
out = append(out, tp)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return tags[i].order > tags[j].order
|
sort.Slice(out, func(a, b int) bool { return out[a].open < out[b].open })
|
||||||
})
|
return out
|
||||||
|
}
|
||||||
|
sameTags := func(a, b []tagPair) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
tagIdx := 0
|
var open []tagPair
|
||||||
for i := 0; i <= len(utf16units); i++ {
|
for i := 0; i <= len(utf16units); i++ {
|
||||||
for tagIdx < len(tags) && tags[tagIdx].pos == i {
|
var cur []tagPair
|
||||||
sb.WriteString(tags[tagIdx].tag)
|
if i < len(utf16units) {
|
||||||
tagIdx++
|
cur = activeAt(i)
|
||||||
|
}
|
||||||
|
if !sameTags(cur, open) {
|
||||||
|
for j := len(open) - 1; j >= 0; j-- {
|
||||||
|
sb.WriteString(open[j].close)
|
||||||
|
}
|
||||||
|
open = cur
|
||||||
|
for _, tp := range open {
|
||||||
|
sb.WriteString(tp.open)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if i < len(utf16units) {
|
if i < len(utf16units) {
|
||||||
if utf16.IsSurrogate(rune(utf16units[i])) && i+1 < len(utf16units) {
|
if utf16.IsSurrogate(rune(utf16units[i])) && i+1 < len(utf16units) {
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,17 @@ func (b *Bridge) flushMediaGroup(ctx context.Context, groupID string) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Пауза связки — альбом тоже не пересылаем.
|
||||||
|
if isCrosspost {
|
||||||
|
if b.repo.CrosspostPaused(maxChatID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if b.repo.PairPaused(items[0].msg.Chat.ID, maxChatID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Дуал-бот: токен бота этого чата в ctx (аплоады + sendMaxDirect альбома идут им).
|
||||||
|
ctx = b.withMaxToken(ctx, b.maxTokenFor(ctx, maxChatID))
|
||||||
|
mc := b.maxClientFor(ctx, maxChatID) // SDK-клиент бота этого чата (дуал)
|
||||||
|
|
||||||
uid := tgUserID(items[0].msg)
|
uid := tgUserID(items[0].msg)
|
||||||
prefix := !isCrosspost && b.hasPrefix("tg", items[0].msg.Chat.ID)
|
prefix := !isCrosspost && b.hasPrefix("tg", items[0].msg.Chat.ID)
|
||||||
|
|
@ -173,7 +184,7 @@ func (b *Bridge) flushMediaGroup(ctx context.Context, groupID string) {
|
||||||
}
|
}
|
||||||
m.AddPhoto(uploaded)
|
m.AddPhoto(uploaded)
|
||||||
} else {
|
} else {
|
||||||
uploaded, err := b.maxApi.Uploads.UploadPhotoFromUrl(ctx, fileURL)
|
uploaded, err := mc.Uploads.UploadPhotoFromUrl(ctx, fileURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("media group: photo upload failed", "err", err)
|
slog.Error("media group: photo upload failed", "err", err)
|
||||||
photoFailErr = err
|
photoFailErr = err
|
||||||
|
|
@ -214,10 +225,12 @@ func (b *Bridge) flushMediaGroup(ctx context.Context, groupID string) {
|
||||||
|
|
||||||
// Если есть фото — отправляем через SDK (поддерживает AddPhoto)
|
// Если есть фото — отправляем через SDK (поддерживает AddPhoto)
|
||||||
if photosSent > 0 {
|
if photosSent > 0 {
|
||||||
result, err := b.maxApi.Messages.SendWithResult(ctx, m)
|
result, err := mc.Messages.SendWithResult(ctx, m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("TG→MAX media group send failed", "err", err)
|
slog.Error("TG→MAX media group send failed", "err", err)
|
||||||
if b.cbFail(maxChatID) {
|
if isChatUnavailable(err.Error()) {
|
||||||
|
b.cbPermanentFail(ctx, maxChatID) // бот недоступен в чате → связка на паузу + DM
|
||||||
|
} else if b.cbFail(maxChatID) {
|
||||||
b.notifyTgUser(ctx, items[0].msg, maxChatID,
|
b.notifyTgUser(ctx, items[0].msg, maxChatID,
|
||||||
fmt.Sprintf("Не удалось переслать альбом в MAX. Пересылка приостановлена на %d мин. Проверьте, что бот добавлен в MAX-чат и является админом.", int(cbCooldown.Minutes())), isCrosspost)
|
fmt.Sprintf("Не удалось переслать альбом в MAX. Пересылка приостановлена на %d мин. Проверьте, что бот добавлен в MAX-чат и является админом.", int(cbCooldown.Minutes())), isCrosspost)
|
||||||
}
|
}
|
||||||
|
|
@ -234,7 +247,7 @@ func (b *Bridge) flushMediaGroup(ctx context.Context, groupID string) {
|
||||||
} else {
|
} else {
|
||||||
cap = formatTgCaption(it.msg, prefix, b.cfg.MessageNewline)
|
cap = formatTgCaption(it.msg, prefix, b.cfg.MessageNewline)
|
||||||
}
|
}
|
||||||
go b.forwardTgToMax(ctx, it.msg, maxChatID, cap, isCrosspost)
|
go b.forwardTgToMax(ctx, it.msg, maxChatID, cap, isCrosspost, false)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2
migrations/postgres/000020_pause_links.down.sql
Normal file
2
migrations/postgres/000020_pause_links.down.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE crossposts DROP COLUMN paused;
|
||||||
|
ALTER TABLE pairs DROP COLUMN paused;
|
||||||
2
migrations/postgres/000020_pause_links.up.sql
Normal file
2
migrations/postgres/000020_pause_links.up.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE pairs ADD COLUMN paused INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE crossposts ADD COLUMN paused INTEGER NOT NULL DEFAULT 0;
|
||||||
1
migrations/postgres/000021_crosspost_seen.down.sql
Normal file
1
migrations/postgres/000021_crosspost_seen.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS crosspost_seen;
|
||||||
7
migrations/postgres/000021_crosspost_seen.up.sql
Normal file
7
migrations/postgres/000021_crosspost_seen.up.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS crosspost_seen (
|
||||||
|
platform TEXT NOT NULL,
|
||||||
|
chat_id BIGINT NOT NULL,
|
||||||
|
msg_id TEXT NOT NULL,
|
||||||
|
at BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (platform, chat_id, msg_id)
|
||||||
|
);
|
||||||
2
migrations/sqlite/000020_pause_links.down.sql
Normal file
2
migrations/sqlite/000020_pause_links.down.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE crossposts DROP COLUMN paused;
|
||||||
|
ALTER TABLE pairs DROP COLUMN paused;
|
||||||
2
migrations/sqlite/000020_pause_links.up.sql
Normal file
2
migrations/sqlite/000020_pause_links.up.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE pairs ADD COLUMN paused INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE crossposts ADD COLUMN paused INTEGER NOT NULL DEFAULT 0;
|
||||||
1
migrations/sqlite/000021_crosspost_seen.down.sql
Normal file
1
migrations/sqlite/000021_crosspost_seen.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS crosspost_seen;
|
||||||
7
migrations/sqlite/000021_crosspost_seen.up.sql
Normal file
7
migrations/sqlite/000021_crosspost_seen.up.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS crosspost_seen (
|
||||||
|
platform TEXT NOT NULL,
|
||||||
|
chat_id INTEGER NOT NULL,
|
||||||
|
msg_id TEXT NOT NULL,
|
||||||
|
at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (platform, chat_id, msg_id)
|
||||||
|
);
|
||||||
65
postgres.go
65
postgres.go
|
|
@ -254,9 +254,7 @@ func (r *pgRepo) CompleteThreadBridge(key string, maxChatID int64) (int64, int,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *pgRepo) GetThreadMaxChat(tgChatID int64, threadID int) (int64, bool) {
|
func (r *pgRepo) GetThreadMaxChat(tgChatID int64, threadID int) (int64, bool) {
|
||||||
if threadID == 0 {
|
// threadID==0 — General-топик форума (валидная связка), НЕ отбиваем (см. sqlite).
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
var id int64
|
var id int64
|
||||||
err := r.db.QueryRow(
|
err := r.db.QueryRow(
|
||||||
"SELECT max_chat_id FROM thread_pairs WHERE tg_chat_id = $1 AND tg_thread_id = $2",
|
"SELECT max_chat_id FROM thread_pairs WHERE tg_chat_id = $1 AND tg_thread_id = $2",
|
||||||
|
|
@ -441,6 +439,67 @@ func (r *pgRepo) SetCrosspostSyncEdits(maxChatID int64, on bool) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Пауза связок (временно не пересылаем, связку не удаляем) ---
|
||||||
|
|
||||||
|
func (r *pgRepo) PairPaused(tgChatID, maxChatID int64) bool {
|
||||||
|
var v int
|
||||||
|
r.db.QueryRow("SELECT COALESCE(paused, 0) FROM pairs WHERE tg_chat_id = $1 AND max_chat_id = $2", tgChatID, maxChatID).Scan(&v)
|
||||||
|
return v != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgRepo) SetPairPaused(tgChatID, maxChatID int64, paused bool) error {
|
||||||
|
v := 0
|
||||||
|
if paused {
|
||||||
|
v = 1
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
_, err := r.db.Exec("UPDATE pairs SET paused = $1 WHERE tg_chat_id = $2 AND max_chat_id = $3", v, tgChatID, maxChatID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgRepo) CrosspostPaused(maxChatID int64) bool {
|
||||||
|
var v int
|
||||||
|
r.db.QueryRow("SELECT COALESCE(paused, 0) FROM crossposts WHERE max_chat_id = $1 AND deleted_at = 0", maxChatID).Scan(&v)
|
||||||
|
return v != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgRepo) SetCrosspostPaused(maxChatID int64, paused bool) error {
|
||||||
|
v := 0
|
||||||
|
if paused {
|
||||||
|
v = 1
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
_, err := r.db.Exec("UPDATE crossposts SET paused = $1 WHERE max_chat_id = $2 AND deleted_at = 0", v, maxChatID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgRepo) ClaimCrosspost(platform string, chatID int64, msgID string) bool {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
now := time.Now().Unix()
|
||||||
|
r.db.Exec("DELETE FROM crosspost_seen WHERE at < $1", now-3600) // TTL: 1ч
|
||||||
|
res, err := r.db.Exec(
|
||||||
|
"INSERT INTO crosspost_seen (platform, chat_id, msg_id, at) VALUES ($1, $2, $3, $4) ON CONFLICT (platform, chat_id, msg_id) DO NOTHING",
|
||||||
|
platform, chatID, msgID, now)
|
||||||
|
if err != nil {
|
||||||
|
return true // на ошибке БД не блокируем доставку
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return n > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgRepo) TgOwnerForChat(chatID int64) int64 {
|
||||||
|
var owner int64
|
||||||
|
r.db.QueryRow("SELECT tg_owner_id FROM pairs WHERE tg_chat_id = $1 OR max_chat_id = $2", chatID, chatID).Scan(&owner)
|
||||||
|
if owner != 0 {
|
||||||
|
return owner
|
||||||
|
}
|
||||||
|
r.db.QueryRow("SELECT tg_owner_id FROM crossposts WHERE (tg_chat_id = $1 OR max_chat_id = $2) AND deleted_at = 0", chatID, chatID).Scan(&owner)
|
||||||
|
return owner
|
||||||
|
}
|
||||||
|
|
||||||
func (r *pgRepo) TouchUser(userID int64, platform, username, firstName string) {
|
func (r *pgRepo) TouchUser(userID int64, platform, username, firstName string) {
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
r.db.Exec(`INSERT INTO users (user_id, platform, username, first_name, first_seen, last_seen) VALUES ($1, $2, $3, $4, $5, $5)
|
r.db.Exec(`INSERT INTO users (user_id, platform, username, first_name, first_seen, last_seen) VALUES ($1, $2, $3, $4, $5, $5)
|
||||||
|
|
|
||||||
49
queue.go
49
queue.go
|
|
@ -2,6 +2,7 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
@ -42,6 +43,11 @@ func humanQueueError(errStr string) string {
|
||||||
// notifyTg2MaxFailure сообщает о невозможности доставить пост в MAX: владельцу
|
// notifyTg2MaxFailure сообщает о невозможности доставить пост в MAX: владельцу
|
||||||
// связки (для crosspost) с номером поста и причиной, иначе — в исходный чат.
|
// связки (для crosspost) с номером поста и причиной, иначе — в исходный чат.
|
||||||
func (b *Bridge) notifyTg2MaxFailure(ctx context.Context, item QueueItem, reason string) {
|
func (b *Bridge) notifyTg2MaxFailure(ctx context.Context, item QueueItem, reason string) {
|
||||||
|
// Глобальный бан аккаунта MAX — молчим (бан общий, уведомлять каждого бессмысленно).
|
||||||
|
if b.maxAccountBlocked() {
|
||||||
|
slog.Debug("queue fail notify suppressed: MAX account blocked", "srcChat", item.SrcChatID)
|
||||||
|
return
|
||||||
|
}
|
||||||
post := ""
|
post := ""
|
||||||
if item.SrcMsgID != "" && item.SrcMsgID != "0" {
|
if item.SrcMsgID != "" && item.SrcMsgID != "0" {
|
||||||
post = fmt.Sprintf(" (пост #%s)", item.SrcMsgID)
|
post = fmt.Sprintf(" (пост #%s)", item.SrcMsgID)
|
||||||
|
|
@ -124,6 +130,17 @@ func (b *Bridge) enqueueMax2Tg(maxChatID, tgChatID int64, maxMid, text, attType,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// enqueueMax2TgAlbum ставит в очередь ВЕСЬ альбом MAX→TG: список {type,url} в JSON,
|
||||||
|
// AttType="album". Иначе при доставке из очереди уходило только первое фото.
|
||||||
|
func (b *Bridge) enqueueMax2TgAlbum(maxChatID, tgChatID int64, maxMid, caption string, items []maxAlbumItem, parseMode string) {
|
||||||
|
data, err := json.Marshal(items)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("album enqueue marshal failed", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.enqueueMax2Tg(maxChatID, tgChatID, maxMid, caption, "album", string(data), parseMode)
|
||||||
|
}
|
||||||
|
|
||||||
// processQueue обрабатывает очередь — вызывается периодически.
|
// processQueue обрабатывает очередь — вызывается периодически.
|
||||||
func (b *Bridge) processQueue(ctx context.Context) {
|
func (b *Bridge) processQueue(ctx context.Context) {
|
||||||
items, err := b.repo.PeekQueue(queueBatchSize)
|
items, err := b.repo.PeekQueue(queueBatchSize)
|
||||||
|
|
@ -155,6 +172,10 @@ func (b *Bridge) processQueue(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bridge) processQueueTg2Max(ctx context.Context, item QueueItem, now time.Time) {
|
func (b *Bridge) processQueueTg2Max(ctx context.Context, item QueueItem, now time.Time) {
|
||||||
|
// Связка на паузе — НЕ доставляем из очереди (оставляем item, заберём после /unpause).
|
||||||
|
if b.repo.PairPaused(item.SrcChatID, item.DstChatID) || b.repo.CrosspostPaused(item.DstChatID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
// Дедуп: если это сообщение уже доставлено (например, прямая отправка прошла,
|
// Дедуп: если это сообщение уже доставлено (например, прямая отправка прошла,
|
||||||
// но бот упал до удаления из очереди) — не отправляем повторно.
|
// но бот упал до удаления из очереди) — не отправляем повторно.
|
||||||
if tgMsgID, _ := strconv.Atoi(item.SrcMsgID); b.alreadyDeliveredToMax(item.SrcChatID, tgMsgID) {
|
if tgMsgID, _ := strconv.Atoi(item.SrcMsgID); b.alreadyDeliveredToMax(item.SrcChatID, tgMsgID) {
|
||||||
|
|
@ -194,12 +215,38 @@ func (b *Bridge) processQueueTg2Max(ctx context.Context, item QueueItem, now tim
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bridge) processQueueMax2Tg(ctx context.Context, item QueueItem, now time.Time) {
|
func (b *Bridge) processQueueMax2Tg(ctx context.Context, item QueueItem, now time.Time) {
|
||||||
|
// Связка на паузе — НЕ доставляем из очереди (src=MAX-чат, dst=TG-чат).
|
||||||
|
if b.repo.PairPaused(item.DstChatID, item.SrcChatID) || b.repo.CrosspostPaused(item.SrcChatID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Дедуп: сообщение уже доставлено (напр. рестарт до удаления из очереди) — не шлём повторно.
|
||||||
|
if item.SrcMsgID != "" {
|
||||||
|
if _, _, _, ok := b.repo.LookupTgMsgID(item.SrcMsgID); ok {
|
||||||
|
slog.Info("queue: skip already-delivered max2tg", "id", item.ID, "maxMid", item.SrcMsgID)
|
||||||
|
b.repo.DeleteFromQueue(item.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
var sentMsgID int
|
var sentMsgID int
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
threadID := b.repo.GetTgThreadID(item.DstChatID)
|
threadID := b.repo.GetTgThreadID(item.DstChatID)
|
||||||
|
|
||||||
if item.AttType != "" && item.AttURL != "" {
|
if item.AttType == "album" {
|
||||||
|
// Альбом: восстанавливаем список {type,url} и шлём media group целиком.
|
||||||
|
var items []maxAlbumItem
|
||||||
|
if json.Unmarshal([]byte(item.AttURL), &items) == nil && len(items) > 0 {
|
||||||
|
ids, e := b.sendMaxAlbumToTg(ctx, item.DstChatID, items, item.Text, item.ParseMode, threadID, 0)
|
||||||
|
err = e
|
||||||
|
if e == nil && len(ids) > 0 {
|
||||||
|
sentMsgID = ids[0]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
slog.Warn("queue: bad album payload, dropping", "id", item.ID)
|
||||||
|
b.repo.DeleteFromQueue(item.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if item.AttType != "" && item.AttURL != "" {
|
||||||
opts := &SendOpts{Caption: item.Text, ParseMode: item.ParseMode, ThreadID: threadID}
|
opts := &SendOpts{Caption: item.Text, ParseMode: item.ParseMode, ThreadID: threadID}
|
||||||
switch item.AttType {
|
switch item.AttType {
|
||||||
case "photo":
|
case "photo":
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,19 @@ type Repository interface {
|
||||||
GetCrosspostSyncEdits(maxChatID int64) bool
|
GetCrosspostSyncEdits(maxChatID int64) bool
|
||||||
SetCrosspostSyncEdits(maxChatID int64, on bool) error
|
SetCrosspostSyncEdits(maxChatID int64, on bool) error
|
||||||
|
|
||||||
|
// Пауза связок (временно не пересылаем, не удаляя)
|
||||||
|
PairPaused(tgChatID, maxChatID int64) bool
|
||||||
|
SetPairPaused(tgChatID, maxChatID int64, paused bool) error
|
||||||
|
CrosspostPaused(maxChatID int64) bool
|
||||||
|
SetCrosspostPaused(maxChatID int64, paused bool) error
|
||||||
|
// TgOwnerForChat — TG-владелец связки (пары или кросспоста), куда входит chatID
|
||||||
|
// (как TG- или MAX-сторона). 0 если не найден. Для DM-уведомлений о паузе.
|
||||||
|
TgOwnerForChat(chatID int64) int64
|
||||||
|
// ClaimCrosspost атомарно застолбляет (platform, chatID, msgID) как «уже кросспостим».
|
||||||
|
// true — застолбили ВПЕРВЫЕ (кросспостить можно); false — уже было (дубль, пропустить).
|
||||||
|
// Идемпотентность против повторной доставки/дубль-строк/петель. Старьё чистится по TTL.
|
||||||
|
ClaimCrosspost(platform string, chatID int64, msgID string) bool
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
TouchUser(userID int64, platform, username, firstName string)
|
TouchUser(userID int64, platform, username, firstName string)
|
||||||
ListUsers(platform string) ([]int64, error)
|
ListUsers(platform string) ([]int64, error)
|
||||||
|
|
|
||||||
66
sqlite.go
66
sqlite.go
|
|
@ -249,9 +249,8 @@ func (r *sqliteRepo) CompleteThreadBridge(key string, maxChatID int64) (int64, i
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *sqliteRepo) GetThreadMaxChat(tgChatID int64, threadID int) (int64, bool) {
|
func (r *sqliteRepo) GetThreadMaxChat(tgChatID int64, threadID int) (int64, bool) {
|
||||||
if threadID == 0 {
|
// threadID==0 — это General-топик форума (валидная тред-связка), НЕ отбиваем:
|
||||||
return 0, false
|
// если для (чат, 0) связки нет — вернётся пусто, и вызывающий уйдёт на групповую.
|
||||||
}
|
|
||||||
var id int64
|
var id int64
|
||||||
err := r.db.QueryRow(
|
err := r.db.QueryRow(
|
||||||
"SELECT max_chat_id FROM thread_pairs WHERE tg_chat_id = ? AND tg_thread_id = ?",
|
"SELECT max_chat_id FROM thread_pairs WHERE tg_chat_id = ? AND tg_thread_id = ?",
|
||||||
|
|
@ -438,6 +437,67 @@ func (r *sqliteRepo) SetCrosspostSyncEdits(maxChatID int64, on bool) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Пауза связок (временно не пересылаем, связку не удаляем) ---
|
||||||
|
|
||||||
|
func (r *sqliteRepo) PairPaused(tgChatID, maxChatID int64) bool {
|
||||||
|
var v int
|
||||||
|
r.db.QueryRow("SELECT COALESCE(paused, 0) FROM pairs WHERE tg_chat_id = ? AND max_chat_id = ?", tgChatID, maxChatID).Scan(&v)
|
||||||
|
return v != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sqliteRepo) SetPairPaused(tgChatID, maxChatID int64, paused bool) error {
|
||||||
|
v := 0
|
||||||
|
if paused {
|
||||||
|
v = 1
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
_, err := r.db.Exec("UPDATE pairs SET paused = ? WHERE tg_chat_id = ? AND max_chat_id = ?", v, tgChatID, maxChatID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sqliteRepo) CrosspostPaused(maxChatID int64) bool {
|
||||||
|
var v int
|
||||||
|
r.db.QueryRow("SELECT COALESCE(paused, 0) FROM crossposts WHERE max_chat_id = ? AND deleted_at = 0", maxChatID).Scan(&v)
|
||||||
|
return v != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sqliteRepo) SetCrosspostPaused(maxChatID int64, paused bool) error {
|
||||||
|
v := 0
|
||||||
|
if paused {
|
||||||
|
v = 1
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
_, err := r.db.Exec("UPDATE crossposts SET paused = ? WHERE max_chat_id = ? AND deleted_at = 0", v, maxChatID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sqliteRepo) ClaimCrosspost(platform string, chatID int64, msgID string) bool {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
now := time.Now().Unix()
|
||||||
|
r.db.Exec("DELETE FROM crosspost_seen WHERE at < ?", now-3600) // TTL: 1ч
|
||||||
|
res, err := r.db.Exec(
|
||||||
|
"INSERT OR IGNORE INTO crosspost_seen (platform, chat_id, msg_id, at) VALUES (?, ?, ?, ?)",
|
||||||
|
platform, chatID, msgID, now)
|
||||||
|
if err != nil {
|
||||||
|
return true // на ошибке БД не блокируем доставку (лучше риск дубля, чем потеря)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return n > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *sqliteRepo) TgOwnerForChat(chatID int64) int64 {
|
||||||
|
var owner int64
|
||||||
|
r.db.QueryRow("SELECT tg_owner_id FROM pairs WHERE tg_chat_id = ? OR max_chat_id = ?", chatID, chatID).Scan(&owner)
|
||||||
|
if owner != 0 {
|
||||||
|
return owner
|
||||||
|
}
|
||||||
|
r.db.QueryRow("SELECT tg_owner_id FROM crossposts WHERE (tg_chat_id = ? OR max_chat_id = ?) AND deleted_at = 0", chatID, chatID).Scan(&owner)
|
||||||
|
return owner
|
||||||
|
}
|
||||||
|
|
||||||
func (r *sqliteRepo) TouchUser(userID int64, platform, username, firstName string) {
|
func (r *sqliteRepo) TouchUser(userID int64, platform, username, firstName string) {
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
|
|
|
||||||
281
telegram.go
281
telegram.go
|
|
@ -2,6 +2,7 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
@ -58,11 +59,35 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
|
||||||
if b.isSelfTgBot(edited.From) {
|
if b.isSelfTgBot(edited.From) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Модерация правок: спамер может опубликовать чистый текст (пройдёт фильтр),
|
||||||
|
// затем отредактировать его в спам. Прогоняем edit через антиспам так же, как
|
||||||
|
// новое сообщение — ДО bridge-зеркалирования и независимо от наличия связки
|
||||||
|
// (иначе в standalone-группе ниже стоит `if !linked { continue }`).
|
||||||
|
if isTgGroup(edited.Chat.Type) && edited.From != nil && !edited.IsService {
|
||||||
|
isAdmin := false
|
||||||
|
if isTgAnonymousAdmin(edited) {
|
||||||
|
isAdmin = true
|
||||||
|
} else if status, err := b.tg.GetChatMember(ctx, edited.Chat.ID, edited.From.ID); err == nil {
|
||||||
|
isAdmin = isTgAdmin(status)
|
||||||
|
}
|
||||||
|
mtext := edited.Text
|
||||||
|
if mtext == "" {
|
||||||
|
mtext = edited.Caption
|
||||||
|
}
|
||||||
|
if edited.HasExternalReply {
|
||||||
|
mtext = strings.TrimSpace(mtext + " " + edited.ExternalReplyText)
|
||||||
|
}
|
||||||
|
if b.moderateGroupMessage(ctx, GroupMessage{
|
||||||
|
Platform: "tg", ChatID: edited.Chat.ID, UserID: edited.From.ID,
|
||||||
|
TgMsgID: edited.MessageID, Text: mtext, HasLink: tgMsgHasLink(edited), IsAdmin: isAdmin,
|
||||||
|
ExtReply: edited.HasExternalReply,
|
||||||
|
}) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
var maxChatID int64
|
var maxChatID int64
|
||||||
var linked bool
|
var linked bool
|
||||||
if edited.MessageThreadID != 0 {
|
maxChatID, linked = b.repo.GetThreadMaxChat(edited.Chat.ID, edited.MessageThreadID)
|
||||||
maxChatID, linked = b.repo.GetThreadMaxChat(edited.Chat.ID, edited.MessageThreadID)
|
|
||||||
}
|
|
||||||
if !linked {
|
if !linked {
|
||||||
maxChatID, linked = b.repo.GetMaxChat(edited.Chat.ID)
|
maxChatID, linked = b.repo.GetMaxChat(edited.Chat.ID)
|
||||||
}
|
}
|
||||||
|
|
@ -79,7 +104,7 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
|
||||||
if hasMedia && !hasMapping {
|
if hasMedia && !hasMapping {
|
||||||
prefix := b.hasPrefix("tg", edited.Chat.ID)
|
prefix := b.hasPrefix("tg", edited.Chat.ID)
|
||||||
caption := formatTgCaption(edited, prefix, b.cfg.MessageNewline)
|
caption := formatTgCaption(edited, prefix, b.cfg.MessageNewline)
|
||||||
go b.forwardTgToMax(ctx, edited, maxChatID, caption, false)
|
go b.forwardTgToMax(ctx, edited, maxChatID, caption, false, false)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,7 +139,7 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
|
||||||
fwd := formatAttributionHTML(name, mdText, b.cfg.MessageNewline)
|
fwd := formatAttributionHTML(name, mdText, b.cfg.MessageNewline)
|
||||||
m := maxbot.NewMessage().SetChat(maxChatID).SetText(fwd)
|
m := maxbot.NewMessage().SetChat(maxChatID).SetText(fwd)
|
||||||
m.SetFormat("html")
|
m.SetFormat("html")
|
||||||
if err := b.maxApi.Messages.EditMessage(ctx, maxMsgID, m); err != nil {
|
if err := b.maxClientFor(ctx, maxChatID).Messages.EditMessage(ctx, maxMsgID, m); err != nil {
|
||||||
slog.Error("TG→MAX edit failed", "err", err, "uid", tgUserID(edited), "tgChat", edited.Chat.ID)
|
slog.Error("TG→MAX edit failed", "err", err, "uid", tgUserID(edited), "tgChat", edited.Chat.ID)
|
||||||
} else {
|
} else {
|
||||||
slog.Info("TG→MAX edited", "mid", maxMsgID, "uid", tgUserID(edited), "tgChat", edited.Chat.ID)
|
slog.Info("TG→MAX edited", "mid", maxMsgID, "uid", tgUserID(edited), "tgChat", edited.Chat.ID)
|
||||||
|
|
@ -195,43 +220,12 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
|
||||||
|
|
||||||
if text == "/start" || text == "/help" {
|
if text == "/start" || text == "/help" {
|
||||||
if ch := customHelp(); ch != "" {
|
if ch := customHelp(); ch != "" {
|
||||||
b.tg.SendMessage(ctx, msg.Chat.ID, ch, &SendOpts{ParseMode: "HTML"})
|
b.tg.SendMessage(ctx, msg.Chat.ID, ch, &SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
b.tg.SendMessage(ctx, msg.Chat.ID,
|
intro, kb := b.tgStartMenu()
|
||||||
"Бот-мост между Telegram и MAX.\n\n"+
|
b.tg.SendMessage(ctx, msg.Chat.ID, intro,
|
||||||
"Команды (группы):\n"+
|
&SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID, ReplyMarkup: kb})
|
||||||
"/bridge — создать ключ для связки чатов\n"+
|
|
||||||
"/bridge <ключ> — связать этот чат с MAX-чатом по ключу\n"+
|
|
||||||
"/bridge prefix on/off — включить/выключить префикс [TG]/[MAX]\n"+
|
|
||||||
"/unbridge — удалить связку\n"+
|
|
||||||
"/thread — направить сообщения из MAX в текущий топик (форум)\n"+
|
|
||||||
"/thread_bridge — связать текущий тред с отдельной MAX-группой (форум)\n"+
|
|
||||||
"/thread_unbridge — разорвать связку треда\n\n"+
|
|
||||||
"Кросспостинг каналов:\n"+
|
|
||||||
"1. Добавьте бота админом в оба канала (с правом постинга)\n"+
|
|
||||||
"2. Перешлите пост из TG-канала в личку TG-бота\n"+
|
|
||||||
"3. Бот покажет ID — скопируйте\n"+
|
|
||||||
"4. В личке MAX-бота: /crosspost <TG_ID>\n"+
|
|
||||||
"5. Перешлите пост из MAX-канала → готово!\n\n"+
|
|
||||||
"/crosspost — список всех связок с кнопками управления\n"+
|
|
||||||
"Управление: перешлите пост из связанного канала → кнопки\n\n"+
|
|
||||||
"Автозамены в кросспостинге:\n"+
|
|
||||||
"В настройках связки (кнопка 🔄) можно добавить замены текста.\n"+
|
|
||||||
"Формат: текст | замена или /regex/ | замена\n"+
|
|
||||||
"Можно заменять только в ссылках или во всём тексте.\n\n"+
|
|
||||||
"Как связать группы:\n"+
|
|
||||||
"1. Добавьте бота в оба чата\n"+
|
|
||||||
" TG: "+b.cfg.TgBotURL+"\n"+
|
|
||||||
" MAX: "+b.cfg.MaxBotURL+"\n"+
|
|
||||||
"2. В MAX сделайте бота админом группы\n"+
|
|
||||||
"3. В TG сделайте бота админом, если группа — супергруппа с темами (форум). "+
|
|
||||||
"В обычных группах админство не требуется.\n"+
|
|
||||||
"4. В одном из чатов отправьте /bridge\n"+
|
|
||||||
"5. Бот выдаст ключ — отправьте /bridge <ключ> в другом чате\n"+
|
|
||||||
"6. Готово!\n\n"+
|
|
||||||
b.extraHelp+
|
|
||||||
"Поддержка: https://github.com/BEARlogin/max-telegram-bridge-bot/issues", &SendOpts{ThreadID: msg.MessageThreadID})
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -332,7 +326,7 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
b.tg.SendMessage(ctx, msg.Chat.ID,
|
b.tg.SendMessage(ctx, msg.Chat.ID,
|
||||||
fmt.Sprintf("TG-канал «%s»\nID: <code>%d</code>\n\nВ личке MAX-бота напишите:\n<code>/crosspost %d</code>\n\nMAX-бот: %s\n\nЗатем перешлите пост из MAX-канала в личку MAX-бота.", channelTitle, channelID, channelID, b.cfg.MaxBotURL),
|
fmt.Sprintf("TG-канал «%s»\nID: <code>%d</code>\n\nВ личке MAX-бота напишите:\n<code>/crosspost %d</code>\n\nMAX-бот: %s\n\nЗатем перешлите пост из MAX-канала в личку MAX-бота.%s", channelTitle, channelID, channelID, b.cfg.MaxBotURL, b.reserveBotHint()),
|
||||||
&SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID})
|
&SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -481,7 +475,7 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
|
||||||
slog.Info("paired", "platform", "tg", "chat", msg.Chat.ID, "key", key)
|
slog.Info("paired", "platform", "tg", "chat", msg.Chat.ID, "key", key)
|
||||||
} else if generatedKey != "" {
|
} else if generatedKey != "" {
|
||||||
b.tg.SendMessage(ctx, msg.Chat.ID,
|
b.tg.SendMessage(ctx, msg.Chat.ID,
|
||||||
fmt.Sprintf("Ключ для связки: <code>%s</code>\n\nДобавьте MAX-бота в нужную MAX-группу и отправьте <b>в ней</b> (не в ЛС бота):\n<code>/bridge %s</code>\n\nСсылка на MAX-бота (для добавления в группу): %s", generatedKey, generatedKey, b.cfg.MaxBotURL),
|
fmt.Sprintf("Ключ для связки: <code>%s</code>\n\nДобавьте MAX-бота в нужную MAX-группу, сделайте его <b>администратором с правом «Доступ к сообщениям»</b> (читать все сообщения — иначе бот не увидит команду), и отправьте <b>в ней</b> (не в ЛС бота):\n<code>/bridge %s</code>\n\nСсылка на MAX-бота (для добавления в группу): %s%s", generatedKey, generatedKey, b.cfg.MaxBotURL, b.reserveBotHint()),
|
||||||
&SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID})
|
&SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID})
|
||||||
slog.Info("pending", "platform", "tg", "chat", msg.Chat.ID, "key", generatedKey)
|
slog.Info("pending", "platform", "tg", "chat", msg.Chat.ID, "key", generatedKey)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -506,6 +500,33 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Пауза/возобновление связки групп (не удаляя её).
|
||||||
|
if text == "/pause" || text == "/unpause" {
|
||||||
|
if isGroup && !isAdmin {
|
||||||
|
b.tg.SendMessage(ctx, msg.Chat.ID, adminDeniedText, &SendOpts{ThreadID: msg.MessageThreadID})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !b.checkUserAllowed(ctx, msg.Chat.ID, tgUserID(msg), msg.MessageThreadID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
maxChatID, linked := b.repo.GetMaxChat(msg.Chat.ID)
|
||||||
|
if !linked {
|
||||||
|
b.tg.SendMessage(ctx, msg.Chat.ID, "Этот чат не связан (пауза — для связки групп).", &SendOpts{ThreadID: msg.MessageThreadID})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pause := text == "/pause"
|
||||||
|
if err := b.repo.SetPairPaused(msg.Chat.ID, maxChatID, pause); err != nil {
|
||||||
|
b.tg.SendMessage(ctx, msg.Chat.ID, "Не удалось изменить паузу.", &SendOpts{ThreadID: msg.MessageThreadID})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if pause {
|
||||||
|
b.tg.SendMessage(ctx, msg.Chat.ID, "⏸ Связка на паузе — сообщения не зеркалятся (в обе стороны). Возобновить: /unpause", &SendOpts{ThreadID: msg.MessageThreadID})
|
||||||
|
} else {
|
||||||
|
b.tg.SendMessage(ctx, msg.Chat.ID, "▶️ Связка возобновлена — пересылка снова работает.", &SendOpts{ThreadID: msg.MessageThreadID})
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// /thread_bridge — связать конкретный тред с отдельным MAX-чатом
|
// /thread_bridge — связать конкретный тред с отдельным MAX-чатом
|
||||||
if text == "/thread_bridge" {
|
if text == "/thread_bridge" {
|
||||||
if !b.checkUserAllowed(ctx, msg.Chat.ID, tgUserID(msg), msg.MessageThreadID) {
|
if !b.checkUserAllowed(ctx, msg.Chat.ID, tgUserID(msg), msg.MessageThreadID) {
|
||||||
|
|
@ -519,12 +540,8 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
|
||||||
b.tg.SendMessage(ctx, msg.Chat.ID, adminDeniedText, &SendOpts{ThreadID: msg.MessageThreadID})
|
b.tg.SendMessage(ctx, msg.Chat.ID, adminDeniedText, &SendOpts{ThreadID: msg.MessageThreadID})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if msg.MessageThreadID == 0 {
|
// General-топик форума имеет thread_id=0 — это валидный тред, связываем как
|
||||||
b.tg.SendMessage(ctx, msg.Chat.ID,
|
// и любой другой (id=0). Маршрутизация ниже проверяет thread-bridge для всех id.
|
||||||
"Отправьте команду <b>внутри конкретного треда</b> (не в General). Тред будет связан с отдельным MAX-чатом.",
|
|
||||||
&SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if maxID, ok := b.repo.GetThreadMaxChat(msg.Chat.ID, msg.MessageThreadID); ok {
|
if maxID, ok := b.repo.GetThreadMaxChat(msg.Chat.ID, msg.MessageThreadID); ok {
|
||||||
b.tg.SendMessage(ctx, msg.Chat.ID,
|
b.tg.SendMessage(ctx, msg.Chat.ID,
|
||||||
fmt.Sprintf("Этот тред уже связан с MAX-чатом (ID <code>%d</code>).\n\n/thread_unbridge — разорвать связку.", maxID),
|
fmt.Sprintf("Этот тред уже связан с MAX-чатом (ID <code>%d</code>).\n\n/thread_unbridge — разорвать связку.", maxID),
|
||||||
|
|
@ -538,7 +555,7 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
_, sendErr := b.tg.SendMessage(ctx, msg.Chat.ID,
|
_, sendErr := b.tg.SendMessage(ctx, msg.Chat.ID,
|
||||||
fmt.Sprintf("Ключ для связки этого треда: <code>%s</code>\n\nДобавьте MAX-бота в отдельную MAX-группу (которая будет зеркалом этого треда) и отправьте <b>в ней</b>:\n<code>/thread_bridge %s</code>\n\nСсылка на MAX-бота: %s", key, key, b.cfg.MaxBotURL),
|
fmt.Sprintf("Ключ для связки этого треда: <code>%s</code>\n\nДобавьте MAX-бота в отдельную MAX-группу (которая будет зеркалом этого треда) и отправьте <b>в ней</b>:\n<code>/thread_bridge %s</code>\n\nСсылка на MAX-бота: %s%s", key, key, b.cfg.MaxBotURL, b.reserveBotHint()),
|
||||||
&SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID})
|
&SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID})
|
||||||
if sendErr != nil {
|
if sendErr != nil {
|
||||||
slog.Error("thread-bridge reply send failed", "err", sendErr, "tgChat", msg.Chat.ID, "thread", msg.MessageThreadID)
|
slog.Error("thread-bridge reply send failed", "err", sendErr, "tgChat", msg.Chat.ID, "thread", msg.MessageThreadID)
|
||||||
|
|
@ -556,10 +573,6 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
|
||||||
b.tg.SendMessage(ctx, msg.Chat.ID, adminDeniedText, &SendOpts{ThreadID: msg.MessageThreadID})
|
b.tg.SendMessage(ctx, msg.Chat.ID, adminDeniedText, &SendOpts{ThreadID: msg.MessageThreadID})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if msg.MessageThreadID == 0 {
|
|
||||||
b.tg.SendMessage(ctx, msg.Chat.ID, "Отправьте команду внутри треда.", nil)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if b.repo.UnpairThread(msg.Chat.ID, msg.MessageThreadID) {
|
if b.repo.UnpairThread(msg.Chat.ID, msg.MessageThreadID) {
|
||||||
b.tg.SendMessage(ctx, msg.Chat.ID, "Связка треда удалена.", &SendOpts{ThreadID: msg.MessageThreadID})
|
b.tg.SendMessage(ctx, msg.Chat.ID, "Связка треда удалена.", &SendOpts{ThreadID: msg.MessageThreadID})
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -571,25 +584,73 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
|
||||||
// Группа обсуждения канала: ловим авто-форвард поста (строим маппинг
|
// Группа обсуждения канала: ловим авто-форвард поста (строим маппинг
|
||||||
// пост↔тред) и ингестим комменты реальных юзеров в мини-апп (TG→app).
|
// пост↔тред) и ингестим комменты реальных юзеров в мини-апп (TG→app).
|
||||||
if isGroup {
|
if isGroup {
|
||||||
if msg.IsAutomaticForward && msg.ForwardOriginChat != nil && msg.ForwardOriginMsgID != 0 {
|
// Вступления — капча/анти-рейд (аддон сам решает по конфигу).
|
||||||
if err := b.repo.SaveDiscussionMessage(msg.ForwardOriginChat.ID, msg.ForwardOriginMsgID, msg.Chat.ID, msg.MessageID); err != nil {
|
if len(msg.NewChatMembers) > 0 {
|
||||||
slog.Warn("save discussion map failed", "err", err)
|
for _, nm := range msg.NewChatMembers {
|
||||||
|
if !b.isSelfTgBot(&nm) {
|
||||||
|
b.memberJoined(ctx, "tg", msg.Chat.ID, nm.ID, strings.TrimSpace(nm.FirstName+" "+nm.LastName), nm.UserName, nm.IsBot)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
continue // авто-форвард не зеркалим и не модерируем
|
// Удаление служебного «вошёл», если включено владельцем (после капчи/рейда).
|
||||||
|
if b.delServiceMsgs("tg", msg.Chat.ID) {
|
||||||
|
b.tg.DeleteMessage(ctx, msg.Chat.ID, msg.MessageID)
|
||||||
|
}
|
||||||
|
continue // служебное сообщение о вступлении — дальше не обрабатываем
|
||||||
|
}
|
||||||
|
// Прочие служебные сообщения (вышел/смена названия/фото/закреп). Удаляем,
|
||||||
|
// если включено — здесь, внутри isGroup, чтобы работало и в standalone-группах
|
||||||
|
// (ниже стоит `if !linked continue`, который их бы не пропустил к общему хендлеру).
|
||||||
|
if msg.IsService {
|
||||||
|
if b.delServiceMsgs("tg", msg.Chat.ID) {
|
||||||
|
b.tg.DeleteMessage(ctx, msg.Chat.ID, msg.MessageID)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if msg.IsAutomaticForward {
|
||||||
|
// Авто-форвард поста канала в группу обсуждения (корень треда) — НИКОГДА
|
||||||
|
// не модерируем и не зеркалим. Маппинг пост↔тред сохраняем, только если
|
||||||
|
// есть данные origin (иначе просто пропускаем — это контент канала, не спам).
|
||||||
|
if msg.ForwardOriginChat != nil && msg.ForwardOriginMsgID != 0 {
|
||||||
|
if err := b.repo.SaveDiscussionMessage(msg.ForwardOriginChat.ID, msg.ForwardOriginMsgID, msg.Chat.ID, msg.MessageID); err != nil {
|
||||||
|
slog.Warn("save discussion map failed", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
// Антиспам/команда — ДО ингеста коммента. Иначе в группе обсуждения канала
|
// Антиспам/команда — ДО ингеста коммента. Иначе в группе обсуждения канала
|
||||||
// /antispam и модерация комментов не срабатывают (сообщение уходит в continue ниже).
|
// /antispam и модерация комментов не срабатывают (сообщение уходит в continue ниже).
|
||||||
if msg.From != nil && !msg.IsService {
|
// SenderChat != nil — сообщение «от имени канала» (пост связанного канала,
|
||||||
|
// репост канала): это контент канала, а не юзер-спам — не модерируем.
|
||||||
|
if msg.From != nil && !msg.IsService && msg.SenderChat == nil {
|
||||||
mtext := msg.Text
|
mtext := msg.Text
|
||||||
if mtext == "" {
|
if mtext == "" {
|
||||||
mtext = msg.Caption
|
mtext = msg.Caption
|
||||||
}
|
}
|
||||||
|
hasLink := tgMsgHasLink(msg)
|
||||||
|
// Спам через цитату чужого канала (external_reply): чистый текст —
|
||||||
|
// наживка, пейлоад в цитате. Подмешиваем текст цитаты в анализ; сам
|
||||||
|
// факт external_reply — отдельный сильный сигнал (ExtReply).
|
||||||
|
if msg.HasExternalReply {
|
||||||
|
mtext = strings.TrimSpace(mtext + " " + msg.ExternalReplyText)
|
||||||
|
}
|
||||||
if b.moderateGroupMessage(ctx, GroupMessage{
|
if b.moderateGroupMessage(ctx, GroupMessage{
|
||||||
Platform: "tg", ChatID: msg.Chat.ID, UserID: msg.From.ID,
|
Platform: "tg", ChatID: msg.Chat.ID, UserID: msg.From.ID,
|
||||||
TgMsgID: msg.MessageID, Text: mtext, HasLink: tgMsgHasLink(msg), IsAdmin: isAdmin,
|
TgMsgID: msg.MessageID, Text: mtext, HasLink: hasLink, IsAdmin: isAdmin,
|
||||||
|
ExtReply: msg.HasExternalReply,
|
||||||
}) {
|
}) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Модерация картинки (QR + vision). Качаем байты только если антиспам
|
||||||
|
// в чате включён (иначе не трогаем фото впустую).
|
||||||
|
if len(msg.Photo) > 0 && b.wantImageCheck("tg", msg.Chat.ID) {
|
||||||
|
if data, mime, err := b.tgPhotoBytes(ctx, msg.Photo); err == nil && len(data) > 0 {
|
||||||
|
if b.moderateImage(ctx, "tg", msg.Chat.ID, msg.From.ID, msg.MessageID, "", data, mime, isAdmin) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
slog.Warn("antispam: photo download failed", "err", err, "chat", msg.Chat.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if msg.MessageThreadID != 0 && msg.From != nil && !b.isSelfTgBot(msg.From) && !msg.IsService {
|
if msg.MessageThreadID != 0 && msg.From != nil && !b.isSelfTgBot(msg.From) && !msg.IsService {
|
||||||
chCh, chMsg, ok := b.repo.LookupChannelByDiscussion(msg.Chat.ID, msg.MessageThreadID)
|
chCh, chMsg, ok := b.repo.LookupChannelByDiscussion(msg.Chat.ID, msg.MessageThreadID)
|
||||||
|
|
@ -615,12 +676,11 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
|
||||||
|
|
||||||
// (антиспам-модерация и /antispam уже выполнены выше — до ингеста комментов обсуждения)
|
// (антиспам-модерация и /antispam уже выполнены выше — до ингеста комментов обсуждения)
|
||||||
|
|
||||||
// Пересылка. Приоритет у thread-bridge: если сообщение в связанном треде — шлём в его MAX-чат.
|
// Пересылка. Приоритет у thread-bridge (вкл. General, thread_id=0): если этот
|
||||||
|
// тред связан отдельно — шлём в его MAX-чат, иначе фоллбэк на групповую связку.
|
||||||
var maxChatID int64
|
var maxChatID int64
|
||||||
var linked bool
|
var linked bool
|
||||||
if msg.MessageThreadID != 0 {
|
maxChatID, linked = b.repo.GetThreadMaxChat(msg.Chat.ID, msg.MessageThreadID)
|
||||||
maxChatID, linked = b.repo.GetThreadMaxChat(msg.Chat.ID, msg.MessageThreadID)
|
|
||||||
}
|
|
||||||
if !linked {
|
if !linked {
|
||||||
maxChatID, linked = b.repo.GetMaxChat(msg.Chat.ID)
|
maxChatID, linked = b.repo.GetMaxChat(msg.Chat.ID)
|
||||||
}
|
}
|
||||||
|
|
@ -665,7 +725,7 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
go b.forwardTgToMax(ctx, msg, maxChatID, caption, false)
|
go b.forwardTgToMax(ctx, msg, maxChatID, caption, false, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -702,7 +762,7 @@ func (b *Bridge) alreadyDeliveredToMax(tgChatID int64, tgMsgID int) bool {
|
||||||
return ok && mid != ""
|
return ok && mid != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID int64, caption string, isCrosspost bool) {
|
func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID int64, caption string, isCrosspost bool, bypassScreen bool) {
|
||||||
// Дедуп: если это сообщение уже доставлено в MAX — не отправляем повторно.
|
// Дедуп: если это сообщение уже доставлено в MAX — не отправляем повторно.
|
||||||
if b.alreadyDeliveredToMax(msg.Chat.ID, msg.MessageID) {
|
if b.alreadyDeliveredToMax(msg.Chat.ID, msg.MessageID) {
|
||||||
slog.Info("skip duplicate TG→MAX", "tgChat", msg.Chat.ID, "tgMsg", msg.MessageID, "crosspost", isCrosspost)
|
slog.Info("skip duplicate TG→MAX", "tgChat", msg.Chat.ID, "tgMsg", msg.MessageID, "crosspost", isCrosspost)
|
||||||
|
|
@ -711,6 +771,44 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
|
||||||
if b.cbBlocked(maxChatID) {
|
if b.cbBlocked(maxChatID) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Пауза связки — временно не пересылаем (связку не удаляем). Возобновить: /unpause.
|
||||||
|
if isCrosspost {
|
||||||
|
if b.repo.CrosspostPaused(maxChatID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if b.repo.PairPaused(msg.Chat.ID, maxChatID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Дуал-бот: выбираем бота для этого чата один раз и пробрасываем токен в ctx —
|
||||||
|
// все аплоады/отправки этого релея пойдут одним и тем же ботом (тем, кто в чате).
|
||||||
|
ctx = b.withMaxToken(ctx, b.maxTokenFor(ctx, maxChatID))
|
||||||
|
mc := b.maxClientFor(ctx, maxChatID) // SDK-клиент бота этого чата (дуал)
|
||||||
|
|
||||||
|
// Relay-гейт ТОЛЬКО для бридж-ГРУПП: там бот постит сообщение от своего имени и
|
||||||
|
// получает страйк за спам/запрещёнку. Каналы (даже связанные через /bridge) и
|
||||||
|
// кросспосты НЕ трогаем — там бот не отправитель. Проверка синхронная, до доставки.
|
||||||
|
if !isCrosspost && !bypassScreen && isTgGroup(msg.Chat.Type) {
|
||||||
|
// Скриним ТОЛЬКО тело сообщения, без форматированного caption (он содержит имя
|
||||||
|
// автора). Вычурные ники (псевдо-кириллица, эмодзи) травили mixed-script/emoji-junk
|
||||||
|
// и блокировали легит-сообщения — имя автора к контенту отношения не имеет.
|
||||||
|
screenText := msg.Text
|
||||||
|
if msg.Caption != "" {
|
||||||
|
if screenText != "" {
|
||||||
|
screenText += "\n"
|
||||||
|
}
|
||||||
|
screenText += msg.Caption
|
||||||
|
}
|
||||||
|
if block, reason := b.screenRelay(ctx, "tg", msg.Chat.ID, tgUserID(msg), screenText); block {
|
||||||
|
slog.Warn("relay blocked TG→MAX (group): prohibited/spam",
|
||||||
|
"tgChat", msg.Chat.ID, "tgMsg", msg.MessageID, "maxChat", maxChatID, "reason", reason)
|
||||||
|
// Не дропаем тихо: кладём в очередь модерации (карточка с кнопками в чате).
|
||||||
|
// payload — сериализованное сообщение для ре-релея на «Пропустить» (file_id внутри).
|
||||||
|
payload, _ := json.Marshal(msg)
|
||||||
|
b.holdForModeration(ctx, "tg", msg.Chat.ID, strconv.Itoa(msg.MessageID), maxChatID, tgUserID(msg), tgName(msg), screenText, caption, reason, string(payload))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
uid := tgUserID(msg)
|
uid := tgUserID(msg)
|
||||||
|
|
||||||
|
|
@ -758,6 +856,9 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
|
||||||
rawText = msg.Text
|
rawText = msg.Text
|
||||||
}
|
}
|
||||||
mdText := tgEntitiesToHTML(rawText, msg.CaptionEntities)
|
mdText := tgEntitiesToHTML(rawText, msg.CaptionEntities)
|
||||||
|
if fl := tgForwardLine(msg); fl != "" {
|
||||||
|
mdText = fl + mdText
|
||||||
|
}
|
||||||
name := tgName(msg)
|
name := tgName(msg)
|
||||||
if b.hasPrefix("tg", msg.Chat.ID) {
|
if b.hasPrefix("tg", msg.Chat.ID) {
|
||||||
name = "[TG] " + name
|
name = "[TG] " + name
|
||||||
|
|
@ -784,7 +885,7 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else if fileURL, err := b.tgFileURL(ctx, photo.FileID); err == nil {
|
} else if fileURL, err := b.tgFileURL(ctx, photo.FileID); err == nil {
|
||||||
if uploaded, err := b.maxApi.Uploads.UploadPhotoFromUrl(ctx, fileURL); err == nil {
|
if uploaded, err := mc.Uploads.UploadPhotoFromUrl(ctx, fileURL); err == nil {
|
||||||
m.AddPhoto(uploaded)
|
m.AddPhoto(uploaded)
|
||||||
} else {
|
} else {
|
||||||
slog.Error("TG→MAX photo upload failed", "err", err)
|
slog.Error("TG→MAX photo upload failed", "err", err)
|
||||||
|
|
@ -802,10 +903,12 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
slog.Info("TG→MAX sending photo", "uid", uid, "tgChat", msg.Chat.ID, "maxChat", maxChatID)
|
slog.Info("TG→MAX sending photo", "uid", uid, "tgChat", msg.Chat.ID, "maxChat", maxChatID)
|
||||||
result, err := b.maxApi.Messages.SendWithResult(ctx, m)
|
result, err := mc.Messages.SendWithResult(ctx, m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("TG→MAX send failed", "err", err, "uid", uid, "tgChat", msg.Chat.ID, "maxChat", maxChatID)
|
slog.Error("TG→MAX send failed", "err", err, "uid", uid, "tgChat", msg.Chat.ID, "maxChat", maxChatID)
|
||||||
if b.cbFail(maxChatID) {
|
if isChatUnavailable(err.Error()) {
|
||||||
|
b.cbPermanentFail(ctx, maxChatID) // бот недоступен в чате → связка на паузу + DM
|
||||||
|
} else if b.cbFail(maxChatID) {
|
||||||
b.notifyTgUser(ctx, msg, maxChatID,
|
b.notifyTgUser(ctx, msg, maxChatID,
|
||||||
fmt.Sprintf("Не удалось переслать в MAX. Пересылка приостановлена на %d мин. Проверьте, что бот добавлен в MAX-чат и является админом.", int(cbCooldown.Minutes())), isCrosspost)
|
fmt.Sprintf("Не удалось переслать в MAX. Пересылка приостановлена на %d мин. Проверьте, что бот добавлен в MAX-чат и является админом.", int(cbCooldown.Minutes())), isCrosspost)
|
||||||
}
|
}
|
||||||
|
|
@ -849,7 +952,7 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
|
||||||
} else {
|
} else {
|
||||||
// Обычный стикер WebP → отправляем как фото
|
// Обычный стикер WebP → отправляем как фото
|
||||||
if fileURL, err := b.tgFileURL(ctx, msg.Sticker.FileID); err == nil {
|
if fileURL, err := b.tgFileURL(ctx, msg.Sticker.FileID); err == nil {
|
||||||
if uploaded, err := b.maxApi.Uploads.UploadPhotoFromUrl(ctx, fileURL); err == nil {
|
if uploaded, err := mc.Uploads.UploadPhotoFromUrl(ctx, fileURL); err == nil {
|
||||||
m := maxbot.NewMessage().SetChat(maxChatID).SetText(caption)
|
m := maxbot.NewMessage().SetChat(maxChatID).SetText(caption)
|
||||||
m.AddPhoto(uploaded)
|
m.AddPhoto(uploaded)
|
||||||
if msg.ReplyToMessage != nil {
|
if msg.ReplyToMessage != nil {
|
||||||
|
|
@ -861,7 +964,7 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
|
||||||
m.AddKeyboard(kb)
|
m.AddKeyboard(kb)
|
||||||
}
|
}
|
||||||
slog.Info("TG→MAX sending sticker as photo", "uid", uid, "tgChat", msg.Chat.ID)
|
slog.Info("TG→MAX sending sticker as photo", "uid", uid, "tgChat", msg.Chat.ID)
|
||||||
result, err := b.maxApi.Messages.SendWithResult(ctx, m)
|
result, err := mc.Messages.SendWithResult(ctx, m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("TG→MAX sticker send failed", "err", err)
|
slog.Error("TG→MAX sticker send failed", "err", err)
|
||||||
b.notifyTgUser(ctx, msg, maxChatID, uploadErrMsg("Не удалось отправить стикер в MAX", err), isCrosspost)
|
b.notifyTgUser(ctx, msg, maxChatID, uploadErrMsg("Не удалось отправить стикер в MAX", err), isCrosspost)
|
||||||
|
|
@ -1012,6 +1115,15 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
|
||||||
mdText = tgEntitiesToHTML(rawText, entities)
|
mdText = tgEntitiesToHTML(rawText, entities)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Контакт (sharing телефона) — текста нет, иначе зеркало пустое.
|
||||||
|
if mdText == "" && msg.Contact != nil {
|
||||||
|
mdText = tgContactText(msg)
|
||||||
|
}
|
||||||
|
// Метка репоста: «↪️ Переслано из X» перед телом.
|
||||||
|
if fl := tgForwardLine(msg); fl != "" {
|
||||||
|
mdText = fl + mdText
|
||||||
|
}
|
||||||
|
|
||||||
// Fallback: медиа было, но не загрузилось (либо не хватило места, либо Bot API
|
// Fallback: медиа было, но не загрузилось (либо не хватило места, либо Bot API
|
||||||
// не смог его скачать). Подмешиваем маркер типа, чтобы хотя бы текст прошёл.
|
// не смог его скачать). Подмешиваем маркер типа, чтобы хотя бы текст прошёл.
|
||||||
hasMedia := msg.Video != nil || msg.VideoNote != nil || msg.Document != nil ||
|
hasMedia := msg.Video != nil || msg.VideoNote != nil || msg.Document != nil ||
|
||||||
|
|
@ -1088,11 +1200,19 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
|
||||||
if sendErr != nil {
|
if sendErr != nil {
|
||||||
errStr := sendErr.Error()
|
errStr := sendErr.Error()
|
||||||
slog.Error("TG→MAX send failed", "err", errStr, "uid", uid, "tgChat", msg.Chat.ID, "maxChat", maxChatID)
|
slog.Error("TG→MAX send failed", "err", errStr, "uid", uid, "tgChat", msg.Chat.ID, "maxChat", maxChatID)
|
||||||
|
b.noteMaxSendErr(errStr) // глобальный бан аккаунта MAX → глушим уведомления
|
||||||
|
// chat.not.found / chat.denied — перманентно: бота нет в MAX-группе или он не
|
||||||
|
// админ. НЕ ретраим и сообщаем владельцу ТОЧНО (а не «MAX недоступен/в очереди»).
|
||||||
|
notInChat := strings.Contains(errStr, "chat.not.found") || strings.Contains(errStr, "chat.denied")
|
||||||
// 403/404 — permanent error, не ретраим
|
// 403/404 — permanent error, не ретраим
|
||||||
if !strings.Contains(errStr, "403") && !strings.Contains(errStr, "404") && !strings.Contains(errStr, "chat.denied") {
|
if !strings.Contains(errStr, "403") && !strings.Contains(errStr, "404") && !strings.Contains(errStr, "chat.denied") {
|
||||||
b.enqueueTg2Max(msg.Chat.ID, msg.MessageID, maxChatID, mdCaption, mediaAttType, mediaToken, replyTo, format)
|
b.enqueueTg2Max(msg.Chat.ID, msg.MessageID, maxChatID, mdCaption, mediaAttType, mediaToken, replyTo, format)
|
||||||
}
|
}
|
||||||
if b.cbFail(maxChatID) {
|
if notInChat {
|
||||||
|
// Бот недоступен в MAX-чате (удалён/не админ) — ставим связку на паузу
|
||||||
|
// и DM-им владельцу (один раз, по переходу unpaused→paused).
|
||||||
|
b.cbPermanentFail(ctx, maxChatID)
|
||||||
|
} else if b.cbFail(maxChatID) {
|
||||||
b.notifyTgUser(ctx, msg, maxChatID,
|
b.notifyTgUser(ctx, msg, maxChatID,
|
||||||
"MAX API недоступен. Сообщения в очереди, будут доставлены автоматически.", isCrosspost)
|
"MAX API недоступен. Сообщения в очереди, будут доставлены автоматически.", isCrosspost)
|
||||||
}
|
}
|
||||||
|
|
@ -1219,7 +1339,7 @@ func (b *Bridge) editTgMediaInMax(ctx context.Context, msg *TGMessage, maxChatID
|
||||||
}
|
}
|
||||||
// Стикеры/VideoNote редактирование подписи не поддерживают со стороны TG, поэтому пропускаем.
|
// Стикеры/VideoNote редактирование подписи не поддерживают со стороны TG, поэтому пропускаем.
|
||||||
|
|
||||||
if err := b.maxApi.Messages.EditMessage(ctx, maxMsgID, m); err != nil {
|
if err := b.maxClientFor(ctx, maxChatID).Messages.EditMessage(ctx, maxMsgID, m); err != nil {
|
||||||
slog.Error("TG→MAX edit media failed", "err", err, "uid", uid, "tgChat", msg.Chat.ID, "maxMsgID", maxMsgID)
|
slog.Error("TG→MAX edit media failed", "err", err, "uid", uid, "tgChat", msg.Chat.ID, "maxMsgID", maxMsgID)
|
||||||
} else {
|
} else {
|
||||||
slog.Info("TG→MAX edited media", "mid", maxMsgID, "uid", uid, "tgChat", msg.Chat.ID)
|
slog.Info("TG→MAX edited media", "mid", maxMsgID, "uid", uid, "tgChat", msg.Chat.ID)
|
||||||
|
|
@ -1259,6 +1379,14 @@ func (b *Bridge) handleTgChannelPost(ctx context.Context, msg *TGMessage) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Идемпотентность: этот пост канала уже кросспостился (повторная доставка вебхука /
|
||||||
|
// дубль-строка связки / перекрытие) — атомарный claim по (канал, msg_id) в БД.
|
||||||
|
// Уже застолблён → не кросспостим повторно. Старые записи чистятся по TTL.
|
||||||
|
if !b.repo.ClaimCrosspost("tg", msg.Chat.ID, strconv.Itoa(msg.MessageID)) {
|
||||||
|
slog.Info("skip duplicate crosspost TG→MAX (already claimed)", "tgChannel", msg.Chat.ID, "tgMsg", msg.MessageID, "maxChat", maxChatID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
go b.publishTgCrosspost(ctx, msg, maxChatID)
|
go b.publishTgCrosspost(ctx, msg, maxChatID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1295,7 +1423,7 @@ func (b *Bridge) publishTgCrosspost(ctx context.Context, msg *TGMessage, maxChat
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
b.forwardTgToMax(ctx, msg, maxChatID, caption, true)
|
b.forwardTgToMax(ctx, msg, maxChatID, caption, true, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleTgCallback обрабатывает нажатия inline-кнопок (crosspost management).
|
// handleTgCallback обрабатывает нажатия inline-кнопок (crosspost management).
|
||||||
|
|
@ -1316,6 +1444,11 @@ func (b *Bridge) handleTgCallback(ctx context.Context, query *TGCallback) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Навигация по меню /start (help:*) — редактируем то же сообщение.
|
||||||
|
if b.handleHelpMenuCallback(ctx, query, data) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// cpd:dir:maxChatID — change direction
|
// cpd:dir:maxChatID — change direction
|
||||||
if strings.HasPrefix(data, "cpd:") {
|
if strings.HasPrefix(data, "cpd:") {
|
||||||
parts := strings.SplitN(data, ":", 3)
|
parts := strings.SplitN(data, ":", 3)
|
||||||
|
|
@ -1694,7 +1827,7 @@ func (b *Bridge) handleTgEditedChannelPost(ctx context.Context, edited *TGMessag
|
||||||
}
|
}
|
||||||
|
|
||||||
m := maxbot.NewMessage().SetChat(maxChatID).SetText(text)
|
m := maxbot.NewMessage().SetChat(maxChatID).SetText(text)
|
||||||
if err := b.maxApi.Messages.EditMessage(ctx, maxMsgID, m); err != nil {
|
if err := b.maxClientFor(ctx, maxChatID).Messages.EditMessage(ctx, maxMsgID, m); err != nil {
|
||||||
slog.Error("TG→MAX crosspost edit failed", "err", err)
|
slog.Error("TG→MAX crosspost edit failed", "err", err)
|
||||||
} else {
|
} else {
|
||||||
slog.Info("TG→MAX crosspost edited", "mid", maxMsgID)
|
slog.Info("TG→MAX crosspost edited", "mid", maxMsgID)
|
||||||
|
|
|
||||||
22
tgsender.go
22
tgsender.go
|
|
@ -26,6 +26,14 @@ type PhotoSize struct {
|
||||||
FileSize int
|
FileSize int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ContactInfo — контакт (sharing телефона).
|
||||||
|
type ContactInfo struct {
|
||||||
|
PhoneNumber string
|
||||||
|
FirstName string
|
||||||
|
LastName string
|
||||||
|
UserID int64
|
||||||
|
}
|
||||||
|
|
||||||
type FileInfo struct {
|
type FileInfo struct {
|
||||||
FileID string
|
FileID string
|
||||||
FileName string
|
FileName string
|
||||||
|
|
@ -77,6 +85,7 @@ type TGMessage struct {
|
||||||
MediaGroupID string
|
MediaGroupID string
|
||||||
ReplyToMessage *TGMessage
|
ReplyToMessage *TGMessage
|
||||||
ForwardOriginChat *ChatInfo // replaces ForwardFromChat, from forward_origin
|
ForwardOriginChat *ChatInfo // replaces ForwardFromChat, from forward_origin
|
||||||
|
ForwardFrom string // отображаемое имя источника форварда (канал/группа/юзер/скрытый), "" если не форвард
|
||||||
ForwardOriginMsgID int // msg_id оригинала в канале-источнике (для старта импорта с поста)
|
ForwardOriginMsgID int // msg_id оригинала в канале-источнике (для старта импорта с поста)
|
||||||
ForwardOriginDate int // дата оригинала (unix) — у постов одного альбома совпадает
|
ForwardOriginDate int // дата оригинала (unix) — у постов одного альбома совпадает
|
||||||
MigrateToChatID int64
|
MigrateToChatID int64
|
||||||
|
|
@ -88,6 +97,15 @@ type TGMessage struct {
|
||||||
// IsAutomaticForward — авто-форвард поста канала в его группу обсуждения
|
// IsAutomaticForward — авто-форвард поста канала в его группу обсуждения
|
||||||
// (Telegram ставит is_automatic_forward). По нему строим маппинг пост↔тред.
|
// (Telegram ставит is_automatic_forward). По нему строим маппинг пост↔тред.
|
||||||
IsAutomaticForward bool
|
IsAutomaticForward bool
|
||||||
|
// HasExternalReply — сообщение цитирует пост из ДРУГОГО чата/канала (external_reply).
|
||||||
|
// Частый приём спама: чистая наживка + цитата скам-канала со ссылкой (текст чистый,
|
||||||
|
// пейлоад в цитате). ExternalReplyText — текст цитаты + название источника + ссылка.
|
||||||
|
HasExternalReply bool
|
||||||
|
ExternalReplyText string
|
||||||
|
// NewChatMembers — вступившие в группу (для капчи/анти-рейда).
|
||||||
|
NewChatMembers []UserInfo
|
||||||
|
// Contact — пересланный/отправленный контакт (телефон). Без текста — иначе зеркало пустое.
|
||||||
|
Contact *ContactInfo
|
||||||
// ChatShared — пользователь выбрал чат через нативную кнопку KeyboardButtonRequestChat
|
// ChatShared — пользователь выбрал чат через нативную кнопку KeyboardButtonRequestChat
|
||||||
// (флоу выбора группы из лички). Содержит chat_id выбранной группы.
|
// (флоу выбора группы из лички). Содержит chat_id выбранной группы.
|
||||||
ChatShared *ChatSharedInfo
|
ChatShared *ChatSharedInfo
|
||||||
|
|
@ -230,8 +248,12 @@ type TGSender interface {
|
||||||
GetChatMember(ctx context.Context, chatID, userID int64) (status string, err error)
|
GetChatMember(ctx context.Context, chatID, userID int64) (status string, err error)
|
||||||
// RestrictChatMember мутит участника (запрет писать) до untilUnix (0 = навсегда).
|
// RestrictChatMember мутит участника (запрет писать) до untilUnix (0 = навсегда).
|
||||||
RestrictChatMember(ctx context.Context, chatID, userID int64, untilUnix int) error
|
RestrictChatMember(ctx context.Context, chatID, userID int64, untilUnix int) error
|
||||||
|
// UnrestrictChatMember снимает мут (возвращает базовые права писать) — для капчи.
|
||||||
|
UnrestrictChatMember(ctx context.Context, chatID, userID int64) error
|
||||||
// BanChatMember банит участника и удаляет его сообщения.
|
// BanChatMember банит участника и удаляет его сообщения.
|
||||||
BanChatMember(ctx context.Context, chatID, userID int64) error
|
BanChatMember(ctx context.Context, chatID, userID int64) error
|
||||||
|
// KickChatMember выгоняет участника с возможностью вернуться (ban+unban) — для капчи-таймаута.
|
||||||
|
KickChatMember(ctx context.Context, chatID, userID int64) error
|
||||||
SetMyCommands(ctx context.Context, commands []BotCommand, scope *CommandScope) error
|
SetMyCommands(ctx context.Context, commands []BotCommand, scope *CommandScope) error
|
||||||
SetMyDescription(ctx context.Context, description string) error
|
SetMyDescription(ctx context.Context, description string) error
|
||||||
SetMenuButtonWebApp(ctx context.Context, text, url string) error
|
SetMenuButtonWebApp(ctx context.Context, text, url string) error
|
||||||
|
|
|
||||||
154
tgsender_impl.go
154
tgsender_impl.go
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-telegram/bot"
|
"github.com/go-telegram/bot"
|
||||||
"github.com/go-telegram/bot/models"
|
"github.com/go-telegram/bot/models"
|
||||||
|
|
@ -93,7 +94,12 @@ func (s *tgBotSender) SendMessage(ctx context.Context, chatID int64, text string
|
||||||
Text: text,
|
Text: text,
|
||||||
}
|
}
|
||||||
applySendMessageOpts(p, opts)
|
applySendMessageOpts(p, opts)
|
||||||
msg, err := s.b.SendMessage(ctx, p)
|
var msg *models.Message
|
||||||
|
err := retryOn429(ctx, func() error {
|
||||||
|
var e error
|
||||||
|
msg, e = s.b.SendMessage(ctx, p)
|
||||||
|
return e
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, wrapErr(err)
|
return 0, wrapErr(err)
|
||||||
}
|
}
|
||||||
|
|
@ -153,14 +159,7 @@ func (s *tgBotSender) SendDocument(ctx context.Context, chatID int64, file FileA
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *tgBotSender) SendMediaGroup(ctx context.Context, chatID int64, media []TGInputMedia, opts *SendOpts) ([]int, error) {
|
func (s *tgBotSender) SendMediaGroup(ctx context.Context, chatID int64, media []TGInputMedia, opts *SendOpts) ([]int, error) {
|
||||||
items := make([]models.InputMedia, 0, len(media))
|
p := &bot.SendMediaGroupParams{ChatID: chatID}
|
||||||
for _, m := range media {
|
|
||||||
items = append(items, toLibInputMedia(m))
|
|
||||||
}
|
|
||||||
p := &bot.SendMediaGroupParams{
|
|
||||||
ChatID: chatID,
|
|
||||||
Media: items,
|
|
||||||
}
|
|
||||||
if opts != nil {
|
if opts != nil {
|
||||||
if opts.ThreadID != 0 {
|
if opts.ThreadID != 0 {
|
||||||
p.MessageThreadID = opts.ThreadID
|
p.MessageThreadID = opts.ThreadID
|
||||||
|
|
@ -169,7 +168,24 @@ func (s *tgBotSender) SendMediaGroup(ctx context.Context, chatID int64, media []
|
||||||
p.ReplyParameters = &models.ReplyParameters{MessageID: opts.ReplyToID}
|
p.ReplyParameters = &models.ReplyParameters{MessageID: opts.ReplyToID}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
msgs, err := s.b.SendMediaGroup(ctx, p)
|
var msgs []*models.Message
|
||||||
|
err := retryOn429(ctx, func() error {
|
||||||
|
// items пересобираем на каждой попытке: у upload-байтов io.Reader одноразовый.
|
||||||
|
items := make([]models.InputMedia, 0, len(media))
|
||||||
|
for i, m := range media {
|
||||||
|
// Уникальное имя attach:// на элемент. Иначе (одинаковое/пустое имя у всех
|
||||||
|
// байтовых аплоадов, напр. фото с MAX-CDN) multipart-парты схлопываются и
|
||||||
|
// ВЕСЬ альбом становится первым фото. Только для upload (у URL/file_id имя не нужно).
|
||||||
|
if len(m.File.Bytes) > 0 {
|
||||||
|
m.File.Name = fmt.Sprintf("m%d_%s", i, m.File.Name)
|
||||||
|
}
|
||||||
|
items = append(items, toLibInputMedia(m))
|
||||||
|
}
|
||||||
|
p.Media = items
|
||||||
|
var e error
|
||||||
|
msgs, e = s.b.SendMediaGroup(ctx, p)
|
||||||
|
return e
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, wrapErr(err)
|
return nil, wrapErr(err)
|
||||||
}
|
}
|
||||||
|
|
@ -274,6 +290,28 @@ func (s *tgBotSender) RestrictChatMember(ctx context.Context, chatID, userID int
|
||||||
return wrapErr(err)
|
return wrapErr(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UnrestrictChatMember возвращает базовые права писать (снятие мута капчи).
|
||||||
|
func (s *tgBotSender) UnrestrictChatMember(ctx context.Context, chatID, userID int64) error {
|
||||||
|
_, err := s.b.RestrictChatMember(ctx, &bot.RestrictChatMemberParams{
|
||||||
|
ChatID: chatID,
|
||||||
|
UserID: userID,
|
||||||
|
Permissions: &models.ChatPermissions{
|
||||||
|
CanSendMessages: true,
|
||||||
|
CanSendAudios: true,
|
||||||
|
CanSendDocuments: true,
|
||||||
|
CanSendPhotos: true,
|
||||||
|
CanSendVideos: true,
|
||||||
|
CanSendVideoNotes: true,
|
||||||
|
CanSendVoiceNotes: true,
|
||||||
|
CanSendPolls: true,
|
||||||
|
CanSendOtherMessages: true,
|
||||||
|
CanAddWebPagePreviews: true,
|
||||||
|
CanInviteUsers: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return wrapErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *tgBotSender) BanChatMember(ctx context.Context, chatID, userID int64) error {
|
func (s *tgBotSender) BanChatMember(ctx context.Context, chatID, userID int64) error {
|
||||||
_, err := s.b.BanChatMember(ctx, &bot.BanChatMemberParams{
|
_, err := s.b.BanChatMember(ctx, &bot.BanChatMemberParams{
|
||||||
ChatID: chatID,
|
ChatID: chatID,
|
||||||
|
|
@ -283,6 +321,19 @@ func (s *tgBotSender) BanChatMember(ctx context.Context, chatID, userID int64) e
|
||||||
return wrapErr(err)
|
return wrapErr(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KickChatMember — выгнать с возможностью вернуться (ban + сразу unban).
|
||||||
|
func (s *tgBotSender) KickChatMember(ctx context.Context, chatID, userID int64) error {
|
||||||
|
if _, err := s.b.BanChatMember(ctx, &bot.BanChatMemberParams{
|
||||||
|
ChatID: chatID, UserID: userID, RevokeMessages: true,
|
||||||
|
}); err != nil {
|
||||||
|
return wrapErr(err)
|
||||||
|
}
|
||||||
|
_, err := s.b.UnbanChatMember(ctx, &bot.UnbanChatMemberParams{
|
||||||
|
ChatID: chatID, UserID: userID, OnlyIfBanned: true,
|
||||||
|
})
|
||||||
|
return wrapErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *tgBotSender) SetMyCommands(ctx context.Context, commands []BotCommand, scope *CommandScope) error {
|
func (s *tgBotSender) SetMyCommands(ctx context.Context, commands []BotCommand, scope *CommandScope) error {
|
||||||
cmds := make([]models.BotCommand, len(commands))
|
cmds := make([]models.BotCommand, len(commands))
|
||||||
for i, c := range commands {
|
for i, c := range commands {
|
||||||
|
|
@ -533,6 +584,36 @@ func applySendDocumentOpts(p *bot.SendDocumentParams, opts *SendOpts) {
|
||||||
|
|
||||||
// --- Error wrapping ---
|
// --- Error wrapping ---
|
||||||
|
|
||||||
|
// retryOn429 выполняет send, повторяя при 429 с учётом retry_after (Telegram при
|
||||||
|
// массовом постинге лимитит ~20 сообщений/мин на чат). send пересобирает параметры
|
||||||
|
// внутри себя, если в них есть одноразовые io.Reader (upload-байты media group).
|
||||||
|
func retryOn429(ctx context.Context, send func() error) error {
|
||||||
|
const maxTries = 4
|
||||||
|
for attempt := 0; ; attempt++ {
|
||||||
|
err := send()
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var tmr *bot.TooManyRequestsError
|
||||||
|
if !errors.As(err, &tmr) || attempt >= maxTries {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
wait := time.Duration(tmr.RetryAfter)*time.Second + 300*time.Millisecond
|
||||||
|
if wait <= 0 {
|
||||||
|
wait = time.Second
|
||||||
|
}
|
||||||
|
if wait > 30*time.Second {
|
||||||
|
wait = 30 * time.Second
|
||||||
|
}
|
||||||
|
slog.Warn("TG 429 rate-limit, backing off", "retryAfter", tmr.RetryAfter, "attempt", attempt+1)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(wait):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func wrapErr(err error) error {
|
func wrapErr(err error) error {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -640,6 +721,21 @@ func convertMsg(m *models.Message) *TGMessage {
|
||||||
msg.ForwardOriginDate = m.ForwardOrigin.MessageOriginChannel.Date
|
msg.ForwardOriginDate = m.ForwardOrigin.MessageOriginChannel.Date
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Имя источника форварда (для метки «Переслано из X») — все типы origin.
|
||||||
|
if m.ForwardOrigin != nil {
|
||||||
|
switch {
|
||||||
|
case m.ForwardOrigin.MessageOriginChannel != nil:
|
||||||
|
msg.ForwardFrom = m.ForwardOrigin.MessageOriginChannel.Chat.Title
|
||||||
|
case m.ForwardOrigin.MessageOriginChat != nil:
|
||||||
|
msg.ForwardFrom = m.ForwardOrigin.MessageOriginChat.SenderChat.Title
|
||||||
|
case m.ForwardOrigin.MessageOriginUser != nil:
|
||||||
|
u := m.ForwardOrigin.MessageOriginUser.SenderUser
|
||||||
|
msg.ForwardFrom = strings.TrimSpace(u.FirstName + " " + u.LastName)
|
||||||
|
case m.ForwardOrigin.MessageOriginHiddenUser != nil:
|
||||||
|
msg.ForwardFrom = m.ForwardOrigin.MessageOriginHiddenUser.SenderUserName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Photo
|
// Photo
|
||||||
for _, p := range m.Photo {
|
for _, p := range m.Photo {
|
||||||
msg.Photo = append(msg.Photo, PhotoSize{
|
msg.Photo = append(msg.Photo, PhotoSize{
|
||||||
|
|
@ -674,6 +770,44 @@ func convertMsg(m *models.Message) *TGMessage {
|
||||||
msg.ReplyToMessage = convertMsg(m.ReplyToMessage)
|
msg.ReplyToMessage = convertMsg(m.ReplyToMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, u := range m.NewChatMembers {
|
||||||
|
msg.NewChatMembers = append(msg.NewChatMembers, UserInfo{
|
||||||
|
ID: u.ID, IsBot: u.IsBot, UserName: u.Username, FirstName: u.FirstName, LastName: u.LastName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.Contact != nil {
|
||||||
|
msg.Contact = &ContactInfo{
|
||||||
|
PhoneNumber: m.Contact.PhoneNumber,
|
||||||
|
FirstName: m.Contact.FirstName,
|
||||||
|
LastName: m.Contact.LastName,
|
||||||
|
UserID: m.Contact.UserID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Цитата на сообщение из другого чата/канала (external_reply) — приём спама.
|
||||||
|
// Собираем пейлоад (цитата + название источника + ссылка) для антиспама.
|
||||||
|
if m.Quote != nil {
|
||||||
|
msg.ExternalReplyText = m.Quote.Text
|
||||||
|
}
|
||||||
|
if m.ExternalReply != nil {
|
||||||
|
msg.HasExternalReply = true
|
||||||
|
parts := []string{msg.ExternalReplyText}
|
||||||
|
if m.ExternalReply.Chat != nil && m.ExternalReply.Chat.Title != "" {
|
||||||
|
parts = append(parts, m.ExternalReply.Chat.Title)
|
||||||
|
}
|
||||||
|
if ch := m.ExternalReply.Origin.MessageOriginChannel; ch != nil {
|
||||||
|
parts = append(parts, ch.Chat.Title)
|
||||||
|
if ch.AuthorSignature != nil {
|
||||||
|
parts = append(parts, *ch.AuthorSignature)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lp := m.ExternalReply.LinkPreviewOptions; lp != nil && lp.URL != nil {
|
||||||
|
parts = append(parts, *lp.URL)
|
||||||
|
}
|
||||||
|
msg.ExternalReplyText = strings.TrimSpace(strings.Join(parts, " "))
|
||||||
|
}
|
||||||
|
|
||||||
for _, e := range m.Entities {
|
for _, e := range m.Entities {
|
||||||
msg.Entities = append(msg.Entities, Entity{Type: string(e.Type), Offset: e.Offset, Length: e.Length, URL: e.URL})
|
msg.Entities = append(msg.Entities, Entity{Type: string(e.Type), Offset: e.Offset, Length: e.Length, URL: e.URL})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
64
upload.go
64
upload.go
|
|
@ -90,7 +90,7 @@ func (b *Bridge) customUploadToMax(ctx context.Context, uploadType maxschemes.Up
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create request: %w", err)
|
return nil, fmt.Errorf("create request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Authorization", b.cfg.MaxToken)
|
req.Header.Set("Authorization", b.maxTokenCtxOr(ctx)) // дуал-бот: токен релея (ctx) или основной
|
||||||
|
|
||||||
resp, err := b.apiClient.Do(req)
|
resp, err := b.apiClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -180,6 +180,39 @@ func (b *Bridge) customUploadToMax(ctx context.Context, uploadType maxschemes.Up
|
||||||
return nil, fmt.Errorf("no token in CDN response: %s", string(cdnBody))
|
return nil, fmt.Errorf("no token in CDN response: %s", string(cdnBody))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tgPhotoBytes скачивает байты самого крупного варианта фото (для модерации картинки —
|
||||||
|
// декод QR и vision). Возвращает данные + mime. Берём последний PhotoSize (наибольший).
|
||||||
|
func (b *Bridge) tgPhotoBytes(ctx context.Context, photos []PhotoSize) ([]byte, string, error) {
|
||||||
|
if len(photos) == 0 {
|
||||||
|
return nil, "", nil
|
||||||
|
}
|
||||||
|
// Жёсткий таймаут: модерация в последовательном цикле апдейтов — нельзя залипнуть
|
||||||
|
// (обычный downloadURL живёт 5 мин). 10с хватает на фото, при зависании не стопаем бот.
|
||||||
|
dctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
fileURL, err := b.tgFileURL(dctx, photos[len(photos)-1].FileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("tg getFileURL: %w", err)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(dctx, http.MethodGet, fileURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
resp, err := b.apiClient.Do(req) // apiClient: 15с таймаут (а не 5 мин)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, "", fmt.Errorf("tg download status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) // лимит 16MB
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return data, "image/jpeg", nil
|
||||||
|
}
|
||||||
|
|
||||||
// uploadTgPhotoToMax скачивает фото из TG и загружает в MAX (возвращает PhotoTokens).
|
// uploadTgPhotoToMax скачивает фото из TG и загружает в MAX (возвращает PhotoTokens).
|
||||||
// Загрузка — в обход SDK (наш customUploadPhotoToMax): SDK добавляет к /uploads
|
// Загрузка — в обход SDK (наш customUploadPhotoToMax): SDK добавляет к /uploads
|
||||||
// устаревший параметр v=1.2.5, из-за которого MAX отдаёт 404 (как было с видео).
|
// устаревший параметр v=1.2.5, из-за которого MAX отдаёт 404 (как было с видео).
|
||||||
|
|
@ -213,7 +246,7 @@ func (b *Bridge) customUploadPhotoToMax(ctx context.Context, reader io.Reader, f
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create request: %w", err)
|
return nil, fmt.Errorf("create request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Authorization", b.cfg.MaxToken)
|
req.Header.Set("Authorization", b.maxTokenCtxOr(ctx)) // дуал-бот: токен релея (ctx) или основной
|
||||||
resp, err := b.apiClient.Do(req)
|
resp, err := b.apiClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get upload url: %w", err)
|
return nil, fmt.Errorf("get upload url: %w", err)
|
||||||
|
|
@ -448,9 +481,14 @@ func (b *Bridge) sendMaxChunk(ctx context.Context, chatID int64, text string, at
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Токен отправки фиксируем до цикла: дуал-бот: ctx-токен релея или по членству.
|
||||||
|
sendTok := b.maxTokenForSend(ctx, chatID)
|
||||||
|
triedAlt := false // пробовали ли уже второго бота (fallback при chat.not.found)
|
||||||
|
flipped := false // только что переключили бота — повторяем без задержки
|
||||||
|
|
||||||
// Retry при attachment.not.ready (файл ещё обрабатывается)
|
// Retry при attachment.not.ready (файл ещё обрабатывается)
|
||||||
for attempt := 0; attempt < 20; attempt++ {
|
for attempt := 0; attempt < 20; attempt++ {
|
||||||
if attempt > 0 {
|
if attempt > 0 && !flipped {
|
||||||
delay := time.Duration(3+attempt*2) * time.Second
|
delay := time.Duration(3+attempt*2) * time.Second
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
|
|
@ -459,12 +497,13 @@ func (b *Bridge) sendMaxChunk(ctx context.Context, chatID int64, text string, at
|
||||||
}
|
}
|
||||||
slog.Warn("MAX retry", "attempt", attempt+1, "maxAttempts", 20)
|
slog.Warn("MAX retry", "attempt", attempt+1, "maxAttempts", 20)
|
||||||
}
|
}
|
||||||
|
flipped = false
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
req.Header.Set("Authorization", b.cfg.MaxToken)
|
req.Header.Set("Authorization", sendTok)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
resp, err := b.apiClient.Do(req)
|
resp, err := b.apiClient.Do(req)
|
||||||
|
|
@ -486,6 +525,7 @@ func (b *Bridge) sendMaxChunk(ctx context.Context, chatID int64, text string, at
|
||||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
b.markChatBot(chatID, sendTok) // запомним рабочего бота для будущих отправок
|
||||||
return result.Message.Body.Mid, nil
|
return result.Message.Body.Mid, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -495,7 +535,21 @@ func (b *Bridge) sendMaxChunk(ctx context.Context, chatID int64, text string, at
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", fmt.Errorf("MAX API %d: %s", resp.StatusCode, string(respBody))
|
// Дуал-бот fallback: бот не в чате (chat.not.found/chat.denied) — пробуем второго
|
||||||
|
// бота и чиним кэш роутинга (бота могли добавить позже / первая проверка промахнулась).
|
||||||
|
bodyStr := string(respBody)
|
||||||
|
if !triedAlt && (strings.Contains(bodyStr, "chat.not.found") || strings.Contains(bodyStr, "chat.denied")) {
|
||||||
|
if alt := b.altMaxToken(sendTok); alt != "" && alt != sendTok {
|
||||||
|
slog.Warn("MAX chat.not.found — пробую другого бота", "maxChat", chatID)
|
||||||
|
sendTok = alt
|
||||||
|
triedAlt = true
|
||||||
|
flipped = true
|
||||||
|
b.markChatBot(chatID, alt) // future-отправки сразу пойдут рабочим
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("MAX API %d: %s", resp.StatusCode, bodyStr)
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("MAX attachment not ready after 10 retries")
|
return "", fmt.Errorf("MAX attachment not ready after 10 retries")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue