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 +4 -4
- data/Gemfile +2 -0
- data/README.md +1 -5
- data/bin/mastodon-register +10 -4
- data/config.yml +3 -2
- data/cross-post.gemspec +2 -1
- data/lib/cross-post.rb +1 -1
- data/lib/cross-post/config.rb +105 -25
- data/lib/cross-post/mastodon.rb +24 -12
- data/lib/cross-post/twitter.rb +36 -13
- data/lib/cross-post/version.rb +1 -1
- data/spec/config_spec.rb +19 -0
- metadata +26 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 3b4eba1daf6b5020cabfb1fe6b5439abb8f0a6cf
|
4
|
+
data.tar.gz: abe89c4de8fd97f43a31d462fedfadffeb9c934a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 15d9a991a38801246fb5290c77d5c3cd99775d9d571aa0ae43f9239a190733bb7316c41bb6737bb355a898ed8a299910826feda8fe5c5ee25d3952b1caa817e1
|
7
|
+
data.tar.gz: dbe761e4e3fdea90c350d0292606d47a5728a9ed5799179426274a86b0bb35bed59b4ad240e9a158e375999a8830580b24b89cc6bfe95919d9f268981bf63584
|
data/Gemfile
CHANGED
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/)
|
data/bin/mastodon-register
CHANGED
@@ -6,18 +6,24 @@ require 'launchy'
|
|
6
6
|
require 'uri'
|
7
7
|
|
8
8
|
config = CrossPost::Config.new
|
9
|
-
|
10
|
-
url
|
11
|
-
|
12
|
-
|
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 =
|
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"
|
data/lib/cross-post/config.rb
CHANGED
@@ -1,40 +1,120 @@
|
|
1
1
|
require 'yaml'
|
2
|
+
require 'fileutils'
|
2
3
|
|
3
4
|
class CrossPost
|
4
5
|
class Config
|
5
|
-
|
6
|
-
|
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
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
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
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
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
|
-
|
54
|
+
@config[key] = value
|
30
55
|
end
|
31
|
-
current = next_
|
32
56
|
end
|
33
|
-
current[last] = value
|
34
57
|
end
|
35
58
|
|
36
|
-
|
37
|
-
|
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
|
data/lib/cross-post/mastodon.rb
CHANGED
@@ -5,12 +5,22 @@ require 'awesome_print'
|
|
5
5
|
class CrossPost
|
6
6
|
class Mastodon
|
7
7
|
def initialize(config)
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
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 {
|
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
|
-
|
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'
|
38
|
-
|
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
|
data/lib/cross-post/twitter.rb
CHANGED
@@ -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:
|
11
|
-
consumer_secret:
|
12
|
-
access_token:
|
13
|
-
access_token_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
|
-
|
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
|
-
|
33
|
+
reply_to = @client.update first, media_ids: media.join(','), in_reply_to_status: reply_to
|
28
34
|
end
|
29
|
-
parts.each { |p|
|
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 =
|
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
|
-
|
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 == ''
|
data/lib/cross-post/version.rb
CHANGED
data/spec/config_spec.rb
ADDED
@@ -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.
|
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:
|
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.
|
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
|