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