2 require 'cgi' # for entity-escaping
6 class DCProtocol < EventMachine::Connection
7 include EventMachine::Protocols::LineText2
9 CLIENT_NAME = "RubyBot"
10 CLIENT_VERSION = "0.1"
12 def self.registerClientVersion(name, version)
13 CLIENT_NAME.replace name
14 CLIENT_VERSION.replace version
17 def registerCallback(callback, &block)
18 @callbacks[callback] << block
22 key = String.new(lock)
23 1.upto(key.size - 1) do |i|
24 key[i] = lock[i] ^ lock[i-1]
26 key[0] = lock[0] ^ lock[-1] ^ lock[-2] ^ 5
29 0.upto(key.size - 1) do |i|
30 key[i] = ((key[i]<<4) & 240) | ((key[i]>>4) & 15)
33 0.upto(key.size - 1) do |i|
34 if [0,5,36,96,124,126].include?(key[i]) then
35 key[i,1] = ("/%%DCN%03d%%/" % key[i])
43 data.gsub("|", "|")
47 CGI.unescapeHTML(data)
50 def send_command(cmd, *args)
51 data = sanitize("$#{cmd}#{["", *args].join(" ")}") + "|"
56 STDERR.puts "-> #{data.gsub(/[^\x20-\x7F]/, ".")}" if @debug
60 def call_callback(callback, *args)
61 @callbacks[callback].each do |proc|
63 proc.call(self, *args)
65 STDERR.puts "Exception: #{e.message}\n#{e.backtrace}"
70 def connection_completed
71 call_callback :connected
74 def receive_line(line)
75 STDERR.puts "<- #{line.gsub(/[^\x20-\x7F]/, ".")}" if @debug
77 line = unsanitize(line)
78 cmd = line.slice!(/^\S+/)
81 if cmd =~ /^<.*>$/ then
82 # this is a specially-formatted command
83 # but lets handle it like other commands
85 if self.respond_to? "cmd_<>" then
86 self.send "cmd_<>", nick, line
88 call_callback :error, "Unknown command: <#{nick}> #{line}"
90 elsif cmd =~ /^\$\S+$/ then
91 # this is a proper command
93 # hardcode the $To: command since the colon is ugly
94 # this protocol is pretty messy
95 cmd = "To" if cmd == "To:"
96 if self.respond_to? "cmd_#{cmd}" then
97 self.send "cmd_#{cmd}", line
99 call_callback :error, "Unknown command: $#{cmd} #{line}"
102 call_callback :error, "Garbage data: #{line}"
107 @callbacks = Hash.new { |h,k| h[k] = [] }
113 call_callback :unbind
117 class DCClientProtocol < DCProtocol
118 # known keys for args are:
119 # password - server password
120 # debug - should this socket print debug data?
125 # slots - number of slots to declare as open
126 def self.connect(host, port, nickname, args = {})
127 EventMachine::connect(host, port, self) do |c|
131 @debug = args[:debug]
132 @config[:description] ||= ""
133 @config[:speed] ||= "Bot"
134 @config[:speed_class] ||= 1
135 @config[:email] ||= ""
136 @config[:slots] ||= 0
138 yield c if block_given?
142 def sendPublicMessage(message)
143 data = sanitize("<#{@nickname}> #{message}") + "|"
147 def sendPrivateMessage(recipient, message)
148 send_command "To:", recipient, "From:", @nickname, "$<#{@nickname}>", message
156 attr_reader :nickname, :hubname, :quit, :users
158 # protocol implementation
161 lock = line.split(" ")[0]
162 key = lockToKey(lock)
164 send_command("Key", "#{key}")
165 send_command("ValidateNick", "#{@nickname}")
168 def cmd_ValidateDenide(line)
169 call_callback :error, "Nickname in use or invalid"
173 def cmd_GetPass(line)
174 if @config.has_key? :password
175 send_command "MyPass", @config[:password]
177 call_callback :error, "Password required but not given"
182 def cmd_BadPass(line)
183 call_callback :error, "Bad password given"
187 def cmd_LogedIn(line)
188 call_callback :logged_in
191 def cmd_HubName(line)
193 call_callback :hubname, @hubname
198 if nick == @nickname then
199 # this is us, we should respond
200 send_command "Version", "1,0091"
201 send_command "GetNickList"
202 send_command "MyINFO", "$ALL #{@nickname} #{@config[:description]}<#{CLIENT_NAME} V:#{CLIENT_VERSION},M:P,H:1/0/0,S:#{@config[:slots]}>$", \
203 "$#{@config[:speed]}#{@config[:speed_class].chr}$#{@config[:email]}$0$"
205 user = DCUser.new(self, nick)
207 call_callback :user_connected, user
211 def cmd_NickList(line)
212 nicks = line.split("$$")
215 @users[nick] = DCUser.new(self, nick)
217 call_callback :nicklist, @users.values
221 nicks = line.split("$$")
223 if @users.has_key? nick then
224 @users[nick].op = true
227 call_callback :oplist, @users.values.select { |user| user.op }
231 if line =~ /^\$ALL (\S+) ([^$]*)\$ +\$([^$]*)\$([^$]*)\$([^$]*)\$$/ then
237 tag = interest.slice!(/<[^>]+>$/)
238 if speed.length > 0 and speed[-1] < 0x20 then
239 # assume last byte a control character means it's the speed class
240 speed_class = speed.slice!(-1)
245 if user and user.nickname != @nickname then
246 user.setInfo(interest, tag, speed, speed_class, email, sharesize)
247 call_callback :info, user
252 def cmd_ConnectToMe(line)
253 # another peer is trying to connect to me
254 if line =~ /^(\S+) (\S+):(\d+)$/ then
258 if mynick == @nickname then
259 connect_to_peer(ip, port)
261 call_callback :error, "Strange ConnectToMe request: #{line}"
266 def cmd_RevConnectToMe(line)
267 if line =~ /^(\S+) (\S+)$/ then
268 # for the moment we're just going to be a passive client
271 if mynick == @nickname then
274 if not user.passive then
275 # the passive switch keeps us from bouncing RevConnectToMe's back and forth
277 call_callback :reverse_connection, user
278 send_command "RevConnectToMe", mynick, nick
280 call_callback :reverse_connection_ignored, user
283 call_callback :error, "RevConnectToMe request from unknown user: #{nick}"
286 call_callback :error, "Strange RevConnectToMe request: #{line}"
291 define_method("cmd_<>") do |nick, line|
292 call_callback :message, nick, line, false
296 if line =~ /^(\S+) From: (\S+) \$<(\S+)> (.*)$/ then
299 displaynick = $3 # ignored for now
301 call_callback :message, nick, message, true, (displaynick == "*")
303 call_callback :error, "Garbage $To: #{line}"
312 call_callback :error, "Unknown user Quit: #{nick}"
314 call_callback :user_quit, user
319 # for the moment, completely ignore this
324 def connect_to_peer(ip, port)
325 @peers << EventMachine::connect(ip, port, DCPeerProtocol) do |c|
327 debug = @debug || @config[:peer_debug]
334 c.call_callback :initialized
338 # event handling methods
345 self.registerCallback :peer_unbind do |socket, peer|
352 @peers.each do |peer|
353 peer.close_connection
359 # major assumption in this implementation is that we are simply uploading
360 # if we want to be able to initiate downloads, this needs some tweaking
361 # we're also a passive client, so we're always connecting to the other client
362 class DCPeerProtocol < DCProtocol
363 XML_FILE_LISTING = <<EOF
364 <?xml version="1.0" encoding="utf-8"?>
365 <FileListing Version="1" Generator="#{CLIENT_NAME} #{CLIENT_VERSION}">
366 <Directory Name="Send a /pm with !help for help">
370 XML_FILE_LISTING_BZ2 = BZ2.bzip2(XML_FILE_LISTING)
372 SUPPORTED_EXTENSIONS = ["ADCGet", "XmlBZList", "TTHF"]
374 attr_reader :remote_nick, :host, :port, :state
380 self.registerCallback :error do |peer, message|
381 peer.send_command "Error", message unless peer.state == :data
382 peer.close_connection_after_writing
386 # callbacks triggered from the peer always begin with peer_
387 def call_callback(name, *args)
389 @parent.call_callback "peer_#{name.to_s}".to_sym, self, *args
392 def connection_completed
394 send_command "MyNick", @parent.nickname
395 send_command "Lock", "EXTENDEDPROTOCOLABCABCABCABCABCABC", "Pk=#{CLIENT_NAME}#{CLIENT_VERSION}ABCABC"
398 def get_file_io(filename)
399 if filename == "files.xml.bz2" then
400 StringIO.new(XML_FILE_LISTING_BZ2)
413 lock = line.split(" ")[0]
414 key = lockToKey(lock)
415 send_command "Supports", *SUPPORTED_EXTENSIONS if lock =~ /^EXTENDEDPROTOCOL/
416 send_command "Direction", "Upload", rand(0x7FFF)
417 send_command "Key", key
421 # who cares if they got the key right? just ignore it
424 def cmd_Direction(line)
425 direction, rnd = line.split(" ")
426 if direction != "Download" then
427 # why did they send me a ConnectToMe if they don't want to download?
428 call_callback :error, "Unexpected peer direction: #{direction}"
434 def cmd_Supports(line)
435 @supports = line.split(" ")
439 if line =~ /^([^$]+)\$(\d+)$/ then
442 offset = $2.to_i - 1 # it's 1-based
443 call_callback :get, filename
444 @fileio = get_file_io(filename)
447 send_command "FileLength", @fileio.size - @fileio.pos
449 send_command "Error", "File Not Available"
450 close_connection_after_writing
453 call_callback :error, "Unknown $Get format"
458 if @fileio.nil? or @state != :data then
459 # we haven't been asked for the file yet
460 send_command "Error", "Unexpected $Send"
461 close_connection_after_writing
463 data = @fileio.read(40906)
472 if line =~ /^(\w+) (.+) (\d+) (-?\d+)(?: (.+))?$/ then
477 flags = ($5 || "").split(" ")
479 if type == "file" then
480 call_callback :get, identifier
481 fileio = get_file_io(identifier)
483 fileio.pos = startpos
484 length = fileio.size - fileio.pos if length == -1
485 send_command "ADCSND", "file", identifier, startpos, length
486 send_data fileio.read(length)
488 send_command "Error", "File Not Available"
491 send_command "Error", "Unknown $ADCGET type: #{type}"
494 send_command "Error", "Unknown $ADCGET flags: #{flags.join(" ")}"
497 send_command "Error", "Unknown $ADCGET format"
501 def cmd_UGetBlock(line)
502 if line =~ /^(\d+) (-?\d+) (.+)$/ then
506 call_callback :get, filename
507 fileio = get_file_io(filename)
509 fileio.pos = startpos
510 length = fileio.size - fileio.pos if length == -1
511 send_command "Sending", length
512 send_data fileio.read(length)
514 send_command "Failed", "File Not Available"
517 send_command "Failed", "Unknown $UGetBlock format"
521 def cmd_Canceled(line)
526 call_callback :error, "Peer Error: #{line}"