mirror of
https://github.com/BEARlogin/max-telegram-bridge-bot.git
synced 2026-07-09 17:18:37 +00:00
Some checks failed
Build / build (push) Has been cancelled
- MAX→TG album: caption over Telegram's 1024 media-group limit is no longer lost — the album is sent without an inline caption and the full text follows as a separate message (sendMaxAlbumToTg, covers both direct and queued delivery). - Forwarded text: strip Telegram custom_emoji (MAX can't render them; the fallback glyph only littered the copy) in tgEntitiesToHTML, and collapse whitespace padding (runs of spaces/nbsp, trailing spaces, 3+ blank lines) in crosspost captions — cuts bloated length (MAX ~4000 char limit) and fixes ugly gaps. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ajcn7YhgeexPwbfrUNMj8u
193 lines
14 KiB
Go
193 lines
14 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
)
|
||
|
||
// Addon — опциональная точка расширения бриджа дополнительными командами.
|
||
// В публичной сборке отсутствует (loadAddon возвращает nil); реализация
|
||
// подключается отдельной сборкой через build-tag `addon`.
|
||
type Addon interface {
|
||
Start(ctx context.Context) error
|
||
HandleDMCommand(ctx context.Context, userID, chatID int64, text string) (handled bool)
|
||
// HandleDMForward вызывается, когда юзер пересылает в личку пост из канала.
|
||
// sourceMsgID — msg_id оригинала в канале (0 если неизвестен); используется,
|
||
// чтобы стартовать импорт с конкретного поста. Возвращает true если обработано.
|
||
HandleDMForward(ctx context.Context, userID, dmChatID, sourceChatID int64, sourceTitle string, sourceMsgID int) (handled bool)
|
||
// HandleCallback вызывается на нажатие inline-кнопки. Аддон проверяет, его ли
|
||
// это callback (по своему префиксу), и возвращает true если обработал. msgID —
|
||
// id сообщения с нажатой кнопкой (чтобы аддон мог его удалить).
|
||
HandleCallback(ctx context.Context, userID, chatID int64, callbackID, data string, msgID int) (handled bool)
|
||
// CrosspostButton вызывается ПЕРЕД отправкой кросспоста в MAX. Аддон решает,
|
||
// нужна ли inline-кнопка мини-аппа под этим постом (например, комментарии), и
|
||
// возвращает её спецификацию. ok=false ⇒ кнопки нет. Generic: ядро не знает
|
||
// семантику, просто навешивает OPEN_APP-кнопку на сообщение, если аддон попросил.
|
||
CrosspostButton(ctx context.Context, ownerID, tgChatID int64, tgMsgID int, maxChatID int64) (appName, text, payload string, ok bool)
|
||
// Moderate вызывается на входящее групповое сообщение (TG или MAX). Generic-хук:
|
||
// бридж не знает семантику (для него это «проверь сообщение»), аддон сам решает
|
||
// и исполняет действие через Deps. Параметры — примитивы (а не struct), чтобы
|
||
// сигнатура совпадала между main.Addon и приватным пакетом аддона (Go требует
|
||
// идентичности типов в методах интерфейса). Возвращает true, если сообщение
|
||
// обработано как нежелательное (удалено) — ядру для остановки дальнейшей пересылки.
|
||
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 ⇒
|
||
// ядро не создаёт связку и показывает reason. Generic: ядро не знает семантику решения.
|
||
CrosspostAllowed(ctx context.Context, ownerMaxID, ownerTgID int64) (ok bool, reason string)
|
||
// CrosspostDeliverable вызывается ПЕРЕД доставкой поста кросспоста: deliver=false ⇒
|
||
// ядро молча пропускает доставку. Generic: ядро не знает семантику решения.
|
||
CrosspostDeliverable(ctx context.Context, ownerMaxID, ownerTgID, maxChatID int64) (deliver bool)
|
||
// HandleMaxCommand — команда из MAX-диалога. true ⇒ обработано аддоном.
|
||
HandleMaxCommand(ctx context.Context, userID, chatID int64, text string) (handled bool)
|
||
// HandleChatShared — пользователь выбрал чат нативной кнопкой (chat_shared в личке).
|
||
// requestID эхо запроса, sharedChatID — выбранный чат. Generic: ядро не знает семантику.
|
||
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 читаемость).
|
||
type GroupMessage struct {
|
||
Platform string // "tg" | "max"
|
||
ChatID int64
|
||
UserID int64
|
||
TgMsgID int
|
||
MaxMid string
|
||
Text string
|
||
HasLink 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 прогоняет сообщение через аддон-модерацию (если подключён).
|
||
func (b *Bridge) moderateGroupMessage(ctx context.Context, m GroupMessage) bool {
|
||
if b.addon == nil {
|
||
return false
|
||
}
|
||
// Служебные/каналовые отправители (пост канала, анонимный админ, авто-форвард) — не спам.
|
||
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 — можно ли создать новую связку (решает аддон). Без аддона — да.
|
||
func (b *Bridge) crosspostAllowed(ctx context.Context, ownerMaxID, ownerTgID int64) (bool, string) {
|
||
if b.addon == nil {
|
||
return true, ""
|
||
}
|
||
return b.addon.CrosspostAllowed(ctx, ownerMaxID, ownerTgID)
|
||
}
|
||
|
||
// maxAddonCommand — отдать команду MAX-диалога аддону (если подключён). true ⇒ обработано.
|
||
func (b *Bridge) maxAddonCommand(ctx context.Context, userID, chatID int64, text string) bool {
|
||
return b.addon != nil && b.addon.HandleMaxCommand(ctx, userID, chatID, text)
|
||
}
|
||
|
||
// chatShared — отдать аддону выбранный нативной кнопкой чат (если подключён). true ⇒ обработано.
|
||
func (b *Bridge) chatShared(ctx context.Context, userID int64, requestID int, sharedChatID int64, title string) bool {
|
||
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 — доставлять ли пост связки (решает аддон). Без аддона — да.
|
||
func (b *Bridge) crosspostDeliverable(ctx context.Context, maxChatID int64) bool {
|
||
if b.addon == nil {
|
||
return true
|
||
}
|
||
maxOwner, tgOwner := b.repo.GetCrosspostOwner(maxChatID)
|
||
return b.addon.CrosspostDeliverable(ctx, maxOwner, tgOwner, maxChatID)
|
||
}
|
||
|
||
// crosspostOpenApp спрашивает у аддона inline-кнопку для кросспоста (если подключён)
|
||
// и возвращает спецификацию OPEN_APP-кнопки для прикрепления к MAX-сообщению. nil —
|
||
// кнопки нет (нет аддона / не нужна). Вызывается ПЕРЕД отправкой.
|
||
func (b *Bridge) crosspostOpenApp(ctx context.Context, tgChatID int64, tgMsgID int, maxChatID int64) *maxOpenApp {
|
||
if b.addon == nil {
|
||
return nil
|
||
}
|
||
_, tgOwner := b.repo.GetCrosspostOwner(maxChatID)
|
||
appName, text, payload, ok := b.addon.CrosspostButton(ctx, tgOwner, tgChatID, tgMsgID, maxChatID)
|
||
if !ok || appName == "" {
|
||
return nil
|
||
}
|
||
return &maxOpenApp{Text: text, AppName: appName, Payload: payload}
|
||
}
|