cross-post 0.1.2 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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