SmsService.java 20.4 KB
Newer Older
1 2
package org.jivesoftware.util;

3 4 5 6 7
import org.apache.commons.pool2.BasePooledObjectFactory;
import org.apache.commons.pool2.ObjectPool;
import org.apache.commons.pool2.PooledObject;
import org.apache.commons.pool2.impl.DefaultPooledObject;
import org.apache.commons.pool2.impl.GenericObjectPool;
8 9 10 11 12 13 14 15 16
import org.jsmpp.bean.*;
import org.jsmpp.extra.NegativeResponseException;
import org.jsmpp.session.BindParameter;
import org.jsmpp.session.SMPPSession;
import org.jsmpp.util.AbsoluteTimeFormatter;
import org.jsmpp.util.TimeFormatter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

17
import java.util.*;
18 19 20 21 22 23 24

/**
 * A service to send SMS messages.<p>
 *
 * This class is configured with a set of Jive properties. Note that each service provider can require a different set
 * of properties to be set.
 * <ul>
25 26
 * <li><tt>sms.smpp.connections.maxAmount</tt> -- the maximum amount of connections. The default value is one.
 * <li><tt>sms.smpp.connections.idleMillis</tt> -- time (in ms) after which idle connections are allowed to be evicted. Defaults to two minutes.
27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60
 * <li><tt>sms.smpp.host</tt> -- the host name of your SMPP Server or SMSC, i.e. smsc.example.org. The default value is "localhost".
 * <li><tt>sms.smpp.port</tt> -- the port on which the SMSC is listening. Defaults to 2775.
 * <li><tt>sms.smpp.systemId</tt> -- the 'user name' to use when connecting to the SMSC.
 * <li><tt>sms.smpp.password</tt> -- the password that authenticates the systemId value when connecting to the SMSC.
 * <li><tt>sms.smpp.systemType</tt> -- an optional system type, which, if defined, will be used when connecting to the SMSC.
 * <li><tt>sms.smpp.receive.ton</tt> -- The type-of-number value for 'receiving' SMS messages. Defaults to 'UNKNOWN'.
 * <li><tt>sms.smpp.receive.npi</tt> -- The number-plan-indicator value for 'receiving' SMS messages. Defaults to 'UNKNOWN'.
 * <li><tt>sms.smpp.source.ton</tt> -- The type-of-number value for the source of SMS messages. Defaults to 'UNKNOWN'.
 * <li><tt>sms.smpp.source.npi</tt> -- The number-plan-indicator value for the source of SMS messages. Defaults to 'UNKNOWN'.
 * <li><tt>sms.smpp.source.address</tt> -- The source address of SMS messages.
 * <li><tt>sms.smpp.destination.ton</tt> -- The type-of-number value for the destination of SMS messages. Defaults to 'UNKNOWN'.
 * <li><tt>sms.smpp.destination.npi</tt> -- The number-plan-indicator value for the destination of SMS messages. Defaults to 'UNKNOWN'.
 * </ul>
 *
 * @author Guus der Kinderen, guus@goodbytes.nl
 */
public class SmsService
{
    private static final Logger Log = LoggerFactory.getLogger( SmsService.class );

    private static TimeFormatter timeFormatter = new AbsoluteTimeFormatter();

    private static SmsService INSTANCE;

    public static synchronized SmsService getInstance()
    {
        if ( INSTANCE == null )
        {
            INSTANCE = new SmsService();
        }

        return INSTANCE;
    }

61 62 63 64 65 66 67 68 69 70 71
    /**
     * Pool of SMPP sessions that is used to transmit messages to the SMSC.
     */
    private final SMPPSessionPool sessionPool;

    private SmsService()
    {
        sessionPool = new SMPPSessionPool();
        PropertyEventDispatcher.addListener( sessionPool );
    }

72 73 74 75 76 77
    /**
     * Causes a new SMS message to be sent.
     *
     * Note that the message is sent asynchronously. This method does not block. A successful invocation does not
     * guarantee successful delivery
     *
78
     * @param message   The body of the message (cannot be null or empty).
79 80 81 82
     * @param recipient The address / phone number to which the message is to be send (cannot be null or empty).
     */
    public void send( String message, String recipient )
    {
83 84
        if ( message == null || message.isEmpty() )
        {
85 86 87
            throw new IllegalArgumentException( "Argument 'message' cannot be null or an empty String." );
        }

88 89
        if ( recipient == null || recipient.isEmpty() )
        {
90 91 92
            throw new IllegalArgumentException( "Argument 'recipient' cannot be null or an empty String." );
        }

93
        TaskEngine.getInstance().submit( new SmsTask( sessionPool, message, recipient ) );
94 95 96 97 98 99 100 101 102 103
    }

    /**
     * Causes a new SMS message to be sent.
     *
     * This method differs from {@link #send(String, String)} in that the message is sent before this method returns,
     * rather than queueing the messages to be sent later (in an async fashion). As a result, any exceptions that occur
     * while sending the message are thrown by this method (which can be useful to test the configuration of this
     * service).
     *
104
     * @param message   The body of the message (cannot be null or empty).
105
     * @param recipient The address / phone number to which the message is to be send (cannot be null or empty).
106
     * @throws Exception On any problem.
107
     */
108
    public void sendImmediately( String message, String recipient ) throws Exception
109
    {
110 111
        if ( message == null || message.isEmpty() )
        {
112 113 114
            throw new IllegalArgumentException( "Argument 'message' cannot be null or an empty String." );
        }

115 116
        if ( recipient == null || recipient.isEmpty() )
        {
117 118 119 120 121
            throw new IllegalArgumentException( "Argument 'recipient' cannot be null or an empty String." );
        }

        try
        {
122
            new SmsTask( sessionPool, message, recipient ).sendMessage();
123
        }
124
        catch ( Exception e )
125
        {
126
            Log.error( "An exception occurred while sending a SMS message (to '{}')", recipient, e );
127 128 129 130 131 132 133 134 135
            throw e;
        }
    }

    /**
     * Checks if an exception in the chain of the provided throwable contains a 'command status' that can be
     * translated in a somewhat more helpful error message.
     *
     * The list of error messages was taken from http://www.smssolutions.net/tutorials/smpp/smpperrorcodes/
136
     *
137
     * @param ex The exception in which to search for a command status.
138
     * @return a human readable error message.
139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222
     */
    public static String getDescriptiveMessage( Throwable ex )
    {
        if ( ex instanceof NegativeResponseException )
        {
            final Map<Integer, String> errors = new HashMap<>();
            errors.put( 0x00000000, "No Error" );
            errors.put( 0x00000001, "Message too long" );
            errors.put( 0x00000002, "Command length is invalid" );
            errors.put( 0x00000003, "Command ID is invalid or not supported" );
            errors.put( 0x00000004, "Incorrect bind status for given command" );
            errors.put( 0x00000005, "Already bound" );
            errors.put( 0x00000006, "Invalid Priority Flag" );
            errors.put( 0x00000007, "Invalid registered delivery flag" );
            errors.put( 0x00000008, "System error" );
            errors.put( 0x0000000A, "Invalid source address" );
            errors.put( 0x0000000B, "Invalid destination address" );
            errors.put( 0x0000000C, "Message ID is invalid" );
            errors.put( 0x0000000D, "Bind failed" );
            errors.put( 0x0000000E, "Invalid password" );
            errors.put( 0x0000000F, "Invalid System ID" );
            errors.put( 0x00000011, "Cancelling message failed" );
            errors.put( 0x00000013, "Message recplacement failed" );
            errors.put( 0x00000014, "Message queue full" );
            errors.put( 0x00000015, "Invalid service type" );
            errors.put( 0x00000033, "Invalid number of destinations" );
            errors.put( 0x00000034, "Invalid distribution list name" );
            errors.put( 0x00000040, "Invalid destination flag" );
            errors.put( 0x00000042, "Invalid submit with replace request" );
            errors.put( 0x00000043, "Invalid esm class set" );
            errors.put( 0x00000044, "Invalid submit to ditribution list" );
            errors.put( 0x00000045, "Submitting message has failed" );
            errors.put( 0x00000048, "Invalid source address type of number ( TON )" );
            errors.put( 0x00000049, "Invalid source address numbering plan ( NPI )" );
            errors.put( 0x00000050, "Invalid destination address type of number ( TON )" );
            errors.put( 0x00000051, "Invalid destination address numbering plan ( NPI )" );
            errors.put( 0x00000053, "Invalid system type" );
            errors.put( 0x00000054, "Invalid replace_if_present flag" );
            errors.put( 0x00000055, "Invalid number of messages" );
            errors.put( 0x00000058, "Throttling error" );
            errors.put( 0x00000061, "Invalid scheduled delivery time" );
            errors.put( 0x00000062, "Invalid Validty Period value" );
            errors.put( 0x00000063, "Predefined message not found" );
            errors.put( 0x00000064, "ESME Receiver temporary error" );
            errors.put( 0x00000065, "ESME Receiver permanent error" );
            errors.put( 0x00000066, "ESME Receiver reject message error" );
            errors.put( 0x00000067, "Message query request failed" );
            errors.put( 0x000000C0, "Error in the optional part of the PDU body" );
            errors.put( 0x000000C1, "TLV not allowed" );
            errors.put( 0x000000C2, "Invalid parameter length" );
            errors.put( 0x000000C3, "Expected TLV missing" );
            errors.put( 0x000000C4, "Invalid TLV value" );
            errors.put( 0x000000FE, "Transaction delivery failure" );
            errors.put( 0x000000FF, "Unknown error" );
            errors.put( 0x00000100, "ESME not authorised to use specified servicetype" );
            errors.put( 0x00000101, "ESME prohibited from using specified operation" );
            errors.put( 0x00000102, "Specified servicetype is unavailable" );
            errors.put( 0x00000103, "Specified servicetype is denied" );
            errors.put( 0x00000104, "Invalid data coding scheme" );
            errors.put( 0x00000105, "Invalid source address subunit" );
            errors.put( 0x00000106, "Invalid destination address subunit" );
            errors.put( 0x0000040B, "Insufficient credits to send message" );
            errors.put( 0x0000040C, "Destination address blocked by the ActiveXperts SMPP Demo Server" );

            String error = errors.get( ( (NegativeResponseException) ex ).getCommandStatus() );
            if ( ex.getMessage() != null && !ex.getMessage().isEmpty() )
            {
                error += " (exception message: '" + ex.getMessage() + "')";
            }
            return error;
        }
        else if ( ex.getCause() != null )
        {
            return getDescriptiveMessage( ex.getCause() );
        }

        return ex.getMessage();
    }

    /**
     * Runnable that allows an SMS to be sent in a different thread.
     */
    private static class SmsTask implements Runnable
    {
223
        private final ObjectPool<SMPPSession> sessionPool;
224 225

        // Settings that apply to source of an SMS message.
226 227 228
        private final TypeOfNumber sourceTon = JiveGlobals.getEnumProperty( "sms.smpp.source.ton", TypeOfNumber.class, TypeOfNumber.UNKNOWN );
        private final NumberingPlanIndicator sourceNpi = JiveGlobals.getEnumProperty( "sms.smpp.source.npi", NumberingPlanIndicator.class, NumberingPlanIndicator.UNKNOWN );
        private final String sourceAddress = JiveGlobals.getProperty( "sms.smpp.source.address" );
229 230

        // Settings that apply to destination of an SMS message.
231
        private final TypeOfNumber destinationTon = JiveGlobals.getEnumProperty( "sms.smpp.destination.ton", TypeOfNumber.class, TypeOfNumber.UNKNOWN );
232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249
        private final NumberingPlanIndicator destinationNpi = JiveGlobals.getEnumProperty( "sms.smpp.destination.npi", NumberingPlanIndicator.class, NumberingPlanIndicator.UNKNOWN );

        private final String destinationAddress;
        private final byte[] message;

        // Non-configurable defaults (for now - TODO?)
        private final ESMClass esm = new ESMClass();
        private final byte protocolId = 0;
        private final byte priorityFlag = 1;
        private final String serviceType = "CMT";
        private final String scheduleDeliveryTime = timeFormatter.format( new Date() );
        private final String validityPeriod = null;
        private final RegisteredDelivery registeredDelivery = new RegisteredDelivery( SMSCDeliveryReceipt.DEFAULT );
        private final byte replaceIfPresentFlag = 0;
        private final DataCoding dataCoding = new GeneralDataCoding( Alphabet.ALPHA_DEFAULT, MessageClass.CLASS1, false );
        private final byte smDefaultMsgId = 0;


250
        SmsTask( ObjectPool<SMPPSession> sessionPool, String message, String destinationAddress )
251
        {
252
            this.sessionPool = sessionPool;
253 254 255 256 257 258 259 260 261 262 263
            this.message = message.getBytes();
            this.destinationAddress = destinationAddress;
        }

        @Override
        public void run()
        {
            try
            {
                sendMessage();
            }
264
            catch ( Exception e )
265
            {
266
                Log.error( "An exception occurred while sending a SMS message (to '{}')", destinationAddress, e );
267 268 269
            }
        }

270
        public void sendMessage() throws Exception
271
        {
272
            final SMPPSession session = sessionPool.borrowObject();
273 274 275 276 277 278 279 280 281 282 283 284 285
            try
            {
                final String messageId = session.submitShortMessage(
                    serviceType,
                    sourceTon, sourceNpi, sourceAddress,
                    destinationTon, destinationNpi, destinationAddress,
                    esm, protocolId, priorityFlag,
                    scheduleDeliveryTime, validityPeriod, registeredDelivery, replaceIfPresentFlag,
                    dataCoding, smDefaultMsgId, message );
                Log.debug( "Message submitted, message_id is '{}'.", messageId );
            }
            finally
            {
286
                sessionPool.returnObject( session );
287 288 289
            }
        }
    }
290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438

    /**
     * A factory of SMPPSession instances that are used in an object pool.
     *
     * @author Guus der Kinderen, guus.der.kinderen@gmail.com
     */
    private static class SMPPSessionFactory extends BasePooledObjectFactory<SMPPSession>
    {
        private static final Logger Log = LoggerFactory.getLogger( SMPPSessionFactory.class );

        @Override
        public SMPPSession create() throws Exception
        {
            // SMSC connection settings
            final String host = JiveGlobals.getProperty( "sms.smpp.host", "localhost" );
            final int port = JiveGlobals.getIntProperty( "sms.smpp.port", 2775 );
            final String systemId = JiveGlobals.getProperty( "sms.smpp.systemId" );
            final String password = JiveGlobals.getProperty( "sms.smpp.password" );
            final String systemType = JiveGlobals.getProperty( "sms.smpp.systemType" );

            // Settings that apply to 'receiving' SMS. Should not apply to this implementation, as we're not receiving anything..
            final TypeOfNumber receiveTon = JiveGlobals.getEnumProperty( "sms.smpp.receive.ton", TypeOfNumber.class, TypeOfNumber.UNKNOWN );
            final NumberingPlanIndicator receiveNpi = JiveGlobals.getEnumProperty( "sms.smpp.receive.npi", NumberingPlanIndicator.class, NumberingPlanIndicator.UNKNOWN );

            Log.debug( "Creating a new sesssion (host: '{}', port: '{}', systemId: '{}'.", host, port, systemId );
            final SMPPSession session = new SMPPSession();
            session.connectAndBind( host, port, new BindParameter( BindType.BIND_TX, systemId, password, systemType, receiveTon, receiveNpi, null ) );
            Log.debug( "Created a new session with ID '{}'.", session.getSessionId() );
            return session;
        }

        @Override
        public boolean validateObject( PooledObject<SMPPSession> pooledObject )
        {
            final SMPPSession session = pooledObject.getObject();
            final boolean isValid = session.getSessionState().isTransmittable(); // updated by the SMPPSession internal enquireLink timer.
            Log.debug( "Ran a check to see if session with ID '{}' is valid. Outcome: {}", session.getSessionId(), isValid );
            return isValid;
        }

        @Override
        public void destroyObject( PooledObject<SMPPSession> pooledObject ) throws Exception
        {
            final SMPPSession session = pooledObject.getObject();
            Log.debug( "Destroying a pooled session with ID '{}'.", session.getSessionId() );
            session.unbindAndClose();
        }

        @Override
        public PooledObject<SMPPSession> wrap( SMPPSession smppSession )
        {
            return new DefaultPooledObject<>( smppSession );
        }
    }

    /**
     * Implementation of an Object pool that manages instances of SMPPSession. The intend of this pool is to have a
     * single session, that's allowed to be idle for at least two minutes before being closed.
     *
     * The pool reacts to Openfire property changes, clearing all (inactive) sessions when a property used to create
     * a session is modified. Note that sessions that are borrowed from the pool are not affected by such a change. When
     * a property change occurs while a session is borrowed, a warning is logged (the property change will be applied
     * when that session is eventually rotated out of the pool by the eviction strategy.
     *
     * @author Guus der Kinderen, guus.der.kinderen@gmail.com
     */
    private static class SMPPSessionPool extends GenericObjectPool<SMPPSession> implements PropertyEventListener
    {
        private static final Logger Log = LoggerFactory.getLogger( SMPPSessionPool.class );

        SMPPSessionPool()
        {
            super( new SMPPSessionFactory() );
            setMaxTotal( JiveGlobals.getIntProperty( "sms.smpp.connections.maxAmount", 1 ) );
            setNumTestsPerEvictionRun( getMaxTotal() );

            setMinEvictableIdleTimeMillis( JiveGlobals.getLongProperty( "sms.smpp.connections.idleMillis", 1000 * 60 * 2 ) );
            if ( getMinEvictableIdleTimeMillis() > 0 )
            {
                setTimeBetweenEvictionRunsMillis( getMinEvictableIdleTimeMillis() / 10 );
            }

            setTestOnBorrow( true );
            setTestWhileIdle( true );
        }

        void processPropertyChange( String propertyName )
        {
            final Set<String> ofInterest = new HashSet<>();
            ofInterest.add( "sms.smpp.host" );
            ofInterest.add( "sms.smpp.port" );
            ofInterest.add( "sms.smpp.systemId" );
            ofInterest.add( "sms.smpp.password" );
            ofInterest.add( "sms.smpp.systemType" );
            ofInterest.add( "sms.smpp.receive.ton" );
            ofInterest.add( "sms.smpp.receive.npi" );

            if ( ofInterest.contains( propertyName ) )
            {
                Log.debug( "Property change for '{}' detected. Clearing all (inactive) sessions.", propertyName );
                if ( getNumActive() > 0 )
                {
                    // This can occur when an SMS is being sent while the property is being updated at the same time.
                    Log.warn( "Note that property change for '{}' will not affect one or more sessions that are currently actively used (although changes will be applied after the session is rotated out, due to time-based eviction)." );
                }
                clear();
            }

            // No need to clear the sessions for these properties:
            if ( propertyName.equals( "sms.smpp.connections.maxAmount" ) )
            {
                setMaxTotal( JiveGlobals.getIntProperty( "sms.smpp.connections.maxAmount", 1 ) );
                setNumTestsPerEvictionRun( getMaxTotal() );
            }

            if ( propertyName.equals( "sms.smpp.connections.idleMillis" ) )
            {
                setMinEvictableIdleTimeMillis( JiveGlobals.getLongProperty( "sms.smpp.connections.idleMillis", 1000 * 60 * 2 ) );
                if ( getMinEvictableIdleTimeMillis() > 0 )
                {
                    setTimeBetweenEvictionRunsMillis( getMinEvictableIdleTimeMillis() / 10 );
                }
            }
        }

        @Override
        public void propertySet( String property, Map<String, Object> params )
        {
            processPropertyChange( property );
        }

        @Override
        public void propertyDeleted( String property, Map<String, Object> params )
        {
            processPropertyChange( property );
        }

        @Override
        public void xmlPropertySet( String property, Map<String, Object> params )
        {
            processPropertyChange( property );
        }

        @Override
        public void xmlPropertyDeleted( String property, Map<String, Object> params )
        {
            processPropertyChange( property );
        }
    }
439
}