4 # :title: Web service for bot
6 # Author:: Matthias Hecker (apoc@geekosphere.org)
8 # HTTP(S)/json based web service for remote controlling the bot,
9 # similar to remote but much more portable.
11 # For more info/documentation:
12 # https://github.com/4poc/rbot/wiki/Web-Service
16 require 'webrick/https'
23 # A WebMessage is a web request and response object combined with helper methods.
26 attr_reader :bot, :method, :bot, :req, :res, :post, :client, :path, :source
27 def initialize(bot, req, res)
32 @method = req.request_method
33 if req.body and not req.body.empty?
34 @post = CGI::parse(req.body)
36 @client = req.peeraddr[3]
38 # login a botuser with http authentication
39 WEBrick::HTTPAuth.basic_auth(req, res, 'RBotAuth') { |username, password|
41 botuser = @bot.auth.get_botuser(Auth::BotUser.sanitize_username(username))
42 if botuser and botuser.password == password
48 true # no need to request auth at this point
53 debug '@path = ' + @path.inspect
56 # The target of a RemoteMessage
61 # Remote messages are always 'private'
66 # Sends a plaintext response
67 def send_plaintext(body, status=200)
69 @res['Content-Type'] = 'text/plain'
74 # works similar to a message mapper but for url paths
77 attr_reader :botmodule, :pattern, :options
78 def initialize(botmodule, pattern, options={})
79 @botmodule = botmodule
82 set_auth_path(@options)
86 message_route = m.path[1..-1].split('/')
87 template_route = @pattern[1..-1].split('/')
90 debug 'web mapping path %s <-> %s' % [message_route.inspect, template_route.inspect]
92 message_route.each do |part|
93 tmpl = template_route.shift
94 return false if not tmpl
97 # push part as url path parameter
98 params[tmpl[1..-1].to_sym] = part
106 debug 'web mapping params is %s' % [params.inspect]
111 def set_auth_path(hash)
112 if hash.has_key?(:full_auth_path)
113 warning "Web route #{@pattern.inspect} in #{@botmodule} sets :full_auth_path, please don't do this"
116 words = @pattern[1..-1].split('/').reject{ |x|
117 x == pre || x =~ /^:/ || x =~ /\[|\]/
124 if hash.has_key?(:auth_path)
125 extra = hash[:auth_path]
126 if extra.sub!(/^:/, "")
130 if extra.sub!(/:$/, "")
132 post = [post,words[1]].compact.join("::")
135 pre = nil if extra.sub!(/^!/, "")
136 post = nil if extra.sub!(/!$/, "")
137 extra = nil if extra.empty?
141 hash[:full_auth_path] = [pre,extra,post].compact.join("::")
142 debug "Web route #{@pattern} in #{botmodule} will use authPath #{hash[:full_auth_path]}"
152 def map(botmodule, pattern, options={})
153 @templates << WebTemplate.new(botmodule.to_s, pattern, options)
154 debug 'template route: ' + @templates[-1].inspect
155 return @templates.length - 1
158 # The unmap method for the RemoteDispatcher nils the template at the given index,
159 # therefore effectively removing the mapping
161 def unmap(botmodule, index)
162 tmpl = @templates[index]
163 raise "Botmodule #{botmodule.name} tried to unmap #{tmpl.inspect} that was handled by #{tmpl.botmodule}" unless tmpl.botmodule == botmodule.name
164 debug "Unmapping #{tmpl.inspect}"
165 @templates[handle] = nil
166 @templates.clear unless @templates.compact.size > 0
169 # Handle a web service request, find matching mapping and dispatch.
171 # In case authentication fails, sends a 401 Not Authorized response.
175 m.send_plaintext('no routes!', 404)
176 return false if @templates.empty?
179 @templates.each do |tmpl|
180 # Skip this element if it was unmapped
182 botmodule = @bot.plugins[tmpl.botmodule]
183 params = tmpl.recognize(m)
185 action = tmpl.options[:action]
186 unless botmodule.respond_to?(action)
187 failures << NoActionFailure.new(tmpl, m)
191 unless not tmpl.options.has_key? :method or tmpl.options[:method] == m.method
192 debug 'request method missmatch'
195 auth = tmpl.options[:full_auth_path]
196 debug "checking auth for #{auth.inspect}"
197 # We check for private permission
198 if m.bot.auth.permit?(m.source || Auth::defaultbotuser, auth, '?')
199 debug "template match found and auth'd: #{action.inspect} #{params.inspect}"
200 response = botmodule.send(action, m, params)
201 if m.res.sent_size == 0
202 m.send_plaintext(response.to_json)
206 debug "auth failed for #{auth}"
207 # if it's just an auth failure but otherwise the match is good,
208 # don't try any more handlers
209 m.send_plaintext('Authentication Required!', 401)
214 debug "#{r.template.inspect} => #{r}"
216 debug "no handler found"
217 m.send_plaintext('No Handler Found!', 404)
222 # Static web dispatcher instance used internally.
224 if defined? @web_dispatcher
227 @web_dispatcher = WebDispatcher.new(self)
232 # Mixin for plugins that want to provide a web interface of some sort.
234 # Plugins include the module and can then use web_map
235 # to register a url to handle.
238 # The remote_map acts just like the BotModule#map method, except that
239 # the map is registered to the @bot's remote_dispatcher. Also, the remote map handle
240 # is handled for the cleanup management
243 # stores the handles/indexes for cleanup:
244 @web_maps = Array.new unless defined? @web_maps
245 @web_maps << @bot.web_dispatcher.map(self, *args)
248 # Unregister the remote maps.
251 return unless defined? @web_maps
253 @bot.web_dispatcher.unmap(self, h)
258 # Redefine the default cleanup method.
269 class ::WebServiceUser < Irc::User
270 def initialize(str, botuser, opts={})
276 attr_accessor :response
279 class DispatchServlet < WEBrick::HTTPServlet::AbstractServlet
280 def initialize(server, bot)
285 def dispatch(req, res)
286 res['Server'] = 'RBot Web Service (http://ruby-rbot.org/)'
288 m = WebMessage.new(@bot, req, res)
289 @bot.web_dispatcher.handle m
292 res['Content-Type'] = 'text/plain'
293 res.body = "Error: %s\n" % [$!.to_s]
294 error 'web dispatch error: ' + $!.to_s
303 def do_POST(req, res)
308 class WebServiceModule < CoreBotModule
312 Config.register Config::BooleanValue.new('webservice.autostart',
314 :requires_rescan => true,
315 :desc => 'Whether the web service should be started automatically')
317 Config.register Config::IntegerValue.new('webservice.port',
319 :requires_rescan => true,
320 :desc => 'Port on which the web service will listen')
322 Config.register Config::StringValue.new('webservice.host',
323 :default => '127.0.0.1',
324 :requires_rescan => true,
325 :desc => 'Host the web service will bind on')
327 Config.register Config::BooleanValue.new('webservice.ssl',
329 :requires_rescan => true,
330 :desc => 'Whether the web server should use SSL (recommended!)')
332 Config.register Config::StringValue.new('webservice.ssl_key',
333 :default => '~/.rbot/wskey.pem',
334 :requires_rescan => true,
335 :desc => 'Private key file to use for SSL')
337 Config.register Config::StringValue.new('webservice.ssl_cert',
338 :default => '~/.rbot/wscert.pem',
339 :requires_rescan => true,
340 :desc => 'Certificate file to use for SSL')
342 Config.register Config::BooleanValue.new('webservice.allow_dispatch',
344 :desc => 'Dispatch normal bot commands, just as a user would through the web service, requires auth for certain commands just like a irc user.')
348 @port = @bot.config['webservice.port']
349 @host = @bot.config['webservice.host']
351 @bot.webservice = self
353 start_service if @bot.config['webservice.autostart']
355 error "couldn't start web service provider: #{e.inspect}"
360 raise "Remote service provider already running" if @server
361 opts = {:BindAddress => @host, :Port => @port}
362 if @bot.config['webservice.ssl']
363 opts.merge! :SSLEnable => true
364 cert = File.expand_path @bot.config['webservice.ssl_cert']
365 key = File.expand_path @bot.config['webservice.ssl_key']
366 if File.exists? cert and File.exists? key
367 debug 'using ssl certificate files'
369 :SSLCertificate => OpenSSL::X509::Certificate.new(File.read(cert)),
370 :SSLPrivateKey => OpenSSL::PKey::RSA.new(File.read(key))
373 debug 'using on-the-fly generated ssl certs'
374 opts.merge! :SSLCertName => [ %w[CN localhost] ]
375 # the problem with this is that it will always use the same
376 # serial number which makes this feature pretty much useless.
379 # Logging to file in ~/.rbot
380 logfile = File.open(@bot.path('webservice.log'), 'a+')
382 :Logger => WEBrick::Log.new(logfile),
383 :AccessLog => [[logfile, WEBrick::AccessLog::COMBINED_LOG_FORMAT]]
385 @server = WEBrick::HTTPServer.new(opts)
386 debug 'webservice started: ' + opts.inspect
387 @server.mount('/', DispatchServlet, @bot)
388 Thread.new { @server.start }
392 @server.shutdown if @server
401 def handle_start(m, params)
403 m.reply 'web service already running'
407 m.reply 'web service started'
409 m.reply 'unable to start web service, error: ' + $!.to_s
414 def handle_stop(m, params)
417 m.reply 'web service stopped'
419 m.reply 'web service not running'
423 def handle_ping(m, params)
424 m.send_plaintext("pong\n")
427 def handle_dispatch(m, params)
428 if not @bot.config['webservice.allow_dispatch']
429 m.send_plaintext('dispatch forbidden by configuration', 403)
433 command = m.post['command'][0]
435 botuser = Auth::defaultbotuser
437 botuser = m.source.botuser
439 netmask = '%s!%s@%s' % [botuser.username, botuser.username, m.client]
441 user = WebServiceUser.new(netmask, botuser)
442 message = Irc::PrivMessage.new(@bot, nil, user, @bot.myself, command)
444 res = @bot.plugins.irc_delegate('privmsg', message)
446 { :reply => user.response }
451 webservice = WebServiceModule.new
453 webservice.map 'webservice start',
454 :action => 'handle_start',
455 :auth_path => ':manage:'
457 webservice.map 'webservice stop',
458 :action => 'handle_stop',
459 :auth_path => ':manage:'
461 webservice.web_map '/ping',
462 :action => :handle_ping,
463 :auth_path => 'public'
465 # executes arbitary bot commands
466 webservice.web_map '/dispatch',
467 :action => :handle_dispatch,
469 :auth_path => 'public'
471 webservice.default_auth('*', false)
472 webservice.default_auth('public', true)