Compare commits

..

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

8 changed files with 177 additions and 693 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
- 📸 **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
### Usage
## Setup
Create a `.env` file with the following:
### Prerequisites
```properties
TG_TOKEN=
TG_CHANNEL_ID=
BSKY_HANDLE=
BSKY_PASSWORD=
```
- Go 1.21+
- A Bluesky account
- A Telegram bot and channel
To run:
### 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
podman run -it --name bsky2tg_<profile> \
--env-file /path/to/.env \
git.zio.sh/astra/bsky2tg:latest
```
### With environment variables
Or without `.env` file:
```bash
podman run -it --name bsky2tg_<profile> \
--env TG_TOKEN=<your-token> \
--env TG_CHANNEL_ID=<your-channel-id> \
--env BSKY_HANDLE=<your.handle> \
--env BSKY_PASSWORD=<your-app-password> \
--env TG_TOKEN= \
--env TG_CHANNEL_ID= \
--env BSKY_HANDLE= \
--env BSKY_PASSWORD= \
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

@ -10,17 +10,6 @@ import (
"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 {
PDSURL string `json:"pds-url"`
Repo string `json:"repo"`
@ -83,11 +72,10 @@ type Link struct {
}
type Bluesky struct {
Cfg *BlueskyConfig
HttpClient *http.Client
Logger *log.Logger
sling *sling.Sling
publicSling *sling.Sling
Cfg *BlueskyConfig
HttpClient *http.Client
Logger *log.Logger
sling *sling.Sling
}
func (bluesky *Bluesky) CreateSession(cfg *BlueskyConfig) error {
@ -100,18 +88,18 @@ func (bluesky *Bluesky) CreateSession(cfg *BlueskyConfig) error {
}
resp := new(BSkySessionResponse)
bluesky.sling.New().Client(bluesky.HttpClient).
Post("/xrpc/com.atproto.server.createSession").BodyJSON(body).ReceiveSuccess(resp)
bluesky.sling.New().Post("/xrpc/com.atproto.server.createSession").BodyJSON(body).ReceiveSuccess(resp)
if resp.AccessJWT != "" {
cfg.AccessJWT = resp.AccessJWT
cfg.RefreshJWT = resp.RefreshJWT
return nil
}
bluesky.sling.New().Set("Authorization", fmt.Sprintf("Bearer %s", bluesky.Cfg.AccessJWT))
return errors.New("unable to authenticate, check handle/password")
}
func (bluesky *Bluesky) RefreshSession() {
func (bluesky *Bluesky) RefreshSession() error {
resp := new(BSkySessionResponse)
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
PersistAuthSession(bluesky.Cfg)
bluesky.sling.Set("Authorization", fmt.Sprintf("Bearer %s", bluesky.Cfg.AccessJWT))
return
}
if resp.Error != "" {
log.Fatalf("RefreshSession error: %s", resp.Message)
return nil
}
bluesky.CreateSession(bluesky.Cfg)
return bluesky.CreateSession(bluesky.Cfg)
}
func (bluesky *Bluesky) CheckSessionValid() {
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)).
Get("/xrpc/app.bsky.actor.getProfile").QueryStruct(params).Receive(resp, resp)
if resp.Error != "" {
Get("/xrpc/app.bsky.actor.getProfile").Receive(resp, resp)
if resp.Error == "ExpiredToken" {
bluesky.RefreshSession()
}
}
type TelegramRecord struct {
ChannelID int64 `json:"channel_id"`
MessageID []int `json:"message_id"`
MessageID int `json:"message_id"`
Link *Link `json:"link"`
Error string `json:"error"`
Message string `json:"message"`
@ -173,7 +152,7 @@ func (bluesky *Bluesky) CommitTelegramResponse(data *TelegramRecord, rkey string
Record TelegramRecord `json:"record"`
}{
Repo: bluesky.Cfg.DID,
Collection: PostCollection,
Collection: "blue.zio.bsky2tg.post",
RKey: rkey,
Record: TelegramRecord{
ChannelID: data.ChannelID,
@ -205,12 +184,12 @@ func (bluesky *Bluesky) GetTelegramData(rkey string) (*TelegramRecord, string) {
RKey string `url:"rkey"`
}{
Repo: bluesky.Cfg.DID,
Collection: PostCollection,
Collection: "blue.zio.bsky2tg.post",
RKey: rkey,
}
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 {
@ -227,9 +206,9 @@ func (bluesky *Bluesky) GetPost(uri string) *Post {
Repo string `url:"repo"`
Collection string `url:"collection"`
}{
RKey: args[uriRkeyIndex],
Repo: args[uriRepoIndex],
Collection: args[uriCollectionIndex],
RKey: args[4],
Repo: args[2],
Collection: args[3],
}
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"`
}{
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)
@ -284,16 +263,3 @@ type Records struct {
Cid string `json:"cid"`
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.sling.New().Get("/xrpc/app.bsky.feed.getPosts").QueryStruct(&params).Receive(resp, resp)
return resp.Posts[0]
}

View file

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

View file

@ -3,7 +3,6 @@ package bsky
import (
"encoding/json"
"fmt"
"html"
"sort"
"strings"
"time"
@ -19,50 +18,42 @@ type Post struct {
Facets *[]Facets `json:"facets,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
type Ref struct {
Link string `json:"$link,omitempty"`
}
type Thumb struct {
Type string `json:"$type,omitempty"`
Ref *Ref `json:"ref,omitempty"`
MimeType string `json:"mimeType,omitempty"`
Size int `json:"size,omitempty"`
}
type External struct {
URI string `json:"uri,omitempty"`
Thumb *Thumb `json:"thumb,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
}
type Video struct {
Type string `json:"$type,omitempty"`
Ref *Ref `json:"ref,omitempty"`
MimeType string `json:"mimeType,omitempty"`
Size int `json:"size,omitempty"`
}
type Image struct {
Type string `json:"$type,omitempty"`
Ref *Ref `json:"ref,omitempty"`
MimeType string `json:"mimeType,omitempty"`
Size int `json:"size,omitempty"`
}
type AspectRatio struct {
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
}
type Images struct {
Alt string `json:"alt,omitempty"`
Image *Image `json:"image,omitempty"`
AspectRatio *AspectRatio `json:"aspectRatio,omitempty"`
}
type Media struct {
Type string `json:"$type,omitempty"`
External *External `json:"external,omitempty"`
@ -70,19 +61,16 @@ type Media struct {
Images *[]Images `json:"images,omitempty"`
AspectRatio *AspectRatio `json:"aspectRatio,omitempty"`
}
type Record struct {
Cid string `json:"cid,omitempty"`
URI string `json:"uri,omitempty"`
}
type PostRecord struct {
Type string `json:"$type,omitempty"`
Cid string `json:"cid,omitempty"`
URI string `json:"uri,omitempty"`
Record *Record `json:"record,omitempty"`
}
type Embed struct {
Type string `json:"$type,omitempty"`
Media *Media `json:"media,omitempty"`
@ -91,59 +79,35 @@ type Embed struct {
Record *PostRecord `json:"record,omitempty"`
External *External `json:"external,omitempty"`
}
type Values struct {
Val string `json:"val,omitempty"`
}
type Labels struct {
Type string `json:"$type,omitempty"`
Values *[]Values `json:"values,omitempty"`
}
type Root struct {
Cid string `json:"cid,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 {
Cid string `json:"cid,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 {
Root *Root `json:"root,omitempty"`
Parent *Parent `json:"parent,omitempty"`
}
type Index struct {
ByteEnd int `json:"byteEnd,omitempty"`
ByteStart int `json:"byteStart,omitempty"`
}
type Features struct {
Did string `json:"did,omitempty"`
URI string `json:"uri,omitempty"`
Tag string `json:"tag,omitempty"`
Type string `json:"$type,omitempty"`
}
type Facets struct {
Type string `json:"$type"`
Index *Index `json:"index,omitempty"`
@ -154,166 +118,11 @@ type ParsedEmbeds struct {
Type string
MimeType string
Ref string
Cid string
URI string
Width 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) {
var p = &Post{}
err := json.Unmarshal(post, &p)
@ -330,7 +139,7 @@ func (post *Post) ProcessFacets(aliases []Records) string {
}
if post.Facets == nil {
return html.EscapeString(post.Text)
return post.Text
}
sort.Slice((*post.Facets), func(i, j int) bool {
@ -339,39 +148,40 @@ func (post *Post) ProcessFacets(aliases []Records) string {
var result strings.Builder
lastIndex := 0
// post.Text = html.EscapeString(post.Text)
for _, facet := range *post.Facets {
start := facet.Index.ByteStart
end := facet.Index.ByteEnd
// Escape HTML in plain text portions
result.WriteString(html.EscapeString(post.Text[lastIndex:start]))
result.WriteString(post.Text[lastIndex:start])
for _, feature := range *facet.Features {
switch feature.Type {
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]))
for _, alias := range aliases {
if alias.Value.Subject == feature.Did {
link = fmt.Sprintf(`<a href="%s">%s</a>`,
strings.SplitN(alias.Value.Target, "#", 2)[0], strings.SplitN(alias.Value.Target, "#", 2)[1])
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 {
if alias.Value.Subject == feature.Did {
link = fmt.Sprintf(`<a href="%s">%s</a>`,
strings.SplitN(alias.Value.Target, "#", 2)[0], strings.SplitN(alias.Value.Target, "#", 2)[1])
}
}
}
result.WriteString(link)
case "app.bsky.richtext.facet#link":
link := fmt.Sprintf(`<a href="%s">%s</a>`, feature.URI, html.EscapeString(post.Text[start:end]))
link := fmt.Sprintf(`<a href="%s">%s</a>`, feature.URI, post.Text[start:end])
result.WriteString(link)
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)
default:
result.WriteString(html.EscapeString(post.Text[start:end]))
result.WriteString(post.Text[start:end])
}
}
lastIndex = end
}
// Escape HTML in the final plain text portion
result.WriteString(html.EscapeString(post.Text[lastIndex:]))
result.WriteString(post.Text[lastIndex:])
return result.String()
}

227
main.go
View file

@ -3,8 +3,6 @@ package main
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"image/jpeg"
"io"
@ -13,7 +11,6 @@ import (
"net/http"
"net/url"
"os"
"regexp"
"strconv"
"strings"
"time"
@ -21,6 +18,7 @@ import (
"git.zio.sh/astra/bsky2tg/bsky"
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/schedulers/sequential"
"github.com/bluesky-social/jetstream/pkg/models"
@ -29,11 +27,10 @@ import (
)
const (
serverAddr = "wss://jetstream2.us-west.bsky.network/subscribe"
// serverAddr = "wss://stream.zio.blue/subscribe"
serverAddr = "wss://jetstream2.us-west.bsky.network/subscribe"
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>"
embedURL = "https://fxbsky.app/profile/%s/post/%s"
)
type handler struct {
@ -42,15 +39,7 @@ type handler struct {
bsky *bsky.BSky
}
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")
)
func main() {
flag.Parse()
var handle = os.Getenv("BSKY_HANDLE")
var password = os.Getenv("BSKY_PASSWORD")
bskyClient := bsky.NewBSky()
@ -59,73 +48,6 @@ func main() {
log.Fatal(err, ". please set BSKY_HANDLE and BSKY_PASSWORD env variables")
}
h := &handler{
seenSeqs: make(map[int64]struct{}),
bsky: bskyClient,
}
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
}
ctx := context.Background()
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug.Level(),
@ -138,6 +60,11 @@ func main() {
config.WantedDids = []string{bskyClient.Bluesky.Cfg.DID}
config.Compress = true
h := &handler{
seenSeqs: make(map[int64]struct{}),
bsky: bskyClient,
}
scheduler := sequential.NewScheduler("jetstream_localdev", logger, h.HandleEvent)
c, err := client.NewClient(config, logger, scheduler)
@ -145,6 +72,57 @@ func main() {
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()
restartCount := 0
loop:
@ -167,19 +145,19 @@ func (h *handler) HandleEvent(ctx context.Context, event *models.Event) error {
return nil
}
switch event.Commit.Operation {
case models.CommitOperationCreate, models.CommitOperationUpdate:
if event.Commit.Operation == models.CommitOperationCreate ||
event.Commit.Operation == models.CommitOperationUpdate {
h.bsky.Bluesky.Cfg.Cursor = event.TimeUS + 1 // +1 to not show same post
bsky.PersistAuthSession(h.bsky.Bluesky.Cfg)
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
bsky.PersistAuthSession(h.bsky.Bluesky.Cfg)
r, e := h.bsky.Bluesky.GetTelegramData(event.Commit.RKey)
if e == "" {
m := tgbotapi.NewDeleteMessages(r.ChannelID, r.MessageID)
m := tgbotapi.NewDeleteMessage(r.ChannelID, r.MessageID)
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})
}
}
@ -190,65 +168,44 @@ func (h *handler) ProcessPost(event *models.Event) error {
ps, _ := h.bsky.ParsePost(event.Commit.Record)
po := ps.GetEmbeds()
cid, _ := strconv.ParseInt(os.Getenv("TG_CHANNEL_ID"), 10, 64)
isEditedPost := false
now := time.Now()
createdAt := ps.CreatedAt
duration := now.Sub(createdAt)
if duration.Hours() > *oldPosts ||
strings.HasPrefix(ps.Text, "@") ||
ps.IsReply() {
if ps.IsReply() { //|| ps.IsQuotePost() {
// don't want to post replies to channel
return nil
}
telegramRecord, telegramRecordErr := h.bsky.Bluesky.GetTelegramData(event.Commit.RKey)
if telegramRecordErr == "" {
isEditedPost = true
}
aliases := h.bsky.Bluesky.FetchAliases()
facets := ps.ProcessFacets(aliases)
var captionText string
if ps.IsQuotePost() {
ownHandle, handleErr := h.bsky.GetHandleFromDID(h.bsky.Bluesky.Cfg.DID)
if handleErr != nil {
ownHandle = h.bsky.Bluesky.Cfg.Handle
}
if ps.Embed.Record.Type == "app.bsky.embed.record" {
handle, _ := h.bsky.GetHandleFromDID(strings.Split(ps.Embed.Record.Record.URI, "/")[2])
captionText = fmt.Sprintf(
quotePostFormat,
facets,
ps.ProcessFacets(h.bsky.Bluesky.FetchAliases()),
strings.Split(ps.Embed.Record.Record.URI, "/")[2],
strings.Split(ps.Embed.Record.Record.URI, "/")[4],
handle,
event.Did,
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,
facets,
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,
ownHandle)
h.bsky.Bluesky.Cfg.Handle)
}
}
if captionText == "" {
ownHandle, handleErr := h.bsky.GetHandleFromDID(h.bsky.Bluesky.Cfg.DID)
if handleErr != nil {
ownHandle = h.bsky.Bluesky.Cfg.Handle
}
if facets != "" {
captionText = fmt.Sprintf(postFormat, facets, h.bsky.Bluesky.Cfg.DID, event.Commit.RKey, ownHandle)
if ps.ProcessFacets(h.bsky.Bluesky.FetchAliases()) != "" {
captionText = fmt.Sprintf(postFormat, ps.ProcessFacets(h.bsky.Bluesky.FetchAliases()), h.bsky.Bluesky.Cfg.DID, event.Commit.RKey, h.bsky.Bluesky.Cfg.Handle)
} 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)
}
}
@ -281,7 +238,7 @@ func (h *handler) ProcessPost(event *models.Event) error {
mediaAdd := tgbotapi.NewInputMediaVideo(tgbotapi.FileReader{Name: "video.mp4", Reader: f})
metadata, err := getVideoMetadata(f.Name())
if err != nil {
log.Printf("Unable to read video metadata: %s - URL: %s\n", err, buildBlobURL(h.bsky.Bluesky.Cfg.PDSURL, h.bsky.Bluesky.Cfg.DID, media.URI))
log.Printf("Unable to read video metadata: %s\n", buildBlobURL(h.bsky.Bluesky.Cfg.PDSURL, h.bsky.Bluesky.Cfg.DID, media.URI))
break
}
mediaAdd.SupportsStreaming = true
@ -305,30 +262,21 @@ func (h *handler) ProcessPost(event *models.Event) error {
if len(mediaGroup) == 0 {
log.Print("No mediaGroup to send, see previous error")
} else {
if isEditedPost {
resp, err := h.tg.Send(tgbotapi.NewEditMessageCaption(telegramRecord.ChannelID, telegramRecord.MessageID[0], captionText))
fmt.Println(resp, err)
} else {
resp, _ := h.tg.SendMediaGroup(tgbotapi.NewMediaGroup(cid, mediaGroup))
uri, cid := getLink(event)
var messageIDs []int
for _, msgID := range resp {
messageIDs = append(messageIDs, msgID.MessageID)
}
h.bsky.Bluesky.CommitTelegramResponse(&bsky.TelegramRecord{
ChannelID: resp[0].Chat.ID,
MessageID: messageIDs,
Link: &bsky.Link{
Cid: cid,
URI: uri,
},
}, event.Commit.RKey)
}
resp, _ := h.tg.SendMediaGroup(tgbotapi.NewMediaGroup(cid, mediaGroup))
uri, cid := getLink(event)
h.bsky.Bluesky.CommitTelegramResponse(&bsky.TelegramRecord{
ChannelID: resp[0].Chat.ID,
MessageID: resp[0].MessageID,
Link: &bsky.Link{
Cid: cid,
URI: uri,
},
}, event.Commit.RKey)
}
} else {
m := tgbotapi.MessageConfig{}
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 {
m = tgbotapi.NewMessage(cid, captionText)
}
@ -336,11 +284,10 @@ func (h *handler) ProcessPost(event *models.Event) error {
if ps.IsQuotePost() {
m.LinkPreviewOptions = tgbotapi.LinkPreviewOptions{
IsDisabled: false,
URL: fmt.Sprintf(embedURL,
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,
PreferLargeMedia: false,
ShowAboveText: true,
}
} else {
@ -350,7 +297,7 @@ func (h *handler) ProcessPost(event *models.Event) error {
uri, cid := getLink(event)
h.bsky.Bluesky.CommitTelegramResponse(&bsky.TelegramRecord{
ChannelID: resp.Chat.ID,
MessageID: []int{resp.MessageID},
MessageID: resp.MessageID,
Link: &bsky.Link{
Cid: cid,
URI: uri,
@ -361,7 +308,7 @@ func (h *handler) ProcessPost(event *models.Event) error {
}
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) {