+27
-25
Gemfile.lock
+27
-25
Gemfile.lock
···
1
1
GEM
2
2
remote: https://rubygems.org/
3
3
specs:
4
-
activemodel (7.2.2.1)
5
-
activesupport (= 7.2.2.1)
6
-
activerecord (7.2.2.1)
7
-
activemodel (= 7.2.2.1)
8
-
activesupport (= 7.2.2.1)
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
9
timeout (>= 0.4.0)
10
-
activesupport (7.2.2.1)
10
+
activesupport (7.2.3)
11
11
base64
12
12
benchmark (>= 0.3)
13
13
bigdecimal
···
19
19
minitest (>= 5.1)
20
20
securerandom (>= 0.3)
21
21
tzinfo (~> 2.0, >= 2.0.5)
22
-
base64 (0.2.0)
23
-
benchmark (0.4.1)
24
-
bigdecimal (3.2.2)
25
-
concurrent-ruby (1.3.5)
26
-
connection_pool (2.5.3)
27
-
didkit (0.2.3)
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
28
drb (2.2.3)
29
-
i18n (1.14.7)
29
+
i18n (1.14.8)
30
30
concurrent-ruby (~> 1.0)
31
-
io-console (0.7.2)
32
-
json (2.10.2)
31
+
io-console (0.8.2)
32
+
json (2.18.0)
33
33
logger (1.7.0)
34
34
mini_portile2 (2.8.9)
35
35
minisky (0.5.0)
36
36
base64 (~> 0.1)
37
-
minitest (5.25.5)
38
-
net-http (0.4.1)
39
-
uri
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)
40
42
securerandom (0.4.1)
41
-
sqlite3 (2.7.1)
43
+
sqlite3 (2.9.0)
42
44
mini_portile2 (~> 2.8.0)
43
-
sqlite3 (2.7.1-aarch64-linux-gnu)
44
-
sqlite3 (2.7.1-arm64-darwin)
45
-
sqlite3 (2.7.1-x86_64-linux-gnu)
46
-
timeout (0.4.3)
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)
47
49
tzinfo (2.0.6)
48
50
concurrent-ruby (~> 1.0)
49
-
uri (0.13.2)
50
-
yaml (0.3.0)
51
+
uri (0.13.3)
52
+
yaml (0.4.0)
51
53
52
54
PLATFORMS
53
55
aarch64-linux
+7
-3
README.md
+7
-3
README.md
···
20
20
21
21
## Installation
22
22
23
-
At the moment:
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
24
25
-
git clone https://github.com/mackuba/tootify.git
25
+
To install the app, run:
26
+
27
+
git clone https://tangled.org/mackuba.eu/tootify
26
28
cd tootify
27
29
bundle install
28
30
···
66
68
67
69
## Credits
68
70
69
-
Copyright ยฉ 2025 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/mackuba.eu)).
71
+
Copyright ยฉ 2025 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr)).
70
72
71
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
+22
-3
app/mastodon_account.rb
+22
-3
app/mastodon_account.rb
···
4
4
class MastodonAccount
5
5
APP_NAME = "Tootify"
6
6
CONFIG_FILE = File.expand_path(File.join(__dir__, '..', 'config', 'mastodon.yml'))
7
-
OAUTH_SCOPES = 'read:accounts read:statuses write:media write:statuses'
7
+
8
+
OAUTH_SCOPES = [
9
+
'read:accounts',
10
+
'read:statuses',
11
+
'read:search',
12
+
'write:media',
13
+
'write:statuses'
14
+
].join(' ')
8
15
9
16
def initialize
10
17
@config = File.exist?(CONFIG_FILE) ? YAML.load(File.read(CONFIG_FILE)) : {}
···
52
59
api.register_oauth_app(APP_NAME, OAUTH_SCOPES)
53
60
end
54
61
55
-
def post_status(text, media_ids = nil, parent_id = nil)
62
+
def instance_info
56
63
instance = @config['handle'].split('@').last
57
64
api = MastodonAPI.new(instance, @config['access_token'])
58
-
api.post_status(text, media_ids, parent_id)
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)
59
72
end
60
73
61
74
def upload_media(data, filename, content_type, alt = nil)
···
63
76
api = MastodonAPI.new(instance, @config['access_token'])
64
77
api.upload_media(data, filename, content_type, alt)
65
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
66
85
end
+16
-1
app/mastodon_api.rb
+16
-1
app/mastodon_api.rb
···
74
74
get_json("/accounts/verify_credentials")
75
75
end
76
76
77
+
def instance_info
78
+
get_json("https://#{@host}/api/v2/instance")
79
+
end
80
+
77
81
def lookup_account(username)
78
82
json = get_json("/accounts/lookup", { acct: username })
79
83
raise UnexpectedResponseError.new unless json.is_a?(Hash) && json['id'].is_a?(String)
···
84
88
get_json("/accounts/#{user_id}/statuses", params)
85
89
end
86
90
87
-
def post_status(text, media_ids = nil, parent_id = nil)
91
+
def post_status(text, media_ids: nil, parent_id: nil, quoted_status_id: nil)
88
92
params = { status: text }
89
93
params['media_ids[]'] = media_ids if media_ids
90
94
params['in_reply_to_id'] = parent_id if parent_id
95
+
params['quoted_status_id'] = quoted_status_id if quoted_status_id
91
96
92
97
post_json("/statuses", params)
93
98
end
···
127
132
128
133
def get_media(media_id)
129
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]
130
145
end
131
146
132
147
def get_json(path, params = {})
+19
-4
app/tootify.rb
+19
-4
app/tootify.rb
···
135
135
136
136
if collection == 'app.bsky.feed.post'
137
137
link_to_append = bsky_post_link(repo, rkey)
138
+
instance_info = @mastodon.instance_info
138
139
139
-
if @config['extract_link_from_quotes']
140
+
if instance_info.dig('api_versions', 'mastodon').to_i >= 7
140
141
quoted_record = fetch_record_by_at_uri(quote_uri)
141
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)
142
157
quote_link = link_embed(quoted_record)
143
158
144
159
if quote_link.nil?
···
151
166
end
152
167
end
153
168
154
-
append_link(text, link_to_append) unless text.include?(link_to_append)
169
+
append_link(text, link_to_append) unless quote_id || text.include?(link_to_append)
155
170
end
156
171
end
157
172
···
191
206
text += "\n\n" + tags.map { |t| '#' + t.gsub(' ', '') }.join(' ')
192
207
end
193
208
194
-
@mastodon.post_status(text, media_ids, mastodon_parent_id)
209
+
@mastodon.post_status(text, media_ids: media_ids, parent_id: mastodon_parent_id, quoted_status_id: quote_id)
195
210
end
196
211
197
212
def fetch_record_by_at_uri(quote_uri)
198
213
repo, collection, rkey = quote_uri.split('/')[2..4]
199
-
pds = DID.new(repo).get_document.pds_endpoint
214
+
pds = DID.new(repo).document.pds_host
200
215
sky = Minisky.new(pds, nil)
201
216
resp = sky.get_request('com.atproto.repo.getRecord', { repo: repo, collection: collection, rkey: rkey })
202
217
resp['value']