diff --git a/bot_handler.go b/bot_handler.go new file mode 100644 index 0000000..73387c6 --- /dev/null +++ b/bot_handler.go @@ -0,0 +1,267 @@ +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] +} diff --git a/bot_listener.go b/bot_listener.go new file mode 100644 index 0000000..100802a --- /dev/null +++ b/bot_listener.go @@ -0,0 +1,54 @@ +package main + +import ( + "log" + "os" + "strconv" + + tgbotapi "github.com/OvyFlash/telegram-bot-api" +) + +// StartBotListener starts listening for Telegram bot messages +func (h *handler) StartBotListener() { + // Load channel admins + err := h.LoadChannelAdmins() + if err != nil { + log.Printf("Warning: Could not load channel admins: %v", err) + } + + u := tgbotapi.NewUpdate(0) + u.Timeout = 60 + + updates := h.tg.GetUpdatesChan(u) + + channelID, _ := strconv.ParseInt(os.Getenv("TG_CHANNEL_ID"), 10, 64) + channelName := os.Getenv("TG_CHANNEL_ID") + if chat, err := h.tg.GetChat(tgbotapi.ChatInfoConfig{ChatConfig: tgbotapi.ChatConfig{ChatID: channelID}}); err == nil { + channelName = chat.Title + } + log.Printf("Bot listener started: @%s (Telegram bot) | @%s (Bluesky) | channel %s", + h.tg.Self.UserName, + h.bsky.Bluesky.Cfg.Handle, + channelName, + ) + + for update := range updates { + if update.CallbackQuery != nil { + h.HandleCallbackQuery(update.CallbackQuery) + continue + } + + if update.Message == nil { + continue + } + + message := update.Message + + if message.Text != "" || message.Caption != "" { + if err := h.HandleBotMessage(message); err != nil { + log.Printf("Error handling message: %v", err) + h.sendMessage(message.Chat.ID, "❌ Error processing message") + } + } + } +} diff --git a/main.go b/main.go index 31691d9..ba3e404 100644 --- a/main.go +++ b/main.go @@ -37,15 +37,17 @@ const ( ) type handler struct { - seenSeqs map[int64]struct{} - tg *tgbotapi.BotAPI - bsky *bsky.BSky + seenSeqs map[int64]struct{} + tg *tgbotapi.BotAPI + bsky *bsky.BSky + channelAdmins map[int64]bool } var ( - post = flag.String("post", "", "URL to a BlueSky post") - delete = flag.Bool("delete", false, "true/false to delete post") - oldPosts = flag.Float64("oldposttime", 1, "Ignore posts if createdAt more than this many hours ago") + post = flag.String("post", "", "URL to a BlueSky post") + delete = flag.Bool("delete", false, "true/false to delete post") + oldPosts = flag.Float64("oldposttime", 1, "Ignore posts if createdAt more than this many hours ago") + botOnly = flag.Bool("bot", false, "Run only the Telegram bot listener, without Jetstream sync") ) func main() { @@ -60,8 +62,9 @@ func main() { } h := &handler{ - seenSeqs: make(map[int64]struct{}), - bsky: bskyClient, + seenSeqs: make(map[int64]struct{}), + bsky: bskyClient, + channelAdmins: make(map[int64]bool), } endpoint := "https://api.telegram.org/bot%s/%s" @@ -126,6 +129,13 @@ func main() { return } + if *botOnly { + h.StartBotListener() + return + } + + go h.StartBotListener() + ctx := context.Background() slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ Level: slog.LevelDebug.Level(),