diff --git a/Dockerfile b/Dockerfile index 2d9a259..f8247ce 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,6 +23,10 @@ ENV TELEGRAM_CHAT_ID="" ENV DISCORD_CHANNEL_ID="" ENV VK_PEER_ID="" ENV VK_GROUP_ID="" +ENV MAX_TOKEN="" +ENV MAX_CHAT_ID="" +ENV MAX_USE_WEBHOOK="" +ENV MAX_WEBHOOK_SECRET="" ENV SITE_API_URL="" ENV SITE_API_TOKEN="" ENV WEBHOOK_API_KEY="" diff --git a/README.md b/README.md index 34add15..09c0700 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # Go Chat Bridge -Мультичат-бот для объединения чатов в **Telegram**, **Discord**, **VK** и на **вашем сайте**. +Мультичат-бот для объединения чатов в **Telegram**, **Discord**, **VK**, **MAX** и на **вашем сайте**. ## Возможности -- Пересылка сообщений между Telegram, Discord, VK и сайтом -- Отображение имени отправителя в пересылаемых сообщениях (`[TG]`, `[DS]`, `[VK]`, `[SITE]`) +- Пересылка сообщений между Telegram, Discord, VK, MAX и сайтом +- Отображение имени отправителя в пересылаемых сообщениях (`[TG]`, `[DS]`, `[VK]`, `[MAX]`, `[SITE]`) - **Медиа**: фото, документы, видео, аудио, голосовые, GIF - В **Discord** и **Telegram** медиа отправляются как вложения (файлы) - В **VK** и на **сайт** — ссылками на прокси (без токена бота в URL) @@ -29,6 +29,7 @@ go mod tidy - **Telegram**: токен у [@BotFather](https://t.me/BotFather), ID чата/канала - **Discord**: токен приложения, ID канала - **VK**: токен группы с правами `messages`, `groups` (см. раздел ниже), `VK_PEER_ID` беседы, `VK_GROUP_ID` +- **MAX**: токен в [платформе MAX](https://dev.max.ru/docs-api) (Чат-боты → Интеграция), ID чата `MAX_CHAT_ID` ### 3. Переменные окружения @@ -46,6 +47,12 @@ DISCORD_CHANNEL_ID=123456789012345678 VK_PEER_ID=2000000002 VK_GROUP_ID=15469861 +# MAX (опционально) +MAX_TOKEN=... +MAX_CHAT_ID=123456789 +# MAX_USE_WEBHOOK=true # для production: webhook вместо long polling +# MAX_WEBHOOK_SECRET=... # секрет для проверки заголовка X-Max-Bot-Api-Secret + # Сайт: отправка сообщений на сайт и приём с сайта SITE_API_URL=https://your-site.com/telebot/chat_api.php SITE_API_TOKEN=... @@ -83,6 +90,7 @@ docker-compose up -d --build | Путь | Описание | |-----------|----------| | `POST /webhook` | Приём сообщений с сайта. Параметр `key` (или поле `key` в JSON) = `WEBHOOK_API_KEY`; в теле — `text`, `sender`. | +| `POST /max/webhook` | Приём событий от MAX (режим webhook). Проверка заголовка `X-Max-Bot-Api-Secret` при заданном `MAX_WEBHOOK_SECRET`. | | `GET /media/<токен>/<имя_файла>.<расширение>` | Прокси медиа из Telegram. Токен подписан, расширение в URL для корректного отображения и сохранения. | ## Формат сообщений в чатах @@ -90,6 +98,7 @@ docker-compose up -d --build - `[TG] Имя: текст` — из Telegram - `[DS] Имя: текст` — из Discord - `[VK] Имя: текст` — из VK +- `[MAX] Имя: текст` — из MAX - `[SITE] Имя: текст` — с сайта При наличии медиа к сообщению добавляются ссылки (в VK и на сайте — на прокси с расширением в конце, например `.../media/xxx/file_332.jpg`). @@ -103,6 +112,14 @@ docker-compose up -d --build ``` Скопируйте `access_token` из адресной строки после редиректа. +## Настройка MAX + +1. Создайте бота на [платформе MAX](https://dev.max.ru/docs-api) и получите токен. +2. Добавьте бота в групповой чат (бот должен быть администратором для получения событий из групп). +3. Задайте `MAX_TOKEN` и `MAX_CHAT_ID` (ID чата, куда пересылать и откуда принимать сообщения). +4. **Long polling** (по умолчанию) — для разработки и тестирования; запускается автоматически при наличии `MAX_TOKEN`. +5. **Webhook** (production) — задайте `MAX_USE_WEBHOOK=true`, `PUBLIC_URL=https://...` (HTTPS на порту 443) и опционально `MAX_WEBHOOK_SECRET`. При старте бот зарегистрирует подписку `POST /subscriptions` на `PUBLIC_URL/max/webhook`. + ## Логи При старте выводятся: diff --git a/bridge.go b/bridge.go index d462c1c..1e9e04c 100644 --- a/bridge.go +++ b/bridge.go @@ -59,9 +59,10 @@ type Bridge struct { Telegram *tgbotapi.BotAPI Discord *discordgo.Session VK *api.VK + MAXToken string } -func NewBridge(telegramToken, discordToken, vkToken string) (*Bridge, error) { +func NewBridge(telegramToken, discordToken, vkToken, maxToken string) (*Bridge, error) { // Telegram tgBot, err := tgbotapi.NewBotAPI(telegramToken) if err != nil { @@ -81,6 +82,7 @@ func NewBridge(telegramToken, discordToken, vkToken string) (*Bridge, error) { Telegram: tgBot, Discord: dg, VK: vk, + MAXToken: maxToken, }, nil } diff --git a/docker-compose.yml b/docker-compose.yml index 745240f..befcf59 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,10 @@ services: DISCORD_CHANNEL_ID: "${DISCORD_CHANNEL_ID}" VK_PEER_ID: "${VK_PEER_ID}" VK_GROUP_ID: "${VK_GROUP_ID}" + MAX_TOKEN: "${MAX_TOKEN}" + MAX_CHAT_ID: "${MAX_CHAT_ID}" + MAX_USE_WEBHOOK: "${MAX_USE_WEBHOOK:-false}" + MAX_WEBHOOK_SECRET: "${MAX_WEBHOOK_SECRET}" SITE_API_URL: "${SITE_API_URL}" SITE_API_TOKEN: "${SITE_API_TOKEN}" WEBHOOK_API_KEY: "${WEBHOOK_API_KEY}" diff --git a/main.go b/main.go index abde5b4..f8f0104 100644 --- a/main.go +++ b/main.go @@ -35,12 +35,13 @@ func main() { 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) + bridge, err := NewBridge(telegramToken, discordToken, vkToken, maxToken) if err != nil { log.Fatalf("Ошибка инициализации Bridge: %v", err) } @@ -51,6 +52,7 @@ func main() { 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, если есть @@ -85,6 +87,11 @@ func main() { _ = bridge.SendToVK(id, "[TG] "+sender+": "+msg) } } + if maxChatID != "" { + if id, err := strconv.ParseInt(maxChatID, 10, 64); err == nil { + _ = bridge.SendToMAX(id, "[TG] "+sender+": "+msg) + } + } _ = bridge.SendToSite("[TG] " + sender + ": " + msg) case "discord": // Для Telegram отправляем файлы, если есть, иначе текст @@ -109,6 +116,11 @@ func main() { _ = bridge.SendToVK(id, "[DS] "+sender+": "+msg) } } + if maxChatID != "" { + if id, err := strconv.ParseInt(maxChatID, 10, 64); err == nil { + _ = bridge.SendToMAX(id, "[DS] "+sender+": "+msg) + } + } _ = bridge.SendToSite("[DS] " + sender + ": " + msg) case "vk": // Если когда-либо будут передаваться ссылки на медиа из VK, для TG/Discord можем тоже отправлять файлы @@ -141,6 +153,11 @@ func main() { } } // Для сайта оставляем ссылки + if maxChatID != "" { + if id, err := strconv.ParseInt(maxChatID, 10, 64); err == nil { + _ = bridge.SendToMAX(id, "[VK] "+sender+": "+msg) + } + } _ = bridge.SendToSite("[VK] " + sender + ": " + msg) case "site": // Из сайта пока не принимаем ссылки на файлы, отправляем как текст @@ -157,12 +174,55 @@ func main() { _ = bridge.SendToVK(id, "[SITE] "+sender+": "+msg) } } + if maxChatID != "" { + if id, err := strconv.ParseInt(maxChatID, 10, 64); err == nil { + _ = bridge.SendToMAX(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) { @@ -279,6 +339,6 @@ func main() { } else { log.Println("Медиа-прокси выключен (задайте PUBLIC_URL и MEDIA_PROXY_SECRET для включения)") } - log.Println("Webhook listening on :8080 (/webhook, /media/)") + log.Println("Webhook listening on :8080 (/webhook, /max/webhook, /media/)") log.Fatal(http.ListenAndServe(":8080", nil)) } diff --git a/max_client.go b/max_client.go new file mode 100644 index 0000000..160ba60 --- /dev/null +++ b/max_client.go @@ -0,0 +1,181 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" +) + +const maxAPIBase = "https://platform-api.max.ru" + +type maxUpdatesResponse struct { + Updates []maxUpdate `json:"updates"` + Marker *int64 `json:"marker"` +} + +type maxUpdate struct { + UpdateType string `json:"update_type"` + Timestamp int64 `json:"timestamp"` + Message *maxMessage `json:"message"` +} + +type maxMessage struct { + Sender *maxUser `json:"sender"` + Recipient *maxRecipient `json:"recipient"` + Body *maxMessageBody `json:"body"` +} + +type maxUser struct { + UserID int64 `json:"user_id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Username string `json:"username"` + IsBot bool `json:"is_bot"` +} + +type maxRecipient struct { + ChatID int64 `json:"chat_id"` + ChatType string `json:"chat_type"` + UserID int64 `json:"user_id"` +} + +type maxMessageBody struct { + Text string `json:"text"` + Attachments []maxAttachment `json:"attachments"` +} + +type maxAttachment struct { + Type string `json:"type"` + Payload json.RawMessage `json:"payload"` +} + +type maxAttachmentURL struct { + URL string `json:"url"` +} + +func (b *Bridge) maxRequest(method, path string, query url.Values, body any) ([]byte, error) { + if b.MAXToken == "" { + return nil, fmt.Errorf("MAX_TOKEN не задан") + } + reqURL := maxAPIBase + path + if len(query) > 0 { + reqURL += "?" + query.Encode() + } + var bodyReader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(data) + } + req, err := http.NewRequest(method, reqURL, bodyReader) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", b.MAXToken) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("MAX API %s %s: %d %s", method, path, resp.StatusCode, string(respBody)) + } + return respBody, nil +} + +func (b *Bridge) SendToMAX(chatID int64, text string) error { + query := url.Values{"chat_id": {strconv.FormatInt(chatID, 10)}} + _, err := b.maxRequest(http.MethodPost, "/messages", query, map[string]string{"text": text}) + return err +} + +func (b *Bridge) GetMAXUpdates(marker *int64, timeout int) (*maxUpdatesResponse, error) { + query := url.Values{ + "limit": {"100"}, + "types": {"message_created"}, + } + if timeout > 0 { + query.Set("timeout", strconv.Itoa(timeout)) + } + if marker != nil { + query.Set("marker", strconv.FormatInt(*marker, 10)) + } + data, err := b.maxRequest(http.MethodGet, "/updates", query, nil) + if err != nil { + return nil, err + } + var resp maxUpdatesResponse + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func (b *Bridge) SubscribeMAXWebhook(webhookURL, secret string) error { + body := map[string]any{ + "url": webhookURL, + "update_types": []string{"message_created"}, + } + if secret != "" { + body["secret"] = secret + } + _, err := b.maxRequest(http.MethodPost, "/subscriptions", nil, body) + return err +} + +func maxUserDisplayName(u *maxUser) string { + if u == nil { + return "unknown" + } + name := u.FirstName + if u.LastName != "" { + if name != "" { + name += " " + } + name += u.LastName + } + if name == "" && u.Username != "" { + name = u.Username + } + if name == "" { + return strconv.FormatInt(u.UserID, 10) + } + return name +} + +func maxMessageChatID(msg *maxMessage) int64 { + if msg == nil || msg.Recipient == nil { + return 0 + } + return msg.Recipient.ChatID +} + +func maxExtractMediaURLs(msg *maxMessage) []string { + if msg == nil || msg.Body == nil { + return nil + } + var urls []string + for _, att := range msg.Body.Attachments { + switch att.Type { + case "image", "video", "audio", "file", "sticker": + var payload maxAttachmentURL + if err := json.Unmarshal(att.Payload, &payload); err == nil && payload.URL != "" { + urls = append(urls, payload.URL) + } + } + } + return urls +} diff --git a/max_listener.go b/max_listener.go new file mode 100644 index 0000000..6ff1a5f --- /dev/null +++ b/max_listener.go @@ -0,0 +1,114 @@ +package main + +import ( + "encoding/json" + "log" + "net/http" + "os" + "strconv" + "strings" +) + +func (b *Bridge) StartMAXListener(forward func(text string, sender string, media []string)) { + if b.MAXToken == "" { + log.Println("MAX_TOKEN не задан, MAX listener не запущен") + return + } + if strings.EqualFold(os.Getenv("MAX_USE_WEBHOOK"), "true") { + log.Println("MAX: режим webhook (long polling отключён)") + return + } + go b.runMAXLongPoll(forward) +} + +func (b *Bridge) runMAXLongPoll(forward func(text string, sender string, media []string)) { + log.Println("[MAX] Long polling запущен") + var marker *int64 + filterChatID := os.Getenv("MAX_CHAT_ID") + for { + resp, err := b.GetMAXUpdates(marker, 30) + if err != nil { + log.Printf("[MAX] ошибка получения updates: %v", err) + continue + } + marker = resp.Marker + for _, upd := range resp.Updates { + if upd.UpdateType != "message_created" || upd.Message == nil { + continue + } + b.handleMAXMessage(upd.Message, filterChatID, forward) + } + } +} + +func (b *Bridge) handleMAXMessage(msg *maxMessage, filterChatID string, forward func(text string, sender string, media []string)) { + if msg.Sender != nil && msg.Sender.IsBot { + return + } + chatID := maxMessageChatID(msg) + if filterChatID != "" { + want, err := strconv.ParseInt(filterChatID, 10, 64) + if err == nil && chatID != want { + return + } + } + text := "" + if msg.Body != nil { + text = msg.Body.Text + } + media := maxExtractMediaURLs(msg) + sender := maxUserDisplayName(msg.Sender) + log.Printf("[MAX] %s: %s", sender, text) + forward(text, sender, media) +} + +func (b *Bridge) RegisterMAXWebhookHandler(forward func(text string, sender string, media []string)) { + secret := os.Getenv("MAX_WEBHOOK_SECRET") + filterChatID := os.Getenv("MAX_CHAT_ID") + + http.HandleFunc("/max/webhook", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + if secret != "" && r.Header.Get("X-Max-Bot-Api-Secret") != secret { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + var upd maxUpdate + if err := json.NewDecoder(r.Body).Decode(&upd); err != nil { + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + if upd.UpdateType == "message_created" && upd.Message != nil { + b.handleMAXMessage(upd.Message, filterChatID, forward) + } + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + }) +} + +func (b *Bridge) SetupMAXWebhook() { + if b.MAXToken == "" { + return + } + if !strings.EqualFold(os.Getenv("MAX_USE_WEBHOOK"), "true") { + return + } + publicURL := strings.TrimSuffix(os.Getenv("PUBLIC_URL"), "/") + if publicURL == "" { + log.Println("MAX: MAX_USE_WEBHOOK=true, но PUBLIC_URL не задан — webhook не зарегистрирован") + return + } + if !strings.HasPrefix(publicURL, "https://") { + log.Println("MAX: для webhook нужен HTTPS в PUBLIC_URL (требование MAX API)") + return + } + webhookURL := publicURL + "/max/webhook" + secret := os.Getenv("MAX_WEBHOOK_SECRET") + if err := b.SubscribeMAXWebhook(webhookURL, secret); err != nil { + log.Printf("[MAX] ошибка регистрации webhook: %v", err) + return + } + log.Printf("[MAX] webhook зарегистрирован: %s", webhookURL) +}