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'
25 # A WebMessage is a web request and response object combined with helper methods.
31 # HTTP method (POST, GET, etc.)
34 # Request object, a instance of WEBrick::HTTPRequest ({http://www.ruby-doc.org/stdlib-2.0/libdoc/webrick/rdoc/WEBrick/HTTPRequest.html docs})
37 # Response object, a instance of WEBrick::HTTPResponse ({http://www.ruby-doc.org/stdlib-2.0/libdoc/webrick/rdoc/WEBrick/HTTPResponse.html docs})
40 # Parsed post request parameters.
43 # Parsed url parameters.
52 # The bot user issuing the command.
55 def initialize(bot, req, res)
60 @method = req.request_method
62 if req.body and not req.body.empty?
63 @post = parse_query(req.body)
66 if req.query_string and not req.query_string.empty?
67 @args = parse_query(req.query_string)
69 @client = req.peeraddr[3]
71 # login a botuser with http authentication
72 WEBrick::HTTPAuth.basic_auth(req, res, 'RBotAuth') { |username, password|
74 botuser = @bot.auth.get_botuser(Auth::BotUser.sanitize_username(username))
75 if botuser and botuser.password == password
82 true # no need to request auth at this point
88 @load_path = [File.join(Config::datadir, 'web')]
89 @load_path += @bot.plugins.core_module_dirs
90 @load_path += @bot.plugins.plugin_dirs
93 def parse_query(query)
94 params = CGI::parse(query)
95 params.each_pair do |key, val|
96 params[key] = val.last
101 # The target of a RemoteMessage
106 # Remote messages are always 'private'
111 # Sends a response with the specified body, status and content type.
112 def send_response(body, status, type)
114 @res['Content-Type'] = type
118 # Sends a plaintext response
119 def send_plaintext(body, status=200)
120 send_response(body, status, 'text/plain')
123 # Sends a json response
124 def send_json(body, status=200)
125 send_response(body, status, 'application/json')
128 # Sends a html response
129 def send_html(body, status=200)
130 send_response(body, status, 'text/html')
133 # Expands a relative filename to absolute using a list of load paths.
134 def get_load_path(filename)
135 @load_path.each do |path|
136 file = File.join(path, filename)
137 return file if File.exists?(file)
141 # Renders a erb template and responds it
142 def render(template, args={})
143 file = get_load_path template
145 raise 'template not found: ' + template
148 tmpl = ERB.new(IO.read(file))
149 ns = OpenStruct.new(args)
150 body = tmpl.result(ns.instance_eval { binding })
155 # works similar to a message mapper but for url paths
158 attr_reader :botmodule, :pattern, :options
159 def initialize(botmodule, pattern, options={})
160 @botmodule = botmodule
163 set_auth_path(@options)
167 message_route = m.path[1..-1].split('/')
168 template_route = @pattern[1..-1].split('/')
171 debug 'web mapping path %s <-> %s' % [message_route.inspect, template_route.inspect]
173 message_route.each do |part|
174 tmpl = template_route.shift
175 return false if not tmpl
178 # push part as url path parameter
179 params[tmpl[1..-1].to_sym] = part
187 debug 'web mapping params is %s' % [params.inspect]
192 def set_auth_path(hash)
193 if hash.has_key?(:full_auth_path)
194 warning "Web route #{@pattern.inspect} in #{@botmodule} sets :full_auth_path, please don't do this"
197 words = @pattern[1..-1].split('/').reject{ |x|
198 x == pre || x =~ /^:/ || x =~ /\[|\]/
205 if hash.has_key?(:auth_path)
206 extra = hash[:auth_path]
207 if extra.sub!(/^:/, "")
211 if extra.sub!(/:$/, "")
213 post = [post,words[1]].compact.join("::")
216 pre = nil if extra.sub!(/^!/, "")
217 post = nil if extra.sub!(/!$/, "")
218 extra = nil if extra.empty?
222 hash[:full_auth_path] = [pre,extra,post].compact.join("::")
223 debug "Web route #{@pattern} in #{botmodule} will use authPath #{hash[:full_auth_path]}"
233 def map(botmodule, pattern, options={})
234 @templates << WebTemplate.new(botmodule.to_s, pattern, options)
235 debug 'template route: ' + @templates[-1].inspect
236 return @templates.length - 1
239 # The unmap method for the RemoteDispatcher nils the template at the given index,
240 # therefore effectively removing the mapping
242 def unmap(botmodule, index)
243 tmpl = @templates[index]
244 raise "Botmodule #{botmodule.name} tried to unmap #{tmpl.inspect} that was handled by #{tmpl.botmodule}" unless tmpl.botmodule == botmodule.name
245 debug "Unmapping #{tmpl.inspect}"
246 @templates[index] = nil
247 @templates.clear unless @templates.compact.size > 0
250 # Handle a web service request, find matching mapping and dispatch.
252 # In case authentication fails, sends a 401 Not Authorized response.
256 m.send_plaintext('no routes!', 404)
257 return false if @templates.empty?
260 @templates.each do |tmpl|
261 # Skip this element if it was unmapped
263 botmodule = @bot.plugins[tmpl.botmodule]
264 params = tmpl.recognize(m)
266 action = tmpl.options[:action]
267 unless botmodule.respond_to?(action)
268 failures << NoActionFailure.new(tmpl, m)
272 unless not tmpl.options.has_key? :method or tmpl.options[:method] == m.method
273 debug 'request method missmatch'
276 auth = tmpl.options[:full_auth_path]
277 debug "checking auth for #{auth.inspect}"
278 # We check for private permission
279 if m.bot.auth.permit?(m.source || Auth::defaultbotuser, auth, '?')
280 debug "template match found and auth'd: #{action.inspect} #{params.inspect}"
281 response = botmodule.send(action, m, params)
282 if m.res.sent_size == 0 and m.res.body.empty?
283 m.send_json(response.to_json)
287 debug "auth failed for #{auth}"
288 # if it's just an auth failure but otherwise the match is good,
289 # don't try any more handlers
290 m.send_plaintext('Authentication Required!', 401)
295 debug "#{r.template.inspect} => #{r}"
297 debug "no handler found"
298 m.send_plaintext('No Handler Found!', 404)
303 # Static web dispatcher instance used internally.
305 if defined? @web_dispatcher
308 @web_dispatcher = WebDispatcher.new(self)
313 # Mixin for plugins that want to provide a web interface of some sort.
315 # Plugins include the module and can then use web_map
316 # to register a url to handle.
319 # The remote_map acts just like the BotModule#map method, except that
320 # the map is registered to the @bot's remote_dispatcher. Also, the remote map handle
321 # is handled for the cleanup management
324 # stores the handles/indexes for cleanup:
325 @web_maps = Array.new unless defined? @web_maps
326 @web_maps << @bot.web_dispatcher.map(self, *args)
329 # Unregister the remote maps.
332 return unless defined? @web_maps
334 @bot.web_dispatcher.unmap(self, h)
339 # Redefine the default cleanup method.
347 # And just because I like consistency:
349 module WebCoreBotModule
360 class ::WebServiceUser < Irc::User
361 def initialize(str, botuser, opts={})
367 attr_accessor :response
370 class DispatchServlet < WEBrick::HTTPServlet::AbstractServlet
371 def initialize(server, bot)
376 def dispatch(req, res)
377 res['Server'] = 'RBot Web Service (http://ruby-rbot.org/)'
379 m = WebMessage.new(@bot, req, res)
380 @bot.web_dispatcher.handle m
381 rescue WEBrick::HTTPStatus::Unauthorized
383 res['Content-Type'] = 'text/plain'
384 res.body = 'Authentication Required!'
385 error 'authentication error (wrong password)'
388 res['Content-Type'] = 'text/plain'
389 res.body = "Error: %s\n" % [$!.to_s]
390 error 'web dispatch error: ' + $!.to_s
399 def do_POST(req, res)
404 class WebServiceModule < CoreBotModule
406 include WebCoreBotModule
408 Config.register Config::BooleanValue.new('webservice.autostart',
410 :requires_rescan => true,
411 :desc => 'Whether the web service should be started automatically')
413 Config.register Config::IntegerValue.new('webservice.port',
415 :requires_rescan => true,
416 :desc => 'Port on which the web service will listen')
418 Config.register Config::StringValue.new('webservice.host',
419 :default => '127.0.0.1',
420 :requires_rescan => true,
421 :desc => 'Host the web service will bind on')
423 Config.register Config::StringValue.new('webservice.url',
424 :default => 'http://127.0.0.1:7268',
425 :desc => 'The public URL of the web service.')
427 Config.register Config::BooleanValue.new('webservice.ssl',
429 :requires_rescan => true,
430 :desc => 'Whether the web server should use SSL (recommended!)')
432 Config.register Config::StringValue.new('webservice.ssl_key',
433 :default => '~/.rbot/wskey.pem',
434 :requires_rescan => true,
435 :desc => 'Private key file to use for SSL')
437 Config.register Config::StringValue.new('webservice.ssl_cert',
438 :default => '~/.rbot/wscert.pem',
439 :requires_rescan => true,
440 :desc => 'Certificate file to use for SSL')
442 Config.register Config::BooleanValue.new('webservice.allow_dispatch',
444 :desc => 'Dispatch normal bot commands, just as a user would through the web service, requires auth for certain commands just like a irc user.')
448 @port = @bot.config['webservice.port']
449 @host = @bot.config['webservice.host']
451 @bot.webservice = self
453 start_service if @bot.config['webservice.autostart']
455 error "couldn't start web service provider: #{e.inspect}"
460 raise "Remote service provider already running" if @server
461 opts = {:BindAddress => @host, :Port => @port}
462 if @bot.config['webservice.ssl']
463 opts.merge! :SSLEnable => true
464 cert = File.expand_path @bot.config['webservice.ssl_cert']
465 key = File.expand_path @bot.config['webservice.ssl_key']
466 if File.exists? cert and File.exists? key
467 debug 'using ssl certificate files'
469 :SSLCertificate => OpenSSL::X509::Certificate.new(File.read(cert)),
470 :SSLPrivateKey => OpenSSL::PKey::RSA.new(File.read(key))
473 debug 'using on-the-fly generated ssl certs'
474 opts.merge! :SSLCertName => [ %w[CN localhost] ]
475 # the problem with this is that it will always use the same
476 # serial number which makes this feature pretty much useless.
479 # Logging to file in ~/.rbot
480 logfile = File.open(@bot.path('webservice.log'), 'a+')
482 :Logger => WEBrick::Log.new(logfile),
483 :AccessLog => [[logfile, WEBrick::AccessLog::COMBINED_LOG_FORMAT]]
485 @server = WEBrick::HTTPServer.new(opts)
486 debug 'webservice started: ' + opts.inspect
487 @server.mount('/', DispatchServlet, @bot)
488 Thread.new { @server.start }
492 @server.shutdown if @server
501 def handle_start(m, params)
503 m.reply 'web service already running'
507 m.reply 'web service started'
509 m.reply 'unable to start web service, error: ' + $!.to_s
514 def handle_stop(m, params)
517 m.reply 'web service stopped'
519 m.reply 'web service not running'
523 def handle_ping(m, params)
524 m.send_plaintext("pong\n")
527 def handle_dispatch(m, params)
528 if not @bot.config['webservice.allow_dispatch']
529 m.send_plaintext('dispatch forbidden by configuration', 403)
533 command = m.post['command']
535 botuser = Auth::defaultbotuser
537 botuser = m.source.botuser
539 netmask = '%s!%s@%s' % [botuser.username, botuser.username, m.client]
541 debug 'dispatch command: ' + command
543 user = WebServiceUser.new(netmask, botuser)
544 message = Irc::PrivMessage.new(@bot, nil, user, @bot.myself, command)
546 res = @bot.plugins.irc_delegate('privmsg', message)
548 if m.req['Accept'] == 'application/json'
549 { :reply => user.response }
551 m.send_plaintext(user.response.join("\n") + "\n")
557 webservice = WebServiceModule.new
559 webservice.map 'webservice start',
560 :action => 'handle_start',
561 :auth_path => ':manage:'
563 webservice.map 'webservice stop',
564 :action => 'handle_stop',
565 :auth_path => ':manage:'
567 webservice.web_map '/ping',
568 :action => :handle_ping,
569 :auth_path => 'public'
571 # executes arbitary bot commands
572 webservice.web_map '/dispatch',
573 :action => :handle_dispatch,
575 :auth_path => 'public'
577 webservice.default_auth('*', false)
578 webservice.default_auth('public', true)