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 <noreply@anthropic.com>
This commit is contained in:
Andrey Lugovskoy 2026-02-15 03:23:31 +03:00
commit 28f671d41c
28 changed files with 2048 additions and 0 deletions

4
.env.example Normal file
View file

@ -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

26
.github/workflows/build.yml vendored Normal file
View file

@ -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 .

59
.github/workflows/release.yml vendored Normal file
View file

@ -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

14
.gitignore vendored Normal file
View file

@ -0,0 +1,14 @@
# Binaries
bridge
bridge_linux
max-telegram-bridge-bot
*.exe
# Database
*.db
*.db-shm
*.db-wal
# Environment
.env
deploy.sh

18
Dockerfile Normal file
View file

@ -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"]

97
README.md Normal file
View file

@ -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` |

58
bridge.go Normal file
View file

@ -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()
}

64
format.go Normal file
View file

@ -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)
}

198
format_test.go Normal file
View file

@ -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)
}
})
}
}

20
go.mod Normal file
View file

@ -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
)

89
go.sum Normal file
View file

@ -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=

104
main.go Normal file
View file

@ -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 остановлен")
}

54
main_test.go Normal file
View file

@ -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)
}
}
}

238
max.go Normal file
View file

@ -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 <key>
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)
}
}
}
}

124
migrate.go Normal file
View file

@ -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
}

View file

@ -0,0 +1,3 @@
DROP TABLE IF EXISTS messages;
DROP TABLE IF EXISTS pairs;
DROP TABLE IF EXISTS pending;

View file

@ -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);

View file

@ -0,0 +1,2 @@
ALTER TABLE pairs DROP COLUMN prefix;
ALTER TABLE messages DROP COLUMN created_at;

View file

@ -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;

View file

@ -0,0 +1,3 @@
DROP TABLE IF EXISTS messages;
DROP TABLE IF EXISTS pairs;
DROP TABLE IF EXISTS pending;

View file

@ -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);

View file

@ -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);

View file

@ -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;

160
postgres.go Normal file
View file

@ -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()
}

24
repository.go Normal file
View file

@ -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
}

151
sqlite.go Normal file
View file

@ -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()
}

276
telegram.go Normal file
View file

@ -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 <key>
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)
}
}
}
}
}

186
upload.go Normal file
View file

@ -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")
}