Public Access
345 lines
12 KiB
Go
345 lines
12 KiB
Go
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")
|
|
vkToken := os.Getenv("VK_TOKEN")
|
|
maxToken := os.Getenv("MAX_TOKEN")
|
|
|
|
if telegramToken == "" || discordToken == "" || vkToken == "" {
|
|
log.Fatal("Необходимо задать переменные окружения TELEGRAM_TOKEN, DISCORD_TOKEN, VK_TOKEN")
|
|
}
|
|
|
|
bridge, err := NewBridge(telegramToken, discordToken, vkToken, maxToken)
|
|
if err != nil {
|
|
log.Fatalf("Ошибка инициализации Bridge: %v", err)
|
|
}
|
|
|
|
log.Println("Bridge успешно инициализирован:", bridge)
|
|
|
|
// Получаем ID чатов/каналов из переменных окружения
|
|
telegramChatID := os.Getenv("TELEGRAM_CHAT_ID") // int64
|
|
discordChannelID := os.Getenv("DISCORD_CHANNEL_ID") // string
|
|
vkPeerID := os.Getenv("VK_PEER_ID") // int
|
|
maxChatID := os.Getenv("MAX_CHAT_ID") // int64
|
|
|
|
forward := func(text string, from string, sender string, media []string) {
|
|
// Собираем итоговый текст с медиа URL, если есть
|
|
msg := text
|
|
if len(media) > 0 {
|
|
if msg != "" {
|
|
msg += "\n"
|
|
}
|
|
msg += strings.Join(media, "\n")
|
|
}
|
|
log.Printf("[Bridge] Пересылка сообщения от %s (%s): %s", from, sender, msg)
|
|
|
|
switch from {
|
|
case "telegram":
|
|
// Для Discord отправляем файлы, если есть, иначе текст
|
|
if discordChannelID != "" {
|
|
if len(media) > 0 {
|
|
files, derr := bridge.DownloadFiles(media)
|
|
if derr == nil && len(files) > 0 {
|
|
_ = bridge.SendFilesToDiscord(discordChannelID, files, "[TG] "+sender+": "+text)
|
|
bridge.CleanupFiles(files)
|
|
} else {
|
|
_ = bridge.SendToDiscord(discordChannelID, "[TG] "+sender+": "+msg)
|
|
}
|
|
} else {
|
|
_ = bridge.SendToDiscord(discordChannelID, "[TG] "+sender+": "+msg)
|
|
}
|
|
}
|
|
// Для VK и сайта оставляем ссылки
|
|
if vkPeerID != "" {
|
|
if id, err := strconv.Atoi(vkPeerID); err == nil {
|
|
_ = bridge.SendToVK(id, "[TG] "+sender+": "+msg)
|
|
}
|
|
}
|
|
if maxChatID != "" {
|
|
if id, err := strconv.ParseInt(maxChatID, 10, 64); err == nil {
|
|
_ = bridge.SendToMAXSmart(id, "[TG] "+sender+": "+msg)
|
|
}
|
|
}
|
|
_ = bridge.SendToSite("[TG] " + sender + ": " + msg)
|
|
case "discord":
|
|
// Для Telegram отправляем файлы, если есть, иначе текст
|
|
if telegramChatID != "" {
|
|
if id, err := strconv.ParseInt(telegramChatID, 10, 64); err == nil {
|
|
if len(media) > 0 {
|
|
files, derr := bridge.DownloadFiles(media)
|
|
if derr == nil && len(files) > 0 {
|
|
_ = bridge.SendFilesToTelegram(id, files, "[DS] "+sender+": "+text)
|
|
bridge.CleanupFiles(files)
|
|
} else {
|
|
_ = bridge.SendToTelegram(id, "[DS] "+sender+": "+msg)
|
|
}
|
|
} else {
|
|
_ = bridge.SendToTelegram(id, "[DS] "+sender+": "+msg)
|
|
}
|
|
}
|
|
}
|
|
// Для VK и сайта оставляем ссылки
|
|
if vkPeerID != "" {
|
|
if id, err := strconv.Atoi(vkPeerID); err == nil {
|
|
_ = bridge.SendToVK(id, "[DS] "+sender+": "+msg)
|
|
}
|
|
}
|
|
if maxChatID != "" {
|
|
if id, err := strconv.ParseInt(maxChatID, 10, 64); err == nil {
|
|
_ = bridge.SendToMAXSmart(id, "[DS] "+sender+": "+msg)
|
|
}
|
|
}
|
|
_ = bridge.SendToSite("[DS] " + sender + ": " + msg)
|
|
case "vk":
|
|
// Если когда-либо будут передаваться ссылки на медиа из VK, для TG/Discord можем тоже отправлять файлы
|
|
if telegramChatID != "" {
|
|
if id, err := strconv.ParseInt(telegramChatID, 10, 64); err == nil {
|
|
if len(media) > 0 {
|
|
files, derr := bridge.DownloadFiles(media)
|
|
if derr == nil && len(files) > 0 {
|
|
_ = bridge.SendFilesToTelegram(id, files, "[VK] "+sender+": "+text)
|
|
bridge.CleanupFiles(files)
|
|
} else {
|
|
_ = bridge.SendToTelegram(id, "[VK] "+sender+": "+msg)
|
|
}
|
|
} else {
|
|
_ = bridge.SendToTelegram(id, "[VK] "+sender+": "+msg)
|
|
}
|
|
}
|
|
}
|
|
if discordChannelID != "" {
|
|
if len(media) > 0 {
|
|
files, derr := bridge.DownloadFiles(media)
|
|
if derr == nil && len(files) > 0 {
|
|
_ = bridge.SendFilesToDiscord(discordChannelID, files, "[VK] "+sender+": "+text)
|
|
bridge.CleanupFiles(files)
|
|
} else {
|
|
_ = bridge.SendToDiscord(discordChannelID, "[VK] "+sender+": "+msg)
|
|
}
|
|
} else {
|
|
_ = bridge.SendToDiscord(discordChannelID, "[VK] "+sender+": "+msg)
|
|
}
|
|
}
|
|
// Для сайта оставляем ссылки
|
|
if maxChatID != "" {
|
|
if id, err := strconv.ParseInt(maxChatID, 10, 64); err == nil {
|
|
_ = bridge.SendToMAXSmart(id, "[VK] "+sender+": "+msg)
|
|
}
|
|
}
|
|
_ = bridge.SendToSite("[VK] " + sender + ": " + msg)
|
|
case "site":
|
|
// Из сайта пока не принимаем ссылки на файлы, отправляем как текст
|
|
if telegramChatID != "" {
|
|
if id, err := strconv.ParseInt(telegramChatID, 10, 64); err == nil {
|
|
_ = bridge.SendToTelegram(id, "[SITE] "+sender+": "+msg)
|
|
}
|
|
}
|
|
if discordChannelID != "" {
|
|
_ = bridge.SendToDiscord(discordChannelID, "[SITE] "+sender+": "+msg)
|
|
}
|
|
if vkPeerID != "" {
|
|
if id, err := strconv.Atoi(vkPeerID); err == nil {
|
|
_ = bridge.SendToVK(id, "[SITE] "+sender+": "+msg)
|
|
}
|
|
}
|
|
if maxChatID != "" {
|
|
if id, err := strconv.ParseInt(maxChatID, 10, 64); err == nil {
|
|
_ = bridge.SendToMAXSmart(id, "[SITE] "+sender+": "+msg)
|
|
}
|
|
}
|
|
case "max":
|
|
if telegramChatID != "" {
|
|
if id, err := strconv.ParseInt(telegramChatID, 10, 64); err == nil {
|
|
if len(media) > 0 {
|
|
files, derr := bridge.DownloadFiles(media)
|
|
if derr == nil && len(files) > 0 {
|
|
_ = bridge.SendFilesToTelegram(id, files, "[MAX] "+sender+": "+text)
|
|
bridge.CleanupFiles(files)
|
|
} else {
|
|
_ = bridge.SendToTelegram(id, "[MAX] "+sender+": "+msg)
|
|
}
|
|
} else {
|
|
_ = bridge.SendToTelegram(id, "[MAX] "+sender+": "+msg)
|
|
}
|
|
}
|
|
}
|
|
if discordChannelID != "" {
|
|
if len(media) > 0 {
|
|
files, derr := bridge.DownloadFiles(media)
|
|
if derr == nil && len(files) > 0 {
|
|
_ = bridge.SendFilesToDiscord(discordChannelID, files, "[MAX] "+sender+": "+text)
|
|
bridge.CleanupFiles(files)
|
|
} else {
|
|
_ = bridge.SendToDiscord(discordChannelID, "[MAX] "+sender+": "+msg)
|
|
}
|
|
} else {
|
|
_ = bridge.SendToDiscord(discordChannelID, "[MAX] "+sender+": "+msg)
|
|
}
|
|
}
|
|
if vkPeerID != "" {
|
|
if id, err := strconv.Atoi(vkPeerID); err == nil {
|
|
_ = bridge.SendToVK(id, "[MAX] "+sender+": "+msg)
|
|
}
|
|
}
|
|
_ = bridge.SendToSite("[MAX] " + sender + ": " + msg)
|
|
}
|
|
}
|
|
|
|
bridge.StartTelegramListener(func(text string, sender string, media []string) { forward(text, "telegram", 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.StartMAXListener(func(text string, sender string, media []string) { forward(text, "max", sender, media) })
|
|
bridge.RegisterMAXWebhookHandler(func(text string, sender string, media []string) { forward(text, "max", sender, media) })
|
|
bridge.SetupMAXWebhook()
|
|
|
|
// Прокси медиа 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 {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
apiKey := os.Getenv("WEBHOOK_API_KEY")
|
|
if apiKey == "" {
|
|
http.Error(w, "Webhook API key not set", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
key := r.URL.Query().Get("key")
|
|
if key == "" {
|
|
// также можно принимать ключ в теле запроса
|
|
var req struct {
|
|
Key string `json:"key"`
|
|
Text string `json:"text"`
|
|
Sender string `json:"sender"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.Key != apiKey {
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
forward(req.Text, "site", req.Sender, nil)
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("ok"))
|
|
return
|
|
}
|
|
if key != apiKey {
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
var req struct {
|
|
Text string `json:"text"`
|
|
Sender string `json:"sender"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
forward(req.Text, "site", req.Sender, nil)
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("ok"))
|
|
})
|
|
|
|
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, /max/webhook, /media/)")
|
|
log.Fatal(http.ListenAndServe(":8080", nil))
|
|
}
|