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 +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
|