+5
-1
Gemfile
+5
-1
Gemfile
+56
-41
Gemfile.lock
+56
-41
Gemfile.lock
···
1
-
GIT
2
-
remote: https://github.com/mastodon/mastodon-api.git
3
-
revision: 60b0ed09c3b979cbeb833db0a320b30e1b022a1e
4
-
specs:
5
-
mastodon-api (2.0.0)
6
-
addressable (~> 2.6)
7
-
buftok (~> 0)
8
-
http (~> 4.0)
9
-
oj (~> 3.7)
10
-
11
GEM
12
remote: https://rubygems.org/
13
specs:
14
-
addressable (2.8.6)
15
-
public_suffix (>= 2.0.2, < 6.0)
16
-
bigdecimal (3.1.7)
17
-
buftok (0.3.0)
18
-
domain_name (0.6.20240107)
19
-
ffi (1.16.3)
20
-
ffi-compiler (1.3.2)
21
-
ffi (>= 1.15.5)
22
-
rake
23
-
http (4.4.1)
24
-
addressable (~> 2.3)
25
-
http-cookie (~> 1.0)
26
-
http-form_data (~> 2.2)
27
-
http-parser (~> 1.2.0)
28
-
http-cookie (1.0.5)
29
-
domain_name (~> 0.5)
30
-
http-form_data (2.3.0)
31
-
http-parser (1.2.3)
32
-
ffi-compiler (>= 1.0, < 2.0)
33
-
io-console (0.7.2)
34
-
json (2.7.1)
35
-
net-http (0.4.1)
36
-
uri
37
-
oj (3.16.3)
38
-
bigdecimal (>= 3.0)
39
-
public_suffix (5.0.4)
40
-
rake (13.1.0)
41
-
uri (0.13.0)
42
-
yaml (0.3.0)
43
44
PLATFORMS
45
arm64-darwin-21
46
ruby
47
48
DEPENDENCIES
49
io-console (~> 0.5)
50
json (~> 2.5)
51
-
mastodon-api!
52
net-http (~> 0.2)
53
uri (~> 0.13)
54
yaml (~> 0.1)
55
56
BUNDLED WITH
57
-
2.5.6
···
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
56
arm64-darwin-21
57
ruby
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
71
BUNDLED WITH
72
+
2.6.5
+22
LICENSE.txt
+22
LICENSE.txt
···
···
1
+
The zlib License
2
+
3
+
Copyright (c) 2024 Jakub Suder
4
+
5
+
This software is provided 'as-is', without any express or implied
6
+
warranty. In no event will the authors be held liable for any damages
7
+
arising from the use of this software.
8
+
9
+
Permission is granted to anyone to use this software for any purpose,
10
+
including commercial applications, and to alter it and redistribute it
11
+
freely, subject to the following restrictions:
12
+
13
+
1. The origin of this software must not be misrepresented; you must not
14
+
claim that you wrote the original software. If you use this software
15
+
in a product, an acknowledgment in the product documentation would be
16
+
appreciated but is not required.
17
+
18
+
2. Altered source versions must be plainly marked as such, and must not be
19
+
misrepresented as being the original software.
20
+
21
+
3. This notice may not be removed or altered from any source distribution.
22
+
+75
README.md
+75
README.md
···
···
1
+
# Tootify ๐ฆโ๐
2
+
3
+
A simple Bluesky-to-Mastodon cross-posting service
4
+
5
+
6
+
## What does it do
7
+
8
+
Tootify allows you to do a selective one-way sync of Bluesky posts to your Mastodon account.
9
+
10
+
The way it works lets you easily pick which skeets you want to turn into toots: it scans your recent posts and checks which of them you have liked yourself, and only those posts are reposted. The self-like is automatically removed afterwards.
11
+
12
+
Currently handles:
13
+
14
+
- post with link embeds
15
+
- quotes โ posted as "RE: bsky.app/..."
16
+
- images (with alt text)
17
+
- videos
18
+
- threads of multiple chained posts from you
19
+
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
+
31
+
32
+
## Usage
33
+
34
+
First, log in to the two accounts:
35
+
36
+
./tootify login johnmastodon@example.com
37
+
./tootify login @alf.bsky.team
38
+
39
+
Press like on the post(s) on Bluesky that you want to be synced to Mastodon.
40
+
41
+
Then, you can either run the sync once:
42
+
43
+
./tootify check
44
+
45
+
Or run it continuously in a loop:
46
+
47
+
./tootify watch
48
+
49
+
By default it checks for new skeets every 60 seconds โ use the `interval` parameter to customize the interval:
50
+
51
+
./tootify watch --interval=15
52
+
53
+
54
+
## Configs
55
+
56
+
Tootify stores configs and data in the `config` folder:
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 ๐
+53
app/bluesky_account.rb
+53
app/bluesky_account.rb
···
···
1
+
require 'didkit'
2
+
require_relative 'bluesky_client'
3
+
4
+
class BlueskyAccount
5
+
def initialize
6
+
@sky = BlueskyClient.new
7
+
end
8
+
9
+
def did
10
+
@sky.user.did
11
+
end
12
+
13
+
def login_with_password(handle, password)
14
+
did = DID.resolve_handle(handle)
15
+
if did.nil?
16
+
puts "Error: couldn't resolve handle #{handle.inspect}"
17
+
exit 1
18
+
end
19
+
20
+
@sky.host = did.document.pds_host
21
+
@sky.user.id = handle
22
+
@sky.user.pass = password
23
+
24
+
@sky.log_in
25
+
end
26
+
27
+
def log_in
28
+
@sky.log_in
29
+
end
30
+
31
+
def fetch_likes
32
+
json = @sky.get_request('com.atproto.repo.listRecords', {
33
+
repo: @sky.user.did,
34
+
collection: 'app.bsky.feed.like',
35
+
limit: 100
36
+
})
37
+
38
+
json['records']
39
+
end
40
+
41
+
def fetch_record(repo, collection, rkey)
42
+
@sky.get_request('com.atproto.repo.getRecord', { repo: repo, collection: collection, rkey: rkey })
43
+
end
44
+
45
+
def fetch_blob(cid)
46
+
@sky.get_request('com.atproto.sync.getBlob', { did: @sky.user.did, cid: cid })
47
+
end
48
+
49
+
def delete_record_at(uri)
50
+
repo, collection, rkey = uri.split('/')[2..4]
51
+
@sky.post_request('com.atproto.repo.deleteRecord', { repo: repo, collection: collection, rkey: rkey })
52
+
end
53
+
end
+27
app/bluesky_client.rb
+27
app/bluesky_client.rb
···
···
1
+
require 'minisky'
2
+
require 'yaml'
3
+
4
+
class BlueskyClient
5
+
include Minisky::Requests
6
+
7
+
CONFIG_FILE = File.expand_path(File.join(__dir__, '..', 'config', 'bluesky.yml'))
8
+
9
+
attr_reader :config
10
+
11
+
def initialize
12
+
@config = File.exist?(CONFIG_FILE) ? YAML.load(File.read(CONFIG_FILE)) : {}
13
+
Dir.mkdir('config') unless Dir.exist?('config')
14
+
end
15
+
16
+
def host
17
+
@config['host']
18
+
end
19
+
20
+
def host=(h)
21
+
@config['host'] = h
22
+
end
23
+
24
+
def save_config
25
+
File.write(CONFIG_FILE, YAML.dump(@config))
26
+
end
27
+
end
+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
+55
-14
app/mastodon_account.rb
+55
-14
app/mastodon_account.rb
···
1
-
require 'mastodon'
2
require 'yaml'
3
-
4
require_relative 'mastodon_api'
5
6
class MastodonAccount
7
-
APP_NAME = "tootify"
8
CONFIG_FILE = File.expand_path(File.join(__dir__, '..', 'config', 'mastodon.yml'))
9
-
OAUTH_SCOPES = 'read:accounts read:statuses write:media write:statuses'
10
11
def initialize
12
@config = File.exist?(CONFIG_FILE) ? YAML.load(File.read(CONFIG_FILE)) : {}
13
end
14
15
def save_config
16
File.write(CONFIG_FILE, YAML.dump(@config))
17
end
18
19
-
def oauth_login(handle, email, password)
20
instance = handle.split('@').last
21
-
app_response = register_oauth_app(instance, OAUTH_SCOPES)
22
23
api = MastodonAPI.new(instance)
24
25
-
json = api.oauth_login_with_password(
26
-
app_response.client_id,
27
-
app_response.client_secret,
28
-
email, password, OAUTH_SCOPES
29
-
)
30
31
api.access_token = json['access_token']
32
info = api.account_info
···
37
save_config
38
end
39
40
-
def register_oauth_app(instance, scopes)
41
-
client = Mastodon::REST::Client.new(base_url: "https://#{instance}")
42
-
client.create_app(APP_NAME, 'urn:ietf:wg:oauth:2.0:oob', scopes)
43
end
44
end
···
1
require 'yaml'
2
require_relative 'mastodon_api'
3
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)) : {}
18
end
19
20
+
def max_alt_length
21
+
1500
22
+
end
23
+
24
def save_config
25
File.write(CONFIG_FILE, YAML.dump(@config))
26
end
27
28
+
def oauth_login(handle)
29
instance = handle.split('@').last
30
+
app_response = register_oauth_app(instance)
31
32
api = MastodonAPI.new(instance)
33
34
+
login_url = api.generate_oauth_login_url(app_response['client_id'], OAUTH_SCOPES)
35
+
36
+
puts "Open this URL in your web browser and authorize the app:"
37
+
puts
38
+
puts login_url
39
+
puts
40
+
puts "Then, enter the received code here:"
41
+
puts
42
+
43
+
print ">> "
44
+
code = STDIN.gets.chomp
45
+
46
+
json = api.complete_oauth_login(app_response['client_id'], app_response['client_secret'], code)
47
48
api.access_token = json['access_token']
49
info = api.account_info
···
54
save_config
55
end
56
57
+
def register_oauth_app(instance)
58
+
api = MastodonAPI.new(instance)
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)
75
+
instance = @config['handle'].split('@').last
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
+113
-14
app/mastodon_api.rb
+113
-14
app/mastodon_api.rb
···
3
require 'uri'
4
5
class MastodonAPI
6
class UnauthenticatedError < StandardError
7
end
8
···
22
end
23
end
24
25
attr_accessor :access_token
26
27
def initialize(host, access_token = nil)
···
30
@access_token = access_token
31
end
32
33
-
def oauth_login_with_password(client_id, client_secret, email, password, scopes)
34
-
url = URI("https://#{@host}/oauth/token")
35
36
params = {
37
client_id: client_id,
38
client_secret: client_secret,
39
-
grant_type: 'password',
40
-
scope: scopes,
41
-
username: email,
42
-
password: password
43
}
44
45
-
response = Net::HTTP.post_form(url, params)
46
-
status = response.code.to_i
47
-
48
-
if status / 100 == 2
49
-
JSON.parse(response.body)
50
-
else
51
-
raise APIError.new(response)
52
-
end
53
end
54
55
def account_info
···
57
get_json("/accounts/verify_credentials")
58
end
59
60
def lookup_account(username)
61
json = get_json("/accounts/lookup", { acct: username })
62
raise UnexpectedResponseError.new unless json.is_a?(Hash) && json['id'].is_a?(String)
···
67
get_json("/accounts/#{user_id}/statuses", params)
68
end
69
70
def get_json(path, params = {})
71
url = URI(path.start_with?('https://') ? path : @root + path)
72
url.query = URI.encode_www_form(params) if params
···
81
JSON.parse(response.body)
82
elsif status / 100 == 3
83
get_json(response['Location'])
84
else
85
raise APIError.new(response)
86
end
···
3
require 'uri'
4
5
class MastodonAPI
6
+
CODE_REDIRECT_URI = 'urn:ietf:wg:oauth:2.0:oob'
7
+
8
class UnauthenticatedError < StandardError
9
end
10
···
24
end
25
end
26
27
+
MEDIA_CHECK_INTERVAL = 5.0
28
+
29
attr_accessor :access_token
30
31
def initialize(host, access_token = nil)
···
34
@access_token = access_token
35
end
36
37
+
def register_oauth_app(app_name, scopes)
38
+
params = {
39
+
client_name: app_name,
40
+
redirect_uris: CODE_REDIRECT_URI,
41
+
scopes: scopes
42
+
}
43
44
+
post_json("https://#{@host}/api/v1/apps", params)
45
+
end
46
+
47
+
def generate_oauth_login_url(client_id, scopes)
48
+
login_url = URI("https://#{@host}/oauth/authorize")
49
+
50
+
login_url.query = URI.encode_www_form(
51
+
client_id: client_id,
52
+
redirect_uri: CODE_REDIRECT_URI,
53
+
response_type: 'code',
54
+
scope: scopes
55
+
)
56
+
57
+
login_url
58
+
end
59
+
60
+
def complete_oauth_login(client_id, client_secret, code)
61
params = {
62
client_id: client_id,
63
client_secret: client_secret,
64
+
redirect_uri: CODE_REDIRECT_URI,
65
+
grant_type: 'authorization_code',
66
+
code: code
67
}
68
69
+
post_json("https://#{@host}/oauth/token", params)
70
end
71
72
def account_info
···
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
99
+
100
+
def upload_media(data, filename, content_type, alt = nil)
101
+
url = URI("https://#{@host}/api/v2/media")
102
+
headers = { 'Authorization' => "Bearer #{@access_token}" }
103
+
104
+
form_data = [
105
+
['file', data, { :filename => filename, :content_type => content_type }]
106
+
]
107
+
108
+
if alt
109
+
form_data << ['description', alt.force_encoding('ASCII-8BIT')]
110
+
end
111
+
112
+
request = Net::HTTP::Post.new(url, headers)
113
+
request.set_form(form_data, 'multipart/form-data')
114
+
115
+
response = Net::HTTP.start(url.hostname, url.port, :use_ssl => true) do |http|
116
+
http.request(request)
117
+
end
118
+
119
+
json = if response.code.to_i / 100 == 2
120
+
JSON.parse(response.body)
121
+
else
122
+
raise APIError.new(response)
123
+
end
124
+
125
+
while json['url'].nil?
126
+
sleep MEDIA_CHECK_INTERVAL
127
+
json = get_media(json['id'])
128
+
end
129
+
130
+
json
131
+
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 = {})
148
url = URI(path.start_with?('https://') ? path : @root + path)
149
url.query = URI.encode_www_form(params) if params
···
158
JSON.parse(response.body)
159
elsif status / 100 == 3
160
get_json(response['Location'])
161
+
else
162
+
raise APIError.new(response)
163
+
end
164
+
end
165
+
166
+
def post_json(path, params = {})
167
+
url = URI(path.start_with?('https://') ? path : @root + path)
168
+
169
+
headers = {}
170
+
headers['Authorization'] = "Bearer #{@access_token}" if @access_token
171
+
172
+
request = Net::HTTP::Post.new(url, headers)
173
+
request.form_data = params
174
+
175
+
response = Net::HTTP.start(url.hostname, url.port, :use_ssl => true) do |http|
176
+
http.request(request)
177
+
end
178
+
179
+
status = response.code.to_i
180
+
181
+
if status / 100 == 2
182
+
JSON.parse(response.body)
183
else
184
raise APIError.new(response)
185
end
+10
app/post.rb
+10
app/post.rb
+318
-7
app/tootify.rb
+318
-7
app/tootify.rb
···
1
require 'io/console'
2
require_relative 'mastodon_account'
3
4
class Tootify
5
def initialize
6
@mastodon = MastodonAccount.new
7
end
8
9
-
def login_bluesky
10
-
end
11
12
-
def login_mastodon(handle)
13
-
print "Email: "
14
-
email = STDIN.gets.chomp
15
16
-
print "Password: "
17
password = STDIN.noecho(&:gets).chomp
18
puts
19
20
-
@mastodon.oauth_login(handle, email, password)
21
end
22
end
···
1
require 'io/console'
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'))
11
+
12
+
attr_accessor :check_interval
13
+
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
24
+
if File.exist?(CONFIG_FILE)
25
+
YAML.load(File.read(CONFIG_FILE))
26
+
else
27
+
{}
28
+
end
29
+
end
30
31
+
def login_to_bluesky(handle)
32
+
handle = handle.gsub(/^@/, '')
33
34
+
print "App password: "
35
password = STDIN.noecho(&:gets).chomp
36
puts
37
38
+
@bluesky.login_with_password(handle, password)
39
+
end
40
+
41
+
def login_to_mastodon(handle)
42
+
@mastodon.oauth_login(handle)
43
+
end
44
+
45
+
def sync
46
+
begin
47
+
likes = @bluesky.fetch_likes
48
+
rescue Minisky::ExpiredTokenError => e
49
+
@bluesky.log_in
50
+
likes = @bluesky.fetch_likes
51
+
end
52
+
53
+
records = []
54
+
55
+
likes.each do |r|
56
+
like_uri = r['uri']
57
+
post_uri = r['value']['subject']['uri']
58
+
repo, collection, rkey = post_uri.split('/')[2..4]
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
75
+
76
+
if reply = record['value']['reply']
77
+
parent_uri = reply['parent']['uri']
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
85
+
# self-reply, we'll try to cross-post it
86
+
end
87
+
end
88
+
89
+
records << [record['value'], rkey, like_uri]
90
+
end
91
+
92
+
records.sort_by { |x| x[0]['createdAt'] }.each do |record, rkey, like_uri|
93
+
mastodon_parent_id = nil
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
116
+
117
+
def watch
118
+
loop do
119
+
sync
120
+
sleep @check_interval
121
+
end
122
+
end
123
+
124
+
def post_to_mastodon(record, mastodon_parent_id = nil)
125
+
log(record)
126
+
127
+
text = expand_facets(record)
128
+
129
+
if link = link_embed(record)
130
+
append_link(text, link) unless text.include?(link)
131
+
end
132
+
133
+
if quote_uri = quoted_post(record)
134
+
repo, collection, rkey = quote_uri.split('/')[2..4]
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
+
173
+
if images = attached_images(record)
174
+
media_ids = []
175
+
176
+
images.each do |embed|
177
+
alt = embed['alt']
178
+
cid = embed['image']['ref']['$link']
179
+
mime = embed['image']['mimeType']
180
+
181
+
if alt && alt.length > @mastodon.max_alt_length
182
+
alt = alt[0...@mastodon.max_alt_length - 3] + "(โฆ)"
183
+
end
184
+
185
+
data = @bluesky.fetch_blob(cid)
186
+
187
+
uploaded_media = @mastodon.upload_media(data, cid, mime, alt)
188
+
media_ids << uploaded_media['id']
189
+
end
190
+
elsif embed = attached_video(record)
191
+
alt = embed['alt']
192
+
cid = embed['video']['ref']['$link']
193
+
mime = embed['video']['mimeType']
194
+
195
+
if alt && alt.length > @mastodon.max_alt_length
196
+
alt = alt[0...@mastodon.max_alt_length - 3] + "(โฆ)"
197
+
end
198
+
199
+
data = @bluesky.fetch_blob(cid)
200
+
201
+
uploaded_media = @mastodon.upload_media(data, cid, mime, alt)
202
+
media_ids = [uploaded_media['id']]
203
+
end
204
+
205
+
if tags = record['tags']
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']
218
+
end
219
+
220
+
def expand_facets(record)
221
+
bytes = record['text'].bytes
222
+
offset = 0
223
+
224
+
if facets = record['facets']
225
+
facets.sort_by { |f| f['index']['byteStart'] }.each do |f|
226
+
if link = f['features'].detect { |ft| ft['$type'] == 'app.bsky.richtext.facet#link' }
227
+
left = f['index']['byteStart']
228
+
right = f['index']['byteEnd']
229
+
content = link['uri'].bytes
230
+
231
+
bytes[(left + offset) ... (right + offset)] = content
232
+
offset += content.length - (right - left)
233
+
end
234
+
end
235
+
end
236
+
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']
257
+
when 'app.bsky.embed.external'
258
+
embed['external']['uri']
259
+
when 'app.bsky.embed.recordWithMedia'
260
+
embed['media']['external'] && embed['media']['external']['uri']
261
+
else
262
+
nil
263
+
end
264
+
end
265
+
end
266
+
267
+
def quoted_post(record)
268
+
if embed = record['embed']
269
+
case embed['$type']
270
+
when 'app.bsky.embed.record'
271
+
embed['record']['uri']
272
+
when 'app.bsky.embed.recordWithMedia'
273
+
embed['record']['record']['uri']
274
+
else
275
+
nil
276
+
end
277
+
end
278
+
end
279
+
280
+
def attached_images(record)
281
+
if embed = record['embed']
282
+
case embed['$type']
283
+
when 'app.bsky.embed.images'
284
+
embed['images']
285
+
when 'app.bsky.embed.recordWithMedia'
286
+
if embed['media']['$type'] == 'app.bsky.embed.images'
287
+
embed['media']['images']
288
+
else
289
+
nil
290
+
end
291
+
else
292
+
nil
293
+
end
294
+
end
295
+
end
296
+
297
+
def attached_video(record)
298
+
if embed = record['embed']
299
+
case embed['$type']
300
+
when 'app.bsky.embed.video'
301
+
embed
302
+
when 'app.bsky.embed.recordWithMedia'
303
+
if embed['media']['$type'] == 'app.bsky.embed.video'
304
+
embed['media']
305
+
else
306
+
nil
307
+
end
308
+
else
309
+
nil
310
+
end
311
+
end
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
319
+
else
320
+
text << ' ' unless text.end_with?(' ')
321
+
text << link
322
+
end
323
+
end
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
+19
-6
tootify
+19
-6
tootify
···
3
require 'bundler/setup'
4
require_relative 'app/tootify'
5
6
-
$app = Tootify.new
7
-
8
def run(argv)
9
options, args = argv.partition { |x| x.start_with?('-') }
10
11
case args.first
12
when 'login'
13
login(args[1])
14
when 'check'
15
else
16
print_help
17
end
···
19
20
def print_help
21
puts "Usage: #{$PROGRAM_NAME} login mastodon@account | login @bluesky"
22
-
puts " #{$PROGRAM_NAME} check"
23
exit 1
24
end
25
26
def login(name)
27
if name =~ /\A[^@]+@[^@]+\z/
28
-
$app.login_mastodon(name)
29
-
elsif name =~ /\A@[^@]+\z/
30
-
$app.login_bluesky(name)
31
elsif name.nil?
32
print_help
33
else
···
3
require 'bundler/setup'
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
12
+
13
+
options.each do |o|
14
+
if o.start_with?('--interval=')
15
+
app.check_interval = o.split('=')[1].to_i
16
+
end
17
+
end
18
+
19
case args.first
20
when 'login'
21
login(args[1])
22
when 'check'
23
+
app.sync
24
+
when 'watch'
25
+
app.watch
26
else
27
print_help
28
end
···
30
31
def print_help
32
puts "Usage: #{$PROGRAM_NAME} login mastodon@account | login @bluesky"
33
+
puts " #{$PROGRAM_NAME} check | watch"
34
exit 1
35
end
36
37
def login(name)
38
+
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
46
else