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

Revision 33421, 34.9 kB (checked in by drry, 6 years ago)
  • (tig.rb) Twitter の不正な JSON に対処しました。
  • (tig.rb) Mentions の取得を調整しました。
  • (tig.rb) ほか。
  • Property svn:executable set to *
  • Property svn:keywords set to Revision
Line 
1#!/usr/bin/env ruby
2# vim:fileencoding=UTF-8:
3=begin
4
5# tig.rb
6
7Ruby version of TwitterIrcGateway
8<http://www.misuzilla.org/dist/net/twitterircgateway/>
9
10## Launch
11
12        $ ruby tig.rb
13
14If you want to help:
15
16        $ ruby tig.rb --help
17
18## Configuration
19
20Options specified by after IRC realname.
21
22Configuration example for Tiarra <http://coderepos.org/share/wiki/Tiarra>.
23
24        twitter {
25                host: localhost
26                port: 16668
27                name: username@example.com mentions secure tid
28                # for Jabber
29                #name: username@example.com jabber=username@example.com:jabberpasswd mentions secure
30                password: password on Twitter
31                in-encoding: utf8
32                out-encoding: utf8
33        }
34
35### athack
36
37If `athack` client option specified,
38all nick in join message is leading with @.
39
40So if you complemente nicks (e.g. Irssi),
41it's good for Twitter like reply command (@nick).
42
43In this case, you will see torrent of join messages after connected,
44because NAMES list can't send @ leading nick (it interpreted op.)
45
46### tid[=<color>[,<bgcolor>]]
47
48Apply ID to each message for make favorites by CTCP ACTION.
49
50        /me fav [ID...]
51
52<color> and <bgcolor> can be
53
54        0  => white
55        1  => black
56        2  => blue         navy
57        3  => green
58        4  => red
59        5  => brown        maroon
60        6  => purple
61        7  => orange       olive
62        8  => yellow
63        9  => lightgreen   lime
64        10 => teal
65        11 => lightcyan    cyan aqua
66        12 => lightblue    royal
67        13 => pink         lightpurple fuchsia
68        14 => grey
69        15 => lightgrey    silver
70
71### jabber=<jid>:<pass>
72
73If `jabber=<jid>:<pass>` option specified,
74use jabber to get friends timeline.
75
76You must setup im notifing settings in the site and
77install "xmpp4r-simple" gem.
78
79        $ sudo gem install xmpp4r-simple
80
81Be careful for managing password.
82
83### alwaysim
84
85Use IM instead of any APIs (e.g. post)
86
87### ratio=<timeline>:<friends>[:<mentions>]
88
8977:1[:12] by default. 47 seconds, an hour and 5 minutes.
90
91### mentions[=<ratio>]
92
93### maxlimit=<hourly limit>
94
95### secure
96
97### clientspoofing
98
99### httpproxy=[<user>[:<password>]@]<address>[:<port>]
100
101### main_channel=<#channel>
102
103### api_source=<source>
104
105Force SSL for API.
106
107## Extended commands through the CTCP ACTION
108
109### list (ls)
110
111        /me list NICK [NUMBER]
112
113### fav (favorite, favourite, unfav, unfavorite, unfavourite)
114
115        /me fav [ID...]
116        /me unfav [ID...]
117        /me fav! [ID...]
118        /me fav NICK
119
120### link (ln)
121
122        /me link ID [ID...]
123
124### destroy (del, delete, miss, oops, remove, rm)
125
126        /me destroy [ID...]
127
128### in (location)
129
130        /me in Sugamo, Tokyo, Japan
131
132### reply (re, mention)
133
134        /me reply ID blah, blah...
135
136### utf7
137
138        /me utf7
139
140### name
141
142        /me name My Name
143
144### description (desc)
145
146        /me description blah, blah...
147
148### spoof
149
150        /me spoof
151        /me spoo[o...]f
152        /me spoof tigrb twitterircgateway twitt web mobileweb
153
154### bot (drone)
155
156        /me bot NICK [NICK...]
157
158## Feed
159
160<http://coderepos.org/share/log/lang/ruby/net-irc/trunk/examples/tig.rb?limit=100&mode=stop_on_copy&format=rss>
161
162## License
163
164Ruby's by cho45
165
166=end
167
168$LOAD_PATH << "lib" << "../lib"
169$KCODE = "u" if RUBY_VERSION < "1.9" # json use this
170
171require "rubygems"
172require "net/irc"
173require "net/https"
174require "uri"
175require "socket"
176require "time"
177require "logger"
178require "yaml"
179require "pathname"
180require "cgi"
181require "json"
182
183module Net::IRC::Constants; RPL_WHOISBOT = "335" end
184
185class TwitterIrcGateway < Net::IRC::Server::Session
186        def server_name
187                "twittergw"
188        end
189
190        def server_version
191                rev = %q$Revision$.split[1]
192                rev &&= "+r#{rev}"
193                "0.0.0#{rev}"
194        end
195
196        def main_channel
197                @opts["main_channel"] || "#twitter"
198        end
199
200        def api_base
201                URI("http://twitter.com/")
202        end
203
204        def api_source
205                "#{@opts["api_source"] || "tigrb"}"
206        end
207
208        def jabber_bot_id
209                "twitter@twitter.com"
210        end
211
212        def hourly_limit
213                100
214        end
215
216        class APIFailed < StandardError; end
217
218        def initialize(*args)
219                super
220                @timeline  = []
221                @groups    = {}
222                @channels  = [] # joined channels (groups)
223                @nicknames = {}
224                @drones    = []
225                @config    = Pathname.new(ENV["HOME"]) + ".tig"
226                @suffix_bl = []
227                @etags     = {}
228                @limit     = hourly_limit
229                @tmap      = TypableMap.new
230                @friends   =
231                @im        =
232                @im_thread =
233                @utf7      = nil
234                load_config
235        end
236
237        def on_user(m)
238                super
239
240                @real, *@opts = (@opts.name || @real).split(/\s+/)
241                @opts = @opts.inject({}) do |r, i|
242                        key, value = i.split("=")
243                        key = "mentions" if key == "replies" # backcompat
244                        r.update key => case value
245                                when nil                      then true
246                                when /\A\d+\z/                then value.to_i
247                                when /\A(?:\d+\.\d*|\.\d+)\z/ then value.to_f
248                                else                               value
249                        end
250                end
251
252                retry_count = 0
253                begin
254                        @me = api("account/verify_credentials")
255                rescue APIFailed => e
256                        @log.error e.inspect
257                        sleep 3
258                        retry_count += 1
259                        retry if retry_count < 3
260                        log "Failed to access API 3 times." <<
261                            " Please check Twitter Status <http://status.twitter.com/> and try again later."
262                        finish
263                end
264
265                @user   = "id=%09d" % @me["id"]
266                @host   = hostname(@me)
267                @prefix = Prefix.new("#{@me["screen_name"]}!#{@user}@#{@host}")
268
269                #post NICK, @me["screen_name"] if @nick != @me["screen_name"]
270                post @prefix, JOIN, main_channel
271                post server_name, MODE, main_channel, "+mto", @prefix.nick
272                post @prefix, TOPIC, main_channel, generate_status_message(@me["status"])
273
274                if @opts["jabber"]
275                        jid, pass = @opts["jabber"].split(":", 2)
276                        @opts["jabber"].replace("jabber=#{jid}:********")
277                        if jabber_bot_id
278                                begin
279                                        require "xmpp4r-simple"
280                                        start_jabber(jid, pass)
281                                rescue LoadError
282                                        log "Failed to start Jabber."
283                                        log 'Installl "xmpp4r-simple" gem or check your ID/pass.'
284                                        finish
285                                end
286                        else
287                                @opts.delete("jabber")
288                                log "This gateway does not support Jabber bot."
289                        end
290                end
291
292                log "Client Options: #{@opts.inspect}"
293                @log.info "Client Options: #{@opts.inspect}"
294
295                @ratio = (@opts["ratio"] || "77:1").split(":")
296                @ratio = Struct.new(:timeline, :friends, :mentions).new(*@ratio)
297                @ratio[:mentions] ||= @opts["mentions"] == true ? 12 : @opts["mentions"]
298
299                @check_friends_thread = Thread.start do
300                        loop do
301                                begin
302                                        check_friends
303                                rescue APIFailed => e
304                                        @log.error e.inspect
305                                rescue Exception => e
306                                        @log.error e.inspect
307                                        e.backtrace.each do |l|
308                                                @log.error "\t#{l}"
309                                        end
310                                end
311                                sleep interval(@ratio[:friends])
312                        end
313                end
314
315                return if @opts["jabber"]
316
317                @sources   = @opts["clientspoofing"] ? fetch_sources : [[api_source, "tig.rb"]]
318                @suffix_bl = fetch_suffix_bl
319
320                @check_timeline_thread = Thread.start do
321                        sleep 3
322
323                        loop do
324                                begin
325                                        check_timeline
326                                        # check_direct_messages
327                                rescue APIFailed => e
328                                        @log.error e.inspect
329                                rescue Exception => e
330                                        @log.error e.inspect
331                                        e.backtrace.each do |l|
332                                                @log.error "\t#{l}"
333                                        end
334                                end
335                                sleep interval(@ratio[:timeline])
336                        end
337                end
338
339                return unless @opts["mentions"]
340
341                @check_mentions_thread = Thread.start do
342                        sleep interval(@ratio[:timeline]) / 2
343
344                        loop do
345                                begin
346                                        check_mentions
347                                rescue APIFailed => e
348                                        @log.error e.inspect
349                                rescue Exception => e
350                                        @log.error e.inspect
351                                        e.backtrace.each do |l|
352                                                @log.error "\t#{l}"
353                                        end
354                                end
355                                sleep interval(@ratio[:mentions])
356                        end
357                end
358        end
359
360        def on_disconnected
361                @check_friends_thread.kill  rescue nil
362                @check_timeline_thread.kill rescue nil
363                @check_mentions_thread.kill rescue nil
364                @im_thread.kill             rescue nil
365                @im.disconnect              rescue nil
366        end
367
368        def on_privmsg(m)
369                return on_ctcp(m[0], ctcp_decoding(m[1])) if m.ctcp?
370
371                target, mesg = *m.params
372                ret          = nil
373                retry_count  = 3
374
375                if @utf7
376                        mesg = Iconv.iconv("UTF-7", "UTF-8", mesg).join
377                        mesg = mesg.force_encoding("ASCII-8BIT") if mesg.respond_to?(:force_encoding)
378                end
379
380                begin
381                        if target =~ /\A#/
382                                if @opts["alwaysim"] and @im and @im.connected? # in jabber mode, using jabber post
383                                        ret = @im.deliver(jabber_bot_id, mesg)
384                                        post @prefix, TOPIC, main_channel, mesg
385                                else
386                                        previous = @me["status"]
387                                        if ((Time.now - Time.parse(previous["created_at"])).to_i < 60 rescue true) and
388                                           mesg.strip == previous["text"]
389                                                log "You can't submit the same status twice in a row."
390                                                return
391                                        end
392                                        ret = api("statuses/update", { :status => mesg, :source => source })
393                                        log oops(ret) if ret["truncated"]
394                                        ret.delete("user")
395                                        @me.update("status" => ret)
396                                end
397                        else # direct message
398                                ret = api("direct_messages/new", { :user => target, :text => mesg })
399                        end
400                        raise APIFailed, "API failed" unless ret
401                        log "Status Updated"
402                rescue => e
403                        @log.error [retry_count, e.inspect].inspect
404                        if retry_count > 0
405                                retry_count -= 1
406                                @log.debug "Retry to setting status..."
407                                retry
408                        end
409                        log "Some Error Happened on Sending #{mesg}. #{e}"
410                end
411        end
412
413        def on_ctcp(target, mesg)
414                _, command, *args = mesg.split(/\s+/)
415                case command
416                when "call"
417                        if args.size < 2
418                                log "/me call <Twitter_screen_name> as <IRC_nickname>"
419                                return
420                        end
421                        screen_name = args[0]
422                        nickname    = args[2] || args[1] # allow omitting "as"
423                        if nickname == "is" and
424                           deleted_nick = @nicknames.delete(screen_name)
425                                log %Q{Removed the nickname "#{deleted_nick}" for #{screen_name}}
426                        else
427                                @nicknames[screen_name] = nickname
428                                log "Call #{screen_name} as #{nickname}"
429                        end
430                        #save_config
431                when "utf7"
432                        begin
433                                require "iconv"
434                                @utf7 = !@utf7
435                                log "UTF-7 mode: #{@utf7 ? 'on' : 'off'}"
436                        rescue LoadError => e
437                                log "Can't load iconv."
438                        end
439                when "list", "ls"
440                        if args.empty?
441                                log "/me list <NICK> [<NUM>]"
442                                return
443                        end
444                        nick = args.first
445                        unless (1..200).include?(count = args[1].to_i)
446                                count = 20
447                        end
448                        to = nick == @nick ? server_name : nick
449                        res = api("statuses/user_timeline/#{nick}", { :count => count }).reverse_each do |s|
450                                time = Time.parse(s["created_at"]) rescue Time.now
451                                post to, NOTICE, main_channel,
452                                     "#{time.strftime "%m-%d %H:%M"} #{generate_status_message(s)}"
453                        end
454                        unless res
455                                post server_name, ERR_NOSUCHNICK, nick, "No such nick/channel"
456                        end
457                when /\A(un)?fav(?:ou?rite)?(!)?\z/
458                # fav, unfav, favorite, unfavorite, favourite, unfavourite
459                        method   = $1.nil? ? "create" : "destroy"
460                        force    = !!$2
461                        entered  = $&.capitalize
462                        statuses = []
463                        if args.empty?
464                                if method == "create"
465                                        id = @timeline.last
466                                        @tmap.each_value do |v|
467                                                if v["id"] == id
468                                                        statuses.push v
469                                                        break
470                                                end
471                                        end
472                                else
473                                        @favorites ||= api("favorites").reverse
474                                        if @favorites.empty?
475                                                log "You've never favorite yet. No favorites to unfavorite."
476                                                return
477                                        end
478                                        statuses.push @favorites.last
479                                end
480                        else
481                                args.each do |tid_or_nick|
482                                        case
483                                        when status = @tmap[tid_or_nick]
484                                                statuses.push status
485                                        when friend = @friends.find {|i| i["screen_name"].casecmp(tid_or_nick).zero? }
486                                                statuses.push friend["status"]
487                                        else
488                                                log "No such ID/NICK #{colored_tid(tid_or_nick)}"
489                                        end
490                                end
491                        end
492                        @favorites ||= []
493                        statuses.each do |status|
494                                if not force and method == "create" and
495                                   @favorites.find {|i| i["id"] == status["id"] }
496                                        log "The status is already favorited! <#{permalink(status)}>"
497                                        next
498                                end
499                                res = api("favorites/#{method}/#{status["id"]}")
500                                log "#{entered}: #{res["user"]["screen_name"]}: #{res["text"]}"
501                                if method == "create"
502                                        @favorites.push res
503                                else
504                                        @favorites.delete_if {|i| i["id"] == res["id"] }
505                                end
506                                sleep 0.5
507                        end
508                when "link", "ln"
509                        args.each do |tid|
510                                if @tmap[tid]
511                                        log "#{colored_tid(tid)}: #{permalink(@tmap[tid])}"
512                                else
513                                        log "No such ID #{colored_tid(tid)}"
514                                end
515                        end
516                when /\Aratios?\z/
517                        unless args.empty?
518                                args = args.first.split(":") if args.size == 1
519                                if @opts["mentions"] and args.size < 3
520                                        log "/me ratios <timeline> <friends> <mentions>"
521                                        return
522                                elsif args.size == 1
523                                        log "/me ratios <timeline> <friends>"
524                                        return
525                                end
526                                ratios = args.map {|ratio| ratio.to_f }
527                                if ratios.any? {|ratio| ratio <= 0.0 }
528                                        log "Ratios must be greater than 0.0 and fractional values are permitted."
529                                        return
530                                end
531                                @ratio[:timeline] = ratios[0]
532                                @ratio[:friends]  = ratios[1]
533                                @ratio[:mentions] = ratios[2] if @opts["mentions"]
534                        end
535                        log "Intervals: " << @ratio.map {|ratio| interval(ratio).round }.join(", ")
536                when /\A(?:de(?:stroy|l(?:ete)?)|miss|oops|r(?:emove|m))\z/
537                # destroy, delete, del, remove, rm, miss, oops
538                        statuses = []
539                        if args.empty?
540                                statuses.push @me["status"]
541                        else
542                                args.each do |tid|
543                                        if status = @tmap[tid]
544                                                if "id=%09d" % status["user"]["id"] == @user
545                                                        statuses.push status
546                                                else
547                                                        log "The status you specified by the ID #{colored_tid(tid)} is not yours."
548                                                end
549                                        else
550                                                log "No such ID #{colored_tid(tid)}"
551                                        end
552                                end
553                        end
554                        b = false
555                        statuses.each do |status|
556                                res = api("statuses/destroy/#{status["id"]}")
557                                @tmap.delete_if {|k, v| v["id"] == res["id"] }
558                                b = status["id"] == @me["status"]["id"]
559                                log "Destroyed: #{res["text"]}"
560                                sleep 0.5
561                        end
562                        if b
563                                @me = api("account/verify_credentials")
564                                post @prefix, TOPIC, main_channel, generate_status_message(@me["status"])
565                        end
566                when "name"
567                        name = mesg.split(/\s+/, 3)[2]
568                        unless name.nil?
569                                api("account/update_profile", { :name => name })
570                                log "You are named #{name}."
571                        end
572                when "email"
573                        # FIXME
574                        email = args.first
575                        unless email.nil?
576                                api("account/update_profile", { :email => email })
577                        end
578                when "url"
579                        # FIXME
580                        url = args.first || ""
581                        api("account/update_profile", { :url => url })
582                when "in", "location"
583                        location = mesg.split(/\s+/, 3)[2] || ""
584                        api("account/update_profile", { :location => location })
585                        location = location.empty? ? "nowhere" : "in #{location}"
586                        log "You are #{location} now."
587                when /\Adesc(?:ription)?\z/
588                        # FIXME
589                        description = mesg.split(/\s+/, 3)[2] || ""
590                        api("account/update_profile", { :description => description })
591                #when /\Acolou?rs?\z/ # TODO
592                #       # bg, text, link, fill and border
593                #when "image", "img" # TODO
594                #       url = args.first
595                #       # DCC SEND
596                #when "follow"# TODO
597                #when "leave" # TODO
598                when /\A(?:mention|re(?:ply)?)\z/ # reply, re, mention
599                        tid = args.first
600                        if status = @tmap[tid]
601                                text = mesg.split(/\s+/, 4)[3]
602                                ret  = api("statuses/update", { :status => text, :source => source,
603                                                                :in_reply_to_status_id => status["id"] })
604                                log oops(ret) if ret["truncated"]
605                                msg = generate_status_message(status)
606                                url = permalink(status)
607                                log "Status updated (In reply to #{colored_tid(tid)}: #{msg} <#{url}>)"
608                                ret.delete("user")
609                                @me.update("status" => ret)
610                        end
611                when /\Aspoo(o+)?f\z/
612                        @sources = args.empty? \
613                                 ? @sources.size == 1 || $1 ? fetch_sources($1 && $1.size) \
614                                                            : [[api_source, "tig.rb"]] \
615                                 : args.map {|src| [src.upcase != "WEB" ? src : "", "=#{src}"] }
616                        log @sources.map {|src| src[1] }.sort.join(", ")
617                when "bot", "drone"
618                        if args.empty?
619                                log "/me bot <NICK> [<NICK>...]"
620                                return
621                        end
622                        args.each do |bot|
623                                unless user = @friends.find {|i| i["screen_name"].casecmp(bot).zero? }
624                                        post server_name, ERR_NOSUCHNICK, bot, "No such nick/channel"
625                                        next
626                                end
627                                if @drones.delete(user["id"])
628                                        mode = "-#{mode}"
629                                        log "#{bot} is no longer a bot."
630                                else
631                                        @drones << user["id"]
632                                        mode = "+#{mode}"
633                                        log "Marks #{bot} as a bot."
634                                end
635                        end
636                        save_config
637                end
638        rescue APIFailed => e
639                log e.inspect
640        end
641
642        def on_whois(m)
643                nick  = m.params[0]
644                users = [@me]
645                users.concat @friends if @friends
646                user = users.find {|i| i["screen_name"].casecmp(nick).zero? }
647                unless user
648                        ret = api("users/username_available", { :username => nick })
649                        if ret and not ret["valid"]
650                                user = api("users/show/#{nick}")
651                        end
652                end
653                if user
654                        host = hostname user
655                        desc = user["name"]
656                        desc << " / #{user["description"]}".gsub(/\s+/, " ") unless user["description"].empty?
657                        idle = (Time.now - Time.parse(user["status"]["created_at"])).to_i rescue 0
658                        sion = Time.parse(user["created_at"]).to_i                        rescue 0
659                        post server_name, RPL_WHOISUSER,   @nick, nick, "id=%09d" % user["id"], host, "*", desc
660                        post server_name, RPL_WHOISSERVER, @nick, nick, api_base.host, "SoMa neighborhood of San Francisco, CA"
661                        post server_name, RPL_WHOISIDLE,   @nick, nick, "#{idle}", "#{sion}", "seconds idle, signon time"
662                        post server_name, RPL_ENDOFWHOIS,  @nick, nick, "End of WHOIS list"
663                        if @drones.include?(user["id"])
664                                post server_name, RPL_WHOISBOT, @nick, nick, "is a \002Bot\002 on #{server_name}"
665                        end
666                else
667                        post server_name, ERR_NOSUCHNICK, nick, "No such nick/channel"
668                end
669        end
670
671        def on_who(m)
672                channel = m.params[0]
673                case
674                when channel.casecmp(main_channel).zero?
675                        users = [@me]
676                        users.concat @friends.reverse if @friends
677                        users.each {|friend| whoreply channel, friend }
678                        post server_name, RPL_ENDOFWHO, @nick, channel
679                when @groups.key?(channel)
680                        @groups[channel].each do |name|
681                                whoreply channel, @friends.find {|i| i["screen_name"] == name }
682                        end
683                        post server_name, RPL_ENDOFWHO, @nick, channel
684                else
685                        post server_name, ERR_NOSUCHNICK, @nick, "No such nick/channel"
686                end
687        end
688
689        def whoreply(channel, u)
690                #     "<channel> <user> <host> <server> <nick>
691                #         ( "H" / "G" > ["*"] [ ( "@" / "+" ) ]
692                #             :<hopcount> <real name>"
693                nick = u["screen_name"]
694                user = "id=%09d" % u["id"]
695                host = hostname u
696                serv = api_base.host
697                real = u["name"]
698                mode = case u["screen_name"]
699                        when @me["screen_name"]        then "@"
700                        #when @drones.include?(u["id"]) then "%" # FIXME
701                        else                                "+"
702                end
703                post server_name, RPL_WHOREPLY, @nick, channel, user, host, serv, nick, "H*#{mode}", "0 #{real}"
704        end; private :whoreply
705
706        def on_join(m)
707                channels = m.params[0].split(/\s*,\s*/)
708                channels.each do |channel|
709                        next if channel.casecmp(main_channel).zero?
710
711                        @channels << channel
712                        @channels.uniq!
713                        post @prefix, JOIN, channel
714                        post server_name, MODE, channel, "+mtio", @prefix.nick
715                        save_config
716                end
717        end
718
719        def on_part(m)
720                channel = m.params[0]
721                return if channel.casecmp(main_channel).zero?
722
723                @channels.delete(channel)
724                post @nick, PART, channel, "Ignore group #{channel}, but setting is alive yet."
725        end
726
727        def on_invite(m)
728                nick, channel = *m.params
729                return if channel.casecmp(main_channel).zero?
730
731                f = (@friends || []).find {|i| i["screen_name"].casecmp(nick).zero? }
732                if f
733                        ((@groups[channel] ||= []) << f["screen_name"]).uniq!
734                        post generate_prefix(f), JOIN, channel
735                        post server_name, MODE, channel, "+v", nick
736                        save_config
737                else
738                        post server_name, ERR_NOSUCHNICK, nick, "No such nick/channel"
739                end
740        end
741
742        def on_kick(m)
743                channel, nick, mes = *m.params
744                return if channel == main_channel
745
746                f = (@friends || []).find {|i| i["screen_name"].casecmp(nick).zero? }
747                if f
748                        (@groups[channel] ||= []).delete(f["screen_name"])
749                        post nick, PART, channel
750                        save_config
751                else
752                        post server_name, ERR_NOSUCHNICK, nick, "No such nick/channel"
753                end
754        end
755
756        #def on_nick(m)
757        #       @nicknames[@nick] = m.params[0]
758        #end
759
760        def on_topic(m)
761                channel = m.params[0]
762                return unless channel.casecmp(main_channel).zero?
763
764                begin
765                        require "levenshtein"
766                        topic    = m.params[1]
767                        previous = @me["status"]
768                        distance = Levenshtein.normalized_distance(previous["text"], topic)
769
770                        return if distance.zero?
771
772                        status = api("statuses/update", { :status => topic, :source => source })
773                        log oops(ret) if status["truncated"]
774                        status.delete("user")
775                        @me.update("status" => status)
776
777                        if distance < 0.5
778                                deleted = api("statuses/destroy/#{previous["id"]}")
779                                @tmap.delete_if {|k, v| v["id"] == deleted["id"] }
780                                log "Fixed: #{status["text"]}"
781                        else
782                                log "Status updated"
783                        end
784                rescue LoadError
785                end
786        end
787
788        private
789        def check_timeline
790                q = { :count => 200 }
791                q[:since_id] = @timeline.last unless @timeline.empty?
792                api("statuses/friends_timeline", q).reverse_each do |status|
793                        id = status["id"]
794                        next if id.nil? or @timeline.include?(id)
795
796                        @timeline << id
797                        tid  = @tmap.push(status.dup)
798                        mesg = generate_status_message(status)
799                        user = status.delete("user")
800                        nick = user["screen_name"]
801
802                        mesg << " " << colored_tid(tid) if @opts["tid"]
803
804                        @log.debug [id, nick, mesg]
805                        if nick == @me["screen_name"] # 自分のときは TOPIC に
806                                post @prefix, TOPIC, main_channel, mesg
807
808                                @me.update("status" => status)
809                        else
810                                message(user, main_channel, mesg)
811
812                                @friends.each do |friend|
813                                        if friend["id"] == user["id"]
814                                                friend.update("status" => status)
815                                                break
816                                        end
817                                end
818                        end
819                        @groups.each do |channel, members|
820                                next unless members.include?(nick)
821                                message(user, channel, mesg)
822                        end
823                end
824                @log.debug "@timeline.size = #{@timeline.size}"
825                @timeline = @timeline.last(200)
826        end
827
828        def generate_status_message(status)
829                mesg = status["text"]
830                @log.debug mesg
831
832                mesg = decode_utf7(mesg)
833                # time = Time.parse(status["created_at"]) rescue Time.now
834                m = { "&quot;" => "\"", "&lt;" => "<", "&gt;" => ">", "&amp;" => "&", "\n" => " " }
835                mesg = mesg.gsub(Regexp.union(*m.keys)) { m[$&] }
836                mesg = mesg.sub(/\s*#{Regexp.union(*@suffix_bl)}\s*\z/, "") unless @suffix_bl.empty?
837                mesg = untinyurl(mesg)
838        end
839
840        def generate_prefix(u, athack = false)
841                nick = u["screen_name"]
842                nick = "@#{nick}" if athack
843                user = "id=%09d" % u["id"]
844                host = hostname u
845                "#{nick}!#{user}@#{host}"
846        end
847
848        def check_mentions
849                return if @timeline.empty?
850                @prev_mention_id ||= @timeline.first
851                api("statuses/mentions", {
852                        :count    => 200,
853                        :since_id => @prev_mention_id
854                }).reverse_each do |mention|
855                        id = @prev_mention_id = mention["id"]
856                        next if id.nil? or @timeline.include?(id)
857
858                        @timeline << id
859                        user = mention["user"]
860                        mesg = generate_status_message(mention)
861                        tid  = @tmap.push(mention)
862
863                        mesg << " " << colored_tid(tid) if @opts["tid"]
864
865                        @log.debug [id, user["screen_name"], mesg].inspect
866                        message(user, main_channel, mesg)
867
868                        @friends.each do |friend|
869                                if friend["id"] == user["id"]
870                                        friend.update("status" => status)
871                                        break
872                                end
873                        end
874                end
875        end
876
877        def check_direct_messages
878                api("direct_messages",
879                    @prev_dm_id ? { :count => 200, :since_id => @prev_dm_id } \
880                                : { :count => 1 }).reverse_each do |mesg|
881                        id   = @prev_dm_id = mesg["id"]
882                        user = mesg["sender"]
883                        text = mesg["text"]
884                        @log.debug [id, user["screen_name"], text].inspect
885                        message(user, @nick, text)
886                end
887        end
888
889        def check_friends
890                first   = @friends.nil?
891                athack  = @opts["athack"]
892                friends = api("statuses/friends")
893                if first and not athack
894                        names_list = friends.map do |i|
895                                name   = i["screen_name"]
896                                #prefix = @drones.include?(i["id"]) ? "%" : "+" # FIXME
897                                prefix = "+"
898                                "#{prefix}#{name}"
899                        end
900                        names_list = names_list.push("@#{@nick}").reverse.join(" ")
901                        post server_name, RPL_NAMREPLY,   @nick, "=", main_channel, names_list
902                        post server_name, RPL_ENDOFNAMES, @nick, main_channel, "End of NAMES list"
903                else
904                        return if not first and friends.size.zero? # 304 ETag
905
906                        prv_friends = (@friends || []).map {|friend| generate_prefix friend, athack }
907                        now_friends = friends.map {|friend| generate_prefix friend, athack }
908
909                        # Twitter API bug?
910                        return if not first and (now_friends.length - prv_friends.length).abs > 10
911
912                        (prv_friends - now_friends).each {|part| post part, PART, main_channel, "" }
913                        params = []
914                        (now_friends - prv_friends).each do |join|
915                                post join, JOIN, main_channel
916                                params << join[/\A[^!]+/]
917                                next if params.size < 3
918
919                                post server_name, MODE, main_channel, "+#{"v" * params.size}", *params
920                                params = []
921                        end
922                        post server_name, MODE, main_channel, "+#{"v" * params.size}", *params unless params.empty?
923                end
924                @friends = friends
925        end
926
927        def interval(ratio)
928                i     = 3600.0       # an hour in seconds
929                limit = 0.9 * @limit # 90% of limit
930                max   = @opts["maxlimit"]
931                i *= @ratio.inject {|sum, r| sum.to_f + r.to_f }
932                i /= ratio.to_f
933                i /= (max and max < limit) ? max : limit
934        rescue => e
935                @log.error e.inspect
936                100
937        end
938
939        def start_jabber(jid, pass)
940                @log.info "Logging-in with #{jid} -> jabber_bot_id: #{jabber_bot_id}"
941                @im = Jabber::Simple.new(jid, pass)
942                @im.add(jabber_bot_id)
943                @im_thread = Thread.start do
944                        loop do
945                                begin
946                                        @im.received_messages.each do |msg|
947                                                @log.debug [msg.from, msg.body]
948                                                if msg.from.strip == jabber_bot_id
949                                                        # Twitter -> 'id: msg'
950                                                        body = msg.body.sub(/\A(.+?)(?:\(([^()]+)\))?: /, "")
951                                                        body = decode_utf7(body)
952
953                                                        if Regexp.last_match
954                                                                nick, id = Regexp.last_match.captures
955                                                                body = untinyurl(CGI.unescapeHTML(body))
956                                                                user = nick
957                                                                nick = id || nick
958                                                                nick = @nicknames[nick] || nick
959                                                                post "#{nick}!#{user}@#{api_base.host}", PRIVMSG, main_channel, body
960                                                        end
961                                                end
962                                        end
963                                rescue Exception => e
964                                        @log.error "Error on Jabber loop: #{e.inspect}"
965                                        e.backtrace.each do |l|
966                                                @log.error "\t#{l}"
967                                        end
968                                end
969                                sleep 1
970                        end
971                end
972        end
973
974        def save_config
975                config = {
976                        :groups    => @groups,
977                        :channels  => @channels,
978                        #:nicknames => @nicknames,
979                        :drones    => @drones,
980                }
981                @config.open("w") {|f| YAML.dump(config, f) }
982        end
983
984        def load_config
985                @config.open do |f|
986                        config     = YAML.load(f)
987                        @groups    = config[:groups]    || {}
988                        @channels  = config[:channels]  || []
989                        #@nicknames = config[:nicknames] || {}
990                        @drones    = config[:drones]    || []
991                end
992        rescue Errno::ENOENT
993        end
994
995        def require_post?(path)
996                %r{
997                        \A
998                        (?: status(?:es)?/update \z
999                          | direct_messages/new \z
1000                          | friendships/create/
1001                          | account/ (?: end_session \z
1002                                       | update_ )
1003                          | favou?ri(?:ing|tes)/create/
1004                          | notifications/
1005                          | blocks/create/ )
1006                }x === path
1007        end
1008
1009        def api(path, q = {}, opt = {})
1010                path      = path.sub(%r{\A/+}, "")
1011                uri       = api_base.dup
1012                uri.port  = 443 if @opts["secure"]
1013                uri.query = q.inject([]) {|r,(k,v)| v.nil? ? r : r << "#{k}=#{URI.escape(v.to_s, /[^-.!~*'()A-Za-z0-9_]/)}" }.join("&")
1014                uri.path += path
1015                uri.path += ".json" if path != "users/username_available"
1016                @log.debug uri.inspect
1017
1018                http = case
1019                        when RE_HTTPPROXY === @opts["httpproxy"]
1020                                Net::HTTP.new(uri.host, uri.port, $3, $4.to_i, $1, $2)
1021                        when ENV["HTTP_PROXY"], ENV["http_proxy"]
1022                                proxy = URI(ENV["HTTP_PROXY"] || ENV["http_proxy"])
1023                                Net::HTTP.new(uri.host, uri.port,
1024                                              proxy.host, proxy.port, proxy.user, proxy.password)
1025                        else
1026                                Net::HTTP.new(uri.host, uri.port)
1027                end
1028                http.open_timeout = 30 # nil by default
1029                http.read_timeout = 30 # 60 by default
1030                http.use_ssl      = !!@opts["secure"]
1031                http.verify_mode  = OpenSSL::SSL::VERIFY_NONE if http.use_ssl?
1032
1033                req = case
1034                        when path.include?("/destroy/") then Net::HTTP::Delete.new uri.request_uri
1035                        when require_post?(path)        then Net::HTTP::Post.new   uri.path
1036                        else                                 Net::HTTP::Get.new    uri.request_uri
1037                end
1038                req.basic_auth @real, @pass
1039                req.add_field "User-Agent",      user_agent
1040                req.add_field "Accept",          "application/json,*/*;q=0.1"
1041                #req.add_field "Accept-Language", @opts["lang"] # "en-us,en;q=0.9,ja;q=0.5"
1042                req.add_field "If-None-Match",   @etags[path] if @etags[path]
1043                if req.request_body_permitted?
1044                        req.add_field "Content-Type", "application/x-www-form-urlencoded"
1045                        req.body = uri.query
1046                end
1047
1048                ret = http.request req
1049
1050                @etags[path] = ret["ETag"]
1051
1052                hourly_limit = ret["X-RateLimit-Limit"].to_i
1053                if not hourly_limit.zero? and @limit != hourly_limit
1054                        msg = "The rate limit per hour was changed: #{@limit} to #{hourly_limit}"
1055                        log msg
1056                        @log.info msg
1057                        @limit = hourly_limit
1058                end
1059
1060                case ret
1061                when Net::HTTPOK # 200
1062                        # Workaround for Twitter's bugs
1063                        json = ret.body.strip
1064                        json = json.sub(/"request"\s*:\s*NULL\s*(?=[,}])/) {|m| m.downcase }
1065                        json = json.sub(/\A(?:false|true)\z/) {|m| "[#{m}]" }
1066
1067                        res  = JSON.parse json
1068                        if res.is_a?(Hash) and res["error"] # and not res["response"]
1069                                if @error != res["error"]
1070                                        @error = res["error"]
1071                                        log @error
1072                                end
1073                                raise APIFailed, res["error"]
1074                        end
1075                        res
1076                when Net::HTTPNotModified # 304
1077                        []
1078                when Net::HTTPBadRequest # 400: exceeded the rate limitation
1079                        if ret.key?("X-RateLimit-Reset")
1080                                s = Time.at(ret["X-RateLimit-Reset"].to_i) - Time.now
1081                                log "#{(s / 60.0).ceil} min remaining."
1082                                #sleep s
1083                        end
1084                        raise APIFailed, "#{ret.code}: #{ret.message}"
1085                when Net::HTTPUnauthorized # 401
1086                        log "Please check your username/email and password combination."
1087                        raise APIFailed, "#{ret.code}: #{ret.message}"
1088                else
1089                        raise APIFailed, "Server Returned #{ret.code} #{ret.message}"
1090                end
1091        rescue Errno::ETIMEDOUT, JSON::ParserError, IOError, Timeout::Error, Errno::ECONNRESET => e
1092                raise APIFailed, e.inspect
1093        end
1094
1095        def message(sender, target, str)
1096                #str.gsub!(/&#(x)?([0-9a-f]+);/i) do
1097                #       [$1 ? $2.hex : $2.to_i].pack("U")
1098                #end
1099                screen_name = sender["screen_name"]
1100                sender.update("screen_name" => @nicknames[screen_name] || screen_name)
1101                prefix = generate_prefix(sender)
1102                post prefix, PRIVMSG, target, str
1103        end
1104
1105        def log(str)
1106                str.gsub!(/\r\n|[\r\n]/, " ")
1107                post server_name, NOTICE, main_channel, str
1108        end
1109
1110        def untinyurl(text)
1111                text.gsub(%r{
1112                        http:// (?:
1113                                bit\.ly | (?:(preview\.)? tin | rub) yurl\.com |
1114                                is\.gd | ff\.im | twurl.nl | blip\.fm | u\.nu
1115                        ) /~?[0-9a-z=-]+ (\?)?
1116                }ix) do |url|
1117                        uri = URI(url)
1118                        uri.host  = uri.host.sub($1, "") if $1
1119                        uri.query = nil if $2
1120                        "#{fetch_location_header(uri) || url}"
1121                end
1122        end
1123
1124        def fetch_location_header(uri, limit = 3)
1125                return uri if limit == 0 or uri.nil?
1126                req = Net::HTTP::Head.new uri.request_uri
1127                req.add_field "User-Agent", user_agent
1128                RE_HTTPPROXY.match(@opts["httpproxy"])
1129                http = Net::HTTP.new uri.host, uri.port, $3, $4.to_i, $1, $2
1130                http.open_timeout = 3
1131                http.read_timeout = 2
1132                begin
1133                        http.request(req) do |res|
1134                                if res.is_a?(Net::HTTPRedirection) and res.key?("Location")
1135                                        begin
1136                                                location = URI(res["Location"])
1137                                        rescue URI::InvalidURIError
1138                                        end
1139                                        unless location.is_a? URI::HTTP
1140                                                begin
1141                                                        location = URI.join(uri.to_s, res["Location"])
1142                                                rescue URI::InvalidURIError, URI::BadURIError
1143                                                        # FIXME
1144                                                end
1145                                        end
1146                                        uri = fetch_location_header(location, limit - 1)
1147                                end
1148                        end
1149                rescue Timeout::Error, Net::HTTPBadResponse
1150                end
1151                uri
1152        end
1153
1154        def decode_utf7(str)
1155                begin
1156                        require "iconv"
1157                        str = str.sub(/\A(?:.+ > |.+\z)/) {|m| Iconv.iconv("UTF-8", "UTF-7", m).join }
1158                        #FIXME str = "[utf7]: #{str}" if str =~ /[^a-z0-9\s]/i
1159                        str
1160                rescue LoadError, Iconv::IllegalSequence
1161                        str
1162                end
1163        end
1164
1165        def fetch_sources(n = nil)
1166                json = http_get URI("http://wedata.net/databases/TwitterSources/items.json")
1167                sources = JSON.parse json
1168                sources.map! {|item| [item["data"]["source"], item["name"]] }.push ["", "web"]
1169                if n.is_a?(Integer) and n < sources.size
1170                        sources = Array.new(n) { sources.delete_at(rand(sources.size)) }.compact
1171                end
1172                sources
1173        rescue => e
1174                @log.error e.inspect
1175                log "An error occured while loading wedata.net."
1176                @sources || [[api_source, "tig.rb"]]
1177        end
1178
1179        def fetch_suffix_bl(r = [])
1180                source = http_get URI("http://svn.coderepos.org/share/platform/twitterircgateway/suffixesblacklist.txt")
1181                if source.respond_to?(:encoding) and source.encoding == Encoding::BINARY
1182                        source.force_encoding("UTF-8")
1183                end
1184                source.split
1185        rescue
1186                r
1187        end
1188
1189        def http_get(uri)
1190                accepts = ["*/*;q=0.1"]
1191                #require 'mime/types'; accepts.unshift MIME::Types.of(uri.path).first.simplified
1192                types   = { "json" => "application/json", "txt" => "text/plain" }
1193                ext     = uri.path[/[^.]+\z/]
1194                accepts.unshift types[ext] if types.key?(ext)
1195
1196                req = Net::HTTP::Get.new uri.request_uri
1197                req.add_field "User-Agent",     user_agent
1198                req.add_field "Accept",         accepts.join(",")
1199                req.add_field "Accept-Charset", "UTF-8,*;q=0.0" if ext != "json"
1200                #req.add_field "If-None-Match",  @etags[uri.to_s] if @etags[uri.to_s]
1201                RE_HTTPPROXY.match(@opts["httpproxy"])
1202                http = Net::HTTP.new(uri.host, uri.port, $3, $4.to_i, $1, $2)
1203                http.open_timeout = 5
1204                http.read_timeout = 10
1205                begin
1206                        res = http.request req
1207                        #@etags[uri.to_s] = res["ETag"]
1208                        res.body
1209                rescue Timeout::Error
1210                end
1211        end
1212
1213        def oops(status)
1214                "Oops! Your update was over 140 characters. We sent the short version" <<
1215                " to your friends (they can view the entire update on the Web <" <<
1216                permalink(status) << ">)."
1217        end
1218
1219        def colored_tid(tid)
1220                c = @opts["tid"] # expect: 0..15, true, "0,1"
1221                b = nil
1222                if c.is_a?(String) and c.include?(",")
1223                        c, b = c.split(",", 2)
1224                        c = c.to_i
1225                        b = b.to_i
1226                end
1227                c = 10 unless c.is_a?(Integer) and (0 .. 15).include?(c)
1228                if b.is_a?(Integer) and (0 .. 15).include?(b)
1229                        "\003%02d,%02d[%s]\017" % [c, b, tid]
1230                else
1231                        "\003%02d[%s]\017"      % [c, tid]
1232                end
1233        end
1234
1235        def hostname(user)
1236                hosts = [api_base.host]
1237                hosts << "protected" if user["protected"]
1238                hosts << "bot"       if @drones.include?(user["id"])
1239                hosts.join("/")
1240        end
1241
1242        def user_agent
1243                "#{self.class}/#{server_version} (#{File.basename(__FILE__)}; Net::IRC::Server)" <<
1244                " Ruby/#{RUBY_VERSION} (#{RUBY_PLATFORM})"
1245        end
1246
1247        def permalink(status); "#{api_base}#{status["user"]["screen_name"]}/statuses/#{status["id"]}" end
1248        def source;            @sources[rand(@sources.size)].first                                    end
1249
1250        RE_HTTPPROXY = /\A(?:([^:@]+)(?::([^@]+))?@)?([^:]+)(?::(\d+))?\z/
1251
1252        class TypableMap < Hash
1253                Roman = %w[
1254                        k g ky gy s z sh j t d ch n ny h b p hy by py m my y r ry w v q
1255                ].unshift("").map do |consonant|
1256                        case consonant
1257                        when "y", /\A.{2}/ then %w|a u o|
1258                        when "q"           then %w|a i e o|
1259                        else                    %w|a i u e o|
1260                        end.map {|vowel| "#{consonant}#{vowel}" }
1261                end.flatten
1262
1263                def initialize(size = 1)
1264                        @seq  = Roman
1265                        @n    = 0
1266                        @size = size
1267                end
1268
1269                def generate(n)
1270                        ret = []
1271                        begin
1272                                n, r = n.divmod(@seq.size)
1273                                ret << @seq[r]
1274                        end while n > 0
1275                        ret.reverse.join
1276                end
1277
1278                def push(obj)
1279                        id = generate(@n)
1280                        self[id] = obj
1281                        @n += 1
1282                        @n %= @seq.size ** @size
1283                        id
1284                end
1285                alias << push
1286
1287                def clear
1288                        @n = 0
1289                        super
1290                end
1291
1292                private :[]=
1293                undef update, merge, merge!, replace
1294        end
1295
1296
1297end
1298
1299if __FILE__ == $0
1300        require "optparse"
1301
1302        opts = {
1303                :port  => 16668,
1304                :host  => "localhost",
1305                :log   => nil,
1306                :debug => false,
1307                :foreground => false,
1308        }
1309
1310        OptionParser.new do |parser|
1311                parser.instance_eval do
1312                        self.banner = <<-EOB.gsub(/^\t+/, "")
1313                                Usage: #{$0} [opts]
1314
1315                        EOB
1316
1317                        separator ""
1318
1319                        separator "Options:"
1320                        on("-p", "--port [PORT=#{opts[:port]}]", "port number to listen") do |port|
1321                                opts[:port] = port
1322                        end
1323
1324                        on("-h", "--host [HOST=#{opts[:host]}]", "host name or IP address to listen") do |host|
1325                                opts[:host] = host
1326                        end
1327
1328                        on("-l", "--log LOG", "log file") do |log|
1329                                opts[:log] = log
1330                        end
1331
1332                        on("--debug", "Enable debug mode") do |debug|
1333                                opts[:log]   = $stdout
1334                                opts[:debug] = true
1335                        end
1336
1337                        on("-f", "--foreground", "run foreground") do |foreground|
1338                                opts[:log]        = $stdout
1339                                opts[:foreground] = true
1340                        end
1341
1342                        on("-n", "--name [user name or email address]") do |name|
1343                                opts[:name] = name
1344                        end
1345
1346                        parse!(ARGV)
1347                end
1348        end
1349
1350        opts[:logger] = Logger.new(opts[:log], "daily")
1351        opts[:logger].level = opts[:debug] ? Logger::DEBUG : Logger::INFO
1352
1353        #def daemonize(foreground = false)
1354        #       [:INT, :TERM, :HUP].each do |sig|
1355        #               Signal.trap sig, "EXIT"
1356        #       end
1357        #       return yield if $DEBUG or foreground
1358        #       Process.fork do
1359        #               Process.setsid
1360        #               Dir.chdir "/"
1361        #               STDIN.reopen  "/dev/null"
1362        #               STDOUT.reopen "/dev/null", "a"
1363        #               STDERR.reopen STDOUT
1364        #               yield
1365        #       end
1366        #       exit! 0
1367        #end
1368
1369        #daemonize(opts[:debug] || opts[:foreground]) do
1370                Net::IRC::Server.new(opts[:host], opts[:port], TwitterIrcGateway, opts).start
1371        #end
1372end
Note: See TracBrowser for help on using the browser.