growlプラグインで、priorityとstickyを設定できるようにしてみた

うれしいことに、先日書いたGrowlプラグインがtermtter本体に取り込まれることとなりました。blogは書いてみるものですね。
せっかくなので、Meowの機能をひととおり使ってみようと、priorityとstickyの設定をできるようにしてみました。

# -*- coding: utf-8 -*-

require 'open-uri'
require 'uri'
require 'fileutils'
require 'cgi'

begin
  require 'meow'
  growl = Meow.new('termtter', 'update_friends_timeline')
rescue LoadError
  growl = nil
end

config.plugins.growl.set_default(:icon_cache_dir, "#{Termtter::CONF_DIR}/tmp/user_profile_images")
config.plugins.growl.set_default(:growl_user, [])
config.plugins.growl.set_default(:growl_keyword, [])
config.plugins.growl.set_default(:priority_veryhigh_user, [])
config.plugins.growl.set_default(:priority_high_user, [])
config.plugins.growl.set_default(:priority_normal_user, [])
config.plugins.growl.set_default(:priority_low_user, [])
config.plugins.growl.set_default(:priority_verylow_user, [])
config.plugins.growl.set_default(:priority_veryhigh_keyword, [])
config.plugins.growl.set_default(:priority_high_keyword, [])
config.plugins.growl.set_default(:priority_normal_keyword, [])
config.plugins.growl.set_default(:priority_low_keyword, [])
config.plugins.growl.set_default(:priority_verylow_keyword, [])
config.plugins.growl.set_default(:sticky_user, [])
config.plugins.growl.set_default(:sticky_keyword, [])
growl_keys    = { 'user'    =>  config.plugins.growl.growl_user,
                  'keyword' =>  Regexp.union(config.plugins.growl.growl_keyword) }
priority_keys = { 'user'    => [config.plugins.growl.priority_veryhigh_user,
                                config.plugins.growl.priority_high_user,
                                config.plugins.growl.priority_normal_user,
                                config.plugins.growl.priority_low_user,
                                config.plugins.growl.priority_verylow_user],
                  'keyword' => [Regexp.union(config.plugins.growl.priority_veryhigh_keyword),
                                Regexp.union(config.plugins.growl.priority_high_keyword),
                                Regexp.union(config.plugins.growl.priority_normal_keyword),
                                Regexp.union(config.plugins.growl.priority_low_keyword),
                                Regexp.union(config.plugins.growl.priority_verylow_keyword) ] }
sticky_keys   = { 'user'    =>  config.plugins.growl.sticky_user,
                  'keyword' =>  Regexp.union(config.plugins.growl.sticky_keyword) }

FileUtils.mkdir_p(config.plugins.growl.icon_cache_dir) unless File.exist?(config.plugins.growl.icon_cache_dir)
Dir.glob("#{config.plugins.growl.icon_cache_dir}/*") {|f| File.delete(f) unless File.size?(f) }
unless File.exist?("#{config.plugins.growl.icon_cache_dir}/default.png")
  File.open("#{config.plugins.growl.icon_cache_dir}/default.png", "wb") do |f|
    f << open("http://static.twitter.com/images/default_profile_normal.png").read
  end
end

def get_icon_path(s)
  /https?:\/\/.+\/(\d+)\/.*?$/ =~ s.user.profile_image_url
  cache_file = "%s/%s-%s%s" % [  config.plugins.growl.icon_cache_dir,
                                 s.user.screen_name,
                                 $+,
                                 File.extname(s.user.profile_image_url)  ]
  unless File.exist?(cache_file)
    Thread.new(s,cache_file) do |s,cache_file|
      Dir.glob("#{config.plugins.growl.icon_cache_dir}/#{s.user.screen_name}-*") {|f| File.delete(f) }
      begin
        s.user.profile_image_url.sub!(/^https/,'http')
        File.open(cache_file, 'wb') do |f|
          f << open(URI.escape(s.user.profile_image_url)).read
        end
      rescue OpenURI::HTTPError
        cache_file = "#{config.plugins.growl.icon_cache_dir}/default.png"
      end
    end
  end
  return cache_file
end

def get_priority(s,priority_keys)
  priority = 2
  5.times {|n|
    return priority.to_s if priority_keys['user'][n].include?(s.user.screen_name) ||\
                            priority_keys['keyword'][n] =~ s.text
    priority -= 1
  }
  return '0'
end

def is_growl(s,growl_keys)
  return true if (growl_keys['user'].empty? && growl_keys['keyword'] == /(?!)/) ||\
                 (growl_keys['user'].include?(s.user.screen_name) || growl_keys['keyword'] =~ s.text)
  return false
end

def is_sticky(s,sticky_keys)
  return true if sticky_keys['user'].include?(s.user.screen_name) || sticky_keys['keyword'] =~ s.text
  return false
end

Termtter::Client.register_hook(
  :name => :growl,
  :points => [:output],
  :exec_proc => lambda {|statuses, event|
    return unless event == :update_friends_timeline
    Thread.start do
      statuses.each do |s|
        next unless is_growl(s,growl_keys)
        growl_title = s.user.screen_name
        growl_title += " (#{s.user.name})" unless s.user.screen_name == s.user.name
        unless growl
          arg = ['growlnotify', growl_title, '-m', s.text.gsub("\n",''), '-n', 'termtter', '-p', get_priority(s,priority_keys), '--image', get_icon_path(s)]
          arg.push('-s') if is_sticky(s,sticky_keys)
          system *arg
        else
          begin
            icon = Meow.import_image(get_icon_path(s))
          rescue
            icon = Meow.import_image("#{config.plugins.growl.icon_cache_dir}/default.png")
          end
          growl.notify(growl_title, CGI.unescape(CGI.unescapeHTML(s.text)),
                       {:icon => icon,
                        :priority => get_priority(s,priority_keys),
                        :sticky => is_sticky(s,sticky_keys) }) do
            s.text.gsub(URI.regexp) {|uri| system "open #{uri}"}
          end
        end
        sleep 0.1
      end
    end
  }
)
#Optional setting example.
#  Growl ON setting.
#    config.plugins.growl.growl_user    = ['p2pquake', 'jihou']
#    config.plugins.growl.growl_keyword = ['地震', /^@screen_name/]
#  Priority setting.
#    config.plugins.growl.priority_veryhigh_user    = ['veryhigh_user']
#    config.plugins.growl.priority_veryhigh_keyword = ['veryhigh_keyword', /^@screen_name/]
#    config.plugins.growl.priority_high_user        = ['high_user']
#    config.plugins.growl.priority_high_keyword     = ['high_keyword']
#    config.plugins.growl.priority_low_user         = ['low_user']
#    config.plugins.growl.priority_low_keyword      = ['low_keyword']
#    config.plugins.growl.priority_verylow_user     = ['verylow_user']
#    config.plugins.growl.priority_verylow_keyword  = ['verylow_keyword']  
#  Sticky setting.
#    config.plugins.growl.sticky_user    = ['screen_name']
#    config.plugins.growl.sticky_keyword = [/^@screen_name/, '#termtter']

priorityの設定については、以下のように記述します。重要度ごとにユーザ名、キーワード(正規表現可)を設定してください。重要度の高い設定ほど優先的に適用されます。
たとえば「やや重要」に設定したユーザが「緊急」に設定したキーワードを投稿したら、priorityは「緊急」になります。
逆に、「緊急」に設定したユーザが「やや重要」のキーワードを投稿した場合も、priorityは「緊急」になります。

#緊急
config.plugins.growl.priority_veryhigh_user    = ['veryhigh_user']
config.plugins.growl.priority_veryhigh_keyword = ['veryhigh_keyword', /^@screen_name/]
#重要
config.plugins.growl.priority_high_user        = ['high_user']
config.plugins.growl.priority_high_keyword     = ['high_keyword']
#やや重要
config.plugins.growl.priority_low_user         = ['low_user']
config.plugins.growl.priority_low_keyword      = ['low_keyword']
#重要でない
config.plugins.growl.priority_verylow_user     = ['verylow_user']
config.plugins.growl.priority_verylow_keyword  = ['verylow_keyword']  

stickyを設定するユーザおよびキーワードは、以下のような感じで。

config.plugins.growl.sticky_user = ['screen_name1', 'screen_name2']
config.plugins.growl.sticky_keyword = [/^@screen_name/, '#termtter']

設定に関しては、もうすこし簡潔な方法がありそうなのですが、おもいつきませんでした。まだまだ勉強不足です。

Post時に、URLをbit.lyで短縮するプラグインを書いてみた

# -*- coding: utf-8 -*-

require 'uri'
require 'open-uri'

config.plugins.bitly.set_default(:length_to_shorten, '40')

length_to_shorten = config.plugins.bitly.length_to_shorten.to_i
login = config.plugins.bitly.login
key = config.plugins.bitly.key
if login.empty? || key.empty?
  puts 'Need your "bit.ly login name" & "API Key"'
  puts 'please set config.plugins.bitly.login & config.plugins.bitly.key'
  puts 'your API Key is here => http://bit.ly/account/'
else
  Termtter::Client.register_hook(
    :name => :bitly,
    :points => [:modify_arg_for_update],
    :exec_proc => lambda {|cmd, arg|
      long_url = []
      arg.gsub(URI.regexp) {|uri|
        long_url.push(uri) unless uri.size < length_to_shorten || /^http:\/\/bit\.ly\// =~ uri
      }
      return arg if long_url.empty?

      api_query = "http://api.bit.ly/shorten?version=2.0.1&login=#{login}&apiKey=#{key}"
      long_url.each {|url| api_query += "&longUrl=#{url}"}

      begin
        response_json = open(api_query).read
      rescue OpenURI::HTTPError
        puts "bit.ly access error : #{$!}"
        return arg
      end

      if /"statusCode": "OK"/ =~ response_json
        long_url.each {|url|
          /#{Regexp.escape(url)}.*?"shortUrl": "(.*?)"/m =~ response_json
          arg.sub!(url, $1)
        }
      else
        /"errorMessage": "(.*?)"/ =~ response_json
        puts "bit.ly API error : #{$1}"
      end
      arg
    }
  )
end

#Necessary settings.
#  config.plugins.bitly.login = 'YOUR LOGIN NAME'
#  config.plugins.bitly.key = 'API KEY'
#Optional setting.
#  config.plugins.bitly.length_to_shorten = '40'
#
#Get API Key at http://bit.ly/account/

bit.lyAPIを利用するには、アカウントを登録してAPI Keyの取得が必要です。そのうえで、アカウントのログインネームとAPI keyをそれぞれ"config.plugins.bitly.login"と"config.plugins.bitly.key"に設定してください。
また、"config.plugins.bitly.length_to_shorten"に設定した文字数以上のURLのみを短縮します(デフォルト値の'40'は、Twitterの仕様に倣ってみました)。

ついでに、URLをtweetburnerで短縮するプラグインも書いてみた

# -*- coding: utf-8 -*-

require 'net/http'

config.plugins.tweetburner.set_default(:length_to_shorten, '40')
config.plugins.tweetburner.set_default(:open_timeout, '4')
config.plugins.tweetburner.set_default(:read_timeout, '6')
length_to_shorten = config.plugins.tweetburner.length_to_shorten.to_i
open_timeout = config.plugins.tweetburner.open_timeout.to_i
read_timeout = config.plugins.tweetburner.read_timeout.to_i

Termtter::Client.register_hook(
  :name => :tweetburner,
  :points => [:modify_arg_for_update],
  :exec_proc => lambda {|cmd, arg|
    long_url = []
    arg.gsub(URI.regexp) {|uri|
      long_url.push(uri) unless uri.size < length_to_shorten || /^http:\/\/twurl\.nl\// =~ uri
    }
    return arg if long_url.empty?

    Net::HTTP.version_1_2
    http = Net::HTTP.new('tweetburner.com')
    http.open_timeout = open_timeout
    http.read_timeout = read_timeout
    begin
      http.start do
        long_url.each {|longurl|
          response = http.post('/links', "link[url]=#{longurl}")
          if response.code == '200'
            arg.sub!(longurl, response.body)
          else
            puts "Tweetburner error : #{response.code}"
          end
        }
      end
    rescue Timeout::Error
      puts 'Tweetburner access timeout'
    end
    arg
  }
)
#Optional setting.
#  config.plugins.tweetburner.length_to_shorten = '40'

もののついでに、tweetburnerでURLを短縮するプラグインも書いてみました。
せっかくなので、見よう見まねでタイムアウトの処理も書いてみましたが、タイムアウト時間などが設定できても、あまりうれしくないですね。

「bit.lyに登録するのは面倒だけど、ちょっと統計情報があったらうれしい」という時につかえるかと思ったのですが、総じてAPIを使うよりWeb上から利用したほうが便利なサービスのようです。

growlプラグインを自分好みに改造してみる

termtter本体添付のgrowlプラグインを、自分好みに改造してみました。

# -*- coding: utf-8 -*-

require 'tmpdir'
require 'open-uri'
require 'uri'
require 'fileutils'
require 'cgi'

begin
  require 'meow'
  growl = Meow.new('termtter', 'update_friends_timeline')
rescue LoadError
  growl = nil
end

config.plugins.growl.set_default(:icon_cache_dir, "#{Termtter::CONF_DIR}/tmp/user_profile_images")
config.plugins.growl.set_default(:growl_user, [])
config.plugins.growl.set_default(:growl_keyword, [])
FileUtils.mkdir_p(config.plugins.growl.icon_cache_dir) unless File.exist?(config.plugins.growl.icon_cache_dir)
unless File.exist?("#{config.plugins.growl.icon_cache_dir}/default.png")
  File.open("#{config.plugins.growl.icon_cache_dir}/default.png", "wb") do |f|
    f << open("http://static.twitter.com/images/default_profile_normal.png").read
  end
end

def get_icon_path(s)
  Dir.mkdir_p(config.plugins.growl.icon_cache_dir) unless File.exists?(config.plugins.growl.icon_cache_dir)

  /http:\/\/.+\/(\d+)\/.*?$/ =~ s.user.profile_image_url
  cache_file = "%s/%s-%s%s" % [  config.plugins.growl.icon_cache_dir,
                                 s.user.screen_name,
                                 $+,
                                 File.extname(s.user.profile_image_url)  ]
  unless File.exist?(cache_file)
    Thread.new(s,cache_file) do |s,cache_file|
      Dir.glob("#{config.plugins.growl.icon_cache_dir}/#{s.user.screen_name}-*") {|f| File.delete(f) }
      begin
        File.open(cache_file, "wb") do |f|
          f << open(URI.escape(s.user.profile_image_url)).read
        end
      rescue OpenURI::HTTPError
        cache_file = "#{config.plugins.growl.icon_cache_dir}/default.png"
      end
    end
  end
  return cache_file
end

def is_growl(s)
  return true if config.plugins.growl.growl_user.empty? && config.plugins.growl.growl_keyword.empty?
  if config.plugins.growl.growl_user.include?(s.user.screen_name) ||\
    Regexp.union(config.plugins.growl.growl_keyword) =~ s.text
    return true
  else
    return false
  end
end

Termtter::Client.register_hook(
  :name => :growl,
  :points => [:output],
  :exec_proc => lambda {|statuses, event|
    return unless event == :update_friends_timeline
    Thread.start do
      statuses.each do |s|
        next unless is_growl(s)
        growl_title = s.user.screen_name
        growl_title += " (#{s.user.name})" unless s.user.screen_name == s.user.name
        unless growl
          system 'growlnotify', growl_title, '-m', s.text.gsub("\n",''), '-n', 'termtter', '--image', get_icon_path(s)
        else
          begin
            icon = Meow.import_image(get_icon_path(s))
          rescue
            icon = Meow.import_image("#{config.plugins.growl.icon_cache_dir}/default.png")
          end
          growl.notify(growl_title, CGI.unescape(CGI.unescapeHTML(s.text)), :icon => icon) do
            s.text.gsub(URI.regexp) {|uri| system "open #{uri}"}
          end
        end
        sleep 0.1
      end
    end
  }
)

変更点

  • Meowでは「Growl通知をクリックしたときの動作」を設定できるので、「発言中にURLがあったら、それを開く」というアクションを設定してみました。
  • ユーザアイコンキャッシュのファイル名に、画像ファイルの親ディレクトリ名を含めるようにしました。
    • Twitterユーザアイコン画像の親ディレクトリは、数桁の数字になっていますが、これはアイコンを変更するごとに更新されるようです。この部分をみることで、アイコンの変更に追従しつつ、余計なアクセスをしなくてすむ、とおもいます。
#設定例
config.plugins.growl.growl_user = ['p2pquake', 'jihou']
config.plugins.growl.growl_keyword = ['地震', /^@User_Name/]
  • (screen nameとは別の)Nameが設定されていた場合、Growlのタイトルに表示するようにしました。
  • Twitterのデフォルトユーザアイコンをキャッシュしておいて、何らかの事情でアイコンが表示されない場合は、デフォルトのアイコンを表示するようにしました。

問題点

一部のユーザのアイコンをGrowlに渡すと、"CMSCreateDataProviderOrGetInfo : Invalid colorspace type"というエラーを吐きます(Meowでも、growlnotifyでも)。これは、「アイコン画像をGrowlで表示する際に、NSImageのインスタンスにしなければいけない」らしいのですが、その際に出てしまうエラーのようです。エラーが出ても、アイコン自体は表示されるのですが、エラーメッセージが標準出力に出力されてしまって、たいへんに不恰好です。

termtterのプラグインをいじってみる

プログラミングに関するブログを読んだり、Twitterプログラマな人をfollowしたりしていると、「ネットユーザーはおしなべてプログラミングができるもんだ」という錯覚におちいります。

プログラミングができるのは素敵だなあ、ということで、ぼくも最近Rubyをおぼえはじめました。はじめてみれば、これがなかなかおもしろいのですね。

さしあたり、てごろな教材としてtermtterのプラグインをいじっていきたいとおもいます。

てはじめに、confirm.rbに手を加えてみました(というほどのものでもないですが)。

# -*- coding: utf-8 -*-

Termtter::Client.register_hook(
  :name => :confirm,
  :points => [:pre_exec_update],
  :exec_proc => lambda {|cmd, arg|
    if /^y?$/i !~ Readline.readline("update? #{arg} (#{arg.split(//).size}) [Y/n] ", false)
      puts 'canceled.'
      raise Termtter::CommandCanceled
    end
  }
)

termtterは、他のクライアントにあるような「入力文字数の表示」がなさそう(Ver 1.0.7 現在)だったので、confirmの際にそれができたらうれしいな、ということでconfirmのプロンプトに"(文字数)"という形で表示されるようにしてみました。
こういう、ちょとしたことを簡単にできるのは、すばらしいですね。
自作や独自に改造したプラグインは、"~/.termtter/plugins"というディレクトリを作って、その中にいれておけば、termtter本体に添付されているプラグインより優先して読み込まれるようです。

outputzプラグインのエラー

termtterのoutputzプラグインがときおりエラーを吐くのですが、outputzにポストする前に変数argが空になるのが原因(Thread - Rubyリファレンスマニュアル)ではないかとおもったので、以下のようにしてみました。
とりあえず、エラーは吐かなくはなったようですが、これで大丈夫なのでしょうか。

# -*- coding: utf-8 -*-

module Termtter::Client
  config.plugins.outputz.set_default(:uri, 'termtter://twitter.com/status/update')

  key = config.plugins.outputz.secret_key
  if key.empty?
    puts 'Need your secret key'
    puts 'please set config.plugins.outputz.secret_key'
  else
    register_hook(
      :name => :outputz,
      :points => [:pre_exec_update],
      :exec_proc => lambda {|cmd, arg|
        Thread.new(arg) do |arg|
          Termtter::API.connection.start('outputz.com', 80) do |http|
              key  = CGI.escape key
              uri  = CGI.escape config.plugins.outputz.uri
              size = arg.split(//).size
              http.post('/api/post', "key=#{key}&uri=#{uri}&size=#{size}")
            end
        end
      }
    )
  end
end

# outputz.rb
#   a plugin that report to outputz your post
#
# settings (note: must this order)
#   config.plugins.outputz.secret_key = 'your secret key'
#   plugin 'outputz'