Wed Jul 18 09:53:27 EST 2007 Matt Palmer * A trac_urls plugin, to convert Trac object refs to complete URLs diff -rN -u old-trac_urls/data/rbot/plugins/trac_urls.rb new-trac_urls/data/rbot/plugins/trac_urls.rb --- old-trac_urls/data/rbot/plugins/trac_urls.rb 1970-01-01 10:00:00.000000000 +1000 +++ new-trac_urls/data/rbot/plugins/trac_urls.rb 2008-10-20 23:31:05.191857644 +1100 @@ -0,0 +1,199 @@ +require 'net/http' +require 'uri' +require 'rubygems' +require 'hpricot' + +class InvalidTracUrl < Exception +end + +class TracUrlsPlugin < Plugin + BotConfig.register BotConfigArrayValue.new('trac_urls.channelmap', + :default => [], :requires_restart => false, + :desc => "A map of channels to the base Trac URL that should be used " + + "in that channel. Format for each entry in the list is " + + "#channel:http://trac.site/to/use. Don't put a trailing " + + "slash on the base URL, please.") + + def help(plugin, topic = "") + case topic + when '': + "trac_urls: Convert common Trac WikiSyntax references into URLs. " + + "I will watch the channel for likely references (see subtopic " + + "'general'), and also respond to specific requests (see " + + "subtopic 'queries')." + + when 'general': + "I can convert common references into URLs when I " + + "see them mentioned in conversation. Currently supports " + + "[NNN], rNNN => revision URL; #NN => bug URL; " + + "wiki:CamelCase => wiki URL. URLs are verified before they're " + + "sent to the channel, to limit noise. I will not respond to " + + "general references if you are talking to me directly! See " + + "subtopic 'queries' for help with direct querying." + + when 'queries': + "You can ask me to lookup some info about a Trac bug, " + + "changeset, or wiki page. I'll give you the URL and the title " + + "of the bug or page, " + + "or the commit message of a changeset. You must address me " + + "directly to get a response. Example: '#{@bot.nick}: tracinfo " + + "#93' will produce a URL and the ticket's title." + end + end + + def listen(m) + # We're a conversation watcher, dammit, don't talk to me! + return if m.address? + + # We don't handle private messages just yet, and we only handle regular + # chat messages + return unless m.kind_of?(PrivMessage) && m.public? + + base = base_url(m.target) + return if base.nil? + + return unless m.message =~ /(\[\d+\]|r\d+|\#\d+|wiki:\S+)/ + + # So what's our ref? Strip out the wiki: if it's there, because + # that's just a special cheat to minimise the amount of crap we have + # to deal with in general conversation. + ref = $1.sub(/^wiki:/, '') + + begin + url = validate_url(ref_into_url(base, ref)[0]) + rescue InvalidTracUrl + # Hmm, whatever we were looking for didn't quite get there. Oh well. + # We'll just keep quiet and hope nobody notices we're here... + return + end + + # Try to address the message to the right person + if m.message =~ /^(\S+)[:,]/ + addressee = "#{$1}: " + else + addressee = "#{m.sourcenick}: " + end + + # So we have a valid URL, and addressee, and now we just have to... speak! + m.reply "#{addressee}#{ref} is #{url}" + end + + def tracinfo(m, params) + debug("Handling tracinfo request; params is #{params.inspect}") + return unless m.kind_of?(PrivMessage) && m.public? + + base = base_url(m.target) + return if base.nil? # If we're not in a channel with a mapped base URL, + # we don't want to respond at all. + + begin + url, reftype = ref_into_url(base, params[:ref]) + debug("Reftype is #{reftype.inspect}") + css_query = case reftype + when :wiki: + nil + when :changeset: + '#searchable p' + when :ticket: + 'h2.summary' + else + warning "Unknown reftype: #{reftype}"; nil + end + + content = unless css_query.nil? + page_element_contents(url, css_query) + end + + + rescue InvalidTracUrl + m.reply "I'm afraid I don't understand #{params[:ref]} or can't find a valid page pointing to it. Sorry." + return + rescue Exception => e + error("Error (#{e.class}) while fetching URL #{url}: #{e.message}") + return + end + + m.reply "#{m.sourcenick}: #{params[:ref]} is #{url}" + (css_query ? ", \"#{content}\"" : '') + end + + private + # Parse the Trac reference given in +ref+, and try to construct a URL + # from +base+ to the resource. Returns an array containing the URL + # and a type symbol (one of :changeset, :ticket, or :wiki), as a pair (eg + # ['http://blah/ticket/1', :ticket]) + # + def ref_into_url(base, ref) + case ref + when /\[(\d+)\]/: + [rev_url(base, $1), :changeset] + + when /r(\d+)/: + [rev_url(base, $1), :changeset] + + when /\#(\d+)/: + [bug_url(base, $1), :ticket] + + else + [wiki_url(base, $1), :wiki] + end + end + + # Return the base URL for the channel (passed in as +target+), or +nil+ + # if the channel isn't in the channelmap. + # + def base_url(target) + e = @bot.config['trac_urls.channelmap'].find {|c| c =~ /^#{target}:/ } + e.gsub(/^#{target}:/, '') unless e.nil? + end + + def rev_url(base_url, num) + base_url + '/changeset/' + num + end + + def bug_url(base_url, num) + base_url + '/ticket/' + num + end + + def wiki_url(base_url, page) + base_url + '/wiki/' + page + end + + # Attempt to download the given URL, and raise InvalidTracUrl if the + # URL doesn't respond with a 200 OK message. Returns the URL if all + # is OK, so you can use it as a decorator. + # + def validate_url(url) + parts = URI.parse(url) + Net::HTTP.start(parts.host) do |conn| + r = conn.head(parts.path) + raise InvalidTracUrl.new("#{url} returned #{r.code} #{r.message}") unless r.code == '200' + end + + url + end + + # Return the contents of the first element that matches +css_query+ in + # the given +url+, or else raise InvalidTracUrl if the page doesn't + # respond with 200 OK. + # + def page_element_contents(url, css_query) + parts = URI.parse(url) + r = nil + Net::HTTP.start(parts.host) do |conn| + r = conn.get(parts.path) + end + + debug("Response object is #{r.inspect} for #{url}") + raise InvalidTracUrl.new("#{url} returned #{r.code} #{r.message}") unless r.code == '200' + elem = Hpricot.parse(r.body).search(css_query).first + unless elem + warning("Didn't find '#{css_query}' in response #{r.body}") + return + end + debug("Found '#{elem.inner_text}' with '#{css_query}'") + elem.inner_text.gsub("\n", ' ').gsub(/\s+/, ' ').strip + end +end + +plugin = TracUrlsPlugin.new +plugin.map 'tracinfo :ref' diff -rN -u old-trac_urls/test/test_trac_urls.rb new-trac_urls/test/test_trac_urls.rb --- old-trac_urls/test/test_trac_urls.rb 1970-01-01 10:00:00.000000000 +1000 +++ new-trac_urls/test/test_trac_urls.rb 2008-10-20 23:31:05.191857644 +1100 @@ -0,0 +1,70 @@ +require 'test_helper' + +class TestTracUrlsPlugin < Test::Unit::TestCase + def test_config + load_plugin(File.dirname(__FILE__) + '/../data/rbot/plugins/trac_urls.rb') + load_plugin(File.dirname(__FILE__) + '/../lib/rbot/core/config.rb') + + assert_equal 1, @bot.pluginmanager.plugins.length + assert_equal 1, @bot.pluginmanager.core_modules.length + + # I'm embarrassed to have to do this... + # FIXME (mjp): Make the config system a regular plugin + @bot.message :target => @bot.nick, + :sourcenick => 'everyman', + :content => 'config list' + + assert_equal [], $rbotlog['error'] + assert_equal [], $rbotlog['warning'] + assert_equal [], $rbotlog['info'] + + assert_equal 1, @bot.replies.length + # Make sure that the trac_urls plugin is in there somewhere + assert_match /trac_urls/, @bot.replies[0][:content] + + assert_equal [], @bot.config['trac_urls.channelmap'] + @bot.config['trac_urls.channelmap'] << '#channel:http://example.com/trac' + + @bot.message :target => @bot.nick, + :sourcenick => 'everyman', + :content => 'config get trac_urls.channelmap' + + assert_equal 2, @bot.replies.length + # Make sure that the URL is in there somewhere + assert_match /http:\/\/example.com/, @bot.replies[1][:content] + end + + def test_tracinfo + load_plugin(File.dirname(__FILE__) + '/../data/rbot/plugins/trac_urls.rb') + + # Verify that the plugin loaded + class << @bot; attr_reader :plugins; end + assert_equal 1, @bot.pluginmanager.plugins.length, "Plugin didn't load" + + assert_equal [], @bot.config['trac_urls.channelmap'] + @bot.config['trac_urls.channelmap'] << '#channel:http://example.com/trac' + + needs_mocha do + # The Mock From Hell + response = Struct.new(:body, :code, :message).new + response.body = '

My First Ticket

' + response.code = '200' + response.message = 'OK' + + conn = mock + conn.expects(:get).with('/trac/ticket/1').returns(response) + Net::HTTP.expects(:start).with('example.com').yields(conn) + + @bot.message :target => '#channel', + :sourcenick => "auser", + :content => "#{@bot.nick}: tracinfo #1" + assert_equal [], $rbotlog['error'] + assert_equal [], $rbotlog['warning'] + assert_equal [], $rbotlog['info'] + + assert_equal 1, @bot.replies.length, "We didn't get a reply" + assert_equal "auser: #1 is http://example.com/trac/ticket/1, \"My First Ticket\"", + @bot.replies[0][:content], "Got the wrong reply" + end + end +end