Permalink
Fetching contributors…
Cannot retrieve contributors at this time
executable file 841 lines (775 sloc) 24.9 KB
#!/usr/bin/env ruby
# -*- coding: utf-8 -*-
#
# TwiProwl - Twitter Notification Script with Prowl.
# Version: 2.0
#
# Copyright (c) 2009,2010,2011 Takuo Kitame.
#
# You can redistribute it and/or modify it under the same term as Ruby.
#
STDOUT.sync = STDERR.sync = true
require 'optparse'
require 'net/https'
require 'json'
require 'uri'
require 'yaml'
require 'logger'
require 'pstore'
require 'thread'
begin
require 'growl'
require 'fileutils'
rescue LoadError
end
$:.unshift( File::dirname( __FILE__ ) )
require 'compat'
TWIPROWL_VERSION = "2.2"
$0 = "TwiProwl/#{TWIPROWL_VERSION}"
PIDFILE = File.join( ENV['HOME'], ".twiprowl.pid" )
LOGFILE = "twiprowl.log"
class TwiProwl
API_BASE = "https://api.twitter.com/1.1/"
PROWL_API_ADD = "https://prowl.weks.net/publicapi/add"
NMA_API_NOTIFY = "https://www.notifymyandroid.com/publicapi/notify"
FOLLOWERS = "#{API_BASE}followers/ids.json?screen_name=%s&cursor=%d"
USERS_LOOKUP = "#{API_BASE}users/lookup.json?user_id=%s"
STREAM_URL = "https://userstream.twitter.com/1.1/user.json"
CONSUMER_KEY = 'nqjjOwQ207D1r3sPVgRhA'
CONSUMER_SECRET = 'Al0rV0Ud4zskHXZLfoEecPNj18Rk0faOrbpcojDOtM'
ACCESS_SITE = 'https://api.twitter.com'
CheckInfo = Struct.new( :name, :interval,
:last_id, :enable, :count, :priority,
:followers, :ignore, :negative, :thread, :time )
attr_accessor :thread, :stream_thread
attr_reader :followers
@@mutex = Mutex.new
def initialize( global, config, logger )
@@conf = global
@@conf['ProxyURI'] = URI::parse( @@conf['ProxyURI'] ) if @@conf['ProxyURI']
@logger = logger
@application = config.key?( 'Application' ) ? config['Application'] : "Twitter"
load_config( config, [ # name, interval( sec )
@mentions = CheckInfo.new( "Mentions" ),
@direct = CheckInfo.new( "Direct" ),
@retweets = CheckInfo.new( "Retweets" ),
@membership = CheckInfo.new( "Membership" ),
@unfollowed = CheckInfo.new( "Unfollowed", 1800 ),
@favorite = CheckInfo.new( "Favorite" )
] )
if config['RegexpMatch']
@match = config['RegexpMatch']
end
if config['RepliesAll']
@replies_all = true
end
@membership.followers = Array.new
@unfollowed.followers = Array.new
@user = config['User']
@use_proxy = config['UseProxy']
pdbfile = File.join( ENV['HOME'], ".twiprowl.pdb" )
begin
@pdb = PStore.new( pdbfile, true )
rescue
@pdb = PStore.new( pdbfile ) # ruby 1,8, but it's not thread safe
end
process_oauth( @user )
@shutdown = false
@@mutex.synchronize do
@pdb.transaction do
@pdb[:idnames] = Hash.new unless @pdb.root?( :idnames )
end
end
File.chmod( 0600, pdbfile )
@notify = config.key?('NotifyMethods') ? config['NotifyMethods'] : [ "prowl" ]
end
@@conf = Hash.new
private
def load_config( config, items )
items.each do |checkinfo|
# set default
checkinfo.enable = false
checkinfo.last_id = -1
checkinfo.priority = 0
checkinfo.count = 10
checkinfo.ignore = true
checkinfo.negative = false
next unless conf = config[checkinfo.name]
checkinfo.enable = conf['Enable'] if conf.key?( 'Enable' )
checkinfo.priority = conf['Priority'] if conf.key?( 'Priority' )
checkinfo.count = conf['Count'] if conf.key?( 'Count' )
checkinfo.interval = conf['Interval'] if conf.key?( 'Interval' )
checkinfo.ignore = conf['IgnoreSelf'] if conf.key?( 'IgnoreSelf' )
checkinfo.negative = conf['IgnoreNegative'] if conf.key?( 'IgnoreNegative' )
end
end
def process_oauth( user )
access_token = nil
access_token_secret = nil
unless @@conf[:auth]
@@mutex.synchronize do
@pdb.transaction do
if @pdb.root?( :tokens ) and @pdb[ :tokens ][ user ]
access_token = @pdb[ :tokens ][ user ][ :access_token ]
access_token_secret = @pdb[ :tokens ][ user ][ :access_token_secret ]
end
end
end
end
params = {
:site => ACCESS_SITE,
:proxy => @use_proxy ? @@conf["ProxyURI"] : nil
}
@consumer = OAuth::Consumer.new( CONSUMER_KEY, CONSUMER_SECRET, params )
begin
if access_token && access_token_secret
@access_token = OAuth::AccessToken.new(
@consumer, access_token, access_token_secret
)
end
rescue
print "Failed to getting Access Token.\n"
@access_token = nil
end
unless @access_token
print "Enter the password for #{user}: "
revertstty = `stty -g` rescue nil
`stty -echo` rescue nil
pass = gets.chomp.strip
print "\n"
`stty #{revertstty}` rescue nil
begin
print "** Processing OAuth authorization for #{user}..."
@access_token = get_access_token( user, pass )
print " done.\n"
@@mutex.synchronize do
@pdb.transaction do
@pdb[ :tokens ] = Hash.new unless @pdb.root?( :tokens )
@pdb[ :tokens ][ user ] = {
:access_token => @access_token.token,
:access_token_secret => @access_token.secret
}
end
end
rescue
print "\n\nError: Failed to OAuth due to wrong password or the server error.\n"
print "ErrorMessage: #{$!}\n"
File.unlink( PIDFILE ) rescue
exit 1
end
end
@token = OAuth::Token.new( @access_token.token,
@access_token.secret )
end
def get_access_token( user, pass )
rt = @consumer.get_request_token
u = URI::parse rt.authorize_url
http = http_new( u, @use_proxy )
req = Net::HTTP::Post.new( u.request_uri )
res = http.request( req )
raise RuntimeError, "HTTP: #{res.code}" if res.code != "200"
at = ot = nil
res.body.each_line do |line|
if /name="authenticity_token" type="hidden" value="([^"]+)"/ =~ line
at = $1
end
if /name="oauth_token" type="hidden" value="([^"]+)"/ =~ line
ot = $1
end
break if at && ot
end
raise RuntimeError, "Could not get tokens" if at.nil? or ot.nil?
query = [ "authenticity_token=#{at}",
"oauth_token=#{ot}",
"session[username_or_email]=#{user}",
"session[password]=#{pass}",
"submit=Allow" ].join("&")
u = URI::parse( "https://api.twitter.com/oauth/authorize" )
res = http.post( u.request_uri, query )
raise RuntimeError, "HTTP: #{res.code}" if res.code != "200"
pin = nil
res.body.each_line do |line|
if line =~ /<code>(\d+)/
pin = $1
break
end
end
raise RuntimeError, "Could not get PIN" unless pin
if pin
token = rt.get_access_token( :oauth_verifier => pin )
end
return token
end
def post_escape( string )
string.gsub(/([^ a-zA-Z0-9_.-]+)/) do
'%' + $1.unpack('H2' * $1.bytesize).join('%').upcase
end.tr(' ', '+')
end
def unescape( text )
text.gsub( /&(amp|quot|gt|lt);/u ) do
match = $1.dup
case match
when 'amp' then '&'
when 'quot' then '"'
when 'gt' then '>'
when 'lt' then '<'
else
"&#{match};"
end
end unless text.nil? || text.empty?
end
# logging
def _log( severity, str )
if @logger
@logger.add( severity, str, "#{@application}@#{@user}" )
else
format = "[%Y-%m-%d %H:%M:%S##{Process.pid}]"
if severity == Logger::ERROR
STDERR.print Time.now.strftime( format ) + " #{@application}@#{@user} - #{str}\n"
else
print Time.now.strftime( format ) + " #{@application}@#{@user} - #{str}\n"
end
end
end
def debug(str)
_log(Logger::DEBUG, str)
end
def error(str)
_log(Logger::ERROR, str)
end
def info(str)
_log(Logger::INFO, str)
end
def http_new( uri, use_proxy = true )
if @@conf['ProxyURI'] and use_proxy
pu = @@conf['ProxyURI']
http = Net::HTTP::Proxy( pu.host, pu.port, pu.user, pu.password ).new( uri.host, uri.port )
else
http = Net::HTTP.new( uri.host, uri.port )
end
if uri.scheme == "https"
http.use_ssl = true
# http.verify_mode = OpenSSL::SSL::VERIFY_PEER
http.ca_path = @@conf['CAPath'] if @@conf['CAPath']
http.ca_file = @@conf['CAFile'] if @@conf['CAFile']
end
return http
end
def notify( icon, params = {}, source_user = nil )
@notify.each do |m|
Thread.new do
send( m, icon, params.dup, source_user ? source_user.dup : nil )
end
end
end
def growl( icon, params={}, user = nil )
growl_params = {}
if @@conf['Growl'].key?( 'Sticky' ) and
params[:priority] >= @@conf['Growl']['Sticky']
growl_params[:sticky] = true
end
growl_params[:title] = params[:event]
growl_params[:priority] = params[:priority]
message = params[:description]
info "Notify with Growl..."
begin
file = nil
if user
url = URI::parse( user['profile_image_url'] )
file = File.join( ENV["HOME"] , ".twiprowl", url.path )
@@mutex.synchronize do
if not File.exist?( file )
FileUtils.mkdir_p( File.dirname( file ) )
http = Net::HTTP.new( url.host, url.port )
image = http.get( url.path )
if image.code == "200"
open( file, "wb+" ) do |fp| fp.write image.body end
end
end
end # synchronize
end
growl_params[:image] = file if file and File.exist?( file )
Growl.notify( message, growl_params )
rescue
error "Error while growl notify #{$!}"
end
nil
end
def prowl( icon, params={}, user = nil )
params[:apikey] = @@conf['Prowl']['APIKey']
params[:description] = "#{icon} #{params[:description]}"
begin
info "Notify with Prowl... prio: #{params[:priority]}"
uri = URI::parse( PROWL_API_ADD )
http = http_new( uri )
request = Net::HTTP::Post.new( uri.request_uri )
request.content_type = "application/x-www-form-urlencoded"
query = params.map do |key, val| "#{key}=#{post_escape(val.to_s)}" end
res = http.request( request, query.join( '&' ) )
debug "Prowl Response: #{res.code}"
rescue
error "Error while Prowling: #{$!}"
end
end
def nma( icon, params = {}, user = nil )
params[:apikey] = @@conf['NMA']['APIKey']
begin
info "Notify with NMA..."
uri = URI::parse( NMA_API_NOTIFY )
http = http_new( uri )
request = Net::HTTP::Post.new( uri.request_uri )
request.content_type = "application/x-www-form-urlencoded"
query = params.map do |key, val| "#{key}=#{post_escape(val.to_s)}" end
res = http.request( request, query.join( '&' ) )
debug "NMA Response: #{res.code}"
rescue
error "Error while Prowling: #{$!}"
end
end
def get_json( url, auth = true )
uri = URI::parse( url )
debug "Check #{uri.request_uri}"
begin
res = @access_token.get( url )
rescue
error "HTTP Get Error: #{$!}"
return [ nil, res ]
end
return res.code == "200" ? [ JSON::parse( res.body ), res ] : [ nil, res ]
end
def correct_followers
ids = @unfollowed.followers
names = Hash.new
num = 0
cur_names = []
@pdb.transaction do
cur_names = @pdb[:idnames]
end
while num < ids.size
user_id = ids[num..num+99]
user_id.reject! do |uid|
cur_names.include?(uid)
end
if user_id.size > 0
url = USERS_LOOKUP % [ user_id.join(',') ]
json, res = get_json( url )
if res.code != "200"
json, res = get_json( url )
end
break if json.nil? or res.code != "200"
json.each do |user|
names[user["id_str"]] = user['screen_name']
end
end
num += 100
end
debug "names size: #{names.size}"
@pdb.transaction do
@pdb[:idnames].update( names )
end
end
# checking followers
def check_followers( checkinfo )
return unless checkinfo.enable
cursor = -1
users = Array.new
while cursor != 0
info "Checking: Followers: cursor=#{cursor}"
url = FOLLOWERS % [ @user, cursor ]
json, res = get_json( url )
return if json.nil?
cursor = json['next_cursor']
users.concat( json['ids'].map{ |id| id.to_s } ) # .map { |u| "@#{u['screen_name']}" } )
end
debug "Current followers: #{users.size}, Previous: #{checkinfo.followers.size}"
if checkinfo.followers.size > 0
diff = checkinfo.followers - users
if diff.size > 0
userdb = nil
@@mutex.synchronize do
@pdb.transaction do
userdb = @pdb[:idnames]
end
end
screen_names = Array.new
diff.each do |id|
screen_name = userdb[id.to_s]
if screen_name.nil?
screen_name = "ID:#{id}"
end
screen_names.push( screen_name )
end
if screen_names.size > 1
b = screen_names.pop
string = screen_names.join(", ")
string += " and #{b}"
else
string = screen_names[0]
end
desc = string + (diff.size > 1 ? " have" : " has" ) + " unfollowed you..."
notify( Unicode::E023,
:application=> @application,
:event => "Unfollowed",
:description => desc,
:priority => checkinfo.priority
)
end
end
checkinfo.followers = users
end
def process_stream( json )
mentions = json['entities'] ? json['entities']['user_mentions'] : nil
retweets = json['retweeted_status']
message = json['direct_message']
event = json['event']
if json['user']
source = json['user']['screen_name']
sid = json['user']['id_str']
source_user = json['user']
elsif json['source']
source = json['source']['screen_name']
sid = json['source']['id_str']
source_user = json['source']
elsif message
source_user = message['sender']
source = message['sender']['screen_name']
sid = message['sender']['id_str']
else
source = nil
end
if source and @unfollowed.followers.include?( sid )
@@mutex.synchronize do
@pdb.transaction do
@pdb[:idnames][sid] = source
end
end
end
desc = retweets ? retweets['text'] : json['text']
desc = unescape( desc )
# RT event
if retweets and retweets['user']['screen_name'] == @user
return if @retweets.ignore and source == @user
desc = unescape( retweets['text'] )
event = "Retweeted by @#{source}"
info "Notify: %s %s" % [ event, desc ]
notify( Unicode::E00F, {
:application=> @application,
:event => event,
:description => desc,
:priority => @retweets.priority }, source_user ) if @retweets.enable
return
end
# mentions event
if mentions and mentions.size > 0 and
mentions.find do |m| m['screen_name'] == @user end
return if @mentions.ignore and source == @user
desc = unescape( json['text'] )
event = "Mentioned by @#{source}"
info "Notify: %s %s" % [ event, desc ]
notify( Unicode::E10F, {
:application=> @application,
:event => event,
:description => desc,
:priority => @mentions.priority }, source_user ) if @mentions.enable
return
end
# direct message
if message and message['recipient_screen_name'] == @user and
(!@direct.ignore or message['sender_screen_name'] != @user)
desc = unescape( message['text'] )
event = "DM from @#{message['sender_screen_name']}"
info "Notify: %s %s" % [ event, desc ]
notify( Unicode::E103, {
:application => @application,
:event => event,
:description => desc,
:priority => @direct.priority }, source_user ) if @direct.enable
return
end
if @match and desc
if retweets
sname = retweets['user']['screen_name']
else
sname = source
end
@match.each do |m|
next unless m['Enable']
screen_name = m.key?( 'User' ) ? m['User'] : ".*"
body_text = m.key?( 'Text' ) ? m['Text'] : ".*"
u = Regexp.new( screen_name, 'i')
t = Regexp.new( body_text )
if u =~ sname and t =~ desc
info "Notify: RegexpMatch"
notify( Unicode::E317, {
:application=> @application,
:event => "@#{source} says",
:description => desc,
:priority => m.key?( 'Priority' ) ? m['Priority'] : 0 },
source_user )
return
end
end
end
# Membership event
case event
when "follow"
if source != @user && @unfollowed.enable
desc = "You have been followed by #{source}"
notify( Unicode::E022, {
:application=> @application,
:event => "Followed",
:description => desc,
:priority => @unfollowed.priority }, source_user )
end
when "list_member_added"
if json['target']['screen_name'] == @user and @membership.enable and
(!@membership.ignore or json['target_object']['user']['screen_name'] != @user)
desc = "You have been added into: #{json['target_object']['full_name']}"
notify( Unicode::E337, {
:application=> @application,
:event => "List membership",
:description => desc,
:priority => @membership.priority },
json['target_object']['user'] )
end
when "list_member_removed"
if json['target']['screen_name'] == @user and
@membership.enable and !@membership.negative and
(!@membership.ignore or json['target_object']['user']['screen_name'] != @user)
desc = "You have been removed from: #{json['target_object']['full_name']}"
notify( Unicode::E333, {
:application=> @application,
:event => "List membership",
:description => desc,
:priority => @membership.priority },
json['target_object']['user'] )
end
when "favorite"
target = json['target_object']['user']['screen_name']
if target == @user and @favorite.enable and
(!@favorite.ignore or source != @user)
text = json['target_object']['text']
desc = unescape( text )
info "Notify: favorite %s" % [ desc ]
notify( Unicode::E32F, {
:application=> @application,
:event => "Favorite by @#{source}",
:description => desc,
:priority => @favorite.priority },
source_user )
end
when "unfavorite"
target = json['target_object']['user']['screen_name']
if target == @user and @favorite.enable and
!@favorite.negative and
(!@favorite.ignore or source != @user)
text = json['target_object']['text']
desc = unescape( text )
info "Notify: unfavorite %s" % [ desc ]
notify( Unicode::E421, {
:application=> @application,
:event => "Unfavorite by @#{source}",
:description => desc,
:priority => @favorite.priority },
source_user )
end
when "follow"
if json['target']['screen_name'] == @user
# @unfollowed.followers.push( json['source']['id'] )
@@mutex.synchronize do
@pdb.transaction do
@pdb[:idnames][json['source']['id_str']] = json['source']['screen_name']
debug "Added idnames: #{json['source']['id_str']} = #{json['source']['screen_name']}"
end
end
end
else
debug "Event: #{event}" unless event.nil? || event.empty?
end
return
end
def stream_monitor
debug "Checking with Streaming API."
uri = URI::parse( STREAM_URL )
params = {}
params.update( { "replies" => "all" } ) if @replies_all
http = http_new( uri, @use_proxy )
request_uri = uri.request_uri + "?" + params.map do |k,v| "#{k}=#{v}"end.join("&")
request = Net::HTTP::Get.new( request_uri )
request['X-User-Agent'] = "TwiProwl/#{TWIPROWL_VERSION}"
request.oauth!( http, @consumer, @token )
@stream_error_count = 0
begin
buf = ''
http.request( request ) do |res|
raise RuntimeError, "Error on HTTP HTTP:#{res.code} #{res.to_s}" if res.code.to_i != 200
res.read_body do |str|
buf << str
@stream_error_count = 0
buf.gsub!( /[\s\S]+?\r\n/ ) do |chunk|
json = JSON::parse( chunk ) rescue next
begin
process_stream( json )
rescue
error( "BUG: error while process JSON" )
error( $!.backtrace.join("\n") )
error( $!.to_s )
end
end
end
end
ensure
http.finish
end
end
public
def run
return if ( @thread and @thread.alive? ) or @shutdown
return unless @unfollowed.enable
info "Starting basic thread for \"#{@application}\"."
@thread = Thread.new do
loop do
check_followers( @unfollowed )
correct_followers
sleep @unfollowed.interval
end # loop
end
end
def stream_run
return if ( @stream_thread and @stream_thread.alive? ) or @shutdown
info "Starting streaming thread for \"#{@application}\"."
@stream_thread = Thread.new do
begin
stream_monitor
rescue
Thread.self.kill if @shutdown
error "Streaming Error: #{$!}"
debug "Stream error count=#{@stream_error_count}"
sleep 10 * (@stream_error_count += 1)
retry
end
end
end
def shutdown
@shutdown = true
info "Killing all threads by shutdown request."
(@stream_thread.kill; debug( "Streaming thread has been killed" )) if @stream_thread and @stream_thread.alive?
(@thread.kill; debug( "Check thread has been killed" )) if @thread and @thread.alive?
end
end
## __MAIN__
## command line options
ProgramConfig = Hash.new
opts = OptionParser.new
opts.on( "-c", "--config FILENAME", String, "Specify the config file." ) { |v| ProgramConfig[:config] = v }
opts.on( "-q", "--daemon",nil, "Enable daemon mode." ) { |v| ProgramConfig[:daemon] = true }
opts.on( "-d", "--debug", nil, "Enable debug output." ) { |v| ProgramConfig[:debug] = true }
opts.on( "-q", "--quit", nil, "Kill running process" ) { |v| ProgramConfig[:quit] = true }
opts.on( "-a", "--auth", nil, "force (re)Auth" ) { |v| ProgramConfig[:auth] = true }
opts.version = TWIPROWL_VERSION
opts.program_name = "twiprowl"
opts.parse!( ARGV )
if ProgramConfig[:quit]
if File.exist?( PIDFILE )
pid = nil
File.open( PIDFILE ) do |fp|
pid = fp.readline.to_i
end
Process.kill(:INT, pid)
puts "Process #{pid} has been killed."
else
puts "No running process."
end
exit
end
if File.exist?( PIDFILE )
pid = nil
open( PIDFILE ) do |fp|
pid = fp.readline()
end
puts "Another process is seems running on #{pid}"
puts "kill #{pid} at first or remove pidfile: `rm #{PIDFILE}'"
exit
end
## config file
config_order = [
File.join( ENV['HOME'], '.twiprowl.conf' ),
File.join( Dir.pwd, 'twiprowl.conf' ),
File.join( Dir.pwd, 'config.yml' ),
File.join( File.dirname( __FILE__ ), 'twiprowl.conf' )
]
filename = nil
if ProgramConfig[:config]
if File.exist?( ProgramConfig[:config] )
filename = ProgramConfig[:config]
else
STDERR.print "Configuration file does not exist: #{ProgramConfig[:config]}\n"
exit 1
end
else
config_order.each do |conf|
next unless File.exist?( conf )
filename = conf
break
end
end
if filename.nil?
STDERR.print "No configuration file exist.\n"
STDERR.print "File candidates are:\n"
STDERR.print config_order.join("\n")
STDERR.print "\n"
exit 1
end
STDOUT.print "LoadConf: #{filename}\n"
config = YAML.load_file( filename )
config['Debug'] = true if ProgramConfig[:debug]
config[:auth] = true if ProgramConfig[:auth]
if config['LogDir']
logdir = config['LogDir']
Dir.mkdir( logdir ) unless File.exist?( logdir )
file = File.join( logdir, LOGFILE )
STDOUT.print "All logs will be written into #{file}.\n"
logger = Logger.new( file, 'daily' )
logger.level = config['Debug'] ? Logger::DEBUG : Logger::INFO
logger.datetime_format = "%Y-%m-%d %H:%M:%S"
else
logger = nil
end
accounts = Array.new
config['Accounts'].each do |account|
next if account.has_key?('Enable') and account['Enable'] == false
accounts.push( TwiProwl.new( config, account, logger ) )
end
## Daemon mode
if ProgramConfig[:daemon] || config['Daemon']
begin
Process.daemon( true, true )
rescue
STDERR.print $!
exit 1
end
STDOUT.print "Daemonized. PID=#{Process.pid}\n"
end
Signal.trap(:INT) {
accounts.each do |ac| ac.shutdown end
File.unlink( PIDFILE ) if File.exist?( PIDFILE )
exit
}
Signal.trap(:TERM) {
accounts.each do |ac| ac.shutdown end
File.unlink( PIDFILE ) if File.exist?( PIDFILE )
exit
}
print "TwiProwl is running.\n"
File.open( PIDFILE, "w" ) do |fp|
fp.write Process.pid
end
# main loop thread
loop do
accounts.each do |ac|
ac.run
ac.stream_run
end
sleep 60
end
# __END__