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 puts "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 puts "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 puts "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 puts "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 p 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 p 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
139 if @config['extract_link_from_quotes']
140 quoted_record = fetch_record_by_at_uri(quote_uri)
141
142 quote_link = link_embed(quoted_record)
143
144 if quote_link.nil?
145 text_links = links_from_facets(quoted_record)
146 quote_link = text_links.first if text_links.length == 1
147 end
148
149 if quote_link
150 link_to_append = quote_link
151 end
152 end
153
154 append_link(text, link_to_append) unless text.include?(link_to_append)
155 end
156 end
157
158 if images = attached_images(record)
159 media_ids = []
160
161 images.each do |embed|
162 alt = embed['alt']
163 cid = embed['image']['ref']['$link']
164 mime = embed['image']['mimeType']
165
166 if alt && alt.length > @mastodon.max_alt_length
167 alt = alt[0...@mastodon.max_alt_length - 3] + "(…)"
168 end
169
170 data = @bluesky.fetch_blob(cid)
171
172 uploaded_media = @mastodon.upload_media(data, cid, mime, alt)
173 media_ids << uploaded_media['id']
174 end
175 elsif embed = attached_video(record)
176 alt = embed['alt']
177 cid = embed['video']['ref']['$link']
178 mime = embed['video']['mimeType']
179
180 if alt && alt.length > @mastodon.max_alt_length
181 alt = alt[0...@mastodon.max_alt_length - 3] + "(…)"
182 end
183
184 data = @bluesky.fetch_blob(cid)
185
186 uploaded_media = @mastodon.upload_media(data, cid, mime, alt)
187 media_ids = [uploaded_media['id']]
188 end
189
190 if tags = record['tags']
191 text += "\n\n" + tags.map { |t| '#' + t.gsub(' ', '') }.join(' ')
192 end
193
194 @mastodon.post_status(text, media_ids, mastodon_parent_id)
195 end
196
197 def fetch_record_by_at_uri(quote_uri)
198 repo, collection, rkey = quote_uri.split('/')[2..4]
199 pds = DID.new(repo).get_document.pds_endpoint
200 sky = Minisky.new(pds, nil)
201 resp = sky.get_request('com.atproto.repo.getRecord', { repo: repo, collection: collection, rkey: rkey })
202 resp['value']
203 end
204
205 def expand_facets(record)
206 bytes = record['text'].bytes
207 offset = 0
208
209 if facets = record['facets']
210 facets.sort_by { |f| f['index']['byteStart'] }.each do |f|
211 if link = f['features'].detect { |ft| ft['$type'] == 'app.bsky.richtext.facet#link' }
212 left = f['index']['byteStart']
213 right = f['index']['byteEnd']
214 content = link['uri'].bytes
215
216 bytes[(left + offset) ... (right + offset)] = content
217 offset += content.length - (right - left)
218 end
219 end
220 end
221
222 bytes.pack('C*').force_encoding('UTF-8')
223 end
224
225 def links_from_facets(record)
226 links = []
227
228 if facets = record['facets']
229 facets.each do |f|
230 if link = f['features'].detect { |ft| ft['$type'] == 'app.bsky.richtext.facet#link' }
231 links << link['uri']
232 end
233 end
234 end
235
236 links.reject { |x| x.start_with?('https://bsky.app/hashtag/') }
237 end
238
239 def link_embed(record)
240 if embed = record['embed']
241 case embed['$type']
242 when 'app.bsky.embed.external'
243 embed['external']['uri']
244 when 'app.bsky.embed.recordWithMedia'
245 embed['media']['external'] && embed['media']['external']['uri']
246 else
247 nil
248 end
249 end
250 end
251
252 def quoted_post(record)
253 if embed = record['embed']
254 case embed['$type']
255 when 'app.bsky.embed.record'
256 embed['record']['uri']
257 when 'app.bsky.embed.recordWithMedia'
258 embed['record']['record']['uri']
259 else
260 nil
261 end
262 end
263 end
264
265 def attached_images(record)
266 if embed = record['embed']
267 case embed['$type']
268 when 'app.bsky.embed.images'
269 embed['images']
270 when 'app.bsky.embed.recordWithMedia'
271 if embed['media']['$type'] == 'app.bsky.embed.images'
272 embed['media']['images']
273 else
274 nil
275 end
276 else
277 nil
278 end
279 end
280 end
281
282 def attached_video(record)
283 if embed = record['embed']
284 case embed['$type']
285 when 'app.bsky.embed.video'
286 embed
287 when 'app.bsky.embed.recordWithMedia'
288 if embed['media']['$type'] == 'app.bsky.embed.video'
289 embed['media']
290 else
291 nil
292 end
293 else
294 nil
295 end
296 end
297 end
298
299 def append_link(text, link)
300 if link =~ %r{^https://bsky\.app/profile/.+/post/.+}
301 text << "\n" unless text.end_with?("\n")
302 text << "\n"
303 text << "RE: " + link
304 else
305 text << ' ' unless text.end_with?(' ')
306 text << link
307 end
308 end
309
310 def bsky_post_link(repo, rkey)
311 "https://bsky.app/profile/#{repo}/post/#{rkey}"
312 end
313end