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 likes.each do |r|
45 like_uri = r['uri']
46 post_uri = r['value']['subject']['uri']
47 repo, collection, rkey = post_uri.split('/')[2..4]
48
49 next unless repo == @bluesky.did && collection == 'app.bsky.feed.post'
50
51 begin
52 record = @bluesky.fetch_record(repo, collection, rkey)
53 rescue Minisky::ClientErrorResponse => e
54 puts "Record not found: #{post_uri}"
55 @bluesky.delete_record_at(like_uri)
56 next
57 end
58
59 if record['value']['reply']
60 puts "Skipping reply"
61 @bluesky.delete_record_at(like_uri)
62 next
63 end
64
65 post_to_mastodon(record['value'])
66
67 @bluesky.delete_record_at(like_uri)
68 end
69 end
70
71 def watch
72 loop do
73 sync
74 sleep @check_interval
75 end
76 end
77
78 def post_to_mastodon(record)
79 p record
80
81 text = expand_facets(record)
82
83 if link = link_embed(record)
84 append_link(text, link) unless text.include?(link)
85 end
86
87 if quote_uri = quoted_post(record)
88 repo, collection, rkey = quote_uri.split('/')[2..4]
89
90 if collection == 'app.bsky.feed.post'
91 bsky_url = bsky_post_link(repo, rkey)
92 append_link(text, bsky_url) unless text.include?(bsky_url)
93 end
94 end
95
96 if images = attached_images(record)
97 media_ids = []
98
99 images.each do |image|
100 alt = image['alt']
101 cid = image['image']['ref']['$link']
102 mime = image['image']['mimeType']
103
104 data = @bluesky.fetch_blob(cid)
105
106 uploaded_media = @mastodon.upload_media(data, cid, mime, alt)
107 media_ids << uploaded_media['id']
108 end
109 end
110
111 if tags = record['tags']
112 text += "\n\n" + tags.map { |t| '#' + t.gsub(' ', '') }.join(' ')
113 end
114
115 p @mastodon.post_status(text, media_ids)
116 end
117
118 def expand_facets(record)
119 bytes = record['text'].bytes
120 offset = 0
121
122 if facets = record['facets']
123 facets.sort_by { |f| f['index']['byteStart'] }.each do |f|
124 if link = f['features'].detect { |ft| ft['$type'] == 'app.bsky.richtext.facet#link' }
125 left = f['index']['byteStart']
126 right = f['index']['byteEnd']
127 content = link['uri'].bytes
128
129 bytes[(left + offset) ... (right + offset)] = content
130 offset += content.length - (right - left)
131 end
132 end
133 end
134
135 bytes.pack('C*').force_encoding('UTF-8')
136 end
137
138 def link_embed(record)
139 if embed = record['embed']
140 case embed['$type']
141 when 'app.bsky.embed.external'
142 embed['external']['uri']
143 when 'app.bsky.embed.recordWithMedia'
144 embed['media']['external'] && embed['media']['external']['uri']
145 else
146 nil
147 end
148 end
149 end
150
151 def quoted_post(record)
152 if embed = record['embed']
153 case embed['$type']
154 when 'app.bsky.embed.record'
155 embed['record']['uri']
156 when 'app.bsky.embed.recordWithMedia'
157 embed['record']['record']['uri']
158 else
159 nil
160 end
161 end
162 end
163
164 def attached_images(record)
165 if embed = record['embed']
166 case embed['$type']
167 when 'app.bsky.embed.images'
168 embed['images']
169 when 'app.bsky.embed.recordWithMedia'
170 embed['media']['images']
171 else
172 nil
173 end
174 end
175 end
176
177 def append_link(text, link)
178 text << ' ' unless text.end_with?(' ')
179 text << link
180 end
181
182 def bsky_post_link(repo, rkey)
183 "https://bsky.app/profile/#{repo}/post/#{rkey}"
184 end
185end