├── README ├── lig.rb └── lingr.rb /README: -------------------------------------------------------------------------------- 1 | = lingr.rb 2 | 3 | * lingr.rb 4 | 5 | Lingr client library. 6 | 7 | * lig.rb 8 | 9 | Lingr IRC gateway. 10 | 11 | == Usage: 12 | 13 | $ ruby lig.rb 14 | 15 | Connect to the local service with your IRC client. 16 | 17 | IRC client settings: 18 | 19 | Host: localhost 20 | Port: 26667 21 | Username: Your Lingr account name 22 | Password: Your Lingr password 23 | 24 | If you want to see back logs in a room, specify as below. 25 | 26 | Realname: backlog 27 | -------------------------------------------------------------------------------- /lig.rb: -------------------------------------------------------------------------------- 1 | #-*- coding: utf-8 -*- 2 | # Created by Satoshi Nakagawa. 3 | # You can redistribute it and/or modify it under the Ruby's license or the GPL2. 4 | 5 | require 'socket' 6 | require 'logger' 7 | require File.dirname(__FILE__) + '/lingr.rb' 8 | 9 | 10 | module LingrIRCGateway 11 | 12 | PRIVMSG = "PRIVMSG" 13 | NOTICE = "NOTICE" 14 | 15 | class Server 16 | def initialize(port, backlog_count=30, logger=nil, api_key=nil) 17 | @port = port 18 | @backlog_count = backlog_count 19 | @logger = logger 20 | @api_key = api_key 21 | end 22 | 23 | def start 24 | @server = TCPServer.open(@port) 25 | log { "started Lingr IRC gateway at localhost:#{@port}" } 26 | loop do 27 | c = Client.new(@server.accept, @backlog_count, @logger, @api_key) 28 | Thread.new do 29 | c.process 30 | end 31 | end 32 | end 33 | 34 | def log(&block) 35 | @logger.info(&block) if @logger 36 | end 37 | end 38 | 39 | 40 | class Client 41 | def initialize(socket, backlog_count, logger=nil, api_key=nil) 42 | @socket = socket 43 | @backlog_count = backlog_count 44 | @logger = logger 45 | @api_key = api_key 46 | end 47 | 48 | def process 49 | while line = @socket.gets 50 | line.chomp! 51 | log { "received from IRC client: #{line}" } 52 | case line 53 | when /^PASS\s+/i 54 | @password = $~.post_match 55 | when /^NICK\s+/i 56 | @user = $~.post_match 57 | when /^USER\s+/i 58 | on_user($~.post_match) 59 | when /^PRIVMSG\s+/i, /^NOTICE\s+/i 60 | s = $~.post_match 61 | on_privmsg(*s.split(/\s+/, 2)) 62 | when /^WHOIS\s+/i 63 | on_whois($~.post_match) 64 | when /^PING\s+/i 65 | on_ping($~.post_match) 66 | when /^QUIT/i 67 | on_quit 68 | end 69 | end 70 | rescue => e 71 | log_error { "error in IRC client read loop: #{e.inspect}" } 72 | terminate 73 | end 74 | 75 | private 76 | 77 | def on_user(param) 78 | params = param.split(' ', 4) 79 | realname = params[3] 80 | realname = $~.post_match if realname =~ /^:/ 81 | @show_backlog = realname =~ /backlog/ 82 | @show_time = realname =~ /time/ 83 | 84 | log { "connecting to Lingr: #{@user}" } 85 | 86 | @lingr = Lingr::Connection.new(@user, @password, @backlog_count, true, @logger, @api_key) 87 | 88 | @lingr.connected_hooks << lambda do |sender| 89 | begin 90 | log { "connected to Lingr" } 91 | 92 | reply(1, ":Welcome to Lingr!") 93 | reply(376, ":End of MOTD.") 94 | 95 | @lingr.rooms.each do |k, room| 96 | send("#{my_prefix} JOIN ##{room.id}") 97 | 98 | # show room name as topic 99 | reply(332, "##{room.id} :#{room.name}") 100 | 101 | # show names list 102 | names = room.members.map{|k,m| "#{m.owner ? '@' : ''}#{m.username}" }.join(' ') 103 | reply(353, "= ##{room.id} :#{names}") 104 | reply(366, "##{room.id} :End of NAMES list.") 105 | 106 | # show backlog 107 | if @show_backlog 108 | room.backlog.each do |m| 109 | send_text(m, room, NOTICE) 110 | end 111 | end 112 | room.backlog.clear 113 | end 114 | rescue => e 115 | log_error { "gateway exception in connected event: #{e.inspect}" } 116 | terminate 117 | end 118 | end 119 | 120 | @lingr.error_hooks << lambda do |sender, error| 121 | begin 122 | log { "received error from Lingr: #{error.inspect}" } 123 | send(%Q[ERROR :Closing Link: #{@user}!#{@user}@lingr.com ("#{error.inspect}")]) 124 | terminate 125 | rescue => e 126 | log_error { "gateway exception in error event: #{e.inspect}" } 127 | terminate 128 | end 129 | end 130 | 131 | @lingr.message_hooks << lambda do |sender, room, message| 132 | begin 133 | log { "received message from Lingr: #{room.id} #{message.inspect}" } 134 | unless message.mine 135 | send_text(message, room, message.type == 'bot' ? NOTICE : PRIVMSG) 136 | end 137 | rescue => e 138 | log_error { "gateway exception in message event: #{e.inspect}" } 139 | terminate 140 | end 141 | end 142 | 143 | @lingr.join_hooks << lambda do |sender, room, member| 144 | begin 145 | log { "received join from Lingr: #{room.id} #{member.username}" } 146 | rescue => e 147 | log_error { "gateway exception in join event: #{e.inspect}" } 148 | terminate 149 | end 150 | end 151 | 152 | @worker = Thread.new do 153 | begin 154 | @lingr.start 155 | rescue Exception => e 156 | log_error { "Lingr connection exception: #{e.inspect}" } 157 | terminate 158 | end 159 | end 160 | end 161 | 162 | def on_privmsg(chan, text) 163 | chan = chan[1..-1] 164 | text = $~.post_match if text =~ /^:/ 165 | @lingr.say(chan, text) 166 | end 167 | 168 | def on_whois(param) 169 | nick = param.split(' ')[0] 170 | 171 | rooms = [] 172 | member = nil 173 | @lingr.rooms.each do |k,r| 174 | if m = r.members[nick] 175 | member = m 176 | rooms << [r,m] 177 | end 178 | end 179 | 180 | if member 181 | reply(311, "#{nick} #{nick} lingr.com * :#{member.name}") 182 | chans = rooms.map {|e| "#{e[1].owner ? '@' : ''}##{e[0].id}" }.join(' ') 183 | reply(319, "#{nick} :#{chans}") 184 | reply(312, "#{nick} lingr.com :San Francisco, US") 185 | reply(318, "#{nick} lingr.com :End of WHOIS list.") 186 | end 187 | end 188 | 189 | def on_ping(server) 190 | send("PONG #{server}") 191 | end 192 | 193 | def on_quit 194 | send(%Q[ERROR :Closing Link: #{@user}!#{@user}@lingr.com ("Client quit")]) 195 | terminate 196 | end 197 | 198 | def terminate 199 | @socket.close 200 | @worker.terminate 201 | rescue Exception 202 | end 203 | 204 | def send(line) 205 | @socket.puts(line) 206 | end 207 | 208 | def reply(num, line) 209 | s = sprintf(":lingr %03d #{@user} #{line}", num) 210 | send(s) 211 | end 212 | 213 | def send_text(message, room, cmd) 214 | timestr = "" 215 | if cmd == NOTICE 216 | if @show_time 217 | time = message.timestamp 218 | time.localtime 219 | timestr = " (#{time.strftime('%m/%d %H:%M')})" 220 | end 221 | end 222 | 223 | lines = message.text.split(/\r?\n/) 224 | lines.each do |line| 225 | send("#{user_prefix(message.speaker_id)} #{cmd} ##{room.id} :#{line.chomp}#{timestr}") 226 | end 227 | end 228 | 229 | def my_prefix 230 | ":#{@user}!#{@user}@lingr.com" 231 | end 232 | 233 | def user_prefix(user) 234 | ":#{user}!#{user}@lingr.com" 235 | end 236 | 237 | def log(&block) 238 | @logger.info(&block) if @logger 239 | end 240 | 241 | def log_error(&block) 242 | @logger.error(&block) if @logger 243 | end 244 | 245 | end 246 | 247 | end 248 | 249 | 250 | if __FILE__ == $0 251 | backlog_count = 30 252 | logger = nil 253 | #logger = Logger.new(STDERR) 254 | api_key = nil 255 | c = LingrIRCGateway::Server.new(26667, backlog_count, logger, api_key) 256 | c.start 257 | end 258 | -------------------------------------------------------------------------------- /lingr.rb: -------------------------------------------------------------------------------- 1 | #-*- coding: utf-8 -*- 2 | # Created by Satoshi Nakagawa. 3 | # You can redistribute it and/or modify it under the Ruby's license or the GPL2. 4 | 5 | require 'net/http' 6 | require 'open-uri' 7 | require 'cgi' 8 | require 'logger' 9 | require 'timeout' 10 | require 'rubygems' 11 | require 'json' 12 | 13 | 14 | module Lingr 15 | 16 | DEFAULT_BACKLOG_COUNT = 30 17 | 18 | class Member 19 | attr_reader :username, :name, :icon_url, :owner 20 | attr_accessor :presence 21 | 22 | def initialize(res) 23 | @username = res["username"] 24 | @name = res["name"] 25 | @icon_url = res["icon_url"] 26 | @owner = res["is_owner"] 27 | @presence = res["is_online"] == 1 28 | end 29 | 30 | def inspect 31 | %Q[<#{self.class} #{username} #{name}>] 32 | end 33 | end 34 | 35 | 36 | class Room 37 | attr_reader :id, :name, :blurb, :public, :backlog, :members 38 | 39 | def initialize(res) 40 | @id = res["id"] 41 | @name = res["name"] 42 | @blurb = res["blurb"] 43 | @public = res["public"] 44 | @backlog = [] 45 | @members = {} 46 | 47 | if msgs = res["messages"] 48 | msgs.each do |m| 49 | @backlog << Message.new(m) 50 | end 51 | end 52 | 53 | if roster = res["roster"] 54 | if members = roster["members"] 55 | members.each do |u| 56 | m = Member.new(u) 57 | @members[m.username] = m 58 | end 59 | end 60 | end 61 | end 62 | 63 | def add_member(member) 64 | @members[m.username] = member 65 | end 66 | 67 | def add_backlog(msgs) 68 | msgs.reverse_each do |m| 69 | @backlog.insert(0, m) 70 | end 71 | end 72 | 73 | def inspect 74 | %Q[<#{self.class} #{id}>] 75 | end 76 | end 77 | 78 | 79 | class Message 80 | attr_reader :id, :type, :nickname, :speaker_id, :public_session_id, :text, :timestamp, :mine 81 | 82 | def initialize(res) 83 | @id = res["id"] 84 | @type = res["type"] 85 | @nickname = res["nickname"] 86 | @speaker_id = res["speaker_id"] 87 | @public_session_id = res["public_session_id"] 88 | @text = res["text"] 89 | @timestamp = Time.iso8601(res["timestamp"]) 90 | @mine = false 91 | end 92 | 93 | def decide_mine(my_public_session_id) 94 | @mine = @public_session_id == my_public_session_id 95 | end 96 | 97 | def inspect 98 | %Q[<#{self.class} #{speaker_id}: #{text}>] 99 | end 100 | end 101 | 102 | 103 | class APIError < Exception 104 | attr_reader :code, :detail 105 | 106 | def initialize(res) 107 | @code = res["code"] 108 | @detail = res["detail"] 109 | end 110 | 111 | def inspect 112 | %Q[<#{self.class} code="#{@code}", detail="#{@detail}">] 113 | end 114 | end 115 | 116 | 117 | class Connection 118 | 119 | URL_BASE = "http://lingr.com/api/" 120 | URL_BASE_OBSERVE = "http://lingr.com:8080/api/" 121 | REQUEST_TIMEOUT = 100 122 | RETRY_INTERVAL = 60 123 | 124 | attr_reader :user, :password, :auto_reconnect 125 | attr_reader :nickname, :public_id, :presence, :name, :username 126 | attr_reader :room_ids, :rooms 127 | attr_reader :connected_hooks, :error_hooks, :message_hooks, :join_hooks, :leave_hooks 128 | 129 | def initialize(user, password, backlog_count, auto_reconnect=true, logger=nil, api_key=nil) 130 | @user = user 131 | @password = password 132 | @backlog_count = backlog_count 133 | @auto_reconnect = auto_reconnect 134 | @logger = logger 135 | @api_key = api_key 136 | @connected_hooks = [] 137 | @error_hooks = [] 138 | @message_hooks = [] 139 | @join_hooks = [] 140 | @leave_hooks = [] 141 | end 142 | 143 | def start 144 | begin 145 | create_session 146 | get_rooms 147 | show_room(@room_ids.join(',')) 148 | subscribe(@room_ids.join(',')) 149 | 150 | if @backlog_count > DEFAULT_BACKLOG_COUNT 151 | @rooms.each do |room_id, room| 152 | if m = room.backlog[0] 153 | res = get_archives(room_id, m.id, @backlog_count - DEFAULT_BACKLOG_COUNT) 154 | if msgs = res['messages'] 155 | room.add_backlog(msgs.map {|e| Message.new(e)}) 156 | end 157 | end 158 | end 159 | end 160 | 161 | @connected_hooks.each {|h| h.call(self) } 162 | 163 | loop do 164 | observe 165 | end 166 | rescue APIError => e 167 | raise e if e.code == "invalid_user_credentials" 168 | on_error(e) 169 | retry if @auto_reconnect 170 | rescue IOError,JSON::ParserError => e 171 | on_error(e) 172 | retry if @auto_reconnect 173 | end 174 | end 175 | 176 | def create_session 177 | debug { "requesting session/create: #{@user}" } 178 | params = {:user => @user, :password => @password} 179 | params.merge!(:api_key => @api_key) if @api_key 180 | res = post("session/create", params) 181 | debug { "session/create response: #{res.inspect}" } 182 | @session = res["session"] 183 | @nickname = res["nickname"] 184 | @public_id = res["public_id"] 185 | @presence = res["presence"] 186 | if user = res["user"] 187 | @name = user["name"] 188 | @username = user["username"] 189 | end 190 | @rooms = {} 191 | res 192 | end 193 | 194 | def destroy_session 195 | debug { "requesting session/destroy" } 196 | begin 197 | res = post("session/destroy", :session => @session) 198 | debug { "session/destroy response: #{res.inspect}" } 199 | rescue Exception => e 200 | log_error { "error in destroy_session: #{e.inspect}" } 201 | end 202 | @session = nil 203 | @nickname = nil 204 | @public_id = nil 205 | @presence = nil 206 | @name = nil 207 | @username = nil 208 | @rooms = {} 209 | res 210 | end 211 | 212 | def set_presence(presence) 213 | debug { "requesting session/set_presence: #{presence}" } 214 | res = post("session/set_presence", :session => @session, :presence => presence, :nickname => @nickname) 215 | debug { "session/set_presence response: #{res.inspect}" } 216 | res 217 | end 218 | 219 | def get_rooms 220 | debug { "requesting user/response" } 221 | res = get("user/get_rooms", :session => @session) 222 | debug { "user/get_rooms response: #{res.inspect}" } 223 | @room_ids = res["rooms"] 224 | res 225 | end 226 | 227 | def show_room(room_id) 228 | debug { "requesting room/show: #{room_id}" } 229 | res = get("room/show", :session => @session, :room => room_id) 230 | debug { "room/show response: #{res.inspect}" } 231 | 232 | if rooms = res["rooms"] 233 | rooms.each do |d| 234 | r = Room.new(d) 235 | r.backlog.each do |m| 236 | m.decide_mine(@public_id) 237 | end 238 | @rooms[r.id] = r 239 | end 240 | end 241 | 242 | res 243 | end 244 | 245 | def get_archives(room_id, max_message_id, limit=100) 246 | debug { "requesting room/get_archives: #{room_id} #{max_message_id}" } 247 | res = get("room/get_archives", :session => @session, :room => room_id, :before => max_message_id, :limit => limit) 248 | debug { "room/get_archives response: #{res.inspect}" } 249 | res 250 | end 251 | 252 | def subscribe(room_id, reset=true) 253 | debug { "requesting room/subscribe: #{room_id}" } 254 | res = post("room/subscribe", :session => @session, :room => room_id, :reset => reset.to_s) 255 | debug { "room/subscribe response: #{res.inspect}" } 256 | @counter = res["counter"] 257 | res 258 | end 259 | 260 | def unsubscribe(room_id) 261 | debug { "requesting room/unsubscribe: #{room_id}" } 262 | res = post("room/unsubscribe", :session => @session, :room => room_id) 263 | debug { "room/unsubscribe response: #{res.inspect}" } 264 | res 265 | end 266 | 267 | def say(room_id, text) 268 | debug { "requesting room/say: #{room_id} #{text}" } 269 | res = post("room/say", :session => @session, :room => room_id, :nickname => @nickname, :text => text) 270 | debug { "room/say response: #{res.inspect}" } 271 | res 272 | end 273 | 274 | def observe 275 | debug { "requesting event/observe: #{@counter}" } 276 | res = get("event/observe", :session => @session, :counter => @counter) 277 | debug { "observe response: #{res.inspect}" } 278 | @counter = res["counter"] if res["counter"] 279 | 280 | if events = res["events"] 281 | events.each do |event| 282 | if d = event["message"] 283 | if room = @rooms[d["room"]] 284 | m = Message.new(d) 285 | m.decide_mine(@public_id) 286 | @message_hooks.each {|h| h.call(self, room, m) } 287 | end 288 | elsif d = event["presence"] 289 | if room = @rooms[d["room"]] 290 | username = d["username"] 291 | id = d["public_session_id"] 292 | if status = d["status"] 293 | case status 294 | when "online" 295 | if m = room.members[username] 296 | m.presence = true 297 | @join_hooks.each {|h| h.call(self, room, m) } 298 | end 299 | when "offline" 300 | if m = room.members[username] 301 | m.presence = false 302 | @leave_hooks.each {|h| h.call(self, room, m) } 303 | end 304 | end 305 | end 306 | end 307 | end 308 | end 309 | end 310 | 311 | res 312 | end 313 | 314 | private 315 | 316 | def on_error(e) 317 | log_error { "error: #{e.inspect}" } 318 | destroy_session if @session 319 | @error_hooks.each {|h| h.call(self, e) } 320 | sleep RETRY_INTERVAL if @auto_reconnect 321 | end 322 | 323 | def get(path, params=nil) 324 | is_observe = path == "event/observe" 325 | url = is_observe ? URL_BASE_OBSERVE : URL_BASE 326 | url += path 327 | 328 | if params 329 | url += '?' + params.map{|k,v| "#{k}=#{CGI.escape(v.to_s)}"}.join('&') 330 | end 331 | 332 | res = nil 333 | begin 334 | timeout(REQUEST_TIMEOUT) do 335 | open(url) do |r| 336 | res = JSON.parse(r.read) 337 | end 338 | end 339 | rescue TimeoutError 340 | debug { "get request timed out: #{url}" } 341 | if is_observe 342 | res = { "status" => "ok" } 343 | else 344 | raise 345 | end 346 | end 347 | 348 | if res["status"] == "ok" 349 | res 350 | else 351 | raise APIError.new(res) 352 | end 353 | end 354 | 355 | def post(path, params=nil) 356 | url = URL_BASE + path 357 | if params 358 | url += '?' + params.map{|k,v| "#{k}=#{CGI.escape(v.to_s)}"}.join('&') 359 | end 360 | u = URI.parse(url) 361 | 362 | res = nil 363 | begin 364 | timeout(REQUEST_TIMEOUT) do 365 | http = Net::HTTP.new(u.host, u.port) 366 | response = http.post(u.path, u.query) 367 | res = JSON.parse(response.body) 368 | end 369 | rescue TimeoutError 370 | debug { "post request timed out: #{url}" } 371 | raise 372 | end 373 | 374 | if res["status"] == "ok" 375 | res 376 | else 377 | raise APIError.new(res) 378 | end 379 | end 380 | 381 | def debug(&block) 382 | @logger.debug(&block) if @logger 383 | end 384 | 385 | def log(&block) 386 | @logger.info(&block) if @logger 387 | end 388 | 389 | def log_error(&block) 390 | @logger.error(&block) if @logger 391 | end 392 | 393 | end 394 | 395 | end 396 | --------------------------------------------------------------------------------