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)) }