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