Add Telegram bot listener with inline post management
All checks were successful
/ build (push) Successful in 1m34s
All checks were successful
/ build (push) Successful in 1m34s
This commit is contained in:
parent
46a33f2af7
commit
b3e37757a2
3 changed files with 339 additions and 8 deletions
267
bot_handler.go
Normal file
267
bot_handler.go
Normal 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
54
bot_listener.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
main.go
10
main.go
|
|
@ -40,12 +40,14 @@ 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() {
|
||||||
|
|
@ -62,6 +64,7 @@ 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(),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue