max-telegram-bridge-bot/main.go
Andrey Lugovskoy f57105d2b6
Some checks failed
Build / build (push) Has been cancelled
crosspost: fix long album caption + strip custom emoji and whitespace padding
- 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
2026-07-03 13:00:46 +04:00

211 lines
6.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"log/slog"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
maxbot "github.com/max-messenger/max-bot-api-client-go"
)
func mustEnv(key string) string {
v := os.Getenv(key)
if v == "" {
fmt.Fprintf(os.Stderr, "Environment variable %s is not set\n", key)
os.Exit(1)
}
return v
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func genKey() string {
b := make([]byte, 8)
rand.Read(b)
return hex.EncodeToString(b)
}
func logLevel() slog.Level {
switch strings.ToLower(os.Getenv("LOG_LEVEL")) {
case "debug":
return slog.LevelDebug
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
func main() {
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel()})))
cfg := Config{
MaxToken: mustEnv("MAX_TOKEN"),
MaxTokenOld: os.Getenv("MAX_TOKEN_OLD"),
TgBotURL: envOr("TG_BOT_URL", "https://t.me/MaxTelegramBridgeBot"),
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"),
MaxWebhookPort: envOr("MAX_WEBHOOK_PORT", "8443"),
TgWebhookURL: os.Getenv("TG_WEBHOOK_URL"),
TgWebhookPort: envOr("TG_WEBHOOK_PORT", "8444"),
TgAPIURL: os.Getenv("TG_API_URL"),
}
// Parse ALLOWED_USERS whitelist
if v := os.Getenv("ALLOWED_USERS"); v != "" {
for _, s := range strings.Split(v, ",") {
s = strings.TrimSpace(s)
if s == "" {
continue
}
id, err := strconv.ParseInt(s, 10, 64)
if err != nil {
slog.Error("Invalid ALLOWED_USERS value", "value", s, "err", err)
os.Exit(1)
}
cfg.AllowedUsers = append(cfg.AllowedUsers, id)
}
slog.Info("User whitelist enabled", "count", len(cfg.AllowedUsers))
}
// Parse file size limits
if v := os.Getenv("TG_MAX_FILE_SIZE_MB"); v != "" {
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n >= 0 {
cfg.TgMaxFileSizeMB = n
} else {
slog.Error("Invalid TG_MAX_FILE_SIZE_MB value", "value", v)
os.Exit(1)
}
}
if v := os.Getenv("MAX_MAX_FILE_SIZE_MB"); v != "" {
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n >= 0 {
cfg.MaxMaxFileSizeMB = n
} else {
slog.Error("Invalid MAX_MAX_FILE_SIZE_MB value", "value", v)
os.Exit(1)
}
}
// Parse MAX_ALLOWED_EXTENSIONS whitelist (e.g. "pdf,docx,zip")
// Если не задан — расширения не проверяются локально (ошибка придёт от CDN).
if v := os.Getenv("MAX_ALLOWED_EXTENSIONS"); v != "" {
cfg.MaxAllowedExts = make(map[string]struct{})
for _, ext := range strings.Split(v, ",") {
ext = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(ext, ".")))
if ext != "" {
cfg.MaxAllowedExts[ext] = struct{}{}
}
}
slog.Info("MAX file extension whitelist enabled", "count", len(cfg.MaxAllowedExts))
}
// MESSAGE_FORMAT=newline — текст с новой строки после имени: "Имя:\nтекст"
// По умолчанию (или MESSAGE_FORMAT=inline) — "Имя: текст"
if strings.ToLower(os.Getenv("MESSAGE_FORMAT")) == "newline" {
cfg.MessageNewline = true
slog.Info("Message format: newline")
}
// DISABLE_PREFIX=true — глобально выключить префиксы [TG]/[MAX] на всех чатах.
if v := strings.ToLower(os.Getenv("DISABLE_PREFIX")); v == "true" || v == "1" || v == "yes" {
cfg.DisablePrefix = true
slog.Info("Prefix [TG]/[MAX] globally disabled via DISABLE_PREFIX")
}
dbPath := envOr("DB_PATH", "bridge.db")
var repo Repository
var err error
if dsn := os.Getenv("DATABASE_URL"); dsn != "" {
repo, err = NewPostgresRepo(dsn)
if err != nil {
slog.Error("PostgreSQL error", "err", err)
os.Exit(1)
}
slog.Info("DB: PostgreSQL")
} else {
repo, err = NewSQLiteRepo(dbPath)
if err != nil {
slog.Error("SQLite error", "err", err)
os.Exit(1)
}
slog.Info("DB: SQLite", "path", dbPath)
}
defer repo.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
tgToken := mustEnv("TG_TOKEN")
tg, err := NewTGBotSender(ctx, tgToken, cfg.TgAPIURL)
if err != nil {
slog.Error("TG bot error", "err", err)
os.Exit(1)
}
// SDK v1.6.x по умолчанию ходит на platform-api2.max.ru, чей TLS-серт прод не
// доверяет. Возвращаем на рабочий platform-api.max.ru (как и наши raw-запросы).
maxApi, err := maxbot.New(cfg.MaxToken, maxbot.WithBaseURL("https://platform-api.max.ru/"))
if err != nil {
slog.Error("MAX bot error", "err", err)
os.Exit(1)
}
var maxUID int64
maxInfo, err := maxApi.Bots.GetBot(context.Background())
if err != nil {
// НЕ валим старт, если MAX недоступен или аккаунт бота заблокирован
// (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)
}
}
}
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
slog.Info("Shutting down...")
cancel()
}()
bridge := NewBridge(cfg, repo, tg, maxApi, maxUID, maxApiOld, maxOldUID)
bridge.Run(ctx)
slog.Info("Bridge stopped")
}