initial commit
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 .
|
||||||
@@ -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
@@ -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
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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`
|
||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 <world>" {
|
||||||
|
t.Errorf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMaxMarkupsToHTML_Bold(t *testing.T) {
|
||||||
|
got := maxMarkupsToHTML("hello world", []maxschemes.MarkUp{
|
||||||
|
{Type: maxschemes.MarkupStrong, From: 0, Length: 5},
|
||||||
|
})
|
||||||
|
want := "<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 := "<b>not bold</b>"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMaxMarkupsToHTML_Multiple(t *testing.T) {
|
||||||
|
got := maxMarkupsToHTML("hello world test", []maxschemes.MarkUp{
|
||||||
|
{Type: maxschemes.MarkupStrong, From: 0, Length: 5},
|
||||||
|
{Type: maxschemes.MarkupEmphasized, From: 6, Length: 5},
|
||||||
|
})
|
||||||
|
want := "<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&b=2">link</a>`
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
+225
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
DROP TABLE IF EXISTS messages;
|
||||||
|
DROP TABLE IF EXISTS pairs;
|
||||||
|
DROP TABLE IF EXISTS pending;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
DROP TABLE IF EXISTS messages;
|
||||||
|
DROP TABLE IF EXISTS pairs;
|
||||||
|
DROP TABLE IF EXISTS pending;
|
||||||
@@ -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
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+198
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user