+1
-1
.gitignore
+1
-1
.gitignore
+3
-1
Gemfile
+3
-1
Gemfile
+49
-47
Gemfile.lock
+49
-47
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.7)
15
-
public_suffix (>= 2.0.2, < 7.0)
16
-
base64 (0.2.0)
17
-
bigdecimal (3.1.9)
18
-
buftok (0.3.0)
19
-
didkit (0.2.3)
20
-
domain_name (0.6.20240107)
21
-
ffi (1.17.1)
22
-
ffi (1.17.1-aarch64-linux-gnu)
23
-
ffi (1.17.1-arm64-darwin)
24
-
ffi (1.17.1-x86_64-linux-gnu)
25
-
ffi-compiler (1.3.2)
26
-
ffi (>= 1.15.5)
27
-
rake
28
-
http (4.4.1)
29
-
addressable (~> 2.3)
30
-
http-cookie (~> 1.0)
31
-
http-form_data (~> 2.2)
32
-
http-parser (~> 1.2.0)
33
-
http-cookie (1.0.5)
34
-
domain_name (~> 0.5)
35
-
http-form_data (2.3.0)
36
-
http-parser (1.2.3)
37
-
ffi-compiler (>= 1.0, < 2.0)
38
-
io-console (0.7.2)
39
-
json (2.10.2)
40
minisky (0.5.0)
41
base64 (~> 0.1)
42
-
net-http (0.4.1)
43
-
uri
44
-
oj (3.16.9)
45
-
bigdecimal (>= 3.0)
46
-
ostruct (>= 0.2)
47
-
ostruct (0.6.1)
48
-
public_suffix (6.0.1)
49
-
rake (13.2.1)
50
-
uri (0.13.2)
51
-
yaml (0.3.0)
52
53
PLATFORMS
54
aarch64-linux
···
57
x86_64-linux
58
59
DEPENDENCIES
60
didkit (~> 0.2)
61
io-console (~> 0.5)
62
json (~> 2.5)
63
-
mastodon-api!
64
minisky (~> 0.5)
65
net-http (~> 0.2)
66
uri (~> 0.13)
67
yaml (~> 0.1)
68
···
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
+41
-16
app/mastodon_account.rb
+41
-16
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)) : {}
···
20
File.write(CONFIG_FILE, YAML.dump(@config))
21
end
22
23
-
def oauth_login(handle, email, password)
24
instance = handle.split('@').last
25
-
app_response = register_oauth_app(instance, OAUTH_SCOPES)
26
27
api = MastodonAPI.new(instance)
28
29
-
json = api.oauth_login_with_password(
30
-
app_response.client_id,
31
-
app_response.client_secret,
32
-
email, password, OAUTH_SCOPES
33
-
)
34
35
api.access_token = json['access_token']
36
info = api.account_info
···
41
save_config
42
end
43
44
-
def register_oauth_app(instance, scopes)
45
-
client = Mastodon::REST::Client.new(base_url: "https://#{instance}")
46
-
client.create_app(APP_NAME, 'urn:ietf:wg:oauth:2.0:oob', scopes)
47
end
48
49
-
def post_status(text, media_ids = nil, parent_id = nil)
50
instance = @config['handle'].split('@').last
51
api = MastodonAPI.new(instance, @config['access_token'])
52
-
api.post_status(text, media_ids, parent_id)
53
end
54
55
def upload_media(data, filename, content_type, alt = nil)
···
57
api = MastodonAPI.new(instance, @config['access_token'])
58
api.upload_media(data, filename, content_type, alt)
59
end
60
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)) : {}
···
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)
···
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
+45
-6
app/mastodon_api.rb
+45
-6
app/mastodon_api.rb
···
3
require 'uri'
4
5
class MastodonAPI
6
class UnauthenticatedError < StandardError
7
end
8
···
32
@access_token = access_token
33
end
34
35
-
def oauth_login_with_password(client_id, client_secret, email, password, scopes)
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
post_json("https://#{@host}/oauth/token", params)
···
50
get_json("/accounts/verify_credentials")
51
end
52
53
def lookup_account(username)
54
json = get_json("/accounts/lookup", { acct: username })
55
raise UnexpectedResponseError.new unless json.is_a?(Hash) && json['id'].is_a?(String)
···
60
get_json("/accounts/#{user_id}/statuses", params)
61
end
62
63
-
def post_status(text, media_ids = nil, parent_id = nil)
64
params = { status: text }
65
params['media_ids[]'] = media_ids if media_ids
66
params['in_reply_to_id'] = parent_id if parent_id
67
68
post_json("/statuses", params)
69
end
···
103
104
def get_media(media_id)
105
get_json("/media/#{media_id}")
106
end
107
108
def get_json(path, params = {})
···
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
···
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)
···
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
···
+71
-28
app/tootify.rb
+71
-28
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
···
26
end
27
end
28
29
-
def login_bluesky(handle)
30
handle = handle.gsub(/^@/, '')
31
32
print "App password: "
···
36
@bluesky.login_with_password(handle, password)
37
end
38
39
-
def login_mastodon(handle)
40
-
print "Email: "
41
-
email = STDIN.gets.chomp
42
-
43
-
print "Password: "
44
-
password = STDIN.noecho(&:gets).chomp
45
-
puts
46
-
47
-
@mastodon.oauth_login(handle, email, password)
48
end
49
50
def sync
···
63
repo, collection, rkey = post_uri.split('/')[2..4]
64
65
next unless repo == @bluesky.did && collection == 'app.bsky.feed.post'
66
67
begin
68
record = @bluesky.fetch_record(repo, collection, rkey)
69
rescue Minisky::ClientErrorResponse => e
70
-
puts "Record not found: #{post_uri}"
71
@bluesky.delete_record_at(like_uri)
72
next
73
end
···
77
prepo = parent_uri.split('/')[2]
78
79
if prepo != @bluesky.did
80
-
puts "Skipping reply to someone else"
81
@bluesky.delete_record_at(like_uri)
82
next
83
else
···
93
94
if reply = record['reply']
95
parent_uri = reply['parent']['uri']
96
-
prkey = parent_uri.split('/')[4]
97
98
-
if parent_id = @history[prkey]
99
-
mastodon_parent_id = parent_id
100
else
101
-
puts "Skipping reply to a post that wasn't cross-posted"
102
@bluesky.delete_record_at(like_uri)
103
next
104
end
105
end
106
107
response = post_to_mastodon(record, mastodon_parent_id)
108
-
p response
109
110
-
@history.add(rkey, response['id'])
111
@bluesky.delete_record_at(like_uri)
112
end
113
end
···
120
end
121
122
def post_to_mastodon(record, mastodon_parent_id = nil)
123
-
p record
124
125
text = expand_facets(record)
126
···
133
134
if collection == 'app.bsky.feed.post'
135
link_to_append = bsky_post_link(repo, rkey)
136
137
-
if @config['extract_link_from_quotes']
138
quoted_record = fetch_record_by_at_uri(quote_uri)
139
140
-
if link_from_quote = link_embed(quoted_record)
141
-
link_to_append = link_from_quote
142
end
143
end
144
145
-
append_link(text, link_to_append) unless text.include?(link_to_append)
146
end
147
end
148
···
182
text += "\n\n" + tags.map { |t| '#' + t.gsub(' ', '') }.join(' ')
183
end
184
185
-
@mastodon.post_status(text, media_ids, mastodon_parent_id)
186
end
187
188
def fetch_record_by_at_uri(quote_uri)
189
repo, collection, rkey = quote_uri.split('/')[2..4]
190
-
pds = DID.new(repo).get_document.pds_endpoint
191
sky = Minisky.new(pds, nil)
192
resp = sky.get_request('com.atproto.repo.getRecord', { repo: repo, collection: collection, rkey: rkey })
193
resp['value']
···
211
end
212
213
bytes.pack('C*').force_encoding('UTF-8')
214
end
215
216
def link_embed(record)
···
274
end
275
276
def append_link(text, link)
277
-
if link =~ /^https:\/\/bsky\.app\/profile\/.+\/post\/.+/
278
text << "\n" unless text.end_with?("\n")
279
text << "\n"
280
text << "RE: " + link
···
286
287
def bsky_post_link(repo, rkey)
288
"https://bsky.app/profile/#{repo}/post/#{rkey}"
289
end
290
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
···
28
end
29
end
30
31
+
def login_to_bluesky(handle)
32
handle = handle.gsub(/^@/, '')
33
34
print "App password: "
···
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
···
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
···
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']
···
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)
···
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
+5
-3
tootify
+5
-3
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
···
36
app = Tootify.new
37
38
if name =~ /\A[^@]+@[^@]+\z/
39
-
app.login_mastodon(name)
40
-
elsif name =~ /\A@[^@]+\z/
41
-
app.login_bluesky(name)
42
elsif name.nil?
43
print_help
44
else
···
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
···
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