initial commit

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