From fd69b3dab752137436bdf135569a8db3a47cde98 Mon Sep 17 00:00:00 2001 From: dmysob Date: Wed, 27 May 2026 09:55:11 +0900 Subject: [PATCH] initial commit --- .env.example | 30 + .github/workflows/RouterOS.yml | 104 ++ .github/workflows/build.yml | 26 + .github/workflows/release.yml | 80 ++ .gitignore | 18 + Dockerfile | 21 + LICENSE | 23 + Makefile | 21 + README.md | 190 +++ admin.go | 33 + admin_test.go | 130 ++ bridge.go | 275 ++++ docker-compose.yml | 35 + docs/pikabu-post.md | 62 + .../plans/2026-03-17-max-antispam.md | 950 +++++++++++++ .../specs/2026-03-17-max-antispam-design.md | 71 + format.go | 134 ++ format_test.go | 255 ++++ go.mod | 20 + go.sum | 89 ++ main.go | 174 +++ main_test.go | 54 + markup.go | 174 +++ markup_test.go | 252 ++++ max.go | 1134 ++++++++++++++++ mediagroup.go | 225 ++++ migrate.go | 124 ++ migrations/postgres/000001_init.down.sql | 3 + migrations/postgres/000001_init.up.sql | 24 + .../000002_add_prefix_and_created_at.down.sql | 2 + .../000002_add_prefix_and_created_at.up.sql | 2 + .../000003_pending_created_at.down.sql | 1 + .../postgres/000003_pending_created_at.up.sql | 1 + .../postgres/000004_add_crossposts.down.sql | 2 + .../postgres/000004_add_crossposts.up.sql | 12 + .../postgres/000005_add_send_queue.down.sql | 1 + .../postgres/000005_add_send_queue.up.sql | 15 + .../postgres/000006_crosspost_owner.down.sql | 1 + .../postgres/000006_crosspost_owner.up.sql | 1 + .../000007_crosspost_soft_delete.down.sql | 2 + .../000007_crosspost_soft_delete.up.sql | 2 + migrations/postgres/000008_add_users.down.sql | 1 + migrations/postgres/000008_add_users.up.sql | 8 + .../000009_crosspost_tg_owner.down.sql | 1 + .../postgres/000009_crosspost_tg_owner.up.sql | 1 + .../postgres/000010_queue_att_url.down.sql | 2 + .../postgres/000010_queue_att_url.up.sql | 2 + .../000011_crosspost_replacements.down.sql | 1 + .../000011_crosspost_replacements.up.sql | 1 + .../postgres/000012_pairs_thread_id.down.sql | 1 + .../postgres/000012_pairs_thread_id.up.sql | 1 + .../000013_crosspost_sync_edits.down.sql | 1 + .../000013_crosspost_sync_edits.up.sql | 1 + migrations/sqlite/000001_init.down.sql | 3 + migrations/sqlite/000001_init.up.sql | 24 + .../000002_add_prefix_and_created_at.down.sql | 24 + .../000002_add_prefix_and_created_at.up.sql | 2 + .../sqlite/000003_pending_created_at.down.sql | 4 + .../sqlite/000003_pending_created_at.up.sql | 1 + .../sqlite/000004_add_crossposts.down.sql | 1 + .../sqlite/000004_add_crossposts.up.sql | 12 + .../sqlite/000005_add_send_queue.down.sql | 1 + .../sqlite/000005_add_send_queue.up.sql | 15 + .../sqlite/000006_crosspost_owner.down.sql | 1 + .../sqlite/000006_crosspost_owner.up.sql | 1 + .../000007_crosspost_soft_delete.down.sql | 2 + .../000007_crosspost_soft_delete.up.sql | 2 + migrations/sqlite/000008_add_users.down.sql | 1 + migrations/sqlite/000008_add_users.up.sql | 8 + .../sqlite/000009_crosspost_tg_owner.down.sql | 1 + .../sqlite/000009_crosspost_tg_owner.up.sql | 1 + .../sqlite/000010_queue_att_url.down.sql | 2 + migrations/sqlite/000010_queue_att_url.up.sql | 2 + .../000011_crosspost_replacements.down.sql | 18 + .../000011_crosspost_replacements.up.sql | 1 + .../sqlite/000012_pairs_thread_id.down.sql | 1 + .../sqlite/000012_pairs_thread_id.up.sql | 1 + .../000013_crosspost_sync_edits.down.sql | 1 + .../sqlite/000013_crosspost_sync_edits.up.sql | 1 + postgres.go | 341 +++++ queue.go | 176 +++ replacements.go | 234 ++++ repository.go | 92 ++ sqlite.go | 347 +++++ telegram.go | 1197 +++++++++++++++++ tgsender.go | 198 +++ tgsender_impl.go | 600 +++++++++ tgsender_impl_test.go | 590 ++++++++ upload.go | 423 ++++++ 89 files changed, 9123 insertions(+) create mode 100644 .env.example create mode 100644 .github/workflows/RouterOS.yml create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 admin.go create mode 100644 admin_test.go create mode 100644 bridge.go create mode 100644 docker-compose.yml create mode 100644 docs/pikabu-post.md create mode 100644 docs/superpowers/plans/2026-03-17-max-antispam.md create mode 100644 docs/superpowers/specs/2026-03-17-max-antispam-design.md create mode 100644 format.go create mode 100644 format_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 main_test.go create mode 100644 markup.go create mode 100644 markup_test.go create mode 100644 max.go create mode 100644 mediagroup.go create mode 100644 migrate.go create mode 100644 migrations/postgres/000001_init.down.sql create mode 100644 migrations/postgres/000001_init.up.sql create mode 100644 migrations/postgres/000002_add_prefix_and_created_at.down.sql create mode 100644 migrations/postgres/000002_add_prefix_and_created_at.up.sql create mode 100644 migrations/postgres/000003_pending_created_at.down.sql create mode 100644 migrations/postgres/000003_pending_created_at.up.sql create mode 100644 migrations/postgres/000004_add_crossposts.down.sql create mode 100644 migrations/postgres/000004_add_crossposts.up.sql create mode 100644 migrations/postgres/000005_add_send_queue.down.sql create mode 100644 migrations/postgres/000005_add_send_queue.up.sql create mode 100644 migrations/postgres/000006_crosspost_owner.down.sql create mode 100644 migrations/postgres/000006_crosspost_owner.up.sql create mode 100644 migrations/postgres/000007_crosspost_soft_delete.down.sql create mode 100644 migrations/postgres/000007_crosspost_soft_delete.up.sql create mode 100644 migrations/postgres/000008_add_users.down.sql create mode 100644 migrations/postgres/000008_add_users.up.sql create mode 100644 migrations/postgres/000009_crosspost_tg_owner.down.sql create mode 100644 migrations/postgres/000009_crosspost_tg_owner.up.sql create mode 100644 migrations/postgres/000010_queue_att_url.down.sql create mode 100644 migrations/postgres/000010_queue_att_url.up.sql create mode 100644 migrations/postgres/000011_crosspost_replacements.down.sql create mode 100644 migrations/postgres/000011_crosspost_replacements.up.sql create mode 100644 migrations/postgres/000012_pairs_thread_id.down.sql create mode 100644 migrations/postgres/000012_pairs_thread_id.up.sql create mode 100644 migrations/postgres/000013_crosspost_sync_edits.down.sql create mode 100644 migrations/postgres/000013_crosspost_sync_edits.up.sql create mode 100644 migrations/sqlite/000001_init.down.sql create mode 100644 migrations/sqlite/000001_init.up.sql create mode 100644 migrations/sqlite/000002_add_prefix_and_created_at.down.sql create mode 100644 migrations/sqlite/000002_add_prefix_and_created_at.up.sql create mode 100644 migrations/sqlite/000003_pending_created_at.down.sql create mode 100644 migrations/sqlite/000003_pending_created_at.up.sql create mode 100644 migrations/sqlite/000004_add_crossposts.down.sql create mode 100644 migrations/sqlite/000004_add_crossposts.up.sql create mode 100644 migrations/sqlite/000005_add_send_queue.down.sql create mode 100644 migrations/sqlite/000005_add_send_queue.up.sql create mode 100644 migrations/sqlite/000006_crosspost_owner.down.sql create mode 100644 migrations/sqlite/000006_crosspost_owner.up.sql create mode 100644 migrations/sqlite/000007_crosspost_soft_delete.down.sql create mode 100644 migrations/sqlite/000007_crosspost_soft_delete.up.sql create mode 100644 migrations/sqlite/000008_add_users.down.sql create mode 100644 migrations/sqlite/000008_add_users.up.sql create mode 100644 migrations/sqlite/000009_crosspost_tg_owner.down.sql create mode 100644 migrations/sqlite/000009_crosspost_tg_owner.up.sql create mode 100644 migrations/sqlite/000010_queue_att_url.down.sql create mode 100644 migrations/sqlite/000010_queue_att_url.up.sql create mode 100644 migrations/sqlite/000011_crosspost_replacements.down.sql create mode 100644 migrations/sqlite/000011_crosspost_replacements.up.sql create mode 100644 migrations/sqlite/000012_pairs_thread_id.down.sql create mode 100644 migrations/sqlite/000012_pairs_thread_id.up.sql create mode 100644 migrations/sqlite/000013_crosspost_sync_edits.down.sql create mode 100644 migrations/sqlite/000013_crosspost_sync_edits.up.sql create mode 100644 postgres.go create mode 100644 queue.go create mode 100644 replacements.go create mode 100644 repository.go create mode 100644 sqlite.go create mode 100644 telegram.go create mode 100644 tgsender.go create mode 100644 tgsender_impl.go create mode 100644 tgsender_impl_test.go create mode 100644 upload.go 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 +}