シェルライクなTwitterクライアントをざっくり作ってみた

昨日Twitterでシェルっぽい感じのTwitterクライアントあったらおもしろくない? とか言っていて, なんか楽しそうだったのでざっくり作ってみました.

使い方はこんな感じ.

$ ruby twsh.rb -u your_twitter_id -p password
twsh% echo "はろー twsh.rb"
twsh% ls
screen_name1: tweet1
screen_name2: tweet2
screen_name3: tweet3
screen_name4: tweet4
screen_name5: tweet5
twsh% exit
$

下記コードをtwsh.rbとかで保存して, Rubyとして実行してあげれば起動します. -pオプションはパスワードで必須ですが, -uによるTwitter IDは省略すると環境変数USERが使われます.

後はechoで発言, lsでTL取得です. exit(quit, logout)でシェルを抜けます*1.

ちなみにTwsh.rbとして実装されていないコマンドを用いると, 普段使っているシェルに実行を委ねます. そしてバッククォートによるコマンドの実行を備えているので:

twsh% echo "ボクの$ unameは" `uname` です

とかやると, "ボクの$ unameDarwin です"って出せたり.

適当で抜けとかありますが, 興味がある方は適当にいじっちゃってくだしあ.

あ, あとボクがTwitterでぶつくさ言っていたら@omasanoriさんが興味を持ったようでして, PythonでTwsh.pyを作っているみたいです! たぶんTwsh.rbよりクールなやつができあがると思いますよ!

require 'optparse'
require 'stringio'
require 'readline'
require 'rubygems'
require 'twitter'

module Twsh
  class Logout < StandardError; end

  class Shell
    class << self
      def login(argv, env)
        new argv, env
      end
    end

    def initialize(argv, env)
      username = env['USER']
      password = nil

      OptionParser.new do |opt|
        opt.on('-u VAL') {|v| username = v}
        opt.on('-p VAL') {|v| password = v}
        opt.parse! argv
      end

      fail if username.nil? || password.nil?

      @client = Twitter::Base.new Twitter::HTTPAuth.new username, password
      input = Input.new self
      logged_in = true

      while logged_in
        begin
          output = execute input.read
          puts output if output
        rescue Logout
          logged_in = false
        rescue Interrupt
          puts
          logged_in = false
        end
      end
    end

    attr_reader :client

    def execute(argv)
      return if argv.empty?
      command = argv.shift
      Command.new(self).execute command, argv
    end
  end

  class Command
    @@commands = {}

    class << self
      def setup
        fail unless block_given?
        yield new
      end
    end

    def initialize(twsh = nil)
      @twsh = twsh
    end

    def on(*commands, &process)
      fail unless block_given?
      commands.each {|command| @@commands[command] = process}
    end

    def execute(command, argv)
      process = @@commands[command]

      if process
        args = []
        args << @twsh if process.arity >= 1
        args << argv if process.arity >= 2
        output = nil

        StringIO.open '', 'w' do |io|
          orig = $stdout
          $stdout = io
          process.call *args
          $stdout = orig
          output = io.string unless io.string.empty?
        end

        output
      else
        `#{command} #{argv.join ' '}`.chomp
      end
    end
  end

  class Input
    def initialize(twsh)
      @twsh = twsh
    end

    def read
      read_with_context
    end

    private

    def read_with_context(context = nil, argv = [])
      prompt = context ? "#{context}> " : argv.empty? ? "twsh% " : '> '
      input = Readline.readline prompt, true
      context, argv, continue = parse "#{input}\n", context, argv
      continue ? read_with_context(context, argv) : argv
    end

    def parse(input, context = nil, argv = [])
      escape = false
      continue = false
      v = !argv.empty? ? argv.pop : ''

      new = lambda do
        argv << v unless v.empty?
        v = ''
      end

      input.each_byte do |c|
        if escape
          escape = false
          case c
          when LINE_FEED
            continue = true
            v << c if context
          when ?n
            v << "\n" if quoted? context
          when ?t
            v << "\t" if quoted? context
          when ?f
            v << "\f" if quoted? context
          when ?v
            v << "\v" if quoted? context
          else
            v << c
          end
        else
          case c
          when ?\n
            v << c if context
          when ?\s
            if context
              v << c
            else
              new.call
            end
          when ?'
            if context == :quote
              new.call
              context = nil
            elsif context
              v << c
            else
              context = :quote
            end
          when ?"
            if context == :dquote
              new.call
              context = nil
            elsif context
              v << c
            else
              context = :dquote
            end
          when ?`
            if context == :bquote
              v = @twsh.execute(parse(v)[1])
              context = nil
            elsif context
              v << c
            else
              context = :bquote
            end
          when ?\\
            escape = true
          else
            v << c
          end
        end
      end

      new.call
      [context, argv, continue || !context.nil?]
    end

    def quoted?(context)
      context == :quote || context == :dquote
    end
  end
end

Twsh::Command.setup do |c|
  c.on('exit', 'quit', 'logout') { raise Twsh::Logout }

  c.on 'echo' do |twsh, argv|
    twsh.client.update argv.join ' '
  end

  c.on 'ls' do |twsh, argv|
    n = 5

    OptionParser.new do |opt|
      opt.on('-n VAL', Integer) {|v| n = v}
      opt.parse! argv
    end

    twsh.client.home_timeline(:count => n).each do |status|
      puts "#{status.user.screen_name}: #{status.text}"
    end
  end

  c.on 'cd' do |twsh, argv|
    if argv.empty?
      Dir.chdir
    else
      Dir.chdir File.expand_path argv.first
    end
  end
end

Twsh::Shell.login ARGV, ENV

*1:Ctrl + cでもいいんですが, Readlineが捉えちゃっているのかInterruptがうまく投げられない……