├── .github └── FUNDING.yml ├── .gitignore ├── Backup.php ├── LICENSE ├── README.md ├── Restore.php ├── Smsconnector.class.php ├── adaptor └── Smsconnector.class.php ├── agi-bin └── sipsmsconn.agi ├── assets ├── README.md ├── js │ └── smsconnector.js └── less │ └── smsconnector.less ├── i18n ├── es_ES │ └── LC_MESSAGES │ │ ├── smsconnector.mo │ │ └── smsconnector.po └── smsconnector.pot ├── module.xml ├── page.smsconnector.php ├── providers ├── Sample_provider-name.php ├── provider-Bandwidth.php ├── provider-Bulkvs.php ├── provider-Commio.php ├── provider-Flowroute.php ├── provider-Signalwire.php ├── provider-Sinch.php ├── provider-Siptrunk.php ├── provider-Skyetel.php ├── provider-Telnyx.php ├── provider-Twilio.php ├── provider-Voipms.php ├── provider-Voxtelesys.php └── providerBase.php ├── public ├── media.php └── provider.php └── views ├── page.main.php ├── rnav.php ├── settings.php ├── view.number.form.php ├── view.number.grid.php └── view.userman.user.php /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | polar: # Replace with a single Polar username 14 | custom: "https://simon.tel/tips" 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | node/logs/install.log 3 | assets/less/cache 4 | .vscode 5 | module.sig 6 | -------------------------------------------------------------------------------- /Backup.php: -------------------------------------------------------------------------------- 1 | addDependency('sms'); 9 | $configs = [ 10 | 'kvstore' => $this->dumpKVStore(), 11 | 'tables' => $this->dumpTables(), 12 | ]; 13 | $this->addConfigs($configs); 14 | } 15 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## SMS Connector 2 | A third-party SMS connector module for FreePBX 16 and 17 3 | 4 | ### Overview 5 | 6 | FreePBX offers SMS and MMS functionality through UCP (User Control Panel) and the Sangoma Connect softphones. 7 | This integrates tightly with Sangoma number services (SIPStation and VoIP Innovations) but until now there have been 8 | no open source modules allowing integration with third-party providers. The aim of this module is to provide 9 | a generic, expandable connector, with new providers added as contributed by the community. 10 | 11 | ### Features 12 | 13 | * Send and receive SMS and MMS through UCP and Sangoma Connect 14 | * Mix-and-match: if you have numbers on multiple providers you are not limited to just one 15 | 16 | ### Providers 17 | 18 | * Bandwidth: Messaging API v2 (https://dev.bandwidth.com/apis/messaging/) (Doesn't currently support status callbacks) 19 | * Bulk Solutions (Bulkvs): API v1 (https://portal.bulkvs.com/api/v1.0/documentation) 20 | * Commio/Thinq: (https://apidocs.thinq.com/#bac2ace6-7777-47d8-931e-495b62f01799) 21 | * Flowroute: Messaging API v2.2, webhook v2.1 (https://developer.flowroute.com/api/messages/v2.2/) 22 | * Sinch: SMS API (https://developers.sinch.com/docs/sms) 23 | * SignalWire: Messaging API (https://developer.signalwire.com/guides/messaging-overview/) 24 | * Siptrunk: Messaging API 25 | * Skyetel: SMS and MMS API (https://support.skyetel.com/hc/en-us/articles/360056299914-SMS-MMS-API) 26 | * Telnyx: Messaging API v2 (https://developers.telnyx.com/docs/api/v2/messaging) 27 | * Twilio: Messaging API version 2010-04-01 (https://www.twilio.com/docs/sms) 28 | * Voip.ms: SMS and MMS API (https://voip.ms/m/apidocs.php) 29 | * Voxtelesys: Messaging API v1 (https://smsapi.voxtelesys.com) 30 | 31 | ### Installation 32 | 33 | * `fwconsole ma downloadinstall https://github.com/simontelephonics/smsconnector/releases/download/v16.0.17.2/smsconnector-16.0.17.2.tar.gz` 34 | * `fwconsole r` 35 | 36 | ### Configuration 37 | 38 | #### General requirements 39 | 40 | For inbound SMS/MMS and outbound MMS to work, you will need an HTTPS path inbound to your PBX from your provider(s). This means: 41 | * Import or generate a TLS certificate in Certificate Manager 42 | * Enable it on the web server using Sysadmin Pro or by manually configuring Apache 43 | * Allow your provider(s) webhook addresses through the FreePBX Firewall and/or your network firewall 44 | * Set the public DNS name in the `AMPWEBADDRESS` setting: Advanced Settings -> FreePBX Web Address 45 | 46 | Sending of SMS/MMS requires verification and registration performed through your provider and is outside of the scope of this 47 | module or document. 48 | 49 | #### Provider Settings 50 | 51 | Once set up with your provider, generate or locate the required credentials, typically an API key and secret, 52 | and enter these into the SMS Connector -> Provider Settings screen. 53 | 54 | In the provider portal, set the webhook URL for inbound SMS/MMS in the format shown on the Provider Settings screen ("Webhook Provider"). 55 | 56 | #### Adding Numbers 57 | 58 | Enter a number (DID), pick the user(s) to which the DID should be assigned, and select the provider for that number. 59 | 60 | ### Possible Improvements 61 | 62 | * Configuration within User Management (dummy screen right now) 63 | * Automatic addition of FreePBX Firewall rules when module is installed and removal when module is uninstalled 64 | -------------------------------------------------------------------------------- /Restore.php: -------------------------------------------------------------------------------- 1 | getConfigs(); 9 | $this->importKVStore($configs['kvstore']); 10 | $this->importTables($configs['tables']); 11 | } 12 | 13 | public function processLegacy($pdo, $data, $tables, $unknownTables) 14 | { 15 | $this->restoreLegacyDatabase($pdo); 16 | } 17 | } -------------------------------------------------------------------------------- /Smsconnector.class.php: -------------------------------------------------------------------------------- 1 | 'smsconnector_relations', 17 | ); 18 | protected $tablesSms = array( 19 | 'routing' => 'sms_routing', 20 | 'dids' => 'sms_dids', 21 | ); 22 | 23 | public function __construct($freepbx = null) 24 | { 25 | if ($freepbx == null) { 26 | throw new \Exception("Not given a FreePBX Object"); 27 | } 28 | $this->FreePBX = $freepbx; 29 | $this->Database = $freepbx->Database; 30 | $this->Userman = $freepbx->Userman; 31 | 32 | $this->loadProviders(); 33 | } 34 | 35 | /** 36 | * smsAdaptor loaded by SMS module hook (loadAdapter) 37 | * 38 | * @param string $adaptor Adaptor name 39 | * @return Smsconnector adaptor object 40 | */ 41 | public function smsAdaptor($adaptor) 42 | { 43 | if(!class_exists('\FreePBX\modules\Sms\Adaptor\Smsconnector')) { 44 | include __DIR__.'/adaptor/Smsconnector.class.php'; 45 | } 46 | return new \FreePBX\modules\Sms\Adaptor\Smsconnector($adaptor); 47 | } 48 | 49 | /** 50 | * Installer run on fwconsole ma install 51 | * 52 | * @return void 53 | */ 54 | public function install() 55 | { 56 | outn(_("Creating link to public folder...")); 57 | $link_public = sprintf("%s/smsconn", $this->FreePBX->Config->get("AMPWEBROOT")); 58 | $link_public_module = sprintf("%s/admin/modules/smsconnector/public", $this->FreePBX->Config->get("AMPWEBROOT")); 59 | if (! file_exists($link_public)) 60 | { 61 | symlink($link_public_module, $link_public); 62 | out(_("Done")); 63 | } 64 | else 65 | { 66 | out(_('Skip: the path already exists!')); 67 | } 68 | // if daemon got installed in 16.0.13, remove it 69 | if (class_exists('FreePBX\modules\Pm2') && $this->FreePBX->Pm2->getStatus("smsconnector-sipsms")) 70 | { 71 | $this->FreePBX->Pm2->delete("smsconnector-sipsms"); 72 | } 73 | } 74 | 75 | /** 76 | * Uninstaller run on fwconsole ma uninstall 77 | * 78 | * @return void 79 | */ 80 | public function uninstall() 81 | { 82 | outn(_("Removing public folder link...")); 83 | $link_public = sprintf("%s/smsconn", $this->FreePBX->Config->get("AMPWEBROOT")); 84 | if(file_exists($link_public)) 85 | { 86 | if(is_link($link_public)) 87 | { 88 | unlink($link_public); 89 | if( ! file_exists($link_public)) 90 | { 91 | out(_("Done")); 92 | } 93 | else 94 | { 95 | out(_("Error: the path still exists!")); 96 | } 97 | } 98 | else 99 | { 100 | out(_("Skip: the path is not a symbolic link!")); 101 | } 102 | } 103 | } 104 | 105 | /** 106 | * Processes form submission and pre-page actions. 107 | * 108 | * @param string $page Display name 109 | * @return void 110 | */ 111 | public function doConfigPageInit($page) 112 | { 113 | /** getReq provided by FreePBX_Helpers see https://wiki.freepbx.org/x/0YGUAQ */ 114 | $action = $this->getReq('action', ''); 115 | $providers = $this->getReq('providers'); 116 | 117 | switch ($action) 118 | { 119 | case 'setproviders': 120 | return $this->updateProviders($providers); 121 | break; 122 | } 123 | } 124 | 125 | public function getRightNav($request) 126 | { 127 | switch($request['view']) 128 | { 129 | case 'settings': 130 | return load_view(dirname(__FILE__).'/views/rnav.php', array()); 131 | break; 132 | default: 133 | //No show Nav 134 | } 135 | } 136 | 137 | /** 138 | * Adds buttons to the bottom of pages per set conditions 139 | * 140 | * @param array $request $_REQUEST 141 | * @return void 142 | */ 143 | public function getActionBar($request) 144 | { 145 | if ('smsconnector' == $request['display']) 146 | { 147 | if (!isset($_GET['view'])) 148 | { 149 | return []; 150 | } 151 | $buttons = [ 152 | 'reset' => [ 153 | 'name' => 'reset', 154 | 'id' => 'reset', 155 | 'value' => _("Reset") 156 | ], 157 | 'submit' => [ 158 | 'name' => 'submit', 159 | 'id' => 'submit', 160 | 'value' => _("Submit") 161 | ] 162 | ]; 163 | return $buttons; 164 | } 165 | } 166 | 167 | /** 168 | * Returns bool permissions for AJAX commands 169 | * https://wiki.freepbx.org/x/XoIzAQ 170 | * @param string $command The ajax command 171 | * @param array $setting ajax settings for this command typically untouched 172 | * @return bool 173 | */ 174 | public function ajaxRequest($req, &$setting) 175 | { 176 | // ** Allow remote consultation with Postman ** 177 | // ******************************************** 178 | // $setting['authenticate'] = false; 179 | // $setting['allowremote'] = true; 180 | // return true; 181 | // ******************************************** 182 | switch($req) 183 | { 184 | case "get_selects": 185 | case "numbers_list": 186 | case "numbers_get": 187 | case "numbers_update": 188 | case "numbers_delete": 189 | return true; 190 | break; 191 | 192 | default: 193 | return false; 194 | } 195 | return false; 196 | } 197 | 198 | /** 199 | * Handle Ajax request 200 | */ 201 | public function ajaxHandler() 202 | { 203 | $command = $this->getReq("command", ""); 204 | $data_return = false; 205 | 206 | switch ($command) 207 | { 208 | case 'get_selects': 209 | $data['users'] = array(); 210 | $data['providers'] = array(); 211 | foreach($this->Userman->getAllUsers() as $user) 212 | { 213 | $data['users'][$user['id']] = empty($user['displayname']) ? $user['username'] : sprintf('%s (%s)', $user['displayname'], $user['username']); 214 | } 215 | foreach ($this->getAvailableProviders() as $provider => $info) 216 | { 217 | $data['providers'][$info['nameraw']] = $info['name']; 218 | } 219 | $data_return = array("status" => true, 'data' => $data); 220 | break; 221 | 222 | case 'numbers_list': 223 | $data_return = $this->getList(); 224 | break; 225 | 226 | case 'numbers_get': 227 | $id = $this->getReq("id", null); 228 | if (empty($id)) 229 | { 230 | $data_return = array("status" => false, "message" => _("ID is missing!")); 231 | } 232 | else if (! $this->isExistDIDByID($id)) 233 | { 234 | $data_return = array("status" => false, "message" => _("ID does not exist!")); 235 | } 236 | else 237 | { 238 | $dataId = $this->getNumber($id); 239 | $data_return = array("status" => true, "data" => $dataId); 240 | } 241 | break; 242 | 243 | case 'numbers_update': 244 | $getdata = $this->getReq("data", array()); 245 | $id = $getdata['id']; 246 | $did = $getdata['didNumber']; 247 | $uids = $getdata['uidsNumber']; 248 | $name = $getdata['providerNumber']; 249 | 250 | $uids = explode(",", $uids); 251 | try 252 | { 253 | if ($getdata['type'] == 'edit') 254 | { 255 | $this->updateNumber($uids, $did, $name); 256 | $data_return = array("status" => true, "message" => _("Number updated successfully")); 257 | } 258 | else 259 | { 260 | $this->addNumber($uids, $did, $name); 261 | $data_return = array("status" => true, "message" => _("Number created successfully")); 262 | } 263 | } 264 | catch (\Exception $e) 265 | { 266 | $data_return = array("status" => false, "message" => $e->getMessage()); 267 | } 268 | 269 | break; 270 | 271 | case 'numbers_delete': 272 | $id = $this->getReq("id", null); 273 | if (empty($id)) 274 | { 275 | $data_return = array("status" => false, "message" => _("ID is missing!")); 276 | } 277 | else if (! $this->isExistDIDByID($id)) 278 | { 279 | $data_return = array("status" => false, "message" => _("ID does not exist!")); 280 | } 281 | else if ($this->deleteNumber($id)) 282 | { 283 | $data_return = array("status" => true, "message" => _("Number delete successfully")); 284 | } 285 | else 286 | { 287 | $data_return = array("status" => false, "message" => _("Number delete failed!")); 288 | } 289 | break; 290 | 291 | default: 292 | $data_return = array("status" => false, "message" => _("Command not found!"), "command" => $command); 293 | } 294 | return $data_return; 295 | } 296 | 297 | /** 298 | * getProviderSettings 299 | * @return array returns an associative array 300 | */ 301 | public function getProviderSettings() 302 | { 303 | return array('providers' => $this->getAll('provider')); 304 | } 305 | 306 | /** 307 | * getAvailableProviders 308 | * @return array list of providers that are configured and available for use 309 | */ 310 | public function getAvailableProviders() 311 | { 312 | $retlist = array(); 313 | $list = $this->getProvider(""); 314 | foreach ($list as $key => $value) 315 | { 316 | if ($value['class']->isAvailable()) 317 | { 318 | $retlist[$key] = $value; 319 | } 320 | } 321 | return $retlist; 322 | } 323 | 324 | /** 325 | * getList gets a list of numbers and their associations 326 | * @return array 327 | */ 328 | public function getList() 329 | { 330 | $sql = sprintf('SELECT r.id, rt.didid, r.providerid AS name, GROUP_CONCAT(DISTINCT rt.uid) AS users, rt.did FROM %s AS rt ' . 331 | 'INNER JOIN %s as r ON rt.didid = r.didid ' . 332 | 'WHERE rt.adaptor = "%s" ' . 333 | 'GROUP BY r.id, rt.didid, r.providerid, rt.did', $this->tablesSms['routing'], $this->tables['relations'], self::adapterName); 334 | 335 | $data = $this->Database->query($sql)->fetchAll(\PDO::FETCH_NAMED); 336 | foreach ($data as $key => &$value) 337 | { 338 | $value['users'] = $this->getInfoUserByID($value['users'], true); 339 | } 340 | return $data; 341 | } 342 | 343 | /** 344 | * getNumber Gets an individual item by smsconnector_relations.ID 345 | * @param int $id Item ID 346 | * @return array Returns an associative array with id, subject and body. 347 | */ 348 | public function getNumber($id) 349 | { 350 | $data_return = null; 351 | if ($this->isExistDIDByID($id)) 352 | { 353 | $sql = sprintf('SELECT r.id, rt.didid, r.providerid AS name, GROUP_CONCAT(DISTINCT rt.uid) AS users, rt.did FROM %s as rt ' . 354 | 'INNER JOIN %s as r ON rt.didid = r.didid ' . 355 | 'WHERE rt.adaptor = "%s" AND rt.didid = :id ' . 356 | 'GROUP BY r.id, rt.didid, r.providerid, rt.did', $this->tablesSms['routing'], $this->tables['relations'], self::adapterName); 357 | $stmt = $this->Database->prepare($sql); 358 | $stmt->bindParam(':id', $id, \PDO::PARAM_INT); 359 | $stmt->execute(); 360 | $row = $stmt->fetchObject(); 361 | $data_return = [ 362 | 'id' => $row->id, 363 | 'didid' => $row->didid, 364 | 'name' => $row->name, 365 | 'users' => $this->getInfoUserByID($row->users, true), 366 | 'did' => $row->did, 367 | ]; 368 | } 369 | return $data_return; 370 | } 371 | 372 | /** 373 | * getInfoUserByID We obtain the information of the users with the userman module. 374 | * @param mixed $uid UID or array of UIDs of the users that we want to obtain information. 375 | * @param bool $needExplode True if we need to exploit the uids, False (default) if nothing needs to be done. 376 | * @param string $explodeChar The character the used for the explode (default ','). 377 | * @param array $info_return The array of the information that we wish to obtain (default userman and displayname). 378 | * @return array Array of the information the users. 379 | */ 380 | private function getInfoUserByID($uid, $needExplode = false, $explodeChar = ",", $info_return = null) 381 | { 382 | if ($needExplode && ! is_array($needExplode)) 383 | { 384 | $uid = explode($explodeChar, $uid); 385 | } 386 | if (! is_array($uid)) 387 | { 388 | $uid = array($uid); 389 | } 390 | 391 | $data_return = array(); 392 | if (is_null($info_return)) 393 | { 394 | $info_return = array('username', 'displayname'); 395 | } 396 | foreach ($uid as $userid) 397 | { 398 | $user_info = $this->Userman->getUserByID($userid); 399 | $data_user = array('uid' => $userid); 400 | 401 | foreach ($info_return as $option) 402 | { 403 | $data_user[$option] = $user_info[$option]; 404 | } 405 | $data_return[$userid] = $data_user; 406 | } 407 | return $data_return; 408 | } 409 | 410 | /* Helper functions */ 411 | public function getUsersByDid($did) 412 | { 413 | return $this->FreePBX->Sms->getAssignedUsers($did); 414 | } 415 | 416 | public function getDidsByUser($uid) 417 | { 418 | return $this->FreePBX->Sms->getDIDs($uid); 419 | } 420 | 421 | public function getUidByDefaultExtension($extension) 422 | { 423 | return $this->Userman->getUserByDefaultExtension($extension)['id']; 424 | } 425 | 426 | public function getSipDefaultDidByUid($uid) 427 | { 428 | return $this->Userman->getModuleSettingByID($uid, 'smsconnector', 'sipsmsdefaultdid', true, false); 429 | } 430 | 431 | /** 432 | * getSIPMessageDeviceByUserID 433 | * @param int $uid UID of the users that we want to obtain information. 434 | * @return mixed extension number or NULL 435 | */ 436 | public function getSIPMessageDeviceByUserID($uid) 437 | { 438 | if ($this->Userman->getModuleSettingByID($uid, 'smsconnector', 'sipsmsenabled', false, false)) 439 | { 440 | $user = $this->Userman->getUserByID($uid); 441 | $extension = $user['default_extension']; 442 | $device = $this->FreePBX->Core->getDevice($extension); 443 | 444 | // only select PJSIP devices that have been set with the appropriate message_context 445 | if ($device['tech'] == 'pjsip' && $device['message_context'] == 'smsconnector-messages') 446 | { 447 | return $extension; 448 | } 449 | } 450 | return NULL; 451 | } 452 | 453 | /** 454 | * processOutboundSip Used by AGI script to send SMS via connector 455 | * @param string $to To (destination) 456 | * @param string $from Extension that is sending the SMS 457 | * @param string $messageBody SMS body 458 | * @return bool 459 | */ 460 | public function processOutboundSip($to, $from, $messageBody) 461 | { 462 | freepbx_log(FPBX_LOG_INFO, sprintf(_("Processing SIP SMS from %s to %s"), $from, $to), true); 463 | $did = $actualTo = NULL; 464 | $allowedToSend = false; 465 | $retval = true; 466 | if (preg_match('/^\+?(\d+)\+\+?(\d+)$/', $to, $matches)) 467 | { 468 | $did = $matches[1]; 469 | $actualTo = $matches[2]; 470 | } 471 | elseif (preg_match('/^\+?(\d+)$/', $to, $matches)) 472 | { 473 | $actualTo = $matches[1]; 474 | } 475 | 476 | $uid = $this->getUidByDefaultExtension($from); 477 | if ($this->Userman->getModuleSettingByID($uid, 'smsconnector', 'sipsmsenabled', false, false)) 478 | { 479 | if ($did) 480 | { 481 | if (in_array($did, $this->getDidsByUser($uid))) { $allowedToSend = true; } 482 | } 483 | else 484 | { 485 | if ($defaultDid = $this->getSipDefaultDidByUid($uid)) 486 | { 487 | $did = $defaultDid; 488 | $allowedToSend = true; 489 | } 490 | } 491 | } 492 | 493 | if ($allowedToSend && $actualTo) 494 | { 495 | $formattedTo = (preg_match('/^[2-9][0-9]{2}[2-9][0-9]{6}$/', $actualTo)) ? '1'.$actualTo : $actualTo; // format NANP 496 | 497 | $adaptor = $this->FreePBX->Sms->getAdaptor($did); 498 | if(is_object($adaptor)) { 499 | $o = $adaptor->sendMessage($formattedTo, $did, '', $messageBody); 500 | if(! $o['status']) { 501 | freepbx_log(FPBX_LOG_INFO, sprintf(_("Outbound message failed: %s"), $o['message']), true); 502 | $retval = false; 503 | } 504 | } else { 505 | freepbx_log(FPBX_LOG_INFO, sprintf(_("No adaptor found for DID %s"), $did), true); 506 | $retval = false; 507 | } 508 | } 509 | else 510 | { 511 | freepbx_log(FPBX_LOG_INFO, sprintf(_("%s tried to send SMS from %s but is not allowed!"), $from, $did), true); 512 | $retval = false; 513 | } 514 | 515 | return $retval; 516 | } 517 | 518 | /** 519 | * inboundHookForSip Hooked by AdaptorBase getMessage on inbound SMS. Relays SMS to SIP devices if enabled. 520 | * @param string $to To (destination) 521 | * @param string $from caller ID 522 | * @param string $message SMS body 523 | */ 524 | public function inboundHookForSip($id, $to, $from, $cnam, $message, $time, $adaptor, $emid, $threadid, $didid) 525 | { 526 | // lookup users for DID 527 | $uids = $this->getUsersByDid($to); 528 | 529 | foreach ($uids as $uid) 530 | { 531 | // get device that can receive SMS 532 | $device = $this->getSIPMessageDeviceByUserID($uid); 533 | 534 | if ($device) 535 | { 536 | if ($to != $this->getSipDefaultDidByUid($uid)) 537 | { 538 | // format the caller ID to include the DID, which will allow replying via non-default DID 539 | $sipFrom = sprintf("%s+%s", $to, $from); 540 | } 541 | else 542 | { 543 | $sipFrom = $from; 544 | } 545 | 546 | // get contacts 547 | $result = $this->FreePBX->astman->send_request('Getvar', array('Variable' => "PJSIP_DIAL_CONTACTS($device)")); 548 | $contacts = array(); 549 | if (!empty($result['Value'])) 550 | { 551 | $contacts = explode('&', $result['Value']); 552 | } 553 | 554 | if (!empty($contacts)) 555 | { 556 | foreach ($contacts as $contact) // message all registered 557 | { 558 | $sipTo = sprintf("pjsip:%s", substr($contact, 6)); // replace "PJSIP/" with "pjsip:" 559 | $result = $this->FreePBX->astman->MessageSend($sipTo, $sipFrom, $message); 560 | if ($result['Response'] == 'Error') 561 | { 562 | freepbx_log(FPBX_LOG_INFO, sprintf(_("Error sending message to %s: %s"), $contact, $result['Message'])); 563 | } 564 | } 565 | } 566 | else // no contacts registered - send email if enabled 567 | { 568 | if ($this->Userman->getModuleSettingByID($uid, 'smsconnector', 'sipsmsemailoffline', false, false)) { 569 | // if no email address defined for user, does nothing 570 | //TODO: Generate body from template. 571 | $body = sprintf(_("While offline, you received an SMS from %s to %s:\n\n%s"), $from, $to, $message); 572 | //TODO: Allow setting the subject message 573 | $subject = _('SMS received while offline'); 574 | $this->Userman->sendEmail($uid, $subject, $body); 575 | } 576 | } 577 | } 578 | } 579 | } 580 | 581 | /** 582 | * addNumber Add a number 583 | * @param int $uid userman user id 584 | * @param string $did DID 585 | * @param string $name name of the SMS provider 586 | */ 587 | public function addNumber($uid, $did, $name, $checkExists = true) 588 | { 589 | if (! is_array($uid)) 590 | { 591 | $uid = array($uid); 592 | } 593 | 594 | if (preg_match('/^[2-9]\d{2}[2-9]\d{6}$/', $did)) // ten digit NANP, make it 11-digit 595 | { 596 | $did = '1'.$did; 597 | } 598 | 599 | if ( ($name == "") || ($did == "") || (empty($uid))) 600 | { 601 | throw new \Exception(_('Necessary data is missing!')); 602 | } 603 | else if (($checkExists == true) && ($this->isExistDID($did))) 604 | { 605 | throw new \Exception(_('The DID already exists!')); 606 | } 607 | 608 | $this->FreePBX->Sms->addDIDRouting($did, $uid, self::adapterName); 609 | 610 | $sql = sprintf("SELECT id FROM %s WHERE did = :did", $this->tablesSms['dids']); 611 | $sth = $this->Database->prepare($sql); 612 | $sth->execute(array(':did' => $did)); 613 | $didid = $sth->fetchColumn(); 614 | 615 | if (! empty($didid)) 616 | { 617 | $sql = sprintf('INSERT INTO %s (didid, providerid) VALUES (:didid, :provider) ON DUPLICATE KEY UPDATE providerid = :provider', $this->tables['relations']); 618 | $stmt = $this->Database->prepare($sql); 619 | $stmt->bindParam(':didid', $didid, \PDO::PARAM_INT); 620 | $stmt->bindParam(':provider', $name, \PDO::PARAM_STR); 621 | $stmt->execute(); 622 | return true; 623 | } 624 | 625 | return false; 626 | } 627 | 628 | /** 629 | * updateNumber Updates the given ID 630 | * @param int $uid userman user ID 631 | * @param string $did DID 632 | * @param string $name provider name 633 | * @return bool Returns true on success or false on failure 634 | */ 635 | public function updateNumber($uid, $did, $name) 636 | { 637 | return $this->addNumber($uid, $did, $name, false); 638 | } 639 | 640 | /** 641 | * deleteNumber Deletes the given number by smsconnector_relations.didid 642 | * @param int $id ID 643 | * @return bool Returns true on success or false on failure 644 | */ 645 | public function deleteNumber($id) 646 | { 647 | if ($this->isExistDIDByID($id)) 648 | { 649 | $sql = sprintf('DELETE FROM %s WHERE didid = :id', $this->tables['relations']); 650 | $stmt = $this->Database->prepare($sql); 651 | $stmt->bindParam(':id', $id, \PDO::PARAM_INT); 652 | $stmt->execute(); 653 | 654 | $sql = sprintf('DELETE FROM %s WHERE didid = :id', $this->tablesSms['routing']); 655 | $stmt = $this->Database->prepare($sql); 656 | $stmt->bindParam(':id', $id, \PDO::PARAM_INT); 657 | $stmt->execute(); 658 | 659 | return true; 660 | } 661 | return false; 662 | } 663 | 664 | /** 665 | * isExistDIDByID Check if the ID exists using the ID or DIDID (table relations) 666 | * @param int $id ID 667 | * @param bool $useddidid True used column DIDID, False used column ID (defualt DIDID). 668 | * @return bool Returns true on existe of false if is not exist or not definded id. 669 | */ 670 | public function isExistDIDByID($id, $useddidid = true) 671 | { 672 | if (trim($id) != "") 673 | { 674 | $sql = sprintf('SELECT COUNT(*) FROM %s WHERE %s = :id', $this->tables['relations'], ($useddidid ? 'didid' : 'id')); 675 | $stmt = $this->Database->prepare($sql); 676 | $stmt->bindParam(':id', $id, \PDO::PARAM_INT); 677 | $stmt->execute(); 678 | if ($stmt->fetchColumn() > 0) 679 | { 680 | return true; 681 | } 682 | } 683 | return false; 684 | } 685 | 686 | /** 687 | * isExistDID Check if the Did exist 688 | * @param string $did Number DID 689 | * @return bool Returns true on exist and False if not exist. 690 | */ 691 | public function isExistDID($did) 692 | { 693 | $data_raturn = false; 694 | if (trim($did) != "") 695 | { 696 | $sql = sprintf('SELECT COUNT(*) FROM %s as r INNER JOIN %s AS d ON r.didid = d.id WHERE d.did = :did', $this->tables['relations'], $this->tablesSms['dids']); 697 | $stmt = $this->Database->prepare($sql); 698 | $stmt->bindParam(':did', $did, \PDO::PARAM_STR); 699 | $stmt->execute(); 700 | if ($stmt->fetchColumn() > 0) 701 | { 702 | $data_raturn = true; 703 | } 704 | } 705 | return $data_raturn; 706 | } 707 | 708 | /** 709 | * updateProviders 710 | * @param array hash of provider settings from form 711 | * @return bool success or failure 712 | */ 713 | public function updateProviders($providers) 714 | { 715 | foreach ($providers as $provider => $creds) 716 | { 717 | $this->setProviderConfig($provider, $creds); 718 | } 719 | return true; 720 | } 721 | 722 | /** 723 | * getUsersWithDids 724 | * @return array of user IDs associated with SMS DIDs 725 | */ 726 | public function getUsersWithDids() 727 | { 728 | $sql = sprintf('SELECT DISTINCT uid FROM %s', $this->tablesSms['routing']); 729 | return $this->Database->query($sql)->fetchAll(\PDO::FETCH_COLUMN, 0); 730 | } 731 | 732 | /** 733 | * This returns html to the main page 734 | * 735 | * @return string html 736 | */ 737 | public function showPage($page, $params = array()) 738 | { 739 | $request = $_REQUEST; 740 | $data = array( 741 | "smsconnector" => $this, 742 | 'request' => $request, 743 | 'page' => $page, 744 | ); 745 | $data = array_merge($data, $params); 746 | 747 | switch ($page) 748 | { 749 | case 'main': 750 | $data_return = load_view(__DIR__ . '/views/page.main.php', $data); 751 | break; 752 | 753 | case 'grid': 754 | $data_return = load_view(__DIR__ . '/views/view.number.grid.php', $data); 755 | $data_return .= load_view(__DIR__ . '/views/view.number.form.php', $data); 756 | break; 757 | 758 | case 'settings': 759 | foreach ($this->listProviders() as $provider) 760 | { 761 | $data['settings'][$provider]['info'] = $this->getProvider($provider); 762 | // unset($data['settings'][$provider]['info']['class']); 763 | $data['settings'][$provider]['value'] = $this->getProviderConfig($provider); 764 | } 765 | $data_return = load_view(__DIR__ . '/views/settings.php', $data); 766 | break; 767 | 768 | case 'userman': 769 | $data['userman'] =& $this->Userman; 770 | $data_return = load_view(__DIR__ . '/views/view.userman.user.php', $data); 771 | break; 772 | 773 | default: 774 | $data_return = sprintf(_("Page Not Found (%s)!!!!"), $page); 775 | } 776 | return $data_return; 777 | } 778 | 779 | public function usermanShowPage() 780 | { 781 | $request = $_REQUEST; 782 | if(isset($request['action'])) 783 | { 784 | switch($request['action']) 785 | { 786 | case 'adduser': 787 | case 'showuser': 788 | $sipSmsEnabled = null; 789 | $defaultDid = null; 790 | $emailOffline = null; 791 | 792 | if(isset($request['user'])) 793 | { 794 | $user = $this->Userman->getUserByID($request['user']); 795 | $sipSmsEnabled = $this->Userman->getModuleSettingByID($user['id'],'smsconnector','sipsmsenabled',true); 796 | $defaultDid = $this->Userman->getModuleSettingByID($user['id'],'smsconnector','sipsmsdefaultdid',true); 797 | $emailOffline = $this->Userman->getModuleSettingById($user['id'],'smsconnector','sipsmsemailoffline',true); 798 | } 799 | return array( 800 | array( 801 | 'title' => _('SMS Connector'), 802 | 'rawname' => 'smsconnector', 803 | 'content' => $this->showPage('userman', array( 804 | 'error' => empty($error)?'':$error, 805 | 'sipsmsenabled' => $sipSmsEnabled, 806 | 'sipsmsdefaultdid' => $defaultDid, 807 | 'sipsmsemailoffline' => $emailOffline, 808 | 'dids' => !empty($request['user']) ? $this->getDidsByUser($request['user']) : array() 809 | )), 810 | ) 811 | ); 812 | break; 813 | } 814 | } 815 | } 816 | 817 | public function usermanAddUser($id, $display, $data) 818 | { 819 | $this->usermanUpdateUser($id, $display, $data); 820 | } 821 | 822 | public function usermanUpdateUser($id, $display, $data) 823 | { 824 | $post = $_POST; 825 | if($display == 'userman' && isset($post['type']) && $post['type'] == 'user') 826 | { 827 | if(isset($post['sipsmsenabled'])) 828 | { 829 | if($post['sipsmsenabled'] == "true") 830 | { 831 | $this->Userman->setModuleSettingByID($id, 'smsconnector', 'sipsmsenabled', true); 832 | $this->Userman->setModuleSettingByID($id, 'smsconnector', 'sipsmsdefaultdid', !empty($post['sipsmsdefaultdid']) ? $post['sipsmsdefaultdid'] : null); 833 | if (!empty($post['sipsmsemailoffline'])) 834 | { 835 | $this->Userman->setModuleSettingByID($id, 'smsconnector', 'sipsmsemailoffline', ($post['sipsmsemailoffline'] == "true") ? true : false); 836 | } 837 | else 838 | { 839 | $this->Userman->setModuleSettingByID($id, 'smsconnector', 'sipsmsemailoffline', null); 840 | } 841 | } 842 | elseif($post['sipsmsenabled'] == "false") 843 | { 844 | $this->Userman->setModuleSettingByID($id,'smsconnector','sipsmsenabled',false); 845 | } 846 | else 847 | { 848 | $this->Userman->setModuleSettingByID($id, 'smsconnector', 'sipsmsenabled', null); 849 | $this->Userman->setModuleSettingByID($id, 'smsconnector', 'sipsmsdefaultdid', null); 850 | $this->Userman->setModuleSettingByID($id, 'smsconnector', 'sipsmsemailoffline', null); 851 | } 852 | } 853 | } 854 | } 855 | 856 | public function usermanDelUser($id, $display, $data) { 857 | freepbx_log(FPBX_LOG_INFO, sprintf(_("SMS Connector received delete request for user id %s ; removing all DID assignments"), $id)); 858 | $this->FreePBX->Sms->addUserRouting($id,array(),'Smsconnector'); // "add" with empty array is delete 859 | } 860 | 861 | private function loadProviders() 862 | { 863 | include_once dirname(__FILE__) . "/providers/providerBase.php"; 864 | $this->providers = array(); 865 | foreach (glob(dirname(__FILE__) . "/providers/provider-*.php") as $filename) 866 | { 867 | if (file_exists($filename)) 868 | { 869 | include_once $filename; 870 | 871 | preg_match('/provider-(.*)\.php/i', $filename, $matches); 872 | $this_provider_name = $matches[1]; 873 | $this_provider_name_full = sprintf("FreePBX\modules\Smsconnector\Provider\%s", $this_provider_name); 874 | $this_provider_name_lower = strtolower($this_provider_name); 875 | 876 | if(class_exists($this_provider_name_full)) 877 | { 878 | $this_provider_class = new $this_provider_name_full(); 879 | 880 | $this->providers[$this_provider_name_lower]['name'] = $this_provider_class->getName(); 881 | $this->providers[$this_provider_name_lower]['nameraw'] = $this_provider_class->getNameRaw(); 882 | $this->providers[$this_provider_name_lower]['configs'] = $this_provider_class->getConfigInfo(); 883 | $this->providers[$this_provider_name_lower]['webhook'] = $this_provider_class->getWebHookUrl(); 884 | $this->providers[$this_provider_name_lower]['class_full'] = $this_provider_name_full; 885 | $this->providers[$this_provider_name_lower]['class_name'] = $this_provider_name; 886 | $this->providers[$this_provider_name_lower]['class'] = $this_provider_class; 887 | } 888 | } 889 | } 890 | } 891 | 892 | public function listProviders() 893 | { 894 | return array_keys($this->providers); 895 | } 896 | 897 | public function getProvider($name) 898 | { 899 | $return_data = array(); 900 | if (empty($name)) 901 | { 902 | $return_data = $this->providers; 903 | } 904 | else 905 | { 906 | if (array_key_exists($name, $this->providers)) 907 | { 908 | $return_data = $this->providers[$name]; 909 | } 910 | } 911 | return $return_data; 912 | } 913 | 914 | public function getProviderConfigDefault($name) 915 | { 916 | $data_return = array(); 917 | $info = $this->getProvider($name); 918 | foreach ($info['configs'] as $config => $options) 919 | { 920 | $data_return[$config] = isset($options['default']) ? $options['default'] : ''; 921 | } 922 | return $data_return; 923 | } 924 | 925 | public function getProviderConfig($name) 926 | { 927 | $data_return = array(); 928 | 929 | $default = $this->getProviderConfigDefault($name); 930 | $setting = $this->getConfig($name, 'provider'); 931 | 932 | foreach ($default as $option => $value) 933 | { 934 | $data_return[$option] = isset($setting[$option]) ? $setting[$option] : $value; 935 | } 936 | return $data_return; 937 | } 938 | 939 | public function setProviderConfig($name, $config) 940 | { 941 | $this->setConfig($name, $config, 'provider'); 942 | } 943 | 944 | public function myDialplanHooks() 945 | { 946 | return true; 947 | } 948 | 949 | public function doDialplanHook(&$ext, $engine, $priority) 950 | { 951 | foreach ($this->getUsersWithDids() as $uid) 952 | { 953 | if ($this->Userman->getModuleSettingByID($uid, 'smsconnector', 'sipsmsenabled', false, false)) 954 | { 955 | $user = $this->Userman->getUserByID($uid); 956 | $extension = $user['default_extension']; 957 | $device = $this->FreePBX->Core->getDevice($extension); 958 | if ($device['tech'] == 'pjsip') 959 | { 960 | $de = $this->kvArrayifyDeviceValues($device); 961 | $de['message_context']['value'] = 'smsconnector-messages'; 962 | $this->FreePBX->Core->delDevice($extension, true); 963 | $this->FreePBX->Core->addDevice($extension, 'pjsip', $de, true); 964 | } 965 | } 966 | } 967 | } 968 | 969 | private function kvArrayifyDeviceValues($values) { // stolen verbatim from Core module 970 | $response = array(); 971 | $flag = 2; 972 | $ignoreTheseKeys = array('id', 'tech'); 973 | foreach($values as $key => $value) { 974 | if (in_array($key, $ignoreTheseKeys)) { 975 | continue; 976 | } 977 | 978 | $response[$key] = array( 979 | 'value' => $value, 980 | 'flag' => $flag++ 981 | ); 982 | } 983 | return $response; 984 | } 985 | } 986 | -------------------------------------------------------------------------------- /adaptor/Smsconnector.class.php: -------------------------------------------------------------------------------- 1 | getmessage()); 28 | } 29 | 30 | // Look up provider info containing name and api credentials 31 | $provider = $this->lookUpProvider($from); 32 | $providerInfo = $this->FreePBX->Smsconnector->getProvider($provider); 33 | if (!empty($providerInfo)) 34 | { 35 | if (! empty($providerInfo['class'])) 36 | { 37 | $providerInfo['class']->sendMedia($retval['id'], $to, $from, $message); 38 | } 39 | } 40 | $retval['emid'] = $emid; // TODO: set this from API call result 41 | return $retval; 42 | } 43 | 44 | public function sendMessage($to,$from,$cnam,$message,$time=null,$adaptor=null,$emid=null,$chatId='') 45 | { 46 | $message ??= ''; // string is expected, so don't allow a null 47 | 48 | // Clean up 'to' number if we got one with a +. Only seems to apply to mobile app. UCP 49 | // and desktop apps format number before invoking this module. 50 | $to = ltrim($to, '+'); 51 | 52 | if (preg_match('/^[2-9][0-9]{2}[2-9][0-9]{6}$/', $to)) // rewrite NANP for mobile app 53 | { 54 | $to = sprintf('1%s', $to); 55 | } 56 | 57 | // Store in database 58 | $retval = array(); 59 | try 60 | { 61 | $retval['id'] = parent::sendMessage($to, $from, $cnam, $message, $time, 'Smsconnector', $emid, $chatId); 62 | $retval['status'] = true; 63 | } 64 | catch (\Exception $e) 65 | { 66 | throw new \Exception('Unable to store message: '.$e->getMessage()); 67 | } 68 | 69 | // look up provider info containing name and api credentials 70 | $provider = $this->lookUpProvider($from); 71 | 72 | $providerInfo = $this->FreePBX->Smsconnector->getProvider($provider); 73 | if (!empty($providerInfo)) 74 | { 75 | if (! empty($providerInfo['class'])) 76 | { 77 | $providerInfo['class']->sendMessage($retval['id'], $to, $from, $message); 78 | } 79 | } 80 | $retval['emid'] = $emid; // TODO: set this from API call result 81 | return $retval; 82 | } 83 | 84 | public function getMessage($to,$from,$cnam,$message,$time=null,$adaptor=null,$emid=null) 85 | { 86 | return parent::getMessage($to, $from, $cnam, $message, $time, 'Smsconnector', $emid); 87 | } 88 | 89 | public function updateMessageByEMID($emid,$message,$adaptor=null) 90 | { 91 | return parent::updateMessageByEMID($emid, $message, 'Smsconnector'); 92 | } 93 | 94 | /** 95 | * Looks up provider info given DID 96 | * 97 | * @param string $did 98 | * @return array hash containing provider details 99 | */ 100 | private function lookUpProvider($did) 101 | { 102 | $sql = 'SELECT providerid FROM smsconnector_relations as r ' . 103 | 'INNER JOIN sms_dids AS d ON r.didid = d.id ' . 104 | 'WHERE d.did = :did'; 105 | 106 | $stmt = $this->db->prepare($sql); 107 | $stmt->bindParam(':did', $did, \PDO::PARAM_STR); 108 | $stmt->execute(); 109 | $row = $stmt->fetchObject(); 110 | 111 | return $row->providerid; 112 | } 113 | 114 | public function dialPlanHooks(&$ext, $engine, $priority): void 115 | { 116 | if ($engine != "asterisk") { return; } 117 | $section = 'smsconnector-messages'; 118 | $p = '_X.'; 119 | $ext->add($section, $p, '', new \ext_NoOp('Processing outbound SIP SMS')); 120 | $ext->add($section, $p, '', new \ext_AGI('sipsmsconn.agi')); 121 | $ext->add($section, $p, '', new \ext_Hangup); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /agi-bin/sipsmsconn.agi: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | true); 6 | require_once '/etc/freepbx.conf'; 7 | 8 | $smsconnector = \FreePBX::create()->Smsconnector(); 9 | require_once 'phpagi.php'; 10 | 11 | $AGI = new AGI(); 12 | $messageTo = get_var($AGI, "MESSAGE(to)"); 13 | $messageFrom = get_var($AGI, "MESSAGE(from)"); 14 | $messageBody = get_var($AGI, "MESSAGE(body)"); 15 | 16 | $matches = array(); 17 | if (preg_match('/:(.*)@/', $messageFrom, $matches)) 18 | { 19 | $from = $matches[1]; 20 | } 21 | else return; 22 | 23 | if (preg_match('/:(.*)@/', $messageTo, $matches)) 24 | { 25 | $to = $matches[1]; 26 | } 27 | else return; 28 | 29 | $AGI->verbose(sprintf(_("SMS SIP connector got message from %s to %s"), $from, $to), 3); 30 | $success = $smsconnector->processOutboundSip($to, $from, $messageBody); 31 | if (!$success) 32 | { 33 | $AGI->verbose(_("SMS SIP connector failed to send message. Check freepbx.log for details"), 3); 34 | } 35 | 36 | exit; 37 | 38 | // helper functions 39 | function get_var($agi, $value) 40 | { 41 | $r = $agi->get_variable($value); 42 | 43 | if ($r['result'] == 1) 44 | { 45 | $result = $r['data']; 46 | return $result; 47 | } 48 | else return ''; 49 | } 50 | 51 | -------------------------------------------------------------------------------- /assets/README.md: -------------------------------------------------------------------------------- 1 | assets/css 2 | =========== 3 | 4 | This is for module specific styling 5 | 6 | assets/js 7 | ========== 8 | This is for module specific Java script. 9 | 10 | 11 | Javascript Libraries 12 | ==================== 13 | 14 | The following libraries are built in already and can be used without inclusion. 15 | 16 | 17 | - jquery-1.6.2.min.js 18 | - jquery-1.7.1.min.js 19 | - jquery-ui-1.8.16.min.js 20 | - jquery-ui-1.8.9.min.js 21 | - jquery.cookie.js 22 | - jquery.hotkeys.js 23 | - jquery.toggleval.3.0.js 24 | -------------------------------------------------------------------------------- /assets/js/smsconnector.js: -------------------------------------------------------------------------------- 1 | //This format's the action column 2 | function linkFormat(value, row, idx){ 3 | var html = ''; 4 | html += ' '; 5 | html += ''; 6 | return html; 7 | } 8 | 9 | function userFormat(value, row, idx) 10 | { 11 | var html = ''; 12 | $.each(value, function(uid, user_info) 13 | { 14 | html += '' 15 | html += (user_info['displayname'] == "") ? user_info['username'] : sprintf("%s (%s)", user_info['username'], user_info['displayname']); 16 | html += ''; 17 | }); 18 | return html; 19 | } 20 | 21 | $(document).ready(function() { 22 | $(".webhook-copy").click(function() { 23 | var input = $(this).siblings("input"); 24 | input.select(); 25 | document.execCommand("copy"); 26 | window.getSelection().removeAllRanges(); 27 | fpbxToast(_("URL copied to clipboard"), '', 'success' ); 28 | }); 29 | }); 30 | 31 | 32 | function updateInputSelects() 33 | { 34 | var status_return = true; 35 | 36 | $('#uidsNumber').empty(); 37 | $('#available_users').empty(); 38 | $('#selected_users').empty(); 39 | 40 | $('#providerNumber').empty(); 41 | 42 | $.ajax({ 43 | type: "POST", 44 | url: window.FreePBX.ajaxurl, 45 | data: { 46 | module : 'smsconnector', 47 | command : 'get_selects', 48 | }, 49 | async: false, 50 | success: function(response) 51 | { 52 | if (response.status) 53 | { 54 | $.each(response.data.users, function(user_id, user_display) { 55 | $("#available_users").append(sprintf('
  • %s
  • ',user_id, user_display)); 56 | }); 57 | $.each(response.data.providers, function(provider_id, provider_display) { 58 | $('#providerNumber').append($('