mirror of
https://github.com/BEARlogin/max-telegram-bridge-bot.git
synced 2026-04-28 03:39:46 +00:00
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:
commit
28f671d41c
28 changed files with 2048 additions and 0 deletions
4
.env.example
Normal file
4
.env.example
Normal 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
26
.github/workflows/build.yml
vendored
Normal 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
59
.github/workflows/release.yml
vendored
Normal 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
14
.gitignore
vendored
Normal 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
18
Dockerfile
Normal 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
97
README.md
Normal 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
58
bridge.go
Normal 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
64
format.go
Normal 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
198
format_test.go
Normal 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
20
go.mod
Normal 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
89
go.sum
Normal 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
104
main.go
Normal 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
54
main_test.go
Normal 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
238
max.go
Normal 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
124
migrate.go
Normal 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
|
||||
}
|
||||
3
migrations/postgres/000001_init.down.sql
Normal file
3
migrations/postgres/000001_init.down.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
DROP TABLE IF EXISTS messages;
|
||||
DROP TABLE IF EXISTS pairs;
|
||||
DROP TABLE IF EXISTS pending;
|
||||
24
migrations/postgres/000001_init.up.sql
Normal file
24
migrations/postgres/000001_init.up.sql
Normal 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);
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE pairs DROP COLUMN prefix;
|
||||
ALTER TABLE messages DROP COLUMN created_at;
|
||||
|
|
@ -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;
|
||||
3
migrations/sqlite/000001_init.down.sql
Normal file
3
migrations/sqlite/000001_init.down.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
DROP TABLE IF EXISTS messages;
|
||||
DROP TABLE IF EXISTS pairs;
|
||||
DROP TABLE IF EXISTS pending;
|
||||
24
migrations/sqlite/000001_init.up.sql
Normal file
24
migrations/sqlite/000001_init.up.sql
Normal 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);
|
||||
24
migrations/sqlite/000002_add_prefix_and_created_at.down.sql
Normal file
24
migrations/sqlite/000002_add_prefix_and_created_at.down.sql
Normal 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);
|
||||
|
|
@ -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
160
postgres.go
Normal 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
24
repository.go
Normal 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
151
sqlite.go
Normal 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
276
telegram.go
Normal 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
186
upload.go
Normal 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")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue