Compare commits

..

No commits in common. "main" and "linkalias" have entirely different histories.

10 changed files with 278 additions and 1288 deletions

View file

@ -1,30 +0,0 @@
on:
push:
paths:
- '*.go'
- 'bsky/*.go'
- 'go.sum'
- 'go.mod'
- 'Dockerfile'
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Login to Docker Hub
uses: https://github.com/docker/login-action@v3
with:
registry: git.zio.sh
username: ${{ secrets.REPO_USER }}
password: ${{ secrets.REPO_PASS }}
- name: Set up Docker Build Push Action
uses: https://github.com/docker/build-push-action@v2
with:
tags: |
git.zio.sh/astra/bsky2tg:latest
git.zio.sh/astra/bsky2tg:${{ github.sha }}
push: true
load: false

View file

@ -1,16 +0,0 @@
FROM golang:alpine AS builder
WORKDIR /go/src/git.zio.sh/bsky2tg
COPY . .
RUN apk update && \
apk add --no-cache git bash && \
go get -d -v ./... && \
go install
FROM alpine:latest
COPY --from=builder /go/bin/bsky2tg /usr/local/bin/bsky2tg
RUN apk update && apk add --no-cache ffmpeg
CMD ["bsky2tg"]

21
LICENSE
View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 astra.blue
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

190
README.md
View file

@ -1,188 +1,36 @@
# bsky2tg bsky2tg
=======
A real-time bridge that forwards Bluesky posts to Telegram. Monitor your Bluesky account and automatically send posts to a Telegram channel with full media support, quote posts, and more. **bsky2tg** will mirror posts from your Bluesky account to a Telegram channel through a bot. It supports creation and deletion of posts on Bluesky but not the other way.
## Features ---
- 🦋 **Real-time sync** - Posts appear on Telegram seconds after posting on Bluesky ### Usage
- 📸 **Full media support** - Images, videos, GIFs (from Tenor)
- 💬 **Quote posts** - Properly formatted with links to original posts
- ✏️ **Edit support** - Updates Telegram message when you edit a Bluesky post
- 🗑️ **Delete sync** - Removes from Telegram when you delete from Bluesky
- 🔗 **Rich links** - @mentions, hashtags, and custom aliases converted to clickable links
- ⏰ **Time filtering** - Ignore old posts and replies if desired
- 🎬 **Video metadata** - Includes duration, dimensions, and thumbnail
## Setup Create a `.env` file with the following:
### Prerequisites ```properties
TG_TOKEN=
TG_CHANNEL_ID=
BSKY_HANDLE=
BSKY_PASSWORD=
```
- Go 1.21+ To run:
- A Bluesky account
- A Telegram bot and channel
### Installation
1. **Clone the repository**
```bash
git clone https://git.zio.sh/astra/bsky2tg
cd bsky2tg
```
2. **Build the project**
```bash
go build
```
3. **Set environment variables**
```bash
export BSKY_HANDLE="your.bsky.handle"
export BSKY_PASSWORD="your-app-password" # NOT your main password
export TG_TOKEN="your-telegram-bot-token"
export TG_CHANNEL_ID="your-channel-id"
```
**Optional:**
```bash
export TG_API_ENDPOINT="https://api.telegram.org/bot%s/%s" # Custom Telegram API endpoint
export OLDPOSTTIME="1" # Ignore posts older than this many hours (default: 1)
```
4. **Run the daemon**
```bash
./bsky2tg
```
## Running with Podman
Run the bot in a container using Podman:
### With `.env` file
```bash ```bash
podman run -it --name bsky2tg_<profile> \ podman run -it --name bsky2tg_<profile> \
--env-file /path/to/.env \ --env-file /path/to/.env \
git.zio.sh/astra/bsky2tg:latest git.zio.sh/astra/bsky2tg:latest
``` ```
### With environment variables Or without `.env` file:
```bash ```bash
podman run -it --name bsky2tg_<profile> \ podman run -it --name bsky2tg_<profile> \
--env TG_TOKEN=<your-token> \ --env TG_TOKEN= \
--env TG_CHANNEL_ID=<your-channel-id> \ --env TG_CHANNEL_ID= \
--env BSKY_HANDLE=<your.handle> \ --env BSKY_HANDLE= \
--env BSKY_PASSWORD=<your-app-password> \ --env BSKY_PASSWORD= \
git.zio.sh/astra/bsky2tg:latest git.zio.sh/astra/bsky2tg:latest
``` ```
### Getting Your Credentials
**Bluesky App Password:**
- Go to Settings → Privacy and Security → App Passwords
- Create a new app password (NOT your main Bluesky password)
**Telegram Bot Token:**
- Message [@BotFather](https://t.me/BotFather) on Telegram
- Create a new bot with `/newbot`
- Copy the token
**Telegram Channel ID:**
- Create a channel (can also be private)
- Add your bot as an admin
- Use `@userinfobot` to get the channel ID
## Usage
### Daemon Mode
The bot runs continuously and syncs new posts in real-time:
```bash
./bsky2tg
```
### One-Shot Post Sync
Send a specific post to Telegram:
```bash
./bsky2tg -post "https://bsky.app/profile/user.bsky/post/abc123"
```
### Delete a Post
Remove a post from Telegram (delete from Bluesky first):
```bash
./bsky2tg -post "https://bsky.app/profile/user.bsky/post/abc123" -delete
```
### Ignore Old Posts
Ignore posts created more than 2 hours ago:
```bash
./bsky2tg -oldposttime 2
```
## How It Works
1. **Authentication** - Logs into Bluesky via ATProto and stores the session
2. **Jetstream Connection** - Subscribes to real-time post events from your account
3. **Post Processing** - Parses posts, extracts media, processes facets (links/mentions)
4. **Telegram Delivery** - Sends formatted messages with media to your channel
5. **Metadata Storage** - Records post mapping (Bluesky → Telegram) for edits/deletes
## Configuration
### Post Format
Posts are sent with this format:
```
[Post text with @mentions and #hashtags]
🦋 @your.handle
```
Quote posts include the quoted post above in a blockquote.
### Custom Aliases
You can set up custom link replacements by creating entries in the `blue.zio.bsky2tg.alias` collection on your PDS.
## Troubleshooting
### Auth errors
- Verify `BSKY_HANDLE` and `BSKY_PASSWORD` are correct
- Use an app password, not your main Bluesky password
- Check `auth-session.json` file permissions
### Posts not syncing
- Ensure the bot is admin in the channel
- Check `TG_CHANNEL_ID` is correct
- Verify Jetstream connection with logs
### Video errors
- FFmpeg must be installed for video processing
- Check that video file can be read
## Project Structure
```
.
├── main.go # Event handler, post processing, Telegram sender
├── bsky/
│ ├── client.go # Bluesky session management, handle resolution
│ ├── bluesky.go # ATProto API calls (posts, records, sessions)
│ └── parse.go # Post parsing, facet processing
├── auth-session.json # Stored auth session (auto-created)
└── README.md # This file
```
## API Integration
- **Bluesky ATProto** - Session creation, post fetching, record management
- **Jetstream** - Real-time firehose subscription
- **Telegram Bot API** - Message/media sending, editing, deleting
## Notes
- Auth sessions are persisted in `auth-session.json`
- Tokens are automatically refreshed when expired
- Posts are deduplicated to prevent duplicates on sync restart
- Media is fetched from your PDS via blob endpoints
## License
See LICENSE file

View file

@ -1,267 +0,0 @@
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]
}

View file

@ -1,54 +0,0 @@
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")
}
}
}
}

View file

@ -10,17 +10,6 @@ import (
"github.com/dghubble/sling" "github.com/dghubble/sling"
) )
const (
// URI parsing indices for at:// URIs split by "/"
uriRepoIndex = 2
uriCollectionIndex = 3
uriRkeyIndex = 4
// Custom collections
PostCollection = "blue.zio.bsky2tg.post"
AliasCollection = "blue.zio.bsky2tg.alias"
)
type BlueskyConfig struct { type BlueskyConfig struct {
PDSURL string `json:"pds-url"` PDSURL string `json:"pds-url"`
Repo string `json:"repo"` Repo string `json:"repo"`
@ -87,7 +76,6 @@ type Bluesky struct {
HttpClient *http.Client HttpClient *http.Client
Logger *log.Logger Logger *log.Logger
sling *sling.Sling sling *sling.Sling
publicSling *sling.Sling
} }
func (bluesky *Bluesky) CreateSession(cfg *BlueskyConfig) error { func (bluesky *Bluesky) CreateSession(cfg *BlueskyConfig) error {
@ -100,18 +88,18 @@ func (bluesky *Bluesky) CreateSession(cfg *BlueskyConfig) error {
} }
resp := new(BSkySessionResponse) resp := new(BSkySessionResponse)
bluesky.sling.New().Client(bluesky.HttpClient). bluesky.sling.New().Post("/xrpc/com.atproto.server.createSession").BodyJSON(body).ReceiveSuccess(resp)
Post("/xrpc/com.atproto.server.createSession").BodyJSON(body).ReceiveSuccess(resp)
if resp.AccessJWT != "" { if resp.AccessJWT != "" {
cfg.AccessJWT = resp.AccessJWT cfg.AccessJWT = resp.AccessJWT
cfg.RefreshJWT = resp.RefreshJWT cfg.RefreshJWT = resp.RefreshJWT
return nil return nil
} }
bluesky.sling.New().Set("Authorization", fmt.Sprintf("Bearer %s", bluesky.Cfg.AccessJWT))
return errors.New("unable to authenticate, check handle/password") return errors.New("unable to authenticate, check handle/password")
} }
func (bluesky *Bluesky) RefreshSession() { func (bluesky *Bluesky) RefreshSession() error {
resp := new(BSkySessionResponse) resp := new(BSkySessionResponse)
bluesky.sling.New().Set("Authorization", fmt.Sprintf("Bearer %s", bluesky.Cfg.RefreshJWT)). bluesky.sling.New().Set("Authorization", fmt.Sprintf("Bearer %s", bluesky.Cfg.RefreshJWT)).
@ -121,34 +109,25 @@ func (bluesky *Bluesky) RefreshSession() {
bluesky.Cfg.RefreshJWT = resp.RefreshJWT bluesky.Cfg.RefreshJWT = resp.RefreshJWT
PersistAuthSession(bluesky.Cfg) PersistAuthSession(bluesky.Cfg)
bluesky.sling.Set("Authorization", fmt.Sprintf("Bearer %s", bluesky.Cfg.AccessJWT)) bluesky.sling.Set("Authorization", fmt.Sprintf("Bearer %s", bluesky.Cfg.AccessJWT))
return return nil
}
if resp.Error != "" {
log.Fatalf("RefreshSession error: %s", resp.Message)
} }
bluesky.CreateSession(bluesky.Cfg) return bluesky.CreateSession(bluesky.Cfg)
} }
func (bluesky *Bluesky) CheckSessionValid() { func (bluesky *Bluesky) CheckSessionValid() {
resp := new(BSkySessionResponse) resp := new(BSkySessionResponse)
params := struct {
Actor string `url:"actor"`
}{
Actor: bluesky.Cfg.Handle,
}
bluesky.sling.New().Set("Authorization", fmt.Sprintf("Bearer %s", bluesky.Cfg.AccessJWT)). bluesky.sling.New().Set("Authorization", fmt.Sprintf("Bearer %s", bluesky.Cfg.AccessJWT)).
Get("/xrpc/app.bsky.actor.getProfile").QueryStruct(params).Receive(resp, resp) Get("/xrpc/app.bsky.actor.getProfile").Receive(resp, resp)
if resp.Error != "" { if resp.Error == "ExpiredToken" {
bluesky.RefreshSession() bluesky.RefreshSession()
} }
} }
type TelegramRecord struct { type TelegramRecord struct {
ChannelID int64 `json:"channel_id"` ChannelID int64 `json:"channel_id"`
MessageID []int `json:"message_id"` MessageID int `json:"message_id"`
Link *Link `json:"link"` Link *Link `json:"link"`
Error string `json:"error"` Error string `json:"error"`
Message string `json:"message"` Message string `json:"message"`
@ -173,7 +152,7 @@ func (bluesky *Bluesky) CommitTelegramResponse(data *TelegramRecord, rkey string
Record TelegramRecord `json:"record"` Record TelegramRecord `json:"record"`
}{ }{
Repo: bluesky.Cfg.DID, Repo: bluesky.Cfg.DID,
Collection: PostCollection, Collection: "blue.zio.bsky2tg.post",
RKey: rkey, RKey: rkey,
Record: TelegramRecord{ Record: TelegramRecord{
ChannelID: data.ChannelID, ChannelID: data.ChannelID,
@ -205,12 +184,12 @@ func (bluesky *Bluesky) GetTelegramData(rkey string) (*TelegramRecord, string) {
RKey string `url:"rkey"` RKey string `url:"rkey"`
}{ }{
Repo: bluesky.Cfg.DID, Repo: bluesky.Cfg.DID,
Collection: PostCollection, Collection: "blue.zio.bsky2tg.post",
RKey: rkey, RKey: rkey,
} }
bluesky.sling.New().Get("/xrpc/com.atproto.repo.getRecord").QueryStruct(&params).Receive(resp, resp) bluesky.sling.New().Get("/xrpc/com.atproto.repo.getRecord").QueryStruct(&params).Receive(resp, resp)
return resp.Value, resp.Error return resp.Value, resp.Message
} }
func (bluesky *Bluesky) GetPost(uri string) *Post { func (bluesky *Bluesky) GetPost(uri string) *Post {
@ -227,9 +206,9 @@ func (bluesky *Bluesky) GetPost(uri string) *Post {
Repo string `url:"repo"` Repo string `url:"repo"`
Collection string `url:"collection"` Collection string `url:"collection"`
}{ }{
RKey: args[uriRkeyIndex], RKey: args[4],
Repo: args[uriRepoIndex], Repo: args[2],
Collection: args[uriCollectionIndex], Collection: args[3],
} }
bluesky.sling.New().Get("/xrpc/com.atproto.repo.getRecord").QueryStruct(params).ReceiveSuccess(&post) bluesky.sling.New().Get("/xrpc/com.atproto.repo.getRecord").QueryStruct(params).ReceiveSuccess(&post)
@ -261,7 +240,7 @@ func (bluesky *Bluesky) FetchAliases() []Records {
Collection string `url:"collection"` Collection string `url:"collection"`
}{ }{
Repo: bluesky.Cfg.DID, Repo: bluesky.Cfg.DID,
Collection: AliasCollection, Collection: "blue.zio.bsky2tg.alias",
} }
bluesky.sling.New().Get("/xrpc/com.atproto.repo.listRecords").QueryStruct(&params).Receive(resp, resp) bluesky.sling.New().Get("/xrpc/com.atproto.repo.listRecords").QueryStruct(&params).Receive(resp, resp)
@ -284,16 +263,3 @@ type Records struct {
Cid string `json:"cid"` Cid string `json:"cid"`
Value Value `json:"value"` Value Value `json:"value"`
} }
func (bluesky *Bluesky) FetchPost(did string, rkey string) FetchedPost {
resp := &struct {
Posts []FetchedPost `json:"posts"`
}{}
params := struct {
URIs string `url:"uris"`
}{
URIs: fmt.Sprintf("at://%s/app.bsky.feed.post/%s", did, rkey),
}
bluesky.publicSling.New().Get("/xrpc/app.bsky.feed.getPosts").QueryStruct(&params).Receive(resp, resp)
return resp.Posts[0]
}

View file

@ -13,14 +13,9 @@ import (
"github.com/dghubble/sling" "github.com/dghubble/sling"
) )
const (
didWebPrefixLen = len("did:web:")
atPrefixLen = len("at://")
httpClientTimeout = 3 * time.Second
)
type BSky struct { type BSky struct {
Bluesky *Bluesky Bluesky *Bluesky
DID string
} }
func NewBSky() *BSky { func NewBSky() *BSky {
@ -28,13 +23,13 @@ func NewBSky() *BSky {
Bluesky: &Bluesky{ Bluesky: &Bluesky{
Cfg: &BlueskyConfig{}, Cfg: &BlueskyConfig{},
HttpClient: &http.Client{}, HttpClient: &http.Client{},
sling: sling.New().Client(&http.Client{Timeout: httpClientTimeout}), sling: sling.New().Client(&http.Client{Timeout: time.Second * 3}),
publicSling: sling.New().Base("https://public.api.bsky.app/").Client(&http.Client{Timeout: httpClientTimeout}),
}, },
} }
} }
func (b *BSky) ResolveHandle(handle string) (string, error) { func (b *BSky) getPDS() error {
httpClient := &http.Client{Timeout: 3 * time.Second}
resp := new(BSkySessionResponse) resp := new(BSkySessionResponse)
errResp := &struct { errResp := &struct {
Message string `json:"message"` Message string `json:"message"`
@ -43,74 +38,61 @@ func (b *BSky) ResolveHandle(handle string) (string, error) {
params := struct { params := struct {
Handle string `url:"handle"` Handle string `url:"handle"`
}{ }{
Handle: handle, Handle: b.Bluesky.Cfg.Handle,
} }
b.Bluesky.publicSling.New().Get("/xrpc/com.atproto.identity.resolveHandle").QueryStruct(params). sling.New().Base("https://public.api.bsky.app/").Client(httpClient).
Get("/xrpc/com.atproto.identity.resolveHandle").QueryStruct(params).
Receive(resp, errResp) Receive(resp, errResp)
if errResp.Error != "" { if errResp.Error != "" {
return "", errors.New(errResp.Message) return errors.New(errResp.Message)
} }
return resp.DID, nil var didURL url.URL
} if strings.HasPrefix(resp.DID, "did:web:") {
didURL.Host = "https://" + resp.DID[8:]
func parseDIDURL(did string) (*url.URL, error) { didURL.Path = "/.well-known/did.json"
if strings.HasPrefix(did, "did:web:") { } else if strings.HasPrefix(resp.DID, "did:plc:") {
return url.Parse("https://" + did[didWebPrefixLen:] + "/.well-known/did.json") didURL.Host = "https://plc.directory"
} else if strings.HasPrefix(did, "did:plc:") { didURL.Path = "/" + resp.DID
return url.Parse("https://plc.directory/" + did) } else {
} return errors.New("DID is not supported")
return nil, errors.New("DID is not supported")
}
func (b *BSky) getPDS() error {
did, _ := b.ResolveHandle(b.Bluesky.Cfg.Handle)
didURL, err := parseDIDURL(did)
if err != nil {
return err
} }
didResp := new(DIDResponse) didResp := new(DIDResponse)
baseURL := fmt.Sprintf("%s://%s", didURL.Scheme, didURL.Host) sling.New().Base(didURL.Host).Get(didURL.Path).ReceiveSuccess(didResp)
sling.New().Base(baseURL).Get(didURL.Path).ReceiveSuccess(didResp)
if didResp.ID == "" { if didResp.ID == "" {
return errors.New("unable to resolve DID") return errors.New("unable to resolve DID")
} }
b.Bluesky.Cfg.DID = didResp.ID b.Bluesky.Cfg.DID = didResp.ID
if len(didResp.Service) == 0 { b.Bluesky.Cfg.PDSURL = didResp.Service[0].ServiceEndpoint
return errors.New("DID response has no services") b.Bluesky.sling.Base(didResp.Service[0].ServiceEndpoint)
}
pdsURL := didResp.Service[0].ServiceEndpoint
if pdsURL == "" {
return errors.New("service endpoint is empty")
}
b.Bluesky.Cfg.PDSURL = pdsURL
b.Bluesky.sling.Base(pdsURL)
return nil return nil
} }
func (b *BSky) GetHandleFromDID(did string) (handle string, err error) { func (b *BSky) GetHandleFromDID(did string) (handle string, err error) {
didURL, err := parseDIDURL(did) var didURL url.URL
if err != nil { if strings.HasPrefix(did, "did:web:") {
return "", err didURL.Host = "https://" + did[8:]
didURL.Path = "/.well-known/did.json"
} else if strings.HasPrefix(did, "did:plc:") {
didURL.Host = "https://plc.directory"
didURL.Path = "/" + did
} else {
return "", errors.New("DID is not supported")
} }
didResp := new(DIDResponse) didResp := new(DIDResponse)
baseURL := fmt.Sprintf("%s://%s", didURL.Scheme, didURL.Host) sling.New().Base(didURL.Host).Get(didURL.Path).ReceiveSuccess(didResp)
sling.New().Base(baseURL).Get(didURL.Path).ReceiveSuccess(didResp)
if didResp.ID == "" { if didResp.ID == "" {
return "", errors.New("unable to resolve DID") return "", errors.New("unable to resolve DID")
} }
return didResp.AlsoKnownAs[0][atPrefixLen:], nil return didResp.AlsoKnownAs[0][5:], nil
} }
func (b *BSky) GetPDS() string { func (b *BSky) GetPDS(handle string) string {
return b.Bluesky.Cfg.PDSURL return b.Bluesky.Cfg.PDSURL
} }
@ -120,9 +102,9 @@ func (b *BSky) Auth(authData []string) error {
auth, err := loadAuth() auth, err := loadAuth()
if err != nil { // no auth session found if err != nil { // no auth session found
b.Bluesky.Cfg.AppPassword = authData[1] b.Bluesky.Cfg.AppPassword = authData[1]
err := b.Bluesky.CreateSession(b.Bluesky.Cfg) err = b.Bluesky.CreateSession(b.Bluesky.Cfg)
if err != nil { if err != nil {
return fmt.Errorf("unable to auth: %s", err) return errors.New(fmt.Sprintf("unable to auth: %s", err))
} }
b.Bluesky.Cfg.AppPassword = "" // we don't need to save this b.Bluesky.Cfg.AppPassword = "" // we don't need to save this
PersistAuthSession(b.Bluesky.Cfg) PersistAuthSession(b.Bluesky.Cfg)
@ -130,6 +112,7 @@ func (b *BSky) Auth(authData []string) error {
b.Bluesky.Cfg.Cursor = auth.Cursor b.Bluesky.Cfg.Cursor = auth.Cursor
b.Bluesky.Cfg.AccessJWT = auth.AccessJWT b.Bluesky.Cfg.AccessJWT = auth.AccessJWT
b.Bluesky.Cfg.RefreshJWT = auth.RefreshJWT b.Bluesky.Cfg.RefreshJWT = auth.RefreshJWT
// b.RefreshSession()
b.Bluesky.CheckSessionValid() b.Bluesky.CheckSessionValid()
} }
@ -162,9 +145,6 @@ func loadAuth() (*BlueskyConfig, error) {
} }
var auth *BlueskyConfig var auth *BlueskyConfig
err = json.Unmarshal(fBytes, &auth) json.Unmarshal(fBytes, &auth)
if err != nil {
return nil, fmt.Errorf("failed to parse auth file: %w", err)
}
return auth, nil return auth, nil
} }

View file

@ -3,7 +3,6 @@ package bsky
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"html"
"sort" "sort"
"strings" "strings"
"time" "time"
@ -16,304 +15,114 @@ type Post struct {
Langs []string `json:"langs,omitempty"` Langs []string `json:"langs,omitempty"`
Labels *Labels `json:"labels,omitempty"` Labels *Labels `json:"labels,omitempty"`
Reply *Reply `json:"reply,omitempty"` Reply *Reply `json:"reply,omitempty"`
Facets []Facets `json:"facets,omitempty"` Facets *[]Facets `json:"facets,omitempty"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
} }
type Ref struct { type Ref struct {
Link string `json:"$link,omitempty"` Link string `json:"$link,omitempty"`
} }
type Thumb struct { type Thumb struct {
Type string `json:"$type,omitempty"` Type string `json:"$type,omitempty"`
Ref *Ref `json:"ref,omitempty"` Ref *Ref `json:"ref,omitempty"`
MimeType string `json:"mimeType,omitempty"` MimeType string `json:"mimeType,omitempty"`
Size int `json:"size,omitempty"` Size int `json:"size,omitempty"`
} }
type External struct { type External struct {
URI string `json:"uri,omitempty"` URI string `json:"uri,omitempty"`
Thumb *Thumb `json:"thumb,omitempty"` Thumb *Thumb `json:"thumb,omitempty"`
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
} }
type Video struct { type Video struct {
Type string `json:"$type,omitempty"` Type string `json:"$type,omitempty"`
Ref *Ref `json:"ref,omitempty"` Ref *Ref `json:"ref,omitempty"`
MimeType string `json:"mimeType,omitempty"` MimeType string `json:"mimeType,omitempty"`
Size int `json:"size,omitempty"` Size int `json:"size,omitempty"`
} }
type Image struct { type Image struct {
Type string `json:"$type,omitempty"` Type string `json:"$type,omitempty"`
Ref *Ref `json:"ref,omitempty"` Ref *Ref `json:"ref,omitempty"`
MimeType string `json:"mimeType,omitempty"` MimeType string `json:"mimeType,omitempty"`
Size int `json:"size,omitempty"` Size int `json:"size,omitempty"`
} }
type AspectRatio struct { type AspectRatio struct {
Width int `json:"width,omitempty"` Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"` Height int `json:"height,omitempty"`
} }
type Images struct { type Images struct {
Alt string `json:"alt,omitempty"` Alt string `json:"alt,omitempty"`
Image *Image `json:"image,omitempty"` Image *Image `json:"image,omitempty"`
AspectRatio *AspectRatio `json:"aspectRatio,omitempty"` AspectRatio *AspectRatio `json:"aspectRatio,omitempty"`
} }
type Media struct { type Media struct {
Type string `json:"$type,omitempty"` Type string `json:"$type,omitempty"`
External *External `json:"external,omitempty"` External *External `json:"external,omitempty"`
Video *Video `json:"video,omitempty"` Video *Video `json:"video,omitempty"`
Images []Images `json:"images,omitempty"` Images *[]Images `json:"images,omitempty"`
AspectRatio *AspectRatio `json:"aspectRatio,omitempty"` AspectRatio *AspectRatio `json:"aspectRatio,omitempty"`
} }
type Record struct { type Record struct {
Cid string `json:"cid,omitempty"` Cid string `json:"cid,omitempty"`
URI string `json:"uri,omitempty"` URI string `json:"uri,omitempty"`
} }
type PostRecord struct { type PostRecord struct {
Type string `json:"$type,omitempty"` Type string `json:"$type,omitempty"`
Cid string `json:"cid,omitempty"` Cid string `json:"cid,omitempty"`
URI string `json:"uri,omitempty"` URI string `json:"uri,omitempty"`
Record *Record `json:"record,omitempty"` Record *Record `json:"record,omitempty"`
} }
type Embed struct { type Embed struct {
Type string `json:"$type,omitempty"` Type string `json:"$type,omitempty"`
Media *Media `json:"media,omitempty"` Media *Media `json:"media,omitempty"`
Images []Images `json:"images,omitempty"` Images *[]Images `json:"images,omitempty"`
Video *Video `json:"video,omitempty"` Video *Video `json:"video,omitempty"`
Record *PostRecord `json:"record,omitempty"` Record *PostRecord `json:"record,omitempty"`
External *External `json:"external,omitempty"` External *External `json:"external,omitempty"`
} }
type Values struct { type Values struct {
Val string `json:"val,omitempty"` Val string `json:"val,omitempty"`
} }
type Labels struct { type Labels struct {
Type string `json:"$type,omitempty"` Type string `json:"$type,omitempty"`
Values []Values `json:"values,omitempty"` Values *[]Values `json:"values,omitempty"`
} }
type Root struct { type Root struct {
Cid string `json:"cid,omitempty"` Cid string `json:"cid,omitempty"`
URI string `json:"uri,omitempty"` URI string `json:"uri,omitempty"`
} }
func (r *Root) GetDID() string {
return strings.Split(r.URI, "/")[2]
}
func (r *Root) GetRKey() string {
return strings.Split(r.URI, "/")[4]
}
type Parent struct { type Parent struct {
Cid string `json:"cid,omitempty"` Cid string `json:"cid,omitempty"`
URI string `json:"uri,omitempty"` URI string `json:"uri,omitempty"`
} }
func (p *Parent) GetDID() string {
return strings.Split(p.URI, "/")[2]
}
func (p *Parent) GetRKey() string {
return strings.Split(p.URI, "/")[4]
}
type Reply struct { type Reply struct {
Root *Root `json:"root,omitempty"` Root *Root `json:"root,omitempty"`
Parent *Parent `json:"parent,omitempty"` Parent *Parent `json:"parent,omitempty"`
} }
type Index struct { type Index struct {
ByteEnd int `json:"byteEnd,omitempty"` ByteEnd int `json:"byteEnd,omitempty"`
ByteStart int `json:"byteStart,omitempty"` ByteStart int `json:"byteStart,omitempty"`
} }
type Features struct { type Features struct {
Did string `json:"did,omitempty"` Did string `json:"did,omitempty"`
URI string `json:"uri,omitempty"` URI string `json:"uri,omitempty"`
Tag string `json:"tag,omitempty"` Tag string `json:"tag,omitempty"`
Type string `json:"$type,omitempty"` Type string `json:"$type,omitempty"`
} }
type Facets struct { type Facets struct {
Type string `json:"$type"` Type string `json:"$type"`
Index *Index `json:"index,omitempty"` Index *Index `json:"index,omitempty"`
Features []Features `json:"features,omitempty"` Features *[]Features `json:"features,omitempty"`
} }
type ParsedEmbeds struct { type ParsedEmbeds struct {
Type string Type string
MimeType string MimeType string
Ref string Ref string
Cid string
URI string URI string
Width int64 Width int64
Height int64 Height int64
} }
type FetchedPost struct {
URI string `json:"uri"`
Cid string `json:"cid"`
Author struct {
Did string `json:"did"`
Handle string `json:"handle"`
DisplayName string `json:"displayName"`
Avatar string `json:"avatar"`
Associated struct {
Chat struct {
AllowIncoming string `json:"allowIncoming"`
} `json:"chat"`
} `json:"associated"`
Labels []interface{} `json:"labels"`
CreatedAt time.Time `json:"createdAt"`
} `json:"author"`
Record *Post `json:"record"`
// Record struct {
// Type string `json:"$type"`
// CreatedAt time.Time `json:"createdAt"`
// Embed struct {
// Type string `json:"$type"`
// Media struct {
// Type string `json:"$type"`
// Images []struct {
// Alt string `json:"alt"`
// AspectRatio struct {
// Height int `json:"height"`
// Width int `json:"width"`
// } `json:"aspectRatio"`
// Image struct {
// Type string `json:"$type"`
// Ref struct {
// Link string `json:"$link"`
// } `json:"ref"`
// MimeType string `json:"mimeType"`
// Size int `json:"size"`
// } `json:"image"`
// } `json:"images"`
// } `json:"media"`
// Record struct {
// Type string `json:"$type"`
// Record struct {
// Cid string `json:"cid"`
// URI string `json:"uri"`
// } `json:"record"`
// } `json:"record"`
// } `json:"embed"`
// Labels struct {
// Type string `json:"$type"`
// Values []struct {
// Val string `json:"val"`
// } `json:"values"`
// } `json:"labels"`
// Langs []string `json:"langs"`
// Text string `json:"text"`
// } `json:"record"`
Embed struct {
Type string `json:"$type"`
Media struct {
Type string `json:"$type"`
Images []struct {
Thumb string `json:"thumb"`
Fullsize string `json:"fullsize"`
Alt string `json:"alt"`
AspectRatio struct {
Height int `json:"height"`
Width int `json:"width"`
} `json:"aspectRatio"`
} `json:"images"`
} `json:"media"`
Record struct {
Record struct {
Type string `json:"$type"`
URI string `json:"uri"`
Cid string `json:"cid"`
Author struct {
Did string `json:"did"`
Handle string `json:"handle"`
DisplayName string `json:"displayName"`
Avatar string `json:"avatar"`
Associated struct {
Chat struct {
AllowIncoming string `json:"allowIncoming"`
} `json:"chat"`
} `json:"associated"`
Labels []interface{} `json:"labels"`
CreatedAt time.Time `json:"createdAt"`
} `json:"author"`
Value struct {
Type string `json:"$type"`
CreatedAt time.Time `json:"createdAt"`
Embed struct {
Type string `json:"$type"`
AspectRatio struct {
Height int `json:"height"`
Width int `json:"width"`
} `json:"aspectRatio"`
Video struct {
Type string `json:"$type"`
Ref struct {
Link string `json:"$link"`
} `json:"ref"`
MimeType string `json:"mimeType"`
Size int `json:"size"`
} `json:"video"`
} `json:"embed"`
Facets []struct {
Type string `json:"$type"`
Features []struct {
Type string `json:"$type"`
Did string `json:"did"`
} `json:"features"`
Index struct {
ByteEnd int `json:"byteEnd"`
ByteStart int `json:"byteStart"`
} `json:"index"`
} `json:"facets"`
Langs []string `json:"langs"`
Text string `json:"text"`
} `json:"value"`
Labels []interface{} `json:"labels"`
LikeCount int `json:"likeCount"`
ReplyCount int `json:"replyCount"`
RepostCount int `json:"repostCount"`
QuoteCount int `json:"quoteCount"`
IndexedAt time.Time `json:"indexedAt"`
Embeds []struct {
Type string `json:"$type"`
Cid string `json:"cid"`
Playlist string `json:"playlist"`
Thumbnail string `json:"thumbnail"`
AspectRatio struct {
Height int `json:"height"`
Width int `json:"width"`
} `json:"aspectRatio"`
} `json:"embeds"`
} `json:"record"`
} `json:"record"`
} `json:"embed,omitempty"`
ReplyCount int `json:"replyCount"`
RepostCount int `json:"repostCount"`
LikeCount int `json:"likeCount"`
QuoteCount int `json:"quoteCount"`
IndexedAt time.Time `json:"indexedAt"`
Labels []struct {
Src string `json:"src"`
URI string `json:"uri"`
Cid string `json:"cid"`
Val string `json:"val"`
Cts time.Time `json:"cts"`
} `json:"labels"`
}
func (b *BSky) ParsePost(post []byte) (*Post, error) { func (b *BSky) ParsePost(post []byte) (*Post, error) {
var p = &Post{} var p = &Post{}
err := json.Unmarshal(post, &p) err := json.Unmarshal(post, &p)
@ -330,136 +139,106 @@ func (post *Post) ProcessFacets(aliases []Records) string {
} }
if post.Facets == nil { if post.Facets == nil {
return html.EscapeString(post.Text) return post.Text
} }
sort.Slice((post.Facets), func(i, j int) bool { sort.Slice((*post.Facets), func(i, j int) bool {
return (post.Facets)[i].Index.ByteStart < (post.Facets)[j].Index.ByteStart return (*post.Facets)[i].Index.ByteStart < (*post.Facets)[j].Index.ByteStart
}) })
var result strings.Builder var result strings.Builder
lastIndex := 0 lastIndex := 0
// post.Text = html.EscapeString(post.Text)
for _, facet := range post.Facets { for _, facet := range *post.Facets {
start := facet.Index.ByteStart start := facet.Index.ByteStart
end := facet.Index.ByteEnd end := facet.Index.ByteEnd
// Escape HTML in plain text portions result.WriteString(post.Text[lastIndex:start])
result.WriteString(html.EscapeString(post.Text[lastIndex:start]))
for _, feature := range facet.Features { for _, feature := range *facet.Features {
switch feature.Type { switch feature.Type {
case "app.bsky.richtext.facet#mention": case "app.bsky.richtext.facet#mention":
link := fmt.Sprintf(`<a href="https://bsky.app/profile/%s">%s</a>`, feature.Did, html.EscapeString(post.Text[start:end])) link := fmt.Sprintf(`<a href="https://bsky.app/profile/%s">%s</a>`, feature.Did, post.Text[start:end])
if aliases != nil {
for _, alias := range aliases { for _, alias := range aliases {
if alias.Value.Subject == feature.Did { if alias.Value.Subject == feature.Did {
parts := strings.SplitN(alias.Value.Target, "#", 2) link = fmt.Sprintf(`<a href="%s">%s</a>`,
if len(parts) == 2 { strings.SplitN(alias.Value.Target, "#", 2)[0], strings.SplitN(alias.Value.Target, "#", 2)[1])
link = fmt.Sprintf(`<a href="%s">%s</a>`, parts[0], parts[1])
} }
} }
} }
result.WriteString(link) result.WriteString(link)
case "app.bsky.richtext.facet#link": case "app.bsky.richtext.facet#link":
uri := strings.Trim(feature.URI, "\"") link := fmt.Sprintf(`<a href="%s">%s</a>`, feature.URI, post.Text[start:end])
link := fmt.Sprintf(`<a href="%s">%s</a>`, uri, html.EscapeString(post.Text[start:end]))
result.WriteString(link) result.WriteString(link)
case "app.bsky.richtext.facet#tag": case "app.bsky.richtext.facet#tag":
link := fmt.Sprintf(`<a href="https://bsky.app/hashtag/%s">%s</a>`, feature.Tag, html.EscapeString(post.Text[start:end])) link := fmt.Sprintf(`<a href="https://bsky.app/search?q=%%23%s">%s</a>`, feature.Tag, post.Text[start:end])
result.WriteString(link) result.WriteString(link)
default: default:
result.WriteString(html.EscapeString(post.Text[start:end])) result.WriteString(post.Text[start:end])
} }
} }
lastIndex = end lastIndex = end
} }
// Escape HTML in the final plain text portion result.WriteString(post.Text[lastIndex:])
result.WriteString(html.EscapeString(post.Text[lastIndex:]))
return result.String() return result.String()
} }
func (p *Post) GetEmbeds() []ParsedEmbeds { func (p *Post) GetEmbeds() *[]ParsedEmbeds {
var parsedEmbeds []ParsedEmbeds var parsedEmbeds = &[]ParsedEmbeds{}
if p.Embed != nil {
if p.Embed == nil { if p.Embed.Video != nil {
return parsedEmbeds parsedEmbed := ParsedEmbeds{
}
switch p.Embed.Type {
case "app.bsky.embed.images":
for _, image := range p.Embed.Images {
if image.Image != nil && image.Image.Ref != nil {
parsedEmbeds = append(parsedEmbeds, ParsedEmbeds{
URI: image.Image.Ref.Link,
Type: "image",
})
}
}
case "app.bsky.embed.video":
if p.Embed.Video != nil && p.Embed.Video.Ref != nil {
parsedEmbeds = append(parsedEmbeds, ParsedEmbeds{
URI: p.Embed.Video.Ref.Link, URI: p.Embed.Video.Ref.Link,
Type: "video", Type: "video",
})
} }
*parsedEmbeds = append(*parsedEmbeds, parsedEmbed)
case "app.bsky.embed.external": }
if p.Embed.External != nil { if p.Embed.External != nil {
t := "external" if strings.Contains(p.Embed.External.URI, "media.tenor.com") {
if strings.Contains(p.Embed.External.URI, "tenor.com") { parsedEmbed := ParsedEmbeds{
t = "gif"
}
parsedEmbeds = append(parsedEmbeds, ParsedEmbeds{
URI: p.Embed.External.URI, URI: p.Embed.External.URI,
Type: t, Type: "external",
})
} }
*parsedEmbeds = append(*parsedEmbeds, parsedEmbed)
case "app.bsky.embed.record":
if p.Embed.Record != nil {
parsedEmbeds = append(parsedEmbeds, ParsedEmbeds{
URI: p.Embed.Record.URI,
Cid: p.Embed.Record.Cid,
Type: "record",
})
} }
case "app.bsky.embed.recordWithMedia":
// Quote post - also extract the media it contains
if p.Embed.Record != nil {
parsedEmbeds = append(parsedEmbeds, ParsedEmbeds{
URI: p.Embed.Record.Record.URI,
Cid: p.Embed.Record.Record.Cid,
Type: "record",
})
} }
if p.Embed.Media != nil { if p.Embed.Media != nil {
if p.Embed.Media.Images != nil { if p.Embed.Media.Images != nil {
for _, image := range p.Embed.Media.Images { for _, image := range *p.Embed.Media.Images {
if image.Image != nil && image.Image.Ref != nil { parsedEmbed := ParsedEmbeds{
parsedEmbeds = append(parsedEmbeds, ParsedEmbeds{
URI: image.Image.Ref.Link, URI: image.Image.Ref.Link,
Type: "image", Type: "image",
}) }
*parsedEmbeds = append(*parsedEmbeds, parsedEmbed)
} }
} }
} if p.Embed.Media.Video != nil {
if p.Embed.Media.Video != nil && p.Embed.Media.Video.Ref != nil { parsedEmbed := ParsedEmbeds{
parsedEmbeds = append(parsedEmbeds, ParsedEmbeds{
URI: p.Embed.Media.Video.Ref.Link, URI: p.Embed.Media.Video.Ref.Link,
Type: "video", Type: "video",
}) }
*parsedEmbeds = append(*parsedEmbeds, parsedEmbed)
} }
if p.Embed.Media.External != nil { if p.Embed.Media.External != nil {
parsedEmbeds = append(parsedEmbeds, ParsedEmbeds{ parsedEmbed := ParsedEmbeds{
URI: p.Embed.Media.External.URI, URI: p.Embed.Media.External.URI,
Type: "external", Type: "external",
}) }
*parsedEmbeds = append(*parsedEmbeds, parsedEmbed)
}
}
if p.Embed.Images != nil {
for _, image := range *p.Embed.Images {
parsedEmbed := ParsedEmbeds{
URI: image.Image.Ref.Link,
Type: "image",
}
*parsedEmbeds = append(*parsedEmbeds, parsedEmbed)
} }
} }
} }
return parsedEmbeds return parsedEmbeds
} }
@ -472,7 +251,7 @@ func (p *Post) GetMedia() *Media {
return nil return nil
} }
func (p *Post) GetMediaImages() []Images { func (p *Post) GetMediaImages() *[]Images {
if p.GetMedia() != nil { if p.GetMedia() != nil {
return p.GetMedia().Images return p.GetMedia().Images
} }

455
main.go
View file

@ -3,8 +3,6 @@ package main
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"flag"
"fmt" "fmt"
"image/jpeg" "image/jpeg"
"io" "io"
@ -13,7 +11,6 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"regexp"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -21,6 +18,7 @@ import (
"git.zio.sh/astra/bsky2tg/bsky" "git.zio.sh/astra/bsky2tg/bsky"
tgbotapi "github.com/OvyFlash/telegram-bot-api" tgbotapi "github.com/OvyFlash/telegram-bot-api"
// apibsky "github.com/bluesky-social/indigo/api/bsky"
"github.com/bluesky-social/jetstream/pkg/client" "github.com/bluesky-social/jetstream/pkg/client"
"github.com/bluesky-social/jetstream/pkg/client/schedulers/sequential" "github.com/bluesky-social/jetstream/pkg/client/schedulers/sequential"
"github.com/bluesky-social/jetstream/pkg/models" "github.com/bluesky-social/jetstream/pkg/models"
@ -29,32 +27,19 @@ import (
) )
const ( const (
// serverAddr = "wss://stream.zio.blue/subscribe"
serverAddr = "wss://jetstream2.us-west.bsky.network/subscribe" serverAddr = "wss://jetstream2.us-west.bsky.network/subscribe"
// serverAddr = "wss://stream.zio.blue/subscribe"
postFormat = "%s\n—\n<a href=\"https://bsky.app/profile/%s/post/%s\">🦋 @%s</a>" postFormat = "%s\n—\n<a href=\"https://bsky.app/profile/%s/post/%s\">🦋 @%s</a>"
quotePostFormat = "<blockquote>%s</blockquote>\n<a href=\"https://bsky.app/profile/%s/post/%s\">➡️ @%s</a>\n—\n<a href=\"https://bsky.app/profile/%s/post/%s\">🦋 @%s</a>" quotePostFormat = "<blockquote>%s</blockquote>\n<a href=\"https://bsky.app/profile/%s/post/%s\">➡️ @%s</a>\n—\n<a href=\"https://bsky.app/profile/%s/post/%s\">🦋 @%s</a>"
embedURL = "https://fxbsky.app/profile/%s/post/%s"
) )
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
openFiles []*os.File
tempFiles []string
} }
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")
botOnly = flag.Bool("bot", false, "Run only the Telegram bot listener, without Jetstream sync")
)
func main() { func main() {
flag.Parse()
var handle = os.Getenv("BSKY_HANDLE") var handle = os.Getenv("BSKY_HANDLE")
var password = os.Getenv("BSKY_PASSWORD") var password = os.Getenv("BSKY_PASSWORD")
bskyClient := bsky.NewBSky() bskyClient := bsky.NewBSky()
@ -63,81 +48,6 @@ func main() {
log.Fatal(err, ". please set BSKY_HANDLE and BSKY_PASSWORD env variables") log.Fatal(err, ". please set BSKY_HANDLE and BSKY_PASSWORD env variables")
} }
h := &handler{
seenSeqs: make(map[int64]struct{}),
bsky: bskyClient,
channelAdmins: make(map[int64]bool),
}
endpoint := "https://api.telegram.org/bot%s/%s"
if os.Getenv("TG_API_ENDPOINT") != "" {
endpoint = os.Getenv("TG_API_ENDPOINT")
}
bot, err := tgbotapi.NewBotAPIWithAPIEndpoint(os.Getenv("TG_TOKEN"), endpoint)
if err != nil {
panic(err)
}
h.tg = bot
if os.Getenv("TG_CHANNEL_ID") == "" {
log.Fatal("TG_CHANNEL_ID is not set")
}
if *post != "" {
r := regexp.MustCompile(`^https:\/\/.*?\/profile\/(.*?)\/post\/(.*?)$`)
s := r.FindStringSubmatch(*post)
handle := s[1]
if s[1][0:4] != "did:" {
handle, _ = bskyClient.ResolveHandle(s[1])
}
if handle != bskyClient.Bluesky.Cfg.DID {
log.Fatal("Unable to send posts from other accounts")
}
tgpost, tgposterr := h.bsky.Bluesky.GetTelegramData(s[2])
if *delete {
if tgposterr == "" {
log.Printf("Found post %s in channel %d, deleting", s[2], tgpost.ChannelID)
m := tgbotapi.NewDeleteMessages(tgpost.ChannelID, tgpost.MessageID)
h.tg.Send(m)
h.bsky.Bluesky.DeleteRecord([]string{s[2], s[1], bsky.PostCollection})
} else {
log.Printf("Unable to find post %s on PDS", s[2])
}
return
}
if tgposterr == "" {
log.Printf("Post %s already sent to channel %d, exiting", s[2], tgpost.ChannelID)
return
}
postJSON := bskyClient.Bluesky.FetchPost(handle, s[2])
p, _ := json.Marshal(postJSON.Record)
h.ProcessPost(&models.Event{
Did: postJSON.Author.Did,
TimeUS: postJSON.Record.CreatedAt.Unix(),
Kind: "",
Commit: &models.Commit{
CID: postJSON.Cid,
Operation: "create",
RKey: strings.Split(postJSON.URI, "/")[4],
Collection: "app.bsky.feed.post",
Record: p,
},
})
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(),
@ -150,6 +60,11 @@ func main() {
config.WantedDids = []string{bskyClient.Bluesky.Cfg.DID} config.WantedDids = []string{bskyClient.Bluesky.Cfg.DID}
config.Compress = true config.Compress = true
h := &handler{
seenSeqs: make(map[int64]struct{}),
bsky: bskyClient,
}
scheduler := sequential.NewScheduler("jetstream_localdev", logger, h.HandleEvent) scheduler := sequential.NewScheduler("jetstream_localdev", logger, h.HandleEvent)
c, err := client.NewClient(config, logger, scheduler) c, err := client.NewClient(config, logger, scheduler)
@ -157,6 +72,57 @@ func main() {
log.Fatalf("failed to create client: %v", err) log.Fatalf("failed to create client: %v", err)
} }
bot, err := tgbotapi.NewBotAPIWithAPIEndpoint(os.Getenv("TG_TOKEN"), "https://bot.astra.blue/bot%s/%s")
if err != nil {
panic(err)
}
h.tg = bot
if os.Getenv("TG_CHANNEL_ID") == "" {
log.Fatal("TG_CHANNEL_ID is not set")
}
// ------------------------------------------------------------------------------
// file, err := os.Open("posts.json")
// if err != nil {
// fmt.Printf("Error opening file: %v\n", err)
// return
// }
// defer file.Close()
// byteValue, err := io.ReadAll(file)
// if err != nil {
// fmt.Printf("Error reading file: %v\n", err)
// return
// }
// var posts = struct {
// Records []struct {
// URI string `json:"uri"`
// CID string `json:"cid"`
// Value *bsky.Post `json:"value"`
// } `json:"records"`
// }{}
// // 4. Unmarshal (decode) the JSON data into the struct
// err = json.Unmarshal(byteValue, &posts)
// if err != nil {
// fmt.Printf("Error unmarshaling JSON: %v\n", err)
// return
// }
// for _, post := range posts.Records {
// log.Printf("post: %s\n", post.Value.ProcessFacets(h.bsky.Bluesky.FetchAliases()))
// s, _ := json.Marshal(post.Value)
// h.ProcessPost(&models.Event{Did: bskyClient.Bluesky.Cfg.DID, Commit: &models.Commit{
// Record: s,
// RKey: strings.Split(post.URI, "/")[4],
// CID: post.CID,
// Collection: "app.bsky.feed.post",
// }})
// time.Sleep(time.Second * 2)
// }
// return
// ------------------------------------------------------------------------------
cursor := time.Now().UnixMicro() cursor := time.Now().UnixMicro()
restartCount := 0 restartCount := 0
loop: loop:
@ -179,225 +145,80 @@ func (h *handler) HandleEvent(ctx context.Context, event *models.Event) error {
return nil return nil
} }
switch event.Commit.Operation { if event.Commit.Operation == models.CommitOperationCreate ||
case models.CommitOperationCreate, models.CommitOperationUpdate: event.Commit.Operation == models.CommitOperationUpdate {
h.bsky.Bluesky.Cfg.Cursor = event.TimeUS + 1 // +1 to not show same post h.bsky.Bluesky.Cfg.Cursor = event.TimeUS + 1 // +1 to not show same post
bsky.PersistAuthSession(h.bsky.Bluesky.Cfg) bsky.PersistAuthSession(h.bsky.Bluesky.Cfg)
h.ProcessPost(event) h.ProcessPost(event)
case models.CommitOperationDelete: } else if event.Commit.Operation == models.CommitOperationDelete {
h.bsky.Bluesky.Cfg.Cursor = event.TimeUS + 1 // +1 to not show same post h.bsky.Bluesky.Cfg.Cursor = event.TimeUS + 1 // +1 to not show same post
bsky.PersistAuthSession(h.bsky.Bluesky.Cfg) bsky.PersistAuthSession(h.bsky.Bluesky.Cfg)
r, e := h.bsky.Bluesky.GetTelegramData(event.Commit.RKey) r, e := h.bsky.Bluesky.GetTelegramData(event.Commit.RKey)
if e == "" { if e == "" {
m := tgbotapi.NewDeleteMessages(r.ChannelID, r.MessageID) m := tgbotapi.NewDeleteMessage(r.ChannelID, r.MessageID)
h.tg.Send(m) h.tg.Send(m)
h.bsky.Bluesky.DeleteRecord([]string{event.Commit.RKey, event.Did, bsky.PostCollection}) h.bsky.Bluesky.DeleteRecord([]string{event.Commit.RKey, event.Did, event.Commit.Collection})
} }
} }
return nil return nil
} }
func (h *handler) handleVideo(media bsky.ParsedEmbeds) (tgbotapi.InputMedia, error) {
url := buildBlobURL(h.bsky.Bluesky.Cfg.PDSURL, h.bsky.Bluesky.Cfg.DID, media.URI)
log.Printf("Fetching video: %s\n", url)
resp, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("failed to fetch video: %w", err)
}
defer resp.Body.Close()
filename := media.URI + ".mp4"
f, err := os.Create(filename)
if err != nil {
return nil, fmt.Errorf("failed to create video file: %w", err)
}
if _, err := io.Copy(f, resp.Body); err != nil {
f.Close()
os.Remove(filename)
return nil, fmt.Errorf("failed to write video: %w", err)
}
if _, err := f.Seek(0, 0); err != nil {
f.Close()
os.Remove(filename)
return nil, fmt.Errorf("failed to seek video: %w", err)
}
metadata, err := getVideoMetadata(f.Name())
if err != nil {
f.Close()
os.Remove(filename)
return nil, fmt.Errorf("failed to read video metadata: %w", err)
}
frames, _ := metadata.ReadFrames(0)
var buf bytes.Buffer
jpeg.Encode(&buf, frames[0], &jpeg.Options{Quality: 90})
// Keep file open for SendMediaGroup to read, cleanup happens in ProcessPost after sending
mediaAdd := tgbotapi.NewInputMediaVideo(tgbotapi.FileReader{Name: "video.mp4", Reader: f})
mediaAdd.SupportsStreaming = true
mediaAdd.Height = metadata.Height()
mediaAdd.Width = metadata.Width()
mediaAdd.Duration = int(metadata.Duration())
mediaAdd.Thumb = tgbotapi.FileBytes{Name: "thumb.jpg", Bytes: buf.Bytes()}
// Store file reference for cleanup after sending
h.openFiles = append(h.openFiles, f)
h.tempFiles = append(h.tempFiles, filename)
return &mediaAdd, nil
}
func (h *handler) cleanupFiles() {
for _, f := range h.openFiles {
f.Close()
}
for _, filename := range h.tempFiles {
os.Remove(filename)
}
h.openFiles = nil
h.tempFiles = nil
}
func (h *handler) ProcessPost(event *models.Event) error { func (h *handler) ProcessPost(event *models.Event) error {
ps, _ := h.bsky.ParsePost(event.Commit.Record) ps, _ := h.bsky.ParsePost(event.Commit.Record)
po := ps.GetEmbeds() po := ps.GetEmbeds()
cid, _ := strconv.ParseInt(os.Getenv("TG_CHANNEL_ID"), 10, 64) cid, _ := strconv.ParseInt(os.Getenv("TG_CHANNEL_ID"), 10, 64)
isEditedPost := false
now := time.Now() if ps.IsReply() { //|| ps.IsQuotePost() {
createdAt := ps.CreatedAt // don't want to post replies to channel
duration := now.Sub(createdAt)
if duration.Hours() > *oldPosts ||
strings.HasPrefix(ps.Text, "@") ||
ps.IsReply() {
return nil return nil
} }
telegramRecord, telegramRecordErr := h.bsky.Bluesky.GetTelegramData(event.Commit.RKey)
if telegramRecordErr == "" {
isEditedPost = true
}
aliases := h.bsky.Bluesky.FetchAliases()
facets := ps.ProcessFacets(aliases)
ownHandle, handleErr := h.bsky.GetHandleFromDID(h.bsky.Bluesky.Cfg.DID)
if handleErr != nil {
ownHandle = h.bsky.Bluesky.Cfg.Handle
}
var captionText string var captionText string
if ps.IsQuotePost() { if ps.IsQuotePost() {
var quotedURI string if ps.Embed.Record.Type == "app.bsky.embed.record" {
if ps.Embed.Record != nil && ps.Embed.Record.Record != nil && ps.Embed.Record.Record.URI != "" { handle, _ := h.bsky.GetHandleFromDID(strings.Split(ps.Embed.Record.Record.URI, "/")[2])
quotedURI = ps.Embed.Record.Record.URI
} else if ps.Embed.Record != nil && ps.Embed.Record.URI != "" {
quotedURI = ps.Embed.Record.URI
}
if quotedURI != "" {
parts := strings.Split(quotedURI, "/")
if len(parts) >= 5 {
handle, _ := h.bsky.GetHandleFromDID(parts[2])
captionText = fmt.Sprintf( captionText = fmt.Sprintf(
quotePostFormat, quotePostFormat,
facets, ps.ProcessFacets(h.bsky.Bluesky.FetchAliases()),
parts[2], strings.Split(ps.Embed.Record.Record.URI, "/")[2],
parts[4], strings.Split(ps.Embed.Record.Record.URI, "/")[4],
handle, handle,
event.Did, event.Did,
event.Commit.RKey, event.Commit.RKey,
ownHandle) h.bsky.Bluesky.Cfg.Handle)
} } else {
handle, _ := h.bsky.GetHandleFromDID(strings.Split(ps.Embed.Record.URI, "/")[2])
captionText = fmt.Sprintf(
quotePostFormat,
ps.ProcessFacets(h.bsky.Bluesky.FetchAliases()),
strings.Split(ps.Embed.Record.URI, "/")[2],
strings.Split(ps.Embed.Record.URI, "/")[4],
handle,
event.Did,
event.Commit.RKey,
h.bsky.Bluesky.Cfg.Handle)
} }
} }
if captionText == "" { if captionText == "" {
if facets != "" { if ps.ProcessFacets(h.bsky.Bluesky.FetchAliases()) != "" {
captionText = fmt.Sprintf(postFormat, facets, h.bsky.Bluesky.Cfg.DID, event.Commit.RKey, ownHandle) captionText = fmt.Sprintf(postFormat, ps.ProcessFacets(h.bsky.Bluesky.FetchAliases()), h.bsky.Bluesky.Cfg.DID, event.Commit.RKey, h.bsky.Bluesky.Cfg.Handle)
} else { } else {
captionText = fmt.Sprintf("<a href=\"https://bsky.app/profile/%s/post/%s\">🦋 @%s</a>", h.bsky.Bluesky.Cfg.DID, event.Commit.RKey, ownHandle) captionText = fmt.Sprintf("<a href=\"https://bsky.app/profile/%s/post/%s\">🦋 @%s</a>", h.bsky.Bluesky.Cfg.DID, event.Commit.RKey, h.bsky.Bluesky.Cfg.Handle)
} }
} }
hasActualMedia := false // post has media
for _, embed := range po { if len((*po)) != 0 {
if embed.Type != "record" {
hasActualMedia = true
break
}
}
if hasActualMedia {
mediaGroup := []tgbotapi.InputMedia{} mediaGroup := []tgbotapi.InputMedia{}
if (*po)[0].Type == "external" {
if ps.Embed.Type == "app.bsky.embed.recordWithMedia" { tenorGif := tgbotapi.NewInputMediaVideo(tgbotapi.FileURL((*po)[0].URI)) // is most likely gif from Tenor
hasExternal := false
for _, media := range po {
if media.Type == "external" {
hasExternal = true
break
}
}
if hasExternal && ps.Embed.Media != nil && ps.Embed.Media.External != nil {
// Send as text message with webpage preview
m := tgbotapi.NewMessage(cid, captionText)
m.ParseMode = tgbotapi.ModeHTML
m.LinkPreviewOptions = tgbotapi.LinkPreviewOptions{
IsDisabled: false,
URL: ps.Embed.Media.External.URI,
PreferLargeMedia: true,
ShowAboveText: true,
}
resp, _ := h.tg.Send(m)
uri, postCid := getLink(event)
h.bsky.Bluesky.CommitTelegramResponse(&bsky.TelegramRecord{
ChannelID: resp.Chat.ID,
MessageID: []int{resp.MessageID},
Link: &bsky.Link{
Cid: postCid,
URI: uri,
},
}, event.Commit.RKey)
return nil
}
// recordWithMedia with images or video (not external) — fall through to normal media handling
for _, media := range po {
switch media.Type {
case "record":
continue
case "image":
mediaAdd := tgbotapi.NewInputMediaPhoto(tgbotapi.FileURL(buildBlobURL(h.bsky.Bluesky.Cfg.PDSURL, h.bsky.Bluesky.Cfg.DID, media.URI)))
if len(mediaGroup) == 0 {
mediaAdd.Caption = captionText
mediaAdd.ParseMode = tgbotapi.ModeHTML
}
mediaGroup = append(mediaGroup, &mediaAdd)
case "video":
mediaAdd, err := h.handleVideo(media)
if err != nil {
log.Printf("Failed to handle video: %s\n", err)
break
}
if len(mediaGroup) == 0 {
setCaption(mediaAdd, captionText)
}
mediaGroup = append(mediaGroup, mediaAdd)
}
}
} else if po[0].Type == "external" {
tenorGif := tgbotapi.NewInputMediaVideo(tgbotapi.FileURL(po[0].URI))
tenorGif.Caption = captionText tenorGif.Caption = captionText
tenorGif.ParseMode = tgbotapi.ModeHTML tenorGif.ParseMode = tgbotapi.ModeHTML
mediaGroup = append(mediaGroup, &tenorGif) mediaGroup = append(mediaGroup, &tenorGif)
} else { } else {
for _, media := range po { for _, media := range *po {
switch media.Type { switch media.Type {
case "image": case "image":
mediaAdd := tgbotapi.NewInputMediaPhoto(tgbotapi.FileURL(buildBlobURL(h.bsky.Bluesky.Cfg.PDSURL, h.bsky.Bluesky.Cfg.DID, media.URI))) mediaAdd := tgbotapi.NewInputMediaPhoto(tgbotapi.FileURL(buildBlobURL(h.bsky.Bluesky.Cfg.PDSURL, h.bsky.Bluesky.Cfg.DID, media.URI)))
@ -407,83 +228,78 @@ func (h *handler) ProcessPost(event *models.Event) error {
} }
mediaGroup = append(mediaGroup, &mediaAdd) mediaGroup = append(mediaGroup, &mediaAdd)
case "video": case "video":
mediaAdd, err := h.handleVideo(media) log.Printf("Fetching video: %s\n", buildBlobURL(h.bsky.Bluesky.Cfg.PDSURL, h.bsky.Bluesky.Cfg.DID, media.URI))
resp, _ := http.Get(buildBlobURL(h.bsky.Bluesky.Cfg.PDSURL, h.bsky.Bluesky.Cfg.DID, media.URI))
defer resp.Body.Close()
f, _ := os.Create(media.URI + ".mp4")
defer f.Close()
io.Copy(f, resp.Body)
f.Seek(0, 0)
mediaAdd := tgbotapi.NewInputMediaVideo(tgbotapi.FileReader{Name: "video.mp4", Reader: f})
metadata, err := getVideoMetadata(f.Name())
if err != nil { if err != nil {
log.Printf("Failed to handle video: %s\n", err) log.Printf("Unable to read video metadata: %s\n", buildBlobURL(h.bsky.Bluesky.Cfg.PDSURL, h.bsky.Bluesky.Cfg.DID, media.URI))
break break
} }
if len(mediaGroup) == 0 { mediaAdd.SupportsStreaming = true
setCaption(mediaAdd, captionText) mediaAdd.Height = metadata.Height()
} mediaAdd.Width = metadata.Width()
mediaGroup = append(mediaGroup, mediaAdd) mediaAdd.Duration = int(metadata.Duration())
}
}
}
frames, _ := metadata.ReadFrames(0)
var buf bytes.Buffer
jpeg.Encode(&buf, frames[0], &jpeg.Options{Quality: 90})
mediaAdd.Thumb = tgbotapi.FileBytes{Name: "thumb.jpg", Bytes: buf.Bytes()}
if len(mediaGroup) == 0 {
mediaAdd.Caption = captionText
mediaAdd.ParseMode = tgbotapi.ModeHTML
}
os.Remove(media.URI + ".mp4")
mediaGroup = append(mediaGroup, &mediaAdd)
}
}
}
if len(mediaGroup) == 0 { if len(mediaGroup) == 0 {
log.Print("No mediaGroup to send, see previous error") log.Print("No mediaGroup to send, see previous error")
} else { } else {
if isEditedPost { resp, _ := h.tg.SendMediaGroup(tgbotapi.NewMediaGroup(cid, mediaGroup))
h.tg.Send(tgbotapi.NewEditMessageCaption(telegramRecord.ChannelID, telegramRecord.MessageID[0], captionText)) uri, cid := getLink(event)
} else {
resp, err := h.tg.SendMediaGroup(tgbotapi.NewMediaGroup(cid, mediaGroup))
fmt.Println(err)
h.cleanupFiles()
uri, postCid := getLink(event)
var messageIDs []int
for _, msgID := range resp {
messageIDs = append(messageIDs, msgID.MessageID)
}
h.bsky.Bluesky.CommitTelegramResponse(&bsky.TelegramRecord{ h.bsky.Bluesky.CommitTelegramResponse(&bsky.TelegramRecord{
ChannelID: resp[0].Chat.ID, ChannelID: resp[0].Chat.ID,
MessageID: messageIDs, MessageID: resp[0].MessageID,
Link: &bsky.Link{ Link: &bsky.Link{
Cid: postCid, Cid: cid,
URI: uri, URI: uri,
}, },
}, event.Commit.RKey) }, event.Commit.RKey)
} }
}
} else { } else {
m := tgbotapi.MessageConfig{} m := tgbotapi.MessageConfig{}
if captionText == "" { if captionText == "" {
m = tgbotapi.NewMessage(cid, fmt.Sprintf(postFormat, facets, h.bsky.Bluesky.Cfg.DID, event.Commit.RKey, h.bsky.Bluesky.Cfg.Handle)) m = tgbotapi.NewMessage(cid, fmt.Sprintf(postFormat, ps.ProcessFacets(h.bsky.Bluesky.FetchAliases()), h.bsky.Bluesky.Cfg.DID, event.Commit.RKey, h.bsky.Bluesky.Cfg.Handle))
} else { } else {
m = tgbotapi.NewMessage(cid, captionText) m = tgbotapi.NewMessage(cid, captionText)
} }
m.ParseMode = tgbotapi.ModeHTML m.ParseMode = tgbotapi.ModeHTML
if ps.IsQuotePost() { if ps.IsQuotePost() {
var previewURI string
if ps.Embed.Record != nil && ps.Embed.Record.Record != nil {
previewURI = fmt.Sprintf(embedURL,
strings.Split(ps.Embed.Record.Record.URI, "/")[2],
strings.Split(ps.Embed.Record.Record.URI, "/")[4])
} else if ps.Embed.Record != nil {
previewURI = fmt.Sprintf(embedURL,
strings.Split(ps.Embed.Record.URI, "/")[2],
strings.Split(ps.Embed.Record.URI, "/")[4])
}
m.LinkPreviewOptions = tgbotapi.LinkPreviewOptions{ m.LinkPreviewOptions = tgbotapi.LinkPreviewOptions{
IsDisabled: false, IsDisabled: false,
URL: previewURI, URL: fmt.Sprintf("https://bsky.app/profile/%s/post/%s",
strings.Split(ps.Embed.Record.URI, "/")[2],
strings.Split(ps.Embed.Record.URI, "/")[4]),
PreferSmallMedia: true, PreferSmallMedia: true,
PreferLargeMedia: false,
ShowAboveText: true, ShowAboveText: true,
} }
} else { } else {
m.LinkPreviewOptions = tgbotapi.LinkPreviewOptions{IsDisabled: true} m.LinkPreviewOptions = tgbotapi.LinkPreviewOptions{IsDisabled: true}
} }
resp, e := h.tg.Send(m) resp, _ := h.tg.Send(m)
if e != nil { uri, cid := getLink(event)
log.Printf("Failed to send message: %s\n", e)
return e
}
uri, postCid := getLink(event)
h.bsky.Bluesky.CommitTelegramResponse(&bsky.TelegramRecord{ h.bsky.Bluesky.CommitTelegramResponse(&bsky.TelegramRecord{
ChannelID: resp.Chat.ID, ChannelID: resp.Chat.ID,
MessageID: []int{resp.MessageID}, MessageID: resp.MessageID,
Link: &bsky.Link{ Link: &bsky.Link{
Cid: postCid, Cid: cid,
URI: uri, URI: uri,
}, },
}, event.Commit.RKey) }, event.Commit.RKey)
@ -491,19 +307,8 @@ func (h *handler) ProcessPost(event *models.Event) error {
return nil return nil
} }
func setCaption(media tgbotapi.InputMedia, caption string) {
switch m := media.(type) {
case *tgbotapi.InputMediaVideo:
m.Caption = caption
m.ParseMode = tgbotapi.ModeHTML
case *tgbotapi.InputMediaPhoto:
m.Caption = caption
m.ParseMode = tgbotapi.ModeHTML
}
}
func buildBlobURL(server string, did string, cid string) string { func buildBlobURL(server string, did string, cid string) string {
return server + "/xrpc/com.atproto.sync.getBlob?did=" + url.QueryEscape(did) + "&cid=" + cid return server + "/xrpc/com.atproto.sync.getBlob?did=" + url.QueryEscape(did) + "&cid=" + url.QueryEscape(cid)
} }
func getLink(event *models.Event) (string, string) { func getLink(event *models.Event) (string, string) {