. namespace core_sms; use Generator; use stdClass; /** * SMS manager. * * @package core_sms * @copyright 2024 Andrew Lyons * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class manager { /** * Create a new SMS manager. * * @param \moodle_database $db */ public function __construct( /** @var \moodle_database The database instance */ protected readonly \moodle_database $db, ) { } /** * Send an SMS to the given recipient. * * @param string $recipientnumber The phone number to send the SMS to * @param string $content The SMS Content * @param string $component The owning component * @param string $messagetype The message type within the component * @param null|int $recipientuserid The user id of the recipient if one exists * @param bool $issensitive Whether this SMS contains sensitive information * @param bool $async Whether this SMS should be sent asynchronously. Note: sensitive messages cannot be sent async * @param ?int $gatewayid the gateway instance id to send the sms in a specific gateway config * @return message * @throws \coding_exception If a sensitive message is sent asynchronously */ public function send( string $recipientnumber, string $content, string $component, string $messagetype, ?int $recipientuserid, bool $issensitive = false, bool $async = true, ?int $gatewayid = null, ): message { $message = new message( recipientnumber: $recipientnumber, content: $content, component: $component, messagetype: $messagetype, recipientuserid: $recipientuserid, issensitive: $issensitive, ); if ($issensitive && $async) { throw new \coding_exception('Sensitive messages cannot be sent asynchronously'); } return $this->send_message( message: $message, async: $async, gatewayid: $gatewayid, ); } /** * Send a message using the message object. * * @param message $message The message object * @param bool $async Whether this SMS should be sent asynchronously. Note: sensitive messages cannot be sent async * @param ?int $gatewayid the gateway instance id to send the sms in a specific gateway config * @return message the message object after trying to send the message */ public function send_message( message $message, bool $async = true, ?int $gatewayid = null, ): message { if ($async) { $message = $message->with(status: message_status::GATEWAY_QUEUED); $message = $this->save_message($message); \core_sms\task\send_sms_task::queue($message); return $message; } if ($gateway = $this->get_gateway_for_message($message, $gatewayid)) { $modifiedmessage = $message->with( gatewayid: $gateway->id, content: $gateway->truncate_message($message->content), ); $message = $gateway->send($modifiedmessage); } else { $message = $message->with(status: message_status::GATEWAY_NOT_AVAILABLE); } return $this->save_message($message); } /** * Get the gateways that can send the given message. * * @param message $message * @return gateway[] */ public function get_possible_gateways_for_message( message $message, ): array { $gateways = []; foreach ($this->get_enabled_gateway_instances() as $gateway) { if ($gateway->get_send_priority($message)) { $gateways[] = $gateway; } } return $gateways; } /** * Get the gateway that can send the given message. * * @param message $message The message instance * @param ?int $gatewayid the gateway instance id to send the sms in a specific gateway config * @return null|gateway */ public function get_gateway_for_message( message $message, ?int $gatewayid = null, ): ?gateway { if ($gatewayid) { // Check if the gateway id is valid. $gateway = $this->get_gateway_instances(['id' => $gatewayid]); $gateway = $gateway ? reset($gateway) : null; return $gateway; } $gateways = $this->get_possible_gateways_for_message($message); if (!count($gateways)) { return null; } // Sort the gateways by their send priority for this message. usort($gateways, fn($a, $b) => $a->get_send_priority($message) <=> $b->get_send_priority($message)); return array_pop($gateways); } /** * Get a list of all gateway instances. * * @param null|array $filter The database filter to apply * @return array */ public function get_gateway_instances(?array $filter = null): array { return array_filter( array_map( function ($record): ?gateway { if (!class_exists($record->gateway)) { debugging( "Unable to find a gateway class for {$record->gateway}", DEBUG_DEVELOPER, ); return null; } return new $record->gateway( id: $record->id, name: $record->name, enabled: $record->enabled, config: $record->config, ); }, $this->get_gateway_records($filter), ) ); } /** * Get the gateway records according to the filter. * * @param array|null $filter The filterable elements to get the records from * @return array */ public function get_gateway_records(?array $filter = null): array { return $this->db->get_records( table: 'sms_gateways', conditions: $filter, ); } /** * Get a list of all enabled gateway instances. * * @return array */ public function get_enabled_gateway_instances(): array { return $this->get_gateway_instances(['enabled' => 1]); } /** * Save the message to the database. * * @param message $message * @return message */ public function save_message( message $message, ): message { if ($message->issensitive) { // Sensitive messages should not store content. $message = $message->with(content: null); } if ($message->id) { $this->db->update_record('sms_messages', $message->to_record()); return $message; } $id = $this->db->insert_record('sms_messages', $message->to_record()); return $message->with(id: $id); } /** * Enable a gateway. * * @param gateway $gateway * @return gateway */ public function enable_gateway(gateway $gateway): gateway { if (!$gateway->enabled) { $gateway = $gateway->with(enabled: true); $this->db->update_record('sms_gateways', $gateway->to_record()); } return $gateway; } /** * Disable a gateway. * * @param gateway $gateway * @return gateway */ public function disable_gateway(gateway $gateway): gateway { if ($gateway->enabled) { try { $hook = new \core_sms\hook\before_gateway_disabled( gateway: $gateway, ); $hookmanager = \core\di::get(\core\hook\manager::class)->dispatch($hook); if (!$hookmanager->isPropagationStopped()) { $gateway = $gateway->with(enabled: false); $this->db->update_record('sms_gateways', $gateway->to_record()); } } catch (\dml_exception $e) { } } return $gateway; } /** * Delete the gateway instance. * * @param gateway $gateway The gateway instance * @return bool */ public function delete_gateway(gateway $gateway): bool { try { // Dispatch the hook before deleting the record. $hook = new \core_sms\hook\before_gateway_deleted( gateway: $gateway, ); $hookmanager = \core\di::get(\core\hook\manager::class)->dispatch($hook); if ($hookmanager->isPropagationStopped()) { $deleted = false; } else { $deleted = $this->db->delete_records('sms_gateways', ['id' => $gateway->id]); } } catch (\dml_exception $exception) { $deleted = false; } return $deleted; } /** * Create a new gateway instance. * * @param string $classname Classname of the gateway * @param string $name The name of the gateway config * @param bool $enabled If the gateway is enabled or not * @param stdClass|null $config The config json * @return gateway */ public function create_gateway_instance( string $classname, string $name, bool $enabled = false, ?stdClass $config = null, ): gateway { if (!class_exists($classname) || !is_a($classname, gateway::class, true)) { throw new \coding_exception("Gateway class not valid: {$classname}"); } $gateway = new $classname( enabled: $enabled, name: $name, config: $config ? json_encode($config) : '', ); $id = $this->db->insert_record('sms_gateways', $gateway->to_record()); return $gateway->with(id: $id); } /** * Update gateway instance. * * @param gateway $gateway The gateway instance * @param stdClass|null $config the configuration of the gateway instance to be updated * @return gateway */ public function update_gateway_instance( gateway $gateway, ?stdClass $config = null, ): gateway { $gateway = $gateway->with(config: $config, name: $gateway->name); $this->db->update_record('sms_gateways', $gateway->to_record()); return $gateway; } /** * Get all messages. * * @param string $sort * @param null|array $filter * @param int $pagesize * @param int $page * @return Generator */ public function get_messages( string $sort = 'timecreated ASC', ?array $filter = null, int $pagesize = 0, int $page = 0, ): Generator { $rows = $this->db->get_records( table: 'sms_messages', conditions: $filter, limitfrom: $pagesize * $page, limitnum: $pagesize, sort: $sort, ); foreach ($rows as $record) { yield new message( id: $record->id, recipientnumber: $record->recipientnumber, content: $record->content, component: $record->component, messagetype: $record->messagetype, recipientuserid: $record->recipientuserid, issensitive: $record->issensitive, status: message_status::from($record->status), gatewayid: $record->gatewayid, timecreated: $record->timecreated, ); } } /** * Get a message * * @param array $filter * @return message */ public function get_message( array $filter, ): message { $record = $this->db->get_record( table: 'sms_messages', conditions: $filter, ); return new message( id: $record->id, recipientnumber: $record->recipientnumber, content: $record->content, component: $record->component, messagetype: $record->messagetype, recipientuserid: $record->recipientuserid, issensitive: $record->issensitive, status: message_status::from($record->status), gatewayid: $record->gatewayid, timecreated: $record->timecreated, ); } /** * This function internationalises a number to E.164 standard. * https://46elks.com/kb/e164 * * @param string $phonenumber the phone number to format. * @param ?string $countrycode The country code of the phone number. * @return string the formatted phone number. */ public static function format_number( string $phonenumber, ?string $countrycode = null, ): string { // Remove all whitespace, dashes, and brackets in one step. $phonenumber = preg_replace('/[ ()-]/', '', $phonenumber); // Check if the number is already in international format or if it starts with a 0. if (!str_starts_with($phonenumber, '+')) { // Strip leading 0. if (str_starts_with($phonenumber, '0')) { $phonenumber = substr($phonenumber, 1); } // Prepend country code if not already in international format. $phonenumber = !empty($countrycode) ? '+' . $countrycode . $phonenumber : $phonenumber; } return $phonenumber; } }