Toot toooooooot (Bluesky-Mastodon cross-poster)
1require 'io/console'
2require 'yaml'
3
4require_relative 'bluesky_account'
5require_relative 'database'
6require_relative 'mastodon_account'
7require_relative 'post'
8
9class Tootify
10 CONFIG_FILE = File.expand_path(File.join(__dir__, '..', 'config', 'tootify.yml'))
11
12 attr_accessor :check_interval
13
14 def initialize
15 @bluesky = BlueskyAccount.new
16 @mastodon = MastodonAccount.new
17 @config = load_config
18 @check_interval = 60
19
20 Database.init
21 end
22
23 def load_config
24 if File.exist?(CONFIG_FILE)
25 YAML.load(File.read(CONFIG_FILE))
26 else
27 {}
28 end
29 end
30
31 def login_to_bluesky(handle)
32 handle = handle.gsub(/^@/, '')
33
34 print "App password: "
35 password = STDIN.noecho(&:gets).chomp
36 puts
37
38 @bluesky.login_with_password(handle, password)
39 end
40
41 def login_to_mastodon(handle)
42 @mastodon.oauth_login(handle)
43 end
44
45 def sync
46 begin
47 likes = @bluesky.fetch_likes
48 rescue Minisky::ExpiredTokenError => e
49 @bluesky.log_in
50 likes = @bluesky.fetch_likes
51 end
52
53 records = []
54
55 likes.each do |r|
56 like_uri = r['uri']
57 post_uri = r['value']['subject']['uri']
58 repo, collection, rkey = post_uri.split('/')[2..4]
59
60 next unless repo == @bluesky.did && collection == 'app.bsky.feed.post'
61
62 if post = Post.find_by(bluesky_rkey: rkey)
63 log "Post #{rkey} was already cross-posted, skipping"
64 @bluesky.delete_record_at(like_uri)
65 next
66 end
67
68 begin
69 record = @bluesky.fetch_record(repo, collection, rkey)
70 rescue Minisky::ClientErrorResponse => e
71 log "Record not found: #{post_uri}"
72 @bluesky.delete_record_at(like_uri)
73 next
74 end
75
76 if reply = record['value']['reply']
77 parent_uri = reply['parent']['uri']
78 prepo = parent_uri.split('/')[2]
79
80 if prepo != @bluesky.did
81 log "Skipping reply to someone else"
82 @bluesky.delete_record_at(like_uri)
83 next
84 else
85 # self-reply, we'll try to cross-post it
86 end
87 end
88
89 records << [record['value'], rkey, like_uri]
90 end
91
92 records.sort_by { |x| x[0]['createdAt'] }.each do |record, rkey, like_uri|
93 mastodon_parent_id = nil
94
95 if reply = record['reply']
96 parent_uri = reply['parent']['uri']
97 parent_rkey = parent_uri.split('/')[4]
98
99 if parent_post = Post.find_by(bluesky_rkey: parent_rkey)
100 mastodon_parent_id = parent_post.mastodon_id
101 else
102 log "Skipping reply to a post that wasn't cross-posted"
103 @bluesky.delete_record_at(like_uri)
104 next
105 end
106 end
107
108 response = post_to_mastodon(record, mastodon_parent_id)
109 log(response)
110
111 Post.create!(bluesky_rkey: rkey, mastodon_id: response['id'])
112
113 @bluesky.delete_record_at(like_uri)
114 end
115 end
116
117 def watch
118 loop do
119 sync
120 sleep @check_interval
121 end
122 end
123
124 def post_to_mastodon(record, mastodon_parent_id = nil)
125 log(record)
126
127 text = expand_facets(record)
128
129 if link = link_embed(record)
130 append_link(text, link) unless text.include?(link)
131 end
132
133 if quote_uri = quoted_post(record)
134 repo, collection, rkey = quote_uri.split('/')[2..4]
135
136 if collection == 'app.bsky.feed.post'
137 link_to_append = bsky_post_link(repo, rkey)
138 instance_info = @mastodon.instance_info
139
140 if instance_info.dig('api_versions', 'mastodon').to_i >= 7
141 quoted_record = fetch_record_by_at_uri(quote_uri)
142
143 # TODO: we need to wait for Bridgy to add support for quote_authorizations
144 quoted_post_url = quoted_record['bridgyOriginalUrl'] #|| "https://bsky.brid.gy/convert/ap/#{quote_uri}"
145
146 if quoted_post_url && (local_post = @mastodon.search_post_by_url(quoted_post_url))
147 quote_policy = local_post.dig('quote_approval', 'current_user')
148
149 if quote_policy == 'automatic' || quote_policy == 'manual'
150 quote_id = local_post['id']
151 end
152 end
153 end
154
155 if !quote_id && @config['extract_link_from_quotes']
156 quoted_record ||= fetch_record_by_at_uri(quote_uri)
157 quote_link = link_embed(quoted_record)
158
159 if quote_link.nil?
160 text_links = links_from_facets(quoted_record)
161 quote_link = text_links.first if text_links.length == 1
162 end
163
164 if quote_link
165 link_to_append = quote_link
166 end
167 end
168
169 append_link(text, link_to_append) unless quote_id || text.include?(link_to_append)
170 end
171 end
172
173 if images = attached_images(record)
174 media_ids = []
175
176 images.each do |embed|
177 alt = embed['alt']
178 cid = embed['image']['ref']['$link']
179 mime = embed['image']['mimeType']
180
181 if alt && alt.length > @mastodon.max_alt_length
182 alt = alt[0...@mastodon.max_alt_length - 3] + "(…)"
183 end
184
185 data = @bluesky.fetch_blob(cid)
186
187 uploaded_media = @mastodon.upload_media(data, cid, mime, alt)
188 media_ids << uploaded_media['id']
189 end
190 elsif embed = attached_video(record)
191 alt = embed['alt']
192 cid = embed['video']['ref']['$link']
193 mime = embed['video']['mimeType']
194
195 if alt && alt.length > @mastodon.max_alt_length
196 alt = alt[0...@mastodon.max_alt_length - 3] + "(…)"
197 end
198
199 data = @bluesky.fetch_blob(cid)
200
201 uploaded_media = @mastodon.upload_media(data, cid, mime, alt)
202 media_ids = [uploaded_media['id']]
203 end
204
205 if tags = record['tags']
206 text += "\n\n" + tags.map { |t| '#' + t.gsub(' ', '') }.join(' ')
207 end
208
209 @mastodon.post_status(text, media_ids: media_ids, parent_id: mastodon_parent_id, quoted_status_id: quote_id)
210 end
211
212 def fetch_record_by_at_uri(quote_uri)
213 repo, collection, rkey = quote_uri.split('/')[2..4]
214 pds = DID.new(repo).document.pds_host
215 sky = Minisky.new(pds, nil)
216 resp = sky.get_request('com.atproto.repo.getRecord', { repo: repo, collection: collection, rkey: rkey })
217 resp['value']
218 end
219
220 def expand_facets(record)
221 bytes = record['text'].bytes
222 offset = 0
223
224 if facets = record['facets']
225 facets.sort_by { |f| f['index']['byteStart'] }.each do |f|
226 if link = f['features'].detect { |ft| ft['$type'] == 'app.bsky.richtext.facet#link' }
227 left = f['index']['byteStart']
228 right = f['index']['byteEnd']
229 content = link['uri'].bytes
230
231 bytes[(left + offset) ... (right + offset)] = content
232 offset += content.length - (right - left)
233 end
234 end
235 end
236
237 bytes.pack('C*').force_encoding('UTF-8')
238 end
239
240 def links_from_facets(record)
241 links = []
242
243 if facets = record['facets']
244 facets.each do |f|
245 if link = f['features'].detect { |ft| ft['$type'] == 'app.bsky.richtext.facet#link' }
246 links << link['uri']
247 end
248 end
249 end
250
251 links.reject { |x| x.start_with?('https://bsky.app/hashtag/') }
252 end
253
254 def link_embed(record)
255 if embed = record['embed']
256 case embed['$type']
257 when 'app.bsky.embed.external'
258 embed['external']['uri']
259 when 'app.bsky.embed.recordWithMedia'
260 embed['media']['external'] && embed['media']['external']['uri']
261 else
262 nil
263 end
264 end
265 end
266
267 def quoted_post(record)
268 if embed = record['embed']
269 case embed['$type']
270 when 'app.bsky.embed.record'
271 embed['record']['uri']
272 when 'app.bsky.embed.recordWithMedia'
273 embed['record']['record']['uri']
274 else
275 nil
276 end
277 end
278 end
279
280 def attached_images(record)
281 if embed = record['embed']
282 case embed['$type']
283 when 'app.bsky.embed.images'
284 embed['images']
285 when 'app.bsky.embed.recordWithMedia'
286 if embed['media']['$type'] == 'app.bsky.embed.images'
287 embed['media']['images']
288 else
289 nil
290 end
291 else
292 nil
293 end
294 end
295 end
296
297 def attached_video(record)
298 if embed = record['embed']
299 case embed['$type']
300 when 'app.bsky.embed.video'
301 embed
302 when 'app.bsky.embed.recordWithMedia'
303 if embed['media']['$type'] == 'app.bsky.embed.video'
304 embed['media']
305 else
306 nil
307 end
308 else
309 nil
310 end
311 end
312 end
313
314 def append_link(text, link)
315 if link =~ %r{^https://bsky\.app/profile/.+/post/.+}
316 text << "\n" unless text.end_with?("\n")
317 text << "\n"
318 text << "RE: " + link
319 else
320 text << ' ' unless text.end_with?(' ')
321 text << link
322 end
323 end
324
325 def bsky_post_link(repo, rkey)
326 "https://bsky.app/profile/#{repo}/post/#{rkey}"
327 end
328
329 def log(obj)
330 text = obj.is_a?(String) ? obj : obj.inspect
331 puts "[#{Time.now}] #{text}"
332 end
333end