Trap ^C and shut down cleanly
[dcbot.git] / dcprotocol.rb
bloba534a595246b8a90edf8bf69dfebf8d97611dc50
1 require 'stringio'
2 require 'cgi' # for entity-escaping
3 require 'bz2'
4 require './dcuser'
6 class DCProtocol < EventMachine::Connection
7   include EventMachine::Protocols::LineText2
8   
9   CLIENT_NAME = "RubyBot"
10   CLIENT_VERSION = "0.1"
11   
12   def self.registerClientVersion(name, version)
13     CLIENT_NAME.replace name
14     CLIENT_VERSION.replace version
15   end
16   
17   def registerCallback(callback, &block)
18     @callbacks[callback] << block
19   end
20   
21   def lockToKey(lock)
22     key = String.new(lock)
23     1.upto(key.size - 1) do |i|
24       key[i] = lock[i] ^ lock[i-1]
25     end
26     key[0] = lock[0] ^ lock[-1] ^ lock[-2] ^ 5
27     
28     # nibble-swap
29     0.upto(key.size - 1) do |i|
30       key[i] = ((key[i]<<4) & 240) | ((key[i]>>4) & 15)
31     end
32     
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])
36       end
37     end
38     
39     key
40   end
41   
42   def sanitize(data)
43     data.gsub("|", "&#124;")
44   end
45   
46   def unsanitize(data)
47     CGI.unescapeHTML(data)
48   end
49   
50   def send_command(cmd, *args)
51     data = sanitize("$#{cmd}#{["", *args].join(" ")}") + "|"
52     send_data(data)
53   end
54   
55   def send_data(data)
56     STDERR.puts "-> #{data.gsub(/[^\x20-\x7F]/, ".")}" if @debug
57     super
58   end
59   
60   def call_callback(callback, *args)
61     @callbacks[callback].each do |proc|
62       begin
63         proc.call(self, *args)
64       rescue Exception => e
65         STDERR.puts "Exception: #{e.message}\n#{e.backtrace}"
66       end
67     end
68   end
69   
70   def connection_completed
71     call_callback :connected
72   end
73   
74   def receive_line(line)
75     STDERR.puts "<- #{line.gsub(/[^\x20-\x7F]/, ".")}" if @debug
76     line.chomp!("|")
77     line = unsanitize(line)
78     cmd = line.slice!(/^\S+/)
79     line.slice!(/^ /)
80     
81     if cmd =~ /^<.*>$/ then
82       # this is a specially-formatted command
83       # but lets handle it like other commands
84       nick = cmd[1...-1]
85       if self.respond_to? "cmd_<>" then
86         self.send "cmd_<>", nick, line
87       else
88         call_callback :error, "Unknown command: <#{nick}> #{line}"
89       end
90     elsif cmd =~ /^\$\S+$/ then
91       # this is a proper command
92       cmd.slice!(0)
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
98       else
99         call_callback :error, "Unknown command: $#{cmd} #{line}"
100       end
101     else
102       call_callback :error, "Garbage data: #{line}"
103     end
104   end
105   
106   def post_init
107     @callbacks = Hash.new { |h,k| h[k] = [] }
108     @debug = false
109     set_delimiter "|"
110   end
111   
112   def unbind
113     call_callback :unbind
114   end
117 class DCClientProtocol < DCProtocol
118   # known keys for args are:
119   #   password - server password
120   #   debug - should this socket print debug data?
121   #   description
122   #   speed
123   #   speed_class
124   #   email
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|
128       c.instance_eval do
129         @nickname = nickname
130         @config = args
131         @debug = args[:debug]
132         @config[:description] ||= ""
133         @config[:speed] ||= "Bot"
134         @config[:speed_class] ||= 1
135         @config[:email] ||= ""
136         @config[:slots] ||= 0
137       end
138       yield c if block_given?
139     end
140   end
141   
142   def sendPublicMessage(message)
143     data = sanitize("<#{@nickname}> #{message}") + "|"
144     send_data data
145   end
146   
147   def sendPrivateMessage(recipient, message)
148     send_command "To:", recipient, "From:", @nickname, "$<#{@nickname}>", message
149   end
150   
151   def close
152     @quit = true
153     close_connection
154   end
155   
156   attr_reader :nickname, :hubname, :quit, :users
157   
158   # protocol implementation
159   
160   def cmd_Lock(line)
161     lock = line.split(" ")[0]
162     key = lockToKey(lock)
163     
164     send_command("Key", "#{key}")
165     send_command("ValidateNick", "#{@nickname}")
166   end
167   
168   def cmd_ValidateDenide(line)
169     call_callback :error, "Nickname in use or invalid"
170     self.close
171   end
172   
173   def cmd_GetPass(line)
174     if @config.has_key? :password
175       send_command "MyPass", @config[:password]
176     else
177       call_callback :error, "Password required but not given"
178       self.close
179     end
180   end
181   
182   def cmd_BadPass(line)
183     call_callback :error, "Bad password given"
184     self.close
185   end
186   
187   def cmd_LogedIn(line)
188     call_callback :logged_in
189   end
190   
191   def cmd_HubName(line)
192     @hubname = line
193     call_callback :hubname, @hubname
194   end
195   
196   def cmd_Hello(line)
197     nick = line
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$"
204     else
205       user = DCUser.new(self, nick)
206       @users[nick] = user
207       call_callback :user_connected, user
208     end
209   end
210   
211   def cmd_NickList(line)
212     nicks = line.split("$$")
213     @users = {}
214     nicks.each do |nick|
215       @users[nick] = DCUser.new(self, nick)
216     end
217     call_callback :nicklist, @users.values
218   end
219   
220   def cmd_OpList(line)
221     nicks = line.split("$$")
222     nicks.each do |nick|
223       if @users.has_key? nick then
224         @users[nick].op = true
225       end
226     end
227     call_callback :oplist, @users.values.select { |user| user.op }
228   end
229   
230   def cmd_MyINFO(line)
231     if line =~ /^\$ALL (\S+) ([^$]*)\$ +\$([^$]*)\$([^$]*)\$([^$]*)\$$/ then
232       nick = $1
233       interest = $2
234       speed = $3
235       email = $4
236       sharesize = $5
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)
241       else
242         speed_class = 0
243       end
244       user = @users[nick]
245       if user and user.nickname != @nickname then
246         user.setInfo(interest, tag, speed, speed_class, email, sharesize)
247         call_callback :info, user
248       end
249     end
250   end
251   
252   def cmd_ConnectToMe(line)
253     # another peer is trying to connect to me
254     if line =~ /^(\S+) (\S+):(\d+)$/ then
255       mynick = $1
256       ip = $2
257       port = $3.to_i
258       if mynick == @nickname then
259         connect_to_peer(ip, port)
260       else
261         call_callback :error, "Strange ConnectToMe request: #{line}"
262       end
263     end
264   end
265   
266   def cmd_RevConnectToMe(line)
267     if line =~ /^(\S+) (\S+)$/ then
268       # for the moment we're just going to be a passive client
269       nick = $1
270       mynick = $2
271       if mynick == @nickname then
272         user = @users[nick]
273         if user then
274           if not user.passive then
275             # the passive switch keeps us from bouncing RevConnectToMe's back and forth
276             user.passive = true
277             call_callback :reverse_connection, user
278             send_command "RevConnectToMe", mynick, nick
279           else
280             call_callback :reverse_connection_ignored, user
281           end
282         else
283           call_callback :error, "RevConnectToMe request from unknown user: #{nick}"
284         end
285       else
286         call_callback :error, "Strange RevConnectToMe request: #{line}"
287       end
288     end
289   end
290   
291   define_method("cmd_<>") do |nick, line|
292     call_callback :message, nick, line, false
293   end
294   
295   def cmd_To(line)
296     if line =~ /^(\S+) From: (\S+) \$<(\S+)> (.*)$/ then
297       mynick = $1
298       nick = $2
299       displaynick = $3 # ignored for now
300       message = $4
301       call_callback :message, nick, message, true, (displaynick == "*")
302     else
303       call_callback :error, "Garbage $To: #{line}"
304     end
305   end
306   
307   def cmd_Quit(line)
308     nick = line
309     user = @users[nick]
310     @users.delete nick
311     if user.nil? then
312       call_callback :error, "Unknown user Quit: #{nick}"
313     else
314       call_callback :user_quit, user
315     end
316   end
317   
318   def cmd_Search(line)
319     # for the moment, completely ignore this
320   end
321   
322   # utility methods
323   
324   def connect_to_peer(ip, port)
325     @peers << EventMachine::connect(ip, port, DCPeerProtocol) do |c|
326       parent = self
327       debug = @debug || @config[:peer_debug]
328       c.instance_eval do
329         @parent = parent
330         @host = ip
331         @port = port
332         @debug = debug
333       end
334       c.call_callback :initialized
335     end
336   end
337   
338   # event handling methods
339   
340   def post_init
341     super
342     @quit = false
343     @peers = []
344     @users = {}
345     self.registerCallback :peer_unbind do |socket, peer|
346       @peers.delete socket
347     end
348   end
349   
350   def unbind
351     super
352     @peers.each do |peer|
353       peer.close_connection
354     end
355     @peers = []
356   end
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">
367 </Directory>
368 </FileListing>
370   XML_FILE_LISTING_BZ2 = BZ2.bzip2(XML_FILE_LISTING)
371   
372   SUPPORTED_EXTENSIONS = ["ADCGet", "XmlBZList", "TTHF"]
373   
374   attr_reader :remote_nick, :host, :port, :state
375   
376   def post_init
377     super
378     @state = :init
379     @supports = nil
380     self.registerCallback :error do |peer, message|
381       peer.send_command "Error", message unless peer.state == :data
382       peer.close_connection_after_writing
383     end
384   end
385   
386   # callbacks triggered from the peer always begin with peer_
387   def call_callback(name, *args)
388     super
389     @parent.call_callback "peer_#{name.to_s}".to_sym, self, *args
390   end
391   
392   def connection_completed
393     super
394     send_command "MyNick", @parent.nickname
395     send_command "Lock", "EXTENDEDPROTOCOLABCABCABCABCABCABC", "Pk=#{CLIENT_NAME}#{CLIENT_VERSION}ABCABC"
396   end
397   
398   def get_file_io(filename)
399     if filename == "files.xml.bz2" then
400       StringIO.new(XML_FILE_LISTING_BZ2)
401     else
402       nil
403     end
404   end
405   
406   # Protocol hooks
407   
408   def cmd_MyNick(line)
409     @remote_nick = line
410   end
411   
412   def cmd_Lock(line)
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
418   end
419   
420   def cmd_Key(line)
421     # who cares if they got the key right? just ignore it
422   end
423   
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}"
429       # close_connection
430     end
431     @state = :normal
432   end
433   
434   def cmd_Supports(line)
435     @supports = line.split(" ")
436   end
437   
438   def cmd_Get(line)
439     if line =~ /^([^$]+)\$(\d+)$/ then
440       @state = :data
441       filename = $1
442       offset = $2.to_i - 1 # it's 1-based
443       call_callback :get, filename
444       @fileio = get_file_io(filename)
445       if @fileio then
446         @fileio.pos = offset
447         send_command "FileLength", @fileio.size - @fileio.pos
448       else
449         send_command "Error", "File Not Available"
450         close_connection_after_writing
451       end
452     else
453       call_callback :error, "Unknown $Get format"
454     end
455   end
456   
457   def cmd_Send(line)
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
462     else
463       data = @fileio.read(40906)
464       send_data data
465       if @fileio.eof? then
466         @state = :normal
467       end
468     end
469   end
470   
471   def cmd_ADCGET(line)
472     if line =~ /^(\w+) (.+) (\d+) (-?\d+)(?: (.+))?$/ then
473       type = $1
474       identifier = $2
475       startpos = $3.to_i
476       length = $4.to_i
477       flags = ($5 || "").split(" ")
478       if flags.empty? then
479         if type == "file" then
480           call_callback :get, identifier
481           fileio = get_file_io(identifier)
482           if fileio then
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)
487           else
488             send_command "Error", "File Not Available"
489           end
490         else
491           send_command "Error", "Unknown $ADCGET type: #{type}"
492         end
493       else
494         send_command "Error", "Unknown $ADCGET flags: #{flags.join(" ")}"
495       end
496     else
497       send_command "Error", "Unknown $ADCGET format"
498     end
499   end
500   
501   def cmd_UGetBlock(line)
502     if line =~ /^(\d+) (-?\d+) (.+)$/ then
503       startpos = $1.to_i
504       length = $2.to_i
505       filename = $3
506       call_callback :get, filename
507       fileio = get_file_io(filename)
508       if fileio then
509         fileio.pos = startpos
510         length = fileio.size - fileio.pos if length == -1
511         send_command "Sending", length
512         send_data fileio.read(length)
513       else
514         send_command "Failed", "File Not Available"
515       end
516     else
517       send_command "Failed", "Unknown $UGetBlock format"
518     end
519   end
520   
521   def cmd_Canceled(line)
522     close_connection
523   end
524   
525   def cmd_Error(line)
526     call_callback :error, "Peer Error: #{line}"
527   end