+1
-1
.gitignore
+1
-1
.gitignore
+3
Gemfile
+3
Gemfile
+49
-8
Gemfile.lock
+49
-8
Gemfile.lock
···
1
GEM
2
remote: https://rubygems.org/
3
specs:
4
-
base64 (0.2.0)
5
-
didkit (0.2.3)
6
-
io-console (0.7.2)
7
-
json (2.10.2)
8
minisky (0.5.0)
9
base64 (~> 0.1)
10
-
net-http (0.4.1)
11
-
uri
12
-
uri (0.13.2)
13
-
yaml (0.3.0)
14
15
PLATFORMS
16
aarch64-linux
···
19
x86_64-linux
20
21
DEPENDENCIES
22
didkit (~> 0.2)
23
io-console (~> 0.5)
24
json (~> 2.5)
25
minisky (~> 0.5)
26
net-http (~> 0.2)
27
uri (~> 0.13)
28
yaml (~> 0.1)
29
···
1
GEM
2
remote: https://rubygems.org/
3
specs:
4
+
activemodel (7.2.3)
5
+
activesupport (= 7.2.3)
6
+
activerecord (7.2.3)
7
+
activemodel (= 7.2.3)
8
+
activesupport (= 7.2.3)
9
+
timeout (>= 0.4.0)
10
+
activesupport (7.2.3)
11
+
base64
12
+
benchmark (>= 0.3)
13
+
bigdecimal
14
+
concurrent-ruby (~> 1.0, >= 1.3.1)
15
+
connection_pool (>= 2.2.5)
16
+
drb
17
+
i18n (>= 1.6, < 2)
18
+
logger (>= 1.4.2)
19
+
minitest (>= 5.1)
20
+
securerandom (>= 0.3)
21
+
tzinfo (~> 2.0, >= 2.0.5)
22
+
base64 (0.3.0)
23
+
benchmark (0.5.0)
24
+
bigdecimal (4.0.1)
25
+
concurrent-ruby (1.3.6)
26
+
connection_pool (3.0.2)
27
+
didkit (0.3.1)
28
+
drb (2.2.3)
29
+
i18n (1.14.8)
30
+
concurrent-ruby (~> 1.0)
31
+
io-console (0.8.2)
32
+
json (2.18.0)
33
+
logger (1.7.0)
34
+
mini_portile2 (2.8.9)
35
minisky (0.5.0)
36
base64 (~> 0.1)
37
+
minitest (6.0.1)
38
+
prism (~> 1.5)
39
+
net-http (0.9.1)
40
+
uri (>= 0.11.1)
41
+
prism (1.7.0)
42
+
securerandom (0.4.1)
43
+
sqlite3 (2.9.0)
44
+
mini_portile2 (~> 2.8.0)
45
+
sqlite3 (2.9.0-aarch64-linux-gnu)
46
+
sqlite3 (2.9.0-arm64-darwin)
47
+
sqlite3 (2.9.0-x86_64-linux-gnu)
48
+
timeout (0.6.0)
49
+
tzinfo (2.0.6)
50
+
concurrent-ruby (~> 1.0)
51
+
uri (0.13.3)
52
+
yaml (0.4.0)
53
54
PLATFORMS
55
aarch64-linux
···
58
x86_64-linux
59
60
DEPENDENCIES
61
+
activerecord (~> 7.2)
62
didkit (~> 0.2)
63
io-console (~> 0.5)
64
json (~> 2.5)
65
minisky (~> 0.5)
66
net-http (~> 0.2)
67
+
sqlite3 (~> 2.7)
68
uri (~> 0.13)
69
yaml (~> 0.1)
70
+9
-4
README.md
+9
-4
README.md
···
20
21
## Installation
22
23
-
At the moment:
24
25
-
git clone https://github.com/mackuba/tootify.git
26
cd tootify
27
bundle install
28
···
55
56
* `bluesky.yml` โ created when you log in, stores Bluesky user ID/password and access tokens
57
* `mastodon.yml` โ created when you log in, stores Mastodon user ID/password and access tokens
58
-
* `history.csv` โ stores a mapping between Bluesky and Mastodon post IDs; used for reply references in threads
59
* `tootify.yml` - optional additional configuration
60
61
The config in `tootify.yml` currently supports one option:
62
63
- `extract_link_from_quotes: true` โ if enabled, posts which are quotes of someone else's post which includes a link will be "collapsed" into a normal post that just includes that link directly without the quote (so the link card on Mastodon will show info about the link and not the quoted bsky.app post)
64
65
66
## Credits
67
68
-
Copyright ยฉ 2025 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/mackuba.eu)).
69
70
The code is available under the terms of the [zlib license](https://choosealicense.com/licenses/zlib/) (permissive, similar to MIT).
···
20
21
## Installation
22
23
+
To run this tool, you need some reasonably recent version of Ruby installed โ although it's recommended to use a version that's still getting maintainance updates, i.e. currently 3.2+. A recent Ruby version is likely to be preinstalled on most Linux systems, or at least available through the OS's package manager, otherwise you can install one using tools such as [RVM](https://rvm.io), [asdf](https://asdf-vm.com), [ruby-install](https://github.com/postmodern/ruby-install) or [ruby-build](https://github.com/rbenv/ruby-build) (see more installation options on [ruby-lang.org](https://www.ruby-lang.org/en/downloads/)).
24
25
+
To install the app, run:
26
+
27
+
git clone https://tangled.org/mackuba.eu/tootify
28
cd tootify
29
bundle install
30
···
57
58
* `bluesky.yml` โ created when you log in, stores Bluesky user ID/password and access tokens
59
* `mastodon.yml` โ created when you log in, stores Mastodon user ID/password and access tokens
60
* `tootify.yml` - optional additional configuration
61
62
The config in `tootify.yml` currently supports one option:
63
64
- `extract_link_from_quotes: true` โ if enabled, posts which are quotes of someone else's post which includes a link will be "collapsed" into a normal post that just includes that link directly without the quote (so the link card on Mastodon will show info about the link and not the quoted bsky.app post)
65
66
+
There is also an SQLite database file that's automatically created in `db/history.sqlite3`. It stores a mapping between Bluesky and Mastodon post IDs, and is used to maintain reply references in threads.
67
+
68
69
## Credits
70
71
+
Copyright ยฉ 2025 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr)).
72
73
The code is available under the terms of the [zlib license](https://choosealicense.com/licenses/zlib/) (permissive, similar to MIT).
74
+
75
+
Bug reports and pull requests are welcome ๐
+1
-3
app/bluesky_account.rb
+1
-3
app/bluesky_account.rb
+16
app/database.rb
+16
app/database.rb
···
···
1
+
require 'active_record'
2
+
3
+
module Database
4
+
DB_FILE = File.expand_path(File.join(__dir__, '..', 'db', 'history.sqlite3'))
5
+
MIGRATIONS_PATH = File.expand_path(File.join(__dir__, '..', 'db', 'migrate'))
6
+
7
+
def self.init
8
+
ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: DB_FILE)
9
+
run_migrations
10
+
end
11
+
12
+
def self.run_migrations
13
+
migration_context = ActiveRecord::MigrationContext.new(MIGRATIONS_PATH)
14
+
migration_context.migrate
15
+
end
16
+
end
+22
-3
app/mastodon_account.rb
+22
-3
app/mastodon_account.rb
···
4
class MastodonAccount
5
APP_NAME = "Tootify"
6
CONFIG_FILE = File.expand_path(File.join(__dir__, '..', 'config', 'mastodon.yml'))
7
-
OAUTH_SCOPES = 'read:accounts read:statuses write:media write:statuses'
8
9
def initialize
10
@config = File.exist?(CONFIG_FILE) ? YAML.load(File.read(CONFIG_FILE)) : {}
···
52
api.register_oauth_app(APP_NAME, OAUTH_SCOPES)
53
end
54
55
-
def post_status(text, media_ids = nil, parent_id = nil)
56
instance = @config['handle'].split('@').last
57
api = MastodonAPI.new(instance, @config['access_token'])
58
-
api.post_status(text, media_ids, parent_id)
59
end
60
61
def upload_media(data, filename, content_type, alt = nil)
···
63
api = MastodonAPI.new(instance, @config['access_token'])
64
api.upload_media(data, filename, content_type, alt)
65
end
66
end
···
4
class MastodonAccount
5
APP_NAME = "Tootify"
6
CONFIG_FILE = File.expand_path(File.join(__dir__, '..', 'config', 'mastodon.yml'))
7
+
8
+
OAUTH_SCOPES = [
9
+
'read:accounts',
10
+
'read:statuses',
11
+
'read:search',
12
+
'write:media',
13
+
'write:statuses'
14
+
].join(' ')
15
16
def initialize
17
@config = File.exist?(CONFIG_FILE) ? YAML.load(File.read(CONFIG_FILE)) : {}
···
59
api.register_oauth_app(APP_NAME, OAUTH_SCOPES)
60
end
61
62
+
def instance_info
63
instance = @config['handle'].split('@').last
64
api = MastodonAPI.new(instance, @config['access_token'])
65
+
api.instance_info
66
+
end
67
+
68
+
def post_status(text, media_ids: nil, parent_id: nil, quoted_status_id: nil)
69
+
instance = @config['handle'].split('@').last
70
+
api = MastodonAPI.new(instance, @config['access_token'])
71
+
api.post_status(text, media_ids: media_ids, parent_id: parent_id, quoted_status_id: quoted_status_id)
72
end
73
74
def upload_media(data, filename, content_type, alt = nil)
···
76
api = MastodonAPI.new(instance, @config['access_token'])
77
api.upload_media(data, filename, content_type, alt)
78
end
79
+
80
+
def search_post_by_url(url)
81
+
instance = @config['handle'].split('@').last
82
+
api = MastodonAPI.new(instance, @config['access_token'])
83
+
api.search_post_by_url(url)
84
+
end
85
end
+16
-1
app/mastodon_api.rb
+16
-1
app/mastodon_api.rb
···
74
get_json("/accounts/verify_credentials")
75
end
76
77
def lookup_account(username)
78
json = get_json("/accounts/lookup", { acct: username })
79
raise UnexpectedResponseError.new unless json.is_a?(Hash) && json['id'].is_a?(String)
···
84
get_json("/accounts/#{user_id}/statuses", params)
85
end
86
87
-
def post_status(text, media_ids = nil, parent_id = nil)
88
params = { status: text }
89
params['media_ids[]'] = media_ids if media_ids
90
params['in_reply_to_id'] = parent_id if parent_id
91
92
post_json("/statuses", params)
93
end
···
127
128
def get_media(media_id)
129
get_json("/media/#{media_id}")
130
end
131
132
def get_json(path, params = {})
···
74
get_json("/accounts/verify_credentials")
75
end
76
77
+
def instance_info
78
+
get_json("https://#{@host}/api/v2/instance")
79
+
end
80
+
81
def lookup_account(username)
82
json = get_json("/accounts/lookup", { acct: username })
83
raise UnexpectedResponseError.new unless json.is_a?(Hash) && json['id'].is_a?(String)
···
88
get_json("/accounts/#{user_id}/statuses", params)
89
end
90
91
+
def post_status(text, media_ids: nil, parent_id: nil, quoted_status_id: nil)
92
params = { status: text }
93
params['media_ids[]'] = media_ids if media_ids
94
params['in_reply_to_id'] = parent_id if parent_id
95
+
params['quoted_status_id'] = quoted_status_id if quoted_status_id
96
97
post_json("/statuses", params)
98
end
···
132
133
def get_media(media_id)
134
get_json("/media/#{media_id}")
135
+
end
136
+
137
+
def search_post_by_url(url)
138
+
json = get_json("https://#{@host}/api/v2/search", {
139
+
q: url,
140
+
type: 'statuses',
141
+
resolve: true
142
+
})
143
+
144
+
json['statuses'] && json['statuses'][0]
145
end
146
147
def get_json(path, params = {})
+10
app/post.rb
+10
app/post.rb
-23
app/post_history.rb
-23
app/post_history.rb
···
1
-
class PostHistory
2
-
HISTORY_FILE = File.expand_path(File.join(__dir__, '..', 'config', 'history.csv'))
3
-
4
-
def initialize
5
-
if File.exist?(HISTORY_FILE)
6
-
@id_map = File.read(HISTORY_FILE).lines.map { |l| l.strip.split(',') }.then { |pairs| Hash[pairs] }
7
-
else
8
-
@id_map = {}
9
-
end
10
-
end
11
-
12
-
def [](bluesky_rkey)
13
-
@id_map[bluesky_rkey]
14
-
end
15
-
16
-
def add(bluesky_rkey, mastodon_id)
17
-
@id_map[bluesky_rkey] = mastodon_id
18
-
19
-
File.open(HISTORY_FILE, 'a') do |f|
20
-
f.puts("#{bluesky_rkey},#{mastodon_id}")
21
-
end
22
-
end
23
-
end
···
+68
-18
app/tootify.rb
+68
-18
app/tootify.rb
···
2
require 'yaml'
3
4
require_relative 'bluesky_account'
5
require_relative 'mastodon_account'
6
-
require_relative 'post_history'
7
8
class Tootify
9
CONFIG_FILE = File.expand_path(File.join(__dir__, '..', 'config', 'tootify.yml'))
···
13
def initialize
14
@bluesky = BlueskyAccount.new
15
@mastodon = MastodonAccount.new
16
-
@history = PostHistory.new
17
@config = load_config
18
@check_interval = 60
19
end
20
21
def load_config
···
57
58
next unless repo == @bluesky.did && collection == 'app.bsky.feed.post'
59
60
begin
61
record = @bluesky.fetch_record(repo, collection, rkey)
62
rescue Minisky::ClientErrorResponse => e
63
-
puts "Record not found: #{post_uri}"
64
@bluesky.delete_record_at(like_uri)
65
next
66
end
···
70
prepo = parent_uri.split('/')[2]
71
72
if prepo != @bluesky.did
73
-
puts "Skipping reply to someone else"
74
@bluesky.delete_record_at(like_uri)
75
next
76
else
···
86
87
if reply = record['reply']
88
parent_uri = reply['parent']['uri']
89
-
prkey = parent_uri.split('/')[4]
90
91
-
if parent_id = @history[prkey]
92
-
mastodon_parent_id = parent_id
93
else
94
-
puts "Skipping reply to a post that wasn't cross-posted"
95
@bluesky.delete_record_at(like_uri)
96
next
97
end
98
end
99
100
response = post_to_mastodon(record, mastodon_parent_id)
101
-
p response
102
103
-
@history.add(rkey, response['id'])
104
@bluesky.delete_record_at(like_uri)
105
end
106
end
···
113
end
114
115
def post_to_mastodon(record, mastodon_parent_id = nil)
116
-
p record
117
118
text = expand_facets(record)
119
···
126
127
if collection == 'app.bsky.feed.post'
128
link_to_append = bsky_post_link(repo, rkey)
129
130
-
if @config['extract_link_from_quotes']
131
quoted_record = fetch_record_by_at_uri(quote_uri)
132
133
-
if link_from_quote = link_embed(quoted_record)
134
-
link_to_append = link_from_quote
135
end
136
end
137
138
-
append_link(text, link_to_append) unless text.include?(link_to_append)
139
end
140
end
141
···
175
text += "\n\n" + tags.map { |t| '#' + t.gsub(' ', '') }.join(' ')
176
end
177
178
-
@mastodon.post_status(text, media_ids, mastodon_parent_id)
179
end
180
181
def fetch_record_by_at_uri(quote_uri)
182
repo, collection, rkey = quote_uri.split('/')[2..4]
183
-
pds = DID.new(repo).get_document.pds_endpoint
184
sky = Minisky.new(pds, nil)
185
resp = sky.get_request('com.atproto.repo.getRecord', { repo: repo, collection: collection, rkey: rkey })
186
resp['value']
···
206
bytes.pack('C*').force_encoding('UTF-8')
207
end
208
209
def link_embed(record)
210
if embed = record['embed']
211
case embed['$type']
···
267
end
268
269
def append_link(text, link)
270
-
if link =~ /^https:\/\/bsky\.app\/profile\/.+\/post\/.+/
271
text << "\n" unless text.end_with?("\n")
272
text << "\n"
273
text << "RE: " + link
···
279
280
def bsky_post_link(repo, rkey)
281
"https://bsky.app/profile/#{repo}/post/#{rkey}"
282
end
283
end
···
2
require 'yaml'
3
4
require_relative 'bluesky_account'
5
+
require_relative 'database'
6
require_relative 'mastodon_account'
7
+
require_relative 'post'
8
9
class Tootify
10
CONFIG_FILE = File.expand_path(File.join(__dir__, '..', 'config', 'tootify.yml'))
···
14
def initialize
15
@bluesky = BlueskyAccount.new
16
@mastodon = MastodonAccount.new
17
@config = load_config
18
@check_interval = 60
19
+
20
+
Database.init
21
end
22
23
def load_config
···
59
60
next unless repo == @bluesky.did && collection == 'app.bsky.feed.post'
61
62
+
if post = Post.find_by(bluesky_rkey: rkey)
63
+
log "Post #{rkey} was already cross-posted, skipping"
64
+
@bluesky.delete_record_at(like_uri)
65
+
next
66
+
end
67
+
68
begin
69
record = @bluesky.fetch_record(repo, collection, rkey)
70
rescue Minisky::ClientErrorResponse => e
71
+
log "Record not found: #{post_uri}"
72
@bluesky.delete_record_at(like_uri)
73
next
74
end
···
78
prepo = parent_uri.split('/')[2]
79
80
if prepo != @bluesky.did
81
+
log "Skipping reply to someone else"
82
@bluesky.delete_record_at(like_uri)
83
next
84
else
···
94
95
if reply = record['reply']
96
parent_uri = reply['parent']['uri']
97
+
parent_rkey = parent_uri.split('/')[4]
98
99
+
if parent_post = Post.find_by(bluesky_rkey: parent_rkey)
100
+
mastodon_parent_id = parent_post.mastodon_id
101
else
102
+
log "Skipping reply to a post that wasn't cross-posted"
103
@bluesky.delete_record_at(like_uri)
104
next
105
end
106
end
107
108
response = post_to_mastodon(record, mastodon_parent_id)
109
+
log(response)
110
+
111
+
Post.create!(bluesky_rkey: rkey, mastodon_id: response['id'])
112
113
@bluesky.delete_record_at(like_uri)
114
end
115
end
···
122
end
123
124
def post_to_mastodon(record, mastodon_parent_id = nil)
125
+
log(record)
126
127
text = expand_facets(record)
128
···
135
136
if collection == 'app.bsky.feed.post'
137
link_to_append = bsky_post_link(repo, rkey)
138
+
instance_info = @mastodon.instance_info
139
140
+
if instance_info.dig('api_versions', 'mastodon').to_i >= 7
141
quoted_record = fetch_record_by_at_uri(quote_uri)
142
143
+
# TODO: we need to wait for Bridgy to add support for quote_authorizations
144
+
quoted_post_url = quoted_record['bridgyOriginalUrl'] #|| "https://bsky.brid.gy/convert/ap/#{quote_uri}"
145
+
146
+
if quoted_post_url && (local_post = @mastodon.search_post_by_url(quoted_post_url))
147
+
quote_policy = local_post.dig('quote_approval', 'current_user')
148
+
149
+
if quote_policy == 'automatic' || quote_policy == 'manual'
150
+
quote_id = local_post['id']
151
+
end
152
end
153
end
154
155
+
if !quote_id && @config['extract_link_from_quotes']
156
+
quoted_record ||= fetch_record_by_at_uri(quote_uri)
157
+
quote_link = link_embed(quoted_record)
158
+
159
+
if quote_link.nil?
160
+
text_links = links_from_facets(quoted_record)
161
+
quote_link = text_links.first if text_links.length == 1
162
+
end
163
+
164
+
if quote_link
165
+
link_to_append = quote_link
166
+
end
167
+
end
168
+
169
+
append_link(text, link_to_append) unless quote_id || text.include?(link_to_append)
170
end
171
end
172
···
206
text += "\n\n" + tags.map { |t| '#' + t.gsub(' ', '') }.join(' ')
207
end
208
209
+
@mastodon.post_status(text, media_ids: media_ids, parent_id: mastodon_parent_id, quoted_status_id: quote_id)
210
end
211
212
def fetch_record_by_at_uri(quote_uri)
213
repo, collection, rkey = quote_uri.split('/')[2..4]
214
+
pds = DID.new(repo).document.pds_host
215
sky = Minisky.new(pds, nil)
216
resp = sky.get_request('com.atproto.repo.getRecord', { repo: repo, collection: collection, rkey: rkey })
217
resp['value']
···
237
bytes.pack('C*').force_encoding('UTF-8')
238
end
239
240
+
def links_from_facets(record)
241
+
links = []
242
+
243
+
if facets = record['facets']
244
+
facets.each do |f|
245
+
if link = f['features'].detect { |ft| ft['$type'] == 'app.bsky.richtext.facet#link' }
246
+
links << link['uri']
247
+
end
248
+
end
249
+
end
250
+
251
+
links.reject { |x| x.start_with?('https://bsky.app/hashtag/') }
252
+
end
253
+
254
def link_embed(record)
255
if embed = record['embed']
256
case embed['$type']
···
312
end
313
314
def append_link(text, link)
315
+
if link =~ %r{^https://bsky\.app/profile/.+/post/.+}
316
text << "\n" unless text.end_with?("\n")
317
text << "\n"
318
text << "RE: " + link
···
324
325
def bsky_post_link(repo, rkey)
326
"https://bsky.app/profile/#{repo}/post/#{rkey}"
327
+
end
328
+
329
+
def log(obj)
330
+
text = obj.is_a?(String) ? obj : obj.inspect
331
+
puts "[#{Time.now}] #{text}"
332
end
333
end
+10
db/migrate/001_create_posts.rb
+10
db/migrate/001_create_posts.rb
+3
-1
tootify
+3
-1
tootify
···
4
require_relative 'app/tootify'
5
6
def run(argv)
7
options, args = argv.partition { |x| x.start_with?('-') }
8
9
app = Tootify.new
···
37
38
if name =~ /\A[^@]+@[^@]+\z/
39
app.login_to_mastodon(name)
40
-
elsif name =~ /\A@[^@]+\z/
41
app.login_to_bluesky(name)
42
elsif name.nil?
43
print_help
···
4
require_relative 'app/tootify'
5
6
def run(argv)
7
+
$stdout.sync = true
8
+
9
options, args = argv.partition { |x| x.start_with?('-') }
10
11
app = Tootify.new
···
39
40
if name =~ /\A[^@]+@[^@]+\z/
41
app.login_to_mastodon(name)
42
+
elsif name =~ /\A@?[^@]+\z/
43
app.login_to_bluesky(name)
44
elsif name.nil?
45
print_help