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