From d300bc9824ef72009ee0af3a9a63ca371313a227 Mon Sep 17 00:00:00 2001 From: Taz Date: Sat, 7 Mar 2026 19:14:58 +0100 Subject: [PATCH] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BF=D0=BE=D1=81=D1=82=D0=B8=D0=BD=D0=B3=20=D1=82?= =?UTF-8?q?=D0=BE=D0=BA=D0=B5=D0=BD=D0=B0=20=D0=B2=20=D1=81=D1=81=D1=8B?= =?UTF-8?q?=D0=BB=D0=BA=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + Dockerfile | 13 ++- README.md | 190 +++++++++++++++++++++++-------------------- bridge.go | 55 +++++++++++++ docker-compose.yml | 2 + go.mod | 1 + go.sum | 2 + main.go | 88 +++++++++++++++++++- telegram_listener.go | 9 +- 9 files changed, 270 insertions(+), 91 deletions(-) diff --git a/.gitignore b/.gitignore index 4c49bd7..0f801d8 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .env +go-chat-bridge diff --git a/Dockerfile b/Dockerfile index e47d677..2d9a259 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,15 @@ # Go Chat Bridge Dockerfile FROM golang:1.22-alpine AS builder WORKDIR /app -COPY . . -RUN go mod tidy && go build -o chat-bridge . +# Сначала только зависимости — этот слой кэшируется, пока не меняются go.mod/go.sum +COPY go.mod go.sum ./ +RUN go mod download +# Код копируем отдельно — любое изменение .go пересобирает бинарник +# Версию можно задать: docker build --build-arg VERSION=1.0.0 +ARG VERSION=dev +COPY *.go ./ +RUN BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) && \ + go build -ldflags "-X main.Version=${VERSION} -X main.BuildDate=$BUILD_DATE" -o chat-bridge . FROM alpine:latest WORKDIR /app @@ -19,5 +26,7 @@ ENV VK_GROUP_ID="" ENV SITE_API_URL="" ENV SITE_API_TOKEN="" ENV WEBHOOK_API_KEY="" +ENV PUBLIC_URL="" +ENV MEDIA_PROXY_SECRET="" CMD ["./chat-bridge"] diff --git a/README.md b/README.md index b14bdfe..34add15 100644 --- a/README.md +++ b/README.md @@ -1,99 +1,117 @@ -## Пример .env - -Создайте файл `.env` в корне проекта и заполните: - -```env -TELEGRAM_TOKEN=your_telegram_token -DISCORD_TOKEN=your_discord_token -VK_TOKEN=your_vk_token -TELEGRAM_CHAT_ID=-1001234567890 -DISCORD_CHANNEL_ID=123456789012345678 -VK_PEER_ID=2000000001 -VK_GROUP_ID=2000000001 -``` - -docker-compose автоматически подхватит эти переменные. - # Go Chat Bridge -Мультичат-бот для объединения Telegram, Discord и VK. +Мультичат-бот для объединения чатов в **Telegram**, **Discord**, **VK** и на **вашем сайте**. ## Возможности -- Пересылка сообщений между Telegram, Discord и VK -- Отображение имени отправителя в пересылаемых сообщениях -- Поддержка работы с группами VK через LongPoll + +- Пересылка сообщений между Telegram, Discord, VK и сайтом +- Отображение имени отправителя в пересылаемых сообщениях (`[TG]`, `[DS]`, `[VK]`, `[SITE]`) +- **Медиа**: фото, документы, видео, аудио, голосовые, GIF + - В **Discord** и **Telegram** медиа отправляются как вложения (файлы) + - В **VK** и на **сайт** — ссылками на прокси (без токена бота в URL) +- **Медиа-прокси**: ссылки из Telegram не содержат токен бота; файлы отдаются через ваш сервер +- **Webhook** для приёма сообщений с сайта (POST `/webhook`) +- Загрузка переменных из файла `.env` при запуске +- Версия и дата сборки в логах при старте ## Быстрый старт -1. Клонируйте репозиторий: - ```bash - git clone - cd turn-bridge - ``` -2. Установите зависимости: - ```bash - go mod tidy - ``` -3. Создайте и настройте ботов/токены для всех платформ: - - Telegram: получите токен у @BotFather - - Discord: создайте приложение и получите токен - - VK: получите токен группы с правами messages, groups (см. инструкцию ниже) -4. Укажите переменные окружения: - ```bash - export TELEGRAM_TOKEN=... # токен Telegram-бота - export DISCORD_TOKEN=... # токен Discord-бота - export VK_TOKEN=... # токен группы VK - export TELEGRAM_CHAT_ID=... # id чата/канала Telegram - export DISCORD_CHANNEL_ID=... # id канала Discord - export VK_PEER_ID=... # peer_id беседы VK - export VK_GROUP_ID=... # id группы VK - ``` -5. Запустите приложение: - ```bash - go run . - ``` +### 1. Клонирование и зависимости -## Получение токена VK с нужными правами - -1. В настройках группы VK включите Long Poll API и разрешите сообщения. -2. Получите токен через интерфейс группы или через OAuth: - - Создайте Standalone-приложение VK - - Перейдите по ссылке: - ``` - https://oauth.vk.com/authorize?client_id=ВАШ_CLIENT_ID&display=page&redirect_uri=https://oauth.vk.com/blank.html&scope=messages,groups&response_type=token&v=5.131 - ``` - - Авторизуйтесь, скопируйте access_token из адресной строки. - -## Формат пересылаемых сообщений - -- `[TG] user: текст` — из Telegram -- `[DS] user: текст` — из Discord -- `[VK] user: текст` — из VK (имя пользователя VK) - -## Пример .env - -Создайте файл `.env` в корне проекта и заполните: - -```env -TELEGRAM_TOKEN=your_telegram_token -DISCORD_TOKEN=your_discord_token -VK_TOKEN=your_vk_token -TELEGRAM_CHAT_ID=-1001234567890 -DISCORD_CHANNEL_ID=123456789012345678 -VK_PEER_ID=2000000001 -VK_GROUP_ID=2000000001 -SITE_API_URL=https://turn-guild.ru/ -SITE_API_TOKEN=website_webhook_token -WEBHOOK_API_KEY=brige_webhook_token +```bash +git clone +cd turn-bridge +go mod tidy ``` -docker-compose автоматически подхватит эти переменные. -## TODO -- Поддержка вложений и файлов -- Админ-команды -- Логирование событий +### 2. Токены и ID + +- **Telegram**: токен у [@BotFather](https://t.me/BotFather), ID чата/канала +- **Discord**: токен приложения, ID канала +- **VK**: токен группы с правами `messages`, `groups` (см. раздел ниже), `VK_PEER_ID` беседы, `VK_GROUP_ID` + +### 3. Переменные окружения + +Создайте `.env` в корне проекта (при запуске бинарника переменные подхватываются автоматически): + +```env +# Обязательные +TELEGRAM_TOKEN=... +DISCORD_TOKEN=... +VK_TOKEN=... + +# ID чатов +TELEGRAM_CHAT_ID=-1001234567890 +DISCORD_CHANNEL_ID=123456789012345678 +VK_PEER_ID=2000000002 +VK_GROUP_ID=15469861 + +# Сайт: отправка сообщений на сайт и приём с сайта +SITE_API_URL=https://your-site.com/telebot/chat_api.php +SITE_API_TOKEN=... +WEBHOOK_API_KEY=... + +# Медиа-прокси (чтобы в ссылках для VK и сайта не светить токен Telegram) +PUBLIC_URL=https://your-bridge-host:8080 +MEDIA_PROXY_SECRET=длинная-секретная-строка +``` + +- **PUBLIC_URL** — базовый URL, по которому доступен этот сервис (порт 8080). По нему отдаются ссылки вида `PUBLIC_URL/media/...`. +- **MEDIA_PROXY_SECRET** — секрет для подписи ссылок на медиа. Без него прокси отключён, в VK и на сайт пойдут прямые ссылки Telegram (с токеном бота). + +### 4. Запуск + +**Локально:** + +```bash +./go-chat-bridge +# или +go run . +``` + +**Docker:** + +```bash +docker-compose up -d --build +``` + +При сборке образа можно задать версию: +`docker build --build-arg VERSION=1.0.0 -t chat-bridge .` + +## Эндпоинты (порт 8080) + +| Путь | Описание | +|-----------|----------| +| `POST /webhook` | Приём сообщений с сайта. Параметр `key` (или поле `key` в JSON) = `WEBHOOK_API_KEY`; в теле — `text`, `sender`. | +| `GET /media/<токен>/<имя_файла>.<расширение>` | Прокси медиа из Telegram. Токен подписан, расширение в URL для корректного отображения и сохранения. | + +## Формат сообщений в чатах + +- `[TG] Имя: текст` — из Telegram +- `[DS] Имя: текст` — из Discord +- `[VK] Имя: текст` — из VK +- `[SITE] Имя: текст` — с сайта + +При наличии медиа к сообщению добавляются ссылки (в VK и на сайте — на прокси с расширением в конце, например `.../media/xxx/file_332.jpg`). + +## Получение токена VK + +1. В настройках группы VK включите Long Poll API и разрешите сообщения. +2. Создайте Standalone-приложение VK, получите токен по ссылке: + ``` + https://oauth.vk.com/authorize?client_id=ВАШ_CLIENT_ID&display=page&redirect_uri=https://oauth.vk.com/blank.html&scope=messages,groups&response_type=token&v=5.131 + ``` + Скопируйте `access_token` из адресной строки после редиректа. + +## Логи + +При старте выводятся: + +- Загрузка `.env` (или сообщение, что файл не найден) +- Версия и дата сборки (при сборке через Docker — дата сборки образа) +- Состояние медиа-прокси (включён / выключен) +- Служебные сообщения `[Telegram]`, `[Media]`, `[Bridge]` при пересылке и отдаче медиа ## Лицензия + MIT - - diff --git a/bridge.go b/bridge.go index b19e905..d462c1c 100644 --- a/bridge.go +++ b/bridge.go @@ -1,6 +1,9 @@ package main import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" "fmt" "io" "mime" @@ -81,6 +84,58 @@ func NewBridge(telegramToken, discordToken, vkToken string) (*Bridge, error) { }, nil } +// signMediaToken создаёт подписанный токен для filePath (HMAC-SHA256), чтобы не светить токен бота в ссылках. +func signMediaToken(filePath, secret string) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(filePath)) + sig := mac.Sum(nil) + // payload.base64(sig) — в URL без padding + enc := base64.URLEncoding.WithPadding(base64.NoPadding) + return enc.EncodeToString([]byte(filePath)) + "." + enc.EncodeToString(sig) +} + +// verifyMediaToken проверяет подпись и возвращает filePath. secret должен совпадать с тем, что при подписи. +func verifyMediaToken(token, secret string) (filePath string, ok bool) { + enc := base64.URLEncoding.WithPadding(base64.NoPadding) + idx := strings.LastIndex(token, ".") + if idx == -1 { + return "", false + } + payloadB, err1 := enc.DecodeString(token[:idx]) + sigB, err2 := enc.DecodeString(token[idx+1:]) + if err1 != nil || err2 != nil || len(sigB) != sha256.Size { + return "", false + } + filePath = string(payloadB) + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(filePath)) + expected := mac.Sum(nil) + if !hmac.Equal(sigB, expected) { + return "", false + } + return filePath, true +} + +// GetTelegramFileProxyURL возвращает публичный URL прокси для файла Telegram (без токена в ссылке). +// В конец URL добавлено имя файла с расширением, чтобы ссылка в чате показывала .jpg и т.д. +func (b *Bridge) GetTelegramFileProxyURL(filePath string) string { + publicURL := strings.TrimSuffix(os.Getenv("PUBLIC_URL"), "/") + secret := os.Getenv("MEDIA_PROXY_SECRET") + if publicURL == "" || secret == "" { + return "" + } + fileName := path.Base(filePath) + if fileName == "" || fileName == "." { + fileName = "file" + } + return publicURL + "/media/" + signMediaToken(filePath, secret) + "/" + url.PathEscape(fileName) +} + +// GetTelegramFileDirectURL возвращает прямую ссылку на файл Telegram (только для внутреннего использования на сервере). +func (b *Bridge) GetTelegramFileDirectURL(filePath string) string { + return fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", b.Telegram.Token, filePath) +} + // Отправка сообщения в Telegram func (b *Bridge) SendToTelegram(chatID int64, text string) error { msg := tgbotapi.NewMessage(chatID, text) diff --git a/docker-compose.yml b/docker-compose.yml index 0632ed5..745240f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,8 @@ services: SITE_API_URL: "${SITE_API_URL}" SITE_API_TOKEN: "${SITE_API_TOKEN}" WEBHOOK_API_KEY: "${WEBHOOK_API_KEY}" + PUBLIC_URL: "${PUBLIC_URL}" + MEDIA_PROXY_SECRET: "${MEDIA_PROXY_SECRET}" restart: unless-stopped ports: - "8080:8080" diff --git a/go.mod b/go.mod index d14beaf..087455d 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( require ( github.com/gorilla/websocket v1.5.3 // indirect + github.com/joho/godotenv v1.5.1 // indirect github.com/klauspost/compress v1.17.9 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect diff --git a/go.sum b/go.sum index 8ec1fb3..10cd689 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,8 @@ github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2 github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/main.go b/main.go index 090ccac..abde5b4 100644 --- a/main.go +++ b/main.go @@ -2,15 +2,35 @@ package main import ( "encoding/json" + "io" "log" "net/http" "os" + "path" + "path/filepath" "strconv" "strings" + + "github.com/joho/godotenv" +) + +// Заполняются при сборке: go build -ldflags "-X main.Version=... -X main.BuildDate=..." +var ( + Version = "dev" + BuildDate = "unknown" ) func main() { + // Загружаем .env из текущей директории или рядом с бинарником + if err := godotenv.Load(); err != nil { + execPath, _ := os.Executable() + envPath := filepath.Join(filepath.Dir(execPath), ".env") + if err2 := godotenv.Load(envPath); err2 != nil { + log.Println("Файл .env не найден, используются переменные окружения shell") + } + } log.Println("Go Chat Bridge запущен.") + log.Printf("Версия: %s, сборка: %s", Version, BuildDate) telegramToken := os.Getenv("TELEGRAM_TOKEN") discordToken := os.Getenv("DISCORD_TOKEN") @@ -144,6 +164,67 @@ func main() { bridge.StartDiscordListener(discordChannelID, func(text string, sender string, media []string) { forward(text, "discord", sender, media) }) bridge.StartVKListener(func(text string, sender string, media []string) { forward(text, "vk", sender, media) }) + // Прокси медиа Telegram: по подписанному токену отдаём файл без раскрытия токена бота в ссылках + http.HandleFunc("/media/", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + log.Printf("[Media] %s %s — Method not allowed", r.Method, r.URL.Path) + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + // Путь: /media/<токен>/<имя_файла.jpg> — имя в конце только для вида, токен до последнего / + pathSuffix := strings.TrimPrefix(r.URL.Path, "/media/") + if pathSuffix == "" { + log.Printf("[Media] запрос без токена") + http.Error(w, "Not found", http.StatusNotFound) + return + } + token := pathSuffix + if idx := strings.LastIndex(pathSuffix, "/"); idx != -1 { + token = pathSuffix[:idx] + } + secret := os.Getenv("MEDIA_PROXY_SECRET") + if secret == "" { + log.Printf("[Media] MEDIA_PROXY_SECRET не задан, прокси отключён") + http.Error(w, "Proxy not configured", http.StatusInternalServerError) + return + } + filePath, ok := verifyMediaToken(token, secret) + if !ok { + pathLog := r.URL.Path + if len(pathLog) > 50 { + pathLog = pathLog[:50] + "..." + } + log.Printf("[Media] неверный или просроченный токен (path=%s)", pathLog) + http.Error(w, "Invalid or expired link", http.StatusForbidden) + return + } + log.Printf("[Media] отдача файла: %s", filePath) + directURL := bridge.GetTelegramFileDirectURL(filePath) + resp, err := http.Get(directURL) + if err != nil { + log.Printf("[Media] ошибка загрузки из Telegram для %s: %v", filePath, err) + http.Error(w, "Failed to fetch file", http.StatusBadGateway) + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + log.Printf("[Media] Telegram вернул %d для %s", resp.StatusCode, filePath) + http.Error(w, "Upstream error", resp.StatusCode) + return + } + if ct := resp.Header.Get("Content-Type"); ct != "" { + w.Header().Set("Content-Type", ct) + } + // Имя файла с расширением — иначе при открытии/сохранении расширение теряется + fileName := path.Base(filePath) + if fileName != "" && fileName != "." { + w.Header().Set("Content-Disposition", "inline; filename=\""+fileName+"\"") + } + w.Header().Set("Cache-Control", "private, max-age=86400") + io.Copy(w, resp.Body) + log.Printf("[Media] отдано: %s", filePath) + }) + // webhook endpoint http.HandleFunc("/webhook", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -193,6 +274,11 @@ func main() { w.Write([]byte("ok")) }) - log.Println("Webhook listening on :8080 (/webhook)") + if os.Getenv("PUBLIC_URL") != "" && os.Getenv("MEDIA_PROXY_SECRET") != "" { + log.Println("Медиа-прокси включён: ссылки из Telegram будут без токена в URL") + } else { + log.Println("Медиа-прокси выключен (задайте PUBLIC_URL и MEDIA_PROXY_SECRET для включения)") + } + log.Println("Webhook listening on :8080 (/webhook, /media/)") log.Fatal(http.ListenAndServe(":8080", nil)) } diff --git a/telegram_listener.go b/telegram_listener.go index fc9b38b..40b3043 100644 --- a/telegram_listener.go +++ b/telegram_listener.go @@ -1,7 +1,6 @@ package main import ( - "fmt" "log" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" @@ -29,9 +28,15 @@ func (b *Bridge) StartTelegramListener(forward func(text string, sender string, getFileURL := func(fileID string) (string, error) { file, err := b.Telegram.GetFile(tgbotapi.FileConfig{FileID: fileID}) if err != nil { + log.Printf("[Telegram] GetFile error: %v", err) return "", err } - return fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", b.Telegram.Token, file.FilePath), nil + if proxyURL := b.GetTelegramFileProxyURL(file.FilePath); proxyURL != "" { + log.Printf("[Telegram] медиа: прокси-ссылка для %s", file.FilePath) + return proxyURL, nil + } + log.Printf("[Telegram] медиа: прямая ссылка (PUBLIC_URL/MEDIA_PROXY_SECRET не заданы?) для %s", file.FilePath) + return b.GetTelegramFileDirectURL(file.FilePath), nil } // Фото (берём самое большое)