267 lines
7.7 KiB
Go
267 lines
7.7 KiB
Go
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]
|
|
}
|