root/lang/ruby/net-irc/trunk/examples/tig.rb @ 9213

Revision 9213, 13.8 kB (checked in by cho45, 5 years ago)

lang/ruby/net-irc/trunk/examples/tig.rb:

Fixed bug around getting friend list. thx: hirose31

  • Property svn:executable set to *
Line 
1#!/usr/bin/env ruby
2=begin
3
4# tig.rb
5
6Ruby version of TwitterIrcGateway
7( http://www.misuzilla.org/dist/net/twitterircgateway/ )
8
9## Launch
10
11        $ ruby tig.rb # daemonized
12
13If you want to help:
14
15        $ ruby tig.rb --help
16
17## Configuration
18
19Options specified by after irc realname.
20
21Configuration example for Tiarra ( http://coderepos.org/share/wiki/Tiarra ).
22
23        twitter {
24                host: localhost
25                port: 16668
26                name: username@example.com athack
27                password: password on Twitter
28                in-encoding: utf8
29                out-encoding: utf8
30        }
31
32### athack
33
34If `athack` client option specified,
35all nick in join message is leading with @.
36
37So if you complemente nicks (ex. Irssi),
38it's good for Twitter like reply command (@nick).
39
40In this case, you will see torrent of join messages after connected,
41because NAMES list can't send @ leading nick (it interpreted op.)
42
43### jabber=<jid>:<pass>
44
45If `jabber=<jid>:<pass>` option specified,
46use jabber to get friends timeline.
47
48You must setup im notifing settings in the site and
49install 'xmpp4r-simple' gem.
50
51        $ sudo gem install xmpp4r-simple
52
53Be careful for managing password.
54
55
56## Licence
57
58Ruby's by cho45
59
60=end
61
62$LOAD_PATH << "lib"
63$LOAD_PATH << "../lib"
64
65$KCODE = "u" # json use this
66
67require "rubygems"
68require "net/irc"
69require "net/http"
70require "net/https"
71require "uri"
72require "json"
73require "socket"
74require "time"
75require "logger"
76require "yaml"
77require "pathname"
78require "cgi"
79
80Net::HTTP.version_1_2
81
82class TwitterIrcGateway < Net::IRC::Server::Session
83        def server_name
84                "twittergw"
85        end
86
87        def server_version
88                "0.0.0"
89        end
90
91        def main_channel
92                "#twitter"
93        end
94
95        def api_base
96                URI("http://twitter.com/")
97        end
98
99        def api_source
100                "tigrb"
101        end
102
103        def jabber_bot_id
104                "twitter@twitter.com"
105        end
106
107        class ApiFailed < StandardError; end
108
109        def initialize(*args)
110                super
111                @groups     = {}
112                @channels   = [] # joined channels (groups)
113                @user_agent = "#{self.class}/#{server_version} (tig.rb)"
114                @config     = Pathname.new(ENV["HOME"]) + ".tig"
115                load_config
116        end
117
118        def on_user(m)
119                super
120                post @prefix, JOIN, main_channel
121                post server_name, MODE, main_channel, "+o", @prefix.nick
122
123                @real, *@opts = @real.split(/\s+/)
124                @opts ||= []
125
126                jabber = @opts.find {|i| i =~ /^jabber=(\S+?):(\S+)/ }
127                if jabber
128                        jid, pass = Regexp.last_match.captures
129                        jabber.replace("jabber=#{jid}:********")
130                        if jabber_bot_id
131                                begin
132                                        require "xmpp4r-simple"
133                                        start_jabber(jid, pass)
134                                rescue LoadError
135                                        log "Failed to start Jabber."
136                                        log "Installl 'xmpp4r-simple' gem or check your id/pass."
137                                        finish
138                                end
139                        else
140                                jabber = nil
141                                log "This gateway does not support Jabber bot."
142                        end
143                end
144
145                @log.info "Client Options: #{@opts.inspect}"
146
147                @timeline = []
148                @check_friends_thread = Thread.start do
149                        loop do
150                                begin
151                                        check_friends
152                                rescue ApiFailed => e
153                                        @log.error e.inspect
154                                rescue Exception => e
155                                        @log.error e.inspect
156                                        e.backtrace.each do |l|
157                                                @log.error "\t#{l}"
158                                        end
159                                end
160                                sleep 10 * 60 # 6 times/hour
161                        end
162                end
163                sleep 3
164
165                return if jabber
166
167                @check_timeline_thread = Thread.start do
168                        loop do
169                                begin
170                                        check_timeline
171                                        # check_direct_messages
172                                rescue ApiFailed => e
173                                        @log.error e.inspect
174                                rescue Exception => e
175                                        @log.error e.inspect
176                                        e.backtrace.each do |l|
177                                                @log.error "\t#{l}"
178                                        end
179                                end
180                                sleep 90 # 40 times/hour
181                        end
182                end
183        end
184
185        def on_disconnected
186                @check_friends_thread.kill  rescue nil
187                @check_timeline_thread.kill rescue nil
188                @im_thread.kill             rescue nil
189                @im.disconnect              rescue nil
190        end
191
192        def on_privmsg(m)
193                retry_count = 3
194                ret = nil
195                target, message = *m.params
196                begin
197                        if target =~ /^#/
198                                ret = api("statuses/update", {"status" => message})
199                        else
200                                # direct message
201                                ret = api("direct_messages/new", {"user" => target, "text" => message})
202                        end
203                        raise ApiFailed, "api failed" unless ret
204                        log "Status Updated"
205                rescue => e
206                        @log.error [retry_count, e.inspect].inspect
207                        if retry_count > 0
208                                retry_count -= 1
209                                @log.debug "Retry to setting status..."
210                                retry
211                        else
212                                log "Some Error Happened on Sending #{message}. #{e}"
213                        end
214                end
215        end
216
217        def on_whois(m)
218                nick = m.params[0]
219                f = (@friends || []).find {|i| i["screen_name"] == nick }
220                if f
221                        post nil, RPL_WHOISUSER,   @nick, nick, nick, api_base.host, "*", "#{f["name"]} / #{f["description"]}"
222                        post nil, RPL_WHOISSERVER, @nick, nick, api_base.host, api_base.to_s
223                        post nil, RPL_WHOISIDLE,   @nick, nick, "0", "seconds idle"
224                        post nil, RPL_ENDOFWHOIS,  @nick, nick, "End of WHOIS list"
225                else
226                        post nil, ERR_NOSUCHNICK, nick, "No such nick/channel"
227                end
228        end
229
230        def on_who(m)
231                channel = m.params[0]
232                case
233                when channel == main_channel
234                        #     "<channel> <user> <host> <server> <nick>
235                        #         ( "H" / "G" > ["*"] [ ( "@" / "+" ) ]
236                        #             :<hopcount> <real name>"
237                        @friends.each do |f|
238                                user = nick = f["screen_name"]
239                                host = serv = api_base.host
240                                real = f["name"]
241                                post nil, RPL_WHOREPLY, @nick, channel, user, host, serv, nick, "H*@", "0 #{real}"
242                        end
243                        post nil, RPL_ENDOFWHO, @nick, channel
244                when @groups.key?(channel)
245                        @groups[channel].each do |name|
246                                f = @friends.find {|i| i["screen_name"] == name }
247                                user = nick = f["screen_name"]
248                                host = serv = api_base.host
249                                real = f["name"]
250                                post nil, RPL_WHOREPLY, @nick, channel, user, host, serv, nick, "H*@", "0 #{real}"
251                        end
252                        post nil, RPL_ENDOFWHO, @nick, channel
253                else
254                        post nil, ERR_NOSUCHNICK, @nick, nick, "No such nick/channel"
255                end
256        end
257
258        def on_join(m)
259                channels = m.params[0].split(/\s*,\s*/)
260                channels.each do |channel|
261                        next if channel == main_channel
262
263                        @channels << channel
264                        @channels.uniq!
265                        post "#{@nick}!#{@nick}@#{api_base.host}", JOIN, channel
266                        post server_name, MODE, channel, "+o", @nick
267                        save_config
268                end
269        end
270
271        def on_part(m)
272                channel = m.params[0]
273                return if channel == main_channel
274
275                @channels.delete(channel)
276                post @nick, PART, channel, "Ignore group #{channel}, but setting is alive yet."
277        end
278
279        def on_invite(m)
280                nick, channel = *m.params
281                return if channel == main_channel
282
283                if (@friends || []).find {|i| i["screen_name"] == nick }
284                        ((@groups[channel] ||= []) << nick).uniq!
285                        post "#{nick}!#{nick}@#{api_base.host}", JOIN, channel
286                        post server_name, MODE, channel, "+o", nick
287                        save_config
288                else
289                        post ERR_NOSUCHNICK, nil, nick, "No such nick/channel"
290                end
291        end
292
293        def on_kick(m)
294                channel, nick, mes = *m.params
295                return if channel == main_channel
296
297                if (@friends || []).find {|i| i["screen_name"] == nick }
298                        (@groups[channel] ||= []).delete(nick)
299                        post nick, PART, channel
300                        save_config
301                else
302                        post ERR_NOSUCHNICK, nil, nick, "No such nick/channel"
303                end
304        end
305
306        private
307        def check_timeline
308                @prev_time ||= Time.at(0)
309                api("statuses/friends_timeline", {"since" => @prev_time.httpdate}).reverse_each do |s|
310                        id = s["id"] || s["rid"]
311                        next if id.nil? || @timeline.include?(id)
312                        @timeline << id
313                        nick = s["user_login_id"] || s["user"]["screen_name"] # it may be better to use user_login_id in Wassr
314                        mesg = s["text"]
315
316                        # added @user in no use @user reply message ( Wassr only )
317                        if s.has_key?('reply_status_url') and s['reply_status_url'] and s['text'] !~ /^@.*/ and %r{([^/]+)/statuses/[^/]+}.match(s['reply_status_url'])
318                                reply_user_id = $1
319                                mesg = "@#{reply_user_id} #{mesg}"
320                        end
321                        # display area name(Wassr only)
322                        if s.has_key?('areaname') and s["areaname"]
323                                mesg += " L: #{s['areaname']}"
324                        end
325                        # display photo url(Wassr only)
326                        if s.has_key?('photo_url') and s["photo_url"]
327                                mesg += " #{s['photo_url']}"
328                        end
329
330                        # time = Time.parse(s["created_at"]) rescue Time.now
331                        m = { "&quot;" => "\"", "&lt;"=> "<", "&gt;"=> ">", "&amp;"=> "&", "\n" => " "}
332                        mesg.gsub!(/(#{m.keys.join("|")})/) { m[$1] }
333
334                        @log.debug [id, nick, mesg]
335                        if nick == @nick # 自分のときは topic に
336                                post nick, TOPIC, main_channel, untinyurl(mesg)
337                        else
338                                message(nick, main_channel, mesg)
339                        end
340                        @groups.each do |channel, members|
341                                if members.include?(nick)
342                                        message(nick, channel, mesg)
343                                end
344                        end
345                end
346                @log.debug "@timeline.size = #{@timeline.size}"
347                @timeline  = @timeline.last(100)
348                @prev_time = Time.now
349        end
350
351        def check_direct_messages
352                @prev_time_d ||= Time.now
353                api("direct_messages", {"since" => @prev_time_d.httpdate}).reverse_each do |s|
354                        nick = s["sender_screen_name"]
355                        mesg = s["text"]
356                        time = Time.parse(s["created_at"])
357                        @log.debug [nick, mesg, time].inspect
358                        message(nick, @nick, mesg)
359                end
360                @prev_time_d = Time.now
361        end
362
363        def check_friends
364                first = true unless @friends
365                @friends ||= []
366                friends = api("statuses/friends")
367                if first && !@opts.include?("athack")
368                        @friends = friends
369                        post nil, RPL_NAMREPLY,   @nick, "=", main_channel, @friends.map{|i| "@#{i["screen_name"]}" }.join(" ")
370                        post nil, RPL_ENDOFNAMES, @nick, main_channel, "End of NAMES list"
371                else
372                        prv_friends = @friends.map {|i| i["screen_name"] }
373                        now_friends = friends.map {|i| i["screen_name"] }
374
375                        # twitter api bug?
376                        return if !first && (now_friends.length - prv_friends.length).abs > 10
377
378                        (now_friends - prv_friends).each do |join|
379                                join = "@#{join}" if @opts.include?("athack")
380                                post "#{join}!#{join}@#{api_base.host}", JOIN, main_channel
381                        end
382                        (prv_friends - now_friends).each do |part|
383                                part = "@#{part}" if @opts.include?("athack")
384                                post "#{part}!#{part}@#{api_base.host}", PART, main_channel, ""
385                        end
386                        @friends = friends
387                end
388        end
389
390        def start_jabber(jid, pass)
391                @log.info "Logging-in with #{jid} -> jabber_bot_id: #{jabber_bot_id}"
392                @im = Jabber::Simple.new(jid, pass)
393                @im.add(jabber_bot_id)
394                @im_thread = Thread.start do
395                        loop do
396                                begin
397                                        @im.received_messages.each do |msg|
398                                                @log.debug [msg.from, msg.body]
399                                                if msg.from.strip == jabber_bot_id
400                                                        # Twitter -> 'id: msg'
401                                                        # Wassr   -> 'nick(id): msg'
402                                                        body = msg.body.sub(/^(.+?)(?:\((.+?)\))?: /, "")
403                                                        if Regexp.last_match
404                                                                nick, id = Regexp.last_match.captures
405                                                                body = CGI.unescapeHTML(body)
406                                                                message(id || nick, main_channel, body)
407                                                        end
408                                                end
409                                        end
410                                rescue Exception => e
411                                        @log.error "Error on Jabber loop: #{e.inspect}"
412                                        e.backtrace.each do |l|
413                                                @log.error "\t#{l}"
414                                        end
415                                end
416                                sleep 1
417                        end
418                end
419        end
420
421        def save_config
422                config = {
423                        :channels => @channels,
424                        :groups   => @groups,
425                }
426                @config.open("w") do |f|
427                        YAML.dump(config, f)
428                end
429        end
430
431        def load_config
432                @config.open do |f|
433                        config = YAML.load(f)
434                        @channels = config[:channels]
435                        @groups   = config[:groups]
436                end
437        rescue Errno::ENOENT
438        end
439
440        def api(path, q={})
441                ret     = {}
442                header = {
443                        "User-Agent"               => @user_agent,
444                        "Authorization"            => "Basic " + ["#{@real}:#{@pass}"].pack("m"),
445                        "X-Twitter-Client"         => api_source,
446                        "X-Twitter-Client-Version" => server_version,
447                        "X-Twitter-Client-URL"     => "http://coderepos.org/share/browser/lang/ruby/misc/tig.rb",
448                }
449                header["If-Modified-Since"]    =  q["since"] if q.key?("since")
450
451                q["source"] ||= api_source
452                q = q.inject([]) {|r,(k,v)| v.inject(r) {|r,i| r << "#{k}=#{URI.escape(i, /[^-.!~*'()\w]/n)}" } }.join("&")
453
454                uri = api_base.dup
455                uri.path  = path.sub(%r{^/*}, "/") << ".json"
456                uri.query = q
457
458                http = Net::HTTP.new(uri.host, uri.port)
459                if uri.scheme == "https"
460                        http.use_ssl     = true
461                        http.verify_mode = OpenSSL::SSL::VERIFY_NONE # FIXME
462                end
463                http.start do
464                        case uri.path
465                        when "/statuses/update.json", "/direct_messages/new.json"
466                                ret = http.post(uri.request_uri, q, header)
467                        else
468                                ret = http.get(uri.request_uri, header)
469                        end
470                end
471
472                case ret
473                when Net::HTTPOK # 200
474                        ret = JSON.parse(ret.body.gsub(/'(y(?:es)?|no?|true|false|null)'/, '"\1"'))
475                        raise ApiFailed, "Server Returned Error: #{ret["error"]}" if ret.kind_of?(Hash) && ret["error"]
476                        ret
477                when Net::HTTPNotModified # 304
478                        []
479                #when Net::HTTPBadRequest # 400
480                        # exceeded the rate limitation
481                else
482                        raise ApiFailed, "Server Returned #{ret.code}"
483                end
484        rescue Errno::ETIMEDOUT, JSON::ParserError, IOError, Timeout::Error, Errno::ECONNRESET => e
485                raise ApiFailed, e.inspect
486        end
487
488        def message(sender, target, str)
489#               str.gsub!(/&#(x)?([0-9a-f]+);/i) do
490#                       [$1 ? $2.hex : $2.to_i].pack("U")
491#               end
492                str    = untinyurl(str)
493                sender = "#{sender}!#{sender}@#{api_base.host}"
494                post sender, PRIVMSG, target, str
495        end
496
497        def log(str)
498                str.gsub!(/\n/, " ")
499                post server_name, NOTICE, main_channel, str
500        end
501
502        def untinyurl(text)
503                text.gsub(%r|http://(preview\.)?tinyurl\.com/[0-9a-z=]+|i) {|m|
504                        uri = URI(m)
505                        uri.host = uri.host.sub($1, "") if $1
506                        Net::HTTP.start(uri.host, uri.port) {|http|
507                                http.open_timeout = 3
508                                begin
509                                        http.head(uri.request_uri, { "User-Agent" => @user_agent })["Location"] || m
510                                rescue Timeout::Error
511                                        m
512                                end
513                        }
514                }
515        end
516end
517
518if __FILE__ == $0
519        require "optparse"
520
521        opts = {
522                :port  => 16668,
523                :host  => "localhost",
524                :log   => nil,
525                :debug => false,
526        }
527
528        OptionParser.new do |parser|
529                parser.instance_eval do
530                        self.banner  = <<-EOB.gsub(/^\t+/, "")
531                                Usage: #{$0} [opts]
532
533                        EOB
534
535                        separator ""
536
537                        separator "Options:"
538                        on("-p", "--port [PORT=#{opts[:port]}]", "port number to listen") do |port|
539                                opts[:port] = port
540                        end
541
542                        on("-h", "--host [HOST=#{opts[:host]}]", "host name or IP address to listen") do |host|
543                                opts[:host] = host
544                        end
545
546                        on("-l", "--log LOG", "log file") do |log|
547                                opts[:log] = log
548                        end
549
550                        on("--debug", "Enable debug mode") do |debug|
551                                opts[:log]   = $stdout
552                                opts[:debug] = true
553                        end
554
555                        parse!(ARGV)
556                end
557        end
558
559        opts[:logger] = Logger.new(opts[:log], "daily")
560        opts[:logger].level = opts[:debug] ? Logger::DEBUG : Logger::INFO
561
562        def daemonize(debug=false)
563                return yield if $DEBUG || debug
564                Process.fork do
565                        Process.setsid
566                        Dir.chdir "/"
567                        trap("SIGINT")  { exit! 0 }
568                        trap("SIGTERM") { exit! 0 }
569                        trap("SIGHUP")  { exit! 0 }
570                        File.open("/dev/null") {|f|
571                                STDIN.reopen  f
572                                STDOUT.reopen f
573                                STDERR.reopen f
574                        }
575                        yield
576                end
577                exit! 0
578        end
579
580        daemonize(opts[:debug]) do
581                Net::IRC::Server.new(opts[:host], opts[:port], TwitterIrcGateway, opts).start
582        end
583end
584
Note: See TracBrowser for help on using the browser.