Toot toooooooot (Bluesky-Mastodon cross-poster)

Compare changes

Choose any two refs to compare.

+1 -1
.gitignore
··· 1 1 .DS_Store 2 - config/*.csv 3 2 config/*.yml 3 + db/history.sqlite3*
+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
··· 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
··· 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
··· 17 17 exit 1 18 18 end 19 19 20 - pds = did.get_document.pds_endpoint.gsub('https://', '') 21 - 22 - @sky.host = pds 20 + @sky.host = did.document.pds_host 23 21 @sky.user.id = handle 24 22 @sky.user.pass = password 25 23
+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
··· 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
+45 -6
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 ··· 103 132 104 133 def get_media(media_id) 105 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] 106 145 end 107 146 108 147 def get_json(path, params = {})
+10
app/post.rb
··· 1 + require 'active_record' 2 + 3 + class Post < ActiveRecord::Base 4 + validates_presence_of :mastodon_id, :bluesky_rkey 5 + 6 + validates_length_of :mastodon_id, maximum: 50 7 + validates_length_of :bluesky_rkey, is: 13 8 + 9 + validates_uniqueness_of :bluesky_rkey 10 + end
-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
··· 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 ··· 63 58 repo, collection, rkey = post_uri.split('/')[2..4] 64 59 65 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 66 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 ··· 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
··· 1 + class CreatePosts < ActiveRecord::Migration[7.2] 2 + def change 3 + create_table :posts do |t| 4 + t.string :bluesky_rkey, null: false 5 + t.string :mastodon_id, null: false 6 + end 7 + 8 + add_index :posts, :bluesky_rkey, unique: true 9 + end 10 + end
+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