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 begin
63 record = @bluesky.fetch_record(repo, collection, rkey)
64 rescue Minisky::ClientErrorResponse => e
65 puts "Record not found: #{post_uri}"
66 @bluesky.delete_record_at(like_uri)
67 next
68 end
69
70 if reply = record['value']['reply']
71 parent_uri = reply['parent']['uri']
72 prepo = parent_uri.split('/')[2]
73
74 if prepo != @bluesky.did
75 puts "Skipping reply to someone else"
76 @bluesky.delete_record_at(like_uri)
77 next
78 else
79 # self-reply, we'll try to cross-post it
80 end
81 end
82
83 records << [record['value'], rkey, like_uri]
84 end
85
86 records.sort_by { |x| x[0]['createdAt'] }.each do |record, rkey, like_uri|
87 mastodon_parent_id = nil
88
89 if reply = record['reply']
90 parent_uri = reply['parent']['uri']
91 parent_rkey = parent_uri.split('/')[4]
92
93 if parent_post = Post.find_by(bluesky_rkey: parent_rkey)
94 mastodon_parent_id = parent_post.mastodon_id
95 else
96 puts "Skipping reply to a post that wasn't cross-posted"
97 @bluesky.delete_record_at(like_uri)
98 next
99 end
100 end
101
102 response = post_to_mastodon(record, mastodon_parent_id)
103 p response
104
105 Post.create!(bluesky_rkey: rkey, mastodon_id: response['id'])
106
107 @bluesky.delete_record_at(like_uri)
108 end
109 end
110
111 def watch
112 loop do
113 sync
114 sleep @check_interval
115 end
116 end
117
118 def post_to_mastodon(record, mastodon_parent_id = nil)
119 p record
120
121 text = expand_facets(record)
122
123 if link = link_embed(record)
124 append_link(text, link) unless text.include?(link)
125 end
126
127 if quote_uri = quoted_post(record)
128 repo, collection, rkey = quote_uri.split('/')[2..4]
129
130 if collection == 'app.bsky.feed.post'
131 link_to_append = bsky_post_link(repo, rkey)
132
133 if @config['extract_link_from_quotes']
134 quoted_record = fetch_record_by_at_uri(quote_uri)
135
136 quote_link = link_embed(quoted_record)
137
138 if quote_link.nil?
139 text_links = links_from_facets(quoted_record)
140 quote_link = text_links.first if text_links.length == 1
141 end
142
143 if quote_link
144 link_to_append = quote_link
145 end
146 end
147
148 append_link(text, link_to_append) unless text.include?(link_to_append)
149 end
150 end
151
152 if images = attached_images(record)
153 media_ids = []
154
155 images.each do |embed|
156 alt = embed['alt']
157 cid = embed['image']['ref']['$link']
158 mime = embed['image']['mimeType']
159
160 if alt && alt.length > @mastodon.max_alt_length
161 alt = alt[0...@mastodon.max_alt_length - 3] + "(…)"
162 end
163
164 data = @bluesky.fetch_blob(cid)
165
166 uploaded_media = @mastodon.upload_media(data, cid, mime, alt)
167 media_ids << uploaded_media['id']
168 end
169 elsif embed = attached_video(record)
170 alt = embed['alt']
171 cid = embed['video']['ref']['$link']
172 mime = embed['video']['mimeType']
173
174 if alt && alt.length > @mastodon.max_alt_length
175 alt = alt[0...@mastodon.max_alt_length - 3] + "(…)"
176 end
177
178 data = @bluesky.fetch_blob(cid)
179
180 uploaded_media = @mastodon.upload_media(data, cid, mime, alt)
181 media_ids = [uploaded_media['id']]
182 end
183
184 if tags = record['tags']
185 text += "\n\n" + tags.map { |t| '#' + t.gsub(' ', '') }.join(' ')
186 end
187
188 @mastodon.post_status(text, media_ids, mastodon_parent_id)
189 end
190
191 def fetch_record_by_at_uri(quote_uri)
192 repo, collection, rkey = quote_uri.split('/')[2..4]
193 pds = DID.new(repo).get_document.pds_endpoint
194 sky = Minisky.new(pds, nil)
195 resp = sky.get_request('com.atproto.repo.getRecord', { repo: repo, collection: collection, rkey: rkey })
196 resp['value']
197 end
198
199 def expand_facets(record)
200 bytes = record['text'].bytes
201 offset = 0
202
203 if facets = record['facets']
204 facets.sort_by { |f| f['index']['byteStart'] }.each do |f|
205 if link = f['features'].detect { |ft| ft['$type'] == 'app.bsky.richtext.facet#link' }
206 left = f['index']['byteStart']
207 right = f['index']['byteEnd']
208 content = link['uri'].bytes
209
210 bytes[(left + offset) ... (right + offset)] = content
211 offset += content.length - (right - left)
212 end
213 end
214 end
215
216 bytes.pack('C*').force_encoding('UTF-8')
217 end
218
219 def links_from_facets(record)
220 links = []
221
222 if facets = record['facets']
223 facets.each do |f|
224 if link = f['features'].detect { |ft| ft['$type'] == 'app.bsky.richtext.facet#link' }
225 links << link['uri']
226 end
227 end
228 end
229
230 links.reject { |x| x.start_with?('https://bsky.app/hashtag/') }
231 end
232
233 def link_embed(record)
234 if embed = record['embed']
235 case embed['$type']
236 when 'app.bsky.embed.external'
237 embed['external']['uri']
238 when 'app.bsky.embed.recordWithMedia'
239 embed['media']['external'] && embed['media']['external']['uri']
240 else
241 nil
242 end
243 end
244 end
245
246 def quoted_post(record)
247 if embed = record['embed']
248 case embed['$type']
249 when 'app.bsky.embed.record'
250 embed['record']['uri']
251 when 'app.bsky.embed.recordWithMedia'
252 embed['record']['record']['uri']
253 else
254 nil
255 end
256 end
257 end
258
259 def attached_images(record)
260 if embed = record['embed']
261 case embed['$type']
262 when 'app.bsky.embed.images'
263 embed['images']
264 when 'app.bsky.embed.recordWithMedia'
265 if embed['media']['$type'] == 'app.bsky.embed.images'
266 embed['media']['images']
267 else
268 nil
269 end
270 else
271 nil
272 end
273 end
274 end
275
276 def attached_video(record)
277 if embed = record['embed']
278 case embed['$type']
279 when 'app.bsky.embed.video'
280 embed
281 when 'app.bsky.embed.recordWithMedia'
282 if embed['media']['$type'] == 'app.bsky.embed.video'
283 embed['media']
284 else
285 nil
286 end
287 else
288 nil
289 end
290 end
291 end
292
293 def append_link(text, link)
294 if link =~ %r{^https://bsky\.app/profile/.+/post/.+}
295 text << "\n" unless text.end_with?("\n")
296 text << "\n"
297 text << "RE: " + link
298 else
299 text << ' ' unless text.end_with?(' ')
300 text << link
301 end
302 end
303
304 def bsky_post_link(repo, rkey)
305 "https://bsky.app/profile/#{repo}/post/#{rkey}"
306 end
307end