diff --git a/bin/sms b/bin/sms new file mode 120000 --- /dev/null +++ b/bin/sms @@ -0,0 +1 @@ +../scripts/sms/manage_sms.php \ No newline at end of file diff --git a/externals/twilio-php b/externals/twilio-php new file mode 160000 --- /dev/null +++ b/externals/twilio-php @@ -0,0 +1 @@ +Subproject commit 389e07ee41eabc422ea88b805006bccac7de9eb3 diff --git a/resources/sql/autopatches/20140507.smstable.sql b/resources/sql/autopatches/20140507.smstable.sql new file mode 100644 --- /dev/null +++ b/resources/sql/autopatches/20140507.smstable.sql @@ -0,0 +1,13 @@ +CREATE TABLE {$NAMESPACE}_sms.sms ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + providerShortName VARCHAR(16) NOT NULL COLLATE utf8_bin, + providerSMSID VARCHAR(40) NOT NULL COLLATE utf8_bin, + toNumber VARCHAR(20) NOT NULL COLLATE utf8_bin, + fromNumber VARCHAR(20) COLLATE utf8_bin, + body LONGTEXT NOT NULL COLLATE utf8_bin, + sendStatus VARCHAR(16) COLLATE utf8_bin, + sendCount TINYINT UNSIGNED NOT NULL, + dateCreated INT UNSIGNED NOT NULL, + dateModified INT UNSIGNED NOT NULL, + UNIQUE KEY `key_provider` (providerSMSID, providerShortName) +) ENGINE=InnoDB, COLLATE utf8_general_ci; diff --git a/scripts/sms/manage_sms.php b/scripts/sms/manage_sms.php new file mode 100755 --- /dev/null +++ b/scripts/sms/manage_sms.php @@ -0,0 +1,21 @@ +#!/usr/bin/env php +setTagline('manage SMS'); +$args->setSynopsis(<<parseStandardArguments(); + +$workflows = id(new PhutilSymbolLoader()) + ->setAncestorClass('PhabricatorSMSManagementWorkflow') + ->loadObjects(); +$workflows[] = new PhutilHelpArgumentWorkflow(); +$args->parseWorkflows($workflows); diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -2037,6 +2037,19 @@ 'PhabricatorRepositoryVCSPassword' => 'applications/repository/storage/PhabricatorRepositoryVCSPassword.php', 'PhabricatorRobotsController' => 'applications/system/controller/PhabricatorRobotsController.php', 'PhabricatorS3FileStorageEngine' => 'applications/files/engine/PhabricatorS3FileStorageEngine.php', + 'PhabricatorSMS' => 'infrastructure/sms/storage/PhabricatorSMS.php', + 'PhabricatorSMSConfigOptions' => 'applications/config/option/PhabricatorSMSConfigOptions.php', + 'PhabricatorSMSDAO' => 'infrastructure/sms/storage/PhabricatorSMSDAO.php', + 'PhabricatorSMSDemultiplexWorker' => 'infrastructure/sms/worker/PhabricatorSMSDemultiplexWorker.php', + 'PhabricatorSMSImplementationAdapter' => 'infrastructure/sms/adapter/PhabricatorSMSImplementationAdapter.php', + 'PhabricatorSMSImplementationTestBlackholeAdapter' => 'infrastructure/sms/adapter/PhabricatorSMSImplementationTestBlackholeAdapter.php', + 'PhabricatorSMSImplementationTwilioAdapter' => 'infrastructure/sms/adapter/PhabricatorSMSImplementationTwilioAdapter.php', + 'PhabricatorSMSManagementListOutboundWorkflow' => 'infrastructure/sms/management/PhabricatorSMSManagementListOutboundWorkflow.php', + 'PhabricatorSMSManagementSendTestWorkflow' => 'infrastructure/sms/management/PhabricatorSMSManagementSendTestWorkflow.php', + 'PhabricatorSMSManagementShowOutboundWorkflow' => 'infrastructure/sms/management/PhabricatorSMSManagementShowOutboundWorkflow.php', + 'PhabricatorSMSManagementWorkflow' => 'infrastructure/sms/management/PhabricatorSMSManagementWorkflow.php', + 'PhabricatorSMSSendWorker' => 'infrastructure/sms/worker/PhabricatorSMSSendWorker.php', + 'PhabricatorSMSWorker' => 'infrastructure/sms/worker/PhabricatorSMSWorker.php', 'PhabricatorSQLPatchList' => 'infrastructure/storage/patch/PhabricatorSQLPatchList.php', 'PhabricatorSSHKeyGenerator' => 'infrastructure/util/PhabricatorSSHKeyGenerator.php', 'PhabricatorSSHLog' => 'infrastructure/log/PhabricatorSSHLog.php', @@ -4977,6 +4990,18 @@ 'PhabricatorRepositoryVCSPassword' => 'PhabricatorRepositoryDAO', 'PhabricatorRobotsController' => 'PhabricatorController', 'PhabricatorS3FileStorageEngine' => 'PhabricatorFileStorageEngine', + 'PhabricatorSMS' => 'PhabricatorSMSDAO', + 'PhabricatorSMSConfigOptions' => 'PhabricatorApplicationConfigOptions', + 'PhabricatorSMSDAO' => 'PhabricatorLiskDAO', + 'PhabricatorSMSDemultiplexWorker' => 'PhabricatorSMSWorker', + 'PhabricatorSMSImplementationTestBlackholeAdapter' => 'PhabricatorSMSImplementationAdapter', + 'PhabricatorSMSImplementationTwilioAdapter' => 'PhabricatorSMSImplementationAdapter', + 'PhabricatorSMSManagementListOutboundWorkflow' => 'PhabricatorSMSManagementWorkflow', + 'PhabricatorSMSManagementSendTestWorkflow' => 'PhabricatorSMSManagementWorkflow', + 'PhabricatorSMSManagementShowOutboundWorkflow' => 'PhabricatorSMSManagementWorkflow', + 'PhabricatorSMSManagementWorkflow' => 'PhabricatorManagementWorkflow', + 'PhabricatorSMSSendWorker' => 'PhabricatorSMSWorker', + 'PhabricatorSMSWorker' => 'PhabricatorWorker', 'PhabricatorSSHKeyGenerator' => 'Phobject', 'PhabricatorSSHLog' => 'Phobject', 'PhabricatorSSHPassthruCommand' => 'Phobject', diff --git a/src/applications/config/option/PhabricatorSMSConfigOptions.php b/src/applications/config/option/PhabricatorSMSConfigOptions.php new file mode 100644 --- /dev/null +++ b/src/applications/config/option/PhabricatorSMSConfigOptions.php @@ -0,0 +1,48 @@ +deformat(pht(<<newOption( + 'sms.default-sender', + 'string', + '12345678901') + ->setDescription(pht('Default "from" number.')), + $this->newOption( + 'sms.default-adapter', + 'class', + 'PhabricatorSMSImplementationTwilioAdapter') + ->setBaseClass('PhabricatorSMSImplementationAdapter') + ->setSummary(pht('Control how sms is sent.')) + ->setDescription($adapter_description), + $this->newOption( + 'twilio.account-sid', + 'string', + 'ABC123ABC123ABC123ABC123ABC123') + ->setDescription(pht('Account ID on Twilio service.')), + $this->newOption( + 'twilio.auth-token', + 'string', + 'ABC123ABC123ABC123ABC123ABC123') + ->setDescription(pht('Authorization token from Twilio service.')) + ); + } + +} diff --git a/src/infrastructure/sms/adapter/PhabricatorSMSImplementationAdapter.php b/src/infrastructure/sms/adapter/PhabricatorSMSImplementationAdapter.php new file mode 100644 --- /dev/null +++ b/src/infrastructure/sms/adapter/PhabricatorSMSImplementationAdapter.php @@ -0,0 +1,84 @@ +fromNumber = $number; + return $this; + } + + public function getFrom() { + return $this->fromNumber; + } + + public function setTo($number) { + $this->toNumber = $number; + return $this; + } + + public function getTo() { + return $this->toNumber; + } + + public function setBody($body) { + $this->body = $body; + return $this; + } + + public function getBody() { + return $this->body; + } + + /** + * 16 characters or less, to be used in database columns and exposed + * to administrators during configuration directly. + */ + abstract public function getProviderShortName(); + + /** + * Send the message. Generally, this means connecting to some service and + * handing data to it. SMS APIs are generally asynchronous, so truly + * determining success or failure is probably impossible synchronously. + * + * That said, if the adapter determines that the SMS will never be + * deliverable, or there is some other known failure, it should throw + * an exception. + * + * @return null + */ + abstract public function send(); + + /** + * Most (all?) SMS APIs are asynchronous, but some do send back some + * initial information. Use this hook to determine what the updated + * sentStatus should be and what the provider is using for an SMS ID, + * as well as throw exceptions if there are any failures. + * + * @return array Tuple of ($sms_id and $sent_status) + */ + abstract public function getSMSDataFromResult($result); + + /** + * Due to the asynchronous nature of sending SMS messages, it can be + * necessary to poll the provider regarding the sent status of a given + * sms. + * + * For now, this *MUST* be implemented and *MUST* work. + */ + abstract public function pollSMSSentStatus(PhabricatorSMS $sms); + + /** + * Convenience function to handle sending an SMS. + */ + public static function sendSMS(array $to_numbers, $body) { + PhabricatorWorker::scheduleTask( + 'PhabricatorSMSDemultiplexWorker', + array( + 'toNumbers' => $to_numbers, + 'body' => $body)); + } +} diff --git a/src/infrastructure/sms/adapter/PhabricatorSMSImplementationTestBlackholeAdapter.php b/src/infrastructure/sms/adapter/PhabricatorSMSImplementationTestBlackholeAdapter.php new file mode 100644 --- /dev/null +++ b/src/infrastructure/sms/adapter/PhabricatorSMSImplementationTestBlackholeAdapter.php @@ -0,0 +1,30 @@ +getID()) { + return PhabricatorSMS::STATUS_SENT; + } + return PhabricatorSMS::STATUS_SENT_UNCONFIRMED; + } + +} diff --git a/src/infrastructure/sms/adapter/PhabricatorSMSImplementationTwilioAdapter.php b/src/infrastructure/sms/adapter/PhabricatorSMSImplementationTwilioAdapter.php new file mode 100644 --- /dev/null +++ b/src/infrastructure/sms/adapter/PhabricatorSMSImplementationTwilioAdapter.php @@ -0,0 +1,75 @@ +buildClient(); + + try { + $message = $client->account->sms_messages->create(array( + 'From' => $this->getFrom(), + 'To' => $this->getTo(), + 'Body' => $this->getBody())); + } catch (Services_Twilio_RestException $e) { + $message = sprintf( + 'HTTP Code %d: %s', + $e->getStatus(), + $e->getMessage()); + + // Twilio tries to provide a link to more specific details if they can. + if ($e->getInfo()) { + $message .= sprintf(' For more information, see %s.', $e->getInfo()); + } + throw new PhabricatorWorkerPermanentFailureException($message); + } + } + + public function getSMSDataFromResult($result) { + return array($result->sid, $this->getSMSStatus($result->status)); + } + + public function pollSMSSentStatus(PhabricatorSMS $sms) { + $client = $this->buildClient(); + $message = $client->account->messages->get($sms->getProviderSMSID()); + + return $this->getSMSStatus($message->status); + } + + /** + * See https://www.twilio.com/docs/api/rest/sms#sms-status-values. + */ + private function getSMSStatus($twilio_status) { + switch ($twilio_status) { + case 'failed': + $status = PhabricatorSMS::STATUS_FAILED; + break; + case 'sent': + $status = PhabricatorSMS::STATUS_SENT; + break; + case 'sending': + case 'queued': + default: + $status = PhabricatorSMS::STATUS_SENT_UNCONFIRMED; + break; + } + return $status; + } + +} diff --git a/src/infrastructure/sms/management/PhabricatorSMSManagementListOutboundWorkflow.php b/src/infrastructure/sms/management/PhabricatorSMSManagementListOutboundWorkflow.php new file mode 100644 --- /dev/null +++ b/src/infrastructure/sms/management/PhabricatorSMSManagementListOutboundWorkflow.php @@ -0,0 +1,50 @@ +setName('list-outbound') + ->setSynopsis('List outbound sms messages sent by Phabricator.') + ->setExamples( + "**list-outbound**") + ->setArguments( + array( + array( + 'name' => 'limit', + 'param' => 'N', + 'default' => 100, + 'help' => + 'Show a specific number of sms messages (default 100).', + ), + )); + } + + public function execute(PhutilArgumentParser $args) { + $console = PhutilConsole::getConsole(); + $viewer = $this->getViewer(); + + $sms_messages = id(new PhabricatorSMS())->loadAllWhere( + '1 = 1 ORDER BY id DESC LIMIT %d', + $args->getArg('limit')); + + if (!$sms_messages) { + $console->writeErr("%s\n", pht("No sent sms.")); + return 0; + } + + foreach (array_reverse($sms_messages) as $sms) { + $console->writeOut( + "%s\n", + sprintf( + "% 8d %-8s To: %s", + $sms->getID(), + $sms->getSendStatus(), + $sms->getToNumber())); + } + + return 0; + } + +} diff --git a/src/infrastructure/sms/management/PhabricatorSMSManagementSendTestWorkflow.php b/src/infrastructure/sms/management/PhabricatorSMSManagementSendTestWorkflow.php new file mode 100644 --- /dev/null +++ b/src/infrastructure/sms/management/PhabricatorSMSManagementSendTestWorkflow.php @@ -0,0 +1,48 @@ +setName('send-test') + ->setSynopsis( + pht( + 'Simulate sending an sms. This may be useful to test your sms '. + 'configuration, or while developing new sms adapters.')) + ->setExamples( + "**send-test** --to 12345678 --body 'pizza time yet?'") + ->setArguments( + array( + array( + 'name' => 'to', + 'param' => 'number', + 'help' => 'Send sms "To:" the specified number.', + 'repeat' => true, + ), + array( + 'name' => 'body', + 'param' => 'text', + 'help' => 'Send sms with the specified body.', + ), + )); + } + + public function execute(PhutilArgumentParser $args) { + $console = PhutilConsole::getConsole(); + $viewer = $this->getViewer(); + + $tos = $args->getArg('to'); + $body = $args->getArg('body'); + + PhabricatorWorker::setRunAllTasksInProcess(true); + PhabricatorSMSImplementationAdapter::sendSMS($tos, $body); + + $console->writeErr( + "%s\n\n phabricator/ $ ./bin/sms list-outbound \n\n", + pht( + 'Send completed! You can view the list of SMS messages sent by '. + 'running this command:')); + } + +} diff --git a/src/infrastructure/sms/management/PhabricatorSMSManagementShowOutboundWorkflow.php b/src/infrastructure/sms/management/PhabricatorSMSManagementShowOutboundWorkflow.php new file mode 100644 --- /dev/null +++ b/src/infrastructure/sms/management/PhabricatorSMSManagementShowOutboundWorkflow.php @@ -0,0 +1,68 @@ +setName('show-outbound') + ->setSynopsis('Show diagnostic details about outbound sms.') + ->setExamples( + "**show-outbound** --id 1 --id 2") + ->setArguments( + array( + array( + 'name' => 'id', + 'param' => 'id', + 'help' => 'Show details about outbound sms with given ID.', + 'repeat' => true, + ), + )); + } + + public function execute(PhutilArgumentParser $args) { + $console = PhutilConsole::getConsole(); + + $ids = $args->getArg('id'); + if (!$ids) { + throw new PhutilArgumentUsageException( + "Use the '--id' flag to specify one or more sms messages to show."); + } + + $messages = id(new PhabricatorSMS())->loadAllWhere( + 'id IN (%Ld)', + $ids); + + if ($ids) { + $ids = array_fuse($ids); + $missing = array_diff_key($ids, $messages); + if ($missing) { + throw new PhutilArgumentUsageException( + "Some specified sms messages do not exist: ". + implode(', ', array_keys($missing))); + } + } + + $last_key = last_key($messages); + foreach ($messages as $message_key => $message) { + $info = array(); + + $info[] = pht('PROPERTIES'); + $info[] = pht('ID: %d', $message->getID()); + $info[] = pht('Status: %s', $message->getSentStatus()); + $info[] = pht('To: %s', $message->getTo()); + $info[] = pht('From: %s', $message->getFrom()); + + $info[] = null; + $info[] = pht('BODY'); + $info[] = $message->getBody(); + + $console->writeOut('%s', implode("\n", $info)); + + if ($message_key != $last_key) { + $console->writeOut("\n%s\n\n", str_repeat('-', 80)); + } + } + } + +} diff --git a/src/infrastructure/sms/management/PhabricatorSMSManagementWorkflow.php b/src/infrastructure/sms/management/PhabricatorSMSManagementWorkflow.php new file mode 100644 --- /dev/null +++ b/src/infrastructure/sms/management/PhabricatorSMSManagementWorkflow.php @@ -0,0 +1,6 @@ +setBody($body) + ->setSendStatus(PhabricatorSMS::STATUS_UNSENT) + ->setSendCount(0) + ->setProviderShortName('phabricator') + ->setProviderSMSID(Filesystem::readRandomCharacters(40)); + } +} diff --git a/src/infrastructure/sms/storage/PhabricatorSMSDAO.php b/src/infrastructure/sms/storage/PhabricatorSMSDAO.php new file mode 100644 --- /dev/null +++ b/src/infrastructure/sms/storage/PhabricatorSMSDAO.php @@ -0,0 +1,11 @@ +getTaskData(); + + $to_numbers = idx($task_data, 'toNumbers'); + if (!$to_numbers) { + // If we don't have any to numbers, don't send any sms. + return; + } + + foreach ($to_numbers as $number) { + // NOTE: we will set the fromNumber and the proper provider data + // in the `PhabricatorSMSSendWorker`. + $sms = PhabricatorSMS::initializeNewSMS($task_data['body']); + $sms->setToNumber($number); + $sms->save(); + $this->queueTask( + 'PhabricatorSMSSendWorker', + array( + 'smsID' => $sms->getID())); + } + } + +} diff --git a/src/infrastructure/sms/worker/PhabricatorSMSSendWorker.php b/src/infrastructure/sms/worker/PhabricatorSMSSendWorker.php new file mode 100644 --- /dev/null +++ b/src/infrastructure/sms/worker/PhabricatorSMSSendWorker.php @@ -0,0 +1,69 @@ +getTaskData(); + + $sms = id(new PhabricatorSMS()) + ->loadOneWhere('id = %d', $task_data['smsID']); + + if (!$sms) { + throw new PhabricatorWorkerPermanentFailureException( + pht('SMS object was not found.')); + } + + // this has the potential to be updated asynchronously + if ($sms->getSendStatus() == PhabricatorSMS::STATUS_SENT) { + return; + } + + $adapter = PhabricatorEnv::getEnvConfig('sms.default-adapter'); + $adapter = newv($adapter, array()); + $up_to_date_status = $adapter->pollSMSSentStatus($sms); + if ($up_to_date_status) { + $sms->setSendStatus($up_to_date_status); + if ($up_to_date_status == PhabricatorSMS::STATUS_SENT) { + $sms->save(); + return; + } + } + + $from_number = PhabricatorEnv::getEnvConfig('sms.default-sender'); + // always set the from number if we get this far in case of configuration + // changes. + $sms->setFromNumber($from_number); + + $adapter->setTo($sms->getToNumber()); + $adapter->setFrom($sms->getFromNumber()); + $adapter->setBody($sms->getBody()); + // give the provider name the same treatment as phone number + $sms->setProviderShortName($adapter->getProviderShortName()); + + try { + $sms->setSendCount($sms->getSendCount() + 1); + $result = $adapter->send(); + list($sms_id, $sent_status) = $adapter->getSMSDataFromResult($result); + } catch (Exception $e) { + $sms->setSendStatus(PhabricatorSMS::STATUS_FAILED_PERMANENTLY); + $sms->save(); + throw new PhabricatorWorkerPermanentFailureException( + $e->getMessage()); + } + $sms->setProviderSMSID($sms_id); + $sms->setSendStatus($sent_status); + $sms->save(); + } + +} diff --git a/src/infrastructure/sms/worker/PhabricatorSMSWorker.php b/src/infrastructure/sms/worker/PhabricatorSMSWorker.php new file mode 100644 --- /dev/null +++ b/src/infrastructure/sms/worker/PhabricatorSMSWorker.php @@ -0,0 +1,11 @@ + array(), 'db.dashboard' => array(), 'db.system' => array(), + 'db.sms' => array(), '0000.legacy.sql' => array( 'legacy' => 0, ),