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