Add some better error handling in P2P connections
[dcbot.git] / dcprotocol.rb
blob418201154d71f2b08b3d04da5bb521523d8da925
1 require 'stringio'
2 require 'cgi' # for entity-escaping
3 require './he3'
5 class DCProtocol < EventMachine::Connection
6   include EventMachine::Protocols::LineText2
7   
8   def registerCallback(callback, &block)
9     @callbacks[callback] = block
10   end
11   
12   def lockToKey(lock)
13     key = String.new(lock)
14     1.upto(key.size - 1) do |i|
15       key[i] = lock[i] ^ lock[i-1]
16     end
17     key[0] = lock[0] ^ lock[-1] ^ lock[-2] ^ 5
18     
19     # nibble-swap
20     0.upto(key.size - 1) do |i|
21       key[i] = ((key[i]<<4) & 240) | ((key[i]>>4) & 15)
22     end
23     
24     0.upto(key.size - 1) do |i|
25       if [0,5,36,96,124,126].include?(key[i]) then
26         key[i,1] = ("/%%DCN%03d%%/" % key[i])
27       end
28     end
29     
30     key
31   end
32   
33   def sanitize(data)
34     data.gsub("|", "&#124;")
35   end
36   
37   def unsanitize(data)
38     CGI.unescapeHTML(data)
39   end
40   
41   def send_command(cmd, *args)
42     data = sanitize("$#{cmd}#{["", *args].join(" ")}") + "|"
43     STDERR.puts "-> #{data}" if $debug
44     send_data(data)
45   end
46   
47   def call_callback(callback, *args)
48     @callbacks[callback].call(self, *args) if @callbacks.has_key? callback
49   end
50   
51   def connection_completed
52     call_callback :connected
53   end
54   
55   def receive_line(line)
56     line.chomp!("|")
57     STDERR.puts "<- #{line}" if $debug
58     line = unsanitize(line)
59     cmd = line.slice!(/^\S+/)
60     line.slice!(/^ /)
61     
62     if cmd =~ /^<.*>$/ then
63       # this must be a public message
64       nick = cmd[1...-1]
65       call_callback :message, nick, line, false, false
66     elsif cmd =~ /^\$\S+$/ then
67       # this is a proper command
68       cmd.slice!(0)
69       # hardcode the $To: command since the colon is ugly
70       # this protocol is pretty messy
71       cmd = "To" if cmd == "To:"
72       if self.respond_to? "cmd_#{cmd}" then
73         self.send "cmd_#{cmd}", line
74       else
75         STDERR.puts "! Unknown command: $#{cmd} #{line}"
76       end
77     else
78       STDERR.puts "! Garbage data: #{line}"
79     end
80   end
81   
82   def post_init
83     @callbacks = {}
84     set_delimiter "|"
85   end
86   
87   def unbind
88     call_callback :unbind
89   end
90 end
92 class DCClientProtocol < DCProtocol
93   RUBYBOT_VERSION = 0.1
94   
95   # known keys for args are:
96   #   password
97   #   description
98   #   speed
99   #   speed_class
100   #   email
101   def self.connect(host, port, nickname, args = {})
102     EventMachine::connect(host, port, self) do |c|
103       c.instance_eval do
104         @nickname = nickname
105         @config = args
106         @config[:description] ||= ""
107         @config[:speed] ||= "Bot"
108         @config[:speed_class] ||= 1
109         @config[:email] = ""
110       end
111       yield c if block_given?
112     end
113   end
114   
115   def sendPublicMessage(message)
116     data = sanitize("<#{@nickname}> #{message}") + "|"
117     STDERR.puts "-> #{data}" if $debug
118     send_data data
119   end
120   
121   def sendPrivateMessage(recipient, message)
122     send_command "To:", recipient, "From:", @nickname, "$<#{@nickname}>", message
123   end
124   
125   def close
126     @quit = true
127     close_connection
128   end
129   
130   attr_reader :nickname, :hubname, :quit
131   
132   # protocol implementation
133   
134   def cmd_Lock(line)
135     lock = line.split(" ")[0]
136     key = lockToKey(lock)
137     
138     send_command("Key", "#{key}")
139     send_command("ValidateNick", "#{@nickname}")
140   end
141   
142   def cmd_ValidateDenide(line)
143     STDERR.puts "Nickname in use or invalid"
144     self.close
145   end
146   
147   def cmd_GetPass(line)
148     if @config.has_key? :password
149       send_command "MyPass", @config[:password]
150     else
151       STDERR.puts "Password required but not given"
152       self.close
153     end
154   end
155   
156   def cmd_BadPass(line)
157     STDERR.puts "Bad password given"
158     self.close
159   end
160   
161   def cmd_LogedIn(line)
162     call_callback :logged_in
163   end
164   
165   def cmd_HubName(line)
166     @hubname = line
167     call_callback :hubname, @hubname
168   end
169   
170   def cmd_Hello(line)
171     nick = line
172     if nick == @nickname then
173       # this is us, we should respond
174       send_command "Version", "1,0091"
175       send_command "GetNickList"
176       send_command "MyINFO", "$ALL #{@nickname} #{@config[:description]}<RubyBot V:#{RUBYBOT_VERSION}>$", \
177                              "$#{@config[:speed]}#{@config[:speed_class].chr}$#{@config[:email]}$0$"
178     else
179       call_callback :user_connected, nick
180     end
181   end
182   
183   def cmd_NickList(line)
184     call_callback :nicklist, line.split("$$")
185   end
186   
187   def cmd_OpList(line)
188     call_callback :oplist, line.split("$$")
189   end
190   
191   def cmd_MyINFO(line)
192     if line =~ /^\$ALL (\S+) ([^$]*)\$ +\$([^$]*)\$([^$]*)\$([^$]*)\$$/ then
193       nick = $1
194       interest = $2
195       speed = $3
196       email = $4
197       sharesize = $5
198       if speed.length > 0 and speed[-1] < 0x20 then
199         # assume last byte a control character means it's the speed class
200         speed_class = speed.slice!(-1)
201       else
202         speed_class = 0
203       end
204       call_callback :info, nick, interest, speed, speed_class, email, sharesize unless nick == @nickname
205     end
206   end
207   
208   def cmd_ConnectToMe(line)
209     # another peer is trying to connect to me
210     if line =~ /^(\S+) (\S+):(\d+)$/ then
211       mynick = $1
212       ip = $2
213       port = $3.to_i
214       if mynick == @nickname then
215         connect_to_peer(ip, port)
216       else
217         STDERR.puts "! Strange ConnectToMe request: #{line}"
218       end
219     end
220   end
221   
222   def cmd_RevConnectToMe(line)
223     if line =~ /^(\S+) (\S+)$/ then
224       # for the moment we're just going to be a passive client
225       nick = $1
226       mynick = $2
227       if mynick == @nickname then
228         STDERR.puts "* Bouncing RevConnectToMe back to user: #{nick}"
229         send_command "RevConnectToMe", mynick, nick
230       else
231         STDERR.puts "! Strange RevConnectToMe request: #{line}"
232       end
233     end
234   end
235   
236   def cmd_To(line)
237     if line =~ /^(\S+) From: (\S+) \$<(\S+)> (.*)$/ then
238       mynick = $1
239       nick = $2
240       displaynick = $3 # ignored for now
241       message = $4
242       call_callback :message, nick, message, true, (displaynick == "*")
243     else
244       STDERR.puts "Garbage To: #{line}"
245     end
246   end
247   
248   def cmd_Quit(line)
249     nick = line
250     call_callback :user_quit, nick
251   end
252   
253   def cmd_Search(line)
254     # for the moment, completely ignore this
255   end
256   
257   # utility methods
258   
259   def connect_to_peer(ip, port)
260     STDERR.puts "* Connecting to peer: #{ip}:#{port}"
261     @peers << EventMachine::connect(ip, port, DCPeerProtocol) do |c|
262       c.parent = self
263       c.registerCallback :unbind do |socket|
264         STDERR.puts "* Connection to peer closed"
265         @peers.delete socket
266       end
267     end
268   end
269   
270   # event handling methods
271   
272   def post_init
273     super
274     @quit = false
275     @peers = []
276   end
279 # major assumption in this implementation is that we are simply uploading
280 # if we want to be able to initiate downloads, this needs some tweaking
281 # we're also a passive client, so we're always connecting to the other client
282 class DCPeerProtocol < DCProtocol
283   XML_FILE_LISTING = <<EOF
284 <?xml version="1.0" encoding="utf-8"?>
285 <FileListing Version="1" Generator="RubyBot">
286 <Directory Name="Send a /pm with !help for help">
287 </Directory>
288 </FileListing>
290   DCLST_FILE_LISTING = <<EOF
291 Send a /pm with !help for help
293   DCLST_FILE_LISTING_HE3 = he3_encode(DCLST_FILE_LISTING)
294   
295   attr_writer :parent
296   
297   def post_init
298     super
299   end
300   
301   def connection_completed
302     super
303     send_command "MyNick", @parent.nickname
304     send_command "Lock", "FOO", "Pk=BAR"
305   end
306   
307   def cmd_MyNick(line)
308     @remote_nick = line
309   end
310   
311   def cmd_Lock(line)
312     lock = line.split(" ")[0]
313     key = lockToKey(lock)
314     send_command "Direction", "Upload", rand(0x7FFF)
315     send_command "Key", key
316   end
317   
318   def cmd_Key(line)
319     # who cares if they got the key right? just ignore it
320   end
321   
322   def cmd_Direction(line)
323     direction, rnd = line.split(" ")
324     if direction != "Download" then
325       # why did they send me a ConnectToMe if they don't want to download?
326       STDERR.puts "! Unexpected peer direction: #{direction}"
327       # close_connection
328     end
329   end
330   
331   def cmd_GetListLen(line)
332     send_command "ListLen", DCLST_FILE_LISTING_HE3.length
333   end
334   
335   def cmd_Get(line)
336     if line =~ /^([^$]+)\$(\d+)$/ then
337       @filename = $1
338       @offset = $2.to_i - 1 # it's 1-based
339       @fileio = StringIO.new(DCLST_FILE_LISTING_HE3)
340       @fileio.pos = @offset
341       if @filename == "MyList.DcLst" then
342         send_command "FileLength", @fileio.size - @fileio.pos
343       else
344         send_command "Error", "File Not Available"
345       end
346     else
347       send_command "Error", "Unknown $Get format"
348       close_connection_after_writing
349     end
350   end
351   
352   def cmd_Send(line)
353     if @filename.nil? then
354       # we haven't been asked for the file yet
355       send_command "Error", "Unexpected $Send"
356       close_connection_after_writing
357     else
358       data = @fileio.read(40906)
359       STDERR.puts "-> #{data}" if $debug
360       send_data data
361     end
362   end
363   
364   def cmd_Canceled(line)
365     close_connection
366   end
367   
368   def cmd_Error(line)
369     STDERR.puts "! Peer Error: #{line}"
370   end