Introducing Message Digests for Open Atrium 2!

Mike Crittenden, Software Architect
#Atrium | Posted

One of the fun features we built for Open Atrium 2 is the ability to group recent messages together into a daily digest. Anyone who has been subscribed to a popular comment thread and has suffered through the "MAKE IT STOP" series of email after email knows how useful this feature is, so naturally it was a must for OA2.

Since OA2 makes heavy use of the Message module stack (which includes Message Notify and Message Subscribe), we opted to implement the feature as a ctools plugin which adds another 2 notification types on top of the default "Email" and SMS" provided by Message Notify. Specifically, we added "Daily Digest" and "Weekly Digest" which do exactly what you'd expect.

So how did we build it? Well, right off the bat, we know we're implementing a ctools plugin for message_notify so let's go ahead and declare that in our .module file.

  1. /**
  2.  * Implements hook_ctools_plugin_api().
  3.  */
  4. function message_digest_ctools_plugin_api($module, $api) {
  5. if ($module == 'message_notify' && $api == 'notifier') {
  6. return array('version' => 1);
  7. }
  8. }
  9.  
  10. /**
  11.  * Implements hook_ctools_plugin_directory().
  12.  */
  13. function message_digest_ctools_plugin_directory($module, $plugin) {
  14. if ($module == 'message_notify') {
  15. return 'plugins/' . $plugin;
  16. }
  17. }

Now we can go ahead and create our plugins. Since digest_day and digest_week will share most of the code (i.e., everything besides the actual interval), we first create an abstract class that they inherit from. This is currently done in plugins/notifier/abstract.inc, although that should be renamed to MessageDigest.inc.

So we first create our class.

  1. /**
  2.  * Message Digest notifier.
  3.  */
  4. class MessageDigest extends MessageNotifierBase {
  5. // Awesome stuff goes here.
  6. }

And then we write a custom ->deliver() method because that is what message_notify calls to send messages. In our case, we don't want to actual "send" the message anywhere except for the database, so that's what we do.

  1. // Do not actually deliver this message because it will be delivered
  2. // via cron in a digest, but return TRUE to prevent a logged error.
  3. // Instead, we "deliver" it to the message_digest DB table so that it
  4. // can be retrieved at a later time.
  5. public function deliver(array $output = array()) {
  6. $message = $this->message;
  7. $plugin = $this->plugin;
  8.  
  9. $message_digest = array(
  10. 'receiver' => $message->uid,
  11. 'gid' => !empty($message->gid) ? $message->gid : 0,
  12. 'notifier' => $plugin['name'],
  13. 'sent' => FALSE,
  14. 'timestamp' => $message->timestamp,
  15. );
  16. // This will only have a value if the message is not a message_subscribe message.
  17. $mid = isset($message->mid) ? $message->mid : NULL;
  18.  
  19. // Our $message is a cloned copy of the original $message with the mid field removed to
  20. // prevent overwriting (this happens in message_subscribe) so we need to fetch the mid manually.
  21. if (empty($mid)) {
  22. $mid = db_select('message', 'm')
  23. ->fields('m', array('mid'))
  24. ->condition('timestamp', $message->timestamp)
  25. ->condition('type', $message->type)
  26. ->execute()
  27. ->fetchField();
  28. }
  29.  
  30. if (!empty($mid)) {
  31. $message_digest['mid'] = $mid;
  32. }
  33.  
  34. drupal_write_record('message_digest', $message_digest);
  35.  
  36. return TRUE; // Sent! Sort of...
  37. }

And oh yeah, we're going to need a table to write to, so message_digest.install creates that for us.

So that handles preventing sending at the time of creation, and it also takes care of storing the message in a custom table for us to use later. That's half the battle.

The second half is aggregating, formatting, and sending the actual digests every day or week (depending on which notification type the user has set as his/her default).

Let's make it run on cron to keep things simple. Here's what our cron hook looks like. Note that this basically just calls all the functions from the plugins without including much of its own logic, so we'll dig in piece by piece.

  1. /**
  2.  * Implements hook_cron().
  3.  * Aggregate, format and send Digest emails.
  4.  */
  5. function message_digest_cron() {
  6. foreach (message_notify_get_notifiers() as $plugin_name => $plugin) {
  7. if (strpos($plugin_name, 'digest') === FALSE) {
  8. // Only load the "Digest" notifiers and skip the rest.
  9. continue;
  10. }
  11.  
  12. // Grab some info we'll need in a bit.
  13. $plugin = message_notify_get_notifier($plugin_name);
  14. $class = ctools_plugin_load_class('message_notify', 'notifier', $plugin_name, 'class');
  15. $notifier = new $class($plugin, new Message());
  16. $interval = $notifier->getInterval();
  17. $last_run = variable_get('message_digest_' . $interval . '_last_run', 0);
  18.  
  19. if ($last_run > strtotime("-" . $interval)) {
  20. // Not time to run this again yet. Wait until the interval has elapsed.
  21. continue;
  22. }
  23.  
  24. // Gather up all the messages into neat little digests and send 'em out.
  25. $digests = $notifier->aggregate();
  26. foreach ($digests as $uid => $groups) {
  27. foreach ($groups as $gid => $messages) {
  28. $context = array(
  29. 'uid' => $uid, // reference only, cannot change
  30. 'gid' => $gid,
  31. 'view_modes' => $plugin['view_modes'],
  32. 'deliver' => TRUE, // set to FALSE to prevent delivery
  33. 'plugin' => $plugin, // reference only
  34. 'messages' => $messages,
  35. );
  36. drupal_alter('message_digest_view_mode', $context);
  37. if ($context['deliver']) {
  38. $formatted_messages = $notifier->format($context['messages'], $context['view_modes']);
  39. $result = $notifier->deliverDigest($uid, $context['gid'], $formatted_messages);
  40. }
  41. }
  42. $notifier->markSent($uid, $plugin_name);
  43. }
  44. variable_set('message_digest_' . $interval . '_last_run', time());
  45. }
  46. }

So the first interesting thing we're doing there is the call to ->aggregate(). That function is defined in the abstract MessageDigest class like so:

  1. /**
  2.   * Aggregate all of the messages for this interval and notifier that haven't
  3.   * already been sent, and group them by user and then by group.
  4.   */
  5. public function aggregate() {
  6. $interval = $this->getInterval();
  7. $start = strtotime('-' . $interval); // Invert $interval since it's in the past.
  8. $message_groups = array();
  9.  
  10. $query = db_select('message_digest', 'md');
  11. $query->fields('md');
  12. $query->condition('timestamp', $start, '>');
  13. $query->condition('sent', FALSE);
  14. $query->condition('notifier', $this->plugin['name']);
  15. $result = $query->execute();
  16.  
  17. foreach ($result as $row) {
  18. $gid = $row->gid;
  19. if (empty($gid)) {
  20. $gid = 0;
  21. }
  22. $account = user_load($row->receiver);
  23. $context = array(
  24. 'account' => $account, // reference only
  25. 'data' => $row,
  26. 'gid' => $gid, // set this to zero to aggregate group content
  27. 'plugin' => $this->plugin, // reference only
  28. );
  29. drupal_alter('message_digest_aggregate', $context);
  30. if (!empty($context['data']->mid)) {
  31. $message_groups[$context['data']->receiver][$context['gid']][] = $context['data']->mid;
  32. }
  33. }
  34. return $message_groups;
  35. }

Basically, we're building an array of all the messages to send out in digests, keyed by the receiver and then by the group (in OA2's case, the Space).

Also of note is that ->getInterval() function which is coincidentally the only function that the digest_day and digest_week plugins have to implement. The default provided by the MessageDigest (abstract.inc) looks like this:

  1. /**
  2.   * This will be overridden in subclasses with custom intervals.
  3.   */
  4. public function getInterval() {
  5. return '1 day';
  6. }

So back to the hook_cron call, you'll note that after running ->aggregate(), we move on to cycling through the users and the respective groups and then running ->format() on the messages. This also comes from MessageDigest (abstract.inc) and looks like so:

  1. /**
  2.   * Given an array of mids, build the full message content for them
  3.   * and combine them into one blob of email content.
  4.   */
  5. public function format($digest, $view_modes) {
  6. $output_array = array();
  7. foreach ($digest as $mid) {
  8. $message = message_load($mid);
  9. if (empty($message) || !is_object($message)) {
  10. continue;
  11. }
  12. $rows = array();
  13. foreach ($view_modes as $view_mode) {
  14. $content = $message->buildContent($view_mode);
  15. $rows[$view_mode] = render($content);
  16. }
  17. $output_array[] = theme('message_digest_row', array(
  18. 'rows' => $rows,
  19. 'plugin' => $this->plugin,
  20. 'message' => $message,
  21. ));
  22. }
  23. return theme('message_digest', array(
  24. 'messages' => $output_array,
  25. 'plugin' => $this->plugin,
  26. ));
  27. }

Pretty simple right? Loop through the messages, build the content for each by using a regular old view mode on the Message entity, add it all to an array, and return it to a theme function.

Once that's done, back in hook_cron(), we pass it into ->deliverDigest() which just passes it to drupal_mail() and is simple enough that there's no need to post it here.

And finally, we run ->markSent() to update those rows in the DB table to show that they are sent and don't need to be included in further digests, and also update the variable which shows when we last sent that digest.

And that's it! A super useful feature complete with support for custom view modes/display types and extensibility (need a different interval? Just write a 5 line class similar to digest_day and digest_week!). Pretty neat! Huge thanks to the great Message module stack by Amitai Burstein for making it this easy!

Be sure to catch Mike Potter's Open Atrium Webinar tomorrow (12/11, 11AM EST.) to learn more about Open Atrium's new email digest in addition to improved responsive theming and tutorial content! Register for the webinar here!

Mike Crittenden

Mike Crittenden

Software Architect