├── .gitignore ├── .gitmodules ├── LICENSE ├── README.md ├── factory.class.php ├── newsyslog.conf.sample ├── options.ini.sample ├── queuemanager.class.php ├── queuemodel.class.php ├── smsreceiver.class.php ├── smssender.class.php └── start.php /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.bz2 3 | options.ini 4 | newsyslog.conf 5 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "smpp"] 2 | path = smpp 3 | url = git://github.com/onlinecity/php-smpp.git 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011 OnlineCity 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Multi process PHP-based workers for SMPP 2 | ============= 3 | 4 | Requirements 5 | ----- 6 | * PHP 5.3+ 7 | * [redis 2.1.7+](http://redis.io/) 8 | * [phpredis](https://github.com/nicolasff/phpredis) - tested with: nicolasff/phpredis@1d6133d4cfc71c555ab4b8551d2818925f7cb444 must support [brpoplpush](http://redis.io/commands/brpoplpush) 9 | * [igbinary](https://github.com/igbinary/igbinary) (optional) 10 | * php extensions 11 | * [posix](http://dk.php.net/manual/en/book.posix.php) 12 | * [pcntl](http://dk.php.net/manual/en/book.pcntl.php) 13 | * [sockets](http://dk.php.net/manual/en/book.sockets.php) 14 | * [pcre](http://dk.php.net/manual/en/book.pcre.php) 15 | * [mbstring](http://dk.php.net/manual/en/ref.mbstring.php) 16 | 17 | Submodule 18 | ----- 19 | This project use the following submodule [onlinecity/php-smpp](https://github.com/onlinecity/php-smpp/). 20 | 21 | So remember to initialize it when you checkout this project: 22 | ``` 23 | git submodule init && git submodule update 24 | ``` 25 | 26 | Simple test usage (send 10x100 messages) 27 | ----- 28 | Run start.php to startup all processes, then inject messages into queue with script below. 29 | 30 | ``` php 31 | produce($m); 47 | ``` 48 | 49 | Configure 50 | ----- 51 | You'll find all configurable options in the options.ini file. 52 | -------------------------------------------------------------------------------- /factory.class.php: -------------------------------------------------------------------------------- 1 | senderPids = array(); 32 | } 33 | 34 | /** 35 | * Start all workers. 36 | * 37 | */ 38 | public function startAll() 39 | { 40 | $this->options = parse_ini_file(self::$optionsFile,true); 41 | if ($this->options === false) throw new InvalidArgumentException('Invalid options ini file, can not start'); 42 | 43 | $this->numSenders = $this->options['factory']['senders']; 44 | 45 | $this->debug("Factory started with pid: ".getmypid()); 46 | file_put_contents(self::$pidFile, getmypid()); 47 | 48 | $this->fork(); 49 | } 50 | 51 | /** 52 | * Shorthand method for calling debug handler 53 | * @param string $s 54 | */ 55 | private function debug($s) 56 | { 57 | call_user_func($this->options['general']['debug_handler'], $s); 58 | } 59 | 60 | private function fork() 61 | { 62 | $signalInstalled = false; 63 | 64 | 65 | for($i=0;$i<($this->numSenders+2);$i++) { 66 | switch ($pid = pcntl_fork()) { 67 | case -1: // @fail 68 | die('Fork failed'); 69 | break; 70 | case 0: // @child 71 | if (!isset($this->queueManagerPid)) { 72 | $worker = new QueueManager($this->options); 73 | } else if (!isset($this->receiverPid)) { 74 | $worker = new SmsReceiver($this->options); 75 | } else { 76 | $worker = new SmsSender($this->options); 77 | } 78 | $this->debug("Constructed: ".get_class($worker)." with pid: ".getmypid()); 79 | $worker->run(); 80 | break; 81 | default: // @parent 82 | if (!isset($this->queueManagerPid)) { 83 | $this->queueManagerPid = $pid; 84 | } else if (!isset($this->receiverPid)) { 85 | $this->receiverPid = $pid; 86 | } else { 87 | $this->senderPids[$pid] = $pid; 88 | } 89 | 90 | if ($i<($this->numSenders+1)) continue; // fork more 91 | 92 | if (!$signalInstalled) { 93 | // for pcntl_sigwaitinfo to work we must install dummy handlers 94 | pcntl_signal(SIGTERM, function($sig) {}); 95 | pcntl_signal(SIGCHLD, function($sig) {}); 96 | $signalInstalled = true; 97 | } 98 | 99 | // All children are spawned, wait for something to happen, and respawn if it does 100 | 101 | $info = array(); 102 | pcntl_sigwaitinfo(array(SIGTERM,SIGCHLD), $info); 103 | 104 | if ($info['signo'] == SIGTERM) { 105 | $this->debug('Factory terminating'); 106 | foreach ($this->senderPids as $child) { 107 | posix_kill($child,SIGTERM); 108 | } 109 | posix_kill($this->receiverPid,SIGTERM); 110 | posix_kill($this->queueManagerPid,SIGTERM); 111 | $res = pcntl_signal_dispatch(); 112 | exit(); 113 | } else { // Something happend to our child 114 | $exitedPid = $info['pid']; 115 | $status = $info['status']; 116 | 117 | // What happened to our child? 118 | if (pcntl_wifsignaled($status)) { 119 | $what = 'was signaled'; 120 | } else if (pcntl_wifexited($status)) { 121 | $what = 'has exited'; 122 | } else { 123 | $what = 'returned for some reason'; 124 | } 125 | $this->debug("Pid: $exitedPid $what"); 126 | 127 | // Sleep one second to prevent aggressive respawns and to allow signal processing to complete 128 | $this->debug("One second respawn timeout..."); 129 | sleep(1); 130 | 131 | // Reap all children (otherwise we get zombies) 132 | do { 133 | $rpid = pcntl_waitpid(-1,$ws,WNOHANG); 134 | if ($rpid) $this->debug("Reaped PID: $rpid"); 135 | } while ($rpid != 0); 136 | } 137 | 138 | // Respawn 139 | if ($exitedPid == $this->queueManagerPid) { 140 | unset($this->queueManagerPid); 141 | $c = 'QueueManager'; 142 | } else if ($exitedPid == $this->receiverPid) { 143 | unset($this->receiverPid); 144 | $c = 'SmsReceiver'; 145 | } else { 146 | unset($this->senderPids[$exitedPid]); 147 | $c = 'SmsSender'; 148 | } 149 | $i--; 150 | $this->debug("Will respawn new $c to cover loss"); 151 | 152 | // Check if any other children died (they might fail simultaneously) 153 | // For this to work children must be reaped first (zombies still has the same SID) 154 | $mySid = posix_getsid(getmypid()); 155 | if (isset($this->queueManagerPid)) { 156 | $sid = posix_getsid($this->queueManagerPid); 157 | if ($sid === false || $sid != $mySid) { 158 | unset($this->queueManagerPid); 159 | $i--; 160 | $this->debug("Will *also* respawn new QueueManager to cover loss"); 161 | } 162 | } 163 | if (isset($this->receiverPid)) { 164 | $sid = posix_getsid($this->receiverPid); 165 | if ($sid === false || $sid != $mySid) { 166 | unset($this->receiverPid); 167 | $i--; 168 | $this->debug("Will *also* respawn new SmsReceiver to cover loss"); 169 | } 170 | } 171 | foreach ($this->senderPids as $senderPid) { 172 | $sid = posix_getsid($senderPid); 173 | if ($sid === false || $sid != $mySid) { 174 | unset($this->senderPids[$senderPid]); 175 | $i--; 176 | $this->debug("Will *also* respawn new SmsSender to cover loss of PID:$senderPid"); 177 | } 178 | } 179 | 180 | continue; 181 | break; 182 | } 183 | } 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /newsyslog.conf.sample: -------------------------------------------------------------------------------- 1 | # logfilename [owner:group] mode count size when flags [/pid_file] [sig_num] 2 | /var/www/smsc-smpp/worker.log 660 30 * @T00 J 3 | /var/www/smsc-smpp/errors.log 660 30 * @T00 J 4 | /var/www/smsc-smpp/trace.log 660 30 * @T00 J 5 | -------------------------------------------------------------------------------- /options.ini.sample: -------------------------------------------------------------------------------- 1 | [general] 2 | debug_handler = "debug" 3 | protocol_debug_handler = "protocolDebug" 4 | 5 | [connection] 6 | ; primary connection 7 | hosts[] = "smpp.yoursmsc.com" 8 | ports[] = 2775 9 | 10 | ; credentials 11 | login = "username" 12 | password = "password" 13 | 14 | ; force into either ipv6 or ipv4 mode? 15 | forceIpv6 = false 16 | forceIpv4 = false 17 | 18 | ; both SMSC and Redis timeouts must be within this interval 19 | enquire_link_timeout = 30 ; in seconds 20 | 21 | ; SMPP SMSC params (used for transmitters) 22 | registered_delivery = true 23 | null_terminate_octetstrings = false 24 | csms_method = 2 ; 0=CSMS_16BIT_TAGS, 1=CSMS_PAYLOAD, 2=CSMS_8BIT_UDH 25 | originator_encoding = ISO-8859-1 26 | 27 | [factory] 28 | debug = true 29 | senders = 10 ; must be within SMSC max connections - 1 (for receiver) 30 | 31 | [receiver] 32 | debug = true 33 | smpp_debug = true 34 | 35 | ; for delivery reports 36 | dlr_provider_id = 1 37 | override_dlr_donedate = true 38 | 39 | ; transport level timeouts in milliseconds 40 | send_timeout = 1000 ; in ms 41 | connect_timeout = 5000 ; in ms 42 | 43 | [sender] 44 | debug = true 45 | smpp_debug = true 46 | 47 | ; transport level timeouts in milliseconds 48 | send_timeout = 500 ; in ms 49 | recv_timeout = 50000 ; in ms 50 | connect_timeout = 5000 ; in ms 51 | 52 | [queue] 53 | host = localhost 54 | port = 6379 55 | connect_timeout = 20 56 | queuekey = smsc.smpp ; redis prefix 57 | use_igbinary = false ; requires igbinary from https://github.com/dynamoid/igbinary if enabled 58 | dlr_queue = nimtastatusqueue ; key for redis list which holds final delivery reports 59 | use_igbinary_for_dlr = false 60 | retention = 50 ; in hours 61 | index = 1 ; redis index 62 | 63 | [queuemanager] 64 | debug = true 65 | retries = 3 66 | retry_interval = 600 ; in seconds 67 | -------------------------------------------------------------------------------- /queuemanager.class.php: -------------------------------------------------------------------------------- 1 | options = $options; 25 | $this->debug = $this->options['queuemanager']['debug']; 26 | pcntl_signal(SIGTERM, array($this,"disconnect"), true); 27 | gc_enable(); 28 | } 29 | 30 | /** 31 | * Close the connection to the queue 32 | */ 33 | public function disconnect() 34 | { 35 | if (isset($this->queue)) $this->queue->close(); 36 | } 37 | 38 | /** 39 | * Shorthand method for calling debug handler 40 | * @param string $s 41 | */ 42 | private function debug($s) 43 | { 44 | call_user_func($this->options['general']['debug_handler'], 'PID:'.getmypid().' - '.$s); 45 | } 46 | 47 | /** 48 | * Run garbage collect and check memory limit 49 | */ 50 | private function checkMemory() 51 | { 52 | // Run garbage collection 53 | gc_collect_cycles(); 54 | 55 | // Check the memory usage for a limit, and exit when 64MB is reached. Parent will re-fork us 56 | if ((memory_get_usage(true)/1024/1024)>64) { 57 | $this->debug('Reached memory max, exiting'); 58 | exit(); 59 | } 60 | } 61 | 62 | /** 63 | * This service's main loop 64 | */ 65 | public function run() 66 | { 67 | $this->queue = new QueueModel($this->options); 68 | 69 | openlog('php-smpp',LOG_PID,LOG_USER); 70 | 71 | while (true) { 72 | // commit suicide if the parent process no longer exists 73 | if (posix_getppid() == 1) exit(); 74 | 75 | // Do the queue have any deferred messages for us? 76 | $deferred = $this->queue->lastDeferred(); /* @var $deferred SmsMessage */ 77 | if (!$deferred) { // Idle 78 | $this->checkMemory(); 79 | sleep(5); 80 | continue; 81 | } 82 | 83 | // How long since last retry? 84 | $sinceLast = time()-$deferred->lastRetry; 85 | $timeToRetry = $this->options['queuemanager']['retry_interval']-$sinceLast; 86 | 87 | // More idleing required? 88 | if ($timeToRetry > 0) { 89 | $this->checkMemory(); 90 | sleep(min(5,$timeToRetry)); // 5 seconds, or next retry interval, whichever comes first 91 | continue; 92 | } 93 | 94 | // Does the message still have retries left 95 | if ($deferred->retries <= $this->options['queuemanager']['retries']) { // Retry message delivery 96 | $this->queue->popLastDeferred(); 97 | 98 | // Remove recipients that already got the message 99 | $msisdns = $this->queue->getMsisdnsForMessage($deferred->id); 100 | if (!empty($msisdns)) $deferred->recipients = array_diff($deferred->recipients,$msisdns); 101 | if (empty($deferred->recipients)) { 102 | $this->debug('Deferred message without valid recipients: '.$deferred->id); 103 | } 104 | 105 | // Re-attempt delivery 106 | $this->debug('Retry delivery of failed message: '.$deferred->id.' retry #'.$deferred->retries); 107 | $this->queue->produce(array($deferred)); 108 | 109 | } else { // remove it 110 | syslog(LOG_WARNING,__FILE__.': Deferred message reached max retries, ID:'.$deferred->id); 111 | $this->debug('Deferred message reached max retries, ID:'.$deferred->id); 112 | $this->queue->popLastDeferred(); 113 | } 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /queuemodel.class.php: -------------------------------------------------------------------------------- 1 | redis = new Redis(); 26 | $this->redis->connect($options['queue']['host'],$options['queue']['port'],$options['queue']['connect_timeout']); 27 | $this->redis->select($options['queue']['index']); 28 | $this->key = $options['queue']['queuekey']; 29 | $this->useIgBinary = ($options['queue']['use_igbinary'] && function_exists('igbinary_serialize')); 30 | $this->options = $options; 31 | } 32 | 33 | /** 34 | * Close connection to queue backend 35 | */ 36 | public function close() 37 | { 38 | $this->redis->close(); 39 | } 40 | 41 | /** 42 | * Produce one or more SMS'es (push to queue). 43 | * Returns the length of the queue on success and false on failure. 44 | * @param array $messages 45 | * @return integer 46 | */ 47 | public function produce($messages) 48 | { 49 | $pipeline = $this->redis->multi(Redis::PIPELINE); 50 | foreach ($messages as $m) { 51 | $pipeline->lpush($this->key.':inactive',$this->serialize($m)); 52 | } 53 | $replies = $pipeline->exec(); 54 | return end($replies); 55 | } 56 | 57 | /** 58 | * Consume a single SMS. Blocking with timeout. 59 | * Timeout is specified in seconds. 60 | * Returns false on timeout 61 | * 62 | * @param integer $pid 63 | * @param integer $timeout 64 | * @return SmsMessage 65 | */ 66 | public function consume($pid,$timeout=5) 67 | { 68 | $m = $this->redis->brpoplpush($this->key.':inactive',$this->key.':active:'.$pid,$timeout); 69 | if (is_null($m) || $m == '*-1' || $m == '*') return null; 70 | return $this->unserialize($m); 71 | } 72 | 73 | /** 74 | * Store SMSC ids with their SMS IDs 75 | * 76 | * @param integer $smsId 77 | * @param array $smscIds 78 | */ 79 | public function storeIds($smsId,array $smscIds,array $msisdns) 80 | { 81 | $retention = (int) $this->options['queue']['retention']; 82 | $pipeline = $this->redis->multi(Redis::PIPELINE); 83 | foreach ($smscIds as $i => $id) { 84 | $pipeline->sAdd($this->key.':ids:'.$smsId,$id); 85 | $pipeline->sAdd($this->key.':msisdns:'.$smsId,$msisdns[$i]); 86 | $pipeline->setex($this->key.':id:'.$id,3600*$retention,$smsId); 87 | } 88 | $pipeline->expire($this->key.':ids:'.$smsId,3600*$retention); 89 | $pipeline->expire($this->key.':msisdns:'.$smsId,3600*$retention); 90 | $replies = $pipeline->exec(); 91 | return end($replies); 92 | } 93 | 94 | /** 95 | * Get the matching sms ids for a bunch of SMSC ids 96 | * @param array $smscIds 97 | * @return array 98 | */ 99 | public function getSmsIds($smscIds) 100 | { 101 | $pipeline = $this->redis->multi(Redis::PIPELINE); 102 | foreach ($smscIds as $i => $id) { 103 | $pipeline->get($this->key.':id:'.$id); 104 | } 105 | $replies = $pipeline->exec(); 106 | if (!$replies) return false; 107 | 108 | $smsids = array(); 109 | foreach ($replies as $i => $reply) { 110 | if ($reply) $smsids[$smscIds[$i]] = $reply; 111 | } 112 | if (empty($smsids)) return false; 113 | return $smsids; 114 | } 115 | 116 | /** 117 | * Store a bunch of DeliveryReports 118 | * @param DeliveryReport $dlr 119 | */ 120 | public function storeDlr(array $dlrs) 121 | { 122 | $pipeline = $this->redis->multi(Redis::PIPELINE); 123 | foreach ($dlrs as $dlr) { 124 | $d = call_user_func((($this->options['queue']['use_igbinary_for_dlr']) ? 'igbinary_serialize' : 'serialize'),$dlr); 125 | $pipeline->lPush($this->options['queue']['dlr_queue'],$d); 126 | } 127 | $replies = $pipeline->exec(); 128 | return end($replies); 129 | } 130 | 131 | /** 132 | * Defer delivery of a SMS. 133 | * 134 | * @param integer $pid 135 | * @param SmsMessage $message 136 | */ 137 | public function defer($pid, SmsMessage $message) 138 | { 139 | $m = $this->serialize($message); 140 | $this->redis->lRem($this->key.':active:'.$pid,$m); 141 | $this->redis->lPush($this->key.':deferred',$m); 142 | } 143 | 144 | /** 145 | * Get MSISDNs for a sms. 146 | * 147 | * @param integer $smsId 148 | * @return array 149 | */ 150 | public function getMsisdnsForMessage($smsId) 151 | { 152 | return $this->redis->sMembers($this->key.':msisdns:'.$smsId); 153 | } 154 | 155 | /** 156 | * Get the latest deferred message 157 | * @return SmsMessage 158 | */ 159 | public function lastDeferred() 160 | { 161 | $m = $this->redis->lIndex($this->key.':deferred',-1); 162 | if (is_null($m) || $m == '*-1' || $m == '*') return null; 163 | return $this->unserialize($m); 164 | } 165 | 166 | /** 167 | * Remove (pop) the lastest deferred message 168 | * @return SmsMessage 169 | */ 170 | public function popLastDeferred() 171 | { 172 | $m = $this->redis->rPop($this->key.':deferred'); 173 | $m = $this->unserialize($m); 174 | return $m; 175 | } 176 | 177 | /** 178 | * Ping the backend to keep our connection alive 179 | */ 180 | public function ping() 181 | { 182 | $this->redis->ping(); 183 | } 184 | 185 | /** 186 | * Shorthand for unserialize 187 | * @param string $d 188 | * @return mixed 189 | */ 190 | private function unserialize($d) 191 | { 192 | return call_user_func(($this->useIgBinary ? 'igbinary_unserialize' : 'unserialize'),$d); 193 | } 194 | 195 | /** 196 | * Shorthand for serialize 197 | * @param mixed $d 198 | * @return string 199 | */ 200 | private function serialize($d) 201 | { 202 | return call_user_func(($this->useIgBinary ? 'igbinary_serialize' : 'serialize'),$d); 203 | } 204 | } 205 | 206 | class SmsMessage 207 | { 208 | public $id; 209 | public $sender; 210 | public $message; 211 | public $recipients; 212 | public $retries; 213 | public $lastRetry; 214 | public $isFlashSms; 215 | 216 | /** 217 | * Create a new SMS Message to send 218 | * 219 | * @param integer $id 220 | * @param string $sender 221 | * @param string $message 222 | * @param array $recipients array of msisdns 223 | * @param boolean $isFlashSms 224 | */ 225 | public function __construct($id, $sender, $message, $recipients, $isFlashSms=false) 226 | { 227 | $this->id = $id; 228 | $this->sender = $sender; 229 | $this->message = $message; 230 | $this->recipients = $recipients; 231 | $this->retries = 0; 232 | $this->lastRetry = null; 233 | $this->isFlashSms = $isFlashSms; 234 | } 235 | } 236 | 237 | class DeliveryReport implements Serializable 238 | { 239 | public $providerId; 240 | public $messageId; 241 | public $msisdn; 242 | public $statusReceived; 243 | public $statusCode; 244 | public $errorCode; 245 | 246 | // Normal status codes 247 | const STATUS_DELIVERED = 1; 248 | const STATUS_BUFFERED = 2; 249 | const STATUS_ERROR = 3; 250 | const STATUS_EXPIRED = 4; 251 | 252 | // Extra status codes, not used often 253 | const STATUS_QUEUED = 5; 254 | const STATUS_INSUFFICIENT_CREDIT = 6; 255 | const STATUS_BLACKLISTED = 7; 256 | const STATUS_UNKNOWN_RECIPIENT = 8; 257 | const STATUS_PROVIDER_ERROR = 9; 258 | const STATUS_INVALID_SMS_ENCODING = 10; 259 | const STATUS_DELETED = 11; 260 | 261 | // Error codes 262 | const ERROR_UNKNOWN = 1; 263 | const ERROR_EXPIRED = 2; 264 | const ERROR_INSUFFICIENT_CREDIT = 3; 265 | const ERROR_BLACKLISTED = 4; 266 | const ERROR_UNKNOWN_RECIPIENT = 5; 267 | const ERROR_INVALID_SMS_ENCODING = 6; 268 | const ERROR_DELETED = 7; 269 | 270 | public function __construct($messageId, $msisdn, $statusReceived, $statusCode, $errorCode=null, $providerId =null) 271 | { 272 | $this->messageId = $messageId; 273 | $this->msisdn = $msisdn; 274 | $this->statusReceived = $statusReceived; 275 | $this->statusCode = $statusCode; 276 | $this->errorCode = $errorCode; 277 | $this->providerId = $providerId; 278 | } 279 | 280 | public function serialize() 281 | { 282 | return serialize(array($this->providerId, $this->messageId, $this->msisdn, $this->statusReceived, $this->statusCode, $this->errorCode)); 283 | } 284 | 285 | public function unserialize($data) 286 | { 287 | list($this->providerId, $this->messageId, $this->msisdn, $this->statusReceived, $this->statusCode, $this->errorCode) = unserialize($data); 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /smsreceiver.class.php: -------------------------------------------------------------------------------- 1 | options = $options; 34 | $this->debug = $this->options['sender']['debug']; 35 | pcntl_signal(SIGTERM, array($this,"disconnect"), true); 36 | 37 | gc_enable(); 38 | } 39 | 40 | public function disconnect() 41 | { 42 | // Process remaining DLRs 43 | if (isset($this->queue) && !empty($this->dlrs)) { 44 | $this->processDlrs(); 45 | } 46 | 47 | // Close queue 48 | if (isset($this->queue)) $this->queue->close(); 49 | 50 | // Disconnect transport 51 | if (isset($this->transport) && $this->transport->isOpen()) { 52 | if (isset($this->client)) { 53 | try { 54 | $this->client->close(); 55 | } catch (Exception $e) { 56 | $this->transport->close(); 57 | } 58 | } else { 59 | $this->transport->close(); 60 | } 61 | } 62 | 63 | // End execution 64 | exit(); 65 | } 66 | 67 | 68 | /** 69 | * Connect to the queue backend. 70 | * Construct and open the transport 71 | * Construct client and bind as transmitter. 72 | * 73 | */ 74 | protected function connect() 75 | { 76 | // Init queue 77 | $this->queue = new QueueModel($this->options); 78 | 79 | // Set some transport defaults first 80 | SocketTransport::$defaultDebug = $this->debug; 81 | SocketTransport::$forceIpv4 = $this->options['connection']['forceIpv6']; 82 | SocketTransport::$forceIpv4 = $this->options['connection']['forceIpv4']; 83 | 84 | // Construct the transport 85 | $h = $this->options['connection']['hosts']; 86 | $p = $this->options['connection']['ports']; 87 | $d = $this->options['general']['debug_handler']; 88 | 89 | $this->transport = new SocketTransport($h,$p,false,$d); 90 | 91 | // Set connection timeout and open connnection 92 | $this->transport->setRecvTimeout($this->options['receiver']['connect_timeout']); 93 | $this->transport->setSendTimeout($this->options['receiver']['connect_timeout']); 94 | $this->transport->open(); 95 | $this->transport->setSendTimeout($this->options['receiver']['send_timeout']); 96 | $this->transport->setRecvTimeout(5000); // wait for 5 seconds for data 97 | 98 | // Construct client and login 99 | $this->client = new SmppClient($this->transport, $this->options['general']['protocol_debug_handler']); 100 | $this->client->debug = $this->options['receiver']['smpp_debug']; 101 | $this->client->bindReceiver($this->options['connection']['login'], $this->options['connection']['password']); 102 | 103 | // Set other client options 104 | SmppClient::$sms_null_terminate_octetstrings = $this->options['connection']['null_terminate_octetstrings']; 105 | } 106 | 107 | /** 108 | * Shorthand method for calling debug handler 109 | * @param string $s 110 | */ 111 | private function debug($s) 112 | { 113 | call_user_func($this->options['general']['debug_handler'], 'PID:'.getmypid().' - '.$s); 114 | } 115 | 116 | /** 117 | * Keep our connections alive. 118 | * Send enquire link to SMSC, respond to any enquire links from SMSC and ping the queue server 119 | */ 120 | protected function ping() 121 | { 122 | $this->queue->ping(); 123 | $this->client->enquireLink(); 124 | $this->client->respondEnquireLink(); 125 | } 126 | 127 | /** 128 | * Process the queue of delivery reports/receipts 129 | * Match up the SMSC ID with the original SMS ID. 130 | * Convert the receipt to a DeliveryReport object, and store it. 131 | * 132 | */ 133 | protected function processDlrs() 134 | { 135 | $smscIds = array_keys($this->dlrs); 136 | $smsIds = $this->queue->getSmsIds($smscIds); 137 | if (!$smsIds) return; 138 | 139 | $reports = array(); 140 | 141 | // Iterate results and convert them into DeliveryReports 142 | foreach ($smsIds as $smscId => $smsId) { 143 | $dlr = $this->dlrs[$smscId]; /* @var $dlr SmppDeliveryReceipt */ 144 | 145 | // Construct DeliveryReport object 146 | $msisdn = $dlr->source->value; 147 | switch ($dlr->stat) { 148 | case 'DELIVRD': 149 | $statusCode = DeliveryReport::STATUS_DELIVERED; 150 | $errorCode = null; 151 | break; 152 | case 'EXPIRED': 153 | $statusCode = DeliveryReport::STATUS_EXPIRED; 154 | $errorCode = DeliveryReport::ERROR_EXPIRED; 155 | break; 156 | case 'DELETED': 157 | $statusCode = DeliveryReport::STATUS_EXPIRED; 158 | $errorCode = DeliveryReport::ERROR_DELETED; 159 | break; 160 | case 'ACCEPTD': 161 | $statusCode = DeliveryReport::STATUS_BUFFERED; 162 | $errorCode = null; 163 | break; 164 | case 'REJECTD': 165 | $statusCode = DeliveryReport::STATUS_ERROR; 166 | $errorCode = DeliveryReport::ERROR_UNKNOWN_RECIPIENT; 167 | break; 168 | case 'UNKNOWN': 169 | case 'UNDELIV': 170 | default: 171 | $statusCode = DeliveryReport::STATUS_ERROR; 172 | $errorCode = DeliveryReport::ERROR_UNKNOWN; 173 | break; 174 | } 175 | $report = new DeliveryReport($smsId, $msisdn, $dlr->doneDate, $statusCode, $errorCode, $this->options['receiver']['dlr_provider_id']); 176 | 177 | // Store the Delivery Report 178 | $reports[$smscId] = $report; 179 | } 180 | 181 | // Push the reports to the queue 182 | $this->queue->storeDlr($reports); 183 | foreach ($reports as $smscId => $report) { 184 | unset($this->dlrs[$smscId]); 185 | } 186 | unset($reports); 187 | 188 | // Remove timed out dlrs 189 | foreach ($this->dlrs as $dlrId => $dlr) { /* @var $dlr SmppDeliveryReceipt */ 190 | // If SMS ID not found within an hour, remove it 191 | if ($dlr->doneDate < (time()-3600)) { 192 | $this->debug('Could not match SMSC ID: '.$dlr->id.' to a SMS ID within an hour. Giving up.'); 193 | unset($this->dlrs[$dlrId]); 194 | continue; 195 | } 196 | } 197 | } 198 | 199 | /** 200 | * Run garbage collect and check memory limit 201 | */ 202 | private function checkMemory() 203 | { 204 | // Run garbage collection 205 | gc_collect_cycles(); 206 | 207 | // Check the memory usage for a limit, and exit when 64MB is reached. Parent will re-fork us 208 | if ((memory_get_usage(true)/1024/1024)>64) { 209 | $this->debug('Reached memory max, exiting'); 210 | $this->disconnect(); 211 | } 212 | } 213 | 214 | /** 215 | * This service's main loop 216 | */ 217 | public function run() 218 | { 219 | $this->connect(); 220 | 221 | $this->lastEnquireLink = 0; 222 | 223 | try { 224 | 225 | $i = 0; 226 | 227 | while (true) { 228 | // commit suicide if the parent process no longer exists 229 | if (posix_getppid() == 1) { 230 | $this->disconnect(); 231 | exit(); 232 | } 233 | 234 | // Make sure to send enquire link periodically to keep the link alive 235 | if (time()-$this->lastEnquireLink >= $this->options['connection']['enquire_link_timeout']) { 236 | $this->ping(); 237 | $this->lastEnquireLink = time(); 238 | } 239 | 240 | // Make sure to process DLRs for every 500 DLRs received 241 | if ($i % 500 == 0) { 242 | if (!empty($this->dlrs)) $this->processDlrs(); 243 | $this->checkMemory(); // do garbage collect, and check the memory limit 244 | } 245 | 246 | // SMPP will block until there is something to do, or a 5 sec timeout is reached 247 | $sms = $this->client->readSMS(); 248 | if ($sms === false) { 249 | // idle 250 | if (!empty($this->dlrs)) $this->processDlrs(); // use this idle time to process dlrs 251 | $this->checkMemory(); 252 | continue; 253 | } 254 | 255 | $i++; // keep track of how many DLRs are received 256 | 257 | if (!$sms instanceof SmppDeliveryReceipt) { 258 | $this->debug('Received SMS instead of DeliveryReceipt, this should not happen. SMS:'.var_export($sms,true)); 259 | continue; 260 | } 261 | 262 | // Done dates from SMSC is sometimes out of sync, override them? 263 | if ($this->options['receiver']['override_dlr_donedate']) { 264 | $sms->doneDate = time(); 265 | } 266 | 267 | // Push the DLR to queue 268 | $this->dlrs[$sms->id] = $sms; 269 | } 270 | } catch (Exception $e) { 271 | $this->debug('Caught '.get_class($e).': '.$e->getMessage()."\n\t".$e->getTraceAsString()); 272 | $this->disconnect(); 273 | } 274 | } 275 | } -------------------------------------------------------------------------------- /smssender.class.php: -------------------------------------------------------------------------------- 1 | options = $options; 33 | $this->debug = $this->options['sender']['debug']; 34 | pcntl_signal(SIGTERM, array($this,"disconnect"), true); 35 | 36 | gc_enable(); 37 | } 38 | 39 | public function disconnect() 40 | { 41 | // Close queue 42 | if (isset($this->queue)) $this->queue->close(); 43 | 44 | // Close transport 45 | if (isset($this->transport) && $this->transport->isOpen()) { 46 | if (isset($this->client)) { 47 | $this->client->close(); 48 | } else { 49 | $this->transport->close(); 50 | } 51 | } 52 | exit(); 53 | } 54 | 55 | /** 56 | * Shorthand method for calling debug handler 57 | * @param string $s 58 | */ 59 | private function debug($s) 60 | { 61 | call_user_func($this->options['general']['debug_handler'], 'PID:'.getmypid().' - '.$s); 62 | } 63 | 64 | /** 65 | * Connect to the queue backend. 66 | * Construct and open the transport 67 | * Construct client and bind as transmitter. 68 | * 69 | */ 70 | protected function connect() 71 | { 72 | // Init queue 73 | $this->queue = new QueueModel($this->options); 74 | 75 | // Set some transport defaults first 76 | SocketTransport::$defaultDebug = $this->debug; 77 | SocketTransport::$forceIpv4 = $this->options['connection']['forceIpv6']; 78 | SocketTransport::$forceIpv4 = $this->options['connection']['forceIpv4']; 79 | 80 | // Construct the transport 81 | $h = $this->options['connection']['hosts']; 82 | $p = $this->options['connection']['ports']; 83 | $d = $this->options['general']['debug_handler']; 84 | 85 | $this->transport = new SocketTransport($h,$p,false,$d); 86 | 87 | // Set connection timeout and open connnection 88 | $this->transport->setRecvTimeout($this->options['sender']['recv_timeout']); 89 | $this->transport->setSendTimeout($this->options['sender']['connect_timeout']); 90 | $this->transport->open(); 91 | $this->transport->setSendTimeout($this->options['sender']['send_timeout']); 92 | 93 | // Construct client and login 94 | $this->client = new SmppClient($this->transport, $this->options['general']['protocol_debug_handler']); 95 | $this->client->debug = $this->options['sender']['smpp_debug']; 96 | $this->client->bindTransmitter($this->options['connection']['login'], $this->options['connection']['password']); 97 | 98 | // Set other client options 99 | SmppClient::$sms_registered_delivery_flag = ($this->options['connection']['registered_delivery']) ? SMPP::REG_DELIVERY_SMSC_BOTH : SMPP::REG_DELIVERY_NO; 100 | SmppClient::$csms_method = $this->options['connection']['csms_method']; 101 | SmppClient::$sms_null_terminate_octetstrings = $this->options['connection']['null_terminate_octetstrings']; 102 | } 103 | 104 | /** 105 | * Keep our connections alive. 106 | * Send enquire link to SMSC, respond to any enquire links from SMSC and ping the queue server 107 | */ 108 | protected function ping() 109 | { 110 | $this->queue->ping(); 111 | $this->client->enquireLink(); 112 | $this->client->respondEnquireLink(); 113 | } 114 | 115 | /** 116 | * Run garbage collect and check memory limit 117 | */ 118 | private function checkMemory() 119 | { 120 | // Run garbage collection 121 | gc_collect_cycles(); 122 | 123 | // Check the memory usage for a limit, and exit when 64MB is reached. Parent will re-fork us 124 | if ((memory_get_usage(true)/1024/1024)>64) { 125 | $this->debug('Reached memory max, exiting'); 126 | $this->disconnect(); 127 | } 128 | } 129 | 130 | /** 131 | * This workers main loop 132 | */ 133 | public function run() 134 | { 135 | $this->connect(); 136 | 137 | $this->lastEnquireLink = 0; 138 | 139 | try { 140 | 141 | while (true) { 142 | // commit suicide if the parent process no longer exists 143 | if (posix_getppid() == 1) { 144 | $this->disconnect(); 145 | exit(); 146 | } 147 | 148 | // Make sure to send enquire link periodically to keep the link alive 149 | if (time()-$this->lastEnquireLink >= $this->options['connection']['enquire_link_timeout']) { 150 | $this->ping(); 151 | $this->lastEnquireLink = time(); 152 | } else { 153 | $this->client->respondEnquireLink(); 154 | } 155 | 156 | // Queue->consume will block until there is something to do, or a 5 sec timeout is reached 157 | $sms = $this->queue->consume(getmypid(),5); 158 | if ($sms === false || is_null($sms)) { // idle 159 | $this->checkMemory(); 160 | continue; 161 | } 162 | 163 | // Prepare message 164 | $encoded = GsmEncoder::utf8_to_gsm0338($sms->message); 165 | $encSender = iconv('UTF-8',$this->options['connection']['originator_encoding'],$sms->sender); 166 | if (strlen($encSender)>11) $encSender = substr($encSender,0,11); // truncate 167 | 168 | // Contruct SMPP Address objects 169 | if (!ctype_digit($sms->sender)) { 170 | $sender = new SmppAddress($encSender,SMPP::TON_ALPHANUMERIC); 171 | } else if ($sms->sender < 10000) { 172 | $sender = new SmppAddress($sms->sender,SMPP::TON_NATIONAL,SMPP::NPI_E164); 173 | } else { 174 | $sender = new SmppAddress($sms->sender,SMPP::TON_INTERNATIONAL,SMPP::NPI_E164); 175 | } 176 | 177 | // Deal with flash sms (dest_addr_subunit: 0x01 - show on display only) 178 | if ($sms->isFlashSms) { 179 | $tags = array(new SmppTag(SmppTag::DEST_ADDR_SUBUNIT, 1, 1, 'c')); 180 | } else { 181 | $tags = null; 182 | } 183 | 184 | // Send message 185 | $ids = array(); 186 | $msisdns = array(); 187 | try { 188 | $i = 0; 189 | foreach ($sms->recipients as $number) { 190 | $address = new SmppAddress($number,SMPP::TON_INTERNATIONAL,SMPP::NPI_E164); 191 | $ids[] = $this->client->sendSMS($sender, $address, $encoded, $tags); 192 | $msisdns[] = $number; 193 | 194 | if (++$i % 10 == 0) { 195 | // relay back for every 10 SMSes 196 | $this->queue->storeIds($sms->id, $ids, $msisdns); 197 | 198 | // Pretty debug output 199 | if ($this->debug) { 200 | $s = 'Sent SMS: '.$sms->id.' with ids:'; 201 | foreach ($ids as $n => $id) { 202 | if ($n % 2 == 0) $s .= "\n"; 203 | $s .= "\t".$msisdns[$n].":".$id; 204 | } 205 | $this->debug($s); 206 | } 207 | $ids = array(); 208 | $msisdns = array(); 209 | } 210 | } 211 | } catch (\Exception $e) { 212 | if (!empty($ids)) { 213 | // make sure to report any partial progress back 214 | $this->queue->storeIds($sms->id, $ids, $msisdns); 215 | $this->debug('SMS with partial progress: '.$sms->id.' with ids: '.implode(', ',$ids)); 216 | } 217 | $this->debug('Deferring SMS id:'.$sms->id); 218 | $sms->retries++; 219 | $sms->lastRetry = time(); 220 | $this->queue->defer(getmypid(), $sms); 221 | throw $e; // rethrow 222 | } 223 | 224 | if (!empty($ids)) { 225 | $this->queue->storeIds($sms->id, $ids, $msisdns); 226 | 227 | // Pretty debug output 228 | if ($this->debug) { 229 | $s = 'Sent SMS: '.$sms->id.' with ids:'; 230 | foreach ($ids as $n => $id) { 231 | if ($n % 2 == 0) $s .= "\n"; 232 | $s .= "\t".$msisdns[$n].":".$id; 233 | } 234 | $this->debug($s); 235 | } 236 | } 237 | } 238 | } catch (Exception $e) { 239 | $this->debug('Caught '.get_class($e).': '.$e->getMessage()."\n\t".$e->getTraceAsString()); 240 | $this->disconnect(); 241 | } 242 | } 243 | } -------------------------------------------------------------------------------- /start.php: -------------------------------------------------------------------------------- 1 | startAll(); 24 | --------------------------------------------------------------------------------