Toot toooooooot (Bluesky-Mastodon cross-poster)
1require 'io/console'
2
3require_relative 'bluesky_account'
4require_relative 'mastodon_account'
5
6class Tootify
7 attr_accessor :check_interval
8
9 def initialize
10 @bluesky = BlueskyAccount.new
11 @mastodon = MastodonAccount.new
12 @check_interval = 60
13 end
14
15 def login_bluesky(handle)
16 handle = handle.gsub(/^@/, '')
17
18 print "App password: "
19 password = STDIN.noecho(&:gets).chomp
20 puts
21
22 @bluesky.login_with_password(handle, password)
23 end
24
25 def login_mastodon(handle)
26 print "Email: "
27 email = STDIN.gets.chomp
28
29 print "Password: "
30 password = STDIN.noecho(&:gets).chomp
31 puts
32
33 @mastodon.oauth_login(handle, email, password)
34 end
35
36 def sync
37 likes = @bluesky.fetch_likes
38
39 likes.each do |r|
40 like_uri = r['uri']
41 post_uri = r['value']['subject']['uri']
42 repo, collection, rkey = post_uri.split('/')[2..4]
43
44 next unless repo == @bluesky.did && collection == 'app.bsky.feed.post'
45
46 begin
47 record = @bluesky.fetch_record(repo, collection, rkey)
48 rescue Minisky::ClientErrorResponse => e
49 puts "Record not found: #{post_uri}"
50 @bluesky.delete_record_at(like_uri)
51 next
52 end
53
54 if record['value']['reply']
55 puts "Skipping reply"
56 @bluesky.delete_record_at(like_uri)
57 next
58 end
59
60 post_to_mastodon(record['value'])
61
62 @bluesky.delete_record_at(like_uri)
63 end
64 end
65
66 def watch
67 loop do
68 sync
69 sleep @check_interval
70 end
71 end
72
73 def post_to_mastodon(record)
74 p record
75
76 text = expand_facets(record)
77
78 if link = link_embed(record)
79 append_link(text, link) unless text.include?(link)
80 end
81
82 if quote_uri = quoted_post(record)
83 repo, collection, rkey = quote_uri.split('/')[2..4]
84
85 if collection == 'app.bsky.feed.post'
86 bsky_url = bsky_post_link(repo, rkey)
87 append_link(text, bsky_url) unless text.include?(bsky_url)
88 end
89 end
90
91 if images = attached_images(record)
92 media_ids = []
93
94 images.each do |image|
95 alt = image['alt']
96 cid = image['image']['ref']['$link']
97 mime = image['image']['mimeType']
98
99 data = @bluesky.fetch_blob(cid)
100
101 uploaded_media = @mastodon.upload_media(data, cid, mime, alt)
102 media_ids << uploaded_media['id']
103 end
104 end
105
106 p @mastodon.post_status(text, media_ids)
107 end
108
109 def expand_facets(record)
110 bytes = record['text'].bytes
111 offset = 0
112
113 if facets = record['facets']
114 facets.sort_by { |f| f['index']['byteStart'] }.each do |f|
115 if link = f['features'].detect { |ft| ft['$type'] == 'app.bsky.richtext.facet#link' }
116 left = f['index']['byteStart']
117 right = f['index']['byteEnd']
118 content = link['uri'].bytes
119
120 bytes[(left + offset) ... (right + offset)] = content
121 offset += content.length - (right - left)
122 end
123 end
124 end
125
126 bytes.pack('C*').force_encoding('UTF-8')
127 end
128
129 def link_embed(record)
130 if embed = record['embed']
131 case embed['$type']
132 when 'app.bsky.embed.external'
133 embed['external']['uri']
134 when 'app.bsky.embed.recordWithMedia'
135 embed['media']['external'] && embed['media']['external']['uri']
136 else
137 nil
138 end
139 end
140 end
141
142 def quoted_post(record)
143 if embed = record['embed']
144 case embed['$type']
145 when 'app.bsky.embed.record'
146 embed['record']['uri']
147 when 'app.bsky.embed.recordWithMedia'
148 embed['record']['record']['uri']
149 else
150 nil
151 end
152 end
153 end
154
155 def attached_images(record)
156 if embed = record['embed']
157 case embed['$type']
158 when 'app.bsky.embed.images'
159 embed['images']
160 when 'app.bsky.embed.recordWithMedia'
161 embed['media']['images']
162 else
163 nil
164 end
165 end
166 end
167
168 def append_link(text, link)
169 text << ' ' unless text.end_with?(' ')
170 text << link
171 end
172
173 def bsky_post_link(repo, rkey)
174 "https://bsky.app/profile/#{repo}/post/#{rkey}"
175 end
176end