Add Telegram bot listener with inline post management
All checks were successful
/ build (push) Successful in 1m34s

This commit is contained in:
Astra 2026-03-30 21:36:34 +01:00
parent 46a33f2af7
commit b3e37757a2
3 changed files with 339 additions and 8 deletions

267
bot_handler.go Normal file
View file

@ -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]
}

54
bot_listener.go Normal file
View file

@ -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")
}
}
}
}

26
main.go
View file

@ -37,15 +37,17 @@ const (
) )
type handler struct { type handler struct {
seenSeqs map[int64]struct{} seenSeqs map[int64]struct{}
tg *tgbotapi.BotAPI tg *tgbotapi.BotAPI
bsky *bsky.BSky bsky *bsky.BSky
channelAdmins map[int64]bool
} }
var ( var (
post = flag.String("post", "", "URL to a BlueSky post") post = flag.String("post", "", "URL to a BlueSky post")
delete = flag.Bool("delete", false, "true/false to delete 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") 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() { func main() {
@ -60,8 +62,9 @@ func main() {
} }
h := &handler{ h := &handler{
seenSeqs: make(map[int64]struct{}), seenSeqs: make(map[int64]struct{}),
bsky: bskyClient, bsky: bskyClient,
channelAdmins: make(map[int64]bool),
} }
endpoint := "https://api.telegram.org/bot%s/%s" endpoint := "https://api.telegram.org/bot%s/%s"
@ -126,6 +129,13 @@ func main() {
return return
} }
if *botOnly {
h.StartBotListener()
return
}
go h.StartBotListener()
ctx := context.Background() ctx := context.Background()
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug.Level(), Level: slog.LevelDebug.Level(),