cross-post 0.1.2 → 0.2.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 4910a3252598ddc59603847ba064cd2fa7e3bba8
4
- data.tar.gz: 2ff550ca76b44fd3f8c718d00f4fb35f1a4ce092
3
+ metadata.gz: 3b4eba1daf6b5020cabfb1fe6b5439abb8f0a6cf
4
+ data.tar.gz: abe89c4de8fd97f43a31d462fedfadffeb9c934a
5
5
  SHA512:
6
- metadata.gz: 6cf706ae52ac4f9cb4d25b2ebc8bfb2513b2318bc022cdf523b7dd9305e65513009760bb8533359394f218922d29ea74a4f28d7c3507a67d897e75ae2619d5e6
7
- data.tar.gz: 385daf97bf09e15200782e449b78423533206ace6eb262d693712f83aa3da5d4bd3fd3a32b9ab348404ed0933e0af5d821dbbb9865eb95ea6e533b61a1f231de
6
+ metadata.gz: 15d9a991a38801246fb5290c77d5c3cd99775d9d571aa0ae43f9239a190733bb7316c41bb6737bb355a898ed8a299910826feda8fe5c5ee25d3952b1caa817e1
7
+ data.tar.gz: dbe761e4e3fdea90c350d0292606d47a5728a9ed5799179426274a86b0bb35bed59b4ad240e9a158e375999a8830580b24b89cc6bfe95919d9f268981bf63584
data/Gemfile CHANGED
@@ -2,5 +2,7 @@ source 'https://rubygems.org'
2
2
 
3
3
  gem 'mastodon-api', '~> 1.1.0', require: 'mastodon', git: 'https://github.com/tootsuite/mastodon-api.git'
4
4
  gem 'awesome_print'
5
+ gem 'dotenv'
6
+ gem 'pry'
5
7
 
6
8
  gemspec
data/README.md CHANGED
@@ -8,7 +8,7 @@ To use it:
8
8
 
9
9
  * Clone this repository somewhere (`git clone https://git.imirhil.fr/aeris/cross-post/`)
10
10
  * Install dependencies with Bundler (`bundler install`)
11
- * Create a `$HOME/.cross-post.yml` configuration file, based on the example available [here](https://git.imirhil.fr/aeris/cross-post/src/master/config.yml)
11
+ * Create a `$HOME/.config/cross-post/config.yml` configuration file, based on the example available [here](https://git.imirhil.fr/aeris/cross-post/src/master/config.yml)
12
12
  * Register the app on Twitter (`bundle exec bin/twitter-register`)
13
13
  * You can reuse my Twitter app OAuth credentials, or register a new app from scratch [here](https://apps.twitter.com/)
14
14
  * Register the app on Mastodon (`bundle exec bin/mastodon-register`)
@@ -18,7 +18,3 @@ To use it:
18
18
  * Enjoy
19
19
 
20
20
  If needed, a SystemD unit example is available [here](https://git.imirhil.fr/aeris/cross-post/src/master/mastodon-twitter.service)
21
-
22
- # Todo
23
-
24
- * Publishing on [RubyGems](https://rubygems.org/)
@@ -6,18 +6,24 @@ require 'launchy'
6
6
  require 'uri'
7
7
 
8
8
  config = CrossPost::Config.new
9
- print 'Mastodon URL ? '
10
- url = gets.chomp
11
- url = "https://#{url}" if URI.parse(url).class == URI::Generic
12
- config['mastodon.url'] = url
9
+ url = config['mastodon.url']
10
+ unless url
11
+ print 'Mastodon URL ? '
12
+ url = gets.chomp
13
+ url = "https://#{url}" if URI.parse(url).class == URI::Generic
14
+ config['mastodon.url'] = url
15
+ end
13
16
 
14
17
  client_id, client_secret = unless config['mastodon.consumer']
18
+ puts 'Creating new app'
15
19
  token = SecureRandom.hex 64
16
20
  redirect_url = 'urn:ietf:wg:oauth:2.0:oob'
17
21
 
18
22
  client = Mastodon::REST::Client.new base_url: url, bearer_token: token
19
23
  app = client.create_app 'CrossPost', redirect_url,
20
24
  'read write', 'https://git.imirhil.fr/aeris/cross-post/'
25
+ config['mastodon.consumer.key'] = app.client_id
26
+ config['mastodon.consumer.secret'] = app.client_secret
21
27
  [app.client_id, app.client_secret]
22
28
  else
23
29
  [config['mastodon.consumer.key'], config['mastodon.consumer.secret']]
data/config.yml CHANGED
@@ -4,6 +4,7 @@ twitter:
4
4
  key: 0jXQtXCpN6AGpPnqv4fzPvVY9
5
5
  secret: Be0jpY6gMt7CxhkWsPWqXBztogaNuyEJkIqWVB3U2tPY99p4YS
6
6
  mastodon:
7
+ consumer:
8
+ key: 83d0881ebeab0007a6d425db12b6a2dafdca99a18dd4e0e1816bc30f9e64f518
9
+ secret: 048ec7390d316aa7a5c071f5228ab259bcbc3ddb3338028d433183a1865ec135
7
10
  user: aeris
8
- url: http://localhost:3000
9
- token: 34b02283e2af4631040e2ac0d5654d871dac4000caa619b4516a7ffa5f11f74a
data/cross-post.gemspec CHANGED
@@ -8,7 +8,7 @@ Gem::Specification.new do |spec|
8
8
  spec.version = CrossPost::VERSION
9
9
  spec.authors = ['aeris']
10
10
  spec.email = ['aeris@imirhil.fr']
11
- spec.summary = "Cross post to Mastodon and Twitter"
11
+ spec.summary = 'Cross post to Mastodon and Twitter'
12
12
  spec.homepage = 'https://git.imirhil.fr/aeris/cross-post/'
13
13
  spec.license = 'AGPL-3.0+'
14
14
 
@@ -17,6 +17,7 @@ Gem::Specification.new do |spec|
17
17
  spec.test_files = spec.files.grep %r{^(test|spec|features)/}
18
18
 
19
19
  spec.add_development_dependency 'bundler', '~> 1.15', '>= 1.15.4'
20
+ spec.add_development_dependency 'rspec', '~> 3.6.0', '>= 3.6.0'
20
21
 
21
22
  spec.add_dependency 'twitter', '~> 6.1', '>= 6.1.0'
22
23
  spec.add_dependency 'mastodon-api', '~> 1.1', '>= 1.1.0'
data/lib/cross-post.rb CHANGED
@@ -11,7 +11,7 @@ OpenURI::Buffer.const_set 'StringMax', 0
11
11
 
12
12
  class CrossPost
13
13
  LOGGER = Logger.new STDERR
14
- LOGGER.level = Logger.const_get ENV.fetch('LOG', 'INFO')
14
+ LOGGER.level = Logger.const_get ENV.fetch('LOG', 'INFO').upcase
15
15
  LOGGER.formatter = proc do |severity, time, _, msg|
16
16
  time = time.strftime '%Y-%m-%dT%H:%M:%S.%6N'.freeze
17
17
  "#{time} #{severity} #{msg}\n"
@@ -1,40 +1,120 @@
1
1
  require 'yaml'
2
+ require 'fileutils'
2
3
 
3
4
  class CrossPost
4
5
  class Config
5
- def initialize
6
- @file = ENV.fetch 'CROSS_POST_CONFIG', File.join(Dir.home, '.cross-post.yml')
7
- File.open(@file) { |f| @config = YAML.safe_load f }
8
- end
6
+ DEFAULT_CONFIG_FOLDER = File.join Dir.home, '.config/cross-post'
7
+ DEFAULT_CONFIG_FILE = 'config.yml'
9
8
 
10
- def [](key)
11
- current = @config
12
- key.split(/\./).each do |k|
13
- current = current[k]
14
- return nil if current.nil?
9
+ class SubConfig
10
+ def initialize(config = {})
11
+ @config = config
12
+ end
13
+
14
+ def each(&block)
15
+ @config.each &block
16
+ end
17
+
18
+ def [](key)
19
+ case key
20
+ when String
21
+ current = @config
22
+ key.split(/\./).each do |k|
23
+ current = current[k]
24
+ return nil if current.nil?
25
+ end
26
+ current
27
+ else
28
+ @config[key]
29
+ end
30
+ end
31
+
32
+ def fetch(key, default = nil)
33
+ self[key] || default
15
34
  end
16
- current
17
- end
18
35
 
19
- def []=(key, value)
20
- *key, last = key.split(/\./)
21
- current = @config
22
- key.each do |k|
23
- next_ = current[k]
24
- case next_
25
- when nil
26
- next_ = current[k] = {}
27
- when Hash
36
+ def []=(key, value)
37
+ case key
38
+ when String
39
+ *key, last = key.to_s.split(/\./)
40
+ current = @config
41
+ key.each do |k|
42
+ next_ = current[k]
43
+ case next_
44
+ when nil
45
+ next_ = current[k] = {}
46
+ when Hash
47
+ else
48
+ raise "Invalid entry, Hash expected, had #{next_.class} (#{next_})"
49
+ end
50
+ current = next_
51
+ end
52
+ current[last] = value
28
53
  else
29
- raise "Invalid entry, Hash expected, had #{next_.class} (#{next_})"
54
+ @config[key] = value
30
55
  end
31
- current = next_
32
56
  end
33
- current[last] = value
34
57
  end
35
58
 
36
- def save
37
- File.write @file, YAML.dump(@config)
59
+ class FifoSubConfig < SubConfig
60
+ def initialize(size = 100)
61
+ @size = size
62
+ @keys = []
63
+ super({})
64
+ end
65
+
66
+ def []=(key, value)
67
+ @keys.delete key
68
+ value = super key, value
69
+ @keys << key
70
+ while @keys.size > @size
71
+ key = @keys.delete_at 0
72
+ @config.delete key
73
+ end
74
+ value
75
+ end
76
+ end
77
+
78
+ class FileSubConfig < SubConfig
79
+ def initialize(file)
80
+ @file = file
81
+ super YAML.load_file @file
82
+ end
83
+
84
+ def put(key, value, save: false)
85
+ self[key] = value
86
+ self.save if save
87
+ end
88
+
89
+ def save
90
+ LOGGER.debug "Saving #{@file}"
91
+ yaml = YAML.dump @config
92
+ File.write @file, yaml
93
+ end
94
+ end
95
+
96
+ def initialize
97
+ @configs = {}
98
+ @dir = ENV.fetch 'CONFIG_FOLDER', DEFAULT_CONFIG_FOLDER
99
+ file = ENV.fetch 'CONFIG_FILE', DEFAULT_CONFIG_FILE
100
+ self.load :settings, file
101
+ self.load :users
102
+ self[:posts] = FifoSubConfig.new
103
+ end
104
+
105
+ def [](name)
106
+ @configs[name]
107
+ end
108
+
109
+ def []=(name, value)
110
+ @configs[name] = value
111
+ end
112
+
113
+ def load(name, file = nil)
114
+ file ||= "#{name}.yml"
115
+ file = File.join @dir, file
116
+ FileUtils.touch file unless File.exist? file
117
+ self[name] = FileSubConfig.new file
38
118
  end
39
119
  end
40
120
  end
@@ -5,12 +5,22 @@ require 'awesome_print'
5
5
  class CrossPost
6
6
  class Mastodon
7
7
  def initialize(config)
8
- url = config['mastodon.url']
9
- token = config['mastodon.token']
10
- user = config['mastodon.user']
11
- @user_url = URI.join(url, "/@#{user}").to_s
12
- @client = ::Mastodon::REST::Client.new base_url: url, bearer_token: token
13
- @stream = ::Mastodon::Streaming::Client.new base_url: url, bearer_token: token
8
+ settings = config[:settings]
9
+ @posts = config[:posts]
10
+
11
+ url = settings['mastodon.url']
12
+ token = settings['mastodon.token']
13
+ user = settings['mastodon.user']
14
+
15
+ LOGGER.debug "Mastodon base URL: #{url}"
16
+ @client = ::Mastodon::REST::Client.new base_url: url, bearer_token: token
17
+
18
+ stream_url = settings.fetch 'mastodon.stream_url', url
19
+ LOGGER.debug "Mastodon stream URL: #{stream_url}"
20
+ @stream = ::Mastodon::Streaming::Client.new base_url: stream_url, bearer_token: token
21
+
22
+ @user_url = URI.join(ENV.fetch('BASE_USER_URL', url), "/@#{user}").to_s
23
+ LOGGER.debug "Mastodon user URL: #{@user_url}"
14
24
  end
15
25
 
16
26
  def feed(twitter)
@@ -18,14 +28,14 @@ class CrossPost
18
28
  begin
19
29
  case object
20
30
  when ::Mastodon::Status
21
- next if reject? object
22
31
  LOGGER.info { 'Receiving status' }
23
- LOGGER.debug { status.ap }
32
+ LOGGER.debug { object.ai }
33
+ next if reject? object
24
34
  twitter.post_status object
25
35
  end
26
36
  rescue => e
27
37
  LOGGER.error e
28
- #raise
38
+ raise
29
39
  end
30
40
  end
31
41
  end
@@ -33,9 +43,11 @@ class CrossPost
33
43
  private
34
44
 
35
45
  def reject?(status)
36
- status.account.url != @user_url or
37
- status.visibility != 'public' or
38
- status.in_reply_to_id
46
+ return true if status.account.url != @user_url or
47
+ status.visibility != 'public'
48
+ reply = status.in_reply_to_id
49
+ return true if reply and !@posts[reply]
50
+ false
39
51
  end
40
52
  end
41
53
  end
@@ -2,43 +2,66 @@ require 'twitter'
2
2
  require 'twitter-text'
3
3
  require 'sanitize'
4
4
  require 'cgi'
5
+ require 'ostruct'
6
+
7
+ ::Twitter::Validation::MAX_LENGTH = 280
5
8
 
6
9
  class CrossPost
7
10
  class Twitter
8
11
  def initialize(config)
12
+ settings = config[:settings]
13
+ @posts = config[:posts]
14
+ @users = config[:users]
15
+
9
16
  config = {
10
- consumer_key: config['twitter.consumer.key'],
11
- consumer_secret: config['twitter.consumer.secret'],
12
- access_token: config['twitter.access.token'],
13
- access_token_secret: config['twitter.access.secret']
17
+ consumer_key: settings['twitter.consumer.key'],
18
+ consumer_secret: settings['twitter.consumer.secret'],
19
+ access_token: settings['twitter.access.token'],
20
+ access_token_secret: settings['twitter.access.secret']
14
21
  }
15
22
  @client = ::Twitter::REST::Client.new config
16
23
  @stream = ::Twitter::Streaming::Client.new config
17
24
  end
18
25
 
19
- def post(content, media = [])
20
- media = media.collect { |f| @client.upload f }
26
+ def post(content, media = [], id:, reply_to:)
27
+ reply_to = OpenStruct.new id: reply_to unless reply_to.respond_to? :id
21
28
 
29
+ media = media.collect { |f| @client.upload f }
22
30
  parts = split content
23
- last = nil
24
-
25
31
  unless media.empty?
26
32
  first, *parts = parts
27
- last = @client.update first, media_ids: media.join(',')
33
+ reply_to = @client.update first, media_ids: media.join(','), in_reply_to_status: reply_to
28
34
  end
29
- parts.each { |p| last = @client.update p, in_reply_to_status: last }
35
+ parts.each { |p| reply_to = @client.update p, in_reply_to_status: reply_to }
36
+
37
+ reply_to = reply_to.id if reply_to.respond_to? :id
38
+ @posts[id] = reply_to
30
39
  end
31
40
 
41
+ WHITESPACE_TAGS = {
42
+ 'br' => { before: "\n", after: '' },
43
+ 'div' => { before: "\n", after: "\n" },
44
+ 'p' => { before: "\n", after: "\n" }
45
+ }.freeze
46
+
32
47
  def post_status(status)
33
- content = Sanitize.clean status.content
48
+ content = status.content
49
+ content = Sanitize.clean(content, whitespace_elements: WHITESPACE_TAGS).strip
34
50
  content = CGI.unescape_html content
51
+
52
+ @users.each do |mastodon, twitter|
53
+ content = content.gsub /@\b#{mastodon}\b/, "@#{twitter}"
54
+ end
55
+
35
56
  media = status.media_attachments.collect { |f| open f.url }
36
57
 
37
58
  LOGGER.info { 'Sending to twitter' }
38
59
  LOGGER.debug { " Content: #{content}" }
39
60
  LOGGER.debug { " Attachments: #{media.size}" }
40
61
 
41
- self.post content, media
62
+ reply = status.in_reply_to_id
63
+ reply_to = reply ? @posts[reply] : nil
64
+ self.post content, media, id: status.id, reply_to: reply_to
42
65
 
43
66
  media.each do |f|
44
67
  f.close
@@ -51,7 +74,7 @@ class CrossPost
51
74
  def split(text)
52
75
  parts = []
53
76
  part = ''
54
- words = text.split ' '
77
+ words = text.split /\ /
55
78
  words.each do |word|
56
79
  old_part = part
57
80
  part += ' ' unless part == ''
@@ -1,3 +1,3 @@
1
1
  class CrossPost
2
- VERSION = '0.1.2'.freeze
2
+ VERSION = '0.2.0'.freeze
3
3
  end
@@ -0,0 +1,19 @@
1
+ require 'cross-post'
2
+
3
+ RSpec.describe CrossPost::Config::FifoSubConfig do
4
+ it 'must remove first value in case of overflow' do
5
+ config = CrossPost::Config::FifoSubConfig.new 2
6
+ config[:foo] = :foo
7
+ config[:bar] = :bar
8
+
9
+ expect(config[:foo]).to be :foo
10
+ expect(config[:bar]).to be :bar
11
+ expect(config[:baz]).to be_nil
12
+
13
+ config[:baz] = :baz
14
+
15
+ expect(config[:foo]).to be_nil
16
+ expect(config[:bar]).to be :bar
17
+ expect(config[:baz]).to be :baz
18
+ end
19
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cross-post
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - aeris
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2017-10-01 00:00:00.000000000 Z
11
+ date: 2018-01-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -30,6 +30,26 @@ dependencies:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
32
  version: 1.15.4
33
+ - !ruby/object:Gem::Dependency
34
+ name: rspec
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: 3.6.0
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: 3.6.0
43
+ type: :development
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - "~>"
48
+ - !ruby/object:Gem::Version
49
+ version: 3.6.0
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: 3.6.0
33
53
  - !ruby/object:Gem::Dependency
34
54
  name: twitter
35
55
  requirement: !ruby/object:Gem::Requirement
@@ -196,6 +216,7 @@ files:
196
216
  - lib/cross-post/version.rb
197
217
  - logo.png
198
218
  - mastodon-twitter.service
219
+ - spec/config_spec.rb
199
220
  homepage: https://git.imirhil.fr/aeris/cross-post/
200
221
  licenses:
201
222
  - AGPL-3.0+
@@ -216,8 +237,9 @@ required_rubygems_version: !ruby/object:Gem::Requirement
216
237
  version: '0'
217
238
  requirements: []
218
239
  rubyforge_project:
219
- rubygems_version: 2.6.11
240
+ rubygems_version: 2.6.14
220
241
  signing_key:
221
242
  specification_version: 4
222
243
  summary: Cross post to Mastodon and Twitter
223
- test_files: []
244
+ test_files:
245
+ - spec/config_spec.rb