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