tootify 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 830e4d6426a58f7d0642bd530ffabd53ada4df9614049b1edbccb33861fef1f7
4
+ data.tar.gz: 2e32af1015d8f4e6b19930f4e5c2e0967845718092e6e2ed61f8a2c42830d224
5
+ SHA512:
6
+ metadata.gz: 5ad9ef7bc3a2cd50c51a95ace8a2608066b0c7edadfe1491ceea5be14b85c66c9f83e94aa29cc1d3d3109ed9a5cd578570bf1b727d4799a9a618d08b4e43a23e
7
+ data.tar.gz: 29a67956184cf61c92380574f4ff8d12f3d5509fb248a6d3ca4cefaf949c58d4be494ec013c52701f631a86b5b842005656a94655beabf9aafa6b94c70c7e3cd
@@ -0,0 +1,52 @@
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
+ pds = did.get_document.pds_endpoint.gsub('https://', '')
21
+
22
+ @sky.host = pds
23
+ @sky.user.id = handle
24
+ @sky.user.pass = password
25
+
26
+ @sky.log_in
27
+ end
28
+
29
+ def fetch_likes
30
+ json = @sky.get_request('com.atproto.repo.listRecords', {
31
+ repo: @sky.user.did,
32
+ collection: 'app.bsky.feed.like',
33
+ limit: 100
34
+ })
35
+
36
+ json['records']
37
+ end
38
+
39
+ def fetch_record(repo, collection, rkey)
40
+ @sky.get_request('com.atproto.repo.getRecord', { repo: repo, collection: collection, rkey: rkey })
41
+ end
42
+
43
+ def delete_record_at(uri)
44
+ repo, collection, rkey = uri.split('/')[2..4]
45
+
46
+ begin
47
+ @sky.post_request('com.atproto.repo.deleteRecord', { repo: repo, collection: collection, rkey: rkey })
48
+ rescue JSON::ParserError
49
+ # todo
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,27 @@
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
@@ -0,0 +1,50 @@
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
33
+
34
+ @config['handle'] = handle
35
+ @config['access_token'] = api.access_token
36
+ @config['user_id'] = info['id']
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
+
45
+ def post_status(text)
46
+ instance = @config['handle'].split('@').last
47
+ api = MastodonAPI.new(instance, @config['access_token'])
48
+ api.post_status(text)
49
+ end
50
+ end
@@ -0,0 +1,107 @@
1
+ require 'json'
2
+ require 'net/http'
3
+ require 'uri'
4
+
5
+ class MastodonAPI
6
+ class UnauthenticatedError < StandardError
7
+ end
8
+
9
+ class UnexpectedResponseError < StandardError
10
+ end
11
+
12
+ class APIError < StandardError
13
+ attr_reader :response
14
+
15
+ def initialize(response)
16
+ @response = response
17
+ super("APIError #{response.code}: #{response.body}")
18
+ end
19
+
20
+ def status
21
+ response.code.to_i
22
+ end
23
+ end
24
+
25
+ attr_accessor :access_token
26
+
27
+ def initialize(host, access_token = nil)
28
+ @host = host
29
+ @root = "https://#{@host}/api/v1"
30
+ @access_token = access_token
31
+ end
32
+
33
+ def oauth_login_with_password(client_id, client_secret, email, password, scopes)
34
+ params = {
35
+ client_id: client_id,
36
+ client_secret: client_secret,
37
+ grant_type: 'password',
38
+ scope: scopes,
39
+ username: email,
40
+ password: password
41
+ }
42
+
43
+ post_json("https://#{@host}/oauth/token", params)
44
+ end
45
+
46
+ def account_info
47
+ raise UnauthenticatedError.new unless @access_token
48
+ get_json("/accounts/verify_credentials")
49
+ end
50
+
51
+ def lookup_account(username)
52
+ json = get_json("/accounts/lookup", { acct: username })
53
+ raise UnexpectedResponseError.new unless json.is_a?(Hash) && json['id'].is_a?(String)
54
+ json
55
+ end
56
+
57
+ def account_statuses(user_id, params = {})
58
+ get_json("/accounts/#{user_id}/statuses", params)
59
+ end
60
+
61
+ def post_status(text)
62
+ post_json("/statuses", {
63
+ status: text
64
+ })
65
+ end
66
+
67
+ def get_json(path, params = {})
68
+ url = URI(path.start_with?('https://') ? path : @root + path)
69
+ url.query = URI.encode_www_form(params) if params
70
+
71
+ headers = {}
72
+ headers['Authorization'] = "Bearer #{@access_token}" if @access_token
73
+
74
+ response = Net::HTTP.get_response(url, headers)
75
+ status = response.code.to_i
76
+
77
+ if status / 100 == 2
78
+ JSON.parse(response.body)
79
+ elsif status / 100 == 3
80
+ get_json(response['Location'])
81
+ else
82
+ raise APIError.new(response)
83
+ end
84
+ end
85
+
86
+ def post_json(path, params = {})
87
+ url = URI(path.start_with?('https://') ? path : @root + path)
88
+
89
+ headers = {}
90
+ headers['Authorization'] = "Bearer #{@access_token}" if @access_token
91
+
92
+ request = Net::HTTP::Post.new(url, headers)
93
+ request.form_data = params
94
+
95
+ response = Net::HTTP.start(url.hostname, url.port, :use_ssl => true) do |http|
96
+ http.request(request)
97
+ end
98
+
99
+ status = response.code.to_i
100
+
101
+ if status / 100 == 2
102
+ JSON.parse(response.body)
103
+ else
104
+ raise APIError.new(response)
105
+ end
106
+ end
107
+ end
data/app/tootify.rb ADDED
@@ -0,0 +1,61 @@
1
+ require 'io/console'
2
+
3
+ require_relative 'bluesky_account'
4
+ require_relative 'mastodon_account'
5
+
6
+ class Tootify
7
+ def initialize
8
+ @bluesky = BlueskyAccount.new
9
+ @mastodon = MastodonAccount.new
10
+ end
11
+
12
+ def login_bluesky(handle)
13
+ handle = handle.gsub(/^@/, '')
14
+
15
+ print "App password: "
16
+ password = STDIN.noecho(&:gets).chomp
17
+ puts
18
+
19
+ @bluesky.login_with_password(handle, password)
20
+ end
21
+
22
+ def login_mastodon(handle)
23
+ print "Email: "
24
+ email = STDIN.gets.chomp
25
+
26
+ print "Password: "
27
+ password = STDIN.noecho(&:gets).chomp
28
+ puts
29
+
30
+ @mastodon.oauth_login(handle, email, password)
31
+ end
32
+
33
+ def sync
34
+ likes = @bluesky.fetch_likes
35
+
36
+ likes.each do |r|
37
+ like_uri = r['uri']
38
+ post_uri = r['value']['subject']['uri']
39
+ repo, collection, rkey = post_uri.split('/')[2..4]
40
+
41
+ next unless repo == @bluesky.did && collection == 'app.bsky.feed.post'
42
+
43
+ begin
44
+ record = @bluesky.fetch_record(repo, collection, rkey)
45
+ rescue Minisky::ClientErrorResponse => e
46
+ puts "Record not found: #{post_uri}"
47
+ @bluesky.delete_record_at(like_uri)
48
+ next
49
+ end
50
+
51
+ post_to_mastodon(record['value'])
52
+
53
+ @bluesky.delete_record_at(like_uri)
54
+ end
55
+ end
56
+
57
+ def post_to_mastodon(record)
58
+ p record
59
+ p @mastodon.post_status(record['text'])
60
+ end
61
+ end
metadata ADDED
@@ -0,0 +1,162 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tootify
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Kuba Suder
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-03-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: didkit
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: mastodon-api
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: minisky
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '0.3'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '0.3'
55
+ - !ruby/object:Gem::Dependency
56
+ name: io-console
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.5'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.5'
69
+ - !ruby/object:Gem::Dependency
70
+ name: json
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '2.5'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '2.5'
83
+ - !ruby/object:Gem::Dependency
84
+ name: net-http
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '0.2'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '0.2'
97
+ - !ruby/object:Gem::Dependency
98
+ name: uri
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '0.13'
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '0.13'
111
+ - !ruby/object:Gem::Dependency
112
+ name: yaml
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '0.1'
118
+ type: :runtime
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '0.1'
125
+ description: Experimental Bluesky->Mastodon cross-poster
126
+ email:
127
+ - jakub.suder@gmail.com
128
+ executables: []
129
+ extensions: []
130
+ extra_rdoc_files: []
131
+ files:
132
+ - app/bluesky_account.rb
133
+ - app/bluesky_client.rb
134
+ - app/mastodon_account.rb
135
+ - app/mastodon_api.rb
136
+ - app/tootify.rb
137
+ homepage: https://github.com/mackuba/tootify
138
+ licenses:
139
+ - Zlib
140
+ metadata:
141
+ bug_tracker_uri: https://github.com/mackuba/tootify/issues
142
+ source_code_uri: https://github.com/mackuba/tootify
143
+ post_install_message:
144
+ rdoc_options: []
145
+ require_paths:
146
+ - app
147
+ required_ruby_version: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - ">="
150
+ - !ruby/object:Gem::Version
151
+ version: 3.0.0
152
+ required_rubygems_version: !ruby/object:Gem::Requirement
153
+ requirements:
154
+ - - ">="
155
+ - !ruby/object:Gem::Version
156
+ version: '0'
157
+ requirements: []
158
+ rubygems_version: 3.4.10
159
+ signing_key:
160
+ specification_version: 4
161
+ summary: Toot toooooooot
162
+ test_files: []