package main import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "fmt" "io" "mime" "net/http" "net/url" "os" "path" "strconv" "strings" "github.com/SevereCloud/vksdk/v2/api" "github.com/bwmarrin/discordgo" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" ) // Отправка сообщения на сайт func (b *Bridge) SendToSite(text string) error { apiURL := os.Getenv("SITE_API_URL") token := os.Getenv("SITE_API_TOKEN") if apiURL == "" || token == "" { return fmt.Errorf("SITE_API_URL или SITE_API_TOKEN не заданы") } fullURL := fmt.Sprintf("%s?token=%s&text=%s", apiURL, token, url.QueryEscape(text)) req, err := http.NewRequest("GET", fullURL, nil) if err != nil { return err } req.Header.Set("Accept", "application/json") client := &http.Client{} resp, err := client.Do(req) if err != nil { return err } defer resp.Body.Close() _, err = io.ReadAll(resp.Body) return err } // Получить имя пользователя VK по id func (b *Bridge) GetVKUserName(userID int) (string, error) { resp, err := b.VK.UsersGet(api.Params{"user_ids": userID}) if err != nil || len(resp) == 0 { return strconv.Itoa(userID), err } return resp[0].FirstName + " " + resp[0].LastName, nil } // Структура для хранения клиентов type Bridge struct { Telegram *tgbotapi.BotAPI Discord *discordgo.Session VK *api.VK } func NewBridge(telegramToken, discordToken, vkToken string) (*Bridge, error) { // Telegram tgBot, err := tgbotapi.NewBotAPI(telegramToken) if err != nil { return nil, err } // Discord dg, err := discordgo.New("Bot " + discordToken) if err != nil { return nil, err } // VK vk := api.NewVK(vkToken) return &Bridge{ Telegram: tgBot, Discord: dg, VK: vk, }, 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) _, err := b.Telegram.Send(msg) return err } // Отправка сообщения в Discord func (b *Bridge) SendToDiscord(channelID, text string) error { _, err := b.Discord.ChannelMessageSend(channelID, text) return err } // Отправка сообщения в VK func (b *Bridge) SendToVK(peerID int, text string) error { _, err := b.VK.MessagesSend(api.Params{ "peer_id": peerID, "message": text, "random_id": 0, }) return err } // Загруженный файл на диск для пересылки как вложение type DownloadedFile struct { FileName string TempPath string Mime string } // DownloadFiles скачивает файлы по URL и сохраняет во временные файлы func (b *Bridge) DownloadFiles(urls []string) ([]DownloadedFile, error) { results := make([]DownloadedFile, 0, len(urls)) for _, u := range urls { resp, err := http.Get(u) if err != nil { return results, err } if resp.Body != nil { defer resp.Body.Close() } parsed, err := url.Parse(u) if err != nil { return results, err } name := path.Base(parsed.Path) if name == "" || name == "/" || name == "." { name = "file" } ext := path.Ext(name) pattern := "bridge_*" + ext tmpFile, err := os.CreateTemp("", pattern) if err != nil { return results, err } _, copyErr := io.Copy(tmpFile, resp.Body) closeErr := tmpFile.Close() if copyErr != nil { _ = os.Remove(tmpFile.Name()) return results, copyErr } if closeErr != nil { _ = os.Remove(tmpFile.Name()) return results, closeErr } // Try to determine mime: header -> by extension -> sniff file contentType := resp.Header.Get("Content-Type") if contentType == "" || contentType == "application/octet-stream" { if m := mime.TypeByExtension(ext); m != "" { contentType = m } } if contentType == "" || contentType == "application/octet-stream" { if f2, err := os.Open(tmpFile.Name()); err == nil { buf := make([]byte, 512) n, _ := f2.Read(buf) _ = f2.Close() if n > 0 { contentType = http.DetectContentType(buf[:n]) } } } results = append(results, DownloadedFile{FileName: name, TempPath: tmpFile.Name(), Mime: contentType}) } return results, nil } // CleanupFiles удаляет временные файлы func (b *Bridge) CleanupFiles(files []DownloadedFile) { for _, f := range files { _ = os.Remove(f.TempPath) } } // SendFilesToDiscord отправляет один или несколько файлов в Discord канал func (b *Bridge) SendFilesToDiscord(channelID string, files []DownloadedFile, content string) error { dgFiles := make([]*discordgo.File, 0, len(files)) openFiles := make([]*os.File, 0, len(files)) for _, f := range files { fh, err := os.Open(f.TempPath) if err != nil { for _, of := range openFiles { _ = of.Close() } return err } openFiles = append(openFiles, fh) dgFiles = append(dgFiles, &discordgo.File{Name: f.FileName, Reader: fh}) } _, err := b.Discord.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{Content: content, Files: dgFiles}) for _, of := range openFiles { _ = of.Close() } return err } // SendFilesToTelegram отправляет файлы как документы, caption добавляется только к первому func (b *Bridge) SendFilesToTelegram(chatID int64, files []DownloadedFile, caption string) error { for i, f := range files { fh, err := os.Open(f.TempPath) if err != nil { return err } var sendErr error if strings.HasPrefix(f.Mime, "image/") { media := tgbotapi.NewPhoto(chatID, tgbotapi.FileReader{Name: f.FileName, Reader: fh}) if i == 0 && caption != "" { media.Caption = caption } _, sendErr = b.Telegram.Send(media) } else if strings.HasPrefix(f.Mime, "video/") { media := tgbotapi.NewVideo(chatID, tgbotapi.FileReader{Name: f.FileName, Reader: fh}) if i == 0 && caption != "" { media.Caption = caption } _, sendErr = b.Telegram.Send(media) } else { media := tgbotapi.NewDocument(chatID, tgbotapi.FileReader{Name: f.FileName, Reader: fh}) if i == 0 && caption != "" { media.Caption = caption } _, sendErr = b.Telegram.Send(media) } closeErr := fh.Close() if sendErr != nil { return sendErr } if closeErr != nil { return closeErr } } return nil }