package main import ( "fmt" "log" "os" "regexp" "strconv" "strings" "git.zio.sh/astra/bsky2tg/bsky" tgbotapi "github.com/OvyFlash/telegram-bot-api" ) // HandleBotMessage processes incoming bot messages and looks for post URLs func (h *handler) HandleBotMessage(message *tgbotapi.Message) error { // Check if user is a channel admin if message.From != nil && !h.isUserAdmin(message.From.ID) { log.Printf("User is not an admin: %s", message.From) // h.sendMessage(message.Chat.ID, "❌ Only channel admins can use this bot") return nil } // Get text from message or caption text := message.Text if text == "" { text = message.Caption } if text == "" { return nil } // Look for Bluesky post URLs in the message // Format: https://bsky.app/profile/{handle}/post/{rkey} var postURL string if message.ForwardOrigin != nil { if len(message.CaptionEntities) != 0 { postURL = message.CaptionEntities[len(message.CaptionEntities)-1].URL } else { postURL = message.Entities[len(message.Entities)-1].URL } } else { postURL = extractBskyPostURL(text) if postURL == "" { return nil } } // Extract RKey from the URL (last part after /post/) rkey := extractRKeyFromURL(postURL) if rkey == "" { h.sendMessage(message.Chat.ID, "❌ Could not extract post ID from URL") return nil } // Fetch the post from PDS using the RKey post := h.bsky.Bluesky.GetPost(fmt.Sprintf("at://%s/app.bsky.feed.post/%s", h.bsky.Bluesky.Cfg.DID, rkey)) if post == nil { h.sendMessage(message.Chat.ID, "❌ Could not fetch post from Bluesky") return nil } // Format the response response := formatPostResponse(post, postURL) // Try to find the corresponding channel message telegramRecord, err := h.bsky.Bluesky.GetTelegramData(rkey) if err == "" && telegramRecord != nil && len(telegramRecord.MessageID) > 0 { h.sendMessageWithButtons(message.Chat.ID, response, rkey, postURL, telegramRecord.ChannelID, telegramRecord.MessageID[0]) } else { h.sendMessage(message.Chat.ID, response) } return nil } // extractBskyPostURL finds a Bluesky post URL in text func extractBskyPostURL(text string) string { // Pattern: https://bsky.app/profile/{anything}/post/{rkey} pattern := regexp.MustCompile(`https://bsky\.app/profile/[^/]+/post/[a-z0-9]+`) matches := pattern.FindString(text) return matches } // extractRKeyFromURL extracts the post ID (RKey) from a Bluesky URL // URL format: https://bsky.app/profile/{handle}/post/{rkey} func extractRKeyFromURL(url string) string { // Get the last part after /post/ parts := strings.Split(url, "/post/") if len(parts) < 2 { return "" } return parts[len(parts)-1] } // formatPostResponse creates a formatted response message func formatPostResponse(post *bsky.Post, postURL string) string { if post == nil { return "❌ Invalid post data" } return `Post found\!` } // escapeMarkdown escapes special markdown characters for Telegram MarkdownV2 func escapeMarkdown(text string) string { // Order matters: escape backslash first to avoid double-escaping // Telegram MarkdownV2 requires these characters to be escaped: replacements := []struct { old, new string }{ {`\`, `\\`}, // backslash must be first {`_`, `\_`}, // underscore (italic) {`*`, `\*`}, // asterisk (bold) {`[`, `\[`}, // bracket (link) {`]`, `\]`}, // bracket (link) {`(`, `\(`}, // parenthesis (link) {`)`, `\)`}, // parenthesis (link) {`~`, `\~`}, // tilde (strikethrough) {"`", "`"}, // backtick (inline code) {`>`, `\>`}, // greater-than (blockquote) {`#`, `\#`}, // hash (heading) {`+`, `\+`}, // plus (list) {`-`, `\-`}, // minus (list) {`.`, `\.`}, // period (ordered list) {`!`, `\!`}, // exclamation (image) } result := text for _, replacement := range replacements { result = strings.ReplaceAll(result, replacement.old, replacement.new) } return result } // sendMessage sends a message to a user func (h *handler) sendMessage(chatID int64, text string) error { msg := tgbotapi.NewMessage(chatID, text) msg.ParseMode = tgbotapi.ModeMarkdownV2 _, err := h.tg.Send(msg) return err } // sendMessageWithButtons sends a message with action buttons for the post func (h *handler) sendMessageWithButtons(chatID int64, text, rkey, postURL string, channelID int64, messageID int) error { msg := tgbotapi.NewMessage(chatID, text) msg.LinkPreviewOptions = tgbotapi.LinkPreviewOptions{ URL: fmt.Sprintf(embedURL, h.bsky.Bluesky.Cfg.DID, rkey), PreferSmallMedia: true, ShowAboveText: true, } msg.ParseMode = tgbotapi.ModeMarkdownV2 viewChannel := tgbotapi.NewInlineKeyboardButtonURL( "📍 View in Channel", fmt.Sprintf("tg://privatepost?channel=%d&post=%d", -channelID-1000000000000, messageID), ) viewBsky := tgbotapi.NewInlineKeyboardButtonURL( "🦋 View on Bluesky", postURL, ) deleteTG := tgbotapi.NewInlineKeyboardButtonData("🗑 Delete from Channel", "del_tg:"+rkey) deleteBsky := tgbotapi.NewInlineKeyboardButtonData("❌ Delete from Bluesky", "del_bsky:"+rkey) closeBtn := tgbotapi.NewInlineKeyboardButtonData("✖ Close", "close") msg.ReplyMarkup = tgbotapi.NewInlineKeyboardMarkup( tgbotapi.NewInlineKeyboardRow(viewChannel, viewBsky), tgbotapi.NewInlineKeyboardRow(deleteTG, deleteBsky), tgbotapi.NewInlineKeyboardRow(closeBtn), ) _, err := h.tg.Send(msg) return err } // HandleCallbackQuery processes inline button presses func (h *handler) HandleCallbackQuery(query *tgbotapi.CallbackQuery) { answer := func(text string) { h.tg.Send(tgbotapi.NewCallback(query.ID, text)) } editDone := func(text string) { if query.Message == nil { return } edit := tgbotapi.NewEditMessageText(query.Message.Chat.ID, query.Message.MessageID, text) edit.ParseMode = tgbotapi.ModeMarkdownV2 edit.LinkPreviewOptions = tgbotapi.LinkPreviewOptions{IsDisabled: true} emptyKb := tgbotapi.NewInlineKeyboardMarkup() edit.ReplyMarkup = &emptyKb h.tg.Send(edit) } if !h.isUserAdmin(query.From.ID) { answer("⛔ Admins only") return } data := query.Data switch { case data == "close": if query.Message != nil { h.tg.Send(tgbotapi.NewDeleteMessage(query.Message.Chat.ID, query.Message.MessageID)) } answer("") case strings.HasPrefix(data, "del_tg:"): rkey := strings.TrimPrefix(data, "del_tg:") rec, err := h.bsky.Bluesky.GetTelegramData(rkey) if err != "" { answer("❌ Post not found in channel records") return } m := tgbotapi.NewDeleteMessages(rec.ChannelID, rec.MessageID) if _, e := h.tg.Send(m); e != nil { answer("❌ Failed to delete: " + e.Error()) return } h.bsky.Bluesky.DeleteRecord([]string{rkey, h.bsky.Bluesky.Cfg.DID, bsky.PostCollection}) answer("🗑 Deleted from channel") editDone("🗑 *Deleted from Telegram channel*") case strings.HasPrefix(data, "del_bsky:"): rkey := strings.TrimPrefix(data, "del_bsky:") h.bsky.Bluesky.DeleteRecord([]string{rkey, h.bsky.Bluesky.Cfg.DID, "app.bsky.feed.post"}) answer("❌ Deleted from Bluesky") editDone("❌ *Deleted from Bluesky*") default: answer("") } } // LoadChannelAdmins fetches the list of channel admins from Telegram func (h *handler) LoadChannelAdmins() error { // Get channel admins cid, _ := strconv.ParseInt(os.Getenv("TG_CHANNEL_ID"), 10, 64) admins, err := h.tg.GetChatAdministrators( tgbotapi.ChatAdministratorsConfig{ ChatConfig: tgbotapi.ChatConfig{ ChatID: cid, }, }, ) if err != nil { return fmt.Errorf("failed to get channel admins: %w", err) } // Store admin IDs in the map for _, admin := range admins { if admin.User != nil { h.channelAdmins[admin.User.ID] = true } } count := len(h.channelAdmins) fmt.Printf("Loaded %d channel admins\n", count) return nil } // isUserAdmin checks if a user ID is a channel admin func (h *handler) isUserAdmin(userID int64) bool { return h.channelAdmins[userID] }