Исправил постинг токена в ссылках

This commit is contained in:
Taz
2026-03-07 19:14:58 +01:00
parent 34e6a4562c
commit d300bc9824
9 changed files with 270 additions and 91 deletions
+1
View File
@@ -1 +1,2 @@
.env .env
go-chat-bridge
+11 -2
View File
@@ -1,8 +1,15 @@
# Go Chat Bridge Dockerfile # Go Chat Bridge Dockerfile
FROM golang:1.22-alpine AS builder FROM golang:1.22-alpine AS builder
WORKDIR /app WORKDIR /app
COPY . . # Сначала только зависимости — этот слой кэшируется, пока не меняются go.mod/go.sum
RUN go mod tidy && go build -o chat-bridge . 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 FROM alpine:latest
WORKDIR /app WORKDIR /app
@@ -19,5 +26,7 @@ ENV VK_GROUP_ID=""
ENV SITE_API_URL="" ENV SITE_API_URL=""
ENV SITE_API_TOKEN="" ENV SITE_API_TOKEN=""
ENV WEBHOOK_API_KEY="" ENV WEBHOOK_API_KEY=""
ENV PUBLIC_URL=""
ENV MEDIA_PROXY_SECRET=""
CMD ["./chat-bridge"] CMD ["./chat-bridge"]
+89 -71
View File
@@ -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 # Go Chat Bridge
Мультичат-бот для объединения Telegram, Discord и VK. Мультичат-бот для объединения чатов в **Telegram**, **Discord**, **VK** и на **вашем сайте**.
## Возможности ## Возможности
- Пересылка сообщений между Telegram, Discord и VK
- Отображение имени отправителя в пересылаемых сообщениях - Пересылка сообщений между Telegram, Discord, VK и сайтом
- Поддержка работы с группами VK через LongPoll - Отображение имени отправителя в пересылаемых сообщениях (`[TG]`, `[DS]`, `[VK]`, `[SITE]`)
- **Медиа**: фото, документы, видео, аудио, голосовые, GIF
- В **Discord** и **Telegram** медиа отправляются как вложения (файлы)
- В **VK** и на **сайт** — ссылками на прокси (без токена бота в URL)
- **Медиа-прокси**: ссылки из Telegram не содержат токен бота; файлы отдаются через ваш сервер
- **Webhook** для приёма сообщений с сайта (POST `/webhook`)
- Загрузка переменных из файла `.env` при запуске
- Версия и дата сборки в логах при старте
## Быстрый старт ## Быстрый старт
1. Клонируйте репозиторий: ### 1. Клонирование и зависимости
```bash ```bash
git clone <repo_url> git clone <repo_url>
cd turn-bridge cd turn-bridge
```
2. Установите зависимости:
```bash
go mod tidy go mod tidy
``` ```
3. Создайте и настройте ботов/токены для всех платформ:
- Telegram: получите токен у @BotFather ### 2. Токены и ID
- Discord: создайте приложение и получите токен
- VK: получите токен группы с правами messages, groups (см. инструкцию ниже) - **Telegram**: токен у [@BotFather](https://t.me/BotFather), ID чата/канала
4. Укажите переменные окружения: - **Discord**: токен приложения, ID канала
```bash - **VK**: токен группы с правами `messages`, `groups` (см. раздел ниже), `VK_PEER_ID` беседы, `VK_GROUP_ID`
export TELEGRAM_TOKEN=... # токен Telegram-бота
export DISCORD_TOKEN=... # токен Discord-бота ### 3. Переменные окружения
export VK_TOKEN=... # токен группы VK
export TELEGRAM_CHAT_ID=... # id чата/канала Telegram Создайте `.env` в корне проекта (при запуске бинарника переменные подхватываются автоматически):
export DISCORD_CHANNEL_ID=... # id канала Discord
export VK_PEER_ID=... # peer_id беседы VK ```env
export VK_GROUP_ID=... # id группы VK # Обязательные
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=длинная-секретная-строка
``` ```
5. Запустите приложение:
- **PUBLIC_URL** — базовый URL, по которому доступен этот сервис (порт 8080). По нему отдаются ссылки вида `PUBLIC_URL/media/...`.
- **MEDIA_PROXY_SECRET** — секрет для подписи ссылок на медиа. Без него прокси отключён, в VK и на сайт пойдут прямые ссылки Telegram (с токеном бота).
### 4. Запуск
**Локально:**
```bash ```bash
./go-chat-bridge
# или
go run . go run .
``` ```
## Получение токена VK с нужными правами **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 и разрешите сообщения. 1. В настройках группы VK включите Long Poll API и разрешите сообщения.
2. Получите токен через интерфейс группы или через OAuth: 2. Создайте Standalone-приложение VK, получите токен по ссылке:
- Создайте 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 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 из адресной строки. Скопируйте `access_token` из адресной строки после редиректа.
## Формат пересылаемых сообщений ## Логи
- `[TG] user: текст` — из Telegram При старте выводятся:
- `[DS] user: текст` — из Discord
- `[VK] user: текст` — из VK (имя пользователя VK)
## Пример .env - Загрузка `.env` (или сообщение, что файл не найден)
- Версия и дата сборки (при сборке через Docker — дата сборки образа)
Создайте файл `.env` в корне проекта и заполните: - Состояние медиа-прокси (включён / выключен)
- Служебные сообщения `[Telegram]`, `[Media]`, `[Bridge]` при пересылке и отдаче медиа
```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
```
docker-compose автоматически подхватит эти переменные.
## TODO
- Поддержка вложений и файлов
- Админ-команды
- Логирование событий
## Лицензия ## Лицензия
MIT MIT
+55
View File
@@ -1,6 +1,9 @@
package main package main
import ( import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt" "fmt"
"io" "io"
"mime" "mime"
@@ -81,6 +84,58 @@ func NewBridge(telegramToken, discordToken, vkToken string) (*Bridge, error) {
}, nil }, 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 // Отправка сообщения в Telegram
func (b *Bridge) SendToTelegram(chatID int64, text string) error { func (b *Bridge) SendToTelegram(chatID int64, text string) error {
msg := tgbotapi.NewMessage(chatID, text) msg := tgbotapi.NewMessage(chatID, text)
+2
View File
@@ -14,6 +14,8 @@ services:
SITE_API_URL: "${SITE_API_URL}" SITE_API_URL: "${SITE_API_URL}"
SITE_API_TOKEN: "${SITE_API_TOKEN}" SITE_API_TOKEN: "${SITE_API_TOKEN}"
WEBHOOK_API_KEY: "${WEBHOOK_API_KEY}" WEBHOOK_API_KEY: "${WEBHOOK_API_KEY}"
PUBLIC_URL: "${PUBLIC_URL}"
MEDIA_PROXY_SECRET: "${MEDIA_PROXY_SECRET}"
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8080:8080" - "8080:8080"
+1
View File
@@ -10,6 +10,7 @@ require (
require ( require (
github.com/gorilla/websocket v1.5.3 // indirect 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/klauspost/compress v1.17.9 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
+2
View File
@@ -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.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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 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 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+87 -1
View File
@@ -2,15 +2,35 @@ package main
import ( import (
"encoding/json" "encoding/json"
"io"
"log" "log"
"net/http" "net/http"
"os" "os"
"path"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"github.com/joho/godotenv"
)
// Заполняются при сборке: go build -ldflags "-X main.Version=... -X main.BuildDate=..."
var (
Version = "dev"
BuildDate = "unknown"
) )
func main() { 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.Println("Go Chat Bridge запущен.")
log.Printf("Версия: %s, сборка: %s", Version, BuildDate)
telegramToken := os.Getenv("TELEGRAM_TOKEN") telegramToken := os.Getenv("TELEGRAM_TOKEN")
discordToken := os.Getenv("DISCORD_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.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) }) 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 // webhook endpoint
http.HandleFunc("/webhook", func(w http.ResponseWriter, r *http.Request) { http.HandleFunc("/webhook", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
@@ -193,6 +274,11 @@ func main() {
w.Write([]byte("ok")) 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)) log.Fatal(http.ListenAndServe(":8080", nil))
} }
+7 -2
View File
@@ -1,7 +1,6 @@
package main package main
import ( import (
"fmt"
"log" "log"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" 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) { getFileURL := func(fileID string) (string, error) {
file, err := b.Telegram.GetFile(tgbotapi.FileConfig{FileID: fileID}) file, err := b.Telegram.GetFile(tgbotapi.FileConfig{FileID: fileID})
if err != nil { if err != nil {
log.Printf("[Telegram] GetFile error: %v", err)
return "", 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
} }
// Фото (берём самое большое) // Фото (берём самое большое)