commit 28f671d41cb85b791725020c91acb7deec428f4e Author: Andrey Lugovskoy Date: Sun Feb 15 03:23:31 2026 +0300 Initial commit: TG↔MAX bridge Bi-directional message bridge between Telegram and MAX messengers. Features: 1:1 chat pairing, reply support, message formatting, prefix toggle, SQLite/PostgreSQL with golang-migrate, Dockerfile, CI/CD with GitHub Actions. Co-Authored-By: Claude Opus 4.6 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..be27a5d --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +TG_TOKEN=your_telegram_bot_token +MAX_TOKEN=your_max_bot_token +DB_PATH=bridge.db +# DATABASE_URL=postgres://user:password@localhost:5432/bridge?sslmode=disable 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..70b34ff --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,59 @@ +name: Release + +on: + release: + types: [created] + +permissions: + contents: write + packages: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - 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: Upload binaries + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload ${{ github.event.release.tag_name }} 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.event.release.tag_name }} + ghcr.io/${{ steps.repo.outputs.name }}:latest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f80db7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Binaries +bridge +bridge_linux +max-telegram-bridge-bot +*.exe + +# Database +*.db +*.db-shm +*.db-wal + +# Environment +.env +deploy.sh diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4b9c98e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +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 + +COPY --from=builder /max-telegram-bridge-bot /usr/local/bin/max-telegram-bridge-bot + +ENTRYPOINT ["max-telegram-bridge-bot"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..08c8746 --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# MaxTelegramBridgeBot + +Мост между Telegram и [MAX](https://max.ru) мессенджером. Пересылает сообщения, медиа, файлы и редактирования между связанными чатами. + +## Возможности + +- Пересылка текстовых сообщений в обе стороны +- Пересылка медиа: фото, видео, документы, голосовые, аудио +- Поддержка ответов (reply) — сохраняется контекст +- Отслеживание редактирования сообщений +- Настраиваемый префикс `[TG]` / `[MAX]` +- SQLite или PostgreSQL для хранения связок и маппинга сообщений + +## Установка + +### Из бинаря + +Скачайте бинарь со [страницы релизов](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 +``` + +Для сохранения БД между перезапусками: + +```bash +docker run -e TG_TOKEN=your_token -e MAX_TOKEN=your_token \ + -v ./data:/data -e DB_PATH=/data/bridge.db \ + ghcr.io/bearlogin/max-telegram-bridge-bot:latest +``` + +### Из исходников + +```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**: через настройки платформы + +### 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 <ключ>` в другом чате + +## Команды + +| Команда | Описание | +|---------|----------| +| `/start`, `/help` | Инструкция | +| `/bridge` | Создать ключ для связки | +| `/bridge <ключ>` | Связать чат по ключу | +| `/bridge prefix on/off` | Включить/выключить префикс `[TG]`/`[MAX]` | +| `/unbridge` | Удалить связку | + +## Переменные окружения + +| Переменная | Описание | По умолчанию | +|------------|----------|--------------| +| `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` | diff --git a/bridge.go b/bridge.go new file mode 100644 index 0000000..214e656 --- /dev/null +++ b/bridge.go @@ -0,0 +1,58 @@ +package main + +import ( + "context" + "sync" + "time" + + maxbot "github.com/max-messenger/max-bot-api-client-go" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +// Config — настройки bridge, читаемые из env. +type Config struct { + MaxToken string // токен MAX API (нужен для direct-send/upload) + TgBotURL string // ссылка на TG-бота для /help + MaxBotURL string // ссылка на MAX-бота для /help +} + +// Bridge — основная структура, объединяющая зависимости. +type Bridge struct { + cfg Config + repo Repository + tgBot *tgbotapi.BotAPI + maxApi *maxbot.Api +} + +// NewBridge создаёт экземпляр Bridge. +func NewBridge(cfg Config, repo Repository, tgBot *tgbotapi.BotAPI, maxApi *maxbot.Api) *Bridge { + return &Bridge{ + cfg: cfg, + repo: repo, + tgBot: tgBot, + maxApi: maxApi, + } +} + +// Run запускает TG и MAX listener'ы + периодическую очистку. +func (b *Bridge) Run(ctx context.Context) { + go func() { + t := time.NewTicker(10 * time.Minute) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + b.repo.CleanOldMessages() + } + } + }() + + 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/format.go b/format.go new file mode 100644 index 0000000..5f00e1a --- /dev/null +++ b/format.go @@ -0,0 +1,64 @@ +package main + +import ( + "fmt" + + maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +func tgName(msg *tgbotapi.Message) string { + name := msg.From.FirstName + if msg.From.LastName != "" { + name += " " + msg.From.LastName + } + return name +} + +// formatTgCaption — для пересылки (текст или caption) +func formatTgCaption(msg *tgbotapi.Message, prefix bool) string { + name := tgName(msg) + text := msg.Text + if text == "" { + text = msg.Caption + } + if prefix { + return fmt.Sprintf("[TG] %s: %s", name, text) + } + return fmt.Sprintf("%s: %s", name, text) +} + +// formatTgMessage — для edit (полный формат) +func formatTgMessage(msg *tgbotapi.Message, prefix bool) string { + name := tgName(msg) + text := msg.Text + if text == "" { + text = msg.Caption + } + if text == "" { + return "" + } + if prefix { + return fmt.Sprintf("[TG] %s: %s", name, text) + } + return fmt.Sprintf("%s: %s", name, text) +} + +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 bool) string { + name := maxName(upd) + text := upd.Message.Body.Text + if prefix { + return fmt.Sprintf("[MAX] %s: %s", name, text) + } + return fmt.Sprintf("%s: %s", name, text) +} diff --git a/format_test.go b/format_test.go new file mode 100644 index 0000000..b771be9 --- /dev/null +++ b/format_test.go @@ -0,0 +1,198 @@ +package main + +import ( + "testing" + + maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes" + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +func TestTgName(t *testing.T) { + tests := []struct { + name string + msg *tgbotapi.Message + expected string + }{ + { + name: "first name only", + msg: &tgbotapi.Message{ + From: &tgbotapi.User{FirstName: "Ivan"}, + }, + expected: "Ivan", + }, + { + name: "first and last name", + msg: &tgbotapi.Message{ + From: &tgbotapi.User{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 := &tgbotapi.Message{ + Text: "hello world", + From: &tgbotapi.User{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) + if got != tt.expected { + t.Errorf("formatTgCaption() = %q, want %q", got, tt.expected) + } + }) + } +} + +func TestFormatTgCaption_UsesCaption(t *testing.T) { + msg := &tgbotapi.Message{ + Text: "", + Caption: "photo caption", + From: &tgbotapi.User{FirstName: "Bob"}, + } + + got := formatTgCaption(msg, 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 *tgbotapi.Message + prefix bool + expected string + }{ + { + name: "text with prefix", + msg: &tgbotapi.Message{ + Text: "edited text", + From: &tgbotapi.User{FirstName: "Ivan"}, + }, + prefix: true, + expected: "[TG] Ivan: edited text", + }, + { + name: "text without prefix", + msg: &tgbotapi.Message{ + Text: "edited text", + From: &tgbotapi.User{FirstName: "Ivan"}, + }, + prefix: false, + expected: "Ivan: edited text", + }, + { + name: "empty text returns empty", + msg: &tgbotapi.Message{ + Text: "", + From: &tgbotapi.User{FirstName: "Ivan"}, + }, + prefix: true, + expected: "", + }, + { + name: "caption fallback", + msg: &tgbotapi.Message{ + Text: "", + Caption: "cap", + From: &tgbotapi.User{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) + 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) + if got != tt.expected { + t.Errorf("formatMaxCaption() = %q, want %q", got, tt.expected) + } + }) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3ab61cb --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module bearlogin-bridge + +go 1.24.0 + +require ( + github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 + 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..cd3a63b --- /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-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= +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..eedb320 --- /dev/null +++ b/main.go @@ -0,0 +1,104 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "log/slog" + "os" + "os/signal" + "syscall" + + maxbot "github.com/max-messenger/max-bot-api-client-go" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +func mustEnv(key string) string { + v := os.Getenv(key) + if v == "" { + fmt.Fprintf(os.Stderr, "Переменная окружения %s не задана\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, 4) + rand.Read(b) + return hex.EncodeToString(b) +} + +func main() { + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil))) + + 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"), + } + + tgToken := mustEnv("TG_TOKEN") + 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("БД: PostgreSQL") + } else { + repo, err = NewSQLiteRepo(dbPath) + if err != nil { + slog.Error("SQLite error", "err", err) + os.Exit(1) + } + slog.Info("БД: SQLite", "path", dbPath) + } + defer repo.Close() + + tgBot, err := tgbotapi.NewBotAPI(tgToken) + if err != nil { + slog.Error("TG bot error", "err", err) + os.Exit(1) + } + slog.Info("Telegram бот запущен", "username", tgBot.Self.UserName) + + 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 бот запущен", "name", maxInfo.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("Завершение...") + cancel() + }() + + bridge := NewBridge(cfg, repo, tgBot, maxApi) + bridge.Run(ctx) + slog.Info("Bridge остановлен") +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..7501be1 --- /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) != 8 { + t.Errorf("genKey() length = %d, want 8", 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/max.go b/max.go new file mode 100644 index 0000000..35c8db2 --- /dev/null +++ b/max.go @@ -0,0 +1,238 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "strings" + + maxbot "github.com/max-messenger/max-bot-api-client-go" + maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +func (b *Bridge) listenMax(ctx context.Context) { + updates := b.maxApi.GetUpdates(ctx) + + for { + select { + case <-ctx.Done(): + return + case upd, ok := <-updates: + if !ok { + return + } + + slog.Debug("MAX update", "type", fmt.Sprintf("%T", upd)) + + // Обработка 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 + } + 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 text == "" || strings.HasPrefix(text, "[TG]") || strings.HasPrefix(text, "[MAX]") { + continue + } + var fwd string + if prefix { + fwd = fmt.Sprintf("[MAX] %s: %s", name, text) + } else { + fwd = fmt.Sprintf("%s: %s", name, text) + } + editMsg := tgbotapi.NewEditMessageText(tgChatID, tgMsgID, fwd) + if _, err := b.tgBot.Send(editMsg); err != nil { + slog.Error("MAX→TG edit failed", "err", err) + } else { + slog.Info("MAX→TG edited", "tgMsg", tgMsgID) + } + continue + } + + msgUpd, isMsg := upd.(*maxschemes.MessageCreatedUpdate) + if !isMsg { + continue + } + + body := msgUpd.Message.Body + chatID := msgUpd.Message.Recipient.ChatId + text := strings.TrimSpace(body.Text) + + slog.Debug("MAX msg received", "from", msgUpd.Message.Sender.Name, "chat", chatID, "text", text) + + 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" + + "1. Добавьте бота в оба чата\n" + + " TG: " + b.cfg.TgBotURL + "\n" + + " MAX: " + b.cfg.MaxBotURL + "\n" + + "2. В одном из чатов отправьте /bridge\n" + + "3. Бот выдаст ключ — отправьте /bridge <ключ> в другом чате\n" + + "4. Готово! Сообщения пересылаются в обе стороны.") + b.maxApi.Messages.Send(ctx, m) + continue + } + + // /bridge prefix on/off + if text == "/bridge prefix on" || text == "/bridge prefix off" { + 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 ") { + 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", generatedKey, generatedKey)) + 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 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 + } + + // Пересылка + tgChatID, linked := b.repo.GetTgChat(chatID) + if !linked || msgUpd.Message.Sender.IsBot { + continue + } + + // Anti-loop + if strings.HasPrefix(text, "[TG]") || strings.HasPrefix(text, "[MAX]") { + continue + } + + prefix := b.repo.HasPrefix("max", chatID) + caption := formatMaxCaption(msgUpd, prefix) + + // 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 sent tgbotapi.Message + var sendErr error + mediaSent := false + + for _, att := range body.Attachments { + switch a := att.(type) { + case *maxschemes.PhotoAttachment: + if a.Payload.Url != "" { + photo := tgbotapi.NewPhoto(tgChatID, tgbotapi.FileURL(a.Payload.Url)) + photo.Caption = caption + photo.ReplyToMessageID = replyToID + sent, sendErr = b.tgBot.Send(photo) + mediaSent = true + } + case *maxschemes.VideoAttachment: + if a.Payload.Url != "" { + video := tgbotapi.NewVideo(tgChatID, tgbotapi.FileURL(a.Payload.Url)) + video.Caption = caption + video.ReplyToMessageID = replyToID + sent, sendErr = b.tgBot.Send(video) + mediaSent = true + } + case *maxschemes.AudioAttachment: + if a.Payload.Url != "" { + audio := tgbotapi.NewAudio(tgChatID, tgbotapi.FileURL(a.Payload.Url)) + audio.Caption = caption + audio.ReplyToMessageID = replyToID + sent, sendErr = b.tgBot.Send(audio) + mediaSent = true + } + case *maxschemes.FileAttachment: + if a.Payload.Url != "" { + doc := tgbotapi.NewDocument(tgChatID, tgbotapi.FileURL(a.Payload.Url)) + doc.Caption = caption + doc.ReplyToMessageID = replyToID + sent, sendErr = b.tgBot.Send(doc) + mediaSent = true + } + } + if mediaSent { + break + } + } + + // Текст без медиа + if !mediaSent { + if text == "" { + continue + } + tgMsg := tgbotapi.NewMessage(tgChatID, caption) + tgMsg.ReplyToMessageID = replyToID + sent, sendErr = b.tgBot.Send(tgMsg) + } + + if sendErr != nil { + slog.Error("MAX→TG send failed", "err", sendErr) + } else { + slog.Info("MAX→TG sent", "msgID", sent.MessageID, "media", mediaSent) + b.repo.SaveMsg(tgChatID, sent.MessageID, chatID, body.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/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/postgres.go b/postgres.go new file mode 100644 index 0000000..a70e2a6 --- /dev/null +++ b/postgres.go @@ -0,0 +1,160 @@ +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", platform, chatID).Scan(&existing) + if err == nil { + return false, existing, nil + } + generated := genKey() + _, err = r.db.Exec("INSERT INTO pending (key, platform, chat_id) VALUES ($1, $2, $3)", generated, platform, chatID) + return false, generated, err + } + + var peerPlatform string + var peerChatID int64 + err := r.db.QueryRow("SELECT platform, chat_id FROM pending WHERE key = $1", 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) 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) +} + +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) Close() error { + return r.db.Close() +} diff --git a/repository.go b/repository.go new file mode 100644 index 0000000..df66a10 --- /dev/null +++ b/repository.go @@ -0,0 +1,24 @@ +package main + +// 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) + + 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 + + Close() error +} diff --git a/sqlite.go b/sqlite.go new file mode 100644 index 0000000..d686446 --- /dev/null +++ b/sqlite.go @@ -0,0 +1,151 @@ +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 = ?", platform, chatID).Scan(&existing) + if err == nil { + return false, existing, nil + } + generated := genKey() + _, err = r.db.Exec("INSERT INTO pending (key, platform, chat_id) VALUES (?, ?, ?)", generated, platform, chatID) + return false, generated, err + } + + var peerPlatform string + var peerChatID int64 + err := r.db.QueryRow("SELECT platform, chat_id FROM pending WHERE key = ?", 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) 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) +} + +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) Close() error { + return r.db.Close() +} diff --git a/telegram.go b/telegram.go new file mode 100644 index 0000000..38a003b --- /dev/null +++ b/telegram.go @@ -0,0 +1,276 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "strings" + + maxbot "github.com/max-messenger/max-bot-api-client-go" + maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +func (b *Bridge) listenTelegram(ctx context.Context) { + u := tgbotapi.NewUpdate(0) + u.Timeout = 60 + updates := b.tgBot.GetUpdatesChan(u) + + for { + select { + case <-ctx.Done(): + return + case update, ok := <-updates: + if !ok { + slog.Warn("TG updates channel closed") + return + } + + // Обработка edit + if update.EditedMessage != nil { + edited := update.EditedMessage + if edited.From != nil && edited.From.IsBot { + continue + } + 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) + if fwd == "" { + continue + } + maxChatID, linked := b.repo.GetMaxChat(edited.Chat.ID) + if !linked { + 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) + } else { + slog.Info("TG→MAX edited", "mid", maxMsgID) + } + continue + } + + if update.Message == nil { + continue + } + + msg := update.Message + text := strings.TrimSpace(msg.Text) + slog.Debug("TG msg received", "from", msg.From.FirstName, "chat", msg.Chat.ID, "text", text) + + if text == "/start" || text == "/help" { + b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, + "Бот-мост между Telegram и MAX.\n\n"+ + "Команды:\n"+ + "/bridge — создать ключ для связки чатов\n"+ + "/bridge <ключ> — связать этот чат с MAX-чатом по ключу\n"+ + "/bridge prefix on/off — включить/выключить префикс [TG]/[MAX]\n"+ + "/unbridge — удалить связку\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. Готово! Сообщения пересылаются в обе стороны.")) + continue + } + + // /bridge prefix on/off + if text == "/bridge prefix on" || text == "/bridge prefix off" { + on := text == "/bridge prefix on" + if b.repo.SetPrefix("tg", msg.Chat.ID, on) { + if on { + b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, "Префикс [TG]/[MAX] включён.")) + } else { + b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, "Префикс [TG]/[MAX] выключен.")) + } + } else { + b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, "Чат не связан. Сначала выполните /bridge.")) + } + continue + } + + // /bridge или /bridge + if text == "/bridge" || strings.HasPrefix(text, "/bridge ") { + 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.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, "Связано! Сообщения теперь пересылаются.")) + slog.Info("paired", "platform", "tg", "chat", msg.Chat.ID, "key", key) + } else if generatedKey != "" { + b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, + fmt.Sprintf("Ключ для связки: %s\n\nОтправьте в MAX-чате:\n/bridge %s", generatedKey, generatedKey))) + slog.Info("pending", "platform", "tg", "chat", msg.Chat.ID, "key", generatedKey) + } else { + b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, "Ключ не найден или чат той же платформы.")) + } + continue + } + + if text == "/unbridge" { + if b.repo.Unpair("tg", msg.Chat.ID) { + b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, "Связка удалена.")) + } else { + b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, "Этот чат не связан.")) + } + 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) + + // Проверяем anti-loop + checkText := msg.Text + if checkText == "" { + checkText = msg.Caption + } + if strings.HasPrefix(checkText, "[MAX]") || strings.HasPrefix(checkText, "[TG]") { + continue + } + + // Определяем медиа + var mediaToken string + var mediaAttType string // "video", "file", "audio" + + if msg.Photo != nil { + // Фото — через SDK (работает) + photo := msg.Photo[len(msg.Photo)-1] + m := maxbot.NewMessage().SetChat(maxChatID).SetText(caption) + if fileURL, err := b.tgBot.GetFileDirectURL(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) + } + } + 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 photo", "caption", caption) + result, err := b.maxApi.Messages.SendWithResult(ctx, m) + if err != nil { + slog.Error("TG→MAX send failed", "err", err) + } else { + slog.Info("TG→MAX sent", "mid", result.Body.Mid) + b.repo.SaveMsg(msg.Chat.ID, msg.MessageID, maxChatID, result.Body.Mid) + } + continue + } else if msg.Video != nil { + if uploaded, err := b.uploadTgMediaToMax(ctx, msg.Video.FileID, maxschemes.VIDEO, "video.mp4"); err == nil { + mediaToken = uploaded.Token + mediaAttType = "video" + } else { + slog.Error("TG→MAX video upload failed", "err", err) + } + } else if msg.Document != nil { + name := "document" + if msg.Document.FileName != "" { + name = msg.Document.FileName + } + if uploaded, err := b.uploadTgMediaToMax(ctx, msg.Document.FileID, maxschemes.FILE, name); err == nil { + mediaToken = uploaded.Token + mediaAttType = "file" + } else { + slog.Error("TG→MAX file upload failed", "err", err) + } + } else if msg.Voice != nil { + if uploaded, err := b.uploadTgMediaToMax(ctx, msg.Voice.FileID, maxschemes.AUDIO, "voice.ogg"); err == nil { + mediaToken = uploaded.Token + mediaAttType = "audio" + } else { + slog.Error("TG→MAX voice upload failed", "err", err) + } + } else if msg.Audio != nil { + name := "audio.mp3" + if msg.Audio.FileName != "" { + name = msg.Audio.FileName + } + if uploaded, err := b.uploadTgMediaToMax(ctx, msg.Audio.FileID, maxschemes.FILE, name); err == nil { + mediaToken = uploaded.Token + mediaAttType = "file" + } else { + slog.Error("TG→MAX audio upload failed", "err", err) + } + } + + // 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: + continue + } + 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 + } + } + + if mediaAttType != "" { + // Медиа — отправляем напрямую (обход SDK) + slog.Info("TG→MAX sending direct", "caption", caption, "type", mediaAttType) + mid, err := b.sendMaxDirect(ctx, maxChatID, caption, mediaAttType, mediaToken, replyTo) + if err != nil { + slog.Error("TG→MAX send failed", "err", err) + } else { + slog.Info("TG→MAX sent", "mid", mid) + b.repo.SaveMsg(msg.Chat.ID, msg.MessageID, maxChatID, mid) + } + } else { + // Текст — через SDK + m := maxbot.NewMessage().SetChat(maxChatID).SetText(caption) + if replyTo != "" { + m.SetReply(caption, replyTo) + } + slog.Info("TG→MAX sending", "caption", caption) + result, err := b.maxApi.Messages.SendWithResult(ctx, m) + if err != nil { + slog.Error("TG→MAX send failed", "err", err) + } else { + slog.Info("TG→MAX sent", "mid", result.Body.Mid) + b.repo.SaveMsg(msg.Chat.ID, msg.MessageID, maxChatID, result.Body.Mid) + } + } + } + } +} diff --git a/upload.go b/upload.go new file mode 100644 index 0000000..3dad3ba --- /dev/null +++ b/upload.go @@ -0,0 +1,186 @@ +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" +) + +// 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 := http.DefaultClient.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) + } + + var endpoint maxschemes.UploadEndpoint + if err := json.NewDecoder(resp.Body).Decode(&endpoint); err != nil { + return nil, fmt.Errorf("decode upload endpoint: %w", err) + } + slog.Debug("MAX upload endpoint", "url", endpoint.Url, "token", endpoint.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() + + cdnResp, err := http.Post(endpoint.Url, writer.FormDataContentType(), &buf) + 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)) + + // 3. Парсим CDN ответ (fileId в 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 token from CDN", "token", cdnResult.Token, "fileId", cdnResult.FileID) + return &maxschemes.UploadedInfo{Token: cdnResult.Token, FileID: cdnResult.FileID}, nil + } + if endpoint.Token != "" { + slog.Debug("MAX token from endpoint", "token", endpoint.Token) + return &maxschemes.UploadedInfo{Token: endpoint.Token}, nil + } + return nil, fmt.Errorf("no token: endpoint and CDN both empty") +} + +// uploadTgMediaToMax скачивает файл из TG и загружает в MAX +func (b *Bridge) uploadTgMediaToMax(ctx context.Context, fileID string, uploadType maxschemes.UploadType, fileName string) (*maxschemes.UploadedInfo, error) { + fileURL, err := b.tgBot.GetFileDirectURL(fileID) + if err != nil { + return nil, fmt.Errorf("tg getFileURL: %w", err) + } + slog.Debug("TG file URL", "url", fileURL) + + resp, err := http.Get(fileURL) + 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) + } + + slog.Debug("TG file downloaded", "status", resp.StatusCode, "size", resp.ContentLength, "contentType", resp.Header.Get("Content-Type")) + + 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) { + 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"` + Link *struct { + Type string `json:"type"` + Mid string `json:"mid"` + } `json:"link,omitempty"` + } + + body := msgBody{Text: text} + 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) + + // Retry при attachment.not.ready (файл ещё обрабатывается) + for attempt := 0; attempt < 10; attempt++ { + if attempt > 0 { + delay := time.Duration(1+attempt) * time.Second + time.Sleep(delay) + slog.Warn("MAX retry", "attempt", attempt+1, "maxAttempts", 10) + } + + 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 := http.DefaultClient.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") +}