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