commit fd69b3dab752137436bdf135569a8db3a47cde98 Author: dmysob Date: Wed May 27 09:55:11 2026 +0900 initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..242849d --- /dev/null +++ b/.env.example @@ -0,0 +1,30 @@ +TG_TOKEN=8627237404:AAG8wBzJOrTVpxQul93S98N5F6E513r6ImQ +MAX_TOKEN=f9LHodD0cOKnd3fqVPTcDK3htO_ZkeYXd0YMddIswlYakC0_EwMG7DucyrZh0-E4ki9d5c-WaygJBnQp-8MS + + +# Max file size for forwarding (in MB). 0 or not set = unlimited. +# TG→MAX: Telegram getFile API is limited to 20 MB. +# MAX→TG: Telegram Bot API sendDocument is limited to 50 MB. +# TG_MAX_FILE_SIZE_MB=20 +# MAX_MAX_FILE_SIZE_MB=20 +# SQLite (по умолчанию, без docker-compose) +# DB_PATH=bridge.db + +# PostgreSQL (docker-compose) +# POSTGRES_USER=bridge +# POSTGRES_PASSWORD=bridge +# POSTGRES_DB=bridge + +# Webhook (опционально) +# WEBHOOK_URL=https://bridge.example.com +# WEBHOOK_PORT=8443 + +# Локальный Telegram Bot API сервер (снимает лимиты на размер файлов) +# https://github.com/tdlib/telegram-bot-api +# TG_API_URL=http://localhost:8081 + +# Уровень логирования: debug, info (по умолчанию), warn, error +# LOG_LEVEL=info + +# Белый список Telegram user ID (comma-separated). Если не задан — доступ открыт для всех. +# ALLOWED_USERS=123456789,987654321 diff --git a/.github/workflows/RouterOS.yml b/.github/workflows/RouterOS.yml new file mode 100644 index 0000000..33ac610 --- /dev/null +++ b/.github/workflows/RouterOS.yml @@ -0,0 +1,104 @@ +name: RouterOS + +on: + workflow_dispatch: + push: + tags: + - 'v*' + +jobs: + binaries: + name: Build binaries + runs-on: ubuntu-latest + strategy: + matrix: + include: + - goarch: arm + goarm: '7' + cc: arm-linux-gnueabihf-gcc + pkg: gcc-arm-linux-gnueabihf + suffix: linux-armv7 + - goarch: arm64 + goarm: '' + cc: aarch64-linux-gnu-gcc + pkg: gcc-aarch64-linux-gnu + suffix: linux-arm64 + - goarch: amd64 + goarm: '' + cc: gcc + pkg: gcc + suffix: linux-amd64 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Install cross-compilation toolchain + run: sudo apt-get update && sudo apt-get install -y ${{ matrix.pkg }} + + - name: Build ${{ matrix.suffix }} + env: + CGO_ENABLED: '1' + GOOS: linux + GOARCH: ${{ matrix.goarch }} + GOARM: ${{ matrix.goarm }} + CC: ${{ matrix.cc }} + run: go build -ldflags="-s -w" -o max-telegram-bridge-bot-${{ matrix.suffix }} . + + - name: Save binary as artifact + uses: actions/upload-artifact@v4 + with: + name: binary-${{ matrix.suffix }} + retention-days: 7 + path: max-telegram-bridge-bot-${{ matrix.suffix }} + + docker-tar: + name: Build Docker tar + runs-on: ubuntu-latest + strategy: + matrix: + include: + - platform: linux/arm/v7 + suffix: linux-armv7 + - platform: linux/arm64 + suffix: linux-arm64 + - platform: linux/amd64 + suffix: linux-amd64 + steps: + - uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # Build via buildx (required for cross-platform) but export as + # legacy Docker format via "docker save" — RouterOS container + # engine only understands the legacy image format, not OCI. + - name: Build image via buildx + uses: docker/build-push-action@v6 + with: + context: . + push: false + platforms: ${{ matrix.platform }} + tags: max-telegram-bridge-bot:build + # Load into local Docker daemon so we can docker save it + outputs: type=docker,dest=/tmp/image-${{ matrix.suffix }}.tar + + # buildx already outputs a tar — but it's OCI format. + # Re-import into Docker daemon and re-export as legacy format. + - name: Re-export as legacy Docker format + run: | + docker load -i /tmp/image-${{ matrix.suffix }}.tar + docker save max-telegram-bridge-bot:build \ + -o max-telegram-bridge-bot-${{ matrix.suffix }}-docker.tar + + - name: Save tar as artifact + uses: actions/upload-artifact@v4 + with: + name: docker-${{ matrix.suffix }} + retention-days: 7 + path: max-telegram-bridge-bot-${{ matrix.suffix }}-docker.tar diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..ebde01c --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,26 @@ +name: Build + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Vet + run: go vet ./... + + - name: Test + run: go test ./... + + - name: Build + run: CGO_ENABLED=1 go build -o max-telegram-bridge-bot . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..39d1c57 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,80 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + packages: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Install dependencies + run: sudo apt-get update && sudo apt-get install -y gcc gcc-aarch64-linux-gnu + + - name: Build linux/amd64 + run: CGO_ENABLED=1 GOOS=linux GOARCH=amd64 CC=gcc go build -o max-telegram-bridge-bot-linux-amd64 . + + - name: Build linux/arm64 + run: CGO_ENABLED=1 GOOS=linux GOARCH=arm64 CC=aarch64-linux-gnu-gcc go build -o max-telegram-bridge-bot-linux-arm64 . + + - name: Generate changelog + id: changelog + run: | + PREV_TAG=$(git tag --sort=-v:refname | head -2 | tail -1) + if [ -z "$PREV_TAG" ] || [ "$PREV_TAG" = "${{ github.ref_name }}" ]; then + CHANGELOG=$(git log --oneline --no-merges -20) + else + CHANGELOG=$(git log --oneline --no-merges ${PREV_TAG}..HEAD) + fi + echo "changelog<> "$GITHUB_OUTPUT" + echo "$CHANGELOG" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + - name: Create release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create ${{ github.ref_name }} \ + --title "${{ github.ref_name }}" \ + --notes "${{ steps.changelog.outputs.changelog }}" \ + max-telegram-bridge-bot-linux-amd64 \ + max-telegram-bridge-bot-linux-arm64 + + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Lowercase repo name + id: repo + run: echo "name=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" + + - uses: docker/build-push-action@v6 + with: + context: . + push: true + platforms: linux/amd64 + tags: | + ghcr.io/${{ steps.repo.outputs.name }}:${{ github.ref_name }} + ghcr.io/${{ steps.repo.outputs.name }}:latest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2fb2b2a --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Binaries +bridge +bridge_linux +max-telegram-bridge-bot +*.exe + +# Database +*.db +*.db-shm +*.db-wal + +# Environment +.env +deploy.sh +backup.sh +backup-cleanup.sh +bearlogin-bridge +bridge.db.bak diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f7f5ee1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM golang:1.24-alpine AS builder + +RUN apk add --no-cache gcc musl-dev + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . + +RUN CGO_ENABLED=1 go build -o /max-telegram-bridge-bot . + +FROM alpine:3.21 + +RUN apk add --no-cache ca-certificates +RUN adduser -D -h /app bridge +USER bridge +WORKDIR /app + +COPY --from=builder /max-telegram-bridge-bot /usr/local/bin/max-telegram-bridge-bot + +ENTRYPOINT ["max-telegram-bridge-bot"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..617ff73 --- /dev/null +++ b/LICENSE @@ -0,0 +1,23 @@ +Creative Commons Attribution-NonCommercial 4.0 International (CC BY-NC 4.0) + +Copyright (c) 2025-2026 Andrey Lugovskoy + +You are free to: + + Share - copy and redistribute the material in any medium or format + Adapt - remix, transform, and build upon the material + +Under the following terms: + + Attribution - You must give appropriate credit, provide a link to the + license, and indicate if changes were made. + + NonCommercial - You may not use the material for commercial purposes. + You may not sell, rent, sublicense, or offer this software as a paid + service or part of a paid service. Commercial use requires explicit + written permission from the copyright holder. + +No additional restrictions - You may not apply legal terms or technological +measures that legally restrict others from doing anything the license permits. + +Full license text: https://creativecommons.org/licenses/by-nc/4.0/legalcode diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c3a77b2 --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +-include .env +export + +.PHONY: build run test vet clean + +BINARY = max-telegram-bridge-bot + +build: + CGO_ENABLED=1 go build -o $(BINARY) . + +run: build + ./$(BINARY) + +test: + go test ./... + +vet: + go vet ./... + +clean: + rm -f $(BINARY) diff --git a/README.md b/README.md new file mode 100644 index 0000000..0d91f8c --- /dev/null +++ b/README.md @@ -0,0 +1,190 @@ +# MaxTelegramBridgeBot + +Мост между Telegram и [MAX](https://max.ru) мессенджером. Пересылает сообщения, медиа, файлы и редактирования между связанными чатами. + +**Сайт:** [maxtelegrambridge.ru](https://maxtelegrambridge.ru) + +**Боты:** [Telegram](https://t.me/MaxTelegramBridgeBot) | [MAX](https://max.ru/id710708943262_bot) + +## Возможности + +- Пересылка текстовых сообщений в обе стороны +- Пересылка медиа: фото, видео, GIF, стикеры, документы, голосовые, аудио, кружки +- Поддержка ответов (reply) — сохраняется контекст +- Отслеживание редактирования сообщений. Если при редактировании добавлено медиа — отправляется как новое сообщение (MAX API не поддерживает редактирование вложений) +- Удаление сообщений (MAX→TG). TG→MAX удаление невозможно — [Telegram Bot API не отправляет событие удаления](https://github.com/tdlib/telegram-bot-api/issues/286) +- Retry-очередь — при недоступности API сообщения сохраняются в БД и доставляются позже +- Поддержка локального Telegram Bot API сервера (`TG_API_URL`) +- Поддержка форумов (топиков) в TG-группах — сообщения из MAX приходят в нужный топик +- Команда `/thread` — выбрать топик по умолчанию для сообщений из MAX +- Автосброс топика при отключении форума в группе +- Настраиваемый префикс `[TG]` / `[MAX]` +- Кросспостинг каналов с выбором направления (`tg>max`, `max>tg`, `both`) +- Сохранение форматирования при кросспостинге (жирный, курсив, код, ссылки, зачёркнутый, подчёркнутый) +- Управление кросспостингом через inline-кнопки +- SQLite или PostgreSQL для хранения связок и маппинга сообщений + +### Форматирование при кросспостинге + +| Формат | TG → MAX | MAX → TG | +|--------|:--------:|:--------:| +| **Жирный** | ✅ | ✅ | +| *Курсив* | ✅ | ✅ | +| Моноширинный | ✅ | ✅ | +| ~~Зачёркнутый~~ | ✅ | ✅ | +| Подчёркнутый | ✅ | ✅ | +| [Ссылки](url) | ✅ | ✅ | +| Цитата | ❌ | ❌ | +| Спойлер | ❌ | — | + +Цитаты и спойлеры не поддерживаются MAX Bot API. + +## Установка + +### Из бинаря + +Скачайте бинарь со [страницы релизов](https://github.com/BEARlogin/max-telegram-bridge-bot/releases) и запустите: + +```bash +chmod +x max-telegram-bridge-bot +./max-telegram-bridge-bot +``` + +### Docker + +```bash +docker run -e TG_TOKEN=your_token -e MAX_TOKEN=your_token ghcr.io/bearlogin/max-telegram-bridge-bot:latest +``` + +### Docker Compose (с PostgreSQL) + +```bash +cp .env.example .env +# Заполните TG_TOKEN и MAX_TOKEN в .env +docker compose up -d +``` + +PostgreSQL настраивается через `.env`: + +```env +POSTGRES_USER=bridge +POSTGRES_PASSWORD=bridge +POSTGRES_DB=bridge +``` + +### Из исходников + +```bash +git clone https://github.com/BEARlogin/max-telegram-bridge-bot.git +cd max-telegram-bridge-bot +go build -o max-telegram-bridge-bot . +./max-telegram-bridge-bot +``` + +## Быстрый старт + +### 1. Создайте ботов + +- **Telegram**: через [@BotFather](https://t.me/BotFather), отключите Privacy Mode (Bot Settings → Group Privacy → Turn off) +- **MAX**: через [business.max.ru](https://dev.max.ru/docs/chatbots/bots-create) + +### 2. Настройте и запустите + +Передайте токены через переменные окружения: + +```bash +TG_TOKEN=your_token MAX_TOKEN=your_token ./max-telegram-bridge-bot +``` + +Или через `export`: + +```bash +export TG_TOKEN=your_token +export MAX_TOKEN=your_token +./max-telegram-bridge-bot +``` + +### 3. Свяжите чаты + +1. Добавьте бота в Telegram-группу и MAX-группу +2. В MAX сделайте бота **админом** группы +3. В одном из чатов отправьте `/bridge` +4. Бот выдаст ключ — отправьте `/bridge <ключ>` в другом чате + +### 4. Кросспостинг каналов + +Настройка через личные сообщения с ботами (ничего не публикуется в каналах): + +1. Добавьте бота как админа в TG-канал и MAX-канал +2. Перешлите любой пост из TG-канала в **личку TG-бота** → бот покажет ID канала +3. В **личке MAX-бота** напишите `/crosspost ` +4. Перешлите любой пост из MAX-канала в **личку MAX-бота** → кросспостинг настроен! + +По умолчанию посты идут в обе стороны. Управление: + +- `/crosspost` (в личке любого бота) — список всех связок с кнопками +- Перешлите пост из связанного канала в личку бота → появятся кнопки управления (направление, удаление) + +## Команды + +### Группы (bridge) + +| Команда | Описание | +|---------|----------| +| `/start`, `/help` | Инструкция | +| `/bridge` | Создать ключ для связки | +| `/bridge <ключ>` | Связать чат по ключу | +| `/bridge prefix on/off` | Включить/выключить префикс `[TG]`/`[MAX]` | +| `/unbridge` | Удалить связку | +| `/thread` | Направить сообщения из MAX в текущий топик (форум-группы) | + +### Каналы (crosspost) — через личку бота + +| Команда | Где | Описание | +|---------|-----|----------| +| `/crosspost` | TG или MAX личка | Список всех связок с кнопками управления | +| `/crosspost ` | MAX личка | Начать настройку (затем переслать пост из MAX-канала) | +| Переслать пост из канала | TG или MAX личка | Показать ID (если не связан) или кнопки управления | + +Кнопки управления позволяют менять направление (TG→MAX, MAX→TG, оба) и удалять связку. + +### 5. Автозамены в кросспостинге + +Автоматическая замена текста при пересылке постов. Удобно для UTM-меток, ссылок и любых строк. + +1. `/crosspost` → нажмите **🔄 Замены** на нужной связке +2. Выберите направление: **+ TG→MAX** или **+ MAX→TG** +3. Выберите тип: **Весь текст** или **Только ссылки** +4. Отправьте правило: `from | to` + +Для регулярных выражений: `/regex/ | replacement` + +Пример: `utm_source=tg | utm_source=max` — при пересылке из TG в MAX все вхождения `utm_source=tg` заменятся на `utm_source=max`. + +## Переменные окружения + +| Переменная | Описание | По умолчанию | +|------------|----------|--------------| +| `TG_TOKEN` | Токен Telegram бота | — (обязательно) | +| `MAX_TOKEN` | Токен MAX бота | — (обязательно) | +| `DB_PATH` | Путь к SQLite базе | `bridge.db` | +| `DATABASE_URL` | DSN для PostgreSQL (если задана — SQLite игнорируется) | — | +| `TG_BOT_URL` | Ссылка на TG-бота (показывается в `/help`) | `https://t.me/MaxTelegramBridgeBot` | +| `MAX_BOT_URL` | Ссылка на MAX-бота (показывается в `/help`) | `https://max.ru/id710708943262_bot` | +| `WEBHOOK_URL` | Базовый URL для webhook, например `https://bridge.example.com` (если не задан — long polling). Эндпоинты: `/tg-webhook`, `/max-webhook` | — | +| `WEBHOOK_PORT` | Порт для webhook сервера | `8443` | +| `LOG_LEVEL` | Уровень логирования: `debug`, `info`, `warn`, `error` | `info` | +| `TG_API_URL` | URL локального [Telegram Bot API сервера](https://github.com/tdlib/telegram-bot-api), например `http://localhost:8081`. Снимает лимиты на размер файлов | — | +| `ALLOWED_USERS` | Белый список Telegram user ID через запятую. Если не задан — доступ открыт для всех | — | +| `TG_MAX_FILE_SIZE_MB` | Максимальный размер файла из Telegram в Max. Рекомендуется 20 МБ (если не используется локальный сервер API), если не задано - без ограничений | — | +| `MAX_MAX_FILE_SIZE_MB` | Максимальный размер файла из Max в Telegram. Рекомендуется 20 МБ (если не используется локальный сервер API), если не задано - без ограничений | — | +| `MAX_ALLOWED_EXTENSIONS` | Список расширений файлов через запятую, которые разрешены к отправке. Если не задано - без ограничений | — | +| `MESSAGE_FORMAT` | Формат сообщений. inline (текущий Имя: текст) и newline (Имя:\nтекст) | inline | + +## Лицензия + +[CC BY-NC 4.0](LICENSE) — свободное использование и модификация, но коммерческое использование только с письменного разрешения автора. + +## Разработчик + +[bearlogin.dev](https://bearlogin.dev) — разработка ботов, сайтов, лендингов и digital-продуктов под ключ. Консультации по автоматизации и AI. diff --git a/admin.go b/admin.go new file mode 100644 index 0000000..4817c1a --- /dev/null +++ b/admin.go @@ -0,0 +1,33 @@ +package main + +import maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes" + +// isTgGroup returns true if the TG chat type indicates a group. +func isTgGroup(chatType string) bool { + return chatType == "group" || chatType == "supergroup" +} + +// isTgChannel returns true if the TG chat type is a channel. +func isTgChannel(chatType string) bool { + return chatType == "channel" +} + +// isTgAdmin returns true if the TG ChatMember status indicates admin rights. +func isTgAdmin(memberStatus string) bool { + return memberStatus == "creator" || memberStatus == "administrator" +} + +// isMaxGroup returns true if the MAX chat type indicates a group. +func isMaxGroup(chatType maxschemes.ChatType) bool { + return chatType == maxschemes.CHAT || chatType == maxschemes.CHANNEL +} + +// isMaxUserAdmin returns true if userID is found in the admin members list. +func isMaxUserAdmin(members []maxschemes.ChatMember, userID int64) bool { + for _, m := range members { + if m.UserId == userID { + return true + } + } + return false +} diff --git a/admin_test.go b/admin_test.go new file mode 100644 index 0000000..ca3d71c --- /dev/null +++ b/admin_test.go @@ -0,0 +1,130 @@ +package main + +import ( + "testing" + + maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes" +) + +func TestIsTgGroup(t *testing.T) { + tests := []struct { + chatType string + want bool + }{ + {"group", true}, + {"supergroup", true}, + {"private", false}, + {"channel", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.chatType, func(t *testing.T) { + if got := isTgGroup(tt.chatType); got != tt.want { + t.Errorf("isTgGroup(%q) = %v, want %v", tt.chatType, got, tt.want) + } + }) + } +} + +func TestIsTgAdmin(t *testing.T) { + tests := []struct { + status string + want bool + }{ + {"creator", true}, + {"administrator", true}, + {"member", false}, + {"restricted", false}, + {"left", false}, + {"kicked", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.status, func(t *testing.T) { + if got := isTgAdmin(tt.status); got != tt.want { + t.Errorf("isTgAdmin(%q) = %v, want %v", tt.status, got, tt.want) + } + }) + } +} + +func TestIsMaxGroup(t *testing.T) { + tests := []struct { + name string + chatType maxschemes.ChatType + want bool + }{ + {"chat", maxschemes.CHAT, true}, + {"channel", maxschemes.CHANNEL, true}, + {"dialog", maxschemes.DIALOG, false}, + {"empty", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isMaxGroup(tt.chatType); got != tt.want { + t.Errorf("isMaxGroup(%q) = %v, want %v", tt.chatType, got, tt.want) + } + }) + } +} + +func TestIsMaxUserAdmin(t *testing.T) { + admins := []maxschemes.ChatMember{ + {UserId: 100, Name: "Owner", IsOwner: true, IsAdmin: true}, + {UserId: 200, Name: "Admin", IsAdmin: true}, + {UserId: 300, Name: "Bot", IsBot: true, IsAdmin: true}, + } + + tests := []struct { + name string + userID int64 + want bool + }{ + {"owner is admin", 100, true}, + {"admin is admin", 200, true}, + {"bot admin", 300, true}, + {"non-admin user", 999, false}, + {"zero id", 0, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isMaxUserAdmin(admins, tt.userID); got != tt.want { + t.Errorf("isMaxUserAdmin(admins, %d) = %v, want %v", tt.userID, got, tt.want) + } + }) + } +} + +func TestIsMaxUserAdmin_EmptyList(t *testing.T) { + if isMaxUserAdmin(nil, 100) { + t.Error("isMaxUserAdmin(nil, 100) = true, want false") + } + if isMaxUserAdmin([]maxschemes.ChatMember{}, 100) { + t.Error("isMaxUserAdmin([], 100) = true, want false") + } +} + +func TestIsTgChannel(t *testing.T) { + tests := []struct { + chatType string + want bool + }{ + {"channel", true}, + {"group", false}, + {"supergroup", false}, + {"private", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.chatType, func(t *testing.T) { + if got := isTgChannel(tt.chatType); got != tt.want { + t.Errorf("isTgChannel(%q) = %v, want %v", tt.chatType, got, tt.want) + } + }) + } +} diff --git a/bridge.go b/bridge.go new file mode 100644 index 0000000..c12b498 --- /dev/null +++ b/bridge.go @@ -0,0 +1,275 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "log/slog" + "net/http" + "sync" + "time" + + maxbot "github.com/max-messenger/max-bot-api-client-go" +) + +// Config — настройки bridge, читаемые из env. +type Config struct { + MaxToken string // токен MAX API (нужен для direct-send/upload) + TgBotURL string // ссылка на TG-бота для /help + MaxBotURL string // ссылка на MAX-бота для /help + WebhookURL string // базовый URL для webhook (если пусто — long polling) + WebhookPort string // порт для webhook сервера + TgAPIURL string // custom TG Bot API URL (если пусто — api.telegram.org) + AllowedUsers []int64 // whitelist TG user IDs (empty = allow all) + TgMaxFileSizeMB int // max file size TG->MAX in MB (0 = unlimited) + MaxMaxFileSizeMB int // max file size MAX->TG in MB (0 = unlimited) + // MaxAllowedExts — whitelist расширений для TG→MAX (nil = не проверять локально). + // Если задан, файлы с не-вхождением блокируются до отправки на CDN. + MaxAllowedExts map[string]struct{} + // MessageNewline — если true, текст идёт с новой строки после имени отправителя: + // "Имя:\nтекст" вместо "Имя: текст". Задаётся через env MESSAGE_FORMAT=newline. + MessageNewline bool +} + +// chatBreaker хранит состояние circuit breaker для одного чата. +type chatBreaker struct { + fails int + blockedAt time.Time +} + +const ( + cbMaxFails = 3 // после N фейлов — блокируем + cbCooldown = 5 * time.Minute // на сколько блокируем +) + +// Bridge — основная структура, объединяющая зависимости. +type Bridge struct { + cfg Config + repo Repository + tg TGSender + maxApi *maxbot.Api + httpClient *http.Client // для скачивания/загрузки файлов (большой таймаут) + apiClient *http.Client // для коротких API-запросов (малый таймаут) + whSecret string // random path segment for webhook URLs + + cpWaitMu sync.Mutex + cpWait map[int64]int64 // MAX userId → TG channel ID (ожидание пересылки) + + cpTgOwnerMu sync.Mutex + cpTgOwner map[int64]int64 // TG channel ID → TG user ID (кто переслал пост) + + cbMu sync.Mutex + breakers map[int64]*chatBreaker // destination chatID → breaker + + // Буферизация TG media groups (альбомы) + mgMu sync.Mutex + mgBuffers map[string]*mediaGroupBuffer // MediaGroupID → buffer +} + +// NewBridge создаёт экземпляр Bridge. +func NewBridge(cfg Config, repo Repository, tg TGSender, maxApi *maxbot.Api) *Bridge { + // Derive webhook secret from tokens (stable across restarts) + h := sha256.Sum256([]byte(cfg.MaxToken + tg.BotToken())) + secret := hex.EncodeToString(h[:8]) + + return &Bridge{ + cfg: cfg, + repo: repo, + tg: tg, + maxApi: maxApi, + httpClient: &http.Client{ + Timeout: 5 * time.Minute, // для download/upload больших файлов + }, + apiClient: &http.Client{ + Timeout: 15 * time.Second, // для коротких API-запросов + }, + whSecret: secret, + cpWait: make(map[int64]int64), + cpTgOwner: make(map[int64]int64), + breakers: make(map[int64]*chatBreaker), + mgBuffers: make(map[string]*mediaGroupBuffer), + } +} + +// cbBlocked проверяет, заблокирован ли чат. +func (b *Bridge) cbBlocked(chatID int64) bool { + b.cbMu.Lock() + defer b.cbMu.Unlock() + cb, ok := b.breakers[chatID] + if !ok { + return false + } + if cb.fails >= cbMaxFails && time.Since(cb.blockedAt) < cbCooldown { + return true + } + if cb.fails >= cbMaxFails { + // Кулдаун прошёл — сбрасываем, пробуем снова + delete(b.breakers, chatID) + } + return false +} + +// cbFail регистрирует ошибку. Возвращает true если чат только что заблокировался. +func (b *Bridge) cbFail(chatID int64) bool { + b.cbMu.Lock() + defer b.cbMu.Unlock() + cb, ok := b.breakers[chatID] + if !ok { + cb = &chatBreaker{} + b.breakers[chatID] = cb + } + cb.fails++ + if cb.fails == cbMaxFails { + cb.blockedAt = time.Now() + slog.Warn("circuit breaker: chat blocked", "chatID", chatID, "cooldown", cbCooldown) + return true + } + return false +} + +// cbSuccess сбрасывает счётчик ошибок для чата. +func (b *Bridge) cbSuccess(chatID int64) { + b.cbMu.Lock() + defer b.cbMu.Unlock() + delete(b.breakers, chatID) +} + +// maxMaxFileBytes returns the MAX-to-TG file size limit in bytes (0 = unlimited). +func (c *Config) maxMaxFileBytes() int64 { + if c.MaxMaxFileSizeMB <= 0 { + return 0 + } + return int64(c.MaxMaxFileSizeMB) * 1024 * 1024 +} + +// isUserAllowed проверяет, есть ли tgUserID в белом списке. +// Если AllowedUsers пуст — доступ разрешён всем. +func (b *Bridge) isUserAllowed(tgUserID int64) bool { + if len(b.cfg.AllowedUsers) == 0 { + return true + } + for _, id := range b.cfg.AllowedUsers { + if id == tgUserID { + return true + } + } + return false +} + +// checkUserAllowed проверяет доступ пользователя и отправляет сообщение об отказе если нужно. +// Возвращает true если доступ разрешён, false — если запрещён (и уже отправил ответ). +// userID == 0 трактуется как «нет отправителя» — доступ запрещается. +func (b *Bridge) checkUserAllowed(ctx context.Context, chatID, userID int64, threadID int) bool { + if userID != 0 && b.isUserAllowed(userID) { + return true + } + slog.Debug("TG user not allowed", "uid", userID) + b.tg.SendMessage(ctx, chatID, "У вас нет прав доступа к боту.", &SendOpts{ThreadID: threadID}) + return false +} + +// isCrosspostOwner проверяет, является ли userID владельцем связки. +// owner_id=0 и tg_owner_id=0 — старая связка, доступна всем. +func (b *Bridge) isCrosspostOwner(maxChatID, userID int64) bool { + maxOwner, tgOwner := b.repo.GetCrosspostOwner(maxChatID) + if maxOwner == 0 && tgOwner == 0 { + return true // legacy, no owner + } + return userID == maxOwner || userID == tgOwner +} + +// tgFileURL возвращает прямой URL файла из TG — через custom API если настроен. +func (b *Bridge) tgFileURL(ctx context.Context, fileID string) (string, error) { + filePath, err := b.tg.GetFile(ctx, fileID) + if err != nil { + return "", err + } + return b.tg.GetFileDirectURL(filePath), nil +} + +// tgChatTitle возвращает title TG-чата/канала по ID. Пустая строка если не удалось. +func (b *Bridge) tgChatTitle(ctx context.Context, chatID int64) string { + title, err := b.tg.GetChat(ctx, chatID) + if err != nil { + return "" + } + return title +} + +func (b *Bridge) tgWebhookPath() string { + return "/tg-webhook-" + b.whSecret +} + +func (b *Bridge) maxWebhookPath() string { + return "/max-webhook-" + b.whSecret +} + +// registerCommands регистрирует команды бота в Telegram. +func (b *Bridge) registerCommands(ctx context.Context) { + cmds := []BotCommand{ + {Command: "bridge", Description: "Связать чат с MAX-чатом"}, + {Command: "unbridge", Description: "Удалить связку чатов"}, + {Command: "thread", Description: "Установить топик для сообщений из MAX"}, + {Command: "crosspost", Description: "Список связок кросспостинга"}, + {Command: "help", Description: "Инструкция"}, + } + if err := b.tg.SetMyCommands(ctx, cmds, nil); err != nil { + slog.Error("TG setMyCommands (default) failed", "err", err) + } + if err := b.tg.SetMyCommands(ctx, cmds, &CommandScope{Type: "all_chat_administrators"}); err != nil { + slog.Error("TG setMyCommands (admins) failed", "err", err) + } +} + +// Run запускает TG и MAX listener'ы + периодическую очистку. +func (b *Bridge) Run(ctx context.Context) { + b.registerCommands(ctx) + go func() { + t := time.NewTicker(10 * time.Minute) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + b.repo.CleanOldMessages() + } + } + }() + + // Воркер очереди — проверяет каждые 10 секунд + go func() { + t := time.NewTicker(10 * time.Second) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + b.processQueue(ctx) + } + } + }() + + if b.cfg.WebhookURL != "" { + go func() { + addr := ":" + b.cfg.WebhookPort + srv := &http.Server{ + Addr: addr, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 60 * time.Second, + } + slog.Info("Webhook server starting", "addr", addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("Webhook server failed", "err", err) + } + }() + } + + var wg sync.WaitGroup + wg.Add(2) + go func() { defer wg.Done(); b.listenTelegram(ctx) }() + go func() { defer wg.Done(); b.listenMax(ctx) }() + wg.Wait() +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1fdae44 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,35 @@ +services: + bridge: + build: . + restart: unless-stopped + env_file: .env + environment: + DATABASE_URL: postgres://${POSTGRES_USER:-bridge}:${POSTGRES_PASSWORD:-bridge}@postgres:5432/${POSTGRES_DB:-bridge}?sslmode=disable + depends_on: + postgres: + condition: service_healthy + networks: + - default + - caddy-net + + postgres: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-bridge} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-bridge} + POSTGRES_DB: ${POSTGRES_DB:-bridge} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-bridge}"] + interval: 5s + timeout: 3s + retries: 5 + +volumes: + pgdata: + +networks: + caddy-net: + external: true diff --git a/docs/pikabu-post.md b/docs/pikabu-post.md new file mode 100644 index 0000000..aa29daa --- /dev/null +++ b/docs/pikabu-post.md @@ -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'ов. Но работает! diff --git a/docs/superpowers/plans/2026-03-17-max-antispam.md b/docs/superpowers/plans/2026-03-17-max-antispam.md new file mode 100644 index 0000000..760f68b --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-max-antispam.md @@ -0,0 +1,950 @@ +# MAX Antispam Bot Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build an antispam bot for MAX messenger with local filters, AI classification (premium), captcha, flood detection, and configurable punishment escalation. + +**Architecture:** Flat Go package (like bridge). MAX Bot API for messaging. Two-tier moderation: fast local filter scores every message, only suspicious ones go to AI (premium). SQLite default, PostgreSQL optional. Config via bot DM with inline keyboards. + +**Tech Stack:** Go 1.24, MAX Bot API SDK (`max-bot-api-client-go`), SQLite/PostgreSQL, OpenRouter API, `golang-migrate/migrate/v4` + +**Project:** `/home/bearlogin/development/bearlogin/max-antispam` + +--- + +## File Structure + +``` +max-antispam/ + main.go — entry point, env config, wiring + bot.go — Bot struct, Run(), MAX polling loop, callback handler + repository.go — Repository interface + sqlite.go — SQLite implementation + postgres.go — PostgreSQL implementation + migrate.go — migration runner (same pattern as bridge) + migrations/ + sqlite/ + 000001_init.up.sql + 000001_init.down.sql + postgres/ + 000001_init.up.sql + 000001_init.down.sql + filter.go — local filter engine (stopwords, regex, links, unicode) + score.go — suspicion scoring system + flood.go — flood detection (rate limiter per user/chat) + captcha.go — new member verification (button + timeout) + punish.go — punishment escalation engine + ai.go — OpenRouter AI classifier (premium) + settings.go — inline keyboard settings UI in bot DM + premium.go — premium key management + deploy.sh — deploy script (same pattern as bridge) + Makefile — build/run/test + go.mod + LICENSE + README.md + + filter_test.go + score_test.go + flood_test.go + captcha_test.go + punish_test.go + ai_test.go + settings_test.go +``` + +--- + +## Chunk 1: Project Scaffold + Storage + +### Task 1: Initialize Go module and project structure + +**Files:** +- Create: `go.mod`, `main.go`, `Makefile`, `LICENSE`, `deploy.sh` + +- [ ] **Step 1: Create project directory and init module** + +```bash +mkdir -p /home/bearlogin/development/bearlogin/max-antispam +cd /home/bearlogin/development/bearlogin/max-antispam +go mod init max-antispam +``` + +- [ ] **Step 2: Create main.go with env loading and basic wiring** + +```go +package main + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/signal" + "strconv" + "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, "env %s is required\n", key) + os.Exit(1) + } + return v +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func envInt(key string, fallback int) int { + v := os.Getenv(key) + if v == "" { + return fallback + } + n, err := strconv.Atoi(v) + if err != nil { + return fallback + } + return n +} + +type Config struct { + MaxToken string + OpenRouterKey string // empty = AI disabled + OpenRouterModel string + FreeChatLimit int +} + +func main() { + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil))) + + cfg := Config{ + MaxToken: mustEnv("MAX_TOKEN"), + OpenRouterKey: os.Getenv("OPENROUTER_KEY"), + OpenRouterModel: envOr("OPENROUTER_MODEL", "openai/gpt-4o-mini"), + FreeChatLimit: envInt("FREE_CHAT_LIMIT", 3), + } + + dbPath := envOr("DB_PATH", "antispam.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() + + maxApi, err := maxbot.New(cfg.MaxToken) + if err != nil { + slog.Error("MAX bot error", "err", err) + os.Exit(1) + } + info, err := maxApi.Bots.GetBot(context.Background()) + if err != nil { + slog.Error("MAX bot info error", "err", err) + os.Exit(1) + } + slog.Info("MAX bot started", "name", info.Name) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigCh + slog.Info("Shutting down...") + cancel() + }() + + bot := NewBot(cfg, repo, maxApi) + bot.Run(ctx) +} +``` + +- [ ] **Step 3: Create Makefile** + +```makefile +-include .env +export + +.PHONY: build run test vet clean + +BINARY = max-antispam + +build: + CGO_ENABLED=1 go build -o $(BINARY) . + +run: build + ./$(BINARY) + +test: + go test ./... + +vet: + go vet ./... + +clean: + rm -f $(BINARY) +``` + +- [ ] **Step 4: Copy LICENSE from bridge, create deploy.sh** + +Copy LICENSE from bridge. Create deploy.sh with same pattern but `SERVICE=max-antispam`, `REMOTE_DIR=/opt/max-antispam`, binary name `max-antispam`. + +- [ ] **Step 5: Commit** + +```bash +git init +git add -A +git commit -m "Initial project scaffold" +``` + +--- + +### Task 2: Repository interface and migrations + +**Files:** +- Create: `repository.go`, `migrate.go`, `migrations/sqlite/000001_init.up.sql`, `migrations/sqlite/000001_init.down.sql`, `migrations/postgres/000001_init.up.sql`, `migrations/postgres/000001_init.down.sql` + +- [ ] **Step 1: Define Repository interface** + +```go +// repository.go +package main + +// ChatSettings holds per-chat configuration. +type ChatSettings struct { + ChatID int64 + CaptchaEnabled bool + CaptchaTimeout int // seconds + FilterEnabled bool + FloodEnabled bool + FloodMaxMessages int // max messages per window + FloodWindowSec int // window in seconds + AIEnabled bool // premium only + AIPrompt string // custom admin prompt + ScoreThreshold int // suspicion score threshold + // Punishment chain: "delete", "mute:3600", "ban" + PunishChain string // comma-separated actions + NewUserMessages int // how many messages to strictly filter for new users +} + +// UserState tracks per-user state in a chat. +type UserState struct { + ChatID int64 + UserID int64 + Verified bool + ViolationCount int + MutedUntil int64 // unix timestamp, 0 = not muted + JoinedAt int64 + MessageCount int // messages since joining +} + +// Violation is a logged moderation action. +type Violation struct { + ChatID int64 + UserID int64 + Reason string + Action string + Timestamp int64 +} + +// Repository abstracts storage for the antispam bot. +type Repository interface { + // Chat settings + GetChatSettings(chatID int64) (*ChatSettings, error) + SaveChatSettings(s *ChatSettings) error + ListChats() ([]ChatSettings, error) + DeleteChat(chatID int64) error + ChatCount() (int, error) + + // Stopwords + GetStopwords(chatID int64) ([]string, error) + AddStopword(chatID int64, word string) error + RemoveStopword(chatID int64, word string) error + + // User state + GetUserState(chatID, userID int64) (*UserState, error) + SaveUserState(u *UserState) error + IncrementViolation(chatID, userID int64) (int, error) + + // Violations log + LogViolation(v *Violation) error + GetViolations(chatID int64, limit int) ([]Violation, error) + + // Premium + IsPremium(chatID int64) bool + ActivatePremium(chatID int64, key string) error + + Close() error +} +``` + +- [ ] **Step 2: Create SQLite migration 000001_init** + +`migrations/sqlite/000001_init.up.sql`: +```sql +CREATE TABLE IF NOT EXISTS chat_settings ( + chat_id INTEGER PRIMARY KEY, + captcha_enabled INTEGER NOT NULL DEFAULT 1, + captcha_timeout INTEGER NOT NULL DEFAULT 60, + filter_enabled INTEGER NOT NULL DEFAULT 1, + flood_enabled INTEGER NOT NULL DEFAULT 1, + flood_max_messages INTEGER NOT NULL DEFAULT 5, + flood_window_sec INTEGER NOT NULL DEFAULT 10, + ai_enabled INTEGER NOT NULL DEFAULT 0, + ai_prompt TEXT NOT NULL DEFAULT '', + score_threshold INTEGER NOT NULL DEFAULT 8, + punish_chain TEXT NOT NULL DEFAULT 'delete,mute:3600,ban', + new_user_messages INTEGER NOT NULL DEFAULT 5 +); + +CREATE TABLE IF NOT EXISTS stopwords ( + chat_id INTEGER NOT NULL, + word TEXT NOT NULL, + PRIMARY KEY (chat_id, word) +); + +CREATE TABLE IF NOT EXISTS user_states ( + chat_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + verified INTEGER NOT NULL DEFAULT 0, + violation_count INTEGER NOT NULL DEFAULT 0, + muted_until INTEGER NOT NULL DEFAULT 0, + joined_at INTEGER NOT NULL DEFAULT 0, + message_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (chat_id, user_id) +); + +CREATE TABLE IF NOT EXISTS violations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chat_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + reason TEXT NOT NULL, + action TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS premium ( + chat_id INTEGER PRIMARY KEY, + key TEXT NOT NULL, + activated_at INTEGER NOT NULL +); +``` + +`migrations/sqlite/000001_init.down.sql`: +```sql +DROP TABLE IF EXISTS premium; +DROP TABLE IF EXISTS violations; +DROP TABLE IF EXISTS user_states; +DROP TABLE IF EXISTS stopwords; +DROP TABLE IF EXISTS chat_settings; +``` + +- [ ] **Step 3: Create PostgreSQL migration 000001_init** + +Same schema but with `BIGINT` instead of `INTEGER` for chat/user IDs, `BOOLEAN` instead of `INTEGER` for booleans, `SERIAL` for auto-increment. + +- [ ] **Step 4: Create migrate.go** + +Copy pattern from bridge's `migrate.go` exactly — embed FS, `runMigrations()`, but without `maybeForceVersion` (fresh project, no legacy). + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "Add repository interface and migrations" +``` + +--- + +### Task 3: SQLite repository implementation + +**Files:** +- Create: `sqlite.go` + +- [ ] **Step 1: Implement SQLite repo** + +Follow the bridge `sqlite.go` pattern: struct with `db *sql.DB` and `mu sync.Mutex`, implement all Repository methods. Use `INSERT OR REPLACE` for upserts. Each method acquires the mutex. + +Key methods: +- `GetChatSettings` — SELECT with defaults if not found (return new ChatSettings with defaults) +- `SaveChatSettings` — INSERT OR REPLACE +- `GetUserState` — SELECT, return new UserState if not found +- `SaveUserState` — INSERT OR REPLACE +- `IncrementViolation` — UPDATE violation_count + 1, return new count +- `IsPremium` — SELECT EXISTS from premium table + +- [ ] **Step 2: Commit** + +```bash +git add sqlite.go +git commit -m "Implement SQLite repository" +``` + +--- + +### Task 4: PostgreSQL repository implementation + +**Files:** +- Create: `postgres.go` + +- [ ] **Step 1: Implement PostgreSQL repo** + +Same as SQLite but using `$1`-style placeholders and `ON CONFLICT ... DO UPDATE` for upserts. Same pattern as bridge's `postgres.go`. + +- [ ] **Step 2: Commit** + +```bash +git add postgres.go +git commit -m "Implement PostgreSQL repository" +``` + +--- + +## Chunk 2: Core Moderation Engine + +### Task 5: Suspicion scoring system + +**Files:** +- Create: `score.go`, `score_test.go` + +- [ ] **Step 1: Write tests for scoring** + +```go +// score_test.go +package main + +import "testing" + +func TestScoreCleanMessage(t *testing.T) { + s := NewScorer() + result := s.Score("привет, как дела?", ScoreContext{IsNewUser: false}) + if result.Total > 0 { + t.Errorf("clean message scored %d, want 0", result.Total) + } +} + +func TestScoreStopword(t *testing.T) { + s := NewScorer() + s.SetStopwords([]string{"казино", "крипта"}) + result := s.Score("заходи в казино!", ScoreContext{}) + if result.Total < 3 { + t.Errorf("stopword message scored %d, want >= 3", result.Total) + } + if !result.HasFlag(FlagStopword) { + t.Error("expected FlagStopword") + } +} + +func TestScoreLinkFromNewUser(t *testing.T) { + s := NewScorer() + result := s.Score("зайди на http://spam.com", ScoreContext{IsNewUser: true}) + if result.Total < 5 { + t.Errorf("link from new user scored %d, want >= 5", result.Total) + } +} + +func TestScoreUnicodeAbuse(t *testing.T) { + s := NewScorer() + // zero-width characters + result := s.Score("п\u200bр\u200bи\u200bв\u200bе\u200bт", ScoreContext{}) + if !result.HasFlag(FlagUnicodeAbuse) { + t.Error("expected FlagUnicodeAbuse") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +go test ./... -run TestScore -v +``` + +- [ ] **Step 3: Implement scoring engine** + +```go +// score.go +package main + +import ( + "regexp" + "strings" + "unicode" +) + +type Flag int + +const ( + FlagStopword Flag = 1 << iota + FlagLink + FlagFlood + FlagUnicodeAbuse + FlagNewUserLink + FlagForward +) + +type ScoreResult struct { + Total int + Flags Flag + Details []string +} + +func (r *ScoreResult) HasFlag(f Flag) bool { + return r.Flags&f != 0 +} + +func (r *ScoreResult) addScore(points int, flag Flag, detail string) { + r.Total += points + r.Flags |= flag + r.Details = append(r.Details, detail) +} + +type ScoreContext struct { + IsNewUser bool + MessageCount int // messages since join +} + +type Scorer struct { + stopwords []string + linkRe *regexp.Regexp +} + +func NewScorer() *Scorer { + return &Scorer{ + linkRe: regexp.MustCompile(`https?://|t\.me/|max\.ru/`), + } +} + +func (s *Scorer) SetStopwords(words []string) { + s.stopwords = words +} + +func (s *Scorer) Score(text string, ctx ScoreContext) ScoreResult { + var r ScoreResult + lower := strings.ToLower(text) + + // Stopwords + for _, w := range s.stopwords { + if strings.Contains(lower, strings.ToLower(w)) { + r.addScore(3, FlagStopword, "stopword: "+w) + } + } + + // Links + if s.linkRe.MatchString(text) { + if ctx.IsNewUser { + r.addScore(5, FlagNewUserLink, "link from new user") + } else { + r.addScore(1, FlagLink, "link") + } + } + + // Unicode abuse (zero-width chars, excessive combining marks) + zwCount := 0 + for _, ch := range text { + if ch == '\u200b' || ch == '\u200c' || ch == '\u200d' || ch == '\ufeff' || + unicode.Is(unicode.Mn, ch) { // combining marks + zwCount++ + } + } + if zwCount > 3 { + r.addScore(2, FlagUnicodeAbuse, "unicode abuse") + } + + return r +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +go test ./... -run TestScore -v +``` + +- [ ] **Step 5: Commit** + +```bash +git add score.go score_test.go +git commit -m "Add suspicion scoring engine" +``` + +--- + +### Task 6: Local filter engine + +**Files:** +- Create: `filter.go`, `filter_test.go` + +- [ ] **Step 1: Write tests** + +Test cases: clean message passes, stopword caught, regex pattern caught, link from new user caught. + +- [ ] **Step 2: Implement filter** + +`Filter` struct wraps `Scorer` and chat settings. Main method: `Check(text string, ctx ScoreContext, settings *ChatSettings) FilterResult` returns action (pass/delete/escalate-to-ai) and reason. + +Logic: +- If `score >= settings.ScoreThreshold` and chat has AI enabled → `ActionEscalateAI` +- If `score >= settings.ScoreThreshold` and no AI → `ActionDelete` +- If `score > 0 but < threshold` → `ActionPass` (log only) +- If `score == 0` → `ActionPass` + +- [ ] **Step 3: Run tests, verify pass** + +- [ ] **Step 4: Commit** + +```bash +git add filter.go filter_test.go +git commit -m "Add local filter engine" +``` + +--- + +### Task 7: Flood detection + +**Files:** +- Create: `flood.go`, `flood_test.go` + +- [ ] **Step 1: Write tests** + +Test: single message passes, N+1 messages within window triggers flood, messages after window expires don't trigger. + +- [ ] **Step 2: Implement flood detector** + +In-memory sliding window per (chatID, userID). Struct: `FloodDetector` with `sync.Mutex` and `map[key][]time.Time`. Method `Check(chatID, userID int64, maxMsg int, windowSec int) bool` — returns true if flood detected. Periodic cleanup of old entries. + +Also detect duplicate messages: track last N message hashes per user. If same hash repeated > 2 times → flood. + +- [ ] **Step 3: Run tests, verify pass** + +- [ ] **Step 4: Commit** + +```bash +git add flood.go flood_test.go +git commit -m "Add flood detection" +``` + +--- + +### Task 8: Punishment escalation + +**Files:** +- Create: `punish.go`, `punish_test.go` + +- [ ] **Step 1: Write tests** + +Test: parse chain "delete,mute:3600,ban". 1st violation → delete, 2nd → mute 3600s, 3rd → ban, 4th+ → ban. + +- [ ] **Step 2: Implement punisher** + +```go +type Action struct { + Type string // "delete", "mute", "ban" + Duration int // seconds, for mute +} + +func ParseChain(chain string) []Action { ... } +func GetAction(chain string, violationCount int) Action { ... } +``` + +`GetAction` returns the action for the Nth violation. If count exceeds chain length, repeat last action. + +- [ ] **Step 3: Run tests, verify pass** + +- [ ] **Step 4: Commit** + +```bash +git add punish.go punish_test.go +git commit -m "Add punishment escalation" +``` + +--- + +## Chunk 3: Captcha + Bot Main Loop + +### Task 9: Captcha system + +**Files:** +- Create: `captcha.go`, `captcha_test.go` + +- [ ] **Step 1: Write tests** + +Test: pending captcha created on join, verify callback removes pending, timeout returns expired list. + +- [ ] **Step 2: Implement captcha manager** + +In-memory map of pending verifications: `map[chatID_userID]pendingCaptcha`. Each has `expiresAt time.Time`. Method `Add(chatID, userID int64, timeout int)`, `Verify(chatID, userID int64) bool`, `Expired() []pending` (called periodically). + +The bot sends an inline keyboard with a "I'm not a bot" button. Callback data: `captcha::`. + +- [ ] **Step 3: Run tests, verify pass** + +- [ ] **Step 4: Commit** + +```bash +git add captcha.go captcha_test.go +git commit -m "Add captcha verification system" +``` + +--- + +### Task 10: Bot main loop and message handling + +**Files:** +- Create: `bot.go` + +- [ ] **Step 1: Create Bot struct and Run()** + +```go +type Bot struct { + cfg Config + repo Repository + api *maxbot.Api + scorer *Scorer + flood *FloodDetector + captcha *CaptchaManager + http *http.Client +} + +func NewBot(cfg Config, repo Repository, api *maxbot.Api) *Bot { ... } +func (b *Bot) Run(ctx context.Context) { ... } +``` + +`Run()` starts MAX polling. On each update: +- `MessageCreatedUpdate` → `b.handleMessage(ctx, upd)` +- `UserAddedToChatUpdate` → `b.handleJoin(ctx, upd)` +- `CallbackAnswer` → `b.handleCallback(ctx, upd)` + +- [ ] **Step 2: Implement handleMessage** + +Flow: +1. Ignore bot messages +2. Check if DM → route to settings handler +3. Load chat settings from repo +4. Check if user is muted → delete message +5. If user not verified and captcha enabled → delete message, re-send captcha +6. Run flood check → if triggered, add flood score +7. Run scorer on message text +8. If score >= threshold and AI enabled (premium) → send to AI +9. If score >= threshold (or AI says bad) → execute punishment +10. If user is new (messageCount < newUserMessages) → strict filter (no links/forwards) +11. Otherwise pass + +- [ ] **Step 3: Implement handleJoin** + +1. Load chat settings +2. If captcha enabled → mute user, send inline button, add to captcha pending +3. If captcha disabled → save user as verified + +- [ ] **Step 4: Implement handleCallback** + +Route by callback data prefix: +- `captcha:` → verify user, unmute, remove pending +- `settings:` → route to settings handler (Task 11) + +- [ ] **Step 5: Periodic tasks** + +In `Run()`, start a ticker (every 10s) to: +- Check captcha expired → kick users +- Cleanup flood detector old entries + +- [ ] **Step 6: Build and verify compilation** + +```bash +go build ./... +``` + +- [ ] **Step 7: Commit** + +```bash +git add bot.go +git commit -m "Add bot main loop with message handling" +``` + +--- + +## Chunk 4: Settings UI + AI + Premium + +### Task 11: Settings via bot DM (inline keyboards) + +**Files:** +- Create: `settings.go` + +- [ ] **Step 1: Implement settings handler** + +When user sends `/start` in DM: +1. Query MAX API for chats where bot is admin +2. Show list as inline keyboard buttons +3. On chat selected → show settings menu: + - Captcha: ON/OFF + - Filters: ON/OFF + - Flood: ON/OFF + - AI: ON/OFF (if premium) + - Punishments: show current chain + - Stopwords: manage list +4. Each toggle sends callback, handler updates repo and refreshes keyboard + +Callback data format: `set:::` + +- [ ] **Step 2: Implement stopwords management** + +Callback flow: `set::stopwords` → show current words + "Add" button. On "Add" → bot asks for word in next message (store pending state). On word received → add to repo. + +- [ ] **Step 3: Commit** + +```bash +git add settings.go +git commit -m "Add settings UI via bot DM" +``` + +--- + +### Task 12: AI classifier (OpenRouter) + +**Files:** +- Create: `ai.go`, `ai_test.go` + +- [ ] **Step 1: Write tests** + +Mock HTTP transport. Test: clean message → "clean", spam message → "spam". Test custom prompt injection into system message. + +- [ ] **Step 2: Implement AI classifier** + +```go +type AIClassifier struct { + apiKey string + model string + http *http.Client +} + +type AIResult struct { + Category string // spam, ads, insult, nsfw, scam, clean + Confidence float64 + Reason string +} + +func (c *AIClassifier) Classify(ctx context.Context, text string, customPrompt string) (*AIResult, error) +``` + +OpenRouter API call: +- POST `https://openrouter.ai/api/v1/chat/completions` +- System prompt: built-in classifier instructions + custom admin prompt +- Ask model to respond with JSON: `{"category": "...", "confidence": 0.0-1.0, "reason": "..."}` +- Parse response, return AIResult + +Simple fuzzy hash cache: `map[uint64]*AIResult` keyed by FNV hash of normalized text. TTL 1 hour. Prevents re-classifying similar messages. + +- [ ] **Step 3: Run tests, verify pass** + +- [ ] **Step 4: Commit** + +```bash +git add ai.go ai_test.go +git commit -m "Add OpenRouter AI classifier" +``` + +--- + +### Task 13: Premium key management + +**Files:** +- Create: `premium.go` + +- [ ] **Step 1: Implement premium commands** + +In bot DM: `/premium ` → validate key format, activate in repo for current chat selection. Show "Premium activated" or error. + +Keys are pre-generated strings stored in DB. For now, admin generates them manually (INSERT into premium table). Later can add a generation command. + +Add helper `(b *Bot) canUseAI(chatID int64) bool` — checks premium + OpenRouter key configured. + +- [ ] **Step 2: Add free chat limit check** + +In `handleMessage` and `handleJoin`: if chat not in repo and `repo.ChatCount() >= cfg.FreeChatLimit` and not premium → ignore, send one-time message "Free limit reached (3 chats). Activate premium: /premium ". + +- [ ] **Step 3: Commit** + +```bash +git add premium.go +git commit -m "Add premium key management and chat limits" +``` + +--- + +## Chunk 5: Deploy + Polish + +### Task 14: Integration wiring and deploy + +**Files:** +- Modify: `main.go`, `bot.go` +- Create: `deploy.sh` + +- [ ] **Step 1: Wire all components in main.go** + +Ensure `NewBot()` creates Scorer, FloodDetector, CaptchaManager, AIClassifier (if key provided). + +- [ ] **Step 2: Full build + vet + test** + +```bash +go build ./... +go vet ./... +go test ./... +``` + +- [ ] **Step 3: Create deploy.sh** + +Same pattern as bridge: build linux/amd64, scp to server, systemd restart. Service name: `max-antispam`, remote dir: `/opt/max-antispam`. + +- [ ] **Step 4: Deploy with --setup** + +```bash +bash deploy.sh --setup +``` + +Fill in `.env` on server with `MAX_TOKEN`. + +- [ ] **Step 5: Commit and push** + +```bash +git add -A +git commit -m "Wire components, add deploy script" +git push origin master +``` + +--- + +### Task 15: README + +**Files:** +- Create: `README.md` + +- [ ] **Step 1: Write README** + +Cover: what it does, features (free vs premium), quick start, env vars, commands, deploy, license. + +- [ ] **Step 2: Commit** + +```bash +git add README.md +git commit -m "Add README" +``` diff --git a/docs/superpowers/specs/2026-03-17-max-antispam-design.md b/docs/superpowers/specs/2026-03-17-max-antispam-design.md new file mode 100644 index 0000000..7b82b5e --- /dev/null +++ b/docs/superpowers/specs/2026-03-17-max-antispam-design.md @@ -0,0 +1,71 @@ +# MAX Antispam Bot — Design Spec + +## Overview + +Antispam bot for MAX messenger with two-tier moderation: fast local filters + AI classification (premium). Configurable through bot DM with inline menus. + +## Architecture + +Two-level moderation pipeline: + +1. **Fast local filter** — stopwords, links, flood detection, regex patterns. Processes 100% of messages, catches ~95% of spam without AI. +2. **AI classifier (premium)** — messages flagged as suspicious by local filter are sent to OpenRouter for classification. Categories: spam, ads, insults, nsfw, scam + custom admin prompts. + +## Components + +### New Member Verification +- On join: inline button with timeout (default 60s, configurable) +- No click within timeout -> kick +- Clicked -> unmuted, but first 5 messages go through strict filter (links, forwards blocked) + +### Message Filtering +- Stopwords (configurable per chat) +- Regex patterns +- Links from new members +- Flood detection: >N identical messages or >M messages in T seconds +- Unicode abuse detection (invisible chars, homoglyphs) + +### Suspicion Scoring +Each rule adds points: stopword +3, link from new user +5, flood +4, unicode abuse +2. Threshold configurable. Above threshold -> delete (free) or send to AI (premium). + +### AI Moderation (Premium, OpenRouter) +- Receives only suspicious messages (score > threshold) +- Classifies: spam, ads, insult, nsfw, scam, clean +- Admin can add custom prompt ("ban crypto talk", "delete profanity") +- Results cached via fuzzy hash for similar messages + +### Punishment Escalation +Configurable chain per chat: +- 1st violation -> delete message +- 2nd -> mute 1 hour +- 3rd -> ban +Defaults are sensible, admin customizes via bot DM. + +### Configuration via Bot DM +- `/start` -> list chats where bot is admin +- Select chat -> inline menu with modules (toggle captcha, filters, AI, thresholds) +- `/premium ` -> activate premium + +### Limits +- Free: up to 3 chats, all filters except AI +- Premium: unlimited chats + AI moderation + custom prompts + violation analytics + +## Storage + +SQLite default / PostgreSQL optional (like bridge): +- `chats` — chat settings (modules, thresholds, punishment chains) +- `users` — user state (verified, violation_count, muted_until) +- `violations` — violation log (for premium analytics) +- `premium` — keys and subscriptions +- `stopwords` — stopwords per chat + +## Stack + +- Go, MAX Bot API, SQLite/PostgreSQL +- OpenRouter API for AI +- systemd + deploy.sh +- License: CC BY-NC 4.0 + +## Project Location + +`/home/bearlogin/development/bearlogin/max-antispam` diff --git a/format.go b/format.go new file mode 100644 index 0000000..e62ec29 --- /dev/null +++ b/format.go @@ -0,0 +1,134 @@ +package main + +import ( + "strings" + + maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes" +) + +func tgName(msg *TGMessage) string { + if msg.From == nil { + if msg.SenderChat != nil { + return msg.SenderChat.Title + } + return "Unknown" + } + name := msg.From.FirstName + if msg.From.LastName != "" { + name += " " + msg.From.LastName + } + return name +} + +// formatAttribution собирает строку "Имя: текст" или "Имя:\nтекст" в зависимости от настройки. +func formatAttribution(name, text string, newline bool) string { + if newline { + return name + ":\n" + text + } + return name + ": " + text +} + +// formatTgCaption — для пересылки (текст или caption) +func formatTgCaption(msg *TGMessage, prefix, newline bool) string { + name := tgName(msg) + text := msg.Text + if text == "" { + text = msg.Caption + } + if prefix { + return formatAttribution("[TG] "+name, text, newline) + } + return formatAttribution(name, text, newline) +} + +// formatTgMessage — для edit (полный формат) +func formatTgMessage(msg *TGMessage, prefix, newline bool) string { + name := tgName(msg) + text := msg.Text + if text == "" { + text = msg.Caption + } + if text == "" { + return "" + } + if prefix { + return formatAttribution("[TG] "+name, text, newline) + } + return formatAttribution(name, text, newline) +} + +func maxName(upd *maxschemes.MessageCreatedUpdate) string { + name := upd.Message.Sender.Name + if name == "" { + name = upd.Message.Sender.Username + } + return name +} + +// formatMaxCaption — для пересылки +func formatMaxCaption(upd *maxschemes.MessageCreatedUpdate, prefix, newline bool) string { + name := maxName(upd) + text := upd.Message.Body.Text + if prefix { + return formatAttribution("[MAX] "+name, text, newline) + } + return formatAttribution(name, text, newline) +} + +// formatTgCrosspostCaption — для кросспостинга каналов (без attribution и префиксов) +func formatTgCrosspostCaption(msg *TGMessage) string { + text := msg.Text + if text == "" { + text = msg.Caption + } + return text +} + +// formatMaxCrosspostCaption — для кросспостинга каналов (без attribution и префиксов) +func formatMaxCrosspostCaption(upd *maxschemes.MessageCreatedUpdate) string { + return upd.Message.Body.Text +} + +// mimeToFilename генерирует имя файла из MIME-типа, если оригинальное имя отсутствует. +func mimeToFilename(base, mime string) string { + ext := "" + // sub = часть после "/" в mime type + if i := strings.Index(mime, "/"); i >= 0 { + sub := mime[i+1:] + switch sub { + case "mp4": + ext = ".mp4" + case "webm": + ext = ".webm" + case "x-matroska": + ext = ".mkv" + case "quicktime": + ext = ".mov" + case "mpeg": + ext = ".mpeg" + case "ogg": + ext = ".ogg" + case "pdf": + ext = ".pdf" + case "gif": + ext = ".gif" + default: + ext = "." + sub + } + } + return base + ext +} + +// fileNameFromURL извлекает имя файла из URL, fallback "file". +func fileNameFromURL(rawURL string) string { + if idx := strings.LastIndex(rawURL, "/"); idx >= 0 { + name := rawURL[idx+1:] + if q := strings.Index(name, "?"); q >= 0 { + name = name[:q] + } + if name != "" { + return name + } + } + return "file" +} diff --git a/format_test.go b/format_test.go new file mode 100644 index 0000000..0c6de05 --- /dev/null +++ b/format_test.go @@ -0,0 +1,255 @@ +package main + +import ( + "testing" + + maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes" +) + +func TestTgName(t *testing.T) { + tests := []struct { + name string + msg *TGMessage + expected string + }{ + { + name: "first name only", + msg: &TGMessage{ + From: &UserInfo{FirstName: "Ivan"}, + }, + expected: "Ivan", + }, + { + name: "first and last name", + msg: &TGMessage{ + From: &UserInfo{FirstName: "Ivan", LastName: "Petrov"}, + }, + expected: "Ivan Petrov", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tgName(tt.msg) + if got != tt.expected { + t.Errorf("tgName() = %q, want %q", got, tt.expected) + } + }) + } +} + +func TestFormatTgCaption(t *testing.T) { + msg := &TGMessage{ + Text: "hello world", + From: &UserInfo{FirstName: "Anna"}, + } + + tests := []struct { + name string + prefix bool + expected string + }{ + {"with prefix", true, "[TG] Anna: hello world"}, + {"without prefix", false, "Anna: hello world"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatTgCaption(msg, tt.prefix, false) + if got != tt.expected { + t.Errorf("formatTgCaption() = %q, want %q", got, tt.expected) + } + }) + } +} + +func TestFormatTgCaption_UsesCaption(t *testing.T) { + msg := &TGMessage{ + Text: "", + Caption: "photo caption", + From: &UserInfo{FirstName: "Bob"}, + } + + got := formatTgCaption(msg, false, false) + expected := "Bob: photo caption" + if got != expected { + t.Errorf("formatTgCaption() = %q, want %q", got, expected) + } +} + +func TestFormatTgMessage(t *testing.T) { + tests := []struct { + name string + msg *TGMessage + prefix bool + expected string + }{ + { + name: "text with prefix", + msg: &TGMessage{ + Text: "edited text", + From: &UserInfo{FirstName: "Ivan"}, + }, + prefix: true, + expected: "[TG] Ivan: edited text", + }, + { + name: "text without prefix", + msg: &TGMessage{ + Text: "edited text", + From: &UserInfo{FirstName: "Ivan"}, + }, + prefix: false, + expected: "Ivan: edited text", + }, + { + name: "empty text returns empty", + msg: &TGMessage{ + Text: "", + From: &UserInfo{FirstName: "Ivan"}, + }, + prefix: true, + expected: "", + }, + { + name: "caption fallback", + msg: &TGMessage{ + Text: "", + Caption: "cap", + From: &UserInfo{FirstName: "Ivan"}, + }, + prefix: false, + expected: "Ivan: cap", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatTgMessage(tt.msg, tt.prefix, false) + if got != tt.expected { + t.Errorf("formatTgMessage() = %q, want %q", got, tt.expected) + } + }) + } +} + +func TestMaxName(t *testing.T) { + tests := []struct { + name string + upd *maxschemes.MessageCreatedUpdate + expected string + }{ + { + name: "has name", + upd: &maxschemes.MessageCreatedUpdate{ + Message: maxschemes.Message{ + Sender: maxschemes.User{Name: "Алексей"}, + }, + }, + expected: "Алексей", + }, + { + name: "fallback to username", + upd: &maxschemes.MessageCreatedUpdate{ + Message: maxschemes.Message{ + Sender: maxschemes.User{Name: "", Username: "alex42"}, + }, + }, + expected: "alex42", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := maxName(tt.upd) + if got != tt.expected { + t.Errorf("maxName() = %q, want %q", got, tt.expected) + } + }) + } +} + +func TestFormatMaxCaption(t *testing.T) { + upd := &maxschemes.MessageCreatedUpdate{ + Message: maxschemes.Message{ + Sender: maxschemes.User{Name: "Вася"}, + Body: maxschemes.MessageBody{Text: "привет"}, + }, + } + + tests := []struct { + name string + prefix bool + expected string + }{ + {"with prefix", true, "[MAX] Вася: привет"}, + {"without prefix", false, "Вася: привет"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatMaxCaption(upd, tt.prefix, false) + if got != tt.expected { + t.Errorf("formatMaxCaption() = %q, want %q", got, tt.expected) + } + }) + } +} + +func TestFormatTgCrosspostCaption(t *testing.T) { + tests := []struct { + name string + msg *TGMessage + expected string + }{ + { + name: "text", + msg: &TGMessage{Text: "Новый пост"}, + expected: "Новый пост", + }, + { + name: "caption fallback", + msg: &TGMessage{Text: "", Caption: "фото"}, + expected: "фото", + }, + { + name: "empty", + msg: &TGMessage{Text: ""}, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatTgCrosspostCaption(tt.msg) + if got != tt.expected { + t.Errorf("formatTgCrosspostCaption() = %q, want %q", got, tt.expected) + } + }) + } +} + +func TestFormatMaxCrosspostCaption(t *testing.T) { + tests := []struct { + name string + text string + expected string + }{ + {"with text", "Новость дня", "Новость дня"}, + {"empty text", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + upd := &maxschemes.MessageCreatedUpdate{ + Message: maxschemes.Message{ + Body: maxschemes.MessageBody{Text: tt.text}, + }, + } + got := formatMaxCrosspostCaption(upd) + if got != tt.expected { + t.Errorf("formatMaxCrosspostCaption() = %q, want %q", got, tt.expected) + } + }) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2011931 --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module bearlogin-bridge + +go 1.24.0 + +require ( + github.com/go-telegram/bot v1.20.0 + github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/lib/pq v1.11.2 + github.com/mattn/go-sqlite3 v1.14.34 + github.com/max-messenger/max-bot-api-client-go v1.4.2 +) + +require ( + github.com/caarlos0/env/v6 v6.10.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/rs/zerolog v1.34.0 // indirect + golang.org/x/sys v0.38.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a1f3916 --- /dev/null +++ b/go.sum @@ -0,0 +1,89 @@ +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/caarlos0/env/v6 v6.10.1 h1:t1mPSxNpei6M5yAeu1qtRdPAK29Nbcf/n3G7x+b3/II= +github.com/caarlos0/env/v6 v6.10.1/go.mod h1:hvp/ryKXKipEkcuYjs9mI4bBCg+UI0Yhgm5Zu0ddvwc= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= +github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-telegram/bot v1.20.0 h1:4Pea/qTidSspr4WBJw9FbHUMNhYeqszBqQUfsQEyFbc= +github.com/go-telegram/bot v1.20.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= +github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs= +github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= +github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/max-messenger/max-bot-api-client-go v1.4.2 h1:H+5Fw25HOSjnG0X+SgUn8qSCxQZWtqH264PifwVBYso= +github.com/max-messenger/max-bot-api-client-go v1.4.2/go.mod h1:Odnmi9qpPWUyurRHhlkaPru9CXc2ZFV+EMLu4eip2HM= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..b8a37ec --- /dev/null +++ b/main.go @@ -0,0 +1,174 @@ +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"), + TgBotURL: envOr("TG_BOT_URL", "https://t.me/MaxTelegramBridgeBot"), + MaxBotURL: envOr("MAX_BOT_URL", "https://max.ru/id710708943262_bot"), + WebhookURL: os.Getenv("WEBHOOK_URL"), + WebhookPort: envOr("WEBHOOK_PORT", "8443"), + 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") + } + + 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) + } + + maxApi, err := maxbot.New(cfg.MaxToken) + if err != nil { + slog.Error("MAX bot error", "err", err) + os.Exit(1) + } + maxInfo, err := maxApi.Bots.GetBot(context.Background()) + if err != nil { + slog.Error("MAX bot info error", "err", err) + os.Exit(1) + } + slog.Info("MAX bot started", "name", maxInfo.Name) + + 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) + bridge.Run(ctx) + slog.Info("Bridge stopped") +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..b20cbfc --- /dev/null +++ b/main_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "os" + "testing" +) + +func TestEnvOr(t *testing.T) { + tests := []struct { + name string + key string + envVal string + fallback string + expected string + }{ + {"env set", "TEST_ENVOR_SET", "from_env", "default", "from_env"}, + {"env empty", "TEST_ENVOR_EMPTY", "", "default", "default"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envVal != "" { + os.Setenv(tt.key, tt.envVal) + defer os.Unsetenv(tt.key) + } else { + os.Unsetenv(tt.key) + } + got := envOr(tt.key, tt.fallback) + if got != tt.expected { + t.Errorf("envOr(%q, %q) = %q, want %q", tt.key, tt.fallback, got, tt.expected) + } + }) + } +} + +func TestGenKey(t *testing.T) { + key := genKey() + if len(key) != 16 { + t.Errorf("genKey() length = %d, want 16", len(key)) + } + + // Keys should be unique + key2 := genKey() + if key == key2 { + t.Errorf("genKey() returned same key twice: %s", key) + } + + // Should be valid hex + for _, c := range key { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + t.Errorf("genKey() contains non-hex char: %c in %s", c, key) + } + } +} diff --git a/markup.go b/markup.go new file mode 100644 index 0000000..0493a40 --- /dev/null +++ b/markup.go @@ -0,0 +1,174 @@ +package main + +import ( + "fmt" + "html" + "sort" + "strings" + "unicode/utf16" + + maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes" +) + +// --- TG Entities → Markdown (для MAX) --- + +// tgEntitiesToMarkdown конвертирует TG text + entities в markdown-текст для MAX. +// Обрабатывает edge cases: пробелы перед/после маркеров выносятся за пределы тегов. +func tgEntitiesToMarkdown(text string, entities []Entity) string { + if len(entities) == 0 { + return text + } + + // Конвертируем в UTF-16 для корректных offsets (TG использует UTF-16) + runes := []rune(text) + utf16units := utf16.Encode(runes) + + // Собираем фрагменты: чередуя plain text и форматированные куски + // Работаем в UTF-16 координатах + type fragment struct { + start, end int // UTF-16 offsets + entity *Entity + } + + // Сортируем entities по offset + sorted := make([]Entity, len(entities)) + copy(sorted, entities) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Offset < sorted[j].Offset + }) + + var sb strings.Builder + pos := 0 + + for i := range sorted { + e := &sorted[i] + var open, close string + switch e.Type { + case "bold": + open, close = "**", "**" + case "italic": + open, close = "_", "_" + case "code": + open, close = "`", "`" + case "pre": + open, close = "```\n", "\n```" + case "strikethrough": + open, close = "~~", "~~" + case "text_link": + open = "[" + close = fmt.Sprintf("](%s)", e.URL) + default: + continue + } + + // Текст до entity + if e.Offset > pos { + sb.WriteString(utf16ToString(utf16units[pos:e.Offset])) + } + + // Текст entity + end := e.Offset + e.Length + if end > len(utf16units) { + end = len(utf16units) + } + inner := utf16ToString(utf16units[e.Offset:end]) + + // Trim пробелов: выносим leading/trailing пробелы за маркеры + trimmed := strings.TrimRight(inner, " \t\n") + trailingSpaces := inner[len(trimmed):] + trimmed2 := strings.TrimLeft(trimmed, " \t\n") + leadingSpaces := trimmed[:len(trimmed)-len(trimmed2)] + + sb.WriteString(leadingSpaces) + if trimmed2 != "" { + sb.WriteString(open) + sb.WriteString(trimmed2) + sb.WriteString(close) + } + sb.WriteString(trailingSpaces) + + pos = end + } + + // Остаток текста + if pos < len(utf16units) { + sb.WriteString(utf16ToString(utf16units[pos:])) + } + + return sb.String() +} + +// utf16ToString конвертирует UTF-16 slice обратно в Go string. +func utf16ToString(units []uint16) string { + runes := utf16.Decode(units) + return string(runes) +} + +// --- MAX Markups → TG HTML --- + +// maxMarkupsToHTML конвертирует MAX text + markups в TG-совместимый HTML. +func maxMarkupsToHTML(text string, markups []maxschemes.MarkUp) string { + if len(markups) == 0 { + return html.EscapeString(text) + } + + runes := []rune(text) + utf16units := utf16.Encode(runes) + + type tag struct { + pos int + open bool + order int + tag string + } + + var tags []tag + for _, m := range markups { + var openTag, closeTag string + switch m.Type { + case maxschemes.MarkupStrong: + openTag, closeTag = "", "" + case maxschemes.MarkupEmphasized: + openTag, closeTag = "", "" + case maxschemes.MarkupMonospaced: + openTag, closeTag = "", "" + case maxschemes.MarkupStrikethrough: + openTag, closeTag = "", "" + case maxschemes.MarkupUnderline: + openTag, closeTag = "", "" + case maxschemes.MarkupLink: + openTag = `` + closeTag = "" + default: + continue + } + tags = append(tags, tag{pos: m.From, open: true, order: 0, tag: openTag}) + tags = append(tags, tag{pos: m.From + m.Length, open: false, order: 1, tag: closeTag}) + } + + sort.Slice(tags, func(i, j int) bool { + if tags[i].pos != tags[j].pos { + return tags[i].pos < tags[j].pos + } + return tags[i].order > tags[j].order + }) + + var sb strings.Builder + tagIdx := 0 + for i := 0; i <= len(utf16units); i++ { + for tagIdx < len(tags) && tags[tagIdx].pos == i { + sb.WriteString(tags[tagIdx].tag) + tagIdx++ + } + if i < len(utf16units) { + if utf16.IsSurrogate(rune(utf16units[i])) && i+1 < len(utf16units) { + r := utf16.DecodeRune(rune(utf16units[i]), rune(utf16units[i+1])) + sb.WriteString(html.EscapeString(string(r))) + i++ + } else { + sb.WriteString(html.EscapeString(string(rune(utf16units[i])))) + } + } + } + return sb.String() +} diff --git a/markup_test.go b/markup_test.go new file mode 100644 index 0000000..b406f5f --- /dev/null +++ b/markup_test.go @@ -0,0 +1,252 @@ +package main + +import ( + "testing" + + maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes" +) + +// --- tgEntitiesToMarkdown --- + +func TestTgEntitiesToMarkdown_NoEntities(t *testing.T) { + got := tgEntitiesToMarkdown("hello world", nil) + if got != "hello world" { + t.Errorf("got %q", got) + } +} + +func TestTgEntitiesToMarkdown_Empty(t *testing.T) { + got := tgEntitiesToMarkdown("hello", []Entity{}) + if got != "hello" { + t.Errorf("got %q", got) + } +} + +func TestTgEntitiesToMarkdown_Bold(t *testing.T) { + got := tgEntitiesToMarkdown("hello world", []Entity{ + {Type: "bold", Offset: 0, Length: 5}, + }) + if got != "**hello** world" { + t.Errorf("got %q, want %q", got, "**hello** world") + } +} + +func TestTgEntitiesToMarkdown_Italic(t *testing.T) { + got := tgEntitiesToMarkdown("hello world", []Entity{ + {Type: "italic", Offset: 6, Length: 5}, + }) + if got != "hello _world_" { + t.Errorf("got %q", got) + } +} + +func TestTgEntitiesToMarkdown_Code(t *testing.T) { + got := tgEntitiesToMarkdown("use fmt.Println please", []Entity{ + {Type: "code", Offset: 4, Length: 11}, + }) + if got != "use `fmt.Println` please" { + t.Errorf("got %q", got) + } +} + +func TestTgEntitiesToMarkdown_Pre(t *testing.T) { + got := tgEntitiesToMarkdown("code: func main()", []Entity{ + {Type: "pre", Offset: 6, Length: 11}, + }) + want := "code: ```\nfunc main()\n```" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestTgEntitiesToMarkdown_Strikethrough(t *testing.T) { + got := tgEntitiesToMarkdown("old new", []Entity{ + {Type: "strikethrough", Offset: 0, Length: 3}, + }) + if got != "~~old~~ new" { + t.Errorf("got %q", got) + } +} + +func TestTgEntitiesToMarkdown_TextLink(t *testing.T) { + got := tgEntitiesToMarkdown("click here now", []Entity{ + {Type: "text_link", Offset: 6, Length: 4, URL: "https://example.com"}, + }) + want := "click [here](https://example.com) now" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestTgEntitiesToMarkdown_MultipleEntities(t *testing.T) { + got := tgEntitiesToMarkdown("hello world test", []Entity{ + {Type: "bold", Offset: 0, Length: 5}, + {Type: "italic", Offset: 6, Length: 5}, + }) + want := "**hello** _world_ test" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestTgEntitiesToMarkdown_TrailingSpaces(t *testing.T) { + // Entity covering "hello " (with trailing space) — space should be outside markers + got := tgEntitiesToMarkdown("hello world", []Entity{ + {Type: "bold", Offset: 0, Length: 6}, // "hello " + }) + want := "**hello** world" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestTgEntitiesToMarkdown_LeadingSpaces(t *testing.T) { + got := tgEntitiesToMarkdown("a bold rest", []Entity{ + {Type: "bold", Offset: 1, Length: 6}, // " bold" + }) + want := "a **bold** rest" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestTgEntitiesToMarkdown_UnknownType(t *testing.T) { + got := tgEntitiesToMarkdown("hello world", []Entity{ + {Type: "mention", Offset: 0, Length: 5}, + }) + if got != "hello world" { + t.Errorf("unknown entity type should be skipped, got %q", got) + } +} + +func TestTgEntitiesToMarkdown_Emoji(t *testing.T) { + // Emoji "🔥" is 2 UTF-16 code units (surrogate pair) + text := "🔥hello" + got := tgEntitiesToMarkdown(text, []Entity{ + {Type: "bold", Offset: 2, Length: 5}, // "hello" starts at UTF-16 offset 2 + }) + want := "🔥**hello**" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestTgEntitiesToMarkdown_EntityBeyondText(t *testing.T) { + got := tgEntitiesToMarkdown("hi", []Entity{ + {Type: "bold", Offset: 0, Length: 100}, + }) + want := "**hi**" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +// --- maxMarkupsToHTML --- + +func TestMaxMarkupsToHTML_NoMarkups(t *testing.T) { + got := maxMarkupsToHTML("hello ", nil) + if got != "hello <world>" { + t.Errorf("got %q", got) + } +} + +func TestMaxMarkupsToHTML_Bold(t *testing.T) { + got := maxMarkupsToHTML("hello world", []maxschemes.MarkUp{ + {Type: maxschemes.MarkupStrong, From: 0, Length: 5}, + }) + want := "hello world" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestMaxMarkupsToHTML_Italic(t *testing.T) { + got := maxMarkupsToHTML("hello world", []maxschemes.MarkUp{ + {Type: maxschemes.MarkupEmphasized, From: 6, Length: 5}, + }) + want := "hello world" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestMaxMarkupsToHTML_Code(t *testing.T) { + got := maxMarkupsToHTML("use fmt.Println", []maxschemes.MarkUp{ + {Type: maxschemes.MarkupMonospaced, From: 4, Length: 11}, + }) + want := "use fmt.Println" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestMaxMarkupsToHTML_Strikethrough(t *testing.T) { + got := maxMarkupsToHTML("old new", []maxschemes.MarkUp{ + {Type: maxschemes.MarkupStrikethrough, From: 0, Length: 3}, + }) + want := "old new" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestMaxMarkupsToHTML_Link(t *testing.T) { + got := maxMarkupsToHTML("click here", []maxschemes.MarkUp{ + {Type: maxschemes.MarkupLink, From: 6, Length: 4, URL: "https://example.com"}, + }) + want := `click here` + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestMaxMarkupsToHTML_EscapesHTML(t *testing.T) { + got := maxMarkupsToHTML("not bold", nil) + want := "<b>not bold</b>" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestMaxMarkupsToHTML_Multiple(t *testing.T) { + got := maxMarkupsToHTML("hello world test", []maxschemes.MarkUp{ + {Type: maxschemes.MarkupStrong, From: 0, Length: 5}, + {Type: maxschemes.MarkupEmphasized, From: 6, Length: 5}, + }) + want := "hello world test" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestMaxMarkupsToHTML_Emoji(t *testing.T) { + // "🔥test" — emoji is surrogate pair (2 UTF-16 units) + text := "🔥test" + got := maxMarkupsToHTML(text, []maxschemes.MarkUp{ + {Type: maxschemes.MarkupStrong, From: 2, Length: 4}, // "test" + }) + want := "🔥test" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestMaxMarkupsToHTML_Underline(t *testing.T) { + got := maxMarkupsToHTML("hello", []maxschemes.MarkUp{ + {Type: maxschemes.MarkupUnderline, From: 0, Length: 5}, + }) + want := "hello" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestMaxMarkupsToHTML_LinkEscapesURL(t *testing.T) { + got := maxMarkupsToHTML("link", []maxschemes.MarkUp{ + {Type: maxschemes.MarkupLink, From: 0, Length: 4, URL: "https://example.com/?a=1&b=2"}, + }) + want := `link` + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} diff --git a/max.go b/max.go new file mode 100644 index 0000000..e6c7b39 --- /dev/null +++ b/max.go @@ -0,0 +1,1134 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "strconv" + "strings" + + maxbot "github.com/max-messenger/max-bot-api-client-go" + maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes" +) + +func (b *Bridge) listenMax(ctx context.Context) { + var updates <-chan maxschemes.UpdateInterface + + if b.cfg.WebhookURL != "" { + whPath := b.maxWebhookPath() + whURL := strings.TrimRight(b.cfg.WebhookURL, "/") + whPath + ch := make(chan maxschemes.UpdateInterface, 100) + http.HandleFunc(whPath, b.maxApi.GetHandler(ch)) + updateTypes := []string{ + "message_created", "message_edited", "message_removed", + "message_callback", "bot_added", "bot_removed", + "user_added", "user_removed", "chat_title_changed", + } + if _, err := b.maxApi.Subscriptions.Subscribe(ctx, whURL, updateTypes, ""); err != nil { + slog.Error("MAX webhook subscribe failed", "err", err) + return + } + updates = ch + slog.Info("MAX webhook mode") + } else { + updates = b.maxApi.GetUpdates(ctx) + slog.Info("MAX polling mode") + } + + for { + select { + case <-ctx.Done(): + return + case upd, ok := <-updates: + if !ok { + return + } + + slog.Debug("MAX update", "type", fmt.Sprintf("%T", upd)) + + // Обработка удаления (только bridge, не crosspost) + if delUpd, isDel := upd.(*maxschemes.MessageRemovedUpdate); isDel { + tgChatID, tgMsgID, ok := b.repo.LookupTgMsgID(delUpd.MessageId) + if !ok { + continue + } + // Delete sync для crosspost: проверяем настройку sync_edits и direction + if maxCP, dir, cpOk := b.repo.GetCrosspostMaxChat(tgChatID); cpOk { + if !b.repo.GetCrosspostSyncEdits(maxCP) || dir == "tg>max" { + continue + } + } + if err := b.tg.DeleteMessage(ctx, tgChatID, tgMsgID); err != nil { + slog.Error("MAX→TG delete failed", "err", err, "maxMid", delUpd.MessageId, "tgChat", tgChatID) + } else { + slog.Info("MAX→TG deleted", "tgMsg", tgMsgID, "tgChat", tgChatID) + } + continue + } + + // Обработка edit + if editUpd, isEdit := upd.(*maxschemes.MessageEditedUpdate); isEdit { + if editUpd.Message.Sender.IsBot { + continue + } + mid := editUpd.Message.Body.Mid + tgChatID, tgMsgID, ok := b.repo.LookupTgMsgID(mid) + if !ok { + continue + } + // Edit sync для crosspost: проверяем настройку sync_edits и direction + if maxCP, dir, cpOk := b.repo.GetCrosspostMaxChat(tgChatID); cpOk { + if !b.repo.GetCrosspostSyncEdits(maxCP) || dir == "tg>max" { + continue + } + } + prefix := b.repo.HasPrefix("max", editUpd.Message.Recipient.ChatId) + name := editUpd.Message.Sender.Name + if name == "" { + name = editUpd.Message.Sender.Username + } + text := editUpd.Message.Body.Text + if strings.HasPrefix(text, "[TG]") || strings.HasPrefix(text, "[MAX]") { + continue + } + var fwd string + if prefix { + fwd = formatAttribution("[MAX] "+name, text, b.cfg.MessageNewline) + } else { + fwd = formatAttribution(name, text, b.cfg.MessageNewline) + } + + // Проверяем вложения в edit — если есть медиа, используем editMessageMedia + var mediaURL, mediaType string + for _, att := range editUpd.Message.Body.Attachments { + switch a := att.(type) { + case *maxschemes.PhotoAttachment: + if a.Payload.Url != "" { + mediaURL, mediaType = a.Payload.Url, "photo" + } + case *maxschemes.VideoAttachment: + if a.Payload.Url != "" { + mediaURL, mediaType = a.Payload.Url, "video" + } + case *maxschemes.FileAttachment: + if a.Payload.Url != "" { + mediaURL, mediaType = a.Payload.Url, "document" + } + } + if mediaURL != "" { + break + } + } + + if mediaURL != "" { + // Скачиваем медиа и отправляем editMessageMedia + data, name, dlErr := b.downloadURLWithLimit(mediaURL, b.cfg.maxMaxFileBytes()) + if dlErr != nil { + slog.Error("MAX→TG edit media download failed", "err", dlErr) + } else { + var mediaIM TGInputMedia + switch mediaType { + case "photo": + mediaIM = TGInputMedia{Type: "photo", File: FileArg{Name: name, Bytes: data}, Caption: fwd} + case "video": + mediaIM = TGInputMedia{Type: "video", File: FileArg{Name: name, Bytes: data}, Caption: fwd} + case "document": + mediaIM = TGInputMedia{Type: "document", File: FileArg{Name: name, Bytes: data}, Caption: fwd} + } + if err := b.tg.EditMessageMedia(ctx, tgChatID, tgMsgID, mediaIM); err != nil { + slog.Error("MAX→TG edit media failed", "err", err, "uid", editUpd.Message.Sender.UserId) + // Fallback — отправляем как новое сообщение + go b.sendTgMediaFromURL(ctx, tgChatID, mediaURL, mediaType, fwd, "", 0, 0, b.cfg.maxMaxFileBytes()) + } else { + slog.Info("MAX→TG edited media", "tgMsg", tgMsgID, "type", mediaType, "uid", editUpd.Message.Sender.UserId) + } + continue + } + } + + if text == "" { + continue + } + if err := b.tg.EditMessageText(ctx, tgChatID, tgMsgID, fwd, nil); err != nil { + slog.Error("MAX→TG edit failed", "err", err, "uid", editUpd.Message.Sender.UserId, "maxChat", editUpd.Message.Recipient.ChatId) + } else { + slog.Info("MAX→TG edited", "tgMsg", tgMsgID, "uid", editUpd.Message.Sender.UserId, "maxChat", editUpd.Message.Recipient.ChatId) + } + continue + } + + // Обработка inline-кнопок (crosspost management) + if cbUpd, isCb := upd.(*maxschemes.MessageCallbackUpdate); isCb { + b.handleMaxCallback(ctx, cbUpd) + continue + } + + msgUpd, isMsg := upd.(*maxschemes.MessageCreatedUpdate) + if !isMsg { + continue + } + + body := msgUpd.Message.Body + chatID := msgUpd.Message.Recipient.ChatId + text := strings.TrimSpace(body.Text) + isDialog := msgUpd.Message.Recipient.ChatType == "dialog" + + slog.Debug("MAX msg received", "uid", msgUpd.Message.Sender.UserId, "chat", chatID, "type", msgUpd.Message.Recipient.ChatType) + + // Запоминаем юзера при личном сообщении + if isDialog && msgUpd.Message.Sender.UserId != 0 { + b.repo.TouchUser(msgUpd.Message.Sender.UserId, "max", msgUpd.Message.Sender.Username, msgUpd.Message.Sender.Name) + } + + if text == "/whoami" { + m := maxbot.NewMessage().SetChat(chatID).SetText( + "MaxTelegramBridgeBot — мост между Telegram и MAX.\n" + + "Автор: Andrey Lugovskoy (@BEARlogin)\n" + + "Исходники: https://github.com/BEARlogin/max-telegram-bridge-bot\n" + + "Лицензия: CC BY-NC 4.0") + b.maxApi.Messages.Send(ctx, m) + continue + } + + if text == "/start" || text == "/help" { + m := maxbot.NewMessage().SetChat(chatID).SetText( + "Бот-мост между MAX и Telegram.\n\n" + + "Команды (группы):\n" + + "/bridge — создать ключ для связки чатов\n" + + "/bridge <ключ> — связать этот чат с Telegram-чатом по ключу\n" + + "/bridge prefix on/off — включить/выключить префикс [TG]/[MAX]\n" + + "/unbridge — удалить связку\n\n" + + "Кросспостинг каналов (в личке бота):\n" + + "/crosspost — связать MAX-канал с TG-каналом\n" + + " (TG ID получить: перешлите пост из TG-канала TG-боту)\n\n" + + "Как связать каналы:\n" + + "1. Добавьте бота админом в оба канала (с правом постинга)\n" + + " TG: " + b.cfg.TgBotURL + "\n" + + "2. Перешлите пост из TG-канала в личку TG-бота\n" + + "3. Бот покажет ID канала — скопируйте\n" + + "4. Здесь в личке напишите: /crosspost \n" + + "5. Перешлите пост из MAX-канала сюда → готово!\n\n" + + "/crosspost — список всех связок с кнопками управления\n" + + "Управление: перешлите пост из связанного канала → кнопки\n\n" + + "Автозамены в кросспостинге:\n" + + "В настройках связки (кнопка 🔄) можно добавить замены текста.\n" + + "Формат: текст | замена или /regex/ | замена\n" + + "Можно заменять только в ссылках или во всём тексте.\n\n" + + "Как связать группы:\n" + + "1. Добавьте бота в оба чата\n" + + " MAX: " + b.cfg.MaxBotURL + "\n" + + "2. В одном из чатов отправьте /bridge\n" + + "3. Бот выдаст ключ — отправьте его в другом чате\n" + + "4. Готово!\n\n" + + "Поддержка: https://github.com/BEARlogin/max-telegram-bridge-bot/issues") + b.maxApi.Messages.Send(ctx, m) + continue + } + + // Проверка прав админа в группах + isGroup := isMaxGroup(msgUpd.Message.Recipient.ChatType) + isAdmin := false + if isGroup && msgUpd.Message.Sender.UserId != 0 { + admins, err := b.maxApi.Chats.GetChatAdmins(ctx, chatID) + if err == nil { + isAdmin = isMaxUserAdmin(admins.Members, msgUpd.Message.Sender.UserId) + } + } else if isGroup { + // В каналах MAX не передаёт sender userId — пропускаем проверку + isAdmin = true + } + + // /bridge prefix on/off + if text == "/bridge prefix on" || text == "/bridge prefix off" { + if isGroup && !isAdmin { + m := maxbot.NewMessage().SetChat(chatID).SetText("Эта команда доступна только админам группы.") + b.maxApi.Messages.Send(ctx, m) + continue + } + on := text == "/bridge prefix on" + if b.repo.SetPrefix("max", chatID, on) { + reply := "Префикс [TG]/[MAX] включён." + if !on { + reply = "Префикс [TG]/[MAX] выключен." + } + m := maxbot.NewMessage().SetChat(chatID).SetText(reply) + b.maxApi.Messages.Send(ctx, m) + } else { + m := maxbot.NewMessage().SetChat(chatID).SetText("Чат не связан. Сначала выполните /bridge.") + b.maxApi.Messages.Send(ctx, m) + } + continue + } + + // /bridge или /bridge + if text == "/bridge" || strings.HasPrefix(text, "/bridge ") { + if isGroup && !isAdmin { + m := maxbot.NewMessage().SetChat(chatID).SetText("Эта команда доступна только админам группы.") + b.maxApi.Messages.Send(ctx, m) + continue + } + key := strings.TrimSpace(strings.TrimPrefix(text, "/bridge")) + paired, generatedKey, err := b.repo.Register(key, "max", chatID) + if err != nil { + slog.Error("register failed", "err", err) + continue + } + + if paired { + m := maxbot.NewMessage().SetChat(chatID).SetText("Связано! Сообщения теперь пересылаются.") + b.maxApi.Messages.Send(ctx, m) + slog.Info("paired", "platform", "max", "chat", chatID, "key", key) + } else if generatedKey != "" { + m := maxbot.NewMessage().SetChat(chatID). + SetText(fmt.Sprintf("Ключ для связки: %s\n\nОтправьте в Telegram-чате:\n/bridge %s\n\nTG-бот: %s", generatedKey, generatedKey, b.cfg.TgBotURL)) + b.maxApi.Messages.Send(ctx, m) + slog.Info("pending", "platform", "max", "chat", chatID, "key", generatedKey) + } else { + m := maxbot.NewMessage().SetChat(chatID).SetText("Ключ не найден или чат той же платформы.") + b.maxApi.Messages.Send(ctx, m) + } + continue + } + + if text == "/unbridge" { + if isGroup && !isAdmin { + m := maxbot.NewMessage().SetChat(chatID).SetText("Эта команда доступна только админам группы.") + b.maxApi.Messages.Send(ctx, m) + continue + } + if b.repo.Unpair("max", chatID) { + m := maxbot.NewMessage().SetChat(chatID).SetText("Связка удалена.") + b.maxApi.Messages.Send(ctx, m) + } else { + m := maxbot.NewMessage().SetChat(chatID).SetText("Этот чат не связан.") + b.maxApi.Messages.Send(ctx, m) + } + continue + } + + // Обработка ввода замены (если юзер в режиме ожидания) + if isDialog && !strings.HasPrefix(text, "/") && msgUpd.Message.Sender.UserId != 0 { + if w, ok := b.getReplWait(msgUpd.Message.Sender.UserId); ok { + b.clearReplWait(msgUpd.Message.Sender.UserId) + rule, valid := parseReplacementInput(text) + if !valid { + m := maxbot.NewMessage().SetChat(chatID).SetText("Неверный формат. Используйте:\nfrom | to\nили\n/regex/ | to") + b.maxApi.Messages.Send(ctx, m) + continue + } + rule.Target = w.target + repl := b.repo.GetCrosspostReplacements(w.maxChatID) + if w.direction == "tg>max" { + repl.TgToMax = append(repl.TgToMax, rule) + } else { + repl.MaxToTg = append(repl.MaxToTg, rule) + } + if err := b.repo.SetCrosspostReplacements(w.maxChatID, repl); err != nil { + slog.Error("save replacements failed", "err", err) + m := maxbot.NewMessage().SetChat(chatID).SetText("Ошибка сохранения.") + b.maxApi.Messages.Send(ctx, m) + continue + } + ruleType := "строка" + if rule.Regex { + ruleType = "regex" + } + dirLabel := "TG → MAX" + if w.direction == "max>tg" { + dirLabel = "MAX → TG" + } + m := maxbot.NewMessage().SetChat(chatID).SetText( + fmt.Sprintf("Замена добавлена (%s, %s):\n%s → %s", dirLabel, ruleType, rule.From, rule.To)) + b.maxApi.Messages.Send(ctx, m) + continue + } + } + + // === Crosspost команды (только в личке бота) === + + // /crosspost — начало настройки (только в личке) + if isDialog && strings.HasPrefix(text, "/crosspost") { + arg := strings.TrimSpace(strings.TrimPrefix(text, "/crosspost")) + if arg == "" { + links := b.repo.ListCrossposts(msgUpd.Message.Sender.UserId) + if len(links) == 0 { + m := maxbot.NewMessage().SetChat(chatID).SetText( + "Нет активных связок.\n\n" + + "Настройка:\n" + + "1. Перешлите пост из TG-канала в личку TG-бота\n" + + " " + b.cfg.TgBotURL + "\n" + + "2. Бот покажет ID канала\n" + + "3. Здесь напишите: /crosspost \n" + + "4. Перешлите пост из MAX-канала сюда") + b.maxApi.Messages.Send(ctx, m) + } else { + for _, l := range links { + kb := maxCrosspostKeyboard(b.maxApi, l.Direction, l.MaxChatID, b.repo.GetCrosspostSyncEdits(l.MaxChatID)) + tgTitle := b.tgChatTitle(ctx, l.TgChatID) + statusText := maxCrosspostStatusText(l.TgChatID, l.Direction) + if tgTitle != "" { + statusText = fmt.Sprintf("TG: «%s» (%d)\n", tgTitle, l.TgChatID) + statusText + } + m := maxbot.NewMessage().SetChat(chatID). + SetText(statusText). + AddKeyboard(kb) + b.maxApi.Messages.Send(ctx, m) + } + } + continue + } + tgChannelID, err := strconv.ParseInt(arg, 10, 64) + if err != nil { + m := maxbot.NewMessage().SetChat(chatID).SetText("Неверный ID. Пример: /crosspost -1001234567890") + b.maxApi.Messages.Send(ctx, m) + continue + } + + // Сохраняем ожидание: userId → tgChannelID + b.cpWaitMu.Lock() + b.cpWait[msgUpd.Message.Sender.UserId] = tgChannelID + b.cpWaitMu.Unlock() + + m := maxbot.NewMessage().SetChat(chatID).SetText( + fmt.Sprintf("TG канал ID: %d\n\nТеперь перешлите любой пост из MAX-канала, который хотите связать.", tgChannelID)) + b.maxApi.Messages.Send(ctx, m) + slog.Info("crosspost waiting for forward", "user", msgUpd.Message.Sender.UserId, "tgChannel", tgChannelID) + continue + } + + // Пересланное сообщение в личке → завершение настройки crosspost или показ управления + if isDialog && msgUpd.Message.Link != nil && msgUpd.Message.Link.Type == maxschemes.FORWARD { + maxChannelID := msgUpd.Message.Link.ChatId + + userId := msgUpd.Message.Sender.UserId + b.cpWaitMu.Lock() + tgChannelID, waiting := b.cpWait[userId] + if waiting { + delete(b.cpWait, userId) + } + b.cpWaitMu.Unlock() + + if waiting && maxChannelID != 0 { + // Проверяем, не связан ли уже + if _, _, ok := b.repo.GetCrosspostTgChat(maxChannelID); ok { + m := maxbot.NewMessage().SetChat(chatID).SetText("Этот MAX-канал уже связан.") + b.maxApi.Messages.Send(ctx, m) + continue + } + + // Достаём TG owner ID (кто переслал пост из TG-канала в TG-бот) + b.cpTgOwnerMu.Lock() + tgOwnerID := b.cpTgOwner[tgChannelID] + b.cpTgOwnerMu.Unlock() + + if err := b.repo.PairCrosspost(tgChannelID, maxChannelID, msgUpd.Message.Sender.UserId, tgOwnerID); err != nil { + slog.Error("crosspost pair failed", "err", err) + m := maxbot.NewMessage().SetChat(chatID).SetText("Ошибка при создании связки.") + b.maxApi.Messages.Send(ctx, m) + continue + } + + // Показать статус + клавиатуру после паринга + kb := maxCrosspostKeyboard(b.maxApi, "both", maxChannelID, false) + m := maxbot.NewMessage().SetChat(chatID). + SetText(fmt.Sprintf("Кросспостинг настроен!\nTG: %d ↔ MAX: %d\nНаправление: ⟷ оба", tgChannelID, maxChannelID)). + AddKeyboard(kb) + b.maxApi.Messages.Send(ctx, m) + slog.Info("crosspost paired", "tg", tgChannelID, "max", maxChannelID, "maxOwner", msgUpd.Message.Sender.UserId, "tgOwner", tgOwnerID) + continue + } + + // Нет cpWait — проверяем, связан ли канал → показать управление + if maxChannelID != 0 { + if tgID, direction, ok := b.repo.GetCrosspostTgChat(maxChannelID); ok { + kb := maxCrosspostKeyboard(b.maxApi, direction, maxChannelID, b.repo.GetCrosspostSyncEdits(maxChannelID)) + m := maxbot.NewMessage().SetChat(chatID). + SetText(maxCrosspostStatusText(tgID, direction)). + AddKeyboard(kb) + b.maxApi.Messages.Send(ctx, m) + continue + } + } + + // Канал не связан, cpWait нет — сообщить + if maxChannelID != 0 { + m := maxbot.NewMessage().SetChat(chatID).SetText("Этот канал не связан с кросспостингом.\n\nДля настройки:\n/crosspost ") + b.maxApi.Messages.Send(ctx, m) + } + continue + } + + // Пересылка (bridge) + tgChatID, linked := b.repo.GetTgChat(chatID) + if linked && !msgUpd.Message.Sender.IsBot { + // Anti-loop + if !strings.HasPrefix(text, "[TG]") && !strings.HasPrefix(text, "[MAX]") { + prefix := b.repo.HasPrefix("max", chatID) + caption := formatMaxCaption(msgUpd, prefix, b.cfg.MessageNewline) + go b.forwardMaxToTg(ctx, msgUpd, tgChatID, caption) + } + continue + } + + // Пересылка (crosspost fallback) + if msgUpd.Message.Sender.IsBot { + continue + } + tgChatID, direction, cpLinked := b.repo.GetCrosspostTgChat(chatID) + if !cpLinked { + continue + } + if direction == "tg>max" { + continue // только TG→MAX, пропускаем + } + + // Anti-loop + if strings.HasPrefix(text, "[TG]") || strings.HasPrefix(text, "[MAX]") { + continue + } + + caption := formatMaxCrosspostCaption(msgUpd) + + // Применяем замены для MAX→TG + repl := b.repo.GetCrosspostReplacements(chatID) + if len(repl.MaxToTg) > 0 { + caption = applyReplacements(caption, repl.MaxToTg) + } + + go b.forwardMaxToTg(ctx, msgUpd, tgChatID, caption) + } + } +} + +// handleMaxCallback обрабатывает нажатия inline-кнопок (crosspost management). +func (b *Bridge) handleMaxCallback(ctx context.Context, cbUpd *maxschemes.MessageCallbackUpdate) { + data := cbUpd.Callback.Payload + callbackID := cbUpd.Callback.CallbackID + userID := cbUpd.Callback.User.UserId + + slog.Debug("MAX callback", "uid", userID, "data", data) + + // cpd:dir:maxChatID — change direction + if strings.HasPrefix(data, "cpd:") { + parts := strings.SplitN(data, ":", 3) + if len(parts) != 3 { + return + } + dir := parts[1] + maxChatID, err := strconv.ParseInt(parts[2], 10, 64) + if err != nil { + return + } + if dir != "tg>max" && dir != "max>tg" && dir != "both" { + return + } + if !b.isCrosspostOwner(maxChatID, userID) { + b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ + Notification: "Только владелец связки может изменять настройки.", + }) + return + } + b.repo.SetCrosspostDirection(maxChatID, dir) + + tgID, _, _ := b.repo.GetCrosspostTgChat(maxChatID) + body := maxCrosspostMessageBody(b.maxApi, maxCrosspostStatusText(tgID, dir), dir, maxChatID, b.repo.GetCrosspostSyncEdits(maxChatID)) + b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ + Message: body, + Notification: "Готово", + }) + return + } + + // cps:maxChatID — toggle sync edits + if strings.HasPrefix(data, "cps:") { + maxChatID, err := strconv.ParseInt(strings.TrimPrefix(data, "cps:"), 10, 64) + if err != nil { + return + } + if !b.isCrosspostOwner(maxChatID, userID) { + b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ + Notification: "Только владелец связки может изменять настройки.", + }) + return + } + cur := b.repo.GetCrosspostSyncEdits(maxChatID) + b.repo.SetCrosspostSyncEdits(maxChatID, !cur) + tgID, direction, _ := b.repo.GetCrosspostTgChat(maxChatID) + body := maxCrosspostMessageBody(b.maxApi, maxCrosspostStatusText(tgID, direction), direction, maxChatID, !cur) + note := "Синхронизация правок выключена" + if !cur { + note = "Синхронизация правок включена" + } + b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ + Message: body, + Notification: note, + }) + return + } + + // cpu:maxChatID — unlink (show confirmation) + if strings.HasPrefix(data, "cpu:") { + maxChatID, err := strconv.ParseInt(strings.TrimPrefix(data, "cpu:"), 10, 64) + if err != nil { + return + } + if !b.isCrosspostOwner(maxChatID, userID) { + b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ + Notification: "Только владелец связки может удалять.", + }) + return + } + kb := b.maxApi.Messages.NewKeyboardBuilder() + kb.AddRow(). + AddCallback("Да, удалить", maxschemes.NEGATIVE, fmt.Sprintf("cpuc:%d", maxChatID)). + AddCallback("Отмена", maxschemes.DEFAULT, fmt.Sprintf("cpux:%d", maxChatID)) + body := &maxschemes.NewMessageBody{ + Text: "Удалить кросспостинг?", + Attachments: []interface{}{maxschemes.NewInlineKeyboardAttachmentRequest(kb.Build())}, + } + b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ + Message: body, + }) + return + } + + // cpuc:maxChatID — unlink confirmed + if strings.HasPrefix(data, "cpuc:") { + maxChatID, err := strconv.ParseInt(strings.TrimPrefix(data, "cpuc:"), 10, 64) + if err != nil { + return + } + if !b.isCrosspostOwner(maxChatID, userID) { + b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ + Notification: "Только владелец связки может удалять.", + }) + return + } + slog.Info("MAX crosspost unlink", "maxChatID", maxChatID, "by", userID) + b.repo.UnpairCrosspost(maxChatID, userID) + body := &maxschemes.NewMessageBody{Text: "Кросспостинг удалён."} + b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ + Message: body, + Notification: "Удалено", + }) + return + } + + // cpr:maxChatID — show replacements + if strings.HasPrefix(data, "cpr:") { + maxChatID, err := strconv.ParseInt(strings.TrimPrefix(data, "cpr:"), 10, 64) + if err != nil { + return + } + repl := b.repo.GetCrosspostReplacements(maxChatID) + id := strconv.FormatInt(maxChatID, 10) + // Заголовок с кнопками добавления + kb := maxReplacementsKeyboard(b.maxApi, maxChatID) + body := &maxschemes.NewMessageBody{ + Text: formatReplacementsHeader(repl), + Attachments: []interface{}{maxschemes.NewInlineKeyboardAttachmentRequest(kb.Build())}, + } + b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{Message: body}) + // Каждая замена — отдельное сообщение с кнопками + for i, r := range repl.TgToMax { + dkb := maxReplItemKeyboard(b.maxApi, "tg>max", i, id, r.Target) + m := maxbot.NewMessage().SetChat(cbUpd.Callback.User.UserId). + SetText(formatReplacementItem(r, "tg>max")). + AddKeyboard(dkb) + b.maxApi.Messages.Send(ctx, m) + } + for i, r := range repl.MaxToTg { + dkb := maxReplItemKeyboard(b.maxApi, "max>tg", i, id, r.Target) + m := maxbot.NewMessage().SetChat(cbUpd.Callback.User.UserId). + SetText(formatReplacementItem(r, "max>tg")). + AddKeyboard(dkb) + b.maxApi.Messages.Send(ctx, m) + } + return + } + + // cprt:dir:index:target:maxChatID — toggle replacement target + if strings.HasPrefix(data, "cprt:") { + parts := strings.SplitN(strings.TrimPrefix(data, "cprt:"), ":", 4) + if len(parts) != 4 { + return + } + dir := parts[0] + idx, err := strconv.Atoi(parts[1]) + if err != nil { + return + } + newTarget := parts[2] + maxChatID, err := strconv.ParseInt(parts[3], 10, 64) + if err != nil { + return + } + repl := b.repo.GetCrosspostReplacements(maxChatID) + id := strconv.FormatInt(maxChatID, 10) + var r *Replacement + if dir == "tg>max" && idx < len(repl.TgToMax) { + r = &repl.TgToMax[idx] + } else if dir == "max>tg" && idx < len(repl.MaxToTg) { + r = &repl.MaxToTg[idx] + } + if r == nil { + return + } + r.Target = newTarget + b.repo.SetCrosspostReplacements(maxChatID, repl) + newText := formatReplacementItem(*r, dir) + dkb := maxReplItemKeyboard(b.maxApi, dir, idx, id, r.Target) + body := &maxschemes.NewMessageBody{ + Text: newText, + Attachments: []interface{}{maxschemes.NewInlineKeyboardAttachmentRequest(dkb.Build())}, + } + label := "весь текст" + if newTarget == "links" { + label = "только ссылки" + } + b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ + Message: body, + Notification: "Тип: " + label, + }) + return + } + + // cprd:dir:index:maxChatID — delete single replacement + if strings.HasPrefix(data, "cprd:") { + parts := strings.SplitN(strings.TrimPrefix(data, "cprd:"), ":", 3) + if len(parts) != 3 { + return + } + dir := parts[0] + idx, err := strconv.Atoi(parts[1]) + if err != nil { + return + } + maxChatID, err := strconv.ParseInt(parts[2], 10, 64) + if err != nil { + return + } + repl := b.repo.GetCrosspostReplacements(maxChatID) + if dir == "tg>max" && idx < len(repl.TgToMax) { + repl.TgToMax = append(repl.TgToMax[:idx], repl.TgToMax[idx+1:]...) + } else if dir == "max>tg" && idx < len(repl.MaxToTg) { + repl.MaxToTg = append(repl.MaxToTg[:idx], repl.MaxToTg[idx+1:]...) + } + b.repo.SetCrosspostReplacements(maxChatID, repl) + body := &maxschemes.NewMessageBody{Text: "Замена удалена."} + b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ + Message: body, + Notification: "Удалено", + }) + return + } + + // cpra:dir:maxChatID — choose target (all or links) + if strings.HasPrefix(data, "cpra:") { + parts := strings.SplitN(strings.TrimPrefix(data, "cpra:"), ":", 2) + if len(parts) != 2 { + return + } + dir := parts[0] + id := parts[1] + dirLabel := "TG → MAX" + if dir == "max>tg" { + dirLabel = "MAX → TG" + } + kb := b.maxApi.Messages.NewKeyboardBuilder() + kb.AddRow(). + AddCallback("📝 Весь текст", maxschemes.DEFAULT, "cprat:"+dir+":all:"+id). + AddCallback("🔗 Только ссылки", maxschemes.DEFAULT, "cprat:"+dir+":links:"+id) + body := &maxschemes.NewMessageBody{ + Text: fmt.Sprintf("Добавление замены для %s.\nГде применять замену?", dirLabel), + Attachments: []interface{}{maxschemes.NewInlineKeyboardAttachmentRequest(kb.Build())}, + } + b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{Message: body}) + return + } + + // cprat:dir:target:maxChatID — set wait state with target + if strings.HasPrefix(data, "cprat:") { + parts := strings.SplitN(strings.TrimPrefix(data, "cprat:"), ":", 3) + if len(parts) != 3 { + return + } + dir := parts[0] + target := parts[1] + maxChatID, err := strconv.ParseInt(parts[2], 10, 64) + if err != nil { + return + } + b.setReplWait(userID, maxChatID, dir, target) + body := &maxschemes.NewMessageBody{ + Text: "Отправьте правило замены:\nfrom | to\n\nДля регулярного выражения:\n/regex/ | to\n\nНапример:\nutm_source=tg | utm_source=max", + } + b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{Message: body}) + return + } + + // cprc:maxChatID — clear all replacements + if strings.HasPrefix(data, "cprc:") { + maxChatID, err := strconv.ParseInt(strings.TrimPrefix(data, "cprc:"), 10, 64) + if err != nil { + return + } + b.repo.SetCrosspostReplacements(maxChatID, CrosspostReplacements{}) + repl := b.repo.GetCrosspostReplacements(maxChatID) + kb := maxReplacementsKeyboard(b.maxApi, maxChatID) + body := &maxschemes.NewMessageBody{ + Text: formatReplacementsHeader(repl), + Attachments: []interface{}{maxschemes.NewInlineKeyboardAttachmentRequest(kb.Build())}, + } + b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ + Message: body, + Notification: "Очищено", + }) + return + } + + // cprb:maxChatID — back to crosspost management + if strings.HasPrefix(data, "cprb:") { + maxChatID, err := strconv.ParseInt(strings.TrimPrefix(data, "cprb:"), 10, 64) + if err != nil { + return + } + tgID, direction, ok := b.repo.GetCrosspostTgChat(maxChatID) + if !ok { + return + } + body := maxCrosspostMessageBody(b.maxApi, maxCrosspostStatusText(tgID, direction), direction, maxChatID, b.repo.GetCrosspostSyncEdits(maxChatID)) + b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{Message: body}) + return + } + + // cpux:maxChatID — cancel (return to management keyboard) + if strings.HasPrefix(data, "cpux:") { + maxChatID, err := strconv.ParseInt(strings.TrimPrefix(data, "cpux:"), 10, 64) + if err != nil { + return + } + tgID, direction, ok := b.repo.GetCrosspostTgChat(maxChatID) + if !ok { + body := &maxschemes.NewMessageBody{Text: "Кросспостинг не найден."} + b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ + Message: body, + }) + return + } + body := maxCrosspostMessageBody(b.maxApi, maxCrosspostStatusText(tgID, direction), direction, maxChatID, b.repo.GetCrosspostSyncEdits(maxChatID)) + b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ + Message: body, + }) + return + } +} + +// maxCrosspostMessageBody строит NewMessageBody с текстом и inline-клавиатурой. +func maxCrosspostMessageBody(api *maxbot.Api, text, direction string, maxChatID int64, syncEdits bool) *maxschemes.NewMessageBody { + kb := maxCrosspostKeyboard(api, direction, maxChatID, syncEdits) + return &maxschemes.NewMessageBody{ + Text: text, + Attachments: []interface{}{maxschemes.NewInlineKeyboardAttachmentRequest(kb.Build())}, + } +} + +// maxCrosspostKeyboard строит inline-клавиатуру для управления кросспостингом в MAX. +func maxCrosspostKeyboard(api *maxbot.Api, direction string, maxChatID int64, syncEdits bool) *maxbot.Keyboard { + lblTgMax := "TG → MAX" + lblMaxTg := "MAX → TG" + lblBoth := "⟷ Оба" + switch direction { + case "tg>max": + lblTgMax = "✓ TG → MAX" + case "max>tg": + lblMaxTg = "✓ MAX → TG" + default: // "both" + lblBoth = "✓ ⟷ Оба" + } + id := strconv.FormatInt(maxChatID, 10) + lblSync := "✏️ Синк правок" + if syncEdits { + lblSync = "✓ ✏️ Синк правок" + } + kb := api.Messages.NewKeyboardBuilder() + kb.AddRow(). + AddCallback(lblTgMax, maxschemes.DEFAULT, "cpd:tg>max:"+id). + AddCallback(lblMaxTg, maxschemes.DEFAULT, "cpd:max>tg:"+id). + AddCallback(lblBoth, maxschemes.DEFAULT, "cpd:both:"+id) + kb.AddRow(). + AddCallback(lblSync, maxschemes.DEFAULT, "cps:"+id). + AddCallback("🔄 Замены", maxschemes.DEFAULT, "cpr:"+id). + AddCallback("❌ Удалить", maxschemes.NEGATIVE, "cpu:"+id) + return kb +} + +// maxCrosspostStatusText возвращает текст статуса кросспостинга для MAX. +func maxCrosspostStatusText(tgChatID int64, direction string) string { + dirLabel := "⟷ оба" + switch direction { + case "tg>max": + dirLabel = "TG → MAX" + case "max>tg": + dirLabel = "MAX → TG" + } + return fmt.Sprintf("Кросспостинг настроен\nTG: %d ↔ MAX\nНаправление: %s", tgChatID, dirLabel) +} + +// forwardMaxToTg пересылает MAX-сообщение (текст/медиа) в TG-чат. +func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageCreatedUpdate, tgChatID int64, caption string) { + if b.cbBlocked(tgChatID) { + return + } + + threadID := b.repo.GetTgThreadID(tgChatID) + + body := msgUpd.Message.Body + chatID := msgUpd.Message.Recipient.ChatId + text := strings.TrimSpace(body.Text) + + // Reply ID + var replyToID int + if body.ReplyTo != "" { + if _, rid, ok := b.repo.LookupTgMsgID(body.ReplyTo); ok { + replyToID = rid + } + } else if msgUpd.Message.Link != nil { + mid := msgUpd.Message.Link.Message.Mid + if mid != "" { + if _, rid, ok := b.repo.LookupTgMsgID(mid); ok { + replyToID = rid + } + } + } + + // Проверяем вложения + var sentMsgID int + var sendErr error + mediaSent := false + var qAttType, qAttURL string // для очереди при ошибке + + // Определяем HTML caption если есть markups (для кросспостинга) + htmlCaption := caption + useHTML := len(body.Markups) > 0 && caption == text + if useHTML { + htmlCaption = maxMarkupsToHTML(text, body.Markups) + } + + // Собираем вложения: фото/видео → albumMedia (отправляем вместе), остальные → soloMedia + var albumMedia []TGInputMedia + var soloMedia []struct { + url string + attType string + name string + } + pm := "" + if useHTML { + pm = "HTML" + } + + for _, att := range body.Attachments { + switch a := att.(type) { + case *maxschemes.PhotoAttachment: + if a.Payload.Url != "" { + if len(albumMedia) == 0 { + qAttType, qAttURL = "photo", a.Payload.Url + } + p := TGInputMedia{Type: "photo", File: FileArg{URL: a.Payload.Url}} + albumMedia = append(albumMedia, p) + } + case *maxschemes.VideoAttachment: + if a.Payload.Url != "" { + if len(albumMedia) == 0 { + qAttType, qAttURL = "video", a.Payload.Url + } + v := TGInputMedia{Type: "video", File: FileArg{URL: a.Payload.Url}} + albumMedia = append(albumMedia, v) + } + case *maxschemes.AudioAttachment: + if a.Payload.Url != "" { + if qAttType == "" { + qAttType, qAttURL = "audio", a.Payload.Url + } + soloMedia = append(soloMedia, struct { + url string + attType string + name string + }{a.Payload.Url, "audio", ""}) + } + case *maxschemes.FileAttachment: + if a.Payload.Url != "" { + if qAttType == "" { + qAttType, qAttURL = "file", a.Payload.Url + } + soloMedia = append(soloMedia, struct { + url string + attType string + name string + }{a.Payload.Url, "file", a.Filename}) + } + case *maxschemes.StickerAttachment: + if a.Payload.Url != "" { + if qAttType == "" { + qAttType, qAttURL = "sticker", a.Payload.Url + } + soloMedia = append(soloMedia, struct { + url string + attType string + name string + }{a.Payload.Url, "sticker", ""}) + } + } + } + + // Отправляем фото/видео как альбом (если их несколько — grouped, иначе — single) + if len(albumMedia) > 0 { + mediaSent = true + // Caption и reply только к первому элементу + if htmlCaption != "" || replyToID != 0 { + albumMedia[0].Caption = htmlCaption + if pm != "" { + albumMedia[0].ParseMode = pm + } + } + + if len(albumMedia) == 1 { + // Одно вложение — отправляем обычным сообщением (альбом из 1 элемента не имеет reply) + sentMsgID, sendErr = b.sendTgMediaFromURL(ctx, tgChatID, qAttURL, qAttType, htmlCaption, pm, replyToID, threadID, b.cfg.maxMaxFileBytes()) + var e *ErrFileTooLarge + if errors.As(sendErr, &e) { + slog.Warn("MAX→TG media too big", "name", e.Name, "size", e.Size) + m := maxbot.NewMessage().SetChat(chatID).SetText( + fmt.Sprintf("⚠️ Файл \"%s\" слишком большой для пересылки (%s). Максимальный размер файла %d МБ.", + e.Name, formatFileSize(int(e.Size)), b.cfg.MaxMaxFileSizeMB)) + b.maxApi.Messages.Send(ctx, m) + } + } else { + // Несколько — отправляем как media group (альбом) + msgIDs, err := b.tg.SendMediaGroup(ctx, tgChatID, albumMedia, &SendOpts{ThreadID: threadID, ReplyToID: replyToID}) + if err != nil { + slog.Error("MAX→TG album send failed", "err", err) + sendErr = err + m := maxbot.NewMessage().SetChat(chatID).SetText("Не удалось отправить медиаальбом в Telegram.") + b.maxApi.Messages.Send(ctx, m) + } else if len(msgIDs) > 0 { + sentMsgID = msgIDs[0] + } + } + } + + // Отправляем остальные вложения (аудио, файлы, стикеры) по одному + // Если фото/видео не отправлялось, caption добавляем к первому вложению + firstSolo := true + for _, sm := range soloMedia { + smCaption := "" + smReplyTo := 0 + if firstSolo && !mediaSent { + smCaption = htmlCaption + smReplyTo = replyToID + } + firstSolo = false + s, err := b.sendTgMediaFromURL(ctx, tgChatID, sm.url, sm.attType, smCaption, pm, smReplyTo, threadID, b.cfg.maxMaxFileBytes(), sm.name) + if err != nil { + var e *ErrFileTooLarge + if errors.As(err, &e) { + slog.Warn("MAX→TG solo media too big", "name", e.Name, "size", e.Size) + m := maxbot.NewMessage().SetChat(chatID).SetText( + fmt.Sprintf("⚠️ Файл \"%s\" слишком большой для пересылки (%s). Максимальный размер файла %d МБ.", + e.Name, formatFileSize(int(e.Size)), b.cfg.MaxMaxFileSizeMB)) + b.maxApi.Messages.Send(ctx, m) + } else { + slog.Error("MAX→TG solo media send failed", "type", sm.attType, "err", err) + m := maxbot.NewMessage().SetChat(chatID).SetText( + fmt.Sprintf("Не удалось отправить файл \"%s\" в Telegram.", sm.name)) + b.maxApi.Messages.Send(ctx, m) + } + if sendErr == nil { + sendErr = err + } + } else if !mediaSent { + sentMsgID = s + mediaSent = true + } + } + + // Текст без медиа + if !mediaSent { + if text == "" { + return + } + // Если есть markups и caption = оригинальный текст (кросспостинг), конвертируем в HTML + if len(body.Markups) > 0 && caption == text { + htmlText := maxMarkupsToHTML(text, body.Markups) + sentMsgID, sendErr = b.tg.SendMessage(ctx, tgChatID, htmlText, &SendOpts{ParseMode: "HTML", ReplyToID: replyToID, ThreadID: threadID}) + } else { + sentMsgID, sendErr = b.tg.SendMessage(ctx, tgChatID, caption, &SendOpts{ReplyToID: replyToID, ThreadID: threadID}) + } + } + + if sendErr != nil { + errStr := sendErr.Error() + slog.Error("MAX→TG send failed", "err", errStr, "uid", msgUpd.Message.Sender.UserId, "maxChat", chatID, "tgChat", tgChatID) + + // Группа преобразована в supergroup — автоматически мигрируем chat ID + var tgErr *TGError + if errors.As(sendErr, &tgErr) && tgErr.MigrateToChatID != 0 { + newChatID := tgErr.MigrateToChatID + slog.Info("TG chat migrated, updating pair", "old", tgChatID, "new", newChatID) + if err := b.repo.MigrateTgChat(tgChatID, newChatID); err != nil { + slog.Error("MigrateTgChat failed", "err", err) + } else { + // Повторяем отправку с новым ID + go b.forwardMaxToTg(ctx, msgUpd, newChatID, caption) + } + return + } + if strings.Contains(errStr, "upgraded to a supergroup") { + // Fallback если не удалось получить новый ID из ошибки + m := maxbot.NewMessage().SetChat(chatID).SetText( + "TG-группа была преобразована в супергруппу. Перепривяжите чат: /unbridge в MAX, затем /bridge заново в обоих чатах.") + b.maxApi.Messages.Send(ctx, m) + return + } + + // TOPIC_CLOSED — General топик закрыт, уведомляем и не ретраим + if strings.Contains(errStr, "TOPIC_CLOSED") { + m := maxbot.NewMessage().SetChat(chatID).SetText( + "Не удалось переслать в Telegram: основной топик (General) закрыт.\nОткройте General в настройках TG-группы или сделайте бота админом.") + b.maxApi.Messages.Send(ctx, m) + return + } + + // Топики были выключены — сбрасываем thread_id и повторяем + if threadID != 0 && (strings.Contains(errStr, "message thread not found") || + strings.Contains(errStr, "TOPIC_NOT_FOUND") || + strings.Contains(errStr, "topics are disabled")) { + slog.Info("TG forum topics disabled, resetting thread_id", "tgChat", tgChatID, "oldThread", threadID) + b.repo.SetTgThreadID(tgChatID, 0) + go b.forwardMaxToTg(ctx, msgUpd, tgChatID, caption) + return + } + + parseMode := "" + if useHTML { + parseMode = "HTML" + } + var eTooLarge *ErrFileTooLarge + if !errors.As(sendErr, &eTooLarge) { + notifyText := "Не удалось переслать сообщение в Telegram. Попробуем ещё раз автоматически." + if b.cbBlocked(tgChatID) { + notifyText = "TG API недоступен. Сообщения в очереди, будут доставлены автоматически." + } + m := maxbot.NewMessage().SetChat(chatID).SetText(notifyText) + b.maxApi.Messages.Send(ctx, m) + } + b.enqueueMax2Tg(chatID, tgChatID, body.Mid, htmlCaption, qAttType, qAttURL, parseMode) + b.cbFail(tgChatID) + } else { + b.cbSuccess(tgChatID) + slog.Info("MAX→TG sent", "msgID", sentMsgID, "media", mediaSent, "uid", msgUpd.Message.Sender.UserId, "maxChat", chatID, "tgChat", tgChatID) + b.repo.SaveMsg(tgChatID, sentMsgID, chatID, body.Mid) + } +} diff --git a/mediagroup.go b/mediagroup.go new file mode 100644 index 0000000..64bd163 --- /dev/null +++ b/mediagroup.go @@ -0,0 +1,225 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "sync" + "time" + + maxbot "github.com/max-messenger/max-bot-api-client-go" + maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes" +) + +const mediaGroupTimeout = 1 * time.Second + +// mediaGroupItem хранит данные одного сообщения из альбома TG. +type mediaGroupItem struct { + photoSizes []PhotoSize + videoFileID string // для видео в альбомах + caption string + replyToMsg *TGMessage + entities []Entity + msg *TGMessage + maxChatID int64 // если задан — используется напрямую (crosspost) + crosspost bool // кросспостинг: без prefix, другой caption формат +} + +// mediaGroupBuffer накапливает сообщения альбома перед отправкой. +type mediaGroupBuffer struct { + mu sync.Mutex + items []mediaGroupItem + timer *time.Timer +} + +// bufferMediaGroup добавляет сообщение в буфер альбома. +// Если это первое сообщение — запускает таймер. +func (b *Bridge) bufferMediaGroup(ctx context.Context, groupID string, item mediaGroupItem) { + b.mgMu.Lock() + + buf, ok := b.mgBuffers[groupID] + if !ok { + buf = &mediaGroupBuffer{} + b.mgBuffers[groupID] = buf + // Добавляем первый item до запуска таймера — исключает гонку + buf.items = append(buf.items, item) + buf.timer = time.AfterFunc(mediaGroupTimeout, func() { + b.flushMediaGroup(ctx, groupID) + }) + b.mgMu.Unlock() + return + } + + b.mgMu.Unlock() + + buf.mu.Lock() + buf.items = append(buf.items, item) + buf.mu.Unlock() +} + +// flushMediaGroup отправляет все накопленные фото/видео альбома одним сообщением в MAX. +func (b *Bridge) flushMediaGroup(ctx context.Context, groupID string) { + b.mgMu.Lock() + buf, ok := b.mgBuffers[groupID] + if !ok { + b.mgMu.Unlock() + return + } + delete(b.mgBuffers, groupID) + b.mgMu.Unlock() + + buf.mu.Lock() + buf.timer.Stop() + items := buf.items + buf.mu.Unlock() + + if len(items) == 0 { + return + } + + // Определяем maxChatID + isCrosspost := items[0].crosspost + maxChatID := items[0].maxChatID + if maxChatID == 0 { + var linked bool + maxChatID, linked = b.repo.GetMaxChat(items[0].msg.Chat.ID) + if !linked { + slog.Warn("media group: chat not linked", "tgChat", items[0].msg.Chat.ID) + return + } + } + + uid := tgUserID(items[0].msg) + prefix := !isCrosspost && b.repo.HasPrefix("tg", items[0].msg.Chat.ID) + + // Caption и entities берём из первого элемента, у которого caption не пустой + var caption string + var entities []Entity + for _, it := range items { + if it.caption != "" { + caption = it.caption + entities = it.entities + break + } + } + + // Reply ID из первого элемента с reply + var replyTo string + for _, it := range items { + if it.replyToMsg != nil { + if maxReplyID, ok := b.repo.LookupMaxMsgID(it.msg.Chat.ID, it.replyToMsg.MessageID); ok { + replyTo = maxReplyID + } + break + } + } + + // Форматируем caption + mdCaption := caption + if entities != nil { + mdCaption = tgEntitiesToMarkdown(caption, entities) + } + + m := maxbot.NewMessage().SetChat(maxChatID).SetText(mdCaption) + if mdCaption != caption { + m.SetFormat("markdown") + } + if replyTo != "" { + m.SetReply(mdCaption, replyTo) + } + + // Загружаем и добавляем все фото + photosSent := 0 + for _, it := range items { + if len(it.photoSizes) > 0 { + photo := it.photoSizes[len(it.photoSizes)-1] + fileURL, err := b.tgFileURL(ctx, photo.FileID) + if err != nil { + slog.Error("media group: tgFileURL failed", "err", err) + continue + } + // Если custom TG API — MAX не может скачать по URL, скачиваем сами + if b.cfg.TgAPIURL != "" { + uploaded, err := b.uploadTgPhotoToMax(ctx, photo.FileID) + if err != nil { + slog.Error("media group: photo upload failed", "err", err) + continue + } + m.AddPhoto(uploaded) + } else { + uploaded, err := b.maxApi.Uploads.UploadPhotoFromUrl(ctx, fileURL) + if err != nil { + slog.Error("media group: photo upload failed", "err", err) + continue + } + m.AddPhoto(uploaded) + } + photosSent++ + } + } + + // Загружаем видео из альбома через direct API + videosSent := 0 + var videoTokens []string + for _, it := range items { + if it.videoFileID != "" { + uploaded, err := b.uploadTgMediaToMax(ctx, it.videoFileID, maxschemes.VIDEO, "video.mp4") + if err != nil { + slog.Error("media group: video upload failed", "err", err) + continue + } + videoTokens = append(videoTokens, uploaded.Token) + videosSent++ + } + } + + totalMedia := photosSent + videosSent + if totalMedia == 0 { + slog.Warn("media group: no media uploaded, skipping") + return + } + + slog.Info("TG→MAX sending media group", "photos", photosSent, "videos", videosSent, "uid", uid, "tgChat", items[0].msg.Chat.ID, "maxChat", maxChatID) + + // Если есть фото — отправляем через SDK (поддерживает AddPhoto) + if photosSent > 0 { + result, err := b.maxApi.Messages.SendWithResult(ctx, m) + if err != nil { + slog.Error("TG→MAX media group send failed", "err", err) + if b.cbFail(maxChatID) { + b.tg.SendMessage(ctx, items[0].msg.Chat.ID, + fmt.Sprintf("Не удалось переслать альбом в MAX. Пересылка приостановлена на %d мин. Проверьте, что бот добавлен в MAX-чат и является админом.", int(cbCooldown.Minutes())), nil) + } + // Fallback — по одному + for _, it := range items { + var cap string + if isCrosspost { + cap = formatTgCrosspostCaption(it.msg) + } else { + cap = formatTgCaption(it.msg, prefix, b.cfg.MessageNewline) + } + go b.forwardTgToMax(ctx, it.msg, maxChatID, cap) + } + return + } + b.cbSuccess(maxChatID) + slog.Info("TG→MAX media group sent", "mid", result.Body.Mid, "photos", photosSent) + b.repo.SaveMsg(items[0].msg.Chat.ID, items[0].msg.MessageID, maxChatID, result.Body.Mid) + } + + // Видео отправляем отдельно через direct API (SDK не поддерживает AddVideo) + for i, token := range videoTokens { + videoCaption := "" + if i == 0 && photosSent == 0 { + videoCaption = mdCaption // caption на первое видео если нет фото + } + mid, err := b.sendMaxDirectFormatted(ctx, maxChatID, videoCaption, "video", token, "", "") + if err != nil { + slog.Error("TG→MAX media group video send failed", "err", err) + continue + } + if i == 0 && photosSent == 0 { + b.repo.SaveMsg(items[0].msg.Chat.ID, items[0].msg.MessageID, maxChatID, mid) + } + } +} diff --git a/migrate.go b/migrate.go new file mode 100644 index 0000000..c17a9fc --- /dev/null +++ b/migrate.go @@ -0,0 +1,124 @@ +package main + +import ( + "database/sql" + "embed" + "errors" + "fmt" + "io/fs" + "log" + + "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/database" + "github.com/golang-migrate/migrate/v4/database/postgres" + "github.com/golang-migrate/migrate/v4/database/sqlite3" + "github.com/golang-migrate/migrate/v4/source/iofs" +) + +//go:embed migrations/sqlite/*.sql +var sqliteMigrationsFS embed.FS + +//go:embed migrations/postgres/*.sql +var postgresMigrationsFS embed.FS + +func runMigrations(db *sql.DB, driver string) error { + var ( + sourceFS fs.FS + subdir string + driverName string + ) + + switch driver { + case "sqlite3": + sourceFS = sqliteMigrationsFS + subdir = "migrations/sqlite" + driverName = "sqlite3" + case "postgres": + sourceFS = postgresMigrationsFS + subdir = "migrations/postgres" + driverName = "postgres" + default: + return fmt.Errorf("unsupported migration driver: %s", driver) + } + + // Existing DB compatibility: if tables exist but schema_migrations doesn't, + // force version to 2 (current full state) so migrate doesn't re-apply. + if err := maybeForceVersion(db, driver); err != nil { + return fmt.Errorf("force version check: %w", err) + } + + source, err := iofs.New(sourceFS, subdir) + if err != nil { + return fmt.Errorf("iofs source: %w", err) + } + + var dbDriver database.Driver + switch driverName { + case "sqlite3": + dbDriver, err = sqlite3.WithInstance(db, &sqlite3.Config{}) + case "postgres": + dbDriver, err = postgres.WithInstance(db, &postgres.Config{}) + } + if err != nil { + return fmt.Errorf("migrate db driver: %w", err) + } + + m, err := migrate.NewWithInstance("iofs", source, driverName, dbDriver) + if err != nil { + return fmt.Errorf("migrate instance: %w", err) + } + + if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return fmt.Errorf("migrate up: %w", err) + } + + log.Println("migrations: up to date") + return nil +} + +// maybeForceVersion checks if the DB already has application tables but no +// schema_migrations table. In that case it creates schema_migrations and sets +// version=2 (dirty=false) so golang-migrate won't try to re-apply old migrations. +func maybeForceVersion(db *sql.DB, driver string) error { + hasSchemaTbl := tableExists(db, driver, "schema_migrations") + if hasSchemaTbl { + return nil // already managed by migrate + } + + hasPairs := tableExists(db, driver, "pairs") + if !hasPairs { + return nil // fresh DB, let migrate handle everything + } + + // Existing DB without schema_migrations — force to version 2. + log.Println("migrations: existing DB detected, forcing version to 2") + + switch driver { + case "sqlite3": + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS schema_migrations (version uint64 not null primary key, dirty boolean not null); + INSERT INTO schema_migrations (version, dirty) VALUES (2, false); + `) + return err + case "postgres": + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS schema_migrations (version bigint not null primary key, dirty boolean not null); + INSERT INTO schema_migrations (version, dirty) VALUES (2, false); + `) + return err + } + return nil +} + +func tableExists(db *sql.DB, driver, table string) bool { + var n int + switch driver { + case "sqlite3": + err := db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&n) + return err == nil && n > 0 + case "postgres": + err := db.QueryRow("SELECT count(*) FROM information_schema.tables WHERE table_name=$1", table).Scan(&n) + return err == nil && n > 0 + } + return false +} diff --git a/migrations/postgres/000001_init.down.sql b/migrations/postgres/000001_init.down.sql new file mode 100644 index 0000000..34a1dfc --- /dev/null +++ b/migrations/postgres/000001_init.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS messages; +DROP TABLE IF EXISTS pairs; +DROP TABLE IF EXISTS pending; diff --git a/migrations/postgres/000001_init.up.sql b/migrations/postgres/000001_init.up.sql new file mode 100644 index 0000000..d9aaac2 --- /dev/null +++ b/migrations/postgres/000001_init.up.sql @@ -0,0 +1,24 @@ +CREATE TABLE IF NOT EXISTS pending ( + key TEXT PRIMARY KEY, + platform TEXT NOT NULL, + chat_id BIGINT NOT NULL +); + +CREATE TABLE IF NOT EXISTS pairs ( + tg_chat_id BIGINT NOT NULL, + max_chat_id BIGINT NOT NULL, + PRIMARY KEY (tg_chat_id, max_chat_id) +); + +CREATE INDEX IF NOT EXISTS idx_pairs_tg ON pairs(tg_chat_id); +CREATE INDEX IF NOT EXISTS idx_pairs_max ON pairs(max_chat_id); + +CREATE TABLE IF NOT EXISTS messages ( + tg_chat_id BIGINT NOT NULL, + tg_msg_id INTEGER NOT NULL, + max_chat_id BIGINT NOT NULL, + max_msg_id TEXT NOT NULL, + PRIMARY KEY (tg_chat_id, tg_msg_id) +); + +CREATE INDEX IF NOT EXISTS idx_messages_max ON messages(max_msg_id); diff --git a/migrations/postgres/000002_add_prefix_and_created_at.down.sql b/migrations/postgres/000002_add_prefix_and_created_at.down.sql new file mode 100644 index 0000000..c1d574d --- /dev/null +++ b/migrations/postgres/000002_add_prefix_and_created_at.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE pairs DROP COLUMN prefix; +ALTER TABLE messages DROP COLUMN created_at; diff --git a/migrations/postgres/000002_add_prefix_and_created_at.up.sql b/migrations/postgres/000002_add_prefix_and_created_at.up.sql new file mode 100644 index 0000000..5751df3 --- /dev/null +++ b/migrations/postgres/000002_add_prefix_and_created_at.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE pairs ADD COLUMN prefix INTEGER NOT NULL DEFAULT 1; +ALTER TABLE messages ADD COLUMN created_at BIGINT NOT NULL DEFAULT 0; diff --git a/migrations/postgres/000003_pending_created_at.down.sql b/migrations/postgres/000003_pending_created_at.down.sql new file mode 100644 index 0000000..cf90577 --- /dev/null +++ b/migrations/postgres/000003_pending_created_at.down.sql @@ -0,0 +1 @@ +ALTER TABLE pending DROP COLUMN created_at; diff --git a/migrations/postgres/000003_pending_created_at.up.sql b/migrations/postgres/000003_pending_created_at.up.sql new file mode 100644 index 0000000..92de2ba --- /dev/null +++ b/migrations/postgres/000003_pending_created_at.up.sql @@ -0,0 +1 @@ +ALTER TABLE pending ADD COLUMN created_at BIGINT NOT NULL DEFAULT 0; diff --git a/migrations/postgres/000004_add_crossposts.down.sql b/migrations/postgres/000004_add_crossposts.down.sql new file mode 100644 index 0000000..fb1fe00 --- /dev/null +++ b/migrations/postgres/000004_add_crossposts.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS crossposts; +ALTER TABLE pending DROP COLUMN IF EXISTS command; diff --git a/migrations/postgres/000004_add_crossposts.up.sql b/migrations/postgres/000004_add_crossposts.up.sql new file mode 100644 index 0000000..bd0097e --- /dev/null +++ b/migrations/postgres/000004_add_crossposts.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS crossposts ( + tg_chat_id BIGINT NOT NULL, + max_chat_id BIGINT NOT NULL, + direction TEXT NOT NULL DEFAULT 'both', + created_at BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (tg_chat_id, max_chat_id) +); + +CREATE INDEX IF NOT EXISTS idx_crossposts_tg ON crossposts(tg_chat_id); +CREATE INDEX IF NOT EXISTS idx_crossposts_max ON crossposts(max_chat_id); + +ALTER TABLE pending ADD COLUMN command TEXT NOT NULL DEFAULT 'bridge'; diff --git a/migrations/postgres/000005_add_send_queue.down.sql b/migrations/postgres/000005_add_send_queue.down.sql new file mode 100644 index 0000000..fcb7c40 --- /dev/null +++ b/migrations/postgres/000005_add_send_queue.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS send_queue; diff --git a/migrations/postgres/000005_add_send_queue.up.sql b/migrations/postgres/000005_add_send_queue.up.sql new file mode 100644 index 0000000..f4ddc35 --- /dev/null +++ b/migrations/postgres/000005_add_send_queue.up.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS send_queue ( + id SERIAL PRIMARY KEY, + direction TEXT NOT NULL, + src_chat_id BIGINT NOT NULL, + dst_chat_id BIGINT NOT NULL, + src_msg_id TEXT NOT NULL DEFAULT '', + text TEXT NOT NULL DEFAULT '', + att_type TEXT NOT NULL DEFAULT '', + att_token TEXT NOT NULL DEFAULT '', + reply_to TEXT NOT NULL DEFAULT '', + format TEXT NOT NULL DEFAULT '', + attempts INTEGER NOT NULL DEFAULT 0, + created_at BIGINT NOT NULL, + next_retry BIGINT NOT NULL DEFAULT 0 +); diff --git a/migrations/postgres/000006_crosspost_owner.down.sql b/migrations/postgres/000006_crosspost_owner.down.sql new file mode 100644 index 0000000..91695b1 --- /dev/null +++ b/migrations/postgres/000006_crosspost_owner.down.sql @@ -0,0 +1 @@ +ALTER TABLE crossposts DROP COLUMN owner_id; diff --git a/migrations/postgres/000006_crosspost_owner.up.sql b/migrations/postgres/000006_crosspost_owner.up.sql new file mode 100644 index 0000000..91b7a29 --- /dev/null +++ b/migrations/postgres/000006_crosspost_owner.up.sql @@ -0,0 +1 @@ +ALTER TABLE crossposts ADD COLUMN owner_id BIGINT NOT NULL DEFAULT 0; diff --git a/migrations/postgres/000007_crosspost_soft_delete.down.sql b/migrations/postgres/000007_crosspost_soft_delete.down.sql new file mode 100644 index 0000000..4273a0b --- /dev/null +++ b/migrations/postgres/000007_crosspost_soft_delete.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE crossposts DROP COLUMN deleted_at; +ALTER TABLE crossposts DROP COLUMN deleted_by; diff --git a/migrations/postgres/000007_crosspost_soft_delete.up.sql b/migrations/postgres/000007_crosspost_soft_delete.up.sql new file mode 100644 index 0000000..0d74e98 --- /dev/null +++ b/migrations/postgres/000007_crosspost_soft_delete.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE crossposts ADD COLUMN deleted_at BIGINT NOT NULL DEFAULT 0; +ALTER TABLE crossposts ADD COLUMN deleted_by BIGINT NOT NULL DEFAULT 0; diff --git a/migrations/postgres/000008_add_users.down.sql b/migrations/postgres/000008_add_users.down.sql new file mode 100644 index 0000000..c99ddcd --- /dev/null +++ b/migrations/postgres/000008_add_users.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS users; diff --git a/migrations/postgres/000008_add_users.up.sql b/migrations/postgres/000008_add_users.up.sql new file mode 100644 index 0000000..f89c9ab --- /dev/null +++ b/migrations/postgres/000008_add_users.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS users ( + user_id BIGINT PRIMARY KEY, + platform TEXT NOT NULL, + username TEXT NOT NULL DEFAULT '', + first_name TEXT NOT NULL DEFAULT '', + first_seen BIGINT NOT NULL DEFAULT 0, + last_seen BIGINT NOT NULL DEFAULT 0 +); diff --git a/migrations/postgres/000009_crosspost_tg_owner.down.sql b/migrations/postgres/000009_crosspost_tg_owner.down.sql new file mode 100644 index 0000000..a055774 --- /dev/null +++ b/migrations/postgres/000009_crosspost_tg_owner.down.sql @@ -0,0 +1 @@ +ALTER TABLE crossposts DROP COLUMN tg_owner_id; diff --git a/migrations/postgres/000009_crosspost_tg_owner.up.sql b/migrations/postgres/000009_crosspost_tg_owner.up.sql new file mode 100644 index 0000000..3ea560f --- /dev/null +++ b/migrations/postgres/000009_crosspost_tg_owner.up.sql @@ -0,0 +1 @@ +ALTER TABLE crossposts ADD COLUMN tg_owner_id BIGINT NOT NULL DEFAULT 0; diff --git a/migrations/postgres/000010_queue_att_url.down.sql b/migrations/postgres/000010_queue_att_url.down.sql new file mode 100644 index 0000000..0e611fa --- /dev/null +++ b/migrations/postgres/000010_queue_att_url.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE send_queue DROP COLUMN att_url; +ALTER TABLE send_queue DROP COLUMN parse_mode; diff --git a/migrations/postgres/000010_queue_att_url.up.sql b/migrations/postgres/000010_queue_att_url.up.sql new file mode 100644 index 0000000..0783ee4 --- /dev/null +++ b/migrations/postgres/000010_queue_att_url.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE send_queue ADD COLUMN att_url TEXT NOT NULL DEFAULT ''; +ALTER TABLE send_queue ADD COLUMN parse_mode TEXT NOT NULL DEFAULT ''; diff --git a/migrations/postgres/000011_crosspost_replacements.down.sql b/migrations/postgres/000011_crosspost_replacements.down.sql new file mode 100644 index 0000000..0128b2c --- /dev/null +++ b/migrations/postgres/000011_crosspost_replacements.down.sql @@ -0,0 +1 @@ +ALTER TABLE crossposts DROP COLUMN replacements; diff --git a/migrations/postgres/000011_crosspost_replacements.up.sql b/migrations/postgres/000011_crosspost_replacements.up.sql new file mode 100644 index 0000000..823e2b4 --- /dev/null +++ b/migrations/postgres/000011_crosspost_replacements.up.sql @@ -0,0 +1 @@ +ALTER TABLE crossposts ADD COLUMN replacements TEXT NOT NULL DEFAULT ''; diff --git a/migrations/postgres/000012_pairs_thread_id.down.sql b/migrations/postgres/000012_pairs_thread_id.down.sql new file mode 100644 index 0000000..e7a1ed3 --- /dev/null +++ b/migrations/postgres/000012_pairs_thread_id.down.sql @@ -0,0 +1 @@ +ALTER TABLE pairs DROP COLUMN tg_thread_id; diff --git a/migrations/postgres/000012_pairs_thread_id.up.sql b/migrations/postgres/000012_pairs_thread_id.up.sql new file mode 100644 index 0000000..80c2a4e --- /dev/null +++ b/migrations/postgres/000012_pairs_thread_id.up.sql @@ -0,0 +1 @@ +ALTER TABLE pairs ADD COLUMN tg_thread_id BIGINT NOT NULL DEFAULT 0; diff --git a/migrations/postgres/000013_crosspost_sync_edits.down.sql b/migrations/postgres/000013_crosspost_sync_edits.down.sql new file mode 100644 index 0000000..6c1595c --- /dev/null +++ b/migrations/postgres/000013_crosspost_sync_edits.down.sql @@ -0,0 +1 @@ +ALTER TABLE crossposts DROP COLUMN sync_edits; diff --git a/migrations/postgres/000013_crosspost_sync_edits.up.sql b/migrations/postgres/000013_crosspost_sync_edits.up.sql new file mode 100644 index 0000000..234610b --- /dev/null +++ b/migrations/postgres/000013_crosspost_sync_edits.up.sql @@ -0,0 +1 @@ +ALTER TABLE crossposts ADD COLUMN sync_edits BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/sqlite/000001_init.down.sql b/migrations/sqlite/000001_init.down.sql new file mode 100644 index 0000000..34a1dfc --- /dev/null +++ b/migrations/sqlite/000001_init.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS messages; +DROP TABLE IF EXISTS pairs; +DROP TABLE IF EXISTS pending; diff --git a/migrations/sqlite/000001_init.up.sql b/migrations/sqlite/000001_init.up.sql new file mode 100644 index 0000000..a178c4f --- /dev/null +++ b/migrations/sqlite/000001_init.up.sql @@ -0,0 +1,24 @@ +CREATE TABLE IF NOT EXISTS pending ( + key TEXT PRIMARY KEY, + platform TEXT NOT NULL, + chat_id INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS pairs ( + tg_chat_id INTEGER NOT NULL, + max_chat_id INTEGER NOT NULL, + PRIMARY KEY (tg_chat_id, max_chat_id) +); + +CREATE INDEX IF NOT EXISTS idx_pairs_tg ON pairs(tg_chat_id); +CREATE INDEX IF NOT EXISTS idx_pairs_max ON pairs(max_chat_id); + +CREATE TABLE IF NOT EXISTS messages ( + tg_chat_id INTEGER NOT NULL, + tg_msg_id INTEGER NOT NULL, + max_chat_id INTEGER NOT NULL, + max_msg_id TEXT NOT NULL, + PRIMARY KEY (tg_chat_id, tg_msg_id) +); + +CREATE INDEX IF NOT EXISTS idx_messages_max ON messages(max_msg_id); diff --git a/migrations/sqlite/000002_add_prefix_and_created_at.down.sql b/migrations/sqlite/000002_add_prefix_and_created_at.down.sql new file mode 100644 index 0000000..f32087e --- /dev/null +++ b/migrations/sqlite/000002_add_prefix_and_created_at.down.sql @@ -0,0 +1,24 @@ +-- SQLite doesn't support DROP COLUMN before 3.35.0, so recreate tables + +CREATE TABLE pairs_backup ( + tg_chat_id INTEGER NOT NULL, + max_chat_id INTEGER NOT NULL, + PRIMARY KEY (tg_chat_id, max_chat_id) +); +INSERT INTO pairs_backup SELECT tg_chat_id, max_chat_id FROM pairs; +DROP TABLE pairs; +ALTER TABLE pairs_backup RENAME TO pairs; +CREATE INDEX IF NOT EXISTS idx_pairs_tg ON pairs(tg_chat_id); +CREATE INDEX IF NOT EXISTS idx_pairs_max ON pairs(max_chat_id); + +CREATE TABLE messages_backup ( + tg_chat_id INTEGER NOT NULL, + tg_msg_id INTEGER NOT NULL, + max_chat_id INTEGER NOT NULL, + max_msg_id TEXT NOT NULL, + PRIMARY KEY (tg_chat_id, tg_msg_id) +); +INSERT INTO messages_backup SELECT tg_chat_id, tg_msg_id, max_chat_id, max_msg_id FROM messages; +DROP TABLE messages; +ALTER TABLE messages_backup RENAME TO messages; +CREATE INDEX IF NOT EXISTS idx_messages_max ON messages(max_msg_id); diff --git a/migrations/sqlite/000002_add_prefix_and_created_at.up.sql b/migrations/sqlite/000002_add_prefix_and_created_at.up.sql new file mode 100644 index 0000000..2dde1e0 --- /dev/null +++ b/migrations/sqlite/000002_add_prefix_and_created_at.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE pairs ADD COLUMN prefix INTEGER NOT NULL DEFAULT 1; +ALTER TABLE messages ADD COLUMN created_at INTEGER NOT NULL DEFAULT 0; diff --git a/migrations/sqlite/000003_pending_created_at.down.sql b/migrations/sqlite/000003_pending_created_at.down.sql new file mode 100644 index 0000000..0cc60c2 --- /dev/null +++ b/migrations/sqlite/000003_pending_created_at.down.sql @@ -0,0 +1,4 @@ +CREATE TABLE pending_backup (key TEXT PRIMARY KEY, platform TEXT NOT NULL, chat_id INTEGER NOT NULL); +INSERT INTO pending_backup SELECT key, platform, chat_id FROM pending; +DROP TABLE pending; +ALTER TABLE pending_backup RENAME TO pending; diff --git a/migrations/sqlite/000003_pending_created_at.up.sql b/migrations/sqlite/000003_pending_created_at.up.sql new file mode 100644 index 0000000..8d3387f --- /dev/null +++ b/migrations/sqlite/000003_pending_created_at.up.sql @@ -0,0 +1 @@ +ALTER TABLE pending ADD COLUMN created_at INTEGER NOT NULL DEFAULT 0; diff --git a/migrations/sqlite/000004_add_crossposts.down.sql b/migrations/sqlite/000004_add_crossposts.down.sql new file mode 100644 index 0000000..90c9cfe --- /dev/null +++ b/migrations/sqlite/000004_add_crossposts.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS crossposts; diff --git a/migrations/sqlite/000004_add_crossposts.up.sql b/migrations/sqlite/000004_add_crossposts.up.sql new file mode 100644 index 0000000..0b46233 --- /dev/null +++ b/migrations/sqlite/000004_add_crossposts.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS crossposts ( + tg_chat_id INTEGER NOT NULL, + max_chat_id INTEGER NOT NULL, + direction TEXT NOT NULL DEFAULT 'both', + created_at INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (tg_chat_id, max_chat_id) +); + +CREATE INDEX IF NOT EXISTS idx_crossposts_tg ON crossposts(tg_chat_id); +CREATE INDEX IF NOT EXISTS idx_crossposts_max ON crossposts(max_chat_id); + +ALTER TABLE pending ADD COLUMN command TEXT NOT NULL DEFAULT 'bridge'; diff --git a/migrations/sqlite/000005_add_send_queue.down.sql b/migrations/sqlite/000005_add_send_queue.down.sql new file mode 100644 index 0000000..fcb7c40 --- /dev/null +++ b/migrations/sqlite/000005_add_send_queue.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS send_queue; diff --git a/migrations/sqlite/000005_add_send_queue.up.sql b/migrations/sqlite/000005_add_send_queue.up.sql new file mode 100644 index 0000000..471017e --- /dev/null +++ b/migrations/sqlite/000005_add_send_queue.up.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS send_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + direction TEXT NOT NULL, -- "tg2max" or "max2tg" + src_chat_id INTEGER NOT NULL, + dst_chat_id INTEGER NOT NULL, + src_msg_id TEXT NOT NULL DEFAULT '', + text TEXT NOT NULL DEFAULT '', + att_type TEXT NOT NULL DEFAULT '', -- "video", "file", "audio", "" + att_token TEXT NOT NULL DEFAULT '', + reply_to TEXT NOT NULL DEFAULT '', + format TEXT NOT NULL DEFAULT '', + attempts INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + next_retry INTEGER NOT NULL DEFAULT 0 +); diff --git a/migrations/sqlite/000006_crosspost_owner.down.sql b/migrations/sqlite/000006_crosspost_owner.down.sql new file mode 100644 index 0000000..91695b1 --- /dev/null +++ b/migrations/sqlite/000006_crosspost_owner.down.sql @@ -0,0 +1 @@ +ALTER TABLE crossposts DROP COLUMN owner_id; diff --git a/migrations/sqlite/000006_crosspost_owner.up.sql b/migrations/sqlite/000006_crosspost_owner.up.sql new file mode 100644 index 0000000..85ebea8 --- /dev/null +++ b/migrations/sqlite/000006_crosspost_owner.up.sql @@ -0,0 +1 @@ +ALTER TABLE crossposts ADD COLUMN owner_id INTEGER NOT NULL DEFAULT 0; diff --git a/migrations/sqlite/000007_crosspost_soft_delete.down.sql b/migrations/sqlite/000007_crosspost_soft_delete.down.sql new file mode 100644 index 0000000..4273a0b --- /dev/null +++ b/migrations/sqlite/000007_crosspost_soft_delete.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE crossposts DROP COLUMN deleted_at; +ALTER TABLE crossposts DROP COLUMN deleted_by; diff --git a/migrations/sqlite/000007_crosspost_soft_delete.up.sql b/migrations/sqlite/000007_crosspost_soft_delete.up.sql new file mode 100644 index 0000000..02fb210 --- /dev/null +++ b/migrations/sqlite/000007_crosspost_soft_delete.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE crossposts ADD COLUMN deleted_at INTEGER NOT NULL DEFAULT 0; +ALTER TABLE crossposts ADD COLUMN deleted_by INTEGER NOT NULL DEFAULT 0; diff --git a/migrations/sqlite/000008_add_users.down.sql b/migrations/sqlite/000008_add_users.down.sql new file mode 100644 index 0000000..c99ddcd --- /dev/null +++ b/migrations/sqlite/000008_add_users.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS users; diff --git a/migrations/sqlite/000008_add_users.up.sql b/migrations/sqlite/000008_add_users.up.sql new file mode 100644 index 0000000..9a93345 --- /dev/null +++ b/migrations/sqlite/000008_add_users.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS users ( + user_id INTEGER PRIMARY KEY, + platform TEXT NOT NULL, + username TEXT NOT NULL DEFAULT '', + first_name TEXT NOT NULL DEFAULT '', + first_seen INTEGER NOT NULL DEFAULT 0, + last_seen INTEGER NOT NULL DEFAULT 0 +); diff --git a/migrations/sqlite/000009_crosspost_tg_owner.down.sql b/migrations/sqlite/000009_crosspost_tg_owner.down.sql new file mode 100644 index 0000000..a055774 --- /dev/null +++ b/migrations/sqlite/000009_crosspost_tg_owner.down.sql @@ -0,0 +1 @@ +ALTER TABLE crossposts DROP COLUMN tg_owner_id; diff --git a/migrations/sqlite/000009_crosspost_tg_owner.up.sql b/migrations/sqlite/000009_crosspost_tg_owner.up.sql new file mode 100644 index 0000000..71fe57b --- /dev/null +++ b/migrations/sqlite/000009_crosspost_tg_owner.up.sql @@ -0,0 +1 @@ +ALTER TABLE crossposts ADD COLUMN tg_owner_id INTEGER NOT NULL DEFAULT 0; diff --git a/migrations/sqlite/000010_queue_att_url.down.sql b/migrations/sqlite/000010_queue_att_url.down.sql new file mode 100644 index 0000000..0e611fa --- /dev/null +++ b/migrations/sqlite/000010_queue_att_url.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE send_queue DROP COLUMN att_url; +ALTER TABLE send_queue DROP COLUMN parse_mode; diff --git a/migrations/sqlite/000010_queue_att_url.up.sql b/migrations/sqlite/000010_queue_att_url.up.sql new file mode 100644 index 0000000..0783ee4 --- /dev/null +++ b/migrations/sqlite/000010_queue_att_url.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE send_queue ADD COLUMN att_url TEXT NOT NULL DEFAULT ''; +ALTER TABLE send_queue ADD COLUMN parse_mode TEXT NOT NULL DEFAULT ''; diff --git a/migrations/sqlite/000011_crosspost_replacements.down.sql b/migrations/sqlite/000011_crosspost_replacements.down.sql new file mode 100644 index 0000000..f16c610 --- /dev/null +++ b/migrations/sqlite/000011_crosspost_replacements.down.sql @@ -0,0 +1,18 @@ +-- SQLite does not support DROP COLUMN before 3.35.0, so we recreate the table. +CREATE TABLE crossposts_backup AS SELECT tg_chat_id, max_chat_id, direction, created_at, owner_id, tg_owner_id, deleted_at, deleted_by FROM crossposts; +DROP TABLE crossposts; +CREATE TABLE crossposts ( + tg_chat_id INTEGER NOT NULL, + max_chat_id INTEGER NOT NULL, + direction TEXT NOT NULL DEFAULT 'both', + created_at INTEGER NOT NULL DEFAULT 0, + owner_id INTEGER NOT NULL DEFAULT 0, + tg_owner_id INTEGER NOT NULL DEFAULT 0, + deleted_at INTEGER NOT NULL DEFAULT 0, + deleted_by INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (tg_chat_id, max_chat_id) +); +INSERT INTO crossposts SELECT * FROM crossposts_backup; +DROP TABLE crossposts_backup; +CREATE INDEX IF NOT EXISTS idx_crossposts_tg ON crossposts(tg_chat_id); +CREATE INDEX IF NOT EXISTS idx_crossposts_max ON crossposts(max_chat_id); diff --git a/migrations/sqlite/000011_crosspost_replacements.up.sql b/migrations/sqlite/000011_crosspost_replacements.up.sql new file mode 100644 index 0000000..823e2b4 --- /dev/null +++ b/migrations/sqlite/000011_crosspost_replacements.up.sql @@ -0,0 +1 @@ +ALTER TABLE crossposts ADD COLUMN replacements TEXT NOT NULL DEFAULT ''; diff --git a/migrations/sqlite/000012_pairs_thread_id.down.sql b/migrations/sqlite/000012_pairs_thread_id.down.sql new file mode 100644 index 0000000..e7a1ed3 --- /dev/null +++ b/migrations/sqlite/000012_pairs_thread_id.down.sql @@ -0,0 +1 @@ +ALTER TABLE pairs DROP COLUMN tg_thread_id; diff --git a/migrations/sqlite/000012_pairs_thread_id.up.sql b/migrations/sqlite/000012_pairs_thread_id.up.sql new file mode 100644 index 0000000..26da086 --- /dev/null +++ b/migrations/sqlite/000012_pairs_thread_id.up.sql @@ -0,0 +1 @@ +ALTER TABLE pairs ADD COLUMN tg_thread_id INTEGER NOT NULL DEFAULT 0; diff --git a/migrations/sqlite/000013_crosspost_sync_edits.down.sql b/migrations/sqlite/000013_crosspost_sync_edits.down.sql new file mode 100644 index 0000000..9bff51b --- /dev/null +++ b/migrations/sqlite/000013_crosspost_sync_edits.down.sql @@ -0,0 +1 @@ +-- SQLite does not support DROP COLUMN before 3.35.0 diff --git a/migrations/sqlite/000013_crosspost_sync_edits.up.sql b/migrations/sqlite/000013_crosspost_sync_edits.up.sql new file mode 100644 index 0000000..f407562 --- /dev/null +++ b/migrations/sqlite/000013_crosspost_sync_edits.up.sql @@ -0,0 +1 @@ +ALTER TABLE crossposts ADD COLUMN sync_edits INTEGER NOT NULL DEFAULT 0; diff --git a/postgres.go b/postgres.go new file mode 100644 index 0000000..937bc42 --- /dev/null +++ b/postgres.go @@ -0,0 +1,341 @@ +package main + +import ( + "database/sql" + "sync" + "time" + + _ "github.com/lib/pq" +) + +type pgRepo struct { + db *sql.DB + mu sync.Mutex +} + +func NewPostgresRepo(dsn string) (Repository, error) { + db, err := sql.Open("postgres", dsn) + if err != nil { + return nil, err + } + if err := db.Ping(); err != nil { + return nil, err + } + + if err := runMigrations(db, "postgres"); err != nil { + return nil, err + } + + return &pgRepo{db: db}, nil +} + +func (r *pgRepo) Register(key, platform string, chatID int64) (bool, string, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if key == "" { + var existing string + err := r.db.QueryRow("SELECT key FROM pending WHERE platform = $1 AND chat_id = $2 AND command = 'bridge'", platform, chatID).Scan(&existing) + if err == nil { + return false, existing, nil + } + generated := genKey() + _, err = r.db.Exec("INSERT INTO pending (key, platform, chat_id, created_at, command) VALUES ($1, $2, $3, $4, 'bridge')", generated, platform, chatID, time.Now().Unix()) + return false, generated, err + } + + var peerPlatform string + var peerChatID int64 + err := r.db.QueryRow("SELECT platform, chat_id FROM pending WHERE key = $1 AND command = 'bridge'", key).Scan(&peerPlatform, &peerChatID) + if err != nil { + return false, "", nil + } + if peerPlatform == platform { + return false, "", nil + } + + r.db.Exec("DELETE FROM pending WHERE key = $1", key) + + var tgID, maxID int64 + if platform == "tg" { + tgID, maxID = chatID, peerChatID + } else { + tgID, maxID = peerChatID, chatID + } + + _, err = r.db.Exec( + "INSERT INTO pairs (tg_chat_id, max_chat_id) VALUES ($1, $2) ON CONFLICT (tg_chat_id, max_chat_id) DO NOTHING", + tgID, maxID) + return true, "", err +} + +func (r *pgRepo) MigrateTgChat(oldID, newID int64) error { + _, err := r.db.Exec("UPDATE pairs SET tg_chat_id = $1 WHERE tg_chat_id = $2", newID, oldID) + if err == nil { + r.db.Exec("UPDATE messages SET tg_chat_id = $1 WHERE tg_chat_id = $2", newID, oldID) + } + return err +} + +func (r *pgRepo) GetMaxChat(tgChatID int64) (int64, bool) { + var id int64 + err := r.db.QueryRow("SELECT max_chat_id FROM pairs WHERE tg_chat_id = $1", tgChatID).Scan(&id) + return id, err == nil +} + +func (r *pgRepo) GetTgChat(maxChatID int64) (int64, bool) { + var id int64 + err := r.db.QueryRow("SELECT tg_chat_id FROM pairs WHERE max_chat_id = $1", maxChatID).Scan(&id) + return id, err == nil +} + +func (r *pgRepo) SaveMsg(tgChatID int64, tgMsgID int, maxChatID int64, maxMsgID string) { + r.db.Exec( + `INSERT INTO messages (tg_chat_id, tg_msg_id, max_chat_id, max_msg_id, created_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (tg_chat_id, tg_msg_id) DO UPDATE + SET max_chat_id = EXCLUDED.max_chat_id, max_msg_id = EXCLUDED.max_msg_id, created_at = EXCLUDED.created_at`, + tgChatID, tgMsgID, maxChatID, maxMsgID, time.Now().Unix()) +} + +func (r *pgRepo) LookupMaxMsgID(tgChatID int64, tgMsgID int) (string, bool) { + var id string + err := r.db.QueryRow("SELECT max_msg_id FROM messages WHERE tg_chat_id = $1 AND tg_msg_id = $2", tgChatID, tgMsgID).Scan(&id) + return id, err == nil +} + +func (r *pgRepo) LookupTgMsgID(maxMsgID string) (int64, int, bool) { + var chatID int64 + var msgID int + err := r.db.QueryRow("SELECT tg_chat_id, tg_msg_id FROM messages WHERE max_msg_id = $1", maxMsgID).Scan(&chatID, &msgID) + return chatID, msgID, err == nil +} + +func (r *pgRepo) CleanOldMessages() { + r.db.Exec("DELETE FROM messages WHERE created_at < $1", time.Now().Unix()-48*3600) + r.db.Exec("DELETE FROM pending WHERE created_at > 0 AND created_at < $1", time.Now().Unix()-3600) +} + +func (r *pgRepo) HasPrefix(platform string, chatID int64) bool { + var v int + var err error + if platform == "tg" { + err = r.db.QueryRow("SELECT prefix FROM pairs WHERE tg_chat_id = $1", chatID).Scan(&v) + } else { + err = r.db.QueryRow("SELECT prefix FROM pairs WHERE max_chat_id = $1", chatID).Scan(&v) + } + if err != nil { + return true + } + return v == 1 +} + +func (r *pgRepo) SetPrefix(platform string, chatID int64, on bool) bool { + v := 0 + if on { + v = 1 + } + var res sql.Result + if platform == "tg" { + res, _ = r.db.Exec("UPDATE pairs SET prefix = $1 WHERE tg_chat_id = $2", v, chatID) + } else { + res, _ = r.db.Exec("UPDATE pairs SET prefix = $1 WHERE max_chat_id = $2", v, chatID) + } + if res == nil { + return false + } + n, _ := res.RowsAffected() + return n > 0 +} + +func (r *pgRepo) Unpair(platform string, chatID int64) bool { + r.mu.Lock() + defer r.mu.Unlock() + var res sql.Result + if platform == "tg" { + res, _ = r.db.Exec("DELETE FROM pairs WHERE tg_chat_id = $1", chatID) + } else { + res, _ = r.db.Exec("DELETE FROM pairs WHERE max_chat_id = $1", chatID) + } + if res == nil { + return false + } + n, _ := res.RowsAffected() + return n > 0 +} + +func (r *pgRepo) GetTgThreadID(tgChatID int64) int { + var id int + r.db.QueryRow("SELECT COALESCE(tg_thread_id, 0) FROM pairs WHERE tg_chat_id = $1", tgChatID).Scan(&id) + return id +} + +func (r *pgRepo) SetTgThreadID(tgChatID int64, threadID int) error { + r.mu.Lock() + defer r.mu.Unlock() + _, err := r.db.Exec("UPDATE pairs SET tg_thread_id = $1 WHERE tg_chat_id = $2", threadID, tgChatID) + return err +} + +func (r *pgRepo) PairCrosspost(tgChatID, maxChatID, ownerID, tgOwnerID int64) error { + _, err := r.db.Exec( + "INSERT INTO crossposts (tg_chat_id, max_chat_id, created_at, owner_id, tg_owner_id) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (tg_chat_id, max_chat_id) DO NOTHING", + tgChatID, maxChatID, time.Now().Unix(), ownerID, tgOwnerID) + return err +} + +func (r *pgRepo) GetCrosspostOwner(maxChatID int64) (maxOwner, tgOwner int64) { + r.db.QueryRow("SELECT owner_id, tg_owner_id FROM crossposts WHERE max_chat_id = $1 AND deleted_at = 0", maxChatID).Scan(&maxOwner, &tgOwner) + return +} + +func (r *pgRepo) GetCrosspostMaxChat(tgChatID int64) (int64, string, bool) { + var id int64 + var dir string + err := r.db.QueryRow("SELECT max_chat_id, direction FROM crossposts WHERE tg_chat_id = $1 AND deleted_at = 0", tgChatID).Scan(&id, &dir) + return id, dir, err == nil +} + +func (r *pgRepo) GetCrosspostTgChat(maxChatID int64) (int64, string, bool) { + var id int64 + var dir string + err := r.db.QueryRow("SELECT tg_chat_id, direction FROM crossposts WHERE max_chat_id = $1 AND deleted_at = 0", maxChatID).Scan(&id, &dir) + return id, dir, err == nil +} + +func (r *pgRepo) ListCrossposts(ownerID int64) []CrosspostLink { + rows, err := r.db.Query("SELECT tg_chat_id, max_chat_id, direction FROM crossposts WHERE (owner_id = $1 OR tg_owner_id = $1 OR (owner_id = 0 AND tg_owner_id = 0)) AND deleted_at = 0", ownerID) + if err != nil { + return nil + } + defer rows.Close() + var links []CrosspostLink + for rows.Next() { + var l CrosspostLink + if rows.Scan(&l.TgChatID, &l.MaxChatID, &l.Direction) == nil { + links = append(links, l) + } + } + return links +} + +func (r *pgRepo) SetCrosspostDirection(maxChatID int64, direction string) bool { + res, _ := r.db.Exec("UPDATE crossposts SET direction = $1 WHERE max_chat_id = $2 AND deleted_at = 0", direction, maxChatID) + if res == nil { + return false + } + n, _ := res.RowsAffected() + return n > 0 +} + +func (r *pgRepo) UnpairCrosspost(maxChatID, deletedBy int64) bool { + r.mu.Lock() + defer r.mu.Unlock() + res, _ := r.db.Exec("UPDATE crossposts SET deleted_at = $1, deleted_by = $2 WHERE max_chat_id = $3 AND deleted_at = 0", + time.Now().Unix(), deletedBy, maxChatID) + if res == nil { + return false + } + n, _ := res.RowsAffected() + return n > 0 +} + +func (r *pgRepo) GetCrosspostReplacements(maxChatID int64) CrosspostReplacements { + var raw string + r.db.QueryRow("SELECT replacements FROM crossposts WHERE max_chat_id = $1 AND deleted_at = 0", maxChatID).Scan(&raw) + return parseCrosspostReplacements(raw) +} + +func (r *pgRepo) SetCrosspostReplacements(maxChatID int64, repl CrosspostReplacements) error { + data := marshalCrosspostReplacements(repl) + r.mu.Lock() + defer r.mu.Unlock() + _, err := r.db.Exec("UPDATE crossposts SET replacements = $1 WHERE max_chat_id = $2 AND deleted_at = 0", data, maxChatID) + return err +} + +func (r *pgRepo) GetCrosspostSyncEdits(maxChatID int64) bool { + var v bool + r.db.QueryRow("SELECT COALESCE(sync_edits, FALSE) FROM crossposts WHERE max_chat_id = $1 AND deleted_at = 0", maxChatID).Scan(&v) + return v +} + +func (r *pgRepo) SetCrosspostSyncEdits(maxChatID int64, on bool) error { + r.mu.Lock() + defer r.mu.Unlock() + _, err := r.db.Exec("UPDATE crossposts SET sync_edits = $1 WHERE max_chat_id = $2 AND deleted_at = 0", on, maxChatID) + return err +} + +func (r *pgRepo) TouchUser(userID int64, platform, username, firstName string) { + 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) + ON CONFLICT(user_id) DO UPDATE SET username=EXCLUDED.username, first_name=EXCLUDED.first_name, last_seen=EXCLUDED.last_seen`, + userID, platform, username, firstName, now) +} + +func (r *pgRepo) ListUsers(platform string) ([]int64, error) { + rows, err := r.db.Query("SELECT user_id FROM users WHERE platform = $1", platform) + if err != nil { + return nil, err + } + defer rows.Close() + var ids []int64 + for rows.Next() { + var id int64 + if rows.Scan(&id) == nil { + ids = append(ids, id) + } + } + return ids, nil +} + +func (r *pgRepo) EnqueueSend(item *QueueItem) error { + _, err := r.db.Exec( + `INSERT INTO send_queue (direction, src_chat_id, dst_chat_id, src_msg_id, text, att_type, att_token, reply_to, format, att_url, parse_mode, attempts, created_at, next_retry) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 0, $12, $13)`, + item.Direction, item.SrcChatID, item.DstChatID, item.SrcMsgID, + item.Text, item.AttType, item.AttToken, item.ReplyTo, item.Format, + item.AttURL, item.ParseMode, + item.CreatedAt, item.NextRetry, + ) + return err +} + +func (r *pgRepo) PeekQueue(limit int) ([]QueueItem, error) { + rows, err := r.db.Query( + `SELECT id, direction, src_chat_id, dst_chat_id, src_msg_id, text, att_type, att_token, reply_to, format, att_url, parse_mode, attempts, created_at, next_retry + FROM send_queue WHERE next_retry <= $1 ORDER BY id ASC LIMIT $2`, + time.Now().Unix(), limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []QueueItem + for rows.Next() { + var q QueueItem + if err := rows.Scan(&q.ID, &q.Direction, &q.SrcChatID, &q.DstChatID, &q.SrcMsgID, + &q.Text, &q.AttType, &q.AttToken, &q.ReplyTo, &q.Format, + &q.AttURL, &q.ParseMode, + &q.Attempts, &q.CreatedAt, &q.NextRetry); err != nil { + return nil, err + } + items = append(items, q) + } + return items, nil +} + +func (r *pgRepo) DeleteFromQueue(id int64) error { + _, err := r.db.Exec("DELETE FROM send_queue WHERE id = $1", id) + return err +} + +func (r *pgRepo) IncrementAttempt(id int64, nextRetry int64) error { + _, err := r.db.Exec("UPDATE send_queue SET attempts = attempts + 1, next_retry = $1 WHERE id = $2", nextRetry, id) + return err +} + +func (r *pgRepo) Close() error { + return r.db.Close() +} diff --git a/queue.go b/queue.go new file mode 100644 index 0000000..04b40e3 --- /dev/null +++ b/queue.go @@ -0,0 +1,176 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "strconv" + "strings" + "time" +) + +const ( + queueMaxAttempts = 30 // максимум попыток + queueMaxAge = 24 * time.Hour // дропаем сообщения старше 24 часов + queueBatchSize = 10 +) + +// retryDelay возвращает задержку перед следующей попыткой (экспоненциально). +func retryDelay(attempt int) time.Duration { + switch { + case attempt < 3: + return 10 * time.Second + case attempt < 6: + return 30 * time.Second + case attempt < 10: + return 1 * time.Minute + default: + return 2 * time.Minute + } +} + +// enqueueTg2Max ставит сообщение TG→MAX в очередь. +func (b *Bridge) enqueueTg2Max(tgChatID int64, tgMsgID int, maxChatID int64, text, attType, attToken, replyTo, format string) { + now := time.Now().Unix() + item := &QueueItem{ + Direction: "tg2max", + SrcChatID: tgChatID, + DstChatID: maxChatID, + SrcMsgID: strconv.Itoa(tgMsgID), + Text: text, + AttType: attType, + AttToken: attToken, + ReplyTo: replyTo, + Format: format, + CreatedAt: now, + NextRetry: now + int64(retryDelay(0).Seconds()), + } + if err := b.repo.EnqueueSend(item); err != nil { + slog.Error("enqueue failed", "err", err) + } else { + slog.Info("enqueued for retry", "dir", "tg2max", "dst", maxChatID) + } +} + +// enqueueMax2Tg ставит сообщение MAX→TG в очередь. +func (b *Bridge) enqueueMax2Tg(maxChatID, tgChatID int64, maxMid, text, attType, attURL, parseMode string) { + now := time.Now().Unix() + item := &QueueItem{ + Direction: "max2tg", + SrcChatID: maxChatID, + DstChatID: tgChatID, + SrcMsgID: maxMid, + Text: text, + AttType: attType, + AttURL: attURL, + ParseMode: parseMode, + CreatedAt: now, + NextRetry: now + int64(retryDelay(0).Seconds()), + } + if err := b.repo.EnqueueSend(item); err != nil { + slog.Error("enqueue failed", "err", err) + } else { + slog.Info("enqueued for retry", "dir", "max2tg", "dst", tgChatID) + } +} + +// processQueue обрабатывает очередь — вызывается периодически. +func (b *Bridge) processQueue(ctx context.Context) { + items, err := b.repo.PeekQueue(queueBatchSize) + if err != nil { + slog.Error("peek queue failed", "err", err) + return + } + + now := time.Now() + for _, item := range items { + // Слишком старое или слишком много попыток — дропаем + age := now.Sub(time.Unix(item.CreatedAt, 0)) + if item.Attempts >= queueMaxAttempts || age > queueMaxAge { + slog.Warn("queue item expired", "id", item.ID, "dir", item.Direction, "attempts", item.Attempts, "age", age) + b.repo.DeleteFromQueue(item.ID) + if item.Direction == "tg2max" { + b.tg.SendMessage(ctx, item.SrcChatID, fmt.Sprintf("Сообщение не доставлено в MAX после %d попыток.", item.Attempts), nil) + } + continue + } + + switch item.Direction { + case "tg2max": + b.processQueueTg2Max(ctx, item, now) + case "max2tg": + b.processQueueMax2Tg(ctx, item, now) + } + } +} + +func (b *Bridge) processQueueTg2Max(ctx context.Context, item QueueItem, now time.Time) { + mid, err := b.sendMaxDirectFormatted(ctx, item.DstChatID, item.Text, item.AttType, item.AttToken, item.ReplyTo, item.Format) + if err != nil { + errStr := err.Error() + // Permanent errors — дропаем + if strings.Contains(errStr, "403") || strings.Contains(errStr, "404") || strings.Contains(errStr, "chat.denied") { + slog.Warn("queue item dropped (permanent error)", "id", item.ID, "err", errStr) + b.repo.DeleteFromQueue(item.ID) + return + } + slog.Warn("queue retry failed", "id", item.ID, "dir", "tg2max", "attempt", item.Attempts+1, "err", err) + b.repo.IncrementAttempt(item.ID, now.Add(retryDelay(item.Attempts+1)).Unix()) + return + } + slog.Info("queue retry ok", "id", item.ID, "dir", "tg2max", "mid", mid) + tgMsgID, _ := strconv.Atoi(item.SrcMsgID) + if tgMsgID > 0 { + b.repo.SaveMsg(item.SrcChatID, tgMsgID, item.DstChatID, mid) + } + b.repo.DeleteFromQueue(item.ID) +} + +func (b *Bridge) processQueueMax2Tg(ctx context.Context, item QueueItem, now time.Time) { + var sentMsgID int + var err error + + threadID := b.repo.GetTgThreadID(item.DstChatID) + + if item.AttType != "" && item.AttURL != "" { + opts := &SendOpts{Caption: item.Text, ParseMode: item.ParseMode, ThreadID: threadID} + switch item.AttType { + case "photo": + sentMsgID, err = b.tg.SendPhoto(ctx, item.DstChatID, FileArg{URL: item.AttURL}, opts) + case "video": + sentMsgID, err = b.tg.SendVideo(ctx, item.DstChatID, FileArg{URL: item.AttURL}, opts) + case "audio": + sentMsgID, err = b.tg.SendAudio(ctx, item.DstChatID, FileArg{URL: item.AttURL}, opts) + case "file": + sentMsgID, err = b.tg.SendDocument(ctx, item.DstChatID, FileArg{URL: item.AttURL}, opts) + default: + sentMsgID, err = b.tg.SendPhoto(ctx, item.DstChatID, FileArg{URL: item.AttURL}, opts) + } + } else { + sentMsgID, err = b.tg.SendMessage(ctx, item.DstChatID, item.Text, &SendOpts{ParseMode: item.ParseMode, ThreadID: threadID}) + } + + if err != nil { + errStr := err.Error() + // Топики выключены — сбрасываем и повторяем без thread_id + if threadID != 0 && (strings.Contains(errStr, "message thread not found") || + strings.Contains(errStr, "TOPIC_NOT_FOUND") || + strings.Contains(errStr, "topics are disabled")) { + slog.Info("queue: forum topics disabled, resetting thread_id", "tgChat", item.DstChatID) + b.repo.SetTgThreadID(item.DstChatID, 0) + b.repo.IncrementAttempt(item.ID, now.Unix()) // retry immediately + return + } + if strings.Contains(errStr, "TOPIC_CLOSED") || strings.Contains(errStr, "403") || strings.Contains(errStr, "chat not found") { + slog.Warn("queue item dropped (permanent error)", "id", item.ID, "dir", "max2tg", "err", errStr) + b.repo.DeleteFromQueue(item.ID) + return + } + slog.Warn("queue retry failed", "id", item.ID, "dir", "max2tg", "attempt", item.Attempts+1, "err", err) + b.repo.IncrementAttempt(item.ID, now.Add(retryDelay(item.Attempts+1)).Unix()) + return + } + slog.Info("queue retry ok", "id", item.ID, "dir", "max2tg", "msgID", sentMsgID) + b.repo.SaveMsg(item.DstChatID, sentMsgID, item.SrcChatID, item.SrcMsgID) + b.repo.DeleteFromQueue(item.ID) +} diff --git a/replacements.go b/replacements.go new file mode 100644 index 0000000..97da45c --- /dev/null +++ b/replacements.go @@ -0,0 +1,234 @@ +package main + +import ( + "encoding/json" + "fmt" + "log/slog" + "regexp" + "strings" + "sync" + + maxbot "github.com/max-messenger/max-bot-api-client-go" + maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes" +) + +// parseCrosspostReplacements парсит JSON из БД в структуру. +func parseCrosspostReplacements(raw string) CrosspostReplacements { + if raw == "" { + return CrosspostReplacements{} + } + var r CrosspostReplacements + if err := json.Unmarshal([]byte(raw), &r); err != nil { + slog.Warn("failed to parse replacements", "err", err) + return CrosspostReplacements{} + } + return r +} + +// marshalCrosspostReplacements сериализует структуру в JSON. +func marshalCrosspostReplacements(r CrosspostReplacements) string { + if len(r.TgToMax) == 0 && len(r.MaxToTg) == 0 { + return "" + } + data, _ := json.Marshal(r) + return string(data) +} + +// urlRegex матчит URL в тексте. +var urlRegex = regexp.MustCompile(`https?://[^\s<>"]+`) + +// applyReplacements применяет список замен к тексту. +func applyReplacements(text string, rules []Replacement) string { + for _, r := range rules { + if r.From == "" { + continue + } + if r.Target == "links" { + text = applyToLinks(text, r) + } else { + text = applyToAll(text, r) + } + } + return text +} + +func applyToAll(text string, r Replacement) string { + if r.Regex { + re, err := regexp.Compile(r.From) + if err != nil { + slog.Warn("invalid replacement regex", "pattern", r.From, "err", err) + return text + } + return re.ReplaceAllString(text, r.To) + } + return strings.ReplaceAll(text, r.From, r.To) +} + +func applyToLinks(text string, r Replacement) string { + return urlRegex.ReplaceAllStringFunc(text, func(url string) string { + if r.Regex { + re, err := regexp.Compile(r.From) + if err != nil { + return url + } + return re.ReplaceAllString(url, r.To) + } + return strings.ReplaceAll(url, r.From, r.To) + }) +} + +// formatReplacementItem форматирует одну замену для отдельного сообщения. +func formatReplacementItem(r Replacement, dir string) string { + dirLabel := "TG → MAX" + if dir == "max>tg" { + dirLabel = "MAX → TG" + } + targetLabel := "весь текст" + if r.Target == "links" { + targetLabel = "только ссылки" + } + return fmt.Sprintf("%s %s\n%s%s\nТип: %s", dirLabel, replacementTags(r), r.From, r.To, targetLabel) +} + +// formatReplacementsHeader формирует заголовок для списка замен. +func formatReplacementsHeader(repl CrosspostReplacements) string { + total := len(repl.TgToMax) + len(repl.MaxToTg) + if total == 0 { + return "🔄 Замен нет.\n\nДобавьте замену — текст в пересылаемых постах будет автоматически заменяться." + } + return fmt.Sprintf("🔄 Замены (%d):", total) +} + +// replacementTags возвращает теги для отображения замены. +func replacementTags(r Replacement) string { + var tags []string + if r.Regex { + tags = append(tags, "regex") + } + if r.Target == "links" { + tags = append(tags, "ссылки") + } + if len(tags) == 0 { + return "" + } + return "[" + strings.Join(tags, ", ") + "] " +} + +// tgReplacementsKeyboard строит inline-клавиатуру для управления заменами. +func tgReplacementsKeyboard(maxChatID int64) *InlineKeyboardMarkup { + id := fmt.Sprintf("%d", maxChatID) + return NewInlineKeyboard( + NewInlineRow( + NewInlineButton("+ TG→MAX", "cpra:tg>max:"+id), + NewInlineButton("+ MAX→TG", "cpra:max>tg:"+id), + ), + NewInlineRow( + NewInlineButton("🗑 Очистить всё", "cprc:"+id), + NewInlineButton("◀ Назад", "cprb:"+id), + ), + ) +} + +// tgReplItemKeyboard — кнопки для одной замены в TG. +func tgReplItemKeyboard(dir string, idx int, maxChatID string, currentTarget string) *InlineKeyboardMarkup { + toggleLabel := "🔗 Только ссылки" + toggleTarget := "links" + if currentTarget == "links" { + toggleLabel = "📝 Весь текст" + toggleTarget = "all" + } + return NewInlineKeyboard( + NewInlineRow( + NewInlineButton(toggleLabel, fmt.Sprintf("cprt:%s:%d:%s:%s", dir, idx, toggleTarget, maxChatID)), + NewInlineButton("❌ Удалить", fmt.Sprintf("cprd:%s:%d:%s", dir, idx, maxChatID)), + ), + ) +} + +// maxReplacementsKeyboard строит inline-клавиатуру для управления заменами в MAX. +func maxReplacementsKeyboard(api *maxbot.Api, maxChatID int64) *maxbot.Keyboard { + id := fmt.Sprintf("%d", maxChatID) + kb := api.Messages.NewKeyboardBuilder() + kb.AddRow(). + AddCallback("+ TG→MAX", maxschemes.DEFAULT, "cpra:tg>max:"+id). + AddCallback("+ MAX→TG", maxschemes.DEFAULT, "cpra:max>tg:"+id) + kb.AddRow(). + AddCallback("🗑 Очистить всё", maxschemes.NEGATIVE, "cprc:"+id). + AddCallback("◀ Назад", maxschemes.DEFAULT, "cprb:"+id) + return kb +} + +// maxReplItemKeyboard — кнопки для одной замены в MAX. +func maxReplItemKeyboard(api *maxbot.Api, dir string, idx int, maxChatID string, currentTarget string) *maxbot.Keyboard { + toggleLabel := "🔗 Только ссылки" + toggleTarget := "links" + if currentTarget == "links" { + toggleLabel = "📝 Весь текст" + toggleTarget = "all" + } + kb := api.Messages.NewKeyboardBuilder() + kb.AddRow(). + AddCallback(toggleLabel, maxschemes.DEFAULT, fmt.Sprintf("cprt:%s:%d:%s:%s", dir, idx, toggleTarget, maxChatID)). + AddCallback("❌ Удалить", maxschemes.NEGATIVE, fmt.Sprintf("cprd:%s:%d:%s", dir, idx, maxChatID)) + return kb +} + +// replWait хранит состояние ожидания ввода замены. +type replWait struct { + maxChatID int64 + direction string // "tg>max" or "max>tg" + target string // "all" or "links" +} + +// replWaitMap — глобальное хранилище ожиданий (по userID). +var ( + replWaits = make(map[int64]replWait) + replWaitsMu sync.Mutex +) + +func (b *Bridge) setReplWait(userID, maxChatID int64, direction, target string) { + replWaitsMu.Lock() + replWaits[userID] = replWait{maxChatID: maxChatID, direction: direction, target: target} + replWaitsMu.Unlock() +} + +func (b *Bridge) getReplWait(userID int64) (replWait, bool) { + replWaitsMu.Lock() + w, ok := replWaits[userID] + replWaitsMu.Unlock() + return w, ok +} + +func (b *Bridge) clearReplWait(userID int64) { + replWaitsMu.Lock() + delete(replWaits, userID) + replWaitsMu.Unlock() +} + +// parseReplacementInput парсит ввод пользователя "from | to" или "/regex/ | to". +func parseReplacementInput(input string) (Replacement, bool) { + idx := strings.Index(input, "|") + if idx < 0 { + return Replacement{}, false + } + + from := strings.TrimSpace(input[:idx]) + to := strings.TrimSpace(input[idx+1:]) + + if from == "" { + return Replacement{}, false + } + + // Regex: /pattern/ + isRegex := false + if len(from) >= 2 && from[0] == '/' && from[len(from)-1] == '/' { + from = from[1 : len(from)-1] + isRegex = true + // Проверяем что regex валидный + if _, err := regexp.Compile(from); err != nil { + return Replacement{}, false + } + } + + return Replacement{From: from, To: to, Regex: isRegex}, true +} diff --git a/repository.go b/repository.go new file mode 100644 index 0000000..aecfa8e --- /dev/null +++ b/repository.go @@ -0,0 +1,92 @@ +package main + +// Replacement — одно правило замены текста. +// Target: "" или "all" — весь текст, "links" — только ссылки. +type Replacement struct { + From string `json:"from"` + To string `json:"to"` + Regex bool `json:"regex"` + Target string `json:"target,omitempty"` +} + +// CrosspostReplacements — замены по направлениям. +type CrosspostReplacements struct { + TgToMax []Replacement `json:"tg>max,omitempty"` + MaxToTg []Replacement `json:"max>tg,omitempty"` +} + +// CrosspostLink — одна связка кросспостинга. +type CrosspostLink struct { + TgChatID int64 + MaxChatID int64 + Direction string +} + +// Repository — абстракция хранилища для bridge. +type Repository interface { + // Register обрабатывает /bridge команду. + // Без ключа — создаёт pending запись и возвращает сгенерированный ключ. + // С ключом — ищет пару и создаёт связку. + Register(key, platform string, chatID int64) (paired bool, generatedKey string, err error) + + GetMaxChat(tgChatID int64) (int64, bool) + GetTgChat(maxChatID int64) (int64, bool) + MigrateTgChat(oldID, newID int64) error + + SaveMsg(tgChatID int64, tgMsgID int, maxChatID int64, maxMsgID string) + LookupMaxMsgID(tgChatID int64, tgMsgID int) (string, bool) + LookupTgMsgID(maxMsgID string) (int64, int, bool) + CleanOldMessages() + + HasPrefix(platform string, chatID int64) bool + SetPrefix(platform string, chatID int64, on bool) bool + + Unpair(platform string, chatID int64) bool + + GetTgThreadID(tgChatID int64) int + SetTgThreadID(tgChatID int64, threadID int) error + + // Crosspost methods + PairCrosspost(tgChatID, maxChatID, ownerID, tgOwnerID int64) error + GetCrosspostOwner(maxChatID int64) (maxOwner, tgOwner int64) + GetCrosspostMaxChat(tgChatID int64) (maxChatID int64, direction string, ok bool) + GetCrosspostTgChat(maxChatID int64) (tgChatID int64, direction string, ok bool) + ListCrossposts(ownerID int64) []CrosspostLink + SetCrosspostDirection(maxChatID int64, direction string) bool + UnpairCrosspost(maxChatID, deletedBy int64) bool + GetCrosspostReplacements(maxChatID int64) CrosspostReplacements + SetCrosspostReplacements(maxChatID int64, repl CrosspostReplacements) error + GetCrosspostSyncEdits(maxChatID int64) bool + SetCrosspostSyncEdits(maxChatID int64, on bool) error + + // Users + TouchUser(userID int64, platform, username, firstName string) + ListUsers(platform string) ([]int64, error) + + // Send queue (retry при недоступности MAX/TG API) + EnqueueSend(item *QueueItem) error + PeekQueue(limit int) ([]QueueItem, error) + DeleteFromQueue(id int64) error + IncrementAttempt(id int64, nextRetry int64) error + + Close() error +} + +// QueueItem — сообщение в очереди на повторную отправку. +type QueueItem struct { + ID int64 + Direction string // "tg2max" or "max2tg" + SrcChatID int64 + DstChatID int64 + SrcMsgID string // TG msg ID (as string) or MAX mid + Text string + AttType string // "video", "file", "audio", "" + AttToken string + ReplyTo string + Format string + AttURL string // URL медиа (для MAX→TG) + ParseMode string // "HTML" или "" + Attempts int + CreatedAt int64 + NextRetry int64 +} diff --git a/sqlite.go b/sqlite.go new file mode 100644 index 0000000..f4cfd02 --- /dev/null +++ b/sqlite.go @@ -0,0 +1,347 @@ +package main + +import ( + "database/sql" + "sync" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +type sqliteRepo struct { + db *sql.DB + mu sync.Mutex +} + +func NewSQLiteRepo(dbPath string) (Repository, error) { + db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL") + if err != nil { + return nil, err + } + + if err := runMigrations(db, "sqlite3"); err != nil { + return nil, err + } + + return &sqliteRepo{db: db}, nil +} + +func (r *sqliteRepo) Register(key, platform string, chatID int64) (bool, string, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if key == "" { + var existing string + err := r.db.QueryRow("SELECT key FROM pending WHERE platform = ? AND chat_id = ? AND command = 'bridge'", platform, chatID).Scan(&existing) + if err == nil { + return false, existing, nil + } + generated := genKey() + _, err = r.db.Exec("INSERT INTO pending (key, platform, chat_id, created_at, command) VALUES (?, ?, ?, ?, 'bridge')", generated, platform, chatID, time.Now().Unix()) + return false, generated, err + } + + var peerPlatform string + var peerChatID int64 + err := r.db.QueryRow("SELECT platform, chat_id FROM pending WHERE key = ? AND command = 'bridge'", key).Scan(&peerPlatform, &peerChatID) + if err != nil { + return false, "", nil + } + if peerPlatform == platform { + return false, "", nil + } + + r.db.Exec("DELETE FROM pending WHERE key = ?", key) + + var tgID, maxID int64 + if platform == "tg" { + tgID, maxID = chatID, peerChatID + } else { + tgID, maxID = peerChatID, chatID + } + + _, err = r.db.Exec("INSERT OR REPLACE INTO pairs (tg_chat_id, max_chat_id) VALUES (?, ?)", tgID, maxID) + return true, "", err +} + +func (r *sqliteRepo) MigrateTgChat(oldID, newID int64) error { + r.mu.Lock() + defer r.mu.Unlock() + _, err := r.db.Exec("UPDATE pairs SET tg_chat_id = ? WHERE tg_chat_id = ?", newID, oldID) + if err == nil { + r.db.Exec("UPDATE messages SET tg_chat_id = ? WHERE tg_chat_id = ?", newID, oldID) + } + return err +} + +func (r *sqliteRepo) GetMaxChat(tgChatID int64) (int64, bool) { + var id int64 + err := r.db.QueryRow("SELECT max_chat_id FROM pairs WHERE tg_chat_id = ?", tgChatID).Scan(&id) + return id, err == nil +} + +func (r *sqliteRepo) GetTgChat(maxChatID int64) (int64, bool) { + var id int64 + err := r.db.QueryRow("SELECT tg_chat_id FROM pairs WHERE max_chat_id = ?", maxChatID).Scan(&id) + return id, err == nil +} + +func (r *sqliteRepo) SaveMsg(tgChatID int64, tgMsgID int, maxChatID int64, maxMsgID string) { + r.db.Exec("INSERT OR REPLACE INTO messages (tg_chat_id, tg_msg_id, max_chat_id, max_msg_id, created_at) VALUES (?, ?, ?, ?, ?)", + tgChatID, tgMsgID, maxChatID, maxMsgID, time.Now().Unix()) +} + +func (r *sqliteRepo) LookupMaxMsgID(tgChatID int64, tgMsgID int) (string, bool) { + var id string + err := r.db.QueryRow("SELECT max_msg_id FROM messages WHERE tg_chat_id = ? AND tg_msg_id = ?", tgChatID, tgMsgID).Scan(&id) + return id, err == nil +} + +func (r *sqliteRepo) LookupTgMsgID(maxMsgID string) (int64, int, bool) { + var chatID int64 + var msgID int + err := r.db.QueryRow("SELECT tg_chat_id, tg_msg_id FROM messages WHERE max_msg_id = ?", maxMsgID).Scan(&chatID, &msgID) + return chatID, msgID, err == nil +} + +func (r *sqliteRepo) CleanOldMessages() { + r.db.Exec("DELETE FROM messages WHERE created_at < ?", time.Now().Unix()-48*3600) + r.db.Exec("DELETE FROM pending WHERE created_at > 0 AND created_at < ?", time.Now().Unix()-3600) +} + +func (r *sqliteRepo) HasPrefix(platform string, chatID int64) bool { + var v int + var err error + if platform == "tg" { + err = r.db.QueryRow("SELECT prefix FROM pairs WHERE tg_chat_id = ?", chatID).Scan(&v) + } else { + err = r.db.QueryRow("SELECT prefix FROM pairs WHERE max_chat_id = ?", chatID).Scan(&v) + } + if err != nil { + return true + } + return v == 1 +} + +func (r *sqliteRepo) SetPrefix(platform string, chatID int64, on bool) bool { + v := 0 + if on { + v = 1 + } + var res sql.Result + if platform == "tg" { + res, _ = r.db.Exec("UPDATE pairs SET prefix = ? WHERE tg_chat_id = ?", v, chatID) + } else { + res, _ = r.db.Exec("UPDATE pairs SET prefix = ? WHERE max_chat_id = ?", v, chatID) + } + if res == nil { + return false + } + n, _ := res.RowsAffected() + return n > 0 +} + +func (r *sqliteRepo) Unpair(platform string, chatID int64) bool { + r.mu.Lock() + defer r.mu.Unlock() + var res sql.Result + if platform == "tg" { + res, _ = r.db.Exec("DELETE FROM pairs WHERE tg_chat_id = ?", chatID) + } else { + res, _ = r.db.Exec("DELETE FROM pairs WHERE max_chat_id = ?", chatID) + } + if res == nil { + return false + } + n, _ := res.RowsAffected() + return n > 0 +} + +func (r *sqliteRepo) GetTgThreadID(tgChatID int64) int { + var id int + r.db.QueryRow("SELECT COALESCE(tg_thread_id, 0) FROM pairs WHERE tg_chat_id = ?", tgChatID).Scan(&id) + return id +} + +func (r *sqliteRepo) SetTgThreadID(tgChatID int64, threadID int) error { + r.mu.Lock() + defer r.mu.Unlock() + _, err := r.db.Exec("UPDATE pairs SET tg_thread_id = ? WHERE tg_chat_id = ?", threadID, tgChatID) + return err +} + +func (r *sqliteRepo) PairCrosspost(tgChatID, maxChatID, ownerID, tgOwnerID int64) error { + _, err := r.db.Exec("INSERT OR REPLACE INTO crossposts (tg_chat_id, max_chat_id, created_at, owner_id, tg_owner_id) VALUES (?, ?, ?, ?, ?)", + tgChatID, maxChatID, time.Now().Unix(), ownerID, tgOwnerID) + return err +} + +func (r *sqliteRepo) GetCrosspostOwner(maxChatID int64) (maxOwner, tgOwner int64) { + r.db.QueryRow("SELECT owner_id, tg_owner_id FROM crossposts WHERE max_chat_id = ? AND deleted_at = 0", maxChatID).Scan(&maxOwner, &tgOwner) + return +} + +func (r *sqliteRepo) GetCrosspostMaxChat(tgChatID int64) (int64, string, bool) { + var id int64 + var dir string + err := r.db.QueryRow("SELECT max_chat_id, direction FROM crossposts WHERE tg_chat_id = ? AND deleted_at = 0", tgChatID).Scan(&id, &dir) + return id, dir, err == nil +} + +func (r *sqliteRepo) GetCrosspostTgChat(maxChatID int64) (int64, string, bool) { + var id int64 + var dir string + err := r.db.QueryRow("SELECT tg_chat_id, direction FROM crossposts WHERE max_chat_id = ? AND deleted_at = 0", maxChatID).Scan(&id, &dir) + return id, dir, err == nil +} + +func (r *sqliteRepo) ListCrossposts(ownerID int64) []CrosspostLink { + rows, err := r.db.Query("SELECT tg_chat_id, max_chat_id, direction FROM crossposts WHERE (owner_id = ? OR tg_owner_id = ? OR (owner_id = 0 AND tg_owner_id = 0)) AND deleted_at = 0", ownerID, ownerID) + if err != nil { + return nil + } + defer rows.Close() + var links []CrosspostLink + for rows.Next() { + var l CrosspostLink + if rows.Scan(&l.TgChatID, &l.MaxChatID, &l.Direction) == nil { + links = append(links, l) + } + } + return links +} + +func (r *sqliteRepo) SetCrosspostDirection(maxChatID int64, direction string) bool { + res, _ := r.db.Exec("UPDATE crossposts SET direction = ? WHERE max_chat_id = ? AND deleted_at = 0", direction, maxChatID) + if res == nil { + return false + } + n, _ := res.RowsAffected() + return n > 0 +} + +func (r *sqliteRepo) UnpairCrosspost(maxChatID, deletedBy int64) bool { + r.mu.Lock() + defer r.mu.Unlock() + res, _ := r.db.Exec("UPDATE crossposts SET deleted_at = ?, deleted_by = ? WHERE max_chat_id = ? AND deleted_at = 0", + time.Now().Unix(), deletedBy, maxChatID) + if res == nil { + return false + } + n, _ := res.RowsAffected() + return n > 0 +} + +func (r *sqliteRepo) GetCrosspostReplacements(maxChatID int64) CrosspostReplacements { + var raw string + r.db.QueryRow("SELECT replacements FROM crossposts WHERE max_chat_id = ? AND deleted_at = 0", maxChatID).Scan(&raw) + return parseCrosspostReplacements(raw) +} + +func (r *sqliteRepo) SetCrosspostReplacements(maxChatID int64, repl CrosspostReplacements) error { + data := marshalCrosspostReplacements(repl) + r.mu.Lock() + defer r.mu.Unlock() + _, err := r.db.Exec("UPDATE crossposts SET replacements = ? WHERE max_chat_id = ? AND deleted_at = 0", data, maxChatID) + return err +} + +func (r *sqliteRepo) GetCrosspostSyncEdits(maxChatID int64) bool { + var v int + r.db.QueryRow("SELECT COALESCE(sync_edits, 0) FROM crossposts WHERE max_chat_id = ? AND deleted_at = 0", maxChatID).Scan(&v) + return v != 0 +} + +func (r *sqliteRepo) SetCrosspostSyncEdits(maxChatID int64, on bool) error { + v := 0 + if on { + v = 1 + } + r.mu.Lock() + defer r.mu.Unlock() + _, err := r.db.Exec("UPDATE crossposts SET sync_edits = ? WHERE max_chat_id = ? AND deleted_at = 0", v, maxChatID) + return err +} + +func (r *sqliteRepo) TouchUser(userID int64, platform, username, firstName string) { + now := time.Now().Unix() + r.mu.Lock() + defer r.mu.Unlock() + r.db.Exec(`INSERT INTO users (user_id, platform, username, first_name, first_seen, last_seen) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET username=excluded.username, first_name=excluded.first_name, last_seen=excluded.last_seen`, + userID, platform, username, firstName, now, now) +} + +func (r *sqliteRepo) ListUsers(platform string) ([]int64, error) { + rows, err := r.db.Query("SELECT user_id FROM users WHERE platform = ?", platform) + if err != nil { + return nil, err + } + defer rows.Close() + var ids []int64 + for rows.Next() { + var id int64 + if rows.Scan(&id) == nil { + ids = append(ids, id) + } + } + return ids, nil +} + +func (r *sqliteRepo) EnqueueSend(item *QueueItem) error { + r.mu.Lock() + defer r.mu.Unlock() + _, err := r.db.Exec( + `INSERT INTO send_queue (direction, src_chat_id, dst_chat_id, src_msg_id, text, att_type, att_token, reply_to, format, att_url, parse_mode, attempts, created_at, next_retry) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`, + item.Direction, item.SrcChatID, item.DstChatID, item.SrcMsgID, + item.Text, item.AttType, item.AttToken, item.ReplyTo, item.Format, + item.AttURL, item.ParseMode, + item.CreatedAt, item.NextRetry, + ) + return err +} + +func (r *sqliteRepo) PeekQueue(limit int) ([]QueueItem, error) { + r.mu.Lock() + defer r.mu.Unlock() + rows, err := r.db.Query( + `SELECT id, direction, src_chat_id, dst_chat_id, src_msg_id, text, att_type, att_token, reply_to, format, att_url, parse_mode, attempts, created_at, next_retry + FROM send_queue WHERE next_retry <= ? ORDER BY id ASC LIMIT ?`, + time.Now().Unix(), limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []QueueItem + for rows.Next() { + var q QueueItem + if err := rows.Scan(&q.ID, &q.Direction, &q.SrcChatID, &q.DstChatID, &q.SrcMsgID, + &q.Text, &q.AttType, &q.AttToken, &q.ReplyTo, &q.Format, + &q.AttURL, &q.ParseMode, + &q.Attempts, &q.CreatedAt, &q.NextRetry); err != nil { + return nil, err + } + items = append(items, q) + } + return items, nil +} + +func (r *sqliteRepo) DeleteFromQueue(id int64) error { + r.mu.Lock() + defer r.mu.Unlock() + _, err := r.db.Exec("DELETE FROM send_queue WHERE id = ?", id) + return err +} + +func (r *sqliteRepo) IncrementAttempt(id int64, nextRetry int64) error { + r.mu.Lock() + defer r.mu.Unlock() + _, err := r.db.Exec("UPDATE send_queue SET attempts = attempts + 1, next_retry = ? WHERE id = ?", nextRetry, id) + return err +} + +func (r *sqliteRepo) Close() error { + return r.db.Close() +} diff --git a/telegram.go b/telegram.go new file mode 100644 index 0000000..b415c80 --- /dev/null +++ b/telegram.go @@ -0,0 +1,1197 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "path/filepath" + "strconv" + "strings" + + maxbot "github.com/max-messenger/max-bot-api-client-go" + maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes" +) + +func (b *Bridge) listenTelegram(ctx context.Context) { + var updates <-chan TGUpdate + + if b.cfg.WebhookURL != "" { + whPath := b.tgWebhookPath() + whURL := strings.TrimRight(b.cfg.WebhookURL, "/") + whPath + if err := b.tg.SetWebhook(ctx, whURL); err != nil { + slog.Error("TG set webhook failed", "err", err) + return + } + updates = b.tg.StartWebhook(ctx, whPath) + slog.Info("TG webhook mode") + } else { + // Удаляем webhook если был, переключаемся на polling + b.tg.DeleteWebhook(ctx) + updates = b.tg.StartPolling(ctx) + slog.Info("TG polling mode") + } + + for { + select { + case <-ctx.Done(): + return + case update, ok := <-updates: + if !ok { + slog.Warn("TG updates channel closed") + return + } + + // Обработка channel posts (crosspost forwarding only) + if update.EditedChannelPost != nil { + b.handleTgEditedChannelPost(ctx, update.EditedChannelPost) + continue + } + if update.ChannelPost != nil { + b.handleTgChannelPost(ctx, update.ChannelPost) + continue + } + + // Обработка edit + if update.EditedMessage != nil { + edited := update.EditedMessage + if edited.From != nil && edited.From.IsBot { + continue + } + maxChatID, linked := b.repo.GetMaxChat(edited.Chat.ID) + if !linked { + continue + } + + // Если edit содержит медиа — отправляем как новое сообщение + hasMedia := edited.Photo != nil || edited.Video != nil || edited.Document != nil || + edited.Animation != nil || edited.Sticker != nil || edited.Voice != nil || edited.Audio != nil + if hasMedia { + prefix := b.repo.HasPrefix("tg", edited.Chat.ID) + caption := formatTgCaption(edited, prefix, b.cfg.MessageNewline) + go b.forwardTgToMax(ctx, edited, maxChatID, caption) + continue + } + + // Текстовый edit + maxMsgID, ok := b.repo.LookupMaxMsgID(edited.Chat.ID, edited.MessageID) + if !ok { + continue + } + prefix := b.repo.HasPrefix("tg", edited.Chat.ID) + fwd := formatTgMessage(edited, prefix, b.cfg.MessageNewline) + if fwd == "" { + continue + } + m := maxbot.NewMessage().SetChat(maxChatID).SetText(fwd) + if err := b.maxApi.Messages.EditMessage(ctx, maxMsgID, m); err != nil { + slog.Error("TG→MAX edit failed", "err", err, "uid", tgUserID(edited), "tgChat", edited.Chat.ID) + } else { + slog.Info("TG→MAX edited", "mid", maxMsgID, "uid", tgUserID(edited), "tgChat", edited.Chat.ID) + } + continue + } + + // Обработка inline-кнопок (crosspost management) + if update.CallbackQuery != nil { + b.handleTgCallback(ctx, update.CallbackQuery) + continue + } + + if update.Message == nil { + continue + } + + msg := update.Message + + // Обработка миграции группы в supergroup — обновляем chat ID в базе + if msg.MigrateToChatID != 0 { + slog.Info("TG chat migrated to supergroup", "old", msg.Chat.ID, "new", msg.MigrateToChatID) + if err := b.repo.MigrateTgChat(msg.Chat.ID, msg.MigrateToChatID); err != nil { + slog.Error("MigrateTgChat failed", "err", err) + } + continue + } + + text := strings.TrimSpace(msg.Text) + // Убираем @botname из команд: /bridge@MaxTelegramBridgeBot → /bridge + if strings.HasPrefix(text, "/") { + if at := strings.Index(text, "@"); at > 0 { + rest := text[at:] + if sp := strings.IndexByte(rest, ' '); sp > 0 { + text = text[:at] + rest[sp:] + } else { + text = text[:at] + } + } + } + slog.Debug("TG msg received", "uid", tgUserID(msg), "chat", msg.Chat.ID, "type", msg.Chat.Type) + + // Запоминаем юзера при личном сообщении + if msg.Chat.Type == "private" && msg.From != nil { + b.repo.TouchUser(msg.From.ID, "tg", msg.From.UserName, msg.From.FirstName) + } + + if text == "/whoami" { + b.tg.SendMessage(ctx, msg.Chat.ID, + "MaxTelegramBridgeBot — мост между Telegram и MAX.\n"+ + "Автор: Andrey Lugovskoy (@BEARlogin)\n"+ + "Исходники: https://github.com/BEARlogin/max-telegram-bridge-bot\n"+ + "Лицензия: CC BY-NC 4.0", &SendOpts{ThreadID: msg.MessageThreadID}) + continue + } + + if text == "/start" || text == "/help" { + b.tg.SendMessage(ctx, msg.Chat.ID, + "Бот-мост между Telegram и MAX.\n\n"+ + "Команды (группы):\n"+ + "/bridge — создать ключ для связки чатов\n"+ + "/bridge <ключ> — связать этот чат с MAX-чатом по ключу\n"+ + "/bridge prefix on/off — включить/выключить префикс [TG]/[MAX]\n"+ + "/unbridge — удалить связку\n"+ + "/thread — направить сообщения из MAX в текущий топик (форум)\n\n"+ + "Кросспостинг каналов:\n"+ + "1. Добавьте бота админом в оба канала (с правом постинга)\n"+ + "2. Перешлите пост из TG-канала в личку TG-бота\n"+ + "3. Бот покажет ID — скопируйте\n"+ + "4. В личке MAX-бота: /crosspost \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. В одном из чатов отправьте /bridge\n"+ + "4. Бот выдаст ключ — отправьте /bridge <ключ> в другом чате\n"+ + "5. Готово!\n\n"+ + "Поддержка: https://github.com/BEARlogin/max-telegram-bridge-bot/issues", &SendOpts{ThreadID: msg.MessageThreadID}) + continue + } + + // Обработка ввода замены (если юзер в режиме ожидания) + if msg.Chat.Type == "private" && msg.From != nil && !strings.HasPrefix(text, "/") { + if w, ok := b.getReplWait(msg.From.ID); ok { + b.clearReplWait(msg.From.ID) + rule, valid := parseReplacementInput(text) + if !valid { + b.tg.SendMessage(ctx, msg.Chat.ID, "Неверный формат. Используйте:\nfrom | to\nили\n/regex/ | to", &SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID}) + continue + } + rule.Target = w.target + repl := b.repo.GetCrosspostReplacements(w.maxChatID) + if w.direction == "tg>max" { + repl.TgToMax = append(repl.TgToMax, rule) + } else { + repl.MaxToTg = append(repl.MaxToTg, rule) + } + if err := b.repo.SetCrosspostReplacements(w.maxChatID, repl); err != nil { + slog.Error("save replacements failed", "err", err) + b.tg.SendMessage(ctx, msg.Chat.ID, "Ошибка сохранения.", &SendOpts{ThreadID: msg.MessageThreadID}) + continue + } + ruleType := "строка" + if rule.Regex { + ruleType = "regex" + } + dirLabel := "TG → MAX" + if w.direction == "max>tg" { + dirLabel = "MAX → TG" + } + b.tg.SendMessage(ctx, msg.Chat.ID, + fmt.Sprintf("Замена добавлена (%s, %s):\n%s%s", dirLabel, ruleType, rule.From, rule.To), + &SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID}) + continue + } + } + + // /crosspost в личке TG — показать список связок + if msg.Chat.Type == "private" && text == "/crosspost" { + if !b.checkUserAllowed(ctx, msg.Chat.ID, msg.From.ID, msg.MessageThreadID) { + continue + } + links := b.repo.ListCrossposts(msg.From.ID) + if len(links) == 0 { + b.tg.SendMessage(ctx, msg.Chat.ID, + "Нет активных связок.\n\nНастройка: перешлите пост из TG-канала сюда, затем в MAX-боте /crosspost ", &SendOpts{ThreadID: msg.MessageThreadID}) + } else { + for _, l := range links { + kb := tgCrosspostKeyboard(l.Direction, l.MaxChatID, b.repo.GetCrosspostSyncEdits(l.MaxChatID)) + tgTitle := b.tgChatTitle(ctx, l.TgChatID) + statusText := tgCrosspostStatusText(tgTitle, l.Direction) + if tgTitle == "" { + statusText += fmt.Sprintf("\nTG: %d ↔ MAX: %d", l.TgChatID, l.MaxChatID) + } else { + statusText += fmt.Sprintf("\nTG: «%s» (%d)\nMAX: %d", tgTitle, l.TgChatID, l.MaxChatID) + } + b.tg.SendMessage(ctx, msg.Chat.ID, statusText, &SendOpts{ReplyMarkup: kb, ThreadID: msg.MessageThreadID}) + } + } + continue + } + + // Пересланное сообщение из канала → показать ID или управление (только в личке) + if msg.Chat.Type == "private" && msg.ForwardOriginChat != nil && msg.ForwardOriginChat.Type == "channel" { + if !b.checkUserAllowed(ctx, msg.Chat.ID, msg.From.ID, msg.MessageThreadID) { + continue + } + channelID := msg.ForwardOriginChat.ID + channelTitle := msg.ForwardOriginChat.Title + + // Запоминаем TG user ID для этого канала (для owner при pairing) + b.cpTgOwnerMu.Lock() + b.cpTgOwner[channelID] = msg.From.ID + b.cpTgOwnerMu.Unlock() + slog.Info("TG crosspost forward", "tgUser", msg.From.ID, "tgChannel", channelID) + + // Проверяем, уже связан ли канал + if maxChatID, direction, ok := b.repo.GetCrosspostMaxChat(channelID); ok { + text := tgCrosspostStatusText(channelTitle, direction) + kb := tgCrosspostKeyboard(direction, maxChatID, b.repo.GetCrosspostSyncEdits(maxChatID)) + b.tg.SendMessage(ctx, msg.Chat.ID, text, &SendOpts{ReplyMarkup: kb, ThreadID: msg.MessageThreadID}) + continue + } + + b.tg.SendMessage(ctx, msg.Chat.ID, + fmt.Sprintf("TG-канал «%s»\nID: %d\n\nВ личке MAX-бота напишите:\n/crosspost %d\n\nMAX-бот: %s\n\nЗатем перешлите пост из MAX-канала в личку MAX-бота.", channelTitle, channelID, channelID, b.cfg.MaxBotURL), + &SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID}) + continue + } + + // Проверка прав админа в группах + isGroup := isTgGroup(msg.Chat.Type) + isAdmin := false + if isGroup && msg.From != nil { + status, err := b.tg.GetChatMember(ctx, msg.Chat.ID, msg.From.ID) + if err == nil { + isAdmin = isTgAdmin(status) + } + } + + // /thread — установить/сбросить топик по умолчанию + if text == "/thread" { + if !b.checkUserAllowed(ctx, msg.Chat.ID, tgUserID(msg), msg.MessageThreadID) { + continue + } + if isGroup && !isAdmin { + b.tg.SendMessage(ctx, msg.Chat.ID, "Эта команда доступна только админам группы.", &SendOpts{ThreadID: msg.MessageThreadID}) + continue + } + if _, ok := b.repo.GetMaxChat(msg.Chat.ID); !ok { + b.tg.SendMessage(ctx, msg.Chat.ID, "Чат не связан. Сначала выполните /bridge.", &SendOpts{ThreadID: msg.MessageThreadID}) + continue + } + if msg.MessageThreadID != 0 { + b.repo.SetTgThreadID(msg.Chat.ID, msg.MessageThreadID) + b.tg.SendMessage(ctx, msg.Chat.ID, + fmt.Sprintf("Топик по умолчанию установлен (thread %d). Сообщения из MAX будут приходить сюда.", msg.MessageThreadID), + &SendOpts{ThreadID: msg.MessageThreadID}) + } else { + b.repo.SetTgThreadID(msg.Chat.ID, 0) + b.tg.SendMessage(ctx, msg.Chat.ID, "Топик сброшен. Сообщения из MAX будут приходить в основной чат.", &SendOpts{}) + } + slog.Info("thread set", "tgChat", msg.Chat.ID, "thread", msg.MessageThreadID, "uid", tgUserID(msg)) + continue + } + + // /bridge prefix on/off + if text == "/bridge prefix on" || text == "/bridge prefix off" { + if !b.checkUserAllowed(ctx, msg.Chat.ID, tgUserID(msg), msg.MessageThreadID) { + continue + } + if isGroup && !isAdmin { + b.tg.SendMessage(ctx, msg.Chat.ID, "Эта команда доступна только админам группы.", &SendOpts{ThreadID: msg.MessageThreadID}) + continue + } + on := text == "/bridge prefix on" + if b.repo.SetPrefix("tg", msg.Chat.ID, on) { + if on { + b.tg.SendMessage(ctx, msg.Chat.ID, "Префикс [TG]/[MAX] включён.", &SendOpts{ThreadID: msg.MessageThreadID}) + } else { + b.tg.SendMessage(ctx, msg.Chat.ID, "Префикс [TG]/[MAX] выключен.", &SendOpts{ThreadID: msg.MessageThreadID}) + } + } else { + b.tg.SendMessage(ctx, msg.Chat.ID, "Чат не связан. Сначала выполните /bridge.", &SendOpts{ThreadID: msg.MessageThreadID}) + } + continue + } + + // /bridge или /bridge + if text == "/bridge" || strings.HasPrefix(text, "/bridge ") { + if !b.checkUserAllowed(ctx, msg.Chat.ID, tgUserID(msg), msg.MessageThreadID) { + continue + } + if isGroup && !isAdmin { + b.tg.SendMessage(ctx, msg.Chat.ID, "Эта команда доступна только админам группы.", &SendOpts{ThreadID: msg.MessageThreadID}) + continue + } + key := strings.TrimSpace(strings.TrimPrefix(text, "/bridge")) + paired, generatedKey, err := b.repo.Register(key, "tg", msg.Chat.ID) + if err != nil { + slog.Error("register failed", "err", err) + continue + } + + if paired { + b.tg.SendMessage(ctx, msg.Chat.ID, "Связано! Сообщения теперь пересылаются.", &SendOpts{ThreadID: msg.MessageThreadID}) + b.repo.SetTgThreadID(msg.Chat.ID, msg.MessageThreadID) // 0 = no topics + slog.Info("paired", "platform", "tg", "chat", msg.Chat.ID, "key", key) + } else if generatedKey != "" { + b.tg.SendMessage(ctx, msg.Chat.ID, + fmt.Sprintf("Ключ для связки: %s\n\nОтправьте в MAX-чате:\n/bridge %s\n\nMAX-бот: %s", generatedKey, generatedKey, b.cfg.MaxBotURL), + &SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID}) + slog.Info("pending", "platform", "tg", "chat", msg.Chat.ID, "key", generatedKey) + } else { + b.tg.SendMessage(ctx, msg.Chat.ID, "Ключ не найден или чат той же платформы.", &SendOpts{ThreadID: msg.MessageThreadID}) + } + continue + } + + if text == "/unbridge" { + if isGroup && !isAdmin { + b.tg.SendMessage(ctx, msg.Chat.ID, "Эта команда доступна только админам группы.", &SendOpts{ThreadID: msg.MessageThreadID}) + continue + } + if !b.checkUserAllowed(ctx, msg.Chat.ID, tgUserID(msg), msg.MessageThreadID) { + continue + } + if b.repo.Unpair("tg", msg.Chat.ID) { + b.tg.SendMessage(ctx, msg.Chat.ID, "Связка удалена.", &SendOpts{ThreadID: msg.MessageThreadID}) + } else { + b.tg.SendMessage(ctx, msg.Chat.ID, "Этот чат не связан.", &SendOpts{ThreadID: msg.MessageThreadID}) + } + continue + } + + // Пересылка + maxChatID, linked := b.repo.GetMaxChat(msg.Chat.ID) + if !linked { + continue + } + if msg.From != nil && msg.From.IsBot { + continue + } + + prefix := b.repo.HasPrefix("tg", msg.Chat.ID) + caption := formatTgCaption(msg, prefix, b.cfg.MessageNewline) + + // Проверяем anti-loop + checkText := msg.Text + if checkText == "" { + checkText = msg.Caption + } + if strings.HasPrefix(checkText, "[MAX]") || strings.HasPrefix(checkText, "[TG]") { + continue + } + + // Media group (альбом) — буферизуем и отправляем вместе + if msg.MediaGroupID != "" { + videoID := "" + if msg.Video != nil { + videoID = msg.Video.FileID + } + go b.bufferMediaGroup(ctx, msg.MediaGroupID, mediaGroupItem{ + photoSizes: msg.Photo, + videoFileID: videoID, + caption: caption, + replyToMsg: msg.ReplyToMessage, + entities: msg.CaptionEntities, + msg: msg, + }) + continue + } + + go b.forwardTgToMax(ctx, msg, maxChatID, caption) + } + } +} + +func tgUserID(msg *TGMessage) int64 { + if msg.From != nil { + return msg.From.ID + } + return 0 +} + +// forwardTgToMax пересылает TG-сообщение (текст/медиа) в MAX-чат. +func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID int64, caption string) { + if b.cbBlocked(maxChatID) { + return + } + + uid := tgUserID(msg) + + // checkSize returns true and sends warning if file exceeds TG_MAX_FILE_SIZE_MB limit. + // fileSize=0 means the size is unknown (old TG messages may omit it) — we skip the check. + checkSize := func(fileSize int, fileName string) bool { + limit := b.cfg.TgMaxFileSizeMB + if limit <= 0 || fileSize <= 0 || fileSize <= limit*1024*1024 { + return false + } + warn := fmt.Sprintf("⚠️ Файл слишком большой для пересылки (%s). Максимальный размер файла %d МБ.", + formatFileSize(fileSize), limit) + if fileName != "" { + warn = fmt.Sprintf("⚠️ Файл \"%s\" слишком большой для пересылки (%s). Максимальный размер файла %d МБ.", + fileName, formatFileSize(fileSize), limit) + } + b.tg.SendMessage(ctx, msg.Chat.ID, warn, nil) + return true + } + + // Определяем медиа + var mediaToken string + var mediaAttType string // "video", "file", "audio" + + if msg.Photo != nil { + photo := msg.Photo[len(msg.Photo)-1] + if checkSize(photo.FileSize, "") { + return + } + // Конвертируем entities в markdown для caption фото + photoEntities := msg.CaptionEntities + mdCaption := tgEntitiesToMarkdown(caption, photoEntities) + m := maxbot.NewMessage().SetChat(maxChatID).SetText(mdCaption) + if mdCaption != caption { + m.SetFormat("markdown") + } + if b.cfg.TgAPIURL != "" { + // Custom TG API — MAX не может скачать по URL, скачиваем и загружаем через reader + if uploaded, err := b.uploadTgPhotoToMax(ctx, photo.FileID); err == nil { + m.AddPhoto(uploaded) + } else { + slog.Error("TG→MAX photo upload failed", "err", err) + b.tg.SendMessage(ctx, msg.Chat.ID, "Не удалось отправить фото в MAX.", nil) + return + } + } else if fileURL, err := b.tgFileURL(ctx, photo.FileID); err == nil { + if uploaded, err := b.maxApi.Uploads.UploadPhotoFromUrl(ctx, fileURL); err == nil { + m.AddPhoto(uploaded) + } else { + slog.Error("TG→MAX photo upload failed", "err", err) + b.tg.SendMessage(ctx, msg.Chat.ID, "Не удалось отправить фото в MAX.", nil) + return + } + } + if msg.ReplyToMessage != nil { + if maxReplyID, ok := b.repo.LookupMaxMsgID(msg.Chat.ID, msg.ReplyToMessage.MessageID); ok { + m.SetReply(mdCaption, maxReplyID) + } + } + slog.Info("TG→MAX sending photo", "uid", uid, "tgChat", msg.Chat.ID, "maxChat", maxChatID) + result, err := b.maxApi.Messages.SendWithResult(ctx, m) + if err != nil { + slog.Error("TG→MAX send failed", "err", err, "uid", uid, "tgChat", msg.Chat.ID, "maxChat", maxChatID) + if b.cbFail(maxChatID) { + b.tg.SendMessage(ctx, msg.Chat.ID, + fmt.Sprintf("Не удалось переслать в MAX. Пересылка приостановлена на %d мин. Проверьте, что бот добавлен в MAX-чат и является админом.", int(cbCooldown.Minutes())), nil) + } + } else { + b.cbSuccess(maxChatID) + slog.Info("TG→MAX sent", "mid", result.Body.Mid) + b.repo.SaveMsg(msg.Chat.ID, msg.MessageID, maxChatID, result.Body.Mid) + } + return + } else if msg.Animation != nil { + // GIF в Telegram — это mp4 в поле Animation + name := "animation.mp4" + if msg.Animation.FileName != "" { + name = msg.Animation.FileName + } + if checkSize(msg.Animation.FileSize, name) { + return + } + if uploaded, err := b.uploadTgMediaToMax(ctx, msg.Animation.FileID, maxschemes.VIDEO, name); err == nil { + mediaToken = uploaded.Token + mediaAttType = "video" + } else { + slog.Error("TG→MAX gif upload failed", "err", err) + b.tg.SendMessage(ctx, msg.Chat.ID, fmt.Sprintf("Не удалось отправить GIF \"%s\" в MAX.", name), nil) + return + } + } else if msg.Sticker != nil { + // Стикеры: обычные — WebP (фото), анимированные — TGS/WEBM + if msg.Sticker.IsAnimated { + if checkSize(msg.Sticker.FileSize, "sticker.webm") { + return + } + if uploaded, err := b.uploadTgMediaToMax(ctx, msg.Sticker.FileID, maxschemes.FILE, "sticker.webm"); err == nil { + mediaToken = uploaded.Token + mediaAttType = "video" + } else { + slog.Error("TG→MAX sticker upload failed", "err", err) + b.tg.SendMessage(ctx, msg.Chat.ID, "Не удалось отправить стикер в MAX.", nil) + return + } + } else { + // Обычный стикер WebP → отправляем как фото + if fileURL, err := b.tgFileURL(ctx, msg.Sticker.FileID); err == nil { + if uploaded, err := b.maxApi.Uploads.UploadPhotoFromUrl(ctx, fileURL); err == nil { + m := maxbot.NewMessage().SetChat(maxChatID).SetText(caption) + m.AddPhoto(uploaded) + if msg.ReplyToMessage != nil { + if maxReplyID, ok := b.repo.LookupMaxMsgID(msg.Chat.ID, msg.ReplyToMessage.MessageID); ok { + m.SetReply(caption, maxReplyID) + } + } + slog.Info("TG→MAX sending sticker as photo", "uid", uid, "tgChat", msg.Chat.ID) + result, err := b.maxApi.Messages.SendWithResult(ctx, m) + if err != nil { + slog.Error("TG→MAX sticker send failed", "err", err) + b.tg.SendMessage(ctx, msg.Chat.ID, "Не удалось отправить стикер в MAX.", nil) + } else { + slog.Info("TG→MAX sent", "mid", result.Body.Mid) + b.repo.SaveMsg(msg.Chat.ID, msg.MessageID, maxChatID, result.Body.Mid) + } + return + } else { + slog.Error("TG→MAX sticker photo upload failed", "err", err) + b.tg.SendMessage(ctx, msg.Chat.ID, "Не удалось отправить стикер в MAX.", nil) + return + } + } + } + } else if msg.Video != nil { + name := "video.mp4" + if msg.Video.FileName != "" { + name = msg.Video.FileName + } + if checkSize(msg.Video.FileSize, name) { + return + } + if uploaded, err := b.uploadTgMediaToMax(ctx, msg.Video.FileID, maxschemes.VIDEO, name); err == nil { + mediaToken = uploaded.Token + mediaAttType = "video" + } else { + slog.Error("TG→MAX video upload failed", "err", err) + b.tg.SendMessage(ctx, msg.Chat.ID, fmt.Sprintf("Не удалось отправить видео \"%s\" в MAX.", name), nil) + return + } + } else if msg.VideoNote != nil { + if checkSize(msg.VideoNote.FileSize, "circle.mp4") { + return + } + if uploaded, err := b.uploadTgMediaToMax(ctx, msg.VideoNote.FileID, maxschemes.VIDEO, "circle.mp4"); err == nil { + mediaToken = uploaded.Token + mediaAttType = "video" + } else { + slog.Error("TG→MAX video note upload failed", "err", err) + b.tg.SendMessage(ctx, msg.Chat.ID, "Не удалось отправить кружок в MAX.", nil) + return + } + } else if msg.Document != nil { + name := msg.Document.FileName + uploadType := maxschemes.FILE + attType := "file" + // Документ с video MIME → загружаем как видео + if strings.HasPrefix(msg.Document.MimeType, "video/") { + uploadType = maxschemes.VIDEO + attType = "video" + if name == "" { + name = mimeToFilename("video", msg.Document.MimeType) + } + } + if name == "" { + name = mimeToFilename("document", msg.Document.MimeType) + } + if checkSize(msg.Document.FileSize, name) { + return + } + // Pre-check расширения до отправки на CDN (если whitelist задан) + if b.cfg.MaxAllowedExts != nil && attType == "file" { + ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), ".")) + if _, ok := b.cfg.MaxAllowedExts[ext]; !ok { + b.tg.SendMessage(ctx, msg.Chat.ID, + fmt.Sprintf("Файл \"%s\" не поддерживается в MAX (расширение .%s не разрешено).", name, ext), nil) + return + } + } + if uploaded, err := b.uploadTgMediaToMax(ctx, msg.Document.FileID, uploadType, name); err == nil { + mediaToken = uploaded.Token + mediaAttType = attType + } else { + var e *ErrForbiddenExtension + if errors.As(err, &e) { + b.tg.SendMessage(ctx, msg.Chat.ID, + fmt.Sprintf("Файл \"%s\" не поддерживается в MAX (запрещённое расширение).", name), nil) + return + } + slog.Error("TG→MAX file upload failed", "err", err) + b.tg.SendMessage(ctx, msg.Chat.ID, + fmt.Sprintf("Не удалось отправить файл \"%s\" в MAX.", name), nil) + return + } + } else if msg.Voice != nil { + if checkSize(msg.Voice.FileSize, "voice.ogg") { + return + } + if uploaded, err := b.uploadTgMediaToMax(ctx, msg.Voice.FileID, maxschemes.AUDIO, "voice.ogg"); err == nil { + mediaToken = uploaded.Token + mediaAttType = "audio" + } else { + var e *ErrForbiddenExtension + if errors.As(err, &e) { + b.tg.SendMessage(ctx, msg.Chat.ID, + fmt.Sprintf("Файл \"%s\" не поддерживается в MAX (запрещённое расширение).", e.Name), nil) + return + } + slog.Error("TG→MAX voice upload failed", "err", err) + b.tg.SendMessage(ctx, msg.Chat.ID, "Не удалось отправить голосовое сообщение в MAX.", nil) + return + } + } else if msg.Audio != nil { + name := "audio.mp3" + if msg.Audio.FileName != "" { + name = msg.Audio.FileName + } + if checkSize(msg.Audio.FileSize, name) { + return + } + // Pre-check расширения до отправки на CDN (если whitelist задан) + if b.cfg.MaxAllowedExts != nil { + ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), ".")) + if _, ok := b.cfg.MaxAllowedExts[ext]; !ok { + b.tg.SendMessage(ctx, msg.Chat.ID, + fmt.Sprintf("Файл \"%s\" не поддерживается в MAX (расширение .%s не разрешено).", name, ext), nil) + return + } + } + if uploaded, err := b.uploadTgMediaToMax(ctx, msg.Audio.FileID, maxschemes.FILE, name); err == nil { + mediaToken = uploaded.Token + mediaAttType = "file" + } else { + var e *ErrForbiddenExtension + if errors.As(err, &e) { + b.tg.SendMessage(ctx, msg.Chat.ID, + fmt.Sprintf("Файл \"%s\" не поддерживается в MAX (запрещённое расширение).", name), nil) + return + } + slog.Error("TG→MAX audio upload failed", "err", err) + b.tg.SendMessage(ctx, msg.Chat.ID, fmt.Sprintf("Не удалось отправить аудио \"%s\" в MAX.", name), nil) + return + } + } + + // Fallback для неудавшейся загрузки медиа + if mediaAttType == "" && msg.Text == "" { + mediaType := "" + switch { + case msg.Video != nil: + mediaType = "[Видео]" + case msg.VideoNote != nil: + mediaType = "[Кружок]" + case msg.Document != nil: + mediaType = "[Файл]" + case msg.Voice != nil: + mediaType = "[Голосовое]" + case msg.Audio != nil: + mediaType = "[Аудио]" + case msg.Sticker != nil: + mediaType = "[Стикер]" + default: + return + } + caption = caption + mediaType + } + + // Reply ID + var replyTo string + if msg.ReplyToMessage != nil { + if maxReplyID, ok := b.repo.LookupMaxMsgID(msg.Chat.ID, msg.ReplyToMessage.MessageID); ok { + replyTo = maxReplyID + } + } + + // Конвертируем TG entities в markdown для MAX + entities := msg.Entities + if entities == nil { + entities = msg.CaptionEntities + } + mdCaption := tgEntitiesToMarkdown(caption, entities) + hasFormatting := mdCaption != caption + + var mid string + var sendErr error + + if mediaAttType != "" { + slog.Info("TG→MAX sending direct", "type", mediaAttType, "uid", uid, "tgChat", msg.Chat.ID, "maxChat", maxChatID) + var format string + if hasFormatting { + format = "markdown" + } + mid, sendErr = b.sendMaxDirectFormatted(ctx, maxChatID, mdCaption, mediaAttType, mediaToken, replyTo, format) + } else { + var format string + if hasFormatting { + format = "markdown" + } + slog.Info("TG→MAX sending", "uid", uid, "tgChat", msg.Chat.ID, "maxChat", maxChatID) + mid, sendErr = b.sendMaxDirectFormatted(ctx, maxChatID, mdCaption, "", "", replyTo, format) + } + + if sendErr != nil { + errStr := sendErr.Error() + slog.Error("TG→MAX send failed", "err", errStr, "uid", uid, "tgChat", msg.Chat.ID, "maxChat", maxChatID) + // 403/404 — permanent error, не ретраим + if !strings.Contains(errStr, "403") && !strings.Contains(errStr, "404") && !strings.Contains(errStr, "chat.denied") { + var format string + if hasFormatting { + format = "markdown" + } + b.enqueueTg2Max(msg.Chat.ID, msg.MessageID, maxChatID, mdCaption, mediaAttType, mediaToken, replyTo, format) + } + if b.cbFail(maxChatID) { + b.tg.SendMessage(ctx, msg.Chat.ID, + "MAX API недоступен. Сообщения в очереди, будут доставлены автоматически.", nil) + } + } else { + b.cbSuccess(maxChatID) + slog.Info("TG→MAX sent", "mid", mid, "uid", uid, "tgChat", msg.Chat.ID, "maxChat", maxChatID) + b.repo.SaveMsg(msg.Chat.ID, msg.MessageID, maxChatID, mid) + } +} + +// handleTgChannelPost обрабатывает посты из TG-каналов (только пересылка crosspost). +func (b *Bridge) handleTgChannelPost(ctx context.Context, msg *TGMessage) { + // Команды в канале игнорируем — настройка через личку с ботом + text := strings.TrimSpace(msg.Text) + if strings.HasPrefix(text, "/") { + return + } + + // Пересылка crosspost: TG → MAX + maxChatID, direction, ok := b.repo.GetCrosspostMaxChat(msg.Chat.ID) + if !ok { + return + } + if direction == "max>tg" { + return // только MAX→TG, пропускаем + } + + // Anti-loop + checkText := msg.Text + if checkText == "" { + checkText = msg.Caption + } + if strings.HasPrefix(checkText, "[MAX]") || strings.HasPrefix(checkText, "[TG]") { + return + } + + caption := formatTgCrosspostCaption(msg) + + // Применяем замены для TG→MAX + repl := b.repo.GetCrosspostReplacements(maxChatID) + if len(repl.TgToMax) > 0 { + caption = applyReplacements(caption, repl.TgToMax) + } + + // Media group (альбом) — буферизуем и отправляем вместе + if msg.MediaGroupID != "" { + videoID := "" + if msg.Video != nil { + videoID = msg.Video.FileID + } + go b.bufferMediaGroup(ctx, msg.MediaGroupID, mediaGroupItem{ + photoSizes: msg.Photo, + videoFileID: videoID, + caption: caption, + replyToMsg: msg.ReplyToMessage, + entities: msg.CaptionEntities, + msg: msg, + maxChatID: maxChatID, + crosspost: true, + }) + return + } + + go b.forwardTgToMax(ctx, msg, maxChatID, caption) +} + +// handleTgCallback обрабатывает нажатия inline-кнопок (crosspost management). +func (b *Bridge) handleTgCallback(ctx context.Context, query *TGCallback) { + if query.Message == nil || query.From == nil { + return + } + data := query.Data + chatID := query.Message.Chat.ID + msgID := query.Message.MessageID + + fromID := query.From.ID + + // cpd:dir:maxChatID — change direction + if strings.HasPrefix(data, "cpd:") { + parts := strings.SplitN(data, ":", 3) + if len(parts) != 3 { + return + } + dir := parts[1] + maxChatID, err := strconv.ParseInt(parts[2], 10, 64) + if err != nil { + return + } + if dir != "tg>max" && dir != "max>tg" && dir != "both" { + return + } + if !b.isCrosspostOwner(maxChatID, fromID) { + b.tg.AnswerCallback(ctx, query.ID, "Только владелец связки может изменять настройки.") + return + } + b.repo.SetCrosspostDirection(maxChatID, dir) + + // Получаем title канала (из текста сообщения) + title := parseTgCrosspostTitle(query.Message.Text) + text := tgCrosspostStatusText(title, dir) + kb := tgCrosspostKeyboard(dir, maxChatID, b.repo.GetCrosspostSyncEdits(maxChatID)) + b.tg.EditMessageText(ctx, chatID, msgID, text, &SendOpts{ReplyMarkup: kb}) + b.tg.AnswerCallback(ctx, query.ID, "Готово") + return + } + + // cps:maxChatID — toggle sync edits + if strings.HasPrefix(data, "cps:") { + maxChatID, err := strconv.ParseInt(strings.TrimPrefix(data, "cps:"), 10, 64) + if err != nil { + return + } + if !b.isCrosspostOwner(maxChatID, fromID) { + b.tg.AnswerCallback(ctx, query.ID, "Только владелец связки может изменять настройки.") + return + } + cur := b.repo.GetCrosspostSyncEdits(maxChatID) + b.repo.SetCrosspostSyncEdits(maxChatID, !cur) + title := parseTgCrosspostTitle(query.Message.Text) + _, direction, _ := b.repo.GetCrosspostTgChat(maxChatID) + text := tgCrosspostStatusText(title, direction) + kb := tgCrosspostKeyboard(direction, maxChatID, !cur) + b.tg.EditMessageText(ctx, chatID, msgID, text, &SendOpts{ReplyMarkup: kb}) + if !cur { + b.tg.AnswerCallback(ctx, query.ID, "Синхронизация правок включена") + } else { + b.tg.AnswerCallback(ctx, query.ID, "Синхронизация правок выключена") + } + return + } + + // cpu:maxChatID — unlink (show confirmation) + if strings.HasPrefix(data, "cpu:") { + maxChatID, err := strconv.ParseInt(strings.TrimPrefix(data, "cpu:"), 10, 64) + if err != nil { + return + } + if !b.isCrosspostOwner(maxChatID, fromID) { + b.tg.AnswerCallback(ctx, query.ID, "Только владелец связки может удалять.") + return + } + kb := NewInlineKeyboard( + NewInlineRow( + NewInlineButton("Да, удалить", fmt.Sprintf("cpuc:%d", maxChatID)), + NewInlineButton("Отмена", fmt.Sprintf("cpux:%d", maxChatID)), + ), + ) + b.tg.EditMessageText(ctx, chatID, msgID, "Удалить кросспостинг?", &SendOpts{ReplyMarkup: kb}) + b.tg.AnswerCallback(ctx, query.ID, "") + return + } + + // cpr:maxChatID — show replacements + if strings.HasPrefix(data, "cpr:") { + maxChatID, err := strconv.ParseInt(strings.TrimPrefix(data, "cpr:"), 10, 64) + if err != nil { + return + } + repl := b.repo.GetCrosspostReplacements(maxChatID) + id := strconv.FormatInt(maxChatID, 10) + // Удаляем сообщение со связкой + b.tg.DeleteMessage(ctx, chatID, msgID) + // Заголовок с кнопками добавления + kb := tgReplacementsKeyboard(maxChatID) + b.tg.SendMessage(ctx, chatID, formatReplacementsHeader(repl), &SendOpts{ReplyMarkup: kb}) + // Каждая замена — отдельное сообщение с кнопкой удаления + for i, r := range repl.TgToMax { + b.tg.SendMessage(ctx, chatID, formatReplacementItem(r, "tg>max"), &SendOpts{ParseMode: "HTML", ReplyMarkup: tgReplItemKeyboard("tg>max", i, id, r.Target)}) + } + for i, r := range repl.MaxToTg { + b.tg.SendMessage(ctx, chatID, formatReplacementItem(r, "max>tg"), &SendOpts{ParseMode: "HTML", ReplyMarkup: tgReplItemKeyboard("max>tg", i, id, r.Target)}) + } + b.tg.AnswerCallback(ctx, query.ID, "") + return + } + + // cprt:dir:index:target:maxChatID — toggle replacement target + if strings.HasPrefix(data, "cprt:") { + parts := strings.SplitN(strings.TrimPrefix(data, "cprt:"), ":", 4) + if len(parts) != 4 { + return + } + dir := parts[0] + idx, err := strconv.Atoi(parts[1]) + if err != nil { + return + } + newTarget := parts[2] + maxChatID, err := strconv.ParseInt(parts[3], 10, 64) + if err != nil { + return + } + repl := b.repo.GetCrosspostReplacements(maxChatID) + id := strconv.FormatInt(maxChatID, 10) + var r *Replacement + if dir == "tg>max" && idx < len(repl.TgToMax) { + r = &repl.TgToMax[idx] + } else if dir == "max>tg" && idx < len(repl.MaxToTg) { + r = &repl.MaxToTg[idx] + } + if r == nil { + return + } + r.Target = newTarget + b.repo.SetCrosspostReplacements(maxChatID, repl) + // Обновляем сообщение + newText := formatReplacementItem(*r, dir) + kb := tgReplItemKeyboard(dir, idx, id, r.Target) + b.tg.EditMessageText(ctx, chatID, msgID, newText, &SendOpts{ParseMode: "HTML", ReplyMarkup: kb}) + label := "весь текст" + if newTarget == "links" { + label = "только ссылки" + } + b.tg.AnswerCallback(ctx, query.ID, "Тип: "+label) + return + } + + // cprd:dir:index:maxChatID — delete single replacement + if strings.HasPrefix(data, "cprd:") { + parts := strings.SplitN(strings.TrimPrefix(data, "cprd:"), ":", 3) + if len(parts) != 3 { + return + } + dir := parts[0] + idx, err := strconv.Atoi(parts[1]) + if err != nil { + return + } + maxChatID, err := strconv.ParseInt(parts[2], 10, 64) + if err != nil { + return + } + repl := b.repo.GetCrosspostReplacements(maxChatID) + if dir == "tg>max" && idx < len(repl.TgToMax) { + repl.TgToMax = append(repl.TgToMax[:idx], repl.TgToMax[idx+1:]...) + } else if dir == "max>tg" && idx < len(repl.MaxToTg) { + repl.MaxToTg = append(repl.MaxToTg[:idx], repl.MaxToTg[idx+1:]...) + } + b.repo.SetCrosspostReplacements(maxChatID, repl) + b.tg.EditMessageText(ctx, chatID, msgID, "Замена удалена.", nil) + b.tg.AnswerCallback(ctx, query.ID, "Удалено") + return + } + + // cpra:dir:maxChatID — choose target (all or links) + if strings.HasPrefix(data, "cpra:") { + parts := strings.SplitN(strings.TrimPrefix(data, "cpra:"), ":", 2) + if len(parts) != 2 { + return + } + dir := parts[0] + id := parts[1] + dirLabel := "TG → MAX" + if dir == "max>tg" { + dirLabel = "MAX → TG" + } + kb := NewInlineKeyboard( + NewInlineRow( + NewInlineButton("📝 Весь текст", "cprat:"+dir+":all:"+id), + NewInlineButton("🔗 Только ссылки", "cprat:"+dir+":links:"+id), + ), + ) + b.tg.EditMessageText(ctx, chatID, msgID, + fmt.Sprintf("Добавление замены для %s.\nГде применять замену?", dirLabel), &SendOpts{ReplyMarkup: kb}) + b.tg.AnswerCallback(ctx, query.ID, "") + return + } + + // cprat:dir:target:maxChatID — set wait state with target + if strings.HasPrefix(data, "cprat:") { + parts := strings.SplitN(strings.TrimPrefix(data, "cprat:"), ":", 3) + if len(parts) != 3 { + return + } + dir := parts[0] + target := parts[1] + maxChatID, err := strconv.ParseInt(parts[2], 10, 64) + if err != nil { + return + } + b.setReplWait(fromID, maxChatID, dir, target) + b.tg.EditMessageText(ctx, chatID, msgID, + fmt.Sprintf("Отправьте правило замены:\nfrom | to\n\nДля регулярного выражения:\n/regex/ | to\n\nНапример:\nutm_source=tg | utm_source=max"), + &SendOpts{ParseMode: "HTML"}) + b.tg.AnswerCallback(ctx, query.ID, "") + return + } + + // cprc:maxChatID — clear all replacements + if strings.HasPrefix(data, "cprc:") { + maxChatID, err := strconv.ParseInt(strings.TrimPrefix(data, "cprc:"), 10, 64) + if err != nil { + return + } + b.repo.SetCrosspostReplacements(maxChatID, CrosspostReplacements{}) + repl := b.repo.GetCrosspostReplacements(maxChatID) + kb := tgReplacementsKeyboard(maxChatID) + b.tg.EditMessageText(ctx, chatID, msgID, formatReplacementsHeader(repl), &SendOpts{ReplyMarkup: kb}) + b.tg.AnswerCallback(ctx, query.ID, "Очищено") + return + } + + // cprb:maxChatID — back to crosspost management + if strings.HasPrefix(data, "cprb:") { + maxChatID, err := strconv.ParseInt(strings.TrimPrefix(data, "cprb:"), 10, 64) + if err != nil { + return + } + _, direction, ok := b.repo.GetCrosspostTgChat(maxChatID) + if !ok { + return + } + title := parseTgCrosspostTitle(query.Message.Text) + text := tgCrosspostStatusText(title, direction) + fmt.Sprintf("\nTG: ↔ MAX: %d", maxChatID) + kb := tgCrosspostKeyboard(direction, maxChatID, b.repo.GetCrosspostSyncEdits(maxChatID)) + b.tg.EditMessageText(ctx, chatID, msgID, text, &SendOpts{ReplyMarkup: kb}) + b.tg.AnswerCallback(ctx, query.ID, "") + return + } + + // cpuc:maxChatID — unlink confirmed + if strings.HasPrefix(data, "cpuc:") { + maxChatID, err := strconv.ParseInt(strings.TrimPrefix(data, "cpuc:"), 10, 64) + if err != nil { + return + } + if !b.isCrosspostOwner(maxChatID, fromID) { + b.tg.AnswerCallback(ctx, query.ID, "Только владелец связки может удалять.") + return + } + slog.Info("TG crosspost unlink", "maxChatID", maxChatID, "by", fromID) + b.repo.UnpairCrosspost(maxChatID, fromID) + b.tg.EditMessageText(ctx, chatID, msgID, "Кросспостинг удалён.", nil) + b.tg.AnswerCallback(ctx, query.ID, "Удалено") + return + } + + // cpux:maxChatID — cancel (return to management keyboard) + if strings.HasPrefix(data, "cpux:") { + maxChatID, err := strconv.ParseInt(strings.TrimPrefix(data, "cpux:"), 10, 64) + if err != nil { + return + } + // Lookup current direction + _, direction, ok := b.repo.GetCrosspostTgChat(maxChatID) + if !ok { + b.tg.EditMessageText(ctx, chatID, msgID, "Кросспостинг не найден.", nil) + b.tg.AnswerCallback(ctx, query.ID, "") + return + } + title := parseTgCrosspostTitle(query.Message.Text) + text := tgCrosspostStatusText(title, direction) + kb := tgCrosspostKeyboard(direction, maxChatID, b.repo.GetCrosspostSyncEdits(maxChatID)) + b.tg.EditMessageText(ctx, chatID, msgID, text, &SendOpts{ReplyMarkup: kb}) + b.tg.AnswerCallback(ctx, query.ID, "") + return + } +} + +// tgCrosspostKeyboard строит inline-клавиатуру для управления кросспостингом. +func tgCrosspostKeyboard(direction string, maxChatID int64, syncEdits bool) *InlineKeyboardMarkup { + lblTgMax := "TG → MAX" + lblMaxTg := "MAX → TG" + lblBoth := "⟷ Оба" + switch direction { + case "tg>max": + lblTgMax = "✓ TG → MAX" + case "max>tg": + lblMaxTg = "✓ MAX → TG" + default: // "both" + lblBoth = "✓ ⟷ Оба" + } + id := strconv.FormatInt(maxChatID, 10) + lblSync := "✏️ Синк правок" + if syncEdits { + lblSync = "✓ ✏️ Синк правок" + } + return NewInlineKeyboard( + NewInlineRow( + NewInlineButton(lblTgMax, "cpd:tg>max:"+id), + NewInlineButton(lblMaxTg, "cpd:max>tg:"+id), + NewInlineButton(lblBoth, "cpd:both:"+id), + ), + NewInlineRow( + NewInlineButton(lblSync, "cps:"+id), + NewInlineButton("🔄 Замены", "cpr:"+id), + NewInlineButton("❌ Удалить", "cpu:"+id), + ), + ) +} + +// tgCrosspostStatusText возвращает текст статуса кросспостинга. +func tgCrosspostStatusText(title, direction string) string { + dirLabel := "⟷ оба" + switch direction { + case "tg>max": + dirLabel = "TG → MAX" + case "max>tg": + dirLabel = "MAX → TG" + } + if title != "" { + return fmt.Sprintf("Кросспостинг «%s»\nНаправление: %s", title, dirLabel) + } + return fmt.Sprintf("Кросспостинг\nНаправление: %s", dirLabel) +} + +// parseTgCrosspostTitle извлекает название канала из текста сообщения. +func parseTgCrosspostTitle(text string) string { + // Ищем «...» в тексте + start := strings.Index(text, "«") + end := strings.Index(text, "»") + if start >= 0 && end > start { + return text[start+len("«") : end] + } + return "" +} + +// handleTgEditedChannelPost обрабатывает редактирования постов в TG-каналах. +func (b *Bridge) handleTgEditedChannelPost(ctx context.Context, edited *TGMessage) { + maxMsgID, ok := b.repo.LookupMaxMsgID(edited.Chat.ID, edited.MessageID) + if !ok { + return + } + + maxChatID, direction, linked := b.repo.GetCrosspostMaxChat(edited.Chat.ID) + if !linked { + return + } + if direction == "max>tg" { + return + } + if !b.repo.GetCrosspostSyncEdits(maxChatID) { + return + } + + text := edited.Text + if text == "" { + text = edited.Caption + } + if text == "" { + return + } + + m := maxbot.NewMessage().SetChat(maxChatID).SetText(text) + if err := b.maxApi.Messages.EditMessage(ctx, maxMsgID, m); err != nil { + slog.Error("TG→MAX crosspost edit failed", "err", err) + } else { + slog.Info("TG→MAX crosspost edited", "mid", maxMsgID) + } +} diff --git a/tgsender.go b/tgsender.go new file mode 100644 index 0000000..fd7d38c --- /dev/null +++ b/tgsender.go @@ -0,0 +1,198 @@ +package main + +import ( + "context" + "fmt" +) + +// --- Custom types for TG adapter --- + +type ChatInfo struct { + ID int64 + Type string + Title string +} + +type UserInfo struct { + ID int64 + IsBot bool + UserName string + FirstName string + LastName string +} + +type PhotoSize struct { + FileID string + FileSize int +} + +type FileInfo struct { + FileID string + FileName string + FileSize int +} + +type DocInfo struct { + FileID string + FileName string + FileSize int + MimeType string +} + +type AudioInfo struct { + FileID string + FileName string + FileSize int +} + +type StickerInfo struct { + FileID string + FileSize int + IsAnimated bool +} + +type Entity struct { + Type string + Offset int + Length int + URL string +} + +type TGMessage struct { + MessageID int + MessageThreadID int + Chat ChatInfo + From *UserInfo + SenderChat *ChatInfo + Text string + Caption string + Photo []PhotoSize + Video *FileInfo + Document *DocInfo + Animation *FileInfo + Sticker *StickerInfo + Voice *FileInfo + Audio *AudioInfo + VideoNote *FileInfo + MediaGroupID string + ReplyToMessage *TGMessage + ForwardOriginChat *ChatInfo // replaces ForwardFromChat, from forward_origin + MigrateToChatID int64 + Entities []Entity + CaptionEntities []Entity +} + +type TGCallback struct { + ID string + From *UserInfo + Message *TGMessage + Data string +} + +type TGUpdate struct { + Message *TGMessage + EditedMessage *TGMessage + ChannelPost *TGMessage + EditedChannelPost *TGMessage + CallbackQuery *TGCallback +} + +// SendOpts — optional parameters for send methods. +type SendOpts struct { + ThreadID int + ReplyToID int + ParseMode string + Caption string + ReplyMarkup *InlineKeyboardMarkup +} + +type InlineKeyboardMarkup struct { + Rows [][]InlineKeyboardButton +} + +type InlineKeyboardButton struct { + Text string + CallbackData string +} + +// FileArg — source for file upload: either Bytes (upload) or URL (send from URL). +type FileArg struct { + Name string + Bytes []byte + URL string +} + +// TGInputMedia — item for media groups and edit-media. +type TGInputMedia struct { + Type string // "photo", "video", "audio", "document" + File FileArg + Caption string + ParseMode string +} + +type BotCommand struct { + Command string + Description string +} + +type CommandScope struct { + Type string // "", "all_chat_administrators" +} + +// TGError represents a Telegram API error. +type TGError struct { + Code int + Description string + MigrateToChatID int64 +} + +func (e *TGError) Error() string { + return fmt.Sprintf("telegram: %s (%d)", e.Description, e.Code) +} + +// --- Keyboard helpers --- + +func NewInlineKeyboard(rows ...[]InlineKeyboardButton) *InlineKeyboardMarkup { + return &InlineKeyboardMarkup{Rows: rows} +} + +func NewInlineRow(buttons ...InlineKeyboardButton) []InlineKeyboardButton { + return buttons +} + +func NewInlineButton(text, data string) InlineKeyboardButton { + return InlineKeyboardButton{Text: text, CallbackData: data} +} + +// --- Interface --- + +// TGSender abstracts Telegram Bot API. All TG calls go through this interface. +type TGSender interface { + // Send methods return message ID. + SendMessage(ctx context.Context, chatID int64, text string, opts *SendOpts) (int, error) + SendPhoto(ctx context.Context, chatID int64, file FileArg, opts *SendOpts) (int, error) + SendVideo(ctx context.Context, chatID int64, file FileArg, opts *SendOpts) (int, error) + SendAudio(ctx context.Context, chatID int64, file FileArg, opts *SendOpts) (int, error) + SendDocument(ctx context.Context, chatID int64, file FileArg, opts *SendOpts) (int, error) + SendMediaGroup(ctx context.Context, chatID int64, media []TGInputMedia, opts *SendOpts) ([]int, error) + + EditMessageText(ctx context.Context, chatID int64, msgID int, text string, opts *SendOpts) error + EditMessageMedia(ctx context.Context, chatID int64, msgID int, media TGInputMedia) error + + DeleteMessage(ctx context.Context, chatID int64, msgID int) error + AnswerCallback(ctx context.Context, callbackID string, text string) error + + GetFile(ctx context.Context, fileID string) (filePath string, err error) + GetFileDirectURL(filePath string) string + GetChatMember(ctx context.Context, chatID, userID int64) (status string, err error) + SetMyCommands(ctx context.Context, commands []BotCommand, scope *CommandScope) error + GetChat(ctx context.Context, chatID int64) (title string, err error) + + SetWebhook(ctx context.Context, url string) error + DeleteWebhook(ctx context.Context) error + StartWebhook(ctx context.Context, path string) <-chan TGUpdate + StartPolling(ctx context.Context) <-chan TGUpdate + + BotUsername() string + BotToken() string +} diff --git a/tgsender_impl.go b/tgsender_impl.go new file mode 100644 index 0000000..6c7ccb4 --- /dev/null +++ b/tgsender_impl.go @@ -0,0 +1,600 @@ +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "net/http" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) + +type tgBotSender struct { + b *bot.Bot + token string + username string + apiURL string + updates chan TGUpdate +} + +func NewTGBotSender(ctx context.Context, token, apiURL string) (*tgBotSender, error) { + s := &tgBotSender{ + token: token, + apiURL: apiURL, + updates: make(chan TGUpdate, 5000), + } + + opts := []bot.Option{ + bot.WithDefaultHandler(func(ctx context.Context, b *bot.Bot, update *models.Update) { + tgu := convertUpdate(update) + select { + case s.updates <- tgu: + default: + slog.Warn("TG update channel full, dropping update") + } + }), + } + if apiURL != "" { + opts = append(opts, bot.WithServerURL(apiURL)) + } + opts = append(opts, bot.WithSkipGetMe()) + + b, err := bot.New(token, opts...) + if err != nil { + return nil, fmt.Errorf("bot.New: %w", err) + } + s.b = b + + me, err := b.GetMe(ctx) + if err != nil { + return nil, fmt.Errorf("TG getMe: %w", err) + } + s.username = me.Username + slog.Info("Telegram bot started", "username", me.Username) + + return s, nil +} + +func (s *tgBotSender) BotUsername() string { return s.username } +func (s *tgBotSender) BotToken() string { return s.token } + +// --- Updates --- + +func (s *tgBotSender) StartPolling(ctx context.Context) <-chan TGUpdate { + go s.b.Start(ctx) + return s.updates +} + +func (s *tgBotSender) StartWebhook(ctx context.Context, path string) <-chan TGUpdate { + http.HandleFunc(path, s.b.WebhookHandler()) + go s.b.StartWebhook(ctx) // start workers that dispatch updates to handlers + return s.updates +} + +func (s *tgBotSender) SetWebhook(ctx context.Context, url string) error { + _, err := s.b.SetWebhook(ctx, &bot.SetWebhookParams{URL: url}) + return wrapErr(err) +} + +func (s *tgBotSender) DeleteWebhook(ctx context.Context) error { + _, err := s.b.DeleteWebhook(ctx, &bot.DeleteWebhookParams{}) + return wrapErr(err) +} + +// --- Send --- + +func (s *tgBotSender) SendMessage(ctx context.Context, chatID int64, text string, opts *SendOpts) (int, error) { + p := &bot.SendMessageParams{ + ChatID: chatID, + Text: text, + } + applySendMessageOpts(p, opts) + msg, err := s.b.SendMessage(ctx, p) + if err != nil { + return 0, wrapErr(err) + } + return msg.ID, nil +} + +func (s *tgBotSender) SendPhoto(ctx context.Context, chatID int64, file FileArg, opts *SendOpts) (int, error) { + p := &bot.SendPhotoParams{ + ChatID: chatID, + Photo: toInputFile(file), + } + applySendPhotoOpts(p, opts) + msg, err := s.b.SendPhoto(ctx, p) + if err != nil { + return 0, wrapErr(err) + } + return msg.ID, nil +} + +func (s *tgBotSender) SendVideo(ctx context.Context, chatID int64, file FileArg, opts *SendOpts) (int, error) { + p := &bot.SendVideoParams{ + ChatID: chatID, + Video: toInputFile(file), + } + applySendVideoOpts(p, opts) + msg, err := s.b.SendVideo(ctx, p) + if err != nil { + return 0, wrapErr(err) + } + return msg.ID, nil +} + +func (s *tgBotSender) SendAudio(ctx context.Context, chatID int64, file FileArg, opts *SendOpts) (int, error) { + p := &bot.SendAudioParams{ + ChatID: chatID, + Audio: toInputFile(file), + } + applySendAudioOpts(p, opts) + msg, err := s.b.SendAudio(ctx, p) + if err != nil { + return 0, wrapErr(err) + } + return msg.ID, nil +} + +func (s *tgBotSender) SendDocument(ctx context.Context, chatID int64, file FileArg, opts *SendOpts) (int, error) { + p := &bot.SendDocumentParams{ + ChatID: chatID, + Document: toInputFile(file), + } + applySendDocumentOpts(p, opts) + msg, err := s.b.SendDocument(ctx, p) + if err != nil { + return 0, wrapErr(err) + } + return msg.ID, nil +} + +func (s *tgBotSender) SendMediaGroup(ctx context.Context, chatID int64, media []TGInputMedia, opts *SendOpts) ([]int, error) { + items := make([]models.InputMedia, 0, len(media)) + for _, m := range media { + items = append(items, toLibInputMedia(m)) + } + p := &bot.SendMediaGroupParams{ + ChatID: chatID, + Media: items, + } + if opts != nil { + if opts.ThreadID != 0 { + p.MessageThreadID = opts.ThreadID + } + if opts.ReplyToID != 0 { + p.ReplyParameters = &models.ReplyParameters{MessageID: opts.ReplyToID} + } + } + msgs, err := s.b.SendMediaGroup(ctx, p) + if err != nil { + return nil, wrapErr(err) + } + ids := make([]int, len(msgs)) + for i, m := range msgs { + ids[i] = m.ID + } + return ids, nil +} + +// --- Edit --- + +func (s *tgBotSender) EditMessageText(ctx context.Context, chatID int64, msgID int, text string, opts *SendOpts) error { + p := &bot.EditMessageTextParams{ + ChatID: chatID, + MessageID: msgID, + Text: text, + } + if opts != nil { + if opts.ParseMode != "" { + p.ParseMode = models.ParseMode(opts.ParseMode) + } + if opts.ReplyMarkup != nil { + p.ReplyMarkup = toLibKeyboard(opts.ReplyMarkup) + } + } + _, err := s.b.EditMessageText(ctx, p) + return wrapErr(err) +} + +func (s *tgBotSender) EditMessageMedia(ctx context.Context, chatID int64, msgID int, media TGInputMedia) error { + p := &bot.EditMessageMediaParams{ + ChatID: chatID, + MessageID: msgID, + Media: toLibInputMedia(media), + } + _, err := s.b.EditMessageMedia(ctx, p) + return wrapErr(err) +} + +// --- Other --- + +func (s *tgBotSender) DeleteMessage(ctx context.Context, chatID int64, msgID int) error { + _, err := s.b.DeleteMessage(ctx, &bot.DeleteMessageParams{ + ChatID: chatID, + MessageID: msgID, + }) + return wrapErr(err) +} + +func (s *tgBotSender) AnswerCallback(ctx context.Context, callbackID string, text string) error { + _, err := s.b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{ + CallbackQueryID: callbackID, + Text: text, + }) + return wrapErr(err) +} + +func (s *tgBotSender) GetFile(ctx context.Context, fileID string) (string, error) { + f, err := s.b.GetFile(ctx, &bot.GetFileParams{FileID: fileID}) + if err != nil { + return "", wrapErr(err) + } + return f.FilePath, nil +} + +func (s *tgBotSender) GetFileDirectURL(filePath string) string { + if s.apiURL != "" { + return s.apiURL + "/file/bot" + s.token + "/" + filePath + } + return "https://api.telegram.org/file/bot" + s.token + "/" + filePath +} + +func (s *tgBotSender) GetChatMember(ctx context.Context, chatID, userID int64) (string, error) { + m, err := s.b.GetChatMember(ctx, &bot.GetChatMemberParams{ + ChatID: chatID, + UserID: userID, + }) + if err != nil { + return "", wrapErr(err) + } + return string(m.Type), nil +} + +func (s *tgBotSender) SetMyCommands(ctx context.Context, commands []BotCommand, scope *CommandScope) error { + cmds := make([]models.BotCommand, len(commands)) + for i, c := range commands { + cmds[i] = models.BotCommand{Command: c.Command, Description: c.Description} + } + p := &bot.SetMyCommandsParams{Commands: cmds} + if scope != nil && scope.Type == "all_chat_administrators" { + p.Scope = &models.BotCommandScopeAllChatAdministrators{} + } + _, err := s.b.SetMyCommands(ctx, p) + return wrapErr(err) +} + +func (s *tgBotSender) GetChat(ctx context.Context, chatID int64) (string, error) { + chat, err := s.b.GetChat(ctx, &bot.GetChatParams{ChatID: chatID}) + if err != nil { + return "", wrapErr(err) + } + return chat.Title, nil +} + +// --- Conversion helpers --- + +func toInputFile(f FileArg) models.InputFile { + if f.URL != "" { + return &models.InputFileString{Data: f.URL} + } + name := f.Name + if name == "" { + name = "file" + } + return &models.InputFileUpload{Filename: name, Data: bytes.NewReader(f.Bytes)} +} + +func toLibInputMedia(m TGInputMedia) models.InputMedia { + pm := models.ParseMode(m.ParseMode) + + // InputMedia structs use string Media field (URL or file_id) plus + // an io.Reader MediaAttachment for uploads. + if m.File.URL != "" { + // URL or file_id — set Media string directly, no attachment. + switch m.Type { + case "video": + return &models.InputMediaVideo{Media: m.File.URL, Caption: m.Caption, ParseMode: pm} + case "audio": + return &models.InputMediaAudio{Media: m.File.URL, Caption: m.Caption, ParseMode: pm} + case "document": + return &models.InputMediaDocument{Media: m.File.URL, Caption: m.Caption, ParseMode: pm} + default: + return &models.InputMediaPhoto{Media: m.File.URL, Caption: m.Caption, ParseMode: pm} + } + } + + // Upload — use attach:// protocol with MediaAttachment reader. + name := m.File.Name + if name == "" { + name = "file" + } + media := "attach://" + name + reader := bytes.NewReader(m.File.Bytes) + switch m.Type { + case "video": + return &models.InputMediaVideo{Media: media, Caption: m.Caption, ParseMode: pm, MediaAttachment: reader} + case "audio": + return &models.InputMediaAudio{Media: media, Caption: m.Caption, ParseMode: pm, MediaAttachment: reader} + case "document": + return &models.InputMediaDocument{Media: media, Caption: m.Caption, ParseMode: pm, MediaAttachment: reader} + default: + return &models.InputMediaPhoto{Media: media, Caption: m.Caption, ParseMode: pm, MediaAttachment: reader} + } +} + +func toLibKeyboard(kb *InlineKeyboardMarkup) *models.InlineKeyboardMarkup { + if kb == nil { + return nil + } + rows := make([][]models.InlineKeyboardButton, len(kb.Rows)) + for i, row := range kb.Rows { + btns := make([]models.InlineKeyboardButton, len(row)) + for j, b := range row { + btns[j] = models.InlineKeyboardButton{Text: b.Text, CallbackData: b.CallbackData} + } + rows[i] = btns + } + return &models.InlineKeyboardMarkup{InlineKeyboard: rows} +} + +// --- Apply opts helpers --- + +func applySendMessageOpts(p *bot.SendMessageParams, opts *SendOpts) { + if opts == nil { + return + } + if opts.ThreadID != 0 { + p.MessageThreadID = opts.ThreadID + } + if opts.ParseMode != "" { + p.ParseMode = models.ParseMode(opts.ParseMode) + } + if opts.ReplyToID != 0 { + p.ReplyParameters = &models.ReplyParameters{MessageID: opts.ReplyToID} + } + if opts.ReplyMarkup != nil { + p.ReplyMarkup = toLibKeyboard(opts.ReplyMarkup) + } +} + +func applySendPhotoOpts(p *bot.SendPhotoParams, opts *SendOpts) { + if opts == nil { + return + } + if opts.ThreadID != 0 { + p.MessageThreadID = opts.ThreadID + } + if opts.Caption != "" { + p.Caption = opts.Caption + } + if opts.ParseMode != "" { + p.ParseMode = models.ParseMode(opts.ParseMode) + } + if opts.ReplyToID != 0 { + p.ReplyParameters = &models.ReplyParameters{MessageID: opts.ReplyToID} + } + if opts.ReplyMarkup != nil { + p.ReplyMarkup = toLibKeyboard(opts.ReplyMarkup) + } +} + +func applySendVideoOpts(p *bot.SendVideoParams, opts *SendOpts) { + if opts == nil { + return + } + if opts.ThreadID != 0 { + p.MessageThreadID = opts.ThreadID + } + if opts.Caption != "" { + p.Caption = opts.Caption + } + if opts.ParseMode != "" { + p.ParseMode = models.ParseMode(opts.ParseMode) + } + if opts.ReplyToID != 0 { + p.ReplyParameters = &models.ReplyParameters{MessageID: opts.ReplyToID} + } + if opts.ReplyMarkup != nil { + p.ReplyMarkup = toLibKeyboard(opts.ReplyMarkup) + } +} + +func applySendAudioOpts(p *bot.SendAudioParams, opts *SendOpts) { + if opts == nil { + return + } + if opts.ThreadID != 0 { + p.MessageThreadID = opts.ThreadID + } + if opts.Caption != "" { + p.Caption = opts.Caption + } + if opts.ParseMode != "" { + p.ParseMode = models.ParseMode(opts.ParseMode) + } + if opts.ReplyToID != 0 { + p.ReplyParameters = &models.ReplyParameters{MessageID: opts.ReplyToID} + } + if opts.ReplyMarkup != nil { + p.ReplyMarkup = toLibKeyboard(opts.ReplyMarkup) + } +} + +func applySendDocumentOpts(p *bot.SendDocumentParams, opts *SendOpts) { + if opts == nil { + return + } + if opts.ThreadID != 0 { + p.MessageThreadID = opts.ThreadID + } + if opts.Caption != "" { + p.Caption = opts.Caption + } + if opts.ParseMode != "" { + p.ParseMode = models.ParseMode(opts.ParseMode) + } + if opts.ReplyToID != 0 { + p.ReplyParameters = &models.ReplyParameters{MessageID: opts.ReplyToID} + } + if opts.ReplyMarkup != nil { + p.ReplyMarkup = toLibKeyboard(opts.ReplyMarkup) + } +} + +// --- Error wrapping --- + +func wrapErr(err error) error { + if err == nil { + return nil + } + var me *bot.MigrateError + if errors.As(err, &me) { + return &TGError{ + Code: 400, + Description: me.Message, + MigrateToChatID: int64(me.MigrateToChatID), + } + } + if errors.Is(err, bot.ErrorForbidden) { + return &TGError{Code: 403, Description: err.Error()} + } + if errors.Is(err, bot.ErrorBadRequest) { + return &TGError{Code: 400, Description: err.Error()} + } + if errors.Is(err, bot.ErrorNotFound) { + return &TGError{Code: 404, Description: err.Error()} + } + var tmr *bot.TooManyRequestsError + if errors.As(err, &tmr) { + return &TGError{Code: 429, Description: tmr.Error()} + } + return err +} + +// --- Update conversion --- + +func convertUpdate(u *models.Update) TGUpdate { + return TGUpdate{ + Message: convertMsg(u.Message), + EditedMessage: convertMsg(u.EditedMessage), + ChannelPost: convertMsg(u.ChannelPost), + EditedChannelPost: convertMsg(u.EditedChannelPost), + CallbackQuery: convertCallback(u.CallbackQuery), + } +} + +func convertMsg(m *models.Message) *TGMessage { + if m == nil { + return nil + } + msg := &TGMessage{ + MessageID: m.ID, + MessageThreadID: m.MessageThreadID, + Chat: ChatInfo{ + ID: m.Chat.ID, + Type: string(m.Chat.Type), + Title: m.Chat.Title, + }, + Text: m.Text, + Caption: m.Caption, + MediaGroupID: m.MediaGroupID, + MigrateToChatID: m.MigrateToChatID, + } + + if m.From != nil { + msg.From = &UserInfo{ + ID: m.From.ID, + IsBot: m.From.IsBot, + UserName: m.From.Username, + FirstName: m.From.FirstName, + LastName: m.From.LastName, + } + } + + if m.SenderChat != nil { + msg.SenderChat = &ChatInfo{ + ID: m.SenderChat.ID, + Type: string(m.SenderChat.Type), + Title: m.SenderChat.Title, + } + } + + // ForwardOrigin -> ForwardOriginChat (for channel forwards) + if m.ForwardOrigin != nil && m.ForwardOrigin.MessageOriginChannel != nil { + ch := m.ForwardOrigin.MessageOriginChannel.Chat + msg.ForwardOriginChat = &ChatInfo{ + ID: ch.ID, + Type: string(ch.Type), + Title: ch.Title, + } + } + + // Photo + for _, p := range m.Photo { + msg.Photo = append(msg.Photo, PhotoSize{ + FileID: p.FileID, + FileSize: p.FileSize, + }) + } + + if m.Video != nil { + msg.Video = &FileInfo{FileID: m.Video.FileID, FileName: m.Video.FileName, FileSize: int(m.Video.FileSize)} + } + if m.Document != nil { + msg.Document = &DocInfo{FileID: m.Document.FileID, FileName: m.Document.FileName, FileSize: int(m.Document.FileSize), MimeType: m.Document.MimeType} + } + if m.Animation != nil { + msg.Animation = &FileInfo{FileID: m.Animation.FileID, FileName: m.Animation.FileName, FileSize: int(m.Animation.FileSize)} + } + if m.Sticker != nil { + msg.Sticker = &StickerInfo{FileID: m.Sticker.FileID, FileSize: m.Sticker.FileSize, IsAnimated: m.Sticker.IsAnimated} + } + if m.Voice != nil { + msg.Voice = &FileInfo{FileID: m.Voice.FileID, FileSize: int(m.Voice.FileSize)} + } + if m.Audio != nil { + msg.Audio = &AudioInfo{FileID: m.Audio.FileID, FileName: m.Audio.FileName, FileSize: int(m.Audio.FileSize)} + } + if m.VideoNote != nil { + msg.VideoNote = &FileInfo{FileID: m.VideoNote.FileID, FileSize: m.VideoNote.FileSize} + } + + if m.ReplyToMessage != nil { + msg.ReplyToMessage = convertMsg(m.ReplyToMessage) + } + + for _, e := range m.Entities { + msg.Entities = append(msg.Entities, Entity{Type: string(e.Type), Offset: e.Offset, Length: e.Length, URL: e.URL}) + } + for _, e := range m.CaptionEntities { + msg.CaptionEntities = append(msg.CaptionEntities, Entity{Type: string(e.Type), Offset: e.Offset, Length: e.Length, URL: e.URL}) + } + + return msg +} + +func convertCallback(cb *models.CallbackQuery) *TGCallback { + if cb == nil { + return nil + } + c := &TGCallback{ + ID: cb.ID, + Data: cb.Data, + } + if cb.From.ID != 0 { + c.From = &UserInfo{ + ID: cb.From.ID, + IsBot: cb.From.IsBot, + UserName: cb.From.Username, + FirstName: cb.From.FirstName, + LastName: cb.From.LastName, + } + } + if cb.Message.Message != nil { + c.Message = convertMsg(cb.Message.Message) + } + return c +} diff --git a/tgsender_impl_test.go b/tgsender_impl_test.go new file mode 100644 index 0000000..d866ecd --- /dev/null +++ b/tgsender_impl_test.go @@ -0,0 +1,590 @@ +package main + +import ( + "errors" + "testing" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) + +// --- convertMsg --- + +func TestConvertMsg_Nil(t *testing.T) { + if got := convertMsg(nil); got != nil { + t.Errorf("convertMsg(nil) = %v, want nil", got) + } +} + +func TestConvertMsg_Basic(t *testing.T) { + m := &models.Message{ + ID: 42, + MessageThreadID: 7, + Chat: models.Chat{ID: -100, Type: "supergroup", Title: "Test"}, + Text: "hello", + Caption: "cap", + MediaGroupID: "mg1", + MigrateToChatID: -200, + } + got := convertMsg(m) + if got.MessageID != 42 { + t.Errorf("MessageID = %d, want 42", got.MessageID) + } + if got.MessageThreadID != 7 { + t.Errorf("MessageThreadID = %d, want 7", got.MessageThreadID) + } + if got.Chat.ID != -100 || got.Chat.Type != "supergroup" || got.Chat.Title != "Test" { + t.Errorf("Chat = %+v", got.Chat) + } + if got.Text != "hello" { + t.Errorf("Text = %q", got.Text) + } + if got.Caption != "cap" { + t.Errorf("Caption = %q", got.Caption) + } + if got.MediaGroupID != "mg1" { + t.Errorf("MediaGroupID = %q", got.MediaGroupID) + } + if got.MigrateToChatID != -200 { + t.Errorf("MigrateToChatID = %d", got.MigrateToChatID) + } + if got.From != nil { + t.Errorf("From should be nil when input From is nil") + } + if got.SenderChat != nil { + t.Errorf("SenderChat should be nil") + } +} + +func TestConvertMsg_From(t *testing.T) { + m := &models.Message{ + ID: 1, + From: &models.User{ + ID: 123, + IsBot: true, + Username: "testbot", + FirstName: "Test", + LastName: "Bot", + }, + Chat: models.Chat{ID: 1}, + } + got := convertMsg(m) + if got.From == nil { + t.Fatal("From is nil") + } + if got.From.ID != 123 { + t.Errorf("From.ID = %d", got.From.ID) + } + if !got.From.IsBot { + t.Error("From.IsBot = false") + } + if got.From.UserName != "testbot" { + t.Errorf("From.UserName = %q", got.From.UserName) + } + if got.From.FirstName != "Test" || got.From.LastName != "Bot" { + t.Errorf("From name = %q %q", got.From.FirstName, got.From.LastName) + } +} + +func TestConvertMsg_SenderChat(t *testing.T) { + m := &models.Message{ + ID: 1, + Chat: models.Chat{ID: 1}, + SenderChat: &models.Chat{ID: -500, Type: "channel", Title: "Chan"}, + } + got := convertMsg(m) + if got.SenderChat == nil { + t.Fatal("SenderChat nil") + } + if got.SenderChat.ID != -500 || got.SenderChat.Type != "channel" || got.SenderChat.Title != "Chan" { + t.Errorf("SenderChat = %+v", got.SenderChat) + } +} + +func TestConvertMsg_ForwardOriginChannel(t *testing.T) { + m := &models.Message{ + ID: 1, + Chat: models.Chat{ID: 1}, + ForwardOrigin: &models.MessageOrigin{ + MessageOriginChannel: &models.MessageOriginChannel{ + Chat: models.Chat{ID: -999, Type: "channel", Title: "News"}, + }, + }, + } + got := convertMsg(m) + if got.ForwardOriginChat == nil { + t.Fatal("ForwardOriginChat nil") + } + if got.ForwardOriginChat.ID != -999 || got.ForwardOriginChat.Title != "News" { + t.Errorf("ForwardOriginChat = %+v", got.ForwardOriginChat) + } +} + +func TestConvertMsg_ForwardOriginNonChannel(t *testing.T) { + m := &models.Message{ + ID: 1, + Chat: models.Chat{ID: 1}, + ForwardOrigin: &models.MessageOrigin{ + MessageOriginUser: &models.MessageOriginUser{}, + }, + } + got := convertMsg(m) + if got.ForwardOriginChat != nil { + t.Errorf("ForwardOriginChat should be nil for non-channel origin, got %+v", got.ForwardOriginChat) + } +} + +func TestConvertMsg_Media(t *testing.T) { + m := &models.Message{ + ID: 1, + Chat: models.Chat{ID: 1}, + Photo: []models.PhotoSize{ + {FileID: "p1", FileSize: 100}, + {FileID: "p2", FileSize: 200}, + }, + Video: &models.Video{FileID: "v1", FileName: "vid.mp4", FileSize: 5000}, + Document: &models.Document{FileID: "d1", FileName: "doc.pdf", FileSize: 3000, MimeType: "application/pdf"}, + Animation: &models.Animation{FileID: "a1", FileName: "anim.gif", FileSize: 1000}, + Sticker: &models.Sticker{FileID: "s1", FileSize: 50, IsAnimated: true}, + Voice: &models.Voice{FileID: "vo1", FileSize: 800}, + Audio: &models.Audio{FileID: "au1", FileName: "song.mp3", FileSize: 4000}, + VideoNote: &models.VideoNote{FileID: "vn1", FileSize: 600}, + } + got := convertMsg(m) + + if len(got.Photo) != 2 || got.Photo[0].FileID != "p1" || got.Photo[1].FileSize != 200 { + t.Errorf("Photo = %+v", got.Photo) + } + if got.Video == nil || got.Video.FileID != "v1" || got.Video.FileName != "vid.mp4" || got.Video.FileSize != 5000 { + t.Errorf("Video = %+v", got.Video) + } + if got.Document == nil || got.Document.FileID != "d1" || got.Document.MimeType != "application/pdf" { + t.Errorf("Document = %+v", got.Document) + } + if got.Animation == nil || got.Animation.FileID != "a1" { + t.Errorf("Animation = %+v", got.Animation) + } + if got.Sticker == nil || got.Sticker.FileID != "s1" || !got.Sticker.IsAnimated { + t.Errorf("Sticker = %+v", got.Sticker) + } + if got.Voice == nil || got.Voice.FileID != "vo1" || got.Voice.FileSize != 800 { + t.Errorf("Voice = %+v", got.Voice) + } + if got.Audio == nil || got.Audio.FileID != "au1" || got.Audio.FileName != "song.mp3" { + t.Errorf("Audio = %+v", got.Audio) + } + if got.VideoNote == nil || got.VideoNote.FileID != "vn1" { + t.Errorf("VideoNote = %+v", got.VideoNote) + } +} + +func TestConvertMsg_Entities(t *testing.T) { + m := &models.Message{ + ID: 1, + Chat: models.Chat{ID: 1}, + Text: "hello world", + Entities: []models.MessageEntity{ + {Type: "bold", Offset: 0, Length: 5}, + {Type: "text_link", Offset: 6, Length: 5, URL: "https://example.com"}, + }, + CaptionEntities: []models.MessageEntity{ + {Type: "italic", Offset: 0, Length: 3}, + }, + } + got := convertMsg(m) + if len(got.Entities) != 2 { + t.Fatalf("Entities len = %d, want 2", len(got.Entities)) + } + if got.Entities[0].Type != "bold" || got.Entities[0].Offset != 0 || got.Entities[0].Length != 5 { + t.Errorf("Entities[0] = %+v", got.Entities[0]) + } + if got.Entities[1].URL != "https://example.com" { + t.Errorf("Entities[1].URL = %q", got.Entities[1].URL) + } + if len(got.CaptionEntities) != 1 || got.CaptionEntities[0].Type != "italic" { + t.Errorf("CaptionEntities = %+v", got.CaptionEntities) + } +} + +func TestConvertMsg_ReplyToMessage(t *testing.T) { + m := &models.Message{ + ID: 10, + Chat: models.Chat{ID: 1}, + ReplyToMessage: &models.Message{ + ID: 5, + Chat: models.Chat{ID: 1}, + Text: "original", + }, + } + got := convertMsg(m) + if got.ReplyToMessage == nil { + t.Fatal("ReplyToMessage nil") + } + if got.ReplyToMessage.MessageID != 5 || got.ReplyToMessage.Text != "original" { + t.Errorf("ReplyToMessage = %+v", got.ReplyToMessage) + } +} + +// --- convertCallback --- + +func TestConvertCallback_Nil(t *testing.T) { + if got := convertCallback(nil); got != nil { + t.Errorf("convertCallback(nil) = %v, want nil", got) + } +} + +func TestConvertCallback_Full(t *testing.T) { + cb := &models.CallbackQuery{ + ID: "cb123", + Data: "cpd:both:999", + From: models.User{ID: 42, Username: "user1", FirstName: "John"}, + Message: models.MaybeInaccessibleMessage{ + Message: &models.Message{ + ID: 77, + Chat: models.Chat{ID: -100, Title: "Group"}, + Text: "old text", + }, + }, + } + got := convertCallback(cb) + if got.ID != "cb123" || got.Data != "cpd:both:999" { + t.Errorf("ID=%q Data=%q", got.ID, got.Data) + } + if got.From == nil || got.From.ID != 42 { + t.Errorf("From = %+v", got.From) + } + if got.Message == nil || got.Message.MessageID != 77 || got.Message.Text != "old text" { + t.Errorf("Message = %+v", got.Message) + } +} + +func TestConvertCallback_NoFrom(t *testing.T) { + cb := &models.CallbackQuery{ + ID: "cb1", + From: models.User{}, // zero value, ID=0 + } + got := convertCallback(cb) + if got.From != nil { + t.Errorf("From should be nil when ID=0, got %+v", got.From) + } +} + +func TestConvertCallback_InaccessibleMessage(t *testing.T) { + cb := &models.CallbackQuery{ + ID: "cb2", + From: models.User{ID: 1}, + Message: models.MaybeInaccessibleMessage{ + InaccessibleMessage: &models.InaccessibleMessage{ + Chat: models.Chat{ID: -100}, + MessageID: 55, + }, + }, + } + got := convertCallback(cb) + if got.Message != nil { + t.Errorf("Message should be nil for inaccessible message, got %+v", got.Message) + } +} + +// --- convertUpdate --- + +func TestConvertUpdate_Routes(t *testing.T) { + u := &models.Update{ + Message: &models.Message{ID: 1, Chat: models.Chat{ID: 1}}, + EditedMessage: &models.Message{ID: 2, Chat: models.Chat{ID: 1}}, + ChannelPost: &models.Message{ID: 3, Chat: models.Chat{ID: 1}}, + EditedChannelPost: &models.Message{ID: 4, Chat: models.Chat{ID: 1}}, + CallbackQuery: &models.CallbackQuery{ID: "cb5", From: models.User{ID: 1}}, + } + got := convertUpdate(u) + if got.Message == nil || got.Message.MessageID != 1 { + t.Errorf("Message = %+v", got.Message) + } + if got.EditedMessage == nil || got.EditedMessage.MessageID != 2 { + t.Errorf("EditedMessage = %+v", got.EditedMessage) + } + if got.ChannelPost == nil || got.ChannelPost.MessageID != 3 { + t.Errorf("ChannelPost = %+v", got.ChannelPost) + } + if got.EditedChannelPost == nil || got.EditedChannelPost.MessageID != 4 { + t.Errorf("EditedChannelPost = %+v", got.EditedChannelPost) + } + if got.CallbackQuery == nil || got.CallbackQuery.ID != "cb5" { + t.Errorf("CallbackQuery = %+v", got.CallbackQuery) + } +} + +func TestConvertUpdate_Empty(t *testing.T) { + got := convertUpdate(&models.Update{}) + if got.Message != nil || got.EditedMessage != nil || got.ChannelPost != nil || got.EditedChannelPost != nil || got.CallbackQuery != nil { + t.Errorf("empty update should produce all nils") + } +} + +// --- wrapErr --- + +func TestWrapErr_Nil(t *testing.T) { + if got := wrapErr(nil); got != nil { + t.Errorf("wrapErr(nil) = %v", got) + } +} + +func TestWrapErr_MigrateError(t *testing.T) { + err := &bot.MigrateError{Message: "group migrated", MigrateToChatID: -1001234} + got := wrapErr(err) + var tgErr *TGError + if !errors.As(got, &tgErr) { + t.Fatalf("expected *TGError, got %T", got) + } + if tgErr.Code != 400 { + t.Errorf("Code = %d, want 400", tgErr.Code) + } + if tgErr.MigrateToChatID != -1001234 { + t.Errorf("MigrateToChatID = %d", tgErr.MigrateToChatID) + } +} + +func TestWrapErr_Forbidden(t *testing.T) { + got := wrapErr(bot.ErrorForbidden) + var tgErr *TGError + if !errors.As(got, &tgErr) { + t.Fatalf("expected *TGError, got %T: %v", got, got) + } + if tgErr.Code != 403 { + t.Errorf("Code = %d, want 403", tgErr.Code) + } +} + +func TestWrapErr_BadRequest(t *testing.T) { + got := wrapErr(bot.ErrorBadRequest) + var tgErr *TGError + if !errors.As(got, &tgErr) { + t.Fatalf("expected *TGError, got %T", got) + } + if tgErr.Code != 400 { + t.Errorf("Code = %d, want 400", tgErr.Code) + } +} + +func TestWrapErr_NotFound(t *testing.T) { + got := wrapErr(bot.ErrorNotFound) + var tgErr *TGError + if !errors.As(got, &tgErr) { + t.Fatalf("expected *TGError, got %T", got) + } + if tgErr.Code != 404 { + t.Errorf("Code = %d, want 404", tgErr.Code) + } +} + +func TestWrapErr_TooManyRequests(t *testing.T) { + err := &bot.TooManyRequestsError{Message: "slow down", RetryAfter: 30} + got := wrapErr(err) + var tgErr *TGError + if !errors.As(got, &tgErr) { + t.Fatalf("expected *TGError, got %T", got) + } + if tgErr.Code != 429 { + t.Errorf("Code = %d, want 429", tgErr.Code) + } +} + +func TestWrapErr_UnknownError(t *testing.T) { + orig := errors.New("something weird") + got := wrapErr(orig) + if got != orig { + t.Errorf("unknown error should pass through, got %v", got) + } +} + +// --- toInputFile --- + +func TestToInputFile_URL(t *testing.T) { + f := toInputFile(FileArg{URL: "https://example.com/photo.jpg"}) + ifs, ok := f.(*models.InputFileString) + if !ok { + t.Fatalf("expected *InputFileString, got %T", f) + } + if ifs.Data != "https://example.com/photo.jpg" { + t.Errorf("Data = %q", ifs.Data) + } +} + +func TestToInputFile_Bytes(t *testing.T) { + f := toInputFile(FileArg{Name: "test.jpg", Bytes: []byte("data")}) + ifu, ok := f.(*models.InputFileUpload) + if !ok { + t.Fatalf("expected *InputFileUpload, got %T", f) + } + if ifu.Filename != "test.jpg" { + t.Errorf("Filename = %q", ifu.Filename) + } +} + +func TestToInputFile_DefaultName(t *testing.T) { + f := toInputFile(FileArg{Bytes: []byte("data")}) + ifu, ok := f.(*models.InputFileUpload) + if !ok { + t.Fatalf("expected *InputFileUpload, got %T", f) + } + if ifu.Filename != "file" { + t.Errorf("Filename = %q, want 'file'", ifu.Filename) + } +} + +// --- toLibInputMedia --- + +func TestToLibInputMedia_PhotoURL(t *testing.T) { + m := toLibInputMedia(TGInputMedia{ + Type: "photo", + File: FileArg{URL: "https://example.com/img.jpg"}, + Caption: "nice", + ParseMode: "HTML", + }) + p, ok := m.(*models.InputMediaPhoto) + if !ok { + t.Fatalf("expected *InputMediaPhoto, got %T", m) + } + if p.Media != "https://example.com/img.jpg" { + t.Errorf("Media = %q", p.Media) + } + if p.Caption != "nice" { + t.Errorf("Caption = %q", p.Caption) + } + if p.ParseMode != "HTML" { + t.Errorf("ParseMode = %q", p.ParseMode) + } +} + +func TestToLibInputMedia_VideoBytes(t *testing.T) { + m := toLibInputMedia(TGInputMedia{ + Type: "video", + File: FileArg{Name: "clip.mp4", Bytes: []byte("vid")}, + }) + v, ok := m.(*models.InputMediaVideo) + if !ok { + t.Fatalf("expected *InputMediaVideo, got %T", m) + } + if v.Media != "attach://clip.mp4" { + t.Errorf("Media = %q", v.Media) + } + if v.MediaAttachment == nil { + t.Error("MediaAttachment is nil") + } +} + +func TestToLibInputMedia_AudioURL(t *testing.T) { + m := toLibInputMedia(TGInputMedia{Type: "audio", File: FileArg{URL: "https://x.com/a.mp3"}}) + if _, ok := m.(*models.InputMediaAudio); !ok { + t.Fatalf("expected *InputMediaAudio, got %T", m) + } +} + +func TestToLibInputMedia_DocumentBytes(t *testing.T) { + m := toLibInputMedia(TGInputMedia{Type: "document", File: FileArg{Name: "f.pdf", Bytes: []byte("pdf")}}) + d, ok := m.(*models.InputMediaDocument) + if !ok { + t.Fatalf("expected *InputMediaDocument, got %T", m) + } + if d.Media != "attach://f.pdf" { + t.Errorf("Media = %q", d.Media) + } +} + +func TestToLibInputMedia_DefaultType(t *testing.T) { + m := toLibInputMedia(TGInputMedia{Type: "unknown", File: FileArg{URL: "https://x.com/img"}}) + if _, ok := m.(*models.InputMediaPhoto); !ok { + t.Fatalf("unknown type should default to photo, got %T", m) + } +} + +func TestToLibInputMedia_BytesDefaultName(t *testing.T) { + m := toLibInputMedia(TGInputMedia{Type: "photo", File: FileArg{Bytes: []byte("x")}}) + p, ok := m.(*models.InputMediaPhoto) + if !ok { + t.Fatalf("expected *InputMediaPhoto, got %T", m) + } + if p.Media != "attach://file" { + t.Errorf("Media = %q, want 'attach://file'", p.Media) + } +} + +// --- toLibKeyboard --- + +func TestToLibKeyboard_Nil(t *testing.T) { + if got := toLibKeyboard(nil); got != nil { + t.Errorf("toLibKeyboard(nil) = %v", got) + } +} + +func TestToLibKeyboard(t *testing.T) { + kb := &InlineKeyboardMarkup{ + Rows: [][]InlineKeyboardButton{ + { + {Text: "A", CallbackData: "a"}, + {Text: "B", CallbackData: "b"}, + }, + { + {Text: "C", CallbackData: "c"}, + }, + }, + } + got := toLibKeyboard(kb) + if len(got.InlineKeyboard) != 2 { + t.Fatalf("rows = %d, want 2", len(got.InlineKeyboard)) + } + if len(got.InlineKeyboard[0]) != 2 { + t.Fatalf("row0 cols = %d, want 2", len(got.InlineKeyboard[0])) + } + if got.InlineKeyboard[0][0].Text != "A" || got.InlineKeyboard[0][0].CallbackData != "a" { + t.Errorf("btn[0][0] = %+v", got.InlineKeyboard[0][0]) + } + if got.InlineKeyboard[1][0].Text != "C" { + t.Errorf("btn[1][0] = %+v", got.InlineKeyboard[1][0]) + } +} + +// --- keyboard helpers --- + +func TestNewInlineKeyboard(t *testing.T) { + kb := NewInlineKeyboard( + NewInlineRow(NewInlineButton("X", "x"), NewInlineButton("Y", "y")), + NewInlineRow(NewInlineButton("Z", "z")), + ) + if len(kb.Rows) != 2 { + t.Fatalf("rows = %d", len(kb.Rows)) + } + if kb.Rows[0][0].Text != "X" || kb.Rows[0][1].CallbackData != "y" { + t.Errorf("row0 = %+v", kb.Rows[0]) + } +} + +// --- TGError --- + +func TestTGError_Error(t *testing.T) { + e := &TGError{Code: 403, Description: "Forbidden: bot blocked"} + got := e.Error() + if got != "telegram: Forbidden: bot blocked (403)" { + t.Errorf("Error() = %q", got) + } +} + +// --- GetFileDirectURL --- + +func TestGetFileDirectURL_Default(t *testing.T) { + s := &tgBotSender{token: "123:ABC"} + got := s.GetFileDirectURL("photos/file_1.jpg") + want := "https://api.telegram.org/file/bot123:ABC/photos/file_1.jpg" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestGetFileDirectURL_CustomAPI(t *testing.T) { + s := &tgBotSender{token: "123:ABC", apiURL: "http://localhost:8081"} + got := s.GetFileDirectURL("photos/file_1.jpg") + want := "http://localhost:8081/photos/file_1.jpg" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} diff --git a/upload.go b/upload.go new file mode 100644 index 0000000..4983636 --- /dev/null +++ b/upload.go @@ -0,0 +1,423 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "mime/multipart" + "net/http" + "strings" + "time" + + maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes" +) + +// downloadURL скачивает файл по URL и возвращает bytes. +func (b *Bridge) downloadURL(url string) ([]byte, error) { + slog.Debug("downloadURL", "url", url) + resp, err := b.httpClient.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + slog.Debug("downloadURL response", "status", resp.StatusCode, "contentLength", resp.ContentLength, "url", url) + if resp.StatusCode != 200 { + return nil, fmt.Errorf("download status %d url: %s", resp.StatusCode, url) + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if len(data) == 0 { + slog.Warn("downloadURL: empty body", "url", url, "contentLength", resp.ContentLength) + return nil, fmt.Errorf("downloaded 0 bytes from %s", url) + } + slog.Debug("downloadURL ok", "size", len(data)) + return data, nil +} + +// sendTgMediaFromURL скачивает файл с URL и отправляет в TG как upload. +// maxBytes=0 means no size limit. fileName overrides name extracted from URL. +func (b *Bridge) sendTgMediaFromURL(ctx context.Context, tgChatID int64, mediaURL, mediaType, caption, parseMode string, replyToID, threadID int, maxBytes int64, fileName ...string) (int, error) { + slog.Debug("sendTgMediaFromURL start", "url", mediaURL, "type", mediaType, "tgChat", tgChatID) + data, nameFromURL, err := b.downloadURLWithLimit(mediaURL, maxBytes) + if err == nil { + slog.Debug("sendTgMediaFromURL downloaded", "size", len(data), "name", nameFromURL) + } + if err != nil { + return 0, fmt.Errorf("download media: %w", err) + } + + name := nameFromURL + if len(fileName) > 0 && fileName[0] != "" { + name = fileName[0] + } + file := FileArg{Name: name, Bytes: data} + + switch mediaType { + case "photo": + return b.tg.SendPhoto(ctx, tgChatID, file, &SendOpts{ + Caption: caption, ParseMode: parseMode, ReplyToID: replyToID, ThreadID: threadID, + }) + case "video": + return b.tg.SendVideo(ctx, tgChatID, file, &SendOpts{ + Caption: caption, ParseMode: parseMode, ReplyToID: replyToID, ThreadID: threadID, + }) + case "audio": + return b.tg.SendAudio(ctx, tgChatID, file, &SendOpts{ + Caption: caption, ParseMode: parseMode, ReplyToID: replyToID, ThreadID: threadID, + }) + case "file": + return b.tg.SendDocument(ctx, tgChatID, file, &SendOpts{ + Caption: caption, ParseMode: parseMode, ReplyToID: replyToID, ThreadID: threadID, + }) + default: + // sticker и прочее — как фото + return b.tg.SendPhoto(ctx, tgChatID, file, &SendOpts{ + Caption: caption, ThreadID: threadID, + }) + } +} + +// customUploadToMax — обход бага SDK: CDN возвращает XML вместо JSON +func (b *Bridge) customUploadToMax(ctx context.Context, uploadType maxschemes.UploadType, reader io.Reader, fileName string) (*maxschemes.UploadedInfo, error) { + // 1. Получаем URL и token от MAX API + apiURL := fmt.Sprintf("https://platform-api.max.ru/uploads?type=%s&v=1.2.5", string(uploadType)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + req.Header.Set("Authorization", b.cfg.MaxToken) + + resp, err := b.apiClient.Do(req) + if err != nil { + return nil, fmt.Errorf("get upload url: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("upload endpoint status: %d", resp.StatusCode) + } + + endpointBody, _ := io.ReadAll(resp.Body) + slog.Debug("MAX upload endpoint response", "status", resp.StatusCode, "body", string(endpointBody)) + + var endpoint maxschemes.UploadEndpoint + if err := json.Unmarshal(endpointBody, &endpoint); err != nil { + return nil, fmt.Errorf("decode upload endpoint: %w", err) + } + slog.Debug("MAX upload endpoint", "url", endpoint.Url, "token", endpoint.Token) + + // Для video/audio: token приходит сразу, но файл ВСЁ РАВНО нужно загрузить на CDN URL. + // Для file/image: token приходит после загрузки на CDN. + videoToken := endpoint.Token // сохраняем для video/audio + + if endpoint.Url == "" && videoToken != "" { + // Нет URL для загрузки, но есть token — file/image (не video/audio) + slog.Debug("MAX upload ok (endpoint token, no CDN needed)") + return &maxschemes.UploadedInfo{Token: videoToken}, nil + } + + if endpoint.Url == "" { + return nil, fmt.Errorf("upload endpoint returned empty URL and no token") + } + + // 2. Загружаем файл на CDN (multipart) + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + part, err := writer.CreateFormFile("data", fileName) + if err != nil { + return nil, fmt.Errorf("create form file: %w", err) + } + if _, err := io.Copy(part, reader); err != nil { + return nil, fmt.Errorf("copy to form: %w", err) + } + writer.Close() + + cdnReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.Url, &buf) + if err != nil { + return nil, fmt.Errorf("create CDN request: %w", err) + } + cdnReq.Header.Set("Content-Type", writer.FormDataContentType()) + + cdnResp, err := b.httpClient.Do(cdnReq) + if err != nil { + return nil, fmt.Errorf("upload to CDN: %w", err) + } + defer cdnResp.Body.Close() + + cdnBody, _ := io.ReadAll(cdnResp.Body) + slog.Debug("MAX CDN response", "status", cdnResp.StatusCode, "body", string(cdnBody)) + + // Проверяем ошибку запрещённого расширения + var apiErr struct { + Code string `json:"code"` + Message string `json:"message"` + } + if json.Unmarshal(cdnBody, &apiErr) == nil && apiErr.Code == "upload.error" { + slog.Warn("MAX upload rejected", "code", apiErr.Code, "message", apiErr.Message, "file", fileName) + return nil, &ErrForbiddenExtension{Name: fileName} + } + + // 3. Для video/audio: используем token из шага 1 (CDN возвращает только retval) + if videoToken != "" { + slog.Debug("MAX upload ok (video/audio token from endpoint)", "token", videoToken) + return &maxschemes.UploadedInfo{Token: videoToken}, nil + } + + // Для file/image: парсим CDN ответ (fileId + token в camelCase) + var cdnResult struct { + FileID int64 `json:"fileId"` + Token string `json:"token"` + } + if err := json.Unmarshal(cdnBody, &cdnResult); err == nil && cdnResult.Token != "" { + slog.Debug("MAX upload ok", "fileId", cdnResult.FileID) + return &maxschemes.UploadedInfo{Token: cdnResult.Token, FileID: cdnResult.FileID}, nil + } + return nil, fmt.Errorf("no token in CDN response: %s", string(cdnBody)) +} + +// uploadTgPhotoToMax скачивает фото из TG и загружает в MAX через SDK (возвращает PhotoTokens). +func (b *Bridge) uploadTgPhotoToMax(ctx context.Context, fileID string) (*maxschemes.PhotoTokens, error) { + fileURL, err := b.tgFileURL(ctx, fileID) + if err != nil { + return nil, fmt.Errorf("tg getFileURL: %w", err) + } + dlReq, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil) + if err != nil { + return nil, fmt.Errorf("create download request: %w", err) + } + resp, err := b.httpClient.Do(dlReq) + if err != nil { + return nil, fmt.Errorf("download: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return nil, fmt.Errorf("tg download status: %d", resp.StatusCode) + } + return b.maxApi.Uploads.UploadPhotoFromReader(ctx, resp.Body) +} + +// uploadTgMediaToMax скачивает файл из TG и загружает в MAX +func (b *Bridge) uploadTgMediaToMax(ctx context.Context, fileID string, uploadType maxschemes.UploadType, fileName string) (*maxschemes.UploadedInfo, error) { + fileURL, err := b.tgFileURL(ctx, fileID) + if err != nil { + return nil, fmt.Errorf("tg getFileURL: %w", err) + } + + dlReq, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil) + if err != nil { + return nil, fmt.Errorf("create download request: %w", err) + } + + resp, err := b.httpClient.Do(dlReq) + if err != nil { + return nil, fmt.Errorf("download: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("tg download status: %d url: %s", resp.StatusCode, fileURL) + } + + slog.Debug("TG file downloaded", "size", resp.ContentLength) + + return b.customUploadToMax(ctx, uploadType, resp.Body, fileName) +} + +// sendMaxDirect — отправка сообщения в MAX напрямую (обход SDK) +func (b *Bridge) sendMaxDirect(ctx context.Context, chatID int64, text string, attType string, token string, replyTo string) (string, error) { + return b.sendMaxDirectFormatted(ctx, chatID, text, attType, token, replyTo, "") +} + +func (b *Bridge) sendMaxDirectFormatted(ctx context.Context, chatID int64, text string, attType string, token string, replyTo string, format string) (string, error) { + type attachment struct { + Type string `json:"type"` + Payload map[string]string `json:"payload"` + } + type msgBody struct { + Text string `json:"text,omitempty"` + Attachments []attachment `json:"attachments,omitempty"` + Format string `json:"format,omitempty"` + Link *struct { + Type string `json:"type"` + Mid string `json:"mid"` + } `json:"link,omitempty"` + } + + body := msgBody{Text: text, Format: format} + if attType != "" && token != "" { + body.Attachments = []attachment{{ + Type: attType, + Payload: map[string]string{"token": token}, + }} + } + if replyTo != "" { + body.Link = &struct { + Type string `json:"type"` + Mid string `json:"mid"` + }{Type: "reply", Mid: replyTo} + } + + data, err := json.Marshal(body) + if err != nil { + return "", err + } + + url := fmt.Sprintf("https://platform-api.max.ru/messages?chat_id=%d&v=1.2.5", chatID) + + // Пауза перед первой отправкой если есть вложение (MAX CDN нужно время на обработку) + if attType != "" && token != "" { + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(3 * time.Second): + } + } + + // Retry при attachment.not.ready (файл ещё обрабатывается) + for attempt := 0; attempt < 20; attempt++ { + if attempt > 0 { + delay := time.Duration(3+attempt*2) * time.Second + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(delay): + } + slog.Warn("MAX retry", "attempt", attempt+1, "maxAttempts", 20) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data)) + if err != nil { + return "", err + } + req.Header.Set("Authorization", b.cfg.MaxToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := b.apiClient.Do(req) + if err != nil { + return "", err + } + + respBody, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + if resp.StatusCode == 200 { + var result struct { + Message struct { + Body struct { + Mid string `json:"mid"` + } `json:"body"` + } `json:"message"` + } + if err := json.Unmarshal(respBody, &result); err != nil { + return "", err + } + return result.Message.Body.Mid, nil + } + + // Проверяем attachment.not.ready — ретраим + if resp.StatusCode == 400 && strings.Contains(string(respBody), "attachment.not.ready") { + slog.Warn("MAX attachment not ready, waiting") + continue + } + + return "", fmt.Errorf("MAX API %d: %s", resp.StatusCode, string(respBody)) + } + return "", fmt.Errorf("MAX attachment not ready after 10 retries") +} + +// formatFileSize formats file size in human-readable form. +func formatFileSize(size int) string { + switch { + case size >= 1024*1024: + return fmt.Sprintf("%.1f МБ", float64(size)/1024/1024) + case size >= 1024: + return fmt.Sprintf("%.1f КБ", float64(size)/1024) + default: + return fmt.Sprintf("%d Б", size) + } +} + +// ErrFileTooLarge is returned when file exceeds the configured size limit. +type ErrFileTooLarge struct { + Size int64 + Name string +} + +func (e *ErrFileTooLarge) Error() string { + return fmt.Sprintf("file too large: %s (%s)", e.Name, formatFileSize(int(e.Size))) +} + +// ErrForbiddenExtension is returned when MAX API rejects the file extension. +type ErrForbiddenExtension struct { + Name string +} + +func (e *ErrForbiddenExtension) Error() string { + return fmt.Sprintf("file extension forbidden by MAX: %s", e.Name) +} + +// downloadURLWithLimit downloads a file from URL with an optional size limit. +// maxBytes=0 means no limit. Returns bytes and filename from Content-Disposition or URL. +func (b *Bridge) downloadURLWithLimit(url string, maxBytes int64) ([]byte, string, error) { + slog.Debug("downloadURLWithLimit start", "url", url, "maxBytes", maxBytes) + resp, err := b.httpClient.Get(url) + if err != nil { + slog.Error("downloadURLWithLimit failed", "err", err, "url", url) + return nil, "", err + } + defer resp.Body.Close() + slog.Debug("downloadURLWithLimit response", "status", resp.StatusCode, "contentLength", resp.ContentLength, "url", url) + if resp.StatusCode != 200 { + return nil, "", fmt.Errorf("download status %d", resp.StatusCode) + } + + // Extract filename from Content-Disposition + name := "" + if cd := resp.Header.Get("Content-Disposition"); cd != "" { + if i := strings.Index(cd, "filename=\""); i >= 0 { + rest := cd[i+len("filename=\""):] + if j := strings.Index(rest, "\""); j >= 0 { + name = rest[:j] + } + } + if name == "" { + if i := strings.Index(cd, "filename="); i >= 0 { + rest := strings.TrimSpace(cd[i+len("filename="):]) + if j := strings.IndexAny(rest, "; \t"); j >= 0 { + name = rest[:j] + } else { + name = rest + } + } + } + } + if name == "" { + name = fileNameFromURL(url) + } + + // Fast check via Content-Length + if maxBytes > 0 && resp.ContentLength > maxBytes { + return nil, name, &ErrFileTooLarge{Size: resp.ContentLength, Name: name} + } + + // Read body + var data []byte + if maxBytes > 0 { + data, err = io.ReadAll(io.LimitReader(resp.Body, maxBytes+1)) + } else { + data, err = io.ReadAll(resp.Body) + } + if err != nil { + return nil, "", err + } + if maxBytes > 0 && int64(len(data)) > maxBytes { + return nil, name, &ErrFileTooLarge{Size: int64(len(data)), Name: name} + } + + return data, name, nil +}