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 p @mastodon.post_status(text)
86 end
87
88 def expand_facets(record)
89 bytes = record['text'].bytes
90 offset = 0
91
92 if facets = record['facets']
93 facets.sort_by { |f| f['index']['byteStart'] }.each do |f|
94 if link = f['features'].detect { |ft| ft['$type'] == 'app.bsky.richtext.facet#link' }
95 left = f['index']['byteStart']
96 right = f['index']['byteEnd']
97 content = link['uri'].bytes
98
99 bytes[(left + offset) ... (right + offset)] = content
100 offset += content.length - (right - left)
101 end
102 end
103 end
104
105 bytes.pack('C*').force_encoding('UTF-8')
106 end
107
108 def link_embed(record)
109 if embed = record['embed']
110 case embed['$type']
111 when 'app.bsky.embed.external'
112 embed['external']['uri']
113 when 'app.bsky.embed.recordWithMedia'
114 embed['media']['external'] && embed['media']['external']['uri']
115 else
116 nil
117 end
118 end
119 end
120
121 def quoted_post(record)
122 if embed = record['embed']
123 case embed['$type']
124 when 'app.bsky.embed.record'
125 embed['record']['uri']
126 when 'app.bsky.embed.recordWithMedia'
127 embed['record']['record']['uri']
128 else
129 nil
130 end
131 end
132 end
133
134 def append_link(text, link)
135 text << ' ' unless text.end_with?(' ')
136 text << link
137 end
138
139 def bsky_post_link(repo, rkey)
140 "https://bsky.app/profile/#{repo}/post/#{rkey}"
141 end
142end