@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator

Add a skeleton for Calendar notifications

Summary:
Ref T7931. I'm going to do this separate from existing infrastructure because:

- events start at different times for different users;
- I like the idea of being able to batch stuff (send one email about several upcoming events);
- triggering on ghost/recurring events is a real complicated mess.

This puts a skeleton in place that finds all the events we need to notify about and writes some silly example bodies to stdout, marking that we notified users so they don't get notified again.

Test Plan:
Ran `bin/calendar notify`, got a "great" notification in the command output.

{F1891625}

Reviewers: chad

Reviewed By: chad

Maniphest Tasks: T7931

Differential Revision: https://secure.phabricator.com/D16783

+325
+1
bin/calendar
··· 1 + ../scripts/setup/manage_calendar.php
+8
resources/sql/autopatches/20161031.calendar.02.notifylog.sql
··· 1 + CREATE TABLE {$NAMESPACE}_calendar.calendar_notification ( 2 + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, 3 + eventPHID VARBINARY(64) NOT NULL, 4 + utcInitialEpoch INT UNSIGNED NOT NULL, 5 + targetPHID VARBINARY(64) NOT NULL, 6 + didNotifyEpoch INT UNSIGNED NOT NULL, 7 + UNIQUE KEY `key_notify` (eventPHID, utcInitialEpoch, targetPHID) 8 + ) ENGINE=InnoDB, COLLATE {$COLLATE_TEXT};
+21
scripts/setup/manage_calendar.php
··· 1 + #!/usr/bin/env php 2 + <?php 3 + 4 + $root = dirname(dirname(dirname(__FILE__))); 5 + require_once $root.'/scripts/__init_script__.php'; 6 + 7 + $args = new PhutilArgumentParser($argv); 8 + $args->setTagline(pht('manage Calendar')); 9 + $args->setSynopsis(<<<EOSYNOPSIS 10 + **calendar** __command__ [__options__] 11 + Manage Calendar. 12 + 13 + EOSYNOPSIS 14 + ); 15 + $args->parseStandardArguments(); 16 + 17 + $workflows = id(new PhutilClassMapQuery()) 18 + ->setAncestorClass('PhabricatorCalendarManagementWorkflow') 19 + ->execute(); 20 + $workflows[] = new PhutilHelpArgumentWorkflow(); 21 + $args->parseWorkflows($workflows);
+8
src/__phutil_library_map__.php
··· 2153 2153 'PhabricatorCalendarImportTriggerLogType' => 'applications/calendar/importlog/PhabricatorCalendarImportTriggerLogType.php', 2154 2154 'PhabricatorCalendarImportUpdateLogType' => 'applications/calendar/importlog/PhabricatorCalendarImportUpdateLogType.php', 2155 2155 'PhabricatorCalendarImportViewController' => 'applications/calendar/controller/PhabricatorCalendarImportViewController.php', 2156 + 'PhabricatorCalendarManagementNotifyWorkflow' => 'applications/calendar/management/PhabricatorCalendarManagementNotifyWorkflow.php', 2157 + 'PhabricatorCalendarManagementWorkflow' => 'applications/calendar/management/PhabricatorCalendarManagementWorkflow.php', 2158 + 'PhabricatorCalendarNotification' => 'applications/calendar/storage/PhabricatorCalendarNotification.php', 2159 + 'PhabricatorCalendarNotificationEngine' => 'applications/calendar/notifications/PhabricatorCalendarNotificationEngine.php', 2156 2160 'PhabricatorCalendarRemarkupRule' => 'applications/calendar/remarkup/PhabricatorCalendarRemarkupRule.php', 2157 2161 'PhabricatorCalendarReplyHandler' => 'applications/calendar/mail/PhabricatorCalendarReplyHandler.php', 2158 2162 'PhabricatorCalendarSchemaSpec' => 'applications/calendar/storage/PhabricatorCalendarSchemaSpec.php', ··· 7014 7018 'PhabricatorCalendarImportTriggerLogType' => 'PhabricatorCalendarImportLogType', 7015 7019 'PhabricatorCalendarImportUpdateLogType' => 'PhabricatorCalendarImportLogType', 7016 7020 'PhabricatorCalendarImportViewController' => 'PhabricatorCalendarController', 7021 + 'PhabricatorCalendarManagementNotifyWorkflow' => 'PhabricatorCalendarManagementWorkflow', 7022 + 'PhabricatorCalendarManagementWorkflow' => 'PhabricatorManagementWorkflow', 7023 + 'PhabricatorCalendarNotification' => 'PhabricatorCalendarDAO', 7024 + 'PhabricatorCalendarNotificationEngine' => 'Phobject', 7017 7025 'PhabricatorCalendarRemarkupRule' => 'PhabricatorObjectRemarkupRule', 7018 7026 'PhabricatorCalendarReplyHandler' => 'PhabricatorApplicationTransactionReplyHandler', 7019 7027 'PhabricatorCalendarSchemaSpec' => 'PhabricatorConfigSchemaSpec',
+25
src/applications/calendar/management/PhabricatorCalendarManagementNotifyWorkflow.php
··· 1 + <?php 2 + 3 + final class PhabricatorCalendarManagementNotifyWorkflow 4 + extends PhabricatorCalendarManagementWorkflow { 5 + 6 + protected function didConstruct() { 7 + $this 8 + ->setName('notify') 9 + ->setExamples('**notify** [options]') 10 + ->setSynopsis( 11 + pht( 12 + 'Test and debug notifications about upcoming events.')) 13 + ->setArguments(array()); 14 + } 15 + 16 + public function execute(PhutilArgumentParser $args) { 17 + $viewer = $this->getViewer(); 18 + 19 + $engine = new PhabricatorCalendarNotificationEngine(); 20 + $engine->publishNotifications(); 21 + 22 + return 0; 23 + } 24 + 25 + }
+4
src/applications/calendar/management/PhabricatorCalendarManagementWorkflow.php
··· 1 + <?php 2 + 3 + abstract class PhabricatorCalendarManagementWorkflow 4 + extends PhabricatorManagementWorkflow {}
+200
src/applications/calendar/notifications/PhabricatorCalendarNotificationEngine.php
··· 1 + <?php 2 + 3 + final class PhabricatorCalendarNotificationEngine 4 + extends Phobject { 5 + 6 + private $cursor; 7 + 8 + public function getCursor() { 9 + if (!$this->cursor) { 10 + $now = PhabricatorTime::getNow(); 11 + $this->cursor = $now - phutil_units('5 minutes in seconds'); 12 + } 13 + 14 + return $this->cursor; 15 + } 16 + 17 + public function publishNotifications() { 18 + $cursor = $this->getCursor(); 19 + 20 + $window_min = $cursor - phutil_units('16 hours in seconds'); 21 + $window_max = $cursor + phutil_units('16 hours in seconds'); 22 + 23 + $viewer = PhabricatorUser::getOmnipotentUser(); 24 + 25 + $events = id(new PhabricatorCalendarEventQuery()) 26 + ->setViewer($viewer) 27 + ->withDateRange($window_min, $window_max) 28 + ->withIsCancelled(false) 29 + ->withIsImported(false) 30 + ->setGenerateGhosts(true) 31 + ->execute(); 32 + if (!$events) { 33 + // No events are starting soon in any timezone, so there is nothing 34 + // left to be done. 35 + return; 36 + } 37 + 38 + $attendee_map = array(); 39 + foreach ($events as $key => $event) { 40 + $notifiable_phids = array(); 41 + foreach ($event->getInvitees() as $invitee) { 42 + if (!$invitee->isAttending()) { 43 + continue; 44 + } 45 + $notifiable_phids[] = $invitee->getInviteePHID(); 46 + } 47 + if (!$notifiable_phids) { 48 + unset($events[$key]); 49 + } 50 + $attendee_map[$key] = array_fuse($notifiable_phids); 51 + } 52 + if (!$attendee_map) { 53 + // None of the events have any notifiable attendees, so there is no 54 + // one to notify of anything. 55 + return; 56 + } 57 + 58 + $all_attendees = array(); 59 + foreach ($attendee_map as $key => $attendee_phids) { 60 + foreach ($attendee_phids as $attendee_phid) { 61 + $all_attendees[$attendee_phid] = $attendee_phid; 62 + } 63 + } 64 + 65 + $user_map = id(new PhabricatorPeopleQuery()) 66 + ->setViewer($viewer) 67 + ->withPHIDs($all_attendees) 68 + ->withIsDisabled(false) 69 + ->needUserSettings(true) 70 + ->execute(); 71 + $user_map = mpull($user_map, null, 'getPHID'); 72 + if (!$user_map) { 73 + // None of the attendees are valid users: they're all imported users 74 + // or projects or invalid or some other kind of unnotifiable entity. 75 + return; 76 + } 77 + 78 + $all_event_phids = array(); 79 + foreach ($events as $key => $event) { 80 + foreach ($event->getNotificationPHIDs() as $phid) { 81 + $all_event_phids[$phid] = $phid; 82 + } 83 + } 84 + 85 + $table = new PhabricatorCalendarNotification(); 86 + $conn = $table->establishConnection('w'); 87 + 88 + $rows = queryfx_all( 89 + $conn, 90 + 'SELECT * FROM %T WHERE eventPHID IN (%Ls) AND targetPHID IN (%Ls)', 91 + $table->getTableName(), 92 + $all_event_phids, 93 + $all_attendees); 94 + $sent_map = array(); 95 + foreach ($rows as $row) { 96 + $event_phid = $row['eventPHID']; 97 + $target_phid = $row['targetPHID']; 98 + $initial_epoch = $row['utcInitialEpoch']; 99 + $sent_map[$event_phid][$target_phid][$initial_epoch] = $row; 100 + } 101 + 102 + $notify_min = $cursor; 103 + $notify_max = $cursor + phutil_units('15 minutes in seconds'); 104 + $notify_map = array(); 105 + foreach ($events as $key => $event) { 106 + $initial_epoch = $event->getUTCInitialEpoch(); 107 + $event_phids = $event->getNotificationPHIDs(); 108 + 109 + // Select attendees who actually exist, and who we have not sent any 110 + // notifications to yet. 111 + $attendee_phids = $attendee_map[$key]; 112 + $users = array_select_keys($user_map, $attendee_phids); 113 + foreach ($users as $user_phid => $user) { 114 + foreach ($event_phids as $event_phid) { 115 + if (isset($sent_map[$event_phid][$user_phid][$initial_epoch])) { 116 + unset($users[$user_phid]); 117 + continue 2; 118 + } 119 + } 120 + } 121 + 122 + if (!$users) { 123 + continue; 124 + } 125 + 126 + // Discard attendees for whom the event start time isn't soon. Events 127 + // may start at different times for different users, so we need to 128 + // check every user's start time. 129 + foreach ($users as $user_phid => $user) { 130 + $user_datetime = $event->newStartDateTime() 131 + ->setViewerTimezone($user->getTimezoneIdentifier()); 132 + 133 + $user_epoch = $user_datetime->getEpoch(); 134 + if ($user_epoch < $notify_min || $user_epoch > $notify_max) { 135 + unset($users[$user_phid]); 136 + continue; 137 + } 138 + 139 + $notify_map[$user_phid][] = array( 140 + 'event' => $event, 141 + 'datetime' => $user_datetime, 142 + 'epoch' => $user_epoch, 143 + ); 144 + } 145 + } 146 + 147 + $mail_list = array(); 148 + $mark_list = array(); 149 + $now = PhabricatorTime::getNow(); 150 + foreach ($notify_map as $user_phid => $events) { 151 + $user = $user_map[$user_phid]; 152 + $events = isort($events, 'epoch'); 153 + 154 + // TODO: This is just a proof-of-concept that gets dumped to the console; 155 + // it will be replaced with a nice fancy email and notification. 156 + 157 + $body = array(); 158 + $body[] = pht('%s, these events start soon:', $user->getUsername()); 159 + $body[] = null; 160 + foreach ($events as $spec) { 161 + $event = $spec['event']; 162 + $body[] = $event->getName(); 163 + } 164 + $body = implode("\n", $body); 165 + 166 + $mail_list[] = $body; 167 + 168 + foreach ($events as $spec) { 169 + $event = $spec['event']; 170 + foreach ($event->getNotificationPHIDs() as $phid) { 171 + $mark_list[] = qsprintf( 172 + $conn, 173 + '(%s, %s, %d, %d)', 174 + $phid, 175 + $user_phid, 176 + $event->getUTCInitialEpoch(), 177 + $now); 178 + } 179 + } 180 + } 181 + 182 + // Mark all the notifications we're about to send as delivered so we 183 + // do not double-notify. 184 + foreach (PhabricatorLiskDAO::chunkSQL($mark_list) as $chunk) { 185 + queryfx( 186 + $conn, 187 + 'INSERT IGNORE INTO %T 188 + (eventPHID, targetPHID, utcInitialEpoch, didNotifyEpoch) 189 + VALUES %Q', 190 + $table->getTableName(), 191 + $chunk); 192 + } 193 + 194 + foreach ($mail_list as $mail) { 195 + echo $mail; 196 + echo "\n\n"; 197 + } 198 + } 199 + 200 + }
+18
src/applications/calendar/query/PhabricatorCalendarEventQuery.php
··· 19 19 private $importUIDs; 20 20 private $utcInitialEpochMin; 21 21 private $utcInitialEpochMax; 22 + private $isImported; 22 23 23 24 private $generateGhosts = false; 24 25 ··· 100 101 101 102 public function withImportUIDs(array $uids) { 102 103 $this->importUIDs = $uids; 104 + return $this; 105 + } 106 + 107 + public function withIsImported($is_imported) { 108 + $this->isImported = $is_imported; 103 109 return $this; 104 110 } 105 111 ··· 470 476 $conn, 471 477 'event.importUID IN (%Ls)', 472 478 $this->importUIDs); 479 + } 480 + 481 + if ($this->isImported !== null) { 482 + if ($this->isImported) { 483 + $where[] = qsprintf( 484 + $conn, 485 + 'event.importSourcePHID IS NOT NULL'); 486 + } else { 487 + $where[] = qsprintf( 488 + $conn, 489 + 'event.importSourcePHID IS NULL'); 490 + } 473 491 } 474 492 475 493 return $where;
+13
src/applications/calendar/storage/PhabricatorCalendarEvent.php
··· 1124 1124 ->execute(); 1125 1125 } 1126 1126 1127 + public function getNotificationPHIDs() { 1128 + $phids = array(); 1129 + if ($this->getPHID()) { 1130 + $phids[] = $this->getPHID(); 1131 + } 1132 + 1133 + if ($this->getSeriesParentPHID()) { 1134 + $phids[] = $this->getSeriesParentPHID(); 1135 + } 1136 + 1137 + return $phids; 1138 + } 1139 + 1127 1140 1128 1141 /* -( Markup Interface )--------------------------------------------------- */ 1129 1142
+27
src/applications/calendar/storage/PhabricatorCalendarNotification.php
··· 1 + <?php 2 + 3 + final class PhabricatorCalendarNotification 4 + extends PhabricatorCalendarDAO { 5 + 6 + protected $eventPHID; 7 + protected $utcInitialEpoch; 8 + protected $targetPHID; 9 + protected $didNotifyEpoch; 10 + 11 + protected function getConfiguration() { 12 + return array( 13 + self::CONFIG_TIMESTAMPS => false, 14 + self::CONFIG_COLUMN_SCHEMA => array( 15 + 'utcInitialEpoch' => 'epoch', 16 + 'didNotifyEpoch' => 'epoch', 17 + ), 18 + self::CONFIG_KEY_SCHEMA => array( 19 + 'key_notify' => array( 20 + 'columns' => array('eventPHID', 'utcInitialEpoch', 'targetPHID'), 21 + 'unique' => true, 22 + ), 23 + ), 24 + ) + parent::getConfiguration(); 25 + } 26 + 27 + }