diff --git a/Dockerfile b/Dockerfile index d8c44aa..5a84d96 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,5 +13,7 @@ ENV TELEGRAM_TOKEN="" ENV DISCORD_TOKEN="" ENV TELEGRAM_CHAT_ID="" ENV DISCORD_CHANNEL_ID="" +ENV DISCORD_REACTION_CHANNEL_ID="" +ENV TELEGRAM_DUPLICATE_REACTION="✍️" CMD ["./chat-bridge"] diff --git a/README.md b/README.md index 5f92757..6a8e508 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ export DISCORD_TOKEN=... # токен Discord-бота export TELEGRAM_CHAT_ID=... # id чата/канала Telegram export DISCORD_CHANNEL_ID=... # id канала Discord + export DISCORD_REACTION_CHANNEL_ID=... # доп. канал Discord для дубля по реакции + export TELEGRAM_DUPLICATE_REACTION=✍️ # какая реакция триггерит дубль ``` 5. Запустите приложение: ```bash @@ -47,9 +49,15 @@ TELEGRAM_TOKEN=your_telegram_token DISCORD_TOKEN=your_discord_token TELEGRAM_CHAT_ID=-1001234567890 DISCORD_CHANNEL_ID=123456789012345678 +DISCORD_REACTION_CHANNEL_ID=123456789012345679 +TELEGRAM_DUPLICATE_REACTION=✍️ ``` docker-compose автоматически подхватит эти переменные. + +## Дублирование по реакции Telegram + +Если у Telegram-сообщения добавлена реакция, указанная в `TELEGRAM_DUPLICATE_REACTION` (по умолчанию `✍️`), то это сообщение дополнительно отправляется в `DISCORD_REACTION_CHANNEL_ID` помимо основного `DISCORD_CHANNEL_ID`. ## TODO - Поддержка вложений и файлов - Админ-команды diff --git a/docker-compose.yml b/docker-compose.yml index 850a440..d173032 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,4 +8,6 @@ services: DISCORD_TOKEN: "${DISCORD_TOKEN}" TELEGRAM_CHAT_ID: "${TELEGRAM_CHAT_ID}" DISCORD_CHANNEL_ID: "${DISCORD_CHANNEL_ID}" + DISCORD_REACTION_CHANNEL_ID: "${DISCORD_REACTION_CHANNEL_ID}" + TELEGRAM_DUPLICATE_REACTION: "${TELEGRAM_DUPLICATE_REACTION}" restart: unless-stopped diff --git a/go-chat-bridge b/go-chat-bridge new file mode 100755 index 0000000..6a69ce0 Binary files /dev/null and b/go-chat-bridge differ diff --git a/main.go b/main.go index 6e3dacb..3262bb6 100644 --- a/main.go +++ b/main.go @@ -5,8 +5,15 @@ import ( "os" "strconv" "strings" + "sync" ) +type telegramMessageCacheItem struct { + Text string + Sender string + Media []string +} + func main() { log.Println("Go Chat Bridge запущен.") @@ -25,10 +32,38 @@ func main() { log.Println("Bridge успешно инициализирован:", bridge) // Получаем ID чатов/каналов из переменных окружения - telegramChatID := os.Getenv("TELEGRAM_CHAT_ID") // int64 - discordChannelID := os.Getenv("DISCORD_CHANNEL_ID") // string + telegramChatID := strings.TrimSpace(os.Getenv("TELEGRAM_CHAT_ID")) // int64 + discordChannelID := strings.TrimSpace(os.Getenv("DISCORD_CHANNEL_ID")) // string + discordReactionChannelID := strings.TrimSpace(os.Getenv("DISCORD_REACTION_CHANNEL_ID")) // string + telegramDuplicateReactionEmoji := strings.TrimSpace(os.Getenv("TELEGRAM_DUPLICATE_REACTION")) + if telegramDuplicateReactionEmoji == "" { + telegramDuplicateReactionEmoji = "✍️" + } + normalizedDuplicateReaction := normalizeEmoji(telegramDuplicateReactionEmoji) - forward := func(text string, from string, sender string, media []string) { + messageCache := make(map[string]telegramMessageCacheItem) + var cacheMu sync.RWMutex + + cacheKey := func(chatID int64, messageID int) string { + return strconv.FormatInt(chatID, 10) + ":" + strconv.Itoa(messageID) + } + + sendTelegramMessageToDiscord := func(channelID, sender, text, msg string, media []string) error { + if channelID == "" { + return nil + } + if len(media) > 0 { + files, derr := bridge.DownloadFiles(media) + if derr == nil && len(files) > 0 { + err := bridge.SendFilesToDiscord(channelID, files, "[TG] "+sender+": "+text) + bridge.CleanupFiles(files) + return err + } + } + return bridge.SendToDiscord(channelID, "[TG] "+sender+": "+msg) + } + + forward := func(text string, from string, sender string, media []string, messageID int, sourceChatID int64) { // Собираем итоговый текст с медиа URL, если есть msg := text if len(media) > 0 { @@ -42,19 +77,16 @@ func main() { 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) - } + if err := sendTelegramMessageToDiscord(discordChannelID, sender, text, msg, media); err != nil { + log.Printf("[Bridge] failed to send telegram message to main Discord channel: %v", err) } + cacheMu.Lock() + messageCache[cacheKey(sourceChatID, messageID)] = telegramMessageCacheItem{ + Text: text, + Sender: sender, + Media: append([]string(nil), media...), + } + cacheMu.Unlock() case "discord": // Для Telegram отправляем файлы, если есть, иначе текст if telegramChatID != "" { @@ -75,9 +107,66 @@ func main() { } } - 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.StartTelegramListener( + func(text string, sender string, media []string, messageID int, chatID int64) { + forward(text, "telegram", sender, media, messageID, chatID) + }, + func(chatID int64, messageID int, reaction string) { + if discordChannelID != "" { + _ = bridge.SendToDiscord(discordChannelID, "реакция поставлена") + } + + if discordReactionChannelID == "" { + log.Printf("[Bridge] reaction duplicate skipped: DISCORD_REACTION_CHANNEL_ID is empty") + return + } + normalizedReaction := normalizeEmoji(reaction) + if normalizedReaction != normalizedDuplicateReaction { + log.Printf("[Bridge] reaction %q does not match configured %q", reaction, telegramDuplicateReactionEmoji) + return + } + + cacheMu.RLock() + item, exists := messageCache[cacheKey(chatID, messageID)] + cacheMu.RUnlock() + if !exists { + log.Printf("[Telegram] reaction %q for message %d in chat %d ignored: original message not in cache", reaction, messageID, chatID) + return + } + + msg := item.Text + if len(item.Media) > 0 { + if msg != "" { + msg += "\n" + } + msg += strings.Join(item.Media, "\n") + } + if err := sendTelegramMessageToDiscord(discordReactionChannelID, item.Sender, item.Text, msg, item.Media); err != nil { + log.Printf("[Bridge] failed to send duplicated message to reaction Discord channel: %v", err) + return + } + log.Printf("[Bridge] duplicated message %d from chat %d to reaction channel by reaction %q", messageID, chatID, reaction) + }, + ) + bridge.StartDiscordListener(discordChannelID, func(text string, sender string, media []string) { + forward(text, "discord", sender, media, 0, 0) + }) log.Println("Chat Bridge запущен и работает") select {} // Бесконечный цикл для поддержания работы программы } + +func normalizeEmoji(value string) string { + trimmed := strings.TrimSpace(value) + filtered := strings.Map(func(r rune) rune { + // Variation selectors and skin tone modifiers are optional in user input/reaction payloads. + if r == '\uFE0E' || r == '\uFE0F' { + return -1 + } + if r >= '\U0001F3FB' && r <= '\U0001F3FF' { + return -1 + } + return r + }, trimmed) + return filtered +} diff --git a/telegram_listener.go b/telegram_listener.go index fc9b38b..62e5537 100644 --- a/telegram_listener.go +++ b/telegram_listener.go @@ -1,85 +1,173 @@ package main import ( + "encoding/json" "fmt" "log" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" ) -func (b *Bridge) StartTelegramListener(forward func(text string, sender string, media []string)) { - u := tgbotapi.NewUpdate(0) - u.Timeout = 60 +type telegramReactionType struct { + Type string `json:"type"` + Emoji string `json:"emoji,omitempty"` + CustomEmojiID string `json:"custom_emoji_id,omitempty"` +} - updates := b.Telegram.GetUpdatesChan(u) +type telegramMessageReactionUpdated struct { + Chat tgbotapi.Chat `json:"chat"` + MessageID int `json:"message_id"` + OldReaction []telegramReactionType `json:"old_reaction"` + NewReaction []telegramReactionType `json:"new_reaction"` +} +type telegramRawUpdate struct { + UpdateID int `json:"update_id"` + Message *tgbotapi.Message `json:"message,omitempty"` + MessageReaction *telegramMessageReactionUpdated `json:"message_reaction,omitempty"` +} + +func (b *Bridge) StartTelegramListener( + forward func(text string, sender string, media []string, messageID int, chatID int64), + onReactionAdded func(chatID int64, messageID int, reaction string), +) { go func() { - for update := range updates { - if update.Message == nil { + offset := 0 + for { + params := tgbotapi.Params{} + params.AddNonZero("offset", offset) + params.AddNonZero("timeout", 60) + _ = params.AddInterface("allowed_updates", []string{"message", "message_reaction"}) + + resp, err := b.Telegram.MakeRequest("getUpdates", params) + if err != nil { + log.Printf("[Telegram] getUpdates error: %v", err) continue } - // Формируем строку с именем и username пользователя - userInfo := update.Message.From.FirstName - if update.Message.From.UserName != "" { - userInfo += " " + update.Message.From.UserName + if !resp.Ok { + log.Printf("[Telegram] getUpdates response error: %s", resp.Description) + continue } - // Собираем медиа URL'ы - media := make([]string, 0) - getFileURL := func(fileID string) (string, error) { - file, err := b.Telegram.GetFile(tgbotapi.FileConfig{FileID: fileID}) - if err != nil { - return "", err + var rawItems []json.RawMessage + if err := json.Unmarshal(resp.Result, &rawItems); err != nil { + log.Printf("[Telegram] cannot decode updates: %v", err) + continue + } + + for _, raw := range rawItems { + var update telegramRawUpdate + if err := json.Unmarshal(raw, &update); err != nil { + log.Printf("[Telegram] cannot decode update item: %v", err) + continue } - return fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", b.Telegram.Token, file.FilePath), nil - } + offset = update.UpdateID + 1 - // Фото (берём самое большое) - if len(update.Message.Photo) > 0 { - photo := update.Message.Photo[len(update.Message.Photo)-1] - if url, err := getFileURL(photo.FileID); err == nil { - media = append(media, url) + if update.Message != nil { + text, media := b.telegramMessagePayload(update.Message) + userInfo := "unknown" + if update.Message.From != nil { + userInfo = update.Message.From.FirstName + if update.Message.From.UserName != "" { + userInfo += " " + update.Message.From.UserName + } + } + log.Printf("[Telegram] %s: %s", userInfo, text) + forward(text, userInfo, media, update.Message.MessageID, update.Message.Chat.ID) + } + + if update.MessageReaction != nil { + for _, reaction := range addedReactions(update.MessageReaction.OldReaction, update.MessageReaction.NewReaction) { + onReactionAdded(update.MessageReaction.Chat.ID, update.MessageReaction.MessageID, reaction) + } } } - - // Document - if update.Message.Document != nil { - if url, err := getFileURL(update.Message.Document.FileID); err == nil { - media = append(media, url) - } - } - - // Video - if update.Message.Video != nil { - if url, err := getFileURL(update.Message.Video.FileID); err == nil { - media = append(media, url) - } - } - - // Audio - if update.Message.Audio != nil { - if url, err := getFileURL(update.Message.Audio.FileID); err == nil { - media = append(media, url) - } - } - - // Voice - if update.Message.Voice != nil { - if url, err := getFileURL(update.Message.Voice.FileID); err == nil { - media = append(media, url) - } - } - - // Animation (GIF) - if update.Message.Animation != nil { - if url, err := getFileURL(update.Message.Animation.FileID); err == nil { - media = append(media, url) - } - } - - log.Printf("[Telegram] %s: %s", userInfo, update.Message.Text) - // Передаём имя отправителя и медиа - forward(update.Message.Text, userInfo, media) } }() } + +func (b *Bridge) telegramMessagePayload(message *tgbotapi.Message) (string, []string) { + if message == nil { + return "", nil + } + media := make([]string, 0) + getFileURL := func(fileID string) (string, error) { + file, err := b.Telegram.GetFile(tgbotapi.FileConfig{FileID: fileID}) + if err != nil { + return "", err + } + return fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", b.Telegram.Token, file.FilePath), nil + } + + if len(message.Photo) > 0 { + photo := message.Photo[len(message.Photo)-1] + if url, err := getFileURL(photo.FileID); err == nil { + media = append(media, url) + } + } + if message.Document != nil { + if url, err := getFileURL(message.Document.FileID); err == nil { + media = append(media, url) + } + } + if message.Video != nil { + if url, err := getFileURL(message.Video.FileID); err == nil { + media = append(media, url) + } + } + if message.Audio != nil { + if url, err := getFileURL(message.Audio.FileID); err == nil { + media = append(media, url) + } + } + if message.Voice != nil { + if url, err := getFileURL(message.Voice.FileID); err == nil { + media = append(media, url) + } + } + if message.Animation != nil { + if url, err := getFileURL(message.Animation.FileID); err == nil { + media = append(media, url) + } + } + + return message.Text, media +} + +func addedReactions(oldList, newList []telegramReactionType) []string { + oldSet := make(map[string]struct{}, len(oldList)) + for _, reaction := range oldList { + oldSet[reactionKey(reaction)] = struct{}{} + } + + added := make([]string, 0) + for _, reaction := range newList { + key := reactionKey(reaction) + if _, exists := oldSet[key]; exists { + continue + } + added = append(added, reactionDisplayValue(reaction)) + } + return added +} + +func reactionKey(reaction telegramReactionType) string { + switch reaction.Type { + case "emoji": + return "emoji:" + reaction.Emoji + case "custom_emoji": + return "custom_emoji:" + reaction.CustomEmojiID + default: + return reaction.Type + } +} + +func reactionDisplayValue(reaction telegramReactionType) string { + if reaction.Type == "emoji" && reaction.Emoji != "" { + return reaction.Emoji + } + if reaction.Type == "custom_emoji" && reaction.CustomEmojiID != "" { + return "custom_emoji:" + reaction.CustomEmojiID + } + return reaction.Type +}