+3
-1
Gemfile
+3
-1
Gemfile
···
1
1
source 'https://rubygems.org'
2
2
3
3
gem 'didkit', '~> 0.2'
4
-
gem 'mastodon-api', git: 'https://github.com/mastodon/mastodon-api.git'
5
4
gem 'minisky', '~> 0.5'
6
5
7
6
gem 'io-console', '~> 0.5'
···
9
8
gem 'net-http', '~> 0.2'
10
9
gem 'uri', '~> 0.13'
11
10
gem 'yaml', '~> 0.1'
11
+
12
+
gem "activerecord", "~> 7.2"
13
+
gem "sqlite3", "~> 2.7"
+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
1
GEM
12
2
remote: https://rubygems.org/
13
3
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)
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)
40
35
minisky (0.5.0)
41
36
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)
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)
52
53
53
54
PLATFORMS
54
55
aarch64-linux
···
57
58
x86_64-linux
58
59
59
60
DEPENDENCIES
61
+
activerecord (~> 7.2)
60
62
didkit (~> 0.2)
61
63
io-console (~> 0.5)
62
64
json (~> 2.5)
63
-
mastodon-api!
64
65
minisky (~> 0.5)
65
66
net-http (~> 0.2)
67
+
sqlite3 (~> 2.7)
66
68
uri (~> 0.13)
67
69
yaml (~> 0.1)
68
70
+9
-4
README.md
+9
-4
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
···
55
57
56
58
* `bluesky.yml` โ created when you log in, stores Bluesky user ID/password and access tokens
57
59
* `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
60
* `tootify.yml` - optional additional configuration
60
61
61
62
The config in `tootify.yml` currently supports one option:
62
63
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)
64
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
+
65
68
66
69
## Credits
67
70
68
-
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)).
69
72
70
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
1
require 'yaml'
3
-
4
2
require_relative 'mastodon_api'
5
3
6
4
class MastodonAccount
7
-
APP_NAME = "tootify"
5
+
APP_NAME = "Tootify"
8
6
CONFIG_FILE = File.expand_path(File.join(__dir__, '..', 'config', 'mastodon.yml'))
9
-
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(' ')
10
15
11
16
def initialize
12
17
@config = File.exist?(CONFIG_FILE) ? YAML.load(File.read(CONFIG_FILE)) : {}
···
20
25
File.write(CONFIG_FILE, YAML.dump(@config))
21
26
end
22
27
23
-
def oauth_login(handle, email, password)
28
+
def oauth_login(handle)
24
29
instance = handle.split('@').last
25
-
app_response = register_oauth_app(instance, OAUTH_SCOPES)
30
+
app_response = register_oauth_app(instance)
26
31
27
32
api = MastodonAPI.new(instance)
28
33
29
-
json = api.oauth_login_with_password(
30
-
app_response.client_id,
31
-
app_response.client_secret,
32
-
email, password, OAUTH_SCOPES
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)
34
47
35
48
api.access_token = json['access_token']
36
49
info = api.account_info
···
41
54
save_config
42
55
end
43
56
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)
57
+
def register_oauth_app(instance)
58
+
api = MastodonAPI.new(instance)
59
+
api.register_oauth_app(APP_NAME, OAUTH_SCOPES)
47
60
end
48
61
49
-
def post_status(text, media_ids = nil, parent_id = nil)
62
+
def instance_info
50
63
instance = @config['handle'].split('@').last
51
64
api = MastodonAPI.new(instance, @config['access_token'])
52
-
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)
53
72
end
54
73
55
74
def upload_media(data, filename, content_type, alt = nil)
···
57
76
api = MastodonAPI.new(instance, @config['access_token'])
58
77
api.upload_media(data, filename, content_type, alt)
59
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
60
85
end
+50
-8
app/mastodon_api.rb
+50
-8
app/mastodon_api.rb
···
3
3
require 'uri'
4
4
5
5
class MastodonAPI
6
+
CODE_REDIRECT_URI = 'urn:ietf:wg:oauth:2.0:oob'
7
+
6
8
class UnauthenticatedError < StandardError
7
9
end
8
10
···
32
34
@access_token = access_token
33
35
end
34
36
35
-
def oauth_login_with_password(client_id, client_secret, email, password, scopes)
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)
36
61
params = {
37
62
client_id: client_id,
38
63
client_secret: client_secret,
39
-
grant_type: 'password',
40
-
scope: scopes,
41
-
username: email,
42
-
password: password
64
+
redirect_uri: CODE_REDIRECT_URI,
65
+
grant_type: 'authorization_code',
66
+
code: code
43
67
}
44
68
45
69
post_json("https://#{@host}/oauth/token", params)
···
50
74
get_json("/accounts/verify_credentials")
51
75
end
52
76
77
+
def instance_info
78
+
get_json("https://#{@host}/api/v2/instance")
79
+
end
80
+
53
81
def lookup_account(username)
54
82
json = get_json("/accounts/lookup", { acct: username })
55
83
raise UnexpectedResponseError.new unless json.is_a?(Hash) && json['id'].is_a?(String)
···
60
88
get_json("/accounts/#{user_id}/statuses", params)
61
89
end
62
90
63
-
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)
64
92
params = { status: text }
65
93
params['media_ids[]'] = media_ids if media_ids
66
94
params['in_reply_to_id'] = parent_id if parent_id
95
+
params['quoted_status_id'] = quoted_status_id if quoted_status_id
67
96
68
97
post_json("/statuses", params)
69
98
end
···
73
102
headers = { 'Authorization' => "Bearer #{@access_token}" }
74
103
75
104
form_data = [
76
-
['file', data, { :filename => filename, :content_type => content_type }],
77
-
['description', alt.to_s.force_encoding('ASCII-8BIT')]
105
+
['file', data, { :filename => filename, :content_type => content_type }]
78
106
]
79
107
108
+
if alt
109
+
form_data << ['description', alt.force_encoding('ASCII-8BIT')]
110
+
end
111
+
80
112
request = Net::HTTP::Post.new(url, headers)
81
113
request.set_form(form_data, 'multipart/form-data')
82
114
···
100
132
101
133
def get_media(media_id)
102
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]
103
145
end
104
146
105
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
+73
-30
app/tootify.rb
+73
-30
app/tootify.rb
···
2
2
require 'yaml'
3
3
4
4
require_relative 'bluesky_account'
5
+
require_relative 'database'
5
6
require_relative 'mastodon_account'
6
-
require_relative 'post_history'
7
+
require_relative 'post'
7
8
8
9
class Tootify
9
10
CONFIG_FILE = File.expand_path(File.join(__dir__, '..', 'config', 'tootify.yml'))
···
13
14
def initialize
14
15
@bluesky = BlueskyAccount.new
15
16
@mastodon = MastodonAccount.new
16
-
@history = PostHistory.new
17
17
@config = load_config
18
18
@check_interval = 60
19
+
20
+
Database.init
19
21
end
20
22
21
23
def load_config
···
26
28
end
27
29
end
28
30
29
-
def login_bluesky(handle)
31
+
def login_to_bluesky(handle)
30
32
handle = handle.gsub(/^@/, '')
31
33
32
34
print "App password: "
···
36
38
@bluesky.login_with_password(handle, password)
37
39
end
38
40
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)
41
+
def login_to_mastodon(handle)
42
+
@mastodon.oauth_login(handle)
48
43
end
49
44
50
45
def sync
···
64
59
65
60
next unless repo == @bluesky.did && collection == 'app.bsky.feed.post'
66
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
+
67
68
begin
68
69
record = @bluesky.fetch_record(repo, collection, rkey)
69
70
rescue Minisky::ClientErrorResponse => e
70
-
puts "Record not found: #{post_uri}"
71
+
log "Record not found: #{post_uri}"
71
72
@bluesky.delete_record_at(like_uri)
72
73
next
73
74
end
···
77
78
prepo = parent_uri.split('/')[2]
78
79
79
80
if prepo != @bluesky.did
80
-
puts "Skipping reply to someone else"
81
+
log "Skipping reply to someone else"
81
82
@bluesky.delete_record_at(like_uri)
82
83
next
83
84
else
···
93
94
94
95
if reply = record['reply']
95
96
parent_uri = reply['parent']['uri']
96
-
prkey = parent_uri.split('/')[4]
97
+
parent_rkey = parent_uri.split('/')[4]
97
98
98
-
if parent_id = @history[prkey]
99
-
mastodon_parent_id = parent_id
99
+
if parent_post = Post.find_by(bluesky_rkey: parent_rkey)
100
+
mastodon_parent_id = parent_post.mastodon_id
100
101
else
101
-
puts "Skipping reply to a post that wasn't cross-posted"
102
+
log "Skipping reply to a post that wasn't cross-posted"
102
103
@bluesky.delete_record_at(like_uri)
103
104
next
104
105
end
105
106
end
106
107
107
108
response = post_to_mastodon(record, mastodon_parent_id)
108
-
p response
109
+
log(response)
109
110
110
-
@history.add(rkey, response['id'])
111
+
Post.create!(bluesky_rkey: rkey, mastodon_id: response['id'])
112
+
111
113
@bluesky.delete_record_at(like_uri)
112
114
end
113
115
end
···
120
122
end
121
123
122
124
def post_to_mastodon(record, mastodon_parent_id = nil)
123
-
p record
125
+
log(record)
124
126
125
127
text = expand_facets(record)
126
128
···
133
135
134
136
if collection == 'app.bsky.feed.post'
135
137
link_to_append = bsky_post_link(repo, rkey)
138
+
instance_info = @mastodon.instance_info
136
139
137
-
if @config['extract_link_from_quotes']
140
+
if instance_info.dig('api_versions', 'mastodon').to_i >= 7
138
141
quoted_record = fetch_record_by_at_uri(quote_uri)
139
142
140
-
if link_from_quote = link_embed(quoted_record)
141
-
link_to_append = link_from_quote
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
142
152
end
143
153
end
144
154
145
-
append_link(text, link_to_append) unless text.include?(link_to_append)
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)
146
170
end
147
171
end
148
172
···
154
178
cid = embed['image']['ref']['$link']
155
179
mime = embed['image']['mimeType']
156
180
157
-
if alt.length > @mastodon.max_alt_length
181
+
if alt && alt.length > @mastodon.max_alt_length
158
182
alt = alt[0...@mastodon.max_alt_length - 3] + "(โฆ)"
159
183
end
160
184
···
168
192
cid = embed['video']['ref']['$link']
169
193
mime = embed['video']['mimeType']
170
194
171
-
if alt.length > @mastodon.max_alt_length
195
+
if alt && alt.length > @mastodon.max_alt_length
172
196
alt = alt[0...@mastodon.max_alt_length - 3] + "(โฆ)"
173
197
end
174
198
···
182
206
text += "\n\n" + tags.map { |t| '#' + t.gsub(' ', '') }.join(' ')
183
207
end
184
208
185
-
@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)
186
210
end
187
211
188
212
def fetch_record_by_at_uri(quote_uri)
189
213
repo, collection, rkey = quote_uri.split('/')[2..4]
190
-
pds = DID.new(repo).get_document.pds_endpoint
214
+
pds = DID.new(repo).document.pds_host
191
215
sky = Minisky.new(pds, nil)
192
216
resp = sky.get_request('com.atproto.repo.getRecord', { repo: repo, collection: collection, rkey: rkey })
193
217
resp['value']
···
211
235
end
212
236
213
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/') }
214
252
end
215
253
216
254
def link_embed(record)
···
274
312
end
275
313
276
314
def append_link(text, link)
277
-
if link =~ /^https:\/\/bsky\.app\/profile\/.+\/post\/.+/
315
+
if link =~ %r{^https://bsky\.app/profile/.+/post/.+}
278
316
text << "\n" unless text.end_with?("\n")
279
317
text << "\n"
280
318
text << "RE: " + link
···
286
324
287
325
def bsky_post_link(repo, rkey)
288
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}"
289
332
end
290
333
end
+10
db/migrate/001_create_posts.rb
+10
db/migrate/001_create_posts.rb
+5
-3
tootify
+5
-3
tootify
···
4
4
require_relative 'app/tootify'
5
5
6
6
def run(argv)
7
+
$stdout.sync = true
8
+
7
9
options, args = argv.partition { |x| x.start_with?('-') }
8
10
9
11
app = Tootify.new
···
36
38
app = Tootify.new
37
39
38
40
if name =~ /\A[^@]+@[^@]+\z/
39
-
app.login_mastodon(name)
40
-
elsif name =~ /\A@[^@]+\z/
41
-
app.login_bluesky(name)
41
+
app.login_to_mastodon(name)
42
+
elsif name =~ /\A@?[^@]+\z/
43
+
app.login_to_bluesky(name)
42
44
elsif name.nil?
43
45
print_help
44
46
else