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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
26
main.go
26
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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue