diff --git a/bot_project/bot_project.module b/bot_project/bot_project.module
index 9b8d1c4..dfe2ce6 100644
--- a/bot_project/bot_project.module
+++ b/bot_project/bot_project.module
@@ -110,154 +110,18 @@ function bot_project_urls_overview() {
 function bot_project_irc_msg_channel($data, $from_query = FALSE) {
   $to = $from_query ? $data->nick : $data->channel;
 
-  // ======================================================================== */
-  // if api.module issue lookups are enabled...                               */
-  // ======================================================================== */
-  if (module_exists('api') && preg_match('/^(\w*):?([\w\-]*)\?$/', $data->messageex[0], $matches)) {
-    $function_name = $matches[1]; $branch = $matches[2] ? $matches[2] : variable_get('bot_project_api_default_branch', NULL);
-    if (!$function_name) { return; } // some modules have bad docs that allow "?" to match incorrectly.
-
-    // there could be more than one result for a function lookup, so we'll load
-    // them all and return only the right branch (if that's a determining factor).
-    // we can't just force the branch into the SQL; too restrictive on 3+ branches.
-    $results = db_query("SELECT ad.branch_name, ad.summary, af.signature FROM {api_function} af INNER JOIN {api_documentation} ad ON
-      af.did = ad.did WHERE ad.object_type = 'function' AND ad.object_name = :function_name", array(':function_name' => $function_name));
-    $db_functions = array(); // a master list of all the matched functions.
-    foreach ($results as $result) { // look for the desired branch and...
-      $db_functions[] = $result; // store the results for use as a last resort.
-      if ($result->branch_name == $branch) { $function_result = $result; }
-    } // no matching branch? we'll just use whatever is first in the list of all matches.
-    $function_result = $function_result ? $function_result : array_shift($db_functions);
-    if (!$function_result->branch_name) { return; } // no branch name == no results.
-
-    // build up the actual message for spitting.
-    if (!$function_result->summary && $function_result->branch_name != 'php') { $function_result->summary = '[There is no documentation! This is a bug. File a patch!]'; }
-    if (preg_match("/\n/", $function_result->summary) && $function_result->branch_name != 'php') { $function_result->summary = '[Documented summary is not one line. This is a bug. File a patch!]'; }
-    $message = $function_result->branch_name != 'php' // if we've indexed the php functions, they're in branch 'php', so we'll spit out a slightly different format.
-      ? t('!function: !summary => !signature => !url', array('!function' => $function_name, '!signature' => $function_result->signature, '!summary' => $function_result->summary, '!url' => strtr(bot_project_branch_url($function_result->branch_name), array('!function' => $function_name))))
-      : t('!function: !url', array('!function' => $function_name, '!url' => 'http://php.net/' . $function_name));
-    bot_message($to, $message);
-  }
-
-  // ======================================================================== */
-  // if project.module issue lookups are enabled...                           */
-  // ======================================================================== */
-  if (variable_get('bot_project_project_enable', FALSE)) {
-    $urls_to_scrape = array(); // master collection.
-
-    // looking for URLs in conversation?
-    if (variable_get('bot_project_project_url_regexp', NULL)) {
-      if (preg_match_all('!(' . variable_get('bot_project_project_url_regexp', '') . ')!i', $data->message, $url_matches)) {
-        foreach ($url_matches[1] as $url) { $urls_to_scrape[] = $url; } // master list for scraping later.
-      }
-    }
-
-    // ok, maybe just node IDs then?
-    if (variable_get('bot_project_project_url', NULL)) {
-      if (preg_match('/^#?(\d+)$/', $data->message, $url_matches)) {
-        $nid = $url_matches[1]; // just for shorthand cos I'm lazy.
-        if ($nid > variable_get('bot_project_project_nid_min', 0) && $nid < variable_get('bot_project_project_nid_max', 99999)) {
-          $urls_to_scrape[] = url(variable_get('bot_project_project_url', NULL) . 'node/' . $nid, array('absolute' => TRUE));
+  // Lookup data from other project integrations.
+  foreach (module_implements('bot_project_info') as $module) {
+    $project_info = module_invoke($module, 'bot_project_info');
+    foreach ($project_info as $key => $info) {
+      $scraper = $info['scraper'];
+      if (variable_get("bot_project_{$key}_enable", FALSE) && function_exists($scraper)) {
+        $messages = call_user_func($scraper, $data);
+        foreach ($messages as $message) {
+          bot_message($to, $message);
         }
       }
     }
-
-    // retrieve each desired URL.
-    foreach ($urls_to_scrape as $url) {
-      if (variable_get('bot_project_too_lazy_to_recompile_for_ssl', 0)) { $url = str_replace('https', 'http', $url); }
-      // @todo I'm too lazy to recompile PHP with SSL support, and too Drupal-oriented to switch to cURL. I'm awesome.
-
-      $result = drupal_http_request($url);
-      if ($result->code != 200) { continue; }
-
-      // we'll always display a title, so grab that first for db storage.
-      preg_match('/<title>(.*?) \|.*?<\/title>/', $result->data, $title_match);
-      $title = $title_match[1] ? $title_match[1] : '<' . t('unable to determine title') . '>';
-
-      // save and set counts into message.
-      $seen_count = bot_project_url_save($url, $title);
-      preg_match_all('/id="comment-(\d+)/', $result->data, $comments_match);
-      $comment_count = format_plural(count($comments_match[1]), '0 comments', '@count comments');
-      $message = "$url => " . decode_entities($title_match[1]) . ' => ' . decode_entities(implode(', ', array($comment_count, $seen_count)));
-
-      // get some metadata about project issue URLs. tested as of 2006-12-28.
-      preg_match('/<td>Project:<\/td><td>(.*)<\/td>/', $result->data, $project_match);
-      if (isset($project_match[1])) { // we'll only do further matches if this is a project.
-        preg_match('/<td>Component:<\/td><td>(.*)<\/td>/', $result->data, $component_match);
-        preg_match('/<td>Priority:<\/td><td>(.*)<\/td>/', $result->data, $priority_match);
-        preg_match('/<td>Status:<\/td><td>(.*)<\/td>/', $result->data, $status_match);
-        $message = "$url => " . decode_entities($title) . ' => ' . decode_entities(implode(', ', array($project_match[1], $component_match[1], $priority_match[1], $status_match[1], $comment_count, $seen_count)));
-      }
-
-      bot_message($to, $message);
-    }
-  }
-
-  // ======================================================================== */
-  // if Trac issue lookups are enabled...                                     */
-  // ======================================================================== */
-  if (variable_get('bot_project_trac_enable', FALSE)) {
-    $urls_to_scrape = array(); // master collection.
-
-    // looking for URLs in conversation?
-    if (variable_get('bot_project_trac_url_regexp', NULL)) {
-      if (preg_match_all('!(' . variable_get('bot_project_trac_url_regexp', '') . ')!i', $data->message, $url_matches)) {
-        foreach ($url_matches[1] as $url) { // we need to add the format=tab to any ticket-like URLs.
-          if (strpos($url, 'ticket') !== FALSE) { $url .= '?format=tab'; }
-          $urls_to_scrape[] = $url; // master list for scraping later.
-        }
-      }
-    }
-
-    // maybe it's a numerical lookup instead...
-    if (variable_get('bot_project_trac_url', NULL)) {
-      if (preg_match('/^([r#])?(\d+)$/', $data->message, $url_matches)) {
-        $num = $url_matches[2]; // just for shorthand cos I'm lazy.
-        $type = $url_matches[1] == 'r' ? 'changeset' : 'ticket';
-        $query = $url_matches[1] == 'r' ? NULL : 'format=tab'; // Trac's tab export is better than CSV (which makes , _).
-        if ($num > variable_get('bot_project_trac_num_min', 0) && $num < variable_get('bot_project_trac_num_max', 99999)) {
-          $urls_to_scrape[] = url(variable_get('bot_project_trac_url', NULL) . $type . '/' . $num, array('query' => $query, 'absolute' => TRUE));
-        }
-      }
-    }
-
-    // retrieve each desired URL.
-    foreach ($urls_to_scrape as $url) {
-      if (variable_get('bot_project_too_lazy_to_recompile_for_ssl', 0)) { $url = str_replace('https', 'http', $url); }
-      // @todo I'm too lazy to recompile PHP with SSL support, and too Drupal-oriented to switch to cURL. I'm awesome.
-
-      $result = drupal_http_request($url);
-      if ($result->code != 200) { continue; }
-
-      // if the URL is a tab export, it's a ticket.
-      if (strpos($url, '?format=tab') !== FALSE) {
-        // we can't use str_getcsv() yet, and we don't actually care about newlines
-        // in the description (since the description doesn't get printed), so we'll
-        // munge this export until it "works". who uses Trac anyways? heh. KIDDING.
-        $lines = explode("\n", $result->data); array_shift($lines); // load, trash header.
-        $ticket = explode("\t", implode('', $lines)); // back into a string and split.
-        $seen_count = bot_project_url_save(str_replace('?format=tab', '', $url), $ticket[1]);
-
-        // 0:id, 1:summary, 2:reporter, 3:owner, 4:description, 5:type, 6:status, 7:priority, 8:component, 9:resolution, 10:keywords, 11:cc
-        $message = str_replace('?format=tab', '', $url) . ' => ' . decode_entities($ticket[1]) . ' => ' . implode(', ', array($ticket[2], $ticket[3], $ticket[8], $ticket[7], $seen_count));
-      }
-      else { // not a ticket? changeset!
-        $result->data = preg_replace("/\n/", '', $result->data); // for easier grepping.
-        preg_match('/<dd class="message searchable">\s*(.*?)\s*<\/dd>/', $result->data, $commit_match);
-        $commit_match[1] = strip_tags($commit_match[1]); // remove any HTML autogenerated by Trac (like ticket links).
-        $commit_msg = drupal_strlen($commit_match[1]) > 120 ? drupal_substr($commit_match[1], 0, 120) . '...' : $commit_match[1];
-
-        // grab the title from the HTML and add our shortened commit message to it.
-        preg_match('/<title>\s*(.*?) – .*? – Trac\s*<\/title>/', $result->data, $title_match);
-        $title = $title_match[1] ? $title_match[1] : '<' . t('unable to determine title') . '>';
-        $seen_count = bot_project_url_save($url, $title); // no commit_msg; tooOoO loOong.
-
-        // and finally generate the final version of our IRC message.
-        $message = "$url => " . decode_entities($commit_msg) . " => $seen_count";
-      }
-
-      bot_message($to, $message);
-    }
   }
 }
 
@@ -309,101 +173,344 @@ function bot_project_url_save($url, $title) {
  * Configures the project toolkit features.
  */
 function bot_project_settings() {
-  if (module_exists('api')) {
-    $results = db_query('SELECT branch_name, title FROM {api_branch} ORDER BY title');
-    $branches = array(); while ($result = db_fetch_object($results)) { $branches[$result->branch_name] = $result->title; }
-
-    $form['bot_project_api'] = array(
-      '#collapsible'   => TRUE,
-      '#title'         => t('api.module integration'),
-      '#type'          => 'fieldset',
-    );
-    $form['bot_project_api']['bot_project_api_default_branch'] = array(
-      '#default_value' => variable_get('bot_project_api_default_branch', NULL),
-      '#description'   => t('Choose the default branch that will be used for non-specific function lookups.'),
-      '#options'       => $branches,
-      '#title'         => t('Default API Branch'),
-      '#type'          => 'select',
-    );
-    foreach ($branches as $branch => $title) {
-      $form['bot_project_api']['bot_project_api_branch_' . $branch . '_url'] = array(
-        '#default_value' => bot_project_branch_url($branch),
-        '#description'   => t('Define the URL displayed in responses to this branch\'s function lookups.'),
-        '#title'         => t('Function URLs for branch %name', array('%name' => $branch)),
-        '#type'          => 'textfield',
-      );
+  // Add settings form parts from other modules.
+  foreach (module_implements('bot_project_info') as $module) {
+    $project_info = module_invoke($module, 'bot_project_info');
+    foreach ($project_info as $key => $info) {
+      $settings = $info['settings'];
+      if (function_exists($settings)) {
+        $enabled = variable_get("bot_project_{$key}_enable", FALSE);
+        $form["bot_project_{$key}"] = array(
+          '#type' => 'fieldset',
+          '#title' => $info['title'],
+          '#collapsible' => TRUE,
+          '#collapsed' => !$enabled,
+        );
+        $form["bot_project_{$key}"]["bot_project_{$key}_enable"] = array(
+          '#default_value' => $enabled,
+          '#title'         => t('Enable !title_short lookups', array('!title_short' => $info['title_short'])),
+          '#type'          => 'checkbox',
+        );
+        $form_extra = $settings();
+        $form["bot_project_{$key}"] = array_merge($form["bot_project_{$key}"], $form_extra);
+      }
     }
   }
 
-  $form['bot_project_project'] = array(
-    '#collapsible'   => TRUE,
-    '#title'         => t('project.module integration'),
-    '#type'          => 'fieldset',
+  return system_settings_form($form);
+}
+
+/**
+ * Implements hook_bot_project_info().
+ * Tell bot_project which type of project will be supported.
+ *
+ * @return
+ *   Keyed array with
+ *   'title'      : Titel of project type to integrate.
+ *   'title_short': Short title of project type.
+ *   'settings'   : Name of function that generate the project specific settings
+ *                  form.
+ *   'scraper'    : Name of function that generates the message based on a given
+ *                  URL.
+ */
+function bot_project_bot_project_info() {
+  $info = array();
+  if (module_exists('api')) {
+    $info['api'] = array(
+      'title'       => t('api.module integration'),
+      'title_short' => t('api.module'),
+      'settings'    => 'bot_project_api_settings',
+      'scraper'     => 'bot_project_api_scraper',
+    );
+  }
+  $info['project'] = array(
+    'title'       => t('project.module integration'),
+    'title_short' => t('project.module'),
+    'settings'    => 'bot_project_project_settings',
+    'scraper'     => 'bot_project_project_scraper',
   );
-  $form['bot_project_project']['bot_project_project_enable'] = array(
-    '#default_value' => variable_get('bot_project_project_enable', FALSE),
-    '#title'         => t('Enable project.module issue lookups'),
-    '#type'          => 'checkbox',
+  $info['trac'] = array(
+    'title' => t('Trac integration'),
+    'title_short' => t('Trac'),
+    'settings' => 'bot_project_trac_settings',
+    'scraper'     => 'bot_project_trac_scraper',
   );
-  $form['bot_project_project']['bot_project_project_url_regexp'] = array(
+  return $info;
+}
+
+/**
+ * Part of settings form for project.module integration.
+ */
+function bot_project_api_settings() {
+  $form = array();
+  $results = db_query('SELECT branch_name, title FROM {api_branch} ORDER BY title');
+  $branches = array();
+  while ($result = db_fetch_object($results)) {
+    $branches[$result->branch_name] = $result->title;
+  }
+
+  $form['bot_project_api_default_branch'] = array(
+    '#default_value' => variable_get('bot_project_api_default_branch', NULL),
+    '#description'   => t('Choose the default branch that will be used for non-specific function lookups.'),
+    '#options'       => $branches,
+    '#title'         => t('Default API Branch'),
+    '#type'          => 'select',
+  );
+  foreach ($branches as $branch => $title) {
+    $form['bot_project_api_branch_' . $branch . '_url'] = array(
+      '#default_value' => bot_project_branch_url($branch),
+      '#description'   => t('Define the URL displayed in responses to this branch\'s function lookups.'),
+      '#title'         => t('Function URLs for branch %name', array('%name' => $branch)),
+      '#type'          => 'textfield',
+    );
+  }
+
+  return $form;
+}
+/**
+ * Part of settings form for project.module integration.
+ */
+function bot_project_project_settings() {
+  $form = array();
+  $form['bot_project_project_url_regexp'] = array(
     '#default_value' => variable_get('bot_project_project_url_regexp', NULL),
     '#description'   => t('Lookup issues when matched in conversation (ex. %example).', array('%example' => 'http://[\w\d\-]*?\.?drupal\.org/node/\d+')),
     '#title'         => t('URL regexp for issue lookups'),
     '#type'          => 'textfield',
   );
-  $form['bot_project_project']['bot_project_project_url'] = array(
+  $form['bot_project_project_url'] = array(
     '#default_value' => variable_get('bot_project_project_url', NULL),
     '#description'   => t('Define the base URL used with node ID issue lookups (ex. %example).', array('%example' => 'http://drupal.org/')),
     '#title'         => t('Base URL (for node ID lookups)'),
     '#type'          => 'textfield',
   );
-  $form['bot_project_project']['bot_project_project_nid_min'] = array(
+  $form['bot_project_project_nid_min'] = array(
     '#default_value' => variable_get('bot_project_project_nid_min', 0),
     '#description'   => t('Lookup issues ("#1234" or "1234" as the entire message) at the base URL larger than this node ID.'),
     '#title'         => t('Minimum node ID for lookups'),
     '#type'          => 'textfield',
   );
-  $form['bot_project_project']['bot_project_project_nid_max'] = array(
+  $form['bot_project_project_nid_max'] = array(
     '#default_value' => variable_get('bot_project_project_nid_max', 99999),
     '#description'   => t('Lookup issues ("#1234" or "1234" as the entire message) at the base URL smaller than this node ID.'),
     '#title'         => t('Maximum node ID for lookups'),
     '#type'          => 'textfield',
   );
+  return $form;
+}
 
-  $form['bot_project_trac'] = array(
-    '#collapsible'   => TRUE,
-    '#title'         => t('Trac integration'),
-    '#type'          => 'fieldset',
-  );
-  $form['bot_project_trac']['bot_project_trac_enable'] = array(
-    '#default_value' => variable_get('bot_project_trac_enable', FALSE),
-    '#title'         => t('Enable Trac lookups'),
-    '#type'          => 'checkbox',
-  );
-  $form['bot_project_trac']['bot_project_trac_url_regexp'] = array(
+/**
+ * Part of settings form for Trac integration.
+ */
+function bot_project_trac_settings() {
+  $form = array();
+  $form['bot_project_trac_url_regexp'] = array(
     '#default_value' => variable_get('bot_project_trac_url_regexp', NULL),
     '#description'   => t('Lookup data when matched in conversation (ex. %example).', array('%example' => 'http://www.example.com/trac/(changeset|ticket)/\d+')),
     '#title'         => t('URL regexp for data lookups'),
     '#type'          => 'textfield',
   );
-  $form['bot_project_trac']['bot_project_trac_url'] = array(
+  $form['bot_project_trac_url'] = array(
     '#default_value' => variable_get('bot_project_trac_url', NULL),
     '#description'   => t('Define the base URL used with numerical lookups (ex. %example).', array('%example' => 'http://www.example.com/trac/')),
     '#title'         => t('Base URL (for numerical lookups)'),
     '#type'          => 'textfield',
   );
-  $form['bot_project_trac']['bot_project_trac_num_min'] = array(
+  $form['bot_project_trac_num_min'] = array(
     '#default_value' => variable_get('bot_project_trac_num_min', 0),
     '#description'   => t('Lookup data ("#1234", "1234", or "r1234" as the entire message) at the base URL larger than this number.'),
     '#title'         => t('Minimum numerical value for lookups'),
     '#type'          => 'textfield',
   );
-  $form['bot_project_trac']['bot_project_trac_num_max'] = array(
+  $form['bot_project_trac_num_max'] = array(
     '#default_value' => variable_get('bot_project_trac_num_max', 99999),
     '#description'   => t('Lookup data ("#1234", "1234", or "r1234" as the entire message) at the base URL smaller than this number.'),
     '#title'         => t('Maximum numerical for lookups'),
     '#type'          => 'textfield',
   );
+  return $form;
+}
 
-  return system_settings_form($form);
+/**
+ * Grab URLs from irc data and create messages with information from
+ * api.module.
+ *
+ * @param $data
+ *  The regular $data object prepared by the IRC library.
+ * @return
+ *  Array with messages.
+ */
+function bot_project_api_scraper($data) {
+  $messages = array();
+  if (module_exists('api') && preg_match('/^(\w*):?([\w\-]*)\?$/', $data->messageex[0], $matches)) {
+    $function_name = $matches[1];
+    $branch = $matches[2] ? $matches[2] : variable_get('bot_project_api_default_branch', NULL);
+    if (!$function_name) { 
+      return;
+    } // some modules have bad docs that allow "?" to match incorrectly.
+
+    // there could be more than one result for a function lookup, so we'll load
+    // them all and return only the right branch (if that's a determining factor).
+    // we can't just force the branch into the SQL; too restrictive on 3+ branches.
+    $results = db_query("SELECT ad.branch_name, ad.summary, af.signature FROM {api_function} af INNER JOIN {api_documentation} ad ON
+      af.did = ad.did WHERE ad.object_type = 'function' AND ad.object_name = :function_name", array(':function_name' => $function_name));
+    $db_functions = array(); // a master list of all the matched functions.
+    foreach ($results as $result) { // look for the desired branch and...
+      $db_functions[] = $result; // store the results for use as a last resort.
+      if ($result->branch_name == $branch) { 
+        $function_result = $result;
+      }
+    } // no matching branch? we'll just use whatever is first in the list of all matches.
+    $function_result = $function_result ? $function_result : array_shift($db_functions);
+    if (!$function_result->branch_name) { 
+      return;
+    } // no branch name == no results.
+
+    // build up the actual message for spitting.
+    if (!$function_result->summary && $function_result->branch_name != 'php') { 
+      $function_result->summary = '[There is no documentation! This is a bug. File a patch!]';
+    }
+    if (preg_match("/\n/", $function_result->summary) && $function_result->branch_name != 'php') { 
+      $function_result->summary = '[Documented summary is not one line. This is a bug. File a patch!]';
+    }
+    $message = $function_result->branch_name != 'php' // if we've indexed the php functions, they're in branch 'php', so we'll spit out a slightly different format.
+      ? t('!function: !summary => !signature => !url', array('!function' => $function_name, '!signature' => $function_result->signature, '!summary' => $function_result->summary, '!url' => strtr(bot_project_branch_url($function_result->branch_name), array('!function' => $function_name))))
+      : t('!function: !url', array('!function' => $function_name, '!url' => 'http://php.net/' . $function_name));
+    $messages[] = $message;
+  }
+  return $messages;
 }
+
+/**
+ * Grab URLs from irc data and create messages with information from
+ * project.module.
+ *
+ * @param $data
+ *  The regular $data object prepared by the IRC library.
+ * @return
+ *  Array with messages.
+ */
+function bot_project_project_scraper($data) {
+  $messages = array();
+  $urls_to_scrape = array(); // master collection.
+
+  // looking for URLs in conversation?
+  if (variable_get('bot_project_project_url_regexp', NULL)) {
+    if (preg_match_all('!(' . variable_get('bot_project_project_url_regexp', '') . ')!i', $data->message, $url_matches)) {
+      foreach ($url_matches[1] as $url) { $urls_to_scrape[] = $url; } // master list for scraping later.
+    }
+  }
+
+  // ok, maybe just node IDs then?
+  if (variable_get('bot_project_project_url', NULL)) {
+    if (preg_match('/^#?(\d+)$/', $data->message, $url_matches)) {
+      $nid = $url_matches[1]; // just for shorthand cos I'm lazy.
+      if ($nid > variable_get('bot_project_project_nid_min', 0) && $nid < variable_get('bot_project_project_nid_max', 99999)) {
+        $urls_to_scrape[] = url(variable_get('bot_project_project_url', NULL) . 'node/' . $nid, array('absolute' => TRUE));
+      }
+    }
+  }
+
+  // retrieve each desired URL.
+  foreach ($urls_to_scrape as $url) {
+    if (variable_get('bot_project_too_lazy_to_recompile_for_ssl', 0)) { $url = str_replace('https', 'http', $url); }
+    // @todo I'm too lazy to recompile PHP with SSL support, and too Drupal-oriented to switch to cURL. I'm awesome.
+
+    $result = drupal_http_request($url);
+    if ($result->code != 200) { continue; }
+
+    // we'll always display a title, so grab that first for db storage.
+    preg_match('/<title>(.*?) \|.*?<\/title>/', $result->data, $title_match);
+    $title = $title_match[1] ? $title_match[1] : '<' . t('unable to determine title') . '>';
+
+    // save and set counts into message.
+    $seen_count = bot_project_url_save($url, $title);
+    preg_match_all('/id="comment-(\d+)/', $result->data, $comments_match);
+    $comment_count = format_plural(count($comments_match[1]), '0 comments', '@count comments');
+    $message = "$url => " . decode_entities($title_match[1]) . ' => ' . decode_entities(implode(', ', array($comment_count, $seen_count)));
+
+    // get some metadata about project issue URLs. tested as of 2006-12-28.
+    preg_match('/<td>Project:<\/td><td>(.*)<\/td>/', $result->data, $project_match);
+    if (isset($project_match[1])) { // we'll only do further matches if this is a project.
+      preg_match('/<td>Component:<\/td><td>(.*)<\/td>/', $result->data, $component_match);
+      preg_match('/<td>Priority:<\/td><td>(.*)<\/td>/', $result->data, $priority_match);
+      preg_match('/<td>Status:<\/td><td>(.*)<\/td>/', $result->data, $status_match);
+      $message = "$url => " . decode_entities($title) . ' => ' . decode_entities(implode(', ', array($project_match[1], $component_match[1], $priority_match[1], $status_match[1], $comment_count, $seen_count)));
+    }
+    $messages[] = $message;
+  }
+  return $messages;
+}
+
+/**
+ * Grab URLs from irc data and create messages with information from Trac.
+ *
+ * @param $data
+ *  The regular $data object prepared by the IRC library.
+ * @return
+ *  Array with messages.
+ */
+function bot_project_trac_scraper($data) {
+  $messages = array();
+  $urls_to_scrape = array(); // master collection.
+
+  // looking for URLs in conversation?
+  if (variable_get('bot_project_trac_url_regexp', NULL)) {
+    if (preg_match_all('!(' . variable_get('bot_project_trac_url_regexp', '') . ')!i', $data->message, $url_matches)) {
+      foreach ($url_matches[1] as $url) { // we need to add the format=tab to any ticket-like URLs.
+        if (strpos($url, 'ticket') !== FALSE) { $url .= '?format=tab'; }
+        $urls_to_scrape[] = $url; // master list for scraping later.
+      }
+    }
+  }
+
+  // maybe it's a numerical lookup instead...
+  if (variable_get('bot_project_trac_url', NULL)) {
+    if (preg_match('/^([r#])?(\d+)$/', $data->message, $url_matches)) {
+      $num = $url_matches[2]; // just for shorthand cos I'm lazy.
+      $type = $url_matches[1] == 'r' ? 'changeset' : 'ticket';
+      $query = $url_matches[1] == 'r' ? NULL : 'format=tab'; // Trac's tab export is better than CSV (which makes , _).
+      if ($num > variable_get('bot_project_trac_num_min', 0) && $num < variable_get('bot_project_trac_num_max', 99999)) {
+        $urls_to_scrape[] = url(variable_get('bot_project_trac_url', NULL) . $type . '/' . $num, array('query' => $query, 'absolute' => TRUE));
+      }
+    }
+  }
+
+  // retrieve each desired URL.
+  foreach ($urls_to_scrape as $url) {
+    if (variable_get('bot_project_too_lazy_to_recompile_for_ssl', 0)) { $url = str_replace('https', 'http', $url); }
+    // @todo I'm too lazy to recompile PHP with SSL support, and too Drupal-oriented to switch to cURL. I'm awesome.
+
+    $result = drupal_http_request($url);
+    if ($result->code != 200) { continue; }
+
+    // if the URL is a tab export, it's a ticket.
+    if (strpos($url, '?format=tab') !== FALSE) {
+      // we can't use str_getcsv() yet, and we don't actually care about newlines
+      // in the description (since the description doesn't get printed), so we'll
+      // munge this export until it "works". who uses Trac anyways? heh. KIDDING.
+      $lines = explode("\n", $result->data); array_shift($lines); // load, trash header.
+      $ticket = explode("\t", implode('', $lines)); // back into a string and split.
+      $seen_count = bot_project_url_save(str_replace('?format=tab', '', $url), $ticket[1]);
+
+      // 0:id, 1:summary, 2:reporter, 3:owner, 4:description, 5:type, 6:status, 7:priority, 8:component, 9:resolution, 10:keywords, 11:cc
+      $message = str_replace('?format=tab', '', $url) . ' => ' . decode_entities($ticket[1]) . ' => ' . implode(', ', array($ticket[2], $ticket[3], $ticket[8], $ticket[7], $seen_count));
+    }
+    else { // not a ticket? changeset!
+      $result->data = preg_replace("/\n/", '', $result->data); // for easier grepping.
+      preg_match('/<dd class="message searchable">\s*(.*?)\s*<\/dd>/', $result->data, $commit_match);
+      $commit_match[1] = strip_tags($commit_match[1]); // remove any HTML autogenerated by Trac (like ticket links).
+      $commit_msg = drupal_strlen($commit_match[1]) > 120 ? drupal_substr($commit_match[1], 0, 120) . '...' : $commit_match[1];
+
+      // grab the title from the HTML and add our shortened commit message to it.
+      preg_match('/<title>\s*(.*?) – .*? – Trac\s*<\/title>/', $result->data, $title_match);
+      $title = $title_match[1] ? $title_match[1] : '<' . t('unable to determine title') . '>';
+      $seen_count = bot_project_url_save($url, $title); // no commit_msg; tooOoO loOong.
+
+      // and finally generate the final version of our IRC message.
+      $message = "$url => " . decode_entities($commit_msg) . " => $seen_count";
+    }
+    $messages[] = $message;
+  }
+  return $messages;
+}
\ No newline at end of file
