Compare commits

...

15 Commits

Author SHA1 Message Date
aeris ab52dcd13d Fix Mastodon registration 2018-01-07 20:42:57 +01:00
aeris 9cf70a74ba Fix registration 2018-01-07 18:21:24 +01:00
aeris 663a05be75 Bump version 2018-01-07 16:59:38 +01:00
aeris aaf143e5b5 Convert Mastodon usernames to Twitter ones 2018-01-07 16:55:14 +01:00
aeris 72ce72fa2c Replying to already cross-posted toot 2018-01-04 19:52:00 +01:00
aeris 91be33ac99 Don't clean HTML too much 2018-01-02 01:30:16 +01:00
aeris 6881206ca1 Refactor config 2018-01-02 01:29:45 +01:00
aeris 50b8b57065 Log level is uppercased 2018-01-02 01:27:02 +01:00
aeris 51904f3808 Move config file in own directory 2018-01-01 15:45:14 +01:00
aeris 011d0c16b8 Remove whitespace because Sanitize create \n for <p> 2017-10-23 17:32:02 +02:00
aeris d51c885fec Preserve whitespace 2017-10-18 00:51:43 +02:00
aeris 40ed5f4414 Bump version 2017-10-01 13:29:57 +02:00
aeris 966b7554a2 Sanitize content before tweet 2017-10-01 13:26:15 +02:00
aeris 99ac0f1be4 Naked domain 2017-10-01 13:24:43 +02:00
aeris 35b7477bac Reconnect in case of error 2017-10-01 13:23:57 +02:00
19 changed files with 475 additions and 98 deletions

2
.gitignore vendored
View File

@ -1 +1 @@
/Gemfile.lock
/vendor/bundle/

View File

@ -2,5 +2,7 @@ source 'https://rubygems.org'
gem 'mastodon-api', '~> 1.1.0', require: 'mastodon', git: 'https://github.com/tootsuite/mastodon-api.git'
gem 'awesome_print'
gem 'dotenv'
gem 'pry'
gemspec

122
Gemfile.lock 100644
View File

@ -0,0 +1,122 @@
GIT
remote: https://github.com/tootsuite/mastodon-api.git
revision: a3ff60a872191aa2f499a2b4c7a85045ead14e64
specs:
mastodon-api (1.1.0)
addressable (~> 2.4)
buftok
http (~> 2.0)
PATH
remote: .
specs:
cross-post (0.2.1)
launchy (~> 2.4, >= 2.4.3)
mastodon-api (~> 1.1, >= 1.1.0)
oauth (~> 0.5, >= 0.5.3)
oauth2 (~> 1.4, >= 1.4.0)
sanitize (~> 4.5, >= 4.5.0)
twitter (~> 6.1, >= 6.1.0)
twitter-text (~> 1.14, >= 1.14.7)
GEM
remote: https://rubygems.org/
specs:
addressable (2.5.2)
public_suffix (>= 2.0.2, < 4.0)
awesome_print (1.8.0)
buftok (0.2.0)
coderay (1.1.2)
crass (1.0.3)
diff-lcs (1.3)
domain_name (0.5.20170404)
unf (>= 0.0.5, < 1.0.0)
dotenv (2.2.1)
equalizer (0.0.11)
faraday (0.11.0)
multipart-post (>= 1.2, < 3)
http (2.2.2)
addressable (~> 2.3)
http-cookie (~> 1.0)
http-form_data (~> 1.0.1)
http_parser.rb (~> 0.6.0)
http-cookie (1.0.3)
domain_name (~> 0.5)
http-form_data (1.0.3)
http_parser.rb (0.6.0)
jwt (1.5.6)
launchy (2.4.3)
addressable (~> 2.3)
memoizable (0.4.2)
thread_safe (~> 0.3, >= 0.3.1)
method_source (0.9.0)
mini_portile2 (2.3.0)
multi_json (1.12.2)
multi_xml (0.6.0)
multipart-post (2.0.0)
naught (1.1.0)
nokogiri (1.8.1)
mini_portile2 (~> 2.3.0)
nokogumbo (1.4.13)
nokogiri
oauth (0.5.4)
oauth2 (1.4.0)
faraday (>= 0.8, < 0.13)
jwt (~> 1.0)
multi_json (~> 1.3)
multi_xml (~> 0.5)
rack (>= 1.2, < 3)
pry (0.11.3)
coderay (~> 1.1.0)
method_source (~> 0.9.0)
public_suffix (3.0.1)
rack (2.0.3)
rspec (3.6.0)
rspec-core (~> 3.6.0)
rspec-expectations (~> 3.6.0)
rspec-mocks (~> 3.6.0)
rspec-core (3.6.0)
rspec-support (~> 3.6.0)
rspec-expectations (3.6.0)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.6.0)
rspec-mocks (3.6.0)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.6.0)
rspec-support (3.6.0)
sanitize (4.5.0)
crass (~> 1.0.2)
nokogiri (>= 1.4.4)
nokogumbo (~> 1.4.1)
simple_oauth (0.3.1)
thread_safe (0.3.6)
twitter (6.1.0)
addressable (~> 2.5)
buftok (~> 0.2.0)
equalizer (= 0.0.11)
faraday (~> 0.11.0)
http (~> 2.1)
http_parser.rb (~> 0.6.0)
memoizable (~> 0.4.2)
naught (~> 1.1)
simple_oauth (~> 0.3.1)
twitter-text (1.14.7)
unf (~> 0.1.0)
unf (0.1.4)
unf_ext
unf_ext (0.0.7.4)
PLATFORMS
ruby
DEPENDENCIES
awesome_print
bundler (~> 1.15, >= 1.15.4)
cross-post!
dotenv
mastodon-api (~> 1.1.0)!
pry
rspec (~> 3.6.0, >= 3.6.0)
BUNDLED WITH
1.16.1

View File

@ -8,7 +8,7 @@ To use it:
* Clone this repository somewhere (`git clone https://git.imirhil.fr/aeris/cross-post/`)
* Install dependencies with Bundler (`bundler install`)
* 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)
* 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)
* Register the app on Twitter (`bundle exec bin/twitter-register`)
* You can reuse my Twitter app OAuth credentials, or register a new app from scratch [here](https://apps.twitter.com/)
* Register the app on Mastodon (`bundle exec bin/mastodon-register`)
@ -18,7 +18,3 @@ To use it:
* Enjoy
If needed, a SystemD unit example is available [here](https://git.imirhil.fr/aeris/cross-post/src/master/mastodon-twitter.service)
# Todo
* Publishing on [RubyGems](https://rubygems.org/)

19
bin/feed-stdout 100755
View File

@ -0,0 +1,19 @@
#!/usr/bin/env ruby
require 'dotenv/load'
require 'cross-post'
require 'awesome_print'
require 'securerandom'
class ::Twitter::REST::Client
def upload(*args, **kargs)
ap type: :upload, args: args, kargs: kargs
SecureRandom.uuid
end
def update(*args, **kargs)
ap type: :update, args: args, kargs: kargs
SecureRandom.uuid
end
end
CrossPost.feed

View File

@ -1,3 +1,3 @@
#!/usr/bin/env ruby
require 'cross-post'
CrossPost.feed
loop { CrossPost.feed rescue nil }

View File

@ -0,0 +1,3 @@
#!/usr/bin/env ruby
require 'cross-post'
CrossPost.feed

View File

@ -3,31 +3,59 @@ require 'cross-post'
require 'mastodon'
require 'oauth2'
require 'launchy'
require 'uri'
config = CrossPost::Config.new
print 'Mastodon URL ? '
url = gets.strip
url = "https://#{url}" unless url.start_with? 'https://'
config['mastodon.url'] = url
config = CrossPost::Config.new
settings = config[:settings]
client_id, client_secret = unless config['mastodon.consumer']
token = SecureRandom.hex 64
redirect_url = 'urn:ietf:wg:oauth:2.0:oob'
url = settings['mastodon.url']
unless url
print 'Mastodon URL? '
url = gets.chomp
url = "https://#{url}" if URI.parse(url).class == URI::Generic
settings['mastodon.url'] = url
end
user = settings['mastodon.user']
unless user
print 'Mastodon username? '
user = gets.chomp
settings['mastodon.user'] = user
end
APP_NAME = 'CrossPost'
APP_URL = 'https://git.imirhil.fr/aeris/cross-post/'
REDIRECT_URI = 'urn:ietf:wg:oauth:2.0:oob'
SCOPES = 'read'
client_id, client_secret = unless settings['mastodon.consumer']
puts 'Creating new app'
token = SecureRandom.hex 64
client = Mastodon::REST::Client.new base_url: url, bearer_token: token
app = client.create_app 'CrossPost', redirect_url,
'read write', 'https://git.imirhil.fr/aeris/cross-post/'
app = client.create_app APP_NAME, REDIRECT_URI, SCOPES, APP_URL
settings['mastodon.consumer.key'] = app.client_id
settings['mastodon.consumer.secret'] = app.client_secret
[app.client_id, app.client_secret]
else
[config['mastodon.consumer.key'], config['mastodon.consumer.secret']]
[settings['mastodon.consumer.key'], settings['mastodon.consumer.secret']]
end
client = OAuth2::Client.new client_id, client_secret, site: url
url = client.auth_code.authorize_url redirect_uri: redirect_url
Launchy.open url
url = client.auth_code.authorize_url redirect_uri: REDIRECT_URI
puts url
begin
Launchy.open url
rescue
end
print 'Token ? '
token = gets.chomp
config['mastodon.token'] = token
print 'Code? '
code = gets.chomp
config.save
token = client.auth_code.get_token code, scopes: SCOPES, redirect_uri: REDIRECT_URI
token = token.token
puts "Token: #{token}"
settings['mastodon.token'] = token
settings.save

28
bin/replay 100755
View File

@ -0,0 +1,28 @@
#!/usr/bin/env ruby
require 'cross-post'
require 'awesome_print'
class Twitter::REST::Client
def update(*args, **kargs)
ap args
ap kargs
0
end
end
config = CrossPost::Config.new
url = config['mastodon.url']
token = config['mastodon.token']
client = ::Mastodon::REST::Client.new base_url: url, bearer_token: token
status = client.status 439490
ap status
twitter = CrossPost::Twitter.new config
twitter.post_status status
# media = '/home/aeris/Images/tux-debian.png'
# media = client.upload_media media
# status = client.create_status 'Test', nil, [media.id]
# ap status
# sleep 5
# client.destroy_status status.id

15
bin/send-test 100755
View File

@ -0,0 +1,15 @@
#!/usr/bin/env ruby
require 'cross-post'
require 'awesome_print'
config = CrossPost::Config.new
url = config['mastodon.url']
token = config['mastodon.token']
client = ::Mastodon::REST::Client.new base_url: url, bearer_token: token
media = '/home/aeris/Images/tux-debian.png'
media = client.upload_media media
status = client.create_status 'Test', nil, [media.id]
ap status
sleep 5
client.destroy_status status.id

3
bin/send-twitter 100755
View File

@ -0,0 +1,3 @@
#!/usr/bin/env ruby
require 'cross-post'
CrossPost.new.twitter.post File.read ARGV[0]

View File

@ -6,9 +6,10 @@ require 'launchy'
require 'awesome_print'
config = CrossPost::Config.new
settings = config[:settings]
consumer_key = config['twitter.consumer.key']
consumer_secret = config['twitter.consumer.secret']
consumer_key = settings['twitter.consumer.key']
consumer_secret = settings['twitter.consumer.secret']
client = OAuth::Consumer.new consumer_key,
consumer_secret,
@ -16,15 +17,18 @@ client = OAuth::Consumer.new consumer_key,
request_token = client.get_request_token
url = request_token.authorize_url
puts url
Launchy.open url
begin
Launchy.open url
rescue
end
print 'PIN ? '
pin = gets.chomp
access_token = request_token.get_access_token oauth_verifier: pin
config['twitter.access.token'] = access_token.token
config['twitter.access.secret'] = access_token.secret
settings['twitter.access.token'] = access_token.token
settings['twitter.access.secret'] = access_token.secret
config.save
settings.save

View File

@ -8,7 +8,7 @@ Gem::Specification.new do |spec|
spec.version = CrossPost::VERSION
spec.authors = ['aeris']
spec.email = ['aeris@imirhil.fr']
spec.summary = "Cross post to Mastodon and Twitter"
spec.summary = 'Cross post to Mastodon and Twitter'
spec.homepage = 'https://git.imirhil.fr/aeris/cross-post/'
spec.license = 'AGPL-3.0+'
@ -16,13 +16,14 @@ Gem::Specification.new do |spec|
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename f }
spec.test_files = spec.files.grep %r{^(test|spec|features)/}
spec.add_development_dependency 'bundler', '~> 1.15.4'
spec.add_development_dependency 'bundler', '~> 1.15', '>= 1.15.4'
spec.add_development_dependency 'rspec', '~> 3.6.0', '>= 3.6.0'
spec.add_dependency 'twitter', '~> 6.1.0'
spec.add_dependency 'mastodon-api', '~> 1.1.0'
spec.add_dependency 'twitter-text', '~> 1.14.7'
spec.add_dependency 'oauth', '~> 0.5.3'
spec.add_dependency 'oauth2', '~> 1.4.0'
spec.add_dependency 'launchy', '~> 2.4.3'
spec.add_dependency 'sanitize', '~> 4.5.0'
spec.add_dependency 'twitter', '~> 6.1', '>= 6.1.0'
spec.add_dependency 'mastodon-api', '~> 1.1', '>= 1.1.0'
spec.add_dependency 'twitter-text', '~> 1.14', '>= 1.14.7'
spec.add_dependency 'oauth', '~> 0.5', '>= 0.5.3'
spec.add_dependency 'oauth2', '~> 1.4', '>= 1.4.0'
spec.add_dependency 'launchy', '~> 2.4', '>= 2.4.3'
spec.add_dependency 'sanitize', '~> 4.5', '>= 4.5.0'
end

View File

@ -2,6 +2,7 @@ require 'cross-post/config'
require 'cross-post/mastodon'
require 'cross-post/twitter'
require 'open-uri'
require 'logger'
# Force OpenURI#open to return a TempFile and not a StringIO
OpenURI::Buffer.send :remove_const, 'StringMax'
@ -9,6 +10,15 @@ OpenURI::Buffer.const_set 'StringMax', 0
class CrossPost
LOGGER = Logger.new STDERR
LOGGER.level = Logger.const_get ENV.fetch('LOG', 'INFO').upcase
LOGGER.formatter = proc do |severity, time, _, msg|
time = time.strftime '%Y-%m-%dT%H:%M:%S.%6N'.freeze
"#{time} #{severity} #{msg}\n"
end
attr_reader :mastodon, :twitter
def initialize
@config = Config.new
@mastodon = Mastodon.new @config

View File

@ -1,40 +1,120 @@
require 'yaml'
require 'fileutils'
class CrossPost
class Config
def initialize
@file = ENV.fetch 'CROSS_POST_CONFIG', File.join(Dir.home, '.cross-post.yml')
File.open(@file) { |f| @config = YAML.safe_load f }
end
DEFAULT_CONFIG_FOLDER = File.join Dir.home, '.config/cross-post'
DEFAULT_CONFIG_FILE = 'config.yml'
def [](key)
current = @config
key.split(/\./).each do |k|
current = current[k]
return nil if current.nil?
class SubConfig
def initialize(config = {})
@config = config
end
current
end
def []=(key, value)
*key, last = key.split(/\./)
current = @config
key.each do |k|
next_ = current[k]
case next_
when nil
next_ = current[k] = {}
when Hash
def each(&block)
@config.each &block
end
def [](key)
case key
when String
current = @config
key.split(/\./).each do |k|
current = current[k]
return nil if current.nil?
end
current
else
raise "Invalid entry, Hash expected, had #{next_.class} (#{next_})"
@config[key]
end
end
def fetch(key, default = nil)
self[key] || default
end
def []=(key, value)
case key
when String
*key, last = key.to_s.split(/\./)
current = @config
key.each do |k|
next_ = current[k]
case next_
when nil
next_ = current[k] = {}
when Hash
else
raise "Invalid entry, Hash expected, had #{next_.class} (#{next_})"
end
current = next_
end
current[last] = value
else
@config[key] = value
end
current = next_
end
current[last] = value
end
def save
File.write @file, YAML.dump(@config)
class FifoSubConfig < SubConfig
def initialize(size = 100)
@size = size
@keys = []
super({})
end
def []=(key, value)
@keys.delete key
value = super key, value
@keys << key
while @keys.size > @size
key = @keys.delete_at 0
@config.delete key
end
value
end
end
class FileSubConfig < SubConfig
def initialize(file)
@file = file
super YAML.load_file @file
end
def put(key, value, save: false)
self[key] = value
self.save if save
end
def save
LOGGER.debug "Saving #{@file}"
yaml = YAML.dump @config
File.write @file, yaml
end
end
def initialize
@configs = {}
@dir = ENV.fetch 'CONFIG_FOLDER', DEFAULT_CONFIG_FOLDER
file = ENV.fetch 'CONFIG_FILE', DEFAULT_CONFIG_FILE
self.load :settings, file
self.load :users
self[:posts] = FifoSubConfig.new
end
def [](name)
@configs[name]
end
def []=(name, value)
@configs[name] = value
end
def load(name, file = nil)
file ||= "#{name}.yml"
file = File.join @dir, file
File.write(file, YAML.dump({})) unless File.exist? file
self[name] = FileSubConfig.new file
end
end
end

View File

@ -1,15 +1,26 @@
require 'mastodon'
require 'sanitize'
require 'awesome_print'
class CrossPost
class Mastodon
def initialize(config)
url = config['mastodon.url']
token = config['mastodon.token']
user = config['mastodon.user']
@user_url = URI.join(url, "/@#{user}").to_s
@client = ::Mastodon::REST::Client.new base_url: url, bearer_token: token
@stream = ::Mastodon::Streaming::Client.new base_url: url, bearer_token: token
settings = config[:settings]
@posts = config[:posts]
url = settings['mastodon.url']
token = settings['mastodon.token']
user = settings['mastodon.user']
LOGGER.debug "Mastodon base URL: #{url}"
@client = ::Mastodon::REST::Client.new base_url: url, bearer_token: token
stream_url = settings.fetch 'mastodon.stream_url', url
LOGGER.debug "Mastodon stream URL: #{stream_url}"
@stream = ::Mastodon::Streaming::Client.new base_url: stream_url, bearer_token: token
@user_url = URI.join(ENV.fetch('BASE_USER_URL', url), "/@#{user}").to_s
LOGGER.debug "Mastodon user URL: #{@user_url}"
end
def feed(twitter)
@ -17,11 +28,13 @@ class CrossPost
begin
case object
when ::Mastodon::Status
LOGGER.info { 'Receiving status' }
LOGGER.debug { object.ai }
next if reject? object
twitter.post object
twitter.post_status object
end
rescue => e
#$stderr.puts e
LOGGER.error e
raise
end
end
@ -30,9 +43,11 @@ class CrossPost
private
def reject?(status)
status.account.url != @user_url or
status.visibility != 'public' or
status.in_reply_to_id
return true if status.account.url != @user_url or
status.visibility != 'public'
reply = status.in_reply_to_id
return true if reply and !@posts[reply]
false
end
end
end

View File

@ -1,40 +1,72 @@
require 'twitter'
require 'twitter-text'
require 'sanitize'
require 'cgi'
require 'ostruct'
::Twitter::Validation::MAX_LENGTH = 280
class CrossPost
class Twitter
def initialize(config)
settings = config[:settings]
@posts = config[:posts]
@users = config[:users]
config = {
consumer_key: config['twitter.consumer.key'],
consumer_secret: config['twitter.consumer.secret'],
access_token: config['twitter.access.token'],
access_token_secret: config['twitter.access.secret']
consumer_key: settings['twitter.consumer.key'],
consumer_secret: settings['twitter.consumer.secret'],
access_token: settings['twitter.access.token'],
access_token_secret: settings['twitter.access.secret']
}
@client = ::Twitter::REST::Client.new config
@stream = ::Twitter::Streaming::Client.new config
end
def post(status)
content = Sanitize.clean status.content
last = nil
parts = split content
attachments = status.media_attachments
media = attachments.collect do |f|
f = open f.url
begin
@client.upload f
ensure
f.close
f.unlink
end
end
def post(content, media = [], id:, reply_to:)
reply_to = OpenStruct.new id: reply_to unless reply_to.respond_to? :id
media = media.collect { |f| @client.upload f }
parts = split content
unless media.empty?
first, *parts = parts
last = @client.update first, media_ids: media.join(',')
reply_to = @client.update first, media_ids: media.join(','), in_reply_to_status: reply_to
end
parts.each { |p| reply_to = @client.update p, in_reply_to_status: reply_to }
reply_to = reply_to.id if reply_to.respond_to? :id
@posts[id] = reply_to
end
WHITESPACE_TAGS = {
'br' => { before: "\n", after: '' },
'div' => { before: "\n", after: "\n" },
'p' => { before: "\n", after: "\n" }
}.freeze
def post_status(status)
content = status.content
content = Sanitize.clean(content, whitespace_elements: WHITESPACE_TAGS).strip
content = CGI.unescape_html content
@users.each do |mastodon, twitter|
content = content.gsub /@\b#{mastodon}\b/, "@#{twitter}"
end
media = status.media_attachments.collect { |f| open f.url }
LOGGER.info { 'Sending to twitter' }
LOGGER.debug { " Content: #{content}" }
LOGGER.debug { " Attachments: #{media.size}" }
reply = status.in_reply_to_id
reply_to = reply ? @posts[reply] : nil
self.post content, media, id: status.id, reply_to: reply_to
media.each do |f|
f.close
f.unlink
end
parts.each { |p| last = @client.update p, in_reply_to_status: last }
end
private
@ -42,7 +74,7 @@ class CrossPost
def split(text)
parts = []
part = ''
words = text.split ' '
words = text.split /\ /
words.each do |word|
old_part = part
part += ' ' unless part == ''

View File

@ -1,3 +1,3 @@
class CrossPost
VERSION = '0.1.1'.freeze
VERSION = '0.2.1'.freeze
end

View File

@ -0,0 +1,19 @@
require 'cross-post'
RSpec.describe CrossPost::Config::FifoSubConfig do
it 'must remove first value in case of overflow' do
config = CrossPost::Config::FifoSubConfig.new 2
config[:foo] = :foo
config[:bar] = :bar
expect(config[:foo]).to be :foo
expect(config[:bar]).to be :bar
expect(config[:baz]).to be_nil
config[:baz] = :baz
expect(config[:foo]).to be_nil
expect(config[:bar]).to be :bar
expect(config[:baz]).to be :baz
end
end