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