├── .gitignore ├── .travis.yml ├── CHANGES.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── README.rst ├── requirements-dev.txt ├── setup.cfg ├── setup.py ├── smsframework ├── Gateway.py ├── IProvider.py ├── __init__.py ├── data │ ├── IncomingMessage.py │ ├── MessageStatus.py │ ├── OutgoingMessage.py │ ├── OutgoingMessageOptions.py │ └── __init__.py ├── exc.py ├── lib │ ├── __init__.py │ └── events.py └── providers │ ├── __init__.py │ ├── forward │ ├── __init__.py │ ├── jsonex.py │ ├── provider.py │ ├── receiver_client.py │ └── receiver_server.py │ ├── log.py │ ├── loopback.py │ └── null.py ├── tests ├── forward_test.py ├── gateway_test.py ├── log_test.py └── loopback_test.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # ===[ APP ]=== # 2 | 3 | # ===[ PYTHON PACKAGE ]=== # 4 | /.tox/ 5 | /build/ 6 | /dist/ 7 | /MANIFEST 8 | /*.egg-info/ 9 | /*.egg/ 10 | 11 | # ===[ OTHER ]=== # 12 | 13 | # IDE Projects 14 | .idea 15 | .nbproject 16 | .project 17 | *.sublime-project 18 | 19 | # Temps 20 | *~ 21 | *.tmp 22 | *.bak 23 | *.swp 24 | *.kate-swp 25 | *.DS_Store 26 | Thumbs.db 27 | 28 | # Utils 29 | .sass-cache/ 30 | .coverage 31 | 32 | # Generated 33 | __pycache__ 34 | *.py[cod] 35 | *.pot 36 | *.mo 37 | 38 | # Runtime 39 | /*.log 40 | /*.pid 41 | 42 | # ===[ EXCLUDES ]=== # 43 | !.gitkeep 44 | !.htaccess 45 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | os: linux 2 | sudo: false 3 | language: python 4 | 5 | matrix: 6 | include: 7 | - python: 2.7 8 | env: TOXENV=py 9 | - python: 3.4 10 | env: TOXENV=py 11 | - python: 3.5 12 | env: TOXENV=py 13 | - python: 3.6 14 | env: TOXENV=py 15 | - python: 3.7-dev 16 | env: TOXENV=py 17 | - python: pypy 18 | env: TOXENV=py 19 | - python: pypy3 20 | env: TOXENV=py 21 | 22 | install: 23 | - pip install tox 24 | cache: 25 | - pip 26 | script: 27 | - tox 28 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | v0.0.3, 2014.01.29 5 | ------------------ 6 | 7 | * Improved `__repr__` for: IncomingMessage, OutgoingMessage, MessageStatus 8 | * `LoggerProvider`: now allows to omit the `logger` config parameter to use a default logger 9 | 10 | v0.0.2, 2014.01.29 11 | ------------------ 12 | 13 | Initial release 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, Mark Vartanyan 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 14 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 15 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 16 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 17 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 18 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 19 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 20 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 21 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 22 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 23 | POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.* 2 | include LICENSE 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: 2 | 3 | SHELL := /bin/bash 4 | 5 | # Package 6 | .PHONY: clean 7 | clean: 8 | @rm -rf build/ dist/ *.egg-info/ 9 | #README.md: 10 | # @python misc/_doc/README.py | j2 --format=json -o README.md misc/_doc/README.md.j2 11 | README.rst: README.md 12 | @pandoc -f markdown -t rst -o README.rst README.md 13 | 14 | .PHONY: build publish-test publish 15 | build: README.rst 16 | @./setup.py build sdist bdist_wheel 17 | publish-test: README.rst 18 | @twine upload --repository pypitest dist/* 19 | publish: README.rst 20 | @twine upload dist/* 21 | 22 | 23 | .PHONY: test test-tox test-docker test-docker-2.6 24 | test: 25 | @nosetests 26 | test-tox: 27 | @tox 28 | test-docker: 29 | @docker run --rm -it -v `pwd`:/src themattrix/tox 30 | test-docker-2.6: # temporary, since `themattrix/tox` has faulty 2.6 31 | @docker run --rm -it -v $(realpath .):/app mrupgrade/deadsnakes:2.6 bash -c 'cd /app && pip install -e . && pip install nose argparse && nosetests' 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://api.travis-ci.org/kolypto/py-smsframework.png?branch=master)](https://travis-ci.org/kolypto/py-smsframework) 2 | [![Pythons](https://img.shields.io/badge/python-2.7%20%7C%203.4%E2%80%933.7%20%7C%20pypy-blue.svg)](.travis.yml) 3 | 4 | SMSframework 5 | ============ 6 | 7 | SMS framework with pluggable providers. 8 | 9 | Key features: 10 | 11 | * Send messages 12 | * Receive messages 13 | * Delivery confirmations 14 | * Handle multiple pluggable providers with a single gateway 15 | * Synchronous message receipt through events 16 | * Reliable message handling 17 | * Supports provider APIs (like getting the balance) 18 | * Providers use [Flask microframework](http://flask.pocoo.org) for message receivers (not required) 19 | * 0 dependencies 20 | * Unit-tested 21 | 22 | 23 | 24 | 25 | 26 | 27 | Table of Contents 28 | ================= 29 | 30 | * Tutorial 31 | * Supported Providers 32 | * Installation 33 | * Gateway 34 | * Providers 35 | * Gateway.add_provider(name, Provider, **config):IProvider 36 | * Gateway.default_provider 37 | * Gateway.get_provider(name):IProvider 38 | * Sending Messages 39 | * Gateway.send(message):OutgoingMessage 40 | * Event Hooks 41 | * Gateway.onSend 42 | * Gateway.onReceive 43 | * Gateway.onStatus 44 | * Data Objects 45 | * IncomingMessage 46 | * OutgoingMessage 47 | * MessageStatus 48 | * Exceptions 49 | * Provider HTTP Receivers 50 | * Gateway.receiver_blueprint_for(name): flask.Blueprint 51 | * Gateway.receiver_blueprints():(name, flask.Blueprint)* 52 | * Gateway.receiver_blueprints_register(app, prefix='/'):flask.Flask 53 | * Message Routing 54 | * Bundled Providers 55 | * NullProvider 56 | * LogProvider 57 | * LoopbackProvider 58 | * LoopbackProvider.get_traffic():list 59 | * LoopbackProvider.received(src, body):IncomingMessage 60 | * LoopbackProvider.subscribe(number, callback):IProvider 61 | * ForwardServerProvider, ForwardClientProvider 62 | * ForwardClientProvider 63 | * ForwardServerProvider 64 | * Routing Server 65 | 66 | 67 | 68 | 69 | 70 | Tutorial 71 | ======== 72 | 73 | ## Sending Messages 74 | 75 | In order to send a message, you will use a `Gateway`: 76 | 77 | ```python 78 | from smsframework import Gateway 79 | gateway = Gateway() 80 | ``` 81 | 82 | By itself, it cannot do anything. However, if you install a *provider* -- a library that implements some SMS service -- 83 | you can add it to the `Gateway` and configure it to send your messages through a provider: 84 | 85 | ```python 86 | from smsframework_clickatell import ClickatellProvider 87 | 88 | gateway.add_provider('main', ClickatellProvider) # the default one 89 | ``` 90 | 91 | The first provider defined becomes the default one. 92 | (If you have multiple providers, `Gateway` supports routing: rules that select which provider to use). 93 | 94 | Now, let's send a message: 95 | 96 | ```python 97 | from smsframework import OutgoingMessage 98 | 99 | gateway.send(OutgoingMessage('+123456789', 'hi there!')) 100 | ``` 101 | 102 | 103 | 104 | ## Receiving Messages 105 | 106 | In order to receive messages, you will use the same `Gateway` object and ask it to generate an HTTP API endpoint 107 | for you. It uses Flask framework, and you'll need to run a Flask application in order to receive SMS messages: 108 | 109 | ```pyhon 110 | from flask import Flask 111 | 112 | app = Flask() 113 | bp = gateway.receiver_blueprint_for('main') # SMS receiver 114 | app.register_blueprint(bp, url_prefix='/sms/main') # register it with Flask 115 | ``` 116 | 117 | Now, use Clickatell's web interface and register the following URL: `http://example.com/sms/main`. 118 | It will send you messages to the application. 119 | 120 | Next, you need to handle the incoming messages in your code. 121 | To to this, you need to subscribe your handler to the `gateway.onReceive` event: 122 | 123 | ```python 124 | def on_receive(message): 125 | """ :type message: IncomingMessage """ 126 | pass # Your logic here 127 | 128 | gateway.onReceive += on_receive 129 | ``` 130 | 131 | In addition to receiving messages, you can receive status reports about the messages you have sent. 132 | See Gateway.onStatus for more information. 133 | 134 | 135 | 136 | 137 | 138 | Supported Providers 139 | =================== 140 | 141 | SMSframework supports the following bundled providers: 142 | 143 | * [log](#logprovider): log provider for testing. Bundled. 144 | * [null](#nullprovider): null provider for testing. Bundled. 145 | * [loopback](#loopbackprovider): loopback provider for testing. Bundled. 146 | 147 | Supported providers list: 148 | 149 | * [Clickatell](https://github.com/kolypto/py-smsframework-clickatell) 150 | * [Vianett](https://github.com/kolypto/py-smsframework-vianett) 151 | * [PSWin](https://github.com/dignio/py-smsframework-pswin) 152 | * [Twilio Studio](https://github.com/dignio/py-smsframework-twiliostudio) 153 | * Expecting more! 154 | 155 | Also see the [full list of providers](https://pypi.python.org/pypi?%3Aaction=search&term=smsframework). 156 | 157 | 158 | 159 | 160 | 161 | 162 | Installation 163 | ============ 164 | 165 | Install from pypi: 166 | 167 | $ pip install smsframework 168 | 169 | Install with some additional providers: 170 | 171 | $ pip install smsframework[clickatell] 172 | 173 | To receive SMS messages, you need to ensure that [Flask microframework](http://flask.pocoo.org) is also installed: 174 | 175 | $ pip install smsframework[clickatell,receiver] 176 | 177 | 178 | 179 | 180 | 181 | 182 | Gateway 183 | ======= 184 | 185 | SMSframework handles the whole messaging thing with a single *Gateway* object. 186 | 187 | Let's start with initializing a gateway: 188 | 189 | ```python 190 | from smsframework import Gateway 191 | 192 | gateway = Gateway() 193 | ``` 194 | 195 | The `Gateway()` constructor currently has no arguments. 196 | 197 | 198 | 199 | Providers 200 | --------- 201 | A *Provider* is a package which implements the logic for a specific SMS provider. 202 | 203 | Each provider reside in an individual package `smsframework_*`. 204 | You'll probably want to install [some of these](#supported-providers) first. 205 | 206 | ### Gateway.add_provider(name, Provider, **config):IProvider 207 | Register a provider on the gateway 208 | 209 | Arguments: 210 | 211 | * `provider: str` Provider name that will be used to uniquely identify it 212 | * `Provider: type` Provider class that inherits from `smsframework.IProvider` 213 | You'll use this string in order to send messages via a specific provider. 214 | * `**config` Provider configuration. Please refer to the Provider documentation. 215 | 216 | ```python 217 | from smsframework.providers import NullProvider 218 | from smsframework_clickatell import ClickatellProvider 219 | 220 | gateway.add_provider('main', ClickatellProvider) # the default one 221 | gateway.add_provider('null', NullProvider) 222 | ``` 223 | 224 | The first provider defined becomes the default one: used in case the routing function has no better idea. 225 | See: [Message Routing](#message-routing). 226 | 227 | ### Gateway.default_provider 228 | Property which contains the default provider name. You can change it to something else: 229 | 230 | ```python 231 | gateway.default_provider = 'null' 232 | ``` 233 | 234 | ### Gateway.get_provider(name):IProvider 235 | Get a provider by name 236 | 237 | You don't normally need this, unless the provider has some public API: 238 | refer to the provider documentation for the details. 239 | 240 | 241 | 242 | Sending Messages 243 | ---------------- 244 | 245 | ### Gateway.send(message):OutgoingMessage 246 | To send a message, you first create the [`OutgoingMessage`](#outgoingmessage) object 247 | and then pass it as the first argument. 248 | 249 | Arguments: 250 | 251 | * `message: OutgoingMessage`: The messasge to send 252 | 253 | Exceptions: 254 | 255 | * `AssertionError`: Wrong provider name encountered (returned by the router, or provided to OutgoingMessage) 256 | * `ProviderError`: Generic provider error 257 | * `ConnectionError`: Connection failed 258 | * `MessageSendError`: Generic sending error 259 | * `RequestError`: Request error: likely, validation errors 260 | * `UnsupportedError`: The requested operation is not supported 261 | * `ServerError`: Server error: sevice unavailable, etc 262 | * `AuthError`: Provider authentication failed 263 | * `LimitsError`: Sending limits exceeded 264 | * `CreditError`: Not enough money on the account 265 | 266 | Returns: the same `OutgoingMessage`, with some additional fields populated: `msgid`, `meta`, .. 267 | 268 | ```python 269 | from smsframework import OutgoingMessage 270 | 271 | msg = gateway.send(OutgoingMessage('+123456789', 'hi there!')) 272 | ``` 273 | 274 | A message sending fail when the provider raises an exception. This typically occurs when the wrapped HTTP API 275 | has returned an immediate error. Note that some errors occur later, and are typically reported with status messages: 276 | see [`MessageStatus`](#messagestatus) 277 | 278 | 279 | 280 | Event Hooks 281 | ----------- 282 | 283 | The `Gateway` object has three events you can subscribe to. 284 | 285 | The event is a simple object that implements the `+=` and `-=` operators which allow you to subscribe to the event 286 | and unsubscribe respectively. 287 | 288 | Event hook is a python callable which accepts arguments explained in the further sections. 289 | 290 | Note that if you accidentally replace the hook with a callable (using the `=` operator instead of `+=`), you'll end 291 | up having a single hook, but smsframework will continue to work normally: thanks to the implementation. 292 | 293 | See [smsframework/lib/events.py](smsframework/lib/events.py). 294 | 295 | ### Gateway.onSend 296 | Outgoing Message: a message that was successfully sent. 297 | 298 | Arguments: 299 | 300 | * `message: OutgoingMessage`: The message that was sent. See [OutgoingMessage](#outgoingmessage). 301 | 302 | The message object is populated with the additional information from the provider, namely, the `msgid` and `meta` fields. 303 | 304 | Note that if the hook raises an Exception, it will propagate to the place where `Gateway.send()` was called! 305 | 306 | ```python 307 | def on_send(message): 308 | """ :type message: OutgoingMessage """ 309 | print(message) 310 | 311 | gw.onSend += on_send 312 | ``` 313 | 314 | ### Gateway.onReceive 315 | Incoming Message: a message that was received from the provider. 316 | 317 | Arguments: 318 | 319 | * `message: IncomingMessage`: The received message. See [IncomingMessage](#incomingmessage). 320 | 321 | Note that if the hook raises an Exception, the Provider will report the error to the sms service. 322 | Most services will retry the message delivery with increasing delays. 323 | 324 | ```python 325 | def on_receive(message): 326 | """ :type message: IncomingMessage """ 327 | print(message) 328 | 329 | gw.onReceive += on_receive 330 | ``` 331 | 332 | ### Gateway.onStatus 333 | Message Status: a message status reported by the provider. 334 | 335 | A status report is only delivered when explicitly requested with `OutgoingMessage.options(status_report=True)`. 336 | 337 | Arguments: 338 | 339 | * `status: MessageStatus`: The status info. See [MessageStatus](#messagestatus) and its subclasses. 340 | 341 | Note that if the hook raises an Exception, the Provider will report the error to the sms service. 342 | Most services will retry the status delivery with increasing delays. 343 | 344 | ```python 345 | def on_status(status): 346 | """ :type status: MessageStatus """ 347 | print(status) 348 | 349 | gw.onStatus += on_status 350 | ``` 351 | 352 | 353 | 354 | 355 | 356 | 357 | Data Objects 358 | ============ 359 | SMSframework uses the following objects to represent message flows. 360 | 361 | Note that internally all non-digit characters are removed from all phone numbers, both outgoing and incoming. 362 | Phone numbers are typically provided in international formats, though some local providers may be less strict with this. 363 | 364 | IncomingMessage 365 | --------------- 366 | A messsage received from the provider. 367 | 368 | Source: [smsframework/data/IncomingMessage.py](smsframework/data/IncomingMessage.py). 369 | 370 | OutgoingMessage 371 | -------------- 372 | A message being sent. 373 | 374 | Source: [smsframework/data/OutgoingMessage.py](smsframework/data/OutgoingMessage.py). 375 | 376 | MessageStatus 377 | ------------- 378 | A status report received from the provider. 379 | 380 | Source: [smsframework/data/MessageStatus.py](smsframework/data/MessageStatus.py). 381 | 382 | Exceptions 383 | ---------- 384 | 385 | Source: [smsframework/exc.py](smsframework/exc.py). 386 | 387 | 388 | 389 | 390 | 391 | 392 | Provider HTTP Receivers 393 | ======================= 394 | Note: the whole receiver feature is optional. Skip this section if you only need to send messages. 395 | 396 | In order to receive messages, most providers need an HTTP handler. 397 | 398 | To get standardized, by default providers use [Flask microframework](http://flask.pocoo.org) for this: 399 | a provider defines a [Blueprint](http://flask.pocoo.org/docs/blueprints/) which can be registered on your Flask 400 | application as the receiver endpoint. 401 | 402 | The resources are provider-dependent: refer to the provider documentation for the details. 403 | The recommended approach is to use `/im` for incoming messages, and `/status` for status reports. 404 | 405 | ## Gateway.receiver_blueprint_for(name): flask.Blueprint 406 | Get a Flask blueprint for the named provider that handles incoming messages & status reports. 407 | 408 | Returns: [flask.Blueprint](http://flask.pocoo.org/docs/blueprints/) 409 | 410 | Errors: 411 | 412 | * `KeyError`: provider not found 413 | * `NotImplementedError`: Provider does not implement a receiver 414 | 415 | This method is mostly internal, as the following ones are usually much more convenient. 416 | 417 | ## Gateway.receiver_blueprints():(name, flask.Blueprint)* 418 | Get Flask blueprints for every provider that supports it. 419 | 420 | The method is a generator that yields `(name, blueprint)` tuples, 421 | where `blueprint` is `flask.Blueprint` for provider named `name`. 422 | 423 | Use this method to register your receivers manually: 424 | 425 | ```python 426 | from flask import Flask 427 | 428 | app = Flask() 429 | 430 | for name, bp in gateway.receiver_blueprints(): 431 | app.register_blueprint(bp, url_prefix='/sms/'+name) 432 | ``` 433 | 434 | With the example above, each receivers will be registered under */name* prefix. 435 | 436 | Assuming the *'clickatell'* provider defines */im* and */status* receivers and your app is running on *http://localhost:5000/*, 437 | you will configure the SMS service to send messages to: 438 | 439 | * http://localhost:5000/sms/clickatell/im 440 | * http://localhost:5000/sms/clickatell/status 441 | 442 | ## Gateway.receiver_blueprints_register(app, prefix='/'):flask.Flask 443 | Register all provider receivers on the provided Flask application under '/{prefix}/provider-name'. 444 | 445 | This is a convenience method to register all blueprints at once using the following recommended rules: 446 | 447 | * If `prefix` is provided, all blueprints are registered under this prefix 448 | * Provider receivers are registered under '/provider-name' path 449 | 450 | It's adviced to mount the receivers under some difficult-to-guess prefix: otherwise, attackers can send 451 | fake messages into your system! 452 | 453 | Secure example: 454 | 455 | ```js 456 | gateway.receiver_blueprints_register(app, '/24fb0d6963f/'); 457 | ``` 458 | 459 | NOTE: Other mechanisms, such as basic authentication, are not typically useful as some services do not support that. 460 | 461 | 462 | 463 | 464 | 465 | 466 | Message Routing 467 | =============== 468 | SMSframework requires you to explicitly specify the provider for each message: 469 | otherwise, it uses the first defined provider by default. 470 | 471 | In real world conditions with multiple providers, you may want a router function that decides on which provider to use 472 | and which options to pick. 473 | 474 | In order to achieve flexible message routing, we need to associate some metadata with each message, for instance: 475 | 476 | * `module`: name of the sending module: e.g. "users" 477 | * `type`: type of the message: e.g. "notification" 478 | 479 | These 2 arbitrary strings need to be standardized in the application code, thus offering the possibility to define 480 | complex routing rules. 481 | 482 | When creating the message, use `OutgoingMessage.route()` function to specify these values: 483 | 484 | ```python 485 | gateway.send(OutgoingMessage('+1234', 'hi').route('users', 'notification')) 486 | ``` 487 | 488 | Now, set a router function on the gateway: 489 | a function which gets an outgoing message + some additional routing values, and decides on the provider to use: 490 | 491 | ```python 492 | gateway.add_provider('primary', ClickatellProvider, ...) 493 | gateway.add_provider('quick', ClickatellProvider, ...) 494 | gateway.add_provider('usa', ClickatellProvider, ...) 495 | 496 | def router(message, module, type): 497 | """ Custom router function """ 498 | if message.dst.startswith('1'): 499 | return 'usa' # Use 'usa' for all messages sent to the United States 500 | elif type == 'notification': 501 | return 'quick' # use the 'quick' for all notifications 502 | else: 503 | return None # Use the default provider ('primary') for everything else 504 | 505 | self.gw.router = router 506 | ``` 507 | 508 | Router function is also the right place to specify provider-specific options. 509 | 510 | 511 | 512 | 513 | 514 | 515 | Bundled Providers 516 | ================= 517 | The following providers are bundled with SMSframework and thus require no additional packages. 518 | 519 | NullProvider 520 | ------------ 521 | 522 | Source: [smsframework/providers/null.py](smsframework/providers/null.py) 523 | 524 | The `'null'` provider just ignores all outgoing messages. 525 | 526 | Configuration: none 527 | 528 | Sending: does nothing, but increments message.msgid 529 | 530 | Receipt: Not implemented 531 | 532 | Status: Not implemented 533 | 534 | ```python 535 | from smsframework.providers import NullProvider 536 | 537 | gw.add_provider('null', NullProvider) 538 | ``` 539 | 540 | LogProvider 541 | ----------- 542 | 543 | Source: [smsframework/providers/log.py](smsframework/providers/log.py) 544 | 545 | Logs the outgoing messages to a python logger provided as the config option. 546 | 547 | Configuration: 548 | 549 | * `logger: logging.Logger`: The logger to use. Default logger is used if nothing provided. 550 | 551 | Sending: does nothing, increments message.msgid, prints the message to the log 552 | 553 | Receipt: Not implemented 554 | 555 | Status: Not implemented 556 | 557 | Example: 558 | 559 | ```python 560 | import logging 561 | from smsframework.providers import LogProvider 562 | 563 | gw.add_provider('log', LogProvider, logger=logging.getLogger(__name__)) 564 | ``` 565 | 566 | LoopbackProvider 567 | ---------------- 568 | 569 | Source: [smsframework/providers/loopback.py](smsframework/providers/loopback.py) 570 | 571 | The `'loopback'` provider is used as a dummy for testing purposes. 572 | 573 | All messages are stored in the local log and can be retrieved as a list. 574 | 575 | The provider even supports status & delivery notifications. 576 | 577 | In addition, is supports virtual subscribers: callbacks bound to some phone numbers which are called when any 578 | simulated message is sent to their phone number. Replies are also supported! 579 | 580 | Configuration: none 581 | 582 | Sending: sends message to a registered subscriber (see: :meth:`LoopbackProvider.subscribe`), 583 | silently ignores other messages. 584 | 585 | Receipt: simulation with a method 586 | 587 | Status: always reports success 588 | 589 | ### LoopbackProvider.get_traffic():list 590 | LoopbackProvider stores all messages that go through it: both IncomingMessage and OutgoingMessage. 591 | 592 | To get those messages, call `.get_traffic()`. 593 | This method empties the message log and returns its previous state: 594 | 595 | ```python 596 | from smsframework.providers import LoopbackProvider 597 | 598 | gateway.add_provider('lo', LoopbackProvider); 599 | gateway.send(OutgoingMessage('+123', 'hi')) 600 | 601 | traffic = gateway.get_provider('lo').get_traffic() 602 | print(traffic[0].body) #-> 'hi' 603 | ``` 604 | 605 | ### LoopbackProvider.received(src, body):IncomingMessage 606 | Simulate an incoming message. 607 | 608 | The message is reported to the Gateway as if it has been received from the sms service. 609 | 610 | Arguments: 611 | 612 | * `src: str`: Source number 613 | * `body: str | unicode`: Message text 614 | 615 | Returns: IncomingMessage 616 | 617 | ### LoopbackProvider.subscribe(number, callback):IProvider 618 | Register a virtual subscriber which receives messages to the matching number. 619 | 620 | Arguments: 621 | 622 | * `number: str`: Subscriber phone number 623 | * `callback: `: A `callback(OutgoingMessage)` which handles the messages directed to the subscriber. 624 | The message object is augmented with the `.reply(str)` method which allows to send a reply easily! 625 | 626 | ```python 627 | def subscriber(message): 628 | print(message) #-> OutgoingMessage('1', 'obey me') 629 | message.reply('got it') # use the augmented reply method 630 | 631 | provider = gateway.get_provider('lo') 632 | provider.subscribe('+1', subscriber) # register the subscriber 633 | 634 | gateway.send('+1', 'obey me') 635 | ``` 636 | 637 | ForwardServerProvider, ForwardClientProvider 638 | -------------------------------------------- 639 | 640 | Source: [smsframework/providers/forward/provider.py](smsframework/providers/forward/provider.py) 641 | 642 | A pair of providers to bind two application instances together: 643 | 644 | * `ForwardClientProvider` can be used to send and receive messages using a remote server as a proxy 645 | * `ForwardServerProvider` is the remote server which: 646 | 647 | * Gets outgoing messages from clients and loops them back to the gateway so they're sent with another provider 648 | * Hooks into the gateway and passes all incoming messages and statuses to the clients 649 | 650 | Two providers are bound together using two pairs of receivers. You are not required to care about this :) 651 | 652 | Remote errors will be transparently re-raised on the local host. 653 | 654 | To support message receipt, include the necessary dependencies: 655 | 656 | pip install smsframework[receiver,async] 657 | 658 | ### ForwardClientProvider 659 | 660 | Example setup: 661 | 662 | ```python 663 | from smsframework.providers import ForwardClientProvider 664 | 665 | gw.add_provider('fwd', ForwardClientProvider, 666 | server_url='http://sms.example.com/sms/fwd') 667 | ``` 668 | 669 | Configuration: 670 | 671 | * `server_url`: URL to ForwardServerProvider installed on a remote host. 672 | All outgoing messages will be sent through it instead. 673 | 674 | ### ForwardServerProvider 675 | 676 | Example setup: 677 | 678 | ```python 679 | from smsframework.providers import ForwardServerProvider 680 | 681 | gw.add_provider(....) # Default provider 682 | gw.add_provider('fwd', ForwardServerProvider, clients=[ 683 | 'http://a.example.com/sms/fwd', 684 | 'http://b.example.com/sms/fwd', 685 | ]) 686 | ``` 687 | 688 | Configuration: 689 | 690 | * `clients`: List of URLs to ForwardClientProvider installed on remote hosts. 691 | All incoming messages and statuses will be forwarded to all specified clients. 692 | 693 | #### Routing Server 694 | If you want to forward only specific messages, you need to override the `choose_clients` method: 695 | given an object, which is either [`IncomingMessage`](#incomingmessage) or [`MessageStatus`](#messagestatus), it should 696 | return a list of client URLs the object should be forwarded to. 697 | 698 | Example: send all messages to "a.example.com", and status reports to "b.example.com": 699 | 700 | 701 | ```python 702 | from smsframework import ForwardServerProvider 703 | from smsframework.data import OutgoingMessage, MessageStatus 704 | 705 | class RoutingProvider(ForwardServerProvider): 706 | def choose_clients(self, obj): 707 | if isinstance(obj, OutgoingMessage): 708 | return [ self.clients[0] ] 709 | else: 710 | return [ self.clients[1] ] 711 | 712 | gw.add_provider(....) # Default provider 713 | gw.add_provider('fwd', RoutingProvider, clients=[ 714 | 'http://a.example.com/sms/fwd', 715 | 'http://b.example.com/sms/fwd', 716 | ]) 717 | ``` 718 | 719 | #### Async 720 | 721 | If your Server is going to forward messages to multiple clients simultaneously, you will probably want this to happen 722 | in parallel. 723 | 724 | Just install the `asynctools` dependency: 725 | 726 | pip install smsframework[receiver,async] 727 | 728 | #### Authentication 729 | 730 | Both Client and Server support HTTP basic authentication in URLs: 731 | 732 | http://user:password@a.example.com/sms/fwd 733 | 734 | For requests. Server-side authentication is your responsibility ;) 735 | 736 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | `Build Status `__ 2 | `Pythons <.travis.yml>`__ 3 | 4 | SMSframework 5 | ============ 6 | 7 | SMS framework with pluggable providers. 8 | 9 | Key features: 10 | 11 | - Send messages 12 | - Receive messages 13 | - Delivery confirmations 14 | - Handle multiple pluggable providers with a single gateway 15 | - Synchronous message receipt through events 16 | - Reliable message handling 17 | - Supports provider APIs (like getting the balance) 18 | - Providers use `Flask microframework `__ for 19 | message receivers (not required) 20 | - 0 dependencies 21 | - Unit-tested 22 | 23 | Table of Contents 24 | ================= 25 | 26 | - Tutorial 27 | - Supported Providers 28 | - Installation 29 | - Gateway 30 | 31 | - Providers 32 | 33 | - Gateway.add_provider(name, Provider, \**config):IProvider 34 | - Gateway.default_provider 35 | - Gateway.get_provider(name):IProvider 36 | 37 | - Sending Messages 38 | 39 | - Gateway.send(message):OutgoingMessage 40 | 41 | - Event Hooks 42 | 43 | - Gateway.onSend 44 | - Gateway.onReceive 45 | - Gateway.onStatus 46 | 47 | - Data Objects 48 | 49 | - IncomingMessage 50 | - OutgoingMessage 51 | - MessageStatus 52 | - Exceptions 53 | 54 | - Provider HTTP Receivers 55 | 56 | - Gateway.receiver_blueprint_for(name): flask.Blueprint 57 | - Gateway.receiver_blueprints():(name, flask.Blueprint)\* 58 | - Gateway.receiver_blueprints_register(app, prefix=‘/’):flask.Flask 59 | 60 | - Message Routing 61 | - Bundled Providers 62 | 63 | - NullProvider 64 | - LogProvider 65 | - LoopbackProvider 66 | 67 | - LoopbackProvider.get_traffic():list 68 | - LoopbackProvider.received(src, body):IncomingMessage 69 | - LoopbackProvider.subscribe(number, callback):IProvider 70 | 71 | - ForwardServerProvider, ForwardClientProvider 72 | 73 | - ForwardClientProvider 74 | - ForwardServerProvider 75 | 76 | - Routing Server 77 | 78 | Tutorial 79 | ======== 80 | 81 | Sending Messages 82 | ---------------- 83 | 84 | In order to send a message, you will use a ``Gateway``: 85 | 86 | .. code:: python 87 | 88 | from smsframework import Gateway 89 | gateway = Gateway() 90 | 91 | By itself, it cannot do anything. However, if you install a *provider* – 92 | a library that implements some SMS service – you can add it to the 93 | ``Gateway`` and configure it to send your messages through a provider: 94 | 95 | .. code:: python 96 | 97 | from smsframework_clickatell import ClickatellProvider 98 | 99 | gateway.add_provider('main', ClickatellProvider) # the default one 100 | 101 | The first provider defined becomes the default one. (If you have 102 | multiple providers, ``Gateway`` supports routing: rules that select 103 | which provider to use). 104 | 105 | Now, let’s send a message: 106 | 107 | .. code:: python 108 | 109 | from smsframework import OutgoingMessage 110 | 111 | gateway.send(OutgoingMessage('+123456789', 'hi there!')) 112 | 113 | Receiving Messages 114 | ------------------ 115 | 116 | In order to receive messages, you will use the same ``Gateway`` object 117 | and ask it to generate an HTTP API endpoint for you. It uses Flask 118 | framework, and you’ll need to run a Flask application in order to 119 | receive SMS messages: 120 | 121 | .. code:: pyhon 122 | 123 | from flask import Flask 124 | 125 | app = Flask() 126 | bp = gateway.receiver_blueprint_for('main') # SMS receiver 127 | app.register_blueprint(bp, url_prefix='/sms/main') # register it with Flask 128 | 129 | Now, use Clickatell’s web interface and register the following URL: 130 | ``http://example.com/sms/main``. It will send you messages to the 131 | application. 132 | 133 | Next, you need to handle the incoming messages in your code. To to this, 134 | you need to subscribe your handler to the ``gateway.onReceive`` event: 135 | 136 | .. code:: python 137 | 138 | def on_receive(message): 139 | """ :type message: IncomingMessage """ 140 | pass # Your logic here 141 | 142 | gateway.onReceive += on_receive 143 | 144 | In addition to receiving messages, you can receive status reports about 145 | the messages you have sent. See Gateway.onStatus for more information. 146 | 147 | Supported Providers 148 | =================== 149 | 150 | SMSframework supports the following bundled providers: 151 | 152 | - `log <#logprovider>`__: log provider for testing. Bundled. 153 | - `null <#nullprovider>`__: null provider for testing. Bundled. 154 | - `loopback <#loopbackprovider>`__: loopback provider for testing. 155 | Bundled. 156 | 157 | Supported providers list: 158 | 159 | - `Clickatell `__ 160 | - `Vianett `__ 161 | - `PSWin `__ 162 | - `Twilio 163 | Studio `__ 164 | - Expecting more! 165 | 166 | Also see the `full list of 167 | providers `__. 168 | 169 | Installation 170 | ============ 171 | 172 | Install from pypi: 173 | 174 | :: 175 | 176 | $ pip install smsframework 177 | 178 | Install with some additional providers: 179 | 180 | :: 181 | 182 | $ pip install smsframework[clickatell] 183 | 184 | To receive SMS messages, you need to ensure that `Flask 185 | microframework `__ is also installed: 186 | 187 | :: 188 | 189 | $ pip install smsframework[clickatell,receiver] 190 | 191 | Gateway 192 | ======= 193 | 194 | SMSframework handles the whole messaging thing with a single *Gateway* 195 | object. 196 | 197 | Let’s start with initializing a gateway: 198 | 199 | .. code:: python 200 | 201 | from smsframework import Gateway 202 | 203 | gateway = Gateway() 204 | 205 | The ``Gateway()`` constructor currently has no arguments. 206 | 207 | Providers 208 | --------- 209 | 210 | A *Provider* is a package which implements the logic for a specific SMS 211 | provider. 212 | 213 | Each provider reside in an individual package ``smsframework_*``. You’ll 214 | probably want to install `some of these <#supported-providers>`__ first. 215 | 216 | Gateway.add_provider(name, Provider, \**config):IProvider Register a provider on the gateway 217 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 218 | 219 | Arguments: 220 | 221 | - ``provider: str`` Provider name that will be used to uniquely 222 | identify it 223 | - ``Provider: type`` Provider class that inherits from 224 | ``smsframework.IProvider`` You’ll use this string in order to send 225 | messages via a specific provider. 226 | - ``**config`` Provider configuration. Please refer to the Provider 227 | documentation. 228 | 229 | .. code:: python 230 | 231 | from smsframework.providers import NullProvider 232 | from smsframework_clickatell import ClickatellProvider 233 | 234 | gateway.add_provider('main', ClickatellProvider) # the default one 235 | gateway.add_provider('null', NullProvider) 236 | 237 | The first provider defined becomes the default one: used in case the 238 | routing function has no better idea. See: `Message 239 | Routing <#message-routing>`__. 240 | 241 | Gateway.default_provider 242 | ~~~~~~~~~~~~~~~~~~~~~~~~ 243 | 244 | Property which contains the default provider name. You can change it to 245 | something else: 246 | 247 | .. code:: python 248 | 249 | gateway.default_provider = 'null' 250 | 251 | Gateway.get_provider(name):IProvider 252 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 253 | 254 | Get a provider by name 255 | 256 | You don’t normally need this, unless the provider has some public API: 257 | refer to the provider documentation for the details. 258 | 259 | .. _sending-messages-1: 260 | 261 | Sending Messages 262 | ---------------- 263 | 264 | Gateway.send(message):OutgoingMessage 265 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 266 | 267 | To send a message, you first create the 268 | ```OutgoingMessage`` <#outgoingmessage>`__ object and then pass it as 269 | the first argument. 270 | 271 | Arguments: 272 | 273 | - ``message: OutgoingMessage``: The messasge to send 274 | 275 | Exceptions: 276 | 277 | - ``AssertionError``: Wrong provider name encountered (returned by the 278 | router, or provided to OutgoingMessage) 279 | - ``ProviderError``: Generic provider error 280 | - ``ConnectionError``: Connection failed 281 | - ``MessageSendError``: Generic sending error 282 | - ``RequestError``: Request error: likely, validation errors 283 | - ``UnsupportedError``: The requested operation is not supported 284 | - ``ServerError``: Server error: sevice unavailable, etc 285 | - ``AuthError``: Provider authentication failed 286 | - ``LimitsError``: Sending limits exceeded 287 | - ``CreditError``: Not enough money on the account 288 | 289 | Returns: the same ``OutgoingMessage``, with some additional fields 290 | populated: ``msgid``, ``meta``, .. 291 | 292 | .. code:: python 293 | 294 | from smsframework import OutgoingMessage 295 | 296 | msg = gateway.send(OutgoingMessage('+123456789', 'hi there!')) 297 | 298 | A message sending fail when the provider raises an exception. This 299 | typically occurs when the wrapped HTTP API has returned an immediate 300 | error. Note that some errors occur later, and are typically reported 301 | with status messages: see ```MessageStatus`` <#messagestatus>`__ 302 | 303 | Event Hooks 304 | ----------- 305 | 306 | The ``Gateway`` object has three events you can subscribe to. 307 | 308 | The event is a simple object that implements the ``+=`` and ``-=`` 309 | operators which allow you to subscribe to the event and unsubscribe 310 | respectively. 311 | 312 | Event hook is a python callable which accepts arguments explained in the 313 | further sections. 314 | 315 | Note that if you accidentally replace the hook with a callable (using 316 | the ``=`` operator instead of ``+=``), you’ll end up having a single 317 | hook, but smsframework will continue to work normally: thanks to the 318 | implementation. 319 | 320 | See `smsframework/lib/events.py `__. 321 | 322 | Gateway.onSend 323 | ~~~~~~~~~~~~~~ 324 | 325 | Outgoing Message: a message that was successfully sent. 326 | 327 | Arguments: 328 | 329 | - ``message: OutgoingMessage``: The message that was sent. See 330 | `OutgoingMessage <#outgoingmessage>`__. 331 | 332 | The message object is populated with the additional information from the 333 | provider, namely, the ``msgid`` and ``meta`` fields. 334 | 335 | Note that if the hook raises an Exception, it will propagate to the 336 | place where ``Gateway.send()`` was called! 337 | 338 | .. code:: python 339 | 340 | def on_send(message): 341 | """ :type message: OutgoingMessage """ 342 | print(message) 343 | 344 | gw.onSend += on_send 345 | 346 | Gateway.onReceive 347 | ~~~~~~~~~~~~~~~~~ 348 | 349 | Incoming Message: a message that was received from the provider. 350 | 351 | Arguments: 352 | 353 | - ``message: IncomingMessage``: The received message. See 354 | `IncomingMessage <#incomingmessage>`__. 355 | 356 | Note that if the hook raises an Exception, the Provider will report the 357 | error to the sms service. Most services will retry the message delivery 358 | with increasing delays. 359 | 360 | .. code:: python 361 | 362 | def on_receive(message): 363 | """ :type message: IncomingMessage """ 364 | print(message) 365 | 366 | gw.onReceive += on_receive 367 | 368 | Gateway.onStatus 369 | ~~~~~~~~~~~~~~~~ 370 | 371 | Message Status: a message status reported by the provider. 372 | 373 | A status report is only delivered when explicitly requested with 374 | ``OutgoingMessage.options(status_report=True)``. 375 | 376 | Arguments: 377 | 378 | - ``status: MessageStatus``: The status info. See 379 | `MessageStatus <#messagestatus>`__ and its subclasses. 380 | 381 | Note that if the hook raises an Exception, the Provider will report the 382 | error to the sms service. Most services will retry the status delivery 383 | with increasing delays. 384 | 385 | .. code:: python 386 | 387 | def on_status(status): 388 | """ :type status: MessageStatus """ 389 | print(status) 390 | 391 | gw.onStatus += on_status 392 | 393 | Data Objects 394 | ============ 395 | 396 | SMSframework uses the following objects to represent message flows. 397 | 398 | Note that internally all non-digit characters are removed from all phone 399 | numbers, both outgoing and incoming. Phone numbers are typically 400 | provided in international formats, though some local providers may be 401 | less strict with this. 402 | 403 | IncomingMessage 404 | --------------- 405 | 406 | A messsage received from the provider. 407 | 408 | Source: 409 | `smsframework/data/IncomingMessage.py `__. 410 | 411 | OutgoingMessage 412 | --------------- 413 | 414 | A message being sent. 415 | 416 | Source: 417 | `smsframework/data/OutgoingMessage.py `__. 418 | 419 | MessageStatus 420 | ------------- 421 | 422 | A status report received from the provider. 423 | 424 | Source: 425 | `smsframework/data/MessageStatus.py `__. 426 | 427 | Exceptions 428 | ---------- 429 | 430 | Source: `smsframework/exc.py `__. 431 | 432 | Provider HTTP Receivers 433 | ======================= 434 | 435 | Note: the whole receiver feature is optional. Skip this section if you 436 | only need to send messages. 437 | 438 | In order to receive messages, most providers need an HTTP handler. 439 | 440 | To get standardized, by default providers use `Flask 441 | microframework `__ for this: a provider defines 442 | a `Blueprint `__ which can be 443 | registered on your Flask application as the receiver endpoint. 444 | 445 | The resources are provider-dependent: refer to the provider 446 | documentation for the details. The recommended approach is to use 447 | ``/im`` for incoming messages, and ``/status`` for status reports. 448 | 449 | Gateway.receiver_blueprint_for(name): flask.Blueprint 450 | ----------------------------------------------------- 451 | 452 | Get a Flask blueprint for the named provider that handles incoming 453 | messages & status reports. 454 | 455 | Returns: `flask.Blueprint `__ 456 | 457 | Errors: 458 | 459 | - ``KeyError``: provider not found 460 | - ``NotImplementedError``: Provider does not implement a receiver 461 | 462 | This method is mostly internal, as the following ones are usually much 463 | more convenient. 464 | 465 | Gateway.receiver_blueprints():(name, flask.Blueprint)\* Get Flask blueprints for every provider that supports it. 466 | ----------------------------------------------------------------------------------------------------------------- 467 | 468 | The method is a generator that yields ``(name, blueprint)`` tuples, 469 | where ``blueprint`` is ``flask.Blueprint`` for provider named ``name``. 470 | 471 | Use this method to register your receivers manually: 472 | 473 | .. code:: python 474 | 475 | from flask import Flask 476 | 477 | app = Flask() 478 | 479 | for name, bp in gateway.receiver_blueprints(): 480 | app.register_blueprint(bp, url_prefix='/sms/'+name) 481 | 482 | With the example above, each receivers will be registered under */name* 483 | prefix. 484 | 485 | Assuming the *‘clickatell’* provider defines */im* and */status* 486 | receivers and your app is running on *http://localhost:5000/*, you will 487 | configure the SMS service to send messages to: 488 | 489 | - http://localhost:5000/sms/clickatell/im 490 | - http://localhost:5000/sms/clickatell/status 491 | 492 | Gateway.receiver_blueprints_register(app, prefix=‘/’):flask.Flask 493 | ----------------------------------------------------------------- 494 | 495 | Register all provider receivers on the provided Flask application under 496 | ‘/{prefix}/provider-name’. 497 | 498 | This is a convenience method to register all blueprints at once using 499 | the following recommended rules: 500 | 501 | - If ``prefix`` is provided, all blueprints are registered under this 502 | prefix 503 | - Provider receivers are registered under ‘/provider-name’ path 504 | 505 | It’s adviced to mount the receivers under some difficult-to-guess 506 | prefix: otherwise, attackers can send fake messages into your system! 507 | 508 | Secure example: 509 | 510 | .. code:: js 511 | 512 | gateway.receiver_blueprints_register(app, '/24fb0d6963f/'); 513 | 514 | NOTE: Other mechanisms, such as basic authentication, are not typically 515 | useful as some services do not support that. 516 | 517 | Message Routing 518 | =============== 519 | 520 | SMSframework requires you to explicitly specify the provider for each 521 | message: otherwise, it uses the first defined provider by default. 522 | 523 | In real world conditions with multiple providers, you may want a router 524 | function that decides on which provider to use and which options to 525 | pick. 526 | 527 | In order to achieve flexible message routing, we need to associate some 528 | metadata with each message, for instance: 529 | 530 | - ``module``: name of the sending module: e.g. “users” 531 | - ``type``: type of the message: e.g. “notification” 532 | 533 | These 2 arbitrary strings need to be standardized in the application 534 | code, thus offering the possibility to define complex routing rules. 535 | 536 | When creating the message, use ``OutgoingMessage.route()`` function to 537 | specify these values: 538 | 539 | .. code:: python 540 | 541 | gateway.send(OutgoingMessage('+1234', 'hi').route('users', 'notification')) 542 | 543 | Now, set a router function on the gateway: a function which gets an 544 | outgoing message + some additional routing values, and decides on the 545 | provider to use: 546 | 547 | .. code:: python 548 | 549 | gateway.add_provider('primary', ClickatellProvider, ...) 550 | gateway.add_provider('quick', ClickatellProvider, ...) 551 | gateway.add_provider('usa', ClickatellProvider, ...) 552 | 553 | def router(message, module, type): 554 | """ Custom router function """ 555 | if message.dst.startswith('1'): 556 | return 'usa' # Use 'usa' for all messages sent to the United States 557 | elif type == 'notification': 558 | return 'quick' # use the 'quick' for all notifications 559 | else: 560 | return None # Use the default provider ('primary') for everything else 561 | 562 | self.gw.router = router 563 | 564 | Router function is also the right place to specify provider-specific 565 | options. 566 | 567 | Bundled Providers 568 | ================= 569 | 570 | The following providers are bundled with SMSframework and thus require 571 | no additional packages. 572 | 573 | NullProvider 574 | ------------ 575 | 576 | Source: 577 | `smsframework/providers/null.py `__ 578 | 579 | The ``'null'`` provider just ignores all outgoing messages. 580 | 581 | Configuration: none 582 | 583 | Sending: does nothing, but increments message.msgid 584 | 585 | Receipt: Not implemented 586 | 587 | Status: Not implemented 588 | 589 | .. code:: python 590 | 591 | from smsframework.providers import NullProvider 592 | 593 | gw.add_provider('null', NullProvider) 594 | 595 | LogProvider 596 | ----------- 597 | 598 | Source: 599 | `smsframework/providers/log.py `__ 600 | 601 | Logs the outgoing messages to a python logger provided as the config 602 | option. 603 | 604 | Configuration: 605 | 606 | - ``logger: logging.Logger``: The logger to use. Default logger is used 607 | if nothing provided. 608 | 609 | Sending: does nothing, increments message.msgid, prints the message to 610 | the log 611 | 612 | Receipt: Not implemented 613 | 614 | Status: Not implemented 615 | 616 | Example: 617 | 618 | .. code:: python 619 | 620 | import logging 621 | from smsframework.providers import LogProvider 622 | 623 | gw.add_provider('log', LogProvider, logger=logging.getLogger(__name__)) 624 | 625 | LoopbackProvider 626 | ---------------- 627 | 628 | Source: 629 | `smsframework/providers/loopback.py `__ 630 | 631 | The ``'loopback'`` provider is used as a dummy for testing purposes. 632 | 633 | All messages are stored in the local log and can be retrieved as a list. 634 | 635 | The provider even supports status & delivery notifications. 636 | 637 | In addition, is supports virtual subscribers: callbacks bound to some 638 | phone numbers which are called when any simulated message is sent to 639 | their phone number. Replies are also supported! 640 | 641 | Configuration: none 642 | 643 | Sending: sends message to a registered subscriber (see: 644 | :meth:``LoopbackProvider.subscribe``), silently ignores other messages. 645 | 646 | Receipt: simulation with a method 647 | 648 | Status: always reports success 649 | 650 | LoopbackProvider.get_traffic():list 651 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 652 | 653 | LoopbackProvider stores all messages that go through it: both 654 | IncomingMessage and OutgoingMessage. 655 | 656 | To get those messages, call ``.get_traffic()``. This method empties the 657 | message log and returns its previous state: 658 | 659 | .. code:: python 660 | 661 | from smsframework.providers import LoopbackProvider 662 | 663 | gateway.add_provider('lo', LoopbackProvider); 664 | gateway.send(OutgoingMessage('+123', 'hi')) 665 | 666 | traffic = gateway.get_provider('lo').get_traffic() 667 | print(traffic[0].body) #-> 'hi' 668 | 669 | LoopbackProvider.received(src, body):IncomingMessage 670 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 671 | 672 | Simulate an incoming message. 673 | 674 | The message is reported to the Gateway as if it has been received from 675 | the sms service. 676 | 677 | Arguments: 678 | 679 | - ``src: str``: Source number 680 | - ``body: str | unicode``: Message text 681 | 682 | Returns: IncomingMessage 683 | 684 | LoopbackProvider.subscribe(number, callback):IProvider 685 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 686 | 687 | Register a virtual subscriber which receives messages to the matching 688 | number. 689 | 690 | Arguments: 691 | 692 | - ``number: str``: Subscriber phone number 693 | - ``callback:``: A ``callback(OutgoingMessage)`` which handles the 694 | messages directed to the subscriber. The message object is augmented 695 | with the ``.reply(str)`` method which allows to send a reply easily! 696 | 697 | .. code:: python 698 | 699 | def subscriber(message): 700 | print(message) #-> OutgoingMessage('1', 'obey me') 701 | message.reply('got it') # use the augmented reply method 702 | 703 | provider = gateway.get_provider('lo') 704 | provider.subscribe('+1', subscriber) # register the subscriber 705 | 706 | gateway.send('+1', 'obey me') 707 | 708 | ForwardServerProvider, ForwardClientProvider 709 | -------------------------------------------- 710 | 711 | Source: 712 | `smsframework/providers/forward/provider.py `__ 713 | 714 | A pair of providers to bind two application instances together: 715 | 716 | - ``ForwardClientProvider`` can be used to send and receive messages 717 | using a remote server as a proxy 718 | - ``ForwardServerProvider`` is the remote server which: 719 | 720 | - Gets outgoing messages from clients and loops them back to the 721 | gateway so they’re sent with another provider 722 | - Hooks into the gateway and passes all incoming messages and 723 | statuses to the clients 724 | 725 | Two providers are bound together using two pairs of receivers. You are 726 | not required to care about this :) 727 | 728 | Remote errors will be transparently re-raised on the local host. 729 | 730 | To support message receipt, include the necessary dependencies: 731 | 732 | :: 733 | 734 | pip install smsframework[receiver,async] 735 | 736 | ForwardClientProvider 737 | ~~~~~~~~~~~~~~~~~~~~~ 738 | 739 | Example setup: 740 | 741 | .. code:: python 742 | 743 | from smsframework.providers import ForwardClientProvider 744 | 745 | gw.add_provider('fwd', ForwardClientProvider, 746 | server_url='http://sms.example.com/sms/fwd') 747 | 748 | Configuration: 749 | 750 | - ``server_url``: URL to ForwardServerProvider installed on a remote 751 | host. All outgoing messages will be sent through it instead. 752 | 753 | ForwardServerProvider 754 | ~~~~~~~~~~~~~~~~~~~~~ 755 | 756 | Example setup: 757 | 758 | .. code:: python 759 | 760 | from smsframework.providers import ForwardServerProvider 761 | 762 | gw.add_provider(....) # Default provider 763 | gw.add_provider('fwd', ForwardServerProvider, clients=[ 764 | 'http://a.example.com/sms/fwd', 765 | 'http://b.example.com/sms/fwd', 766 | ]) 767 | 768 | Configuration: 769 | 770 | - ``clients``: List of URLs to ForwardClientProvider installed on 771 | remote hosts. All incoming messages and statuses will be forwarded to 772 | all specified clients. 773 | 774 | Routing Server 775 | ^^^^^^^^^^^^^^ 776 | 777 | If you want to forward only specific messages, you need to override the 778 | ``choose_clients`` method: given an object, which is either 779 | ```IncomingMessage`` <#incomingmessage>`__ or 780 | ```MessageStatus`` <#messagestatus>`__, it should return a list of 781 | client URLs the object should be forwarded to. 782 | 783 | Example: send all messages to “a.example.com”, and status reports to 784 | “b.example.com”: 785 | 786 | .. code:: python 787 | 788 | from smsframework import ForwardServerProvider 789 | from smsframework.data import OutgoingMessage, MessageStatus 790 | 791 | class RoutingProvider(ForwardServerProvider): 792 | def choose_clients(self, obj): 793 | if isinstance(obj, OutgoingMessage): 794 | return [ self.clients[0] ] 795 | else: 796 | return [ self.clients[1] ] 797 | 798 | gw.add_provider(....) # Default provider 799 | gw.add_provider('fwd', RoutingProvider, clients=[ 800 | 'http://a.example.com/sms/fwd', 801 | 'http://b.example.com/sms/fwd', 802 | ]) 803 | 804 | Async 805 | ^^^^^ 806 | 807 | If your Server is going to forward messages to multiple clients 808 | simultaneously, you will probably want this to happen in parallel. 809 | 810 | Just install the ``asynctools`` dependency: 811 | 812 | :: 813 | 814 | pip install smsframework[receiver,async] 815 | 816 | Authentication 817 | ^^^^^^^^^^^^^^ 818 | 819 | Both Client and Server support HTTP basic authentication in URLs: 820 | 821 | :: 822 | 823 | http://user:password@a.example.com/sms/fwd 824 | 825 | For requests. Server-side authentication is your responsibility ;) 826 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | wheel 2 | nose 3 | exdoc 4 | j2cli 5 | flask 6 | asynctools 7 | testfixtures 8 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | """ Bi-directional SMS gateway with pluggable providers """ 3 | 4 | from setuptools import setup, find_packages 5 | 6 | setup( 7 | # http://pythonhosted.org/setuptools/setuptools.html 8 | name='smsframework', 9 | version='0.0.9-3', 10 | author='Mark Vartanyan', 11 | author_email='kolypto@gmail.com', 12 | 13 | url='https://github.com/kolypto/py-smsframework', 14 | license='BSD', 15 | description=__doc__, 16 | long_description=open('README.md').read(), 17 | long_description_content_type='text/markdown', 18 | keywords=['sms', 'message', 'notification', 'receive', 'send'], 19 | 20 | packages=find_packages(), 21 | scripts=[], 22 | entry_points={}, 23 | 24 | install_requires=[], 25 | extras_require={ 26 | 'clickatell': ['smsframework-clickatell >= 0.0.3'], 27 | 'vianett': ['smsframework-vianett >= 0.0.2'], 28 | 'receiver': ['flask >= 0.10'], 29 | 'async': ['asynctools >= 0.1.3'], 30 | }, 31 | include_package_data=True, 32 | test_suite='nose.collector', 33 | 34 | platforms='any', 35 | classifiers=[ 36 | # https://pypi.python.org/pypi?%3Aaction=list_classifiers 37 | 'Development Status :: 5 - Production/Stable', 38 | 'Intended Audience :: Developers', 39 | 'Natural Language :: English', 40 | 'Operating System :: OS Independent', 41 | 'Programming Language :: Python :: 2', 42 | 'Programming Language :: Python :: 3', 43 | 'Topic :: Software Development :: Libraries :: Python Modules', 44 | ], 45 | ) 46 | -------------------------------------------------------------------------------- /smsframework/Gateway.py: -------------------------------------------------------------------------------- 1 | from .IProvider import IProvider 2 | from .lib.events import EventHook 3 | 4 | 5 | class Gateway(object): 6 | """ SMS Gateway 7 | 8 | The primary object to send & receive messages. 9 | 10 | Sending Steps: 11 | 12 | 1. Instantiate the gateway: 13 | 14 | from smsframework import Gateway 15 | gw = Gateway() 16 | 17 | 1. Register providers with add_provider(): 18 | 19 | from smsframework.providers import ClickatellProvider 20 | gw.add_provider('main', ClickatellProvider, ...) 21 | 22 | 2. You already can send messages with send()! 23 | 24 | from smsframework import OutgoingMessage 25 | gw.send(OutgoingMessage('+123456789', 'hi there!')) 26 | 27 | 3. Replace the router() function to get customized routing that depends on message fields: 28 | 29 | gw.router = lambda message: 'main' 30 | 31 | 4. Use get_provider() to access provider-specific APIs: 32 | 33 | print gw.get_provider('main').get_balance() 34 | 35 | Receiving steps: 36 | 37 | 1. Register providers 38 | 2. Subscribe to onReceive and onStatus events: 39 | 40 | gw.onReceive += my_receive_handler 41 | gw.onStatus += my_status_handler 42 | 43 | 3. Initialize message receiver URLs with .... 44 | 4. Visit your provider's control panel and set up the receiver URLs 45 | 5. Enjoy! 46 | """ 47 | 48 | def __init__(self): 49 | #: Registered providers 50 | self._providers = {} 51 | 52 | # Events 53 | self.onSend = EventHook() 54 | self.onReceive = EventHook() 55 | self.onStatus = EventHook() 56 | 57 | 58 | 59 | #region Providers 60 | 61 | def add_provider(self, name, Provider, **config): 62 | """ Register a provider on the gateway 63 | 64 | The first provider defined becomes the default one: used in case the routing function has no better idea. 65 | 66 | :type name: str 67 | :param name: Provider name that will be used to uniquely identify it 68 | :type Provider: type 69 | :param Provider: Provider class that inherits from `smsframework.IProvider` 70 | :param config: Provider configuration. Please refer to the Provider documentation. 71 | :rtype: IProvider 72 | :returns: The created provider 73 | """ 74 | assert issubclass(Provider, IProvider), 'Provider does not implement IProvider' 75 | assert isinstance(name, str), 'Provider name must be a string' 76 | 77 | # Configure 78 | provider = Provider(self, name, **config) 79 | 80 | # Register 81 | assert name not in self._providers, 'Provider is already registered' 82 | self._providers[name] = provider 83 | 84 | # If first - set default 85 | if self.default_provider is None: 86 | self.default_provider = name 87 | 88 | # Finish 89 | return provider 90 | 91 | _default_provider = None 92 | 93 | @property 94 | def default_provider(self): 95 | """ Default provider name 96 | 97 | :rtype: str | None 98 | """ 99 | return self._default_provider 100 | 101 | @default_provider.setter 102 | def default_provider(self, name): 103 | assert name in self._providers, 'Provider "{}" is not registered'.format(name) 104 | self._default_provider = name 105 | 106 | def get_provider(self, name): 107 | """ Get a provider by name 108 | 109 | You don't normally need this, unless the provider has some public API: 110 | refer to the provider documentation for the details. 111 | 112 | :type name: str 113 | :param name: Provider name 114 | :rtype: IProvider 115 | :raises KeyError: provider not found 116 | """ 117 | return self._providers[name] 118 | 119 | #endregion 120 | 121 | 122 | 123 | #region Sending 124 | 125 | def router(self, message, *args): 126 | """ Router function that decides which provider to use for the given message for sending. 127 | 128 | Replace it with a function of your choice for custom routing. 129 | 130 | :type message: data.OutgoingMessage 131 | :param message: The message being sent 132 | :type args: Message routing values, as specified with the OutgoingMessage.route 133 | :rtype: str | None 134 | :returns: Provider name to use, or None to use the default one 135 | """ 136 | # By default, this always uses the default provider 137 | return None 138 | 139 | def send(self, message): 140 | """ Send a message object 141 | 142 | :type message: data.OutgoingMessage 143 | :param message: The message to send 144 | :rtype: data.OutgoingMessage 145 | :returns: The sent message with populated fields 146 | :raises AssertionError: wrong provider name encountered (returned by the router, or provided to OutgoingMessage) 147 | :raises MessageSendError: generic errors 148 | :raises AuthError: provider authentication failed 149 | :raises LimitsError: sending limits exceeded 150 | :raises CreditError: not enough money on the account 151 | """ 152 | # Which provider to use? 153 | provider_name = self._default_provider # default 154 | if message.provider is not None: 155 | assert message.provider in self._providers, \ 156 | 'Unknown provider specified in OutgoingMessage.provideer: {}'.format(provider_name) 157 | provider = self.get_provider(message.provider) 158 | else: 159 | # Apply routing 160 | if message.routing_values is not None: # Use the default provider when no routing values are given 161 | # Routing values are present 162 | provider_name = self.router(message, *message.routing_values) or self._default_provider 163 | assert provider_name in self._providers, \ 164 | 'Routing function returned an unknown provider name: {}'.format(provider_name) 165 | provider = self.get_provider(provider_name) 166 | 167 | # Set message provider name 168 | message.provider = provider.name 169 | 170 | # Send the message using the provider 171 | message = provider.send(message) 172 | 173 | # Emit the send event 174 | self.onSend(message) 175 | 176 | # Finish 177 | return message 178 | 179 | #region 180 | 181 | 182 | #region Receipt 183 | 184 | def receiver_blueprint_for(self, name): 185 | """ Get a Flask blueprint for the named provider that handles incoming messages & status reports 186 | 187 | Note: this requires Flask microframework. 188 | 189 | :rtype: flask.blueprints.Blueprint 190 | :returns: Flask Blueprint, fully functional 191 | :raises KeyError: provider not found 192 | :raises NotImplementedError: Provider does not implement a receiver 193 | """ 194 | # Get the provider & blueprint 195 | provider = self.get_provider(name) 196 | bp = provider.make_receiver_blueprint() 197 | 198 | # Register a Flask handler that initializes `g.provider` 199 | # This is the only way for the blueprint to get the current IProvider instance 200 | from flask.globals import g # local import as the user is not required to use receivers at all 201 | 202 | @bp.before_request 203 | def init_g(): 204 | g.provider = provider 205 | 206 | # Finish 207 | return bp 208 | 209 | def receiver_blueprints(self): 210 | """ Get Flask blueprints for every provider that supports it 211 | 212 | Note: this requires Flask microframework. 213 | 214 | :rtype: dict 215 | :returns: A dict { provider-name: Blueprint } 216 | """ 217 | blueprints = {} 218 | for name in self._providers: 219 | try: 220 | blueprints[name] = self.receiver_blueprint_for(name) 221 | except NotImplementedError: 222 | pass # Ignore providers that does not support receivers 223 | return blueprints 224 | 225 | def receiver_blueprints_register(self, app, prefix='/'): 226 | """ Register all provider receivers on the provided Flask application under '/{prefix}/provider-name' 227 | 228 | Note: this requires Flask microframework. 229 | 230 | :type app: flask.Flask 231 | :param app: Flask app to register the blueprints on 232 | :type prefix: str 233 | :param prefix: URL prefix to hide the receivers under. 234 | You likely want some random stuff here so no stranger can simulate incoming messages. 235 | :rtype: flask.Flask 236 | """ 237 | # Register 238 | for name, bp in self.receiver_blueprints().items(): 239 | app.register_blueprint( 240 | bp, 241 | url_prefix='{prefix}{name}'.format( 242 | prefix='/'+prefix.strip('/')+'/' if prefix else '/', 243 | name=name 244 | ) 245 | ) 246 | 247 | # Finish 248 | return app 249 | 250 | #endregion 251 | -------------------------------------------------------------------------------- /smsframework/IProvider.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | 3 | 4 | class IProvider(object): 5 | """ SmsFramework provider interface 6 | 7 | Implements methods to interact with the :class:`smsframework.Gateway` 8 | """ 9 | 10 | def __init__(self, gateway, name, **config): 11 | """ Initialize the provider 12 | 13 | :type gateway: Gateway 14 | :param gateway: Parent Gateway 15 | :type name: str 16 | :param name: Provider name. Used to uniquely identify the provider 17 | :param config: Provider-dependent configuration 18 | """ 19 | self.gateway = gateway 20 | self.name = name 21 | 22 | def send(self, message): 23 | """ Send a message 24 | 25 | Providers are required to: 26 | * Populate `message.msgid` and `message.meta` on completion 27 | * Expect that `message.src` can be empty 28 | * Support both ASCII and Unicode messages 29 | * Use `message.params` for provider-dependent configuration 30 | * Raise exceptions from `exc.py` for errors 31 | 32 | :type message: data.OutgoingMessage 33 | :param message: The message to send 34 | :rtype: OutgoingMessage 35 | :returns: The sent message with populated fields 36 | :raises MessageSendError: sending errors 37 | """ 38 | raise NotImplementedError('Provider.send not implemented') 39 | 40 | def make_receiver_blueprint(self): 41 | """ Get a Blueprint for the HTTP receiver 42 | 43 | :rtype: flask.Blueprint 44 | :returns: configured Flask Blueprint receiver 45 | :raises NotImplementedError: Provider does not support message reception 46 | """ 47 | raise NotImplementedError('Provider does not support message reception') 48 | 49 | 50 | #region Receiver callbacks 51 | 52 | def _receive_message(self, message): 53 | """ Incoming message callback 54 | 55 | Calls Gateway.onReceive event hook 56 | 57 | Providers are required to: 58 | * Cast phone numbers to digits-only 59 | * Support both ASCII and Unicode messages 60 | * Populate `message.msgid` and `message.meta` fields 61 | * If this method fails with an exception, the provider is required to respond with an error to the service 62 | 63 | :type message: IncomingMessage 64 | :param message: The received message 65 | :rtype: IncomingMessage 66 | """ 67 | # Populate fields 68 | message.provider = self.name 69 | 70 | # Fire the event hook 71 | self.gateway.onReceive(message) 72 | 73 | # Finish 74 | return message 75 | 76 | def _receive_status(self, status): 77 | """ Incoming status callback 78 | 79 | Calls Gateway.onStatus event hook 80 | 81 | Providers are required to: 82 | * Cast phone numbers to digits-only 83 | * Use proper MessageStatus subclasses 84 | * Populate `status.msgid` and `status.meta` fields 85 | * If this method fails with an exception, the provider is required to respond with an error to the service 86 | 87 | :type status: MessageStatus 88 | :param status: The received status 89 | :rtype: MessageStatus 90 | """ 91 | # Populate fields 92 | status.provider = self.name 93 | 94 | # Fire the event hook 95 | self.gateway.onStatus(status) 96 | 97 | # Finish 98 | return status 99 | 100 | #endregion 101 | -------------------------------------------------------------------------------- /smsframework/__init__.py: -------------------------------------------------------------------------------- 1 | from . import exc 2 | from .data import * 3 | 4 | from .Gateway import Gateway 5 | from .IProvider import IProvider 6 | -------------------------------------------------------------------------------- /smsframework/data/IncomingMessage.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from ..lib import digits_only 3 | 4 | 5 | class IncomingMessage(object): 6 | """ Incoming Message: Mobile Originated (MO) 7 | 8 | Represents a message received from the provider 9 | """ 10 | 11 | #: Provider name 12 | provider = None 13 | 14 | def __init__(self, src, body, msgid=None, dst=None, rtime=None, meta=None): 15 | """ Create the received message struct 16 | 17 | :type src: str 18 | :param src: Source number (sender) 19 | :type body: str | unicode 20 | :param body: Message contents 21 | :type msgid: str | None 22 | :param msgid: Message ID from the provider 23 | :type dst: str | None 24 | :param dst: Destination number (receiver) 25 | :type rtime: datetime 26 | :param rtime: Received time, naive, UTC 27 | :type meta: dict | None 28 | :param meta: Provider-dependent message info 29 | """ 30 | self.msgid = msgid 31 | self.src = digits_only(src) 32 | self.body = body 33 | self.dst = digits_only(dst) if dst else None 34 | self.rtime = rtime or datetime.utcnow() 35 | self.meta = meta or {} 36 | 37 | def __repr__(self): 38 | return '{cls}({provider!r}, {src!r}, {body!r}, dst={dst!r}, msgid={msgid!r})'.format( 39 | cls=self.__class__.__name__, 40 | provider=self.provider, 41 | src=self.src, 42 | dst=self.dst, 43 | body=self.body, 44 | msgid=self.msgid 45 | ) 46 | -------------------------------------------------------------------------------- /smsframework/data/MessageStatus.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | class MessageStatus(object): 4 | """ Sent Message Status 5 | 6 | Represent network's response to a sent message. 7 | """ 8 | 9 | #: Provider name 10 | provider = None 11 | 12 | #: Was the message accepted by the network? 13 | #: True | False | None (unknown) 14 | accepted = None 15 | 16 | #: Was the message delivered to the recipient? 17 | #: True | False | None (unknown) 18 | delivered = None 19 | 20 | #: Has the mesage expired? 21 | #: True | False 22 | expired = False 23 | 24 | #: Has an error occurred? See status then 25 | #: True | False 26 | error = False 27 | 28 | #: Status code from the provider, if any 29 | status_code = None 30 | 31 | #: Status text from the provider, if any 32 | status = None 33 | 34 | #: Provider-dependent info dict, if any 35 | meta = None 36 | 37 | def __init__(self, msgid, rtime=None, meta=None): 38 | """ Create the message status struct. 39 | 40 | Most fields are defined with direct property access, or by using subclasses 41 | 42 | :type msgid: str 43 | :param msgid: Unique message id 44 | :type rtime: datetime | None 45 | :param rtime: Status timestamp, naive, UTC 46 | :type meta: dict | None 47 | :param meta: Provider-dependent info 48 | """ 49 | self.msgid = msgid 50 | self.rtime = rtime or datetime.utcnow() 51 | self.meta = meta or {} 52 | 53 | @property 54 | def states(self): 55 | """ Get the set of states. Mostly used for pretty printing 56 | 57 | :rtype: set 58 | :returns: Set of 'accepted', 'delivered', 'expired', 'error' 59 | """ 60 | ret = set() 61 | if self.accepted: 62 | ret.add('accepted') 63 | if self.delivered: 64 | ret.add('delivered') 65 | if self.expired: 66 | ret.add('expired') 67 | if self.error: 68 | ret.add('error') 69 | return ret 70 | 71 | def __repr__(self): 72 | return '{cls}({provider!r}, {msgid!r}, state={state!r}, status={status!r})'.format( 73 | cls=self.__class__.__name__, 74 | provider=self.provider, 75 | msgid=self.msgid, 76 | state=self.states, 77 | status=self.status 78 | ) 79 | 80 | 81 | class MessageAccepted(MessageStatus): 82 | """ Accepted for processing 83 | 84 | The message contained no errors and was accepted, but not yet delivered. 85 | Not all providers report this status. 86 | """ 87 | accepted = True 88 | delivered = False 89 | expired = False 90 | 91 | 92 | class MessageDelivered(MessageAccepted): 93 | """ Delivered successfully 94 | 95 | The message was accepted and finally delivered 96 | """ 97 | accepted = True 98 | delivered = True 99 | expired = False 100 | 101 | 102 | class MessageExpired(MessageAccepted): 103 | """ Message has expired 104 | 105 | The message was accepted, has stayed idle for some time, and finally expired 106 | """ 107 | accepted = True 108 | delivered = False 109 | expired = True 110 | 111 | 112 | class MessageError(MessageStatus): 113 | """ Late Error 114 | 115 | The message was accepted (as no exception was raised by the Gateway.send() method), but later failed 116 | """ 117 | accepted = True 118 | delivered = False 119 | expired = False 120 | error = True 121 | -------------------------------------------------------------------------------- /smsframework/data/OutgoingMessage.py: -------------------------------------------------------------------------------- 1 | from .OutgoingMessageOptions import OutgoingMessageOptions 2 | from ..lib import digits_only 3 | 4 | 5 | class OutgoingMessage(object): 6 | """ Outgoing Message: Mobile Terminated (MT) 7 | 8 | Represents a message that's being sent or was sent to the provider 9 | """ 10 | 11 | #: Routing values 12 | routing_values = None 13 | 14 | #: Unique message id, populated by the provider on send 15 | msgid = None 16 | 17 | #: Provider-dependent message info dict, populated by the provider on send 18 | meta = None 19 | 20 | def __init__(self, dst, body, src=None, provider=None): 21 | """ Create a message for sending 22 | 23 | :type dst: str | None 24 | :param dst: Destination phone number. Non-digit chars are cut off 25 | :type body: str | unicode 26 | :param body: Message 27 | 28 | :type src: str | None 29 | :param src: Source phone number. Non-digit chars are cut off 30 | :type provider: str | None 31 | :param provider: Provider name to use for sending. 32 | If not specified explicitly, the message will be routed using the routing values: 33 | see :meth:`OutgoingMessage.route` 34 | """ 35 | self.src = src 36 | self.dst = digits_only(dst) 37 | self.body = body 38 | 39 | self.provider = provider 40 | 41 | #: Sending options for the Gateway 42 | self.provider_options = OutgoingMessageOptions() 43 | 44 | #: Provider-dependent sending parameters 45 | self.provider_params = {} 46 | 47 | def options(self, **kwargs): 48 | """ Specify sending options for the Gateway. 49 | 50 | See: :class:`OutgoingMessageOptions` 51 | 52 | :param allow_reply: Replies allowed? 53 | :param status_report: Request a status report from the network? 54 | :param expires: Message validity period, minutes 55 | :param senderId: Sender ID to replace the number 56 | :param escalate: Is a high-pri message? These are delivered faster and costier. 57 | 58 | :rtype: OutgoingMessage 59 | """ 60 | self.provider_options.__dict__.update(kwargs) 61 | return self 62 | 63 | def params(self, **params): 64 | """ Specify provider-specific sending parameters 65 | 66 | :rtype: OutgoingMessage 67 | """ 68 | self.provider_params = params 69 | return self 70 | 71 | def route(self, *args): 72 | """ Specify arbitrary routing values. 73 | 74 | These are used by the Gateway's routing function to decide on which provider to use for the message 75 | (if no provider was explicitly specified), 76 | 77 | If no routing values are provided at all - the default route is used. 78 | 79 | :rtype: OutgoingMessage 80 | """ 81 | self.routing_values = args 82 | return self 83 | 84 | def __repr__(self): 85 | return '{cls}({dst!r}, {body!r}, src={src!r}, provider={provider!r}, msgid={msgid!r})'.format( 86 | cls=self.__class__.__name__, 87 | dst=self.dst, 88 | body=self.body, 89 | src=self.src, 90 | provider=self.provider, 91 | msgid=self.msgid 92 | ) 93 | -------------------------------------------------------------------------------- /smsframework/data/OutgoingMessageOptions.py: -------------------------------------------------------------------------------- 1 | class OutgoingMessageOptions(object): 2 | """ Sending Options for :class:`OutgoingMessage` """ 3 | 4 | #: Replies allowed? 5 | allow_reply = True 6 | 7 | #: Request a status report from the network? 8 | status_report = False 9 | 10 | #: Message validity period, minutes 11 | expires = None 12 | 13 | #: Sender ID to replace the number 14 | senderId = None 15 | 16 | #: Is a high-pri message? These are delivered faster and costier. 17 | escalate = False 18 | -------------------------------------------------------------------------------- /smsframework/data/__init__.py: -------------------------------------------------------------------------------- 1 | from .IncomingMessage import IncomingMessage 2 | from .OutgoingMessage import OutgoingMessage 3 | from .MessageStatus import MessageStatus, \ 4 | MessageAccepted, MessageDelivered, MessageExpired, MessageError 5 | -------------------------------------------------------------------------------- /smsframework/exc.py: -------------------------------------------------------------------------------- 1 | class ProviderError(RuntimeError): 2 | """ Generic provider error """ 3 | 4 | 5 | class ConnectionError(ProviderError): 6 | """ Connection failed """ 7 | 8 | 9 | #region Sending errors 10 | 11 | class MessageSendError(ProviderError): 12 | """ Base for erorrs reported by the provider """ 13 | 14 | 15 | class RequestError(MessageSendError): 16 | """ Request error: likely, validation errors """ 17 | 18 | 19 | class UnsupportedError(MessageSendError): 20 | """ The requested operation is not supported """ 21 | 22 | 23 | class ServerError(MessageSendError): 24 | """ Server error: sevice unavailable, etc """ 25 | 26 | 27 | class AuthError(MessageSendError): 28 | """ Authentication error """ 29 | 30 | 31 | class LimitsError(MessageSendError): 32 | """ Sending limits exceeded """ 33 | 34 | 35 | class CreditError(MessageSendError): 36 | """ Not enough money on the account """ 37 | 38 | 39 | #endregion 40 | -------------------------------------------------------------------------------- /smsframework/lib/__init__.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | def digits_only(num): 5 | """ Remove all non-digit characters from the phone number 6 | 7 | :type num: str 8 | :param num: Phone number 9 | :rtype: str 10 | """ 11 | return re.sub(r'[^\d]+', '', num) 12 | -------------------------------------------------------------------------------- /smsframework/lib/events.py: -------------------------------------------------------------------------------- 1 | class EventHook(object): 2 | """ Event Pattern 3 | 4 | Create: 5 | event = EventHook() 6 | Subscribe: 7 | event += handler 8 | Unsubscribe: 9 | event -= handler 10 | Fire: 11 | event(...) 12 | 13 | Based on: http://www.voidspace.org.uk/python/weblog/arch_d7_2007_02_03.shtml#e616 14 | """ 15 | 16 | def __init__(self): 17 | self.__handlers = [] 18 | 19 | def __iadd__(self, handler): 20 | self.__handlers.append(handler) 21 | return self 22 | 23 | def __isub__(self, handler): 24 | self.__handlers.remove(handler) 25 | return self 26 | 27 | def __call__(self, *args, **kwargs): 28 | for handler in self.__handlers: 29 | handler(*args, **kwargs) 30 | -------------------------------------------------------------------------------- /smsframework/providers/__init__.py: -------------------------------------------------------------------------------- 1 | """ Bundled providers """ 2 | 3 | from .null import NullProvider 4 | from .log import LogProvider 5 | from .loopback import LoopbackProvider 6 | from .forward import ForwardClientProvider, ForwardServerProvider 7 | -------------------------------------------------------------------------------- /smsframework/providers/forward/__init__.py: -------------------------------------------------------------------------------- 1 | from .provider import ForwardClientProvider, ForwardServerProvider 2 | -------------------------------------------------------------------------------- /smsframework/providers/forward/jsonex.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime, time 2 | from json import JSONDecoder, JSONEncoder 3 | import inspect 4 | 5 | 6 | class JsonExEncoder(JSONEncoder): 7 | """ JsonEx encoder, which can marshall objects and exceptions """ 8 | def default(self, o): 9 | if isinstance(o, (date, datetime, time)): 10 | return {'?': [o.__class__.__name__, o.isoformat()]} 11 | if isinstance(o, BaseException): 12 | return {'?E': [o.__class__.__name__, o.args]} 13 | return {'?': [ 14 | o.__class__.__name__, 15 | o.__dict__ 16 | ]} 17 | 18 | 19 | class JsonExDecoder(JSONDecoder): 20 | """ JsonEx decoder, which can un-marshall objects """ 21 | 22 | def __init__(self, classes, exceptions, *args, **kwargs): 23 | """ 24 | :param classes: 25 | Dict of class names, 26 | mapped to: 27 | - Class 28 | - Callable function: lambda **props 29 | With class, property names MUST match constructor argument names! 30 | Extra properties are stupidly copied to the object 31 | :type classes: dict 32 | :param exceptions: 33 | Dict of exception class names, 34 | mapped to: 35 | - Class 36 | - Callable function: lambda *args 37 | If exception is not registered -- defaults to RuntimeError 38 | :type exceptions: dict 39 | """ 40 | self.classes = classes 41 | self.exceptions = exceptions 42 | 43 | kwargs['object_hook'] = self.dict_to_object 44 | super(JsonExDecoder, self).__init__(*args, object_hook=self.dict_to_object) 45 | 46 | def dict_to_object(self, d): 47 | # Special handling for exceptions 48 | if '?E' in d and len(d) == 1: 49 | exc, args = d['?E'] 50 | if exc not in self.exceptions: 51 | # Fallback 52 | return RuntimeError(*args) 53 | else: 54 | E = self.exceptions[exc] 55 | return E(*args) 56 | 57 | # Special handling for objects 58 | if '?' in d and len(d) == 1: 59 | cls, props = d['?'] 60 | 61 | # Special handling for dates 62 | if cls == 'datetime': 63 | return datetime.strptime(props, '%Y-%m-%dT%H:%M:%S.%f') 64 | elif cls == 'date': 65 | return datetime.strptime(props, '%Y-%m-%d').date() 66 | elif cls == 'time': 67 | return datetime.strptime(props, '%H:%M:%S.%f').time() 68 | 69 | # Other classes 70 | if cls in self.classes: 71 | C = self.classes[cls] 72 | if inspect.isfunction(C): 73 | # Lambda-constructor 74 | o = C(**props) 75 | elif inspect.isclass(C): 76 | # Dict constructor 77 | if not hasattr(C, '__init__') or C.__init__ is object.__init__: 78 | # Classes without an explicitly declared constructor 79 | o = C() 80 | else: 81 | # Classes with a constructor 82 | argspec = inspect.getargspec(C.__init__) 83 | 84 | # Arguments should be named after properties 85 | args = [props.pop(a) for a in argspec.args[1:]] # (self, (....)) 86 | 87 | # Create object 88 | o = C(*args) 89 | 90 | # And now copy the remaining props 91 | for k, v in props.items(): 92 | setattr(o, k, v) 93 | 94 | return o 95 | 96 | # Nothing special, return as is 97 | return d 98 | -------------------------------------------------------------------------------- /smsframework/providers/forward/provider.py: -------------------------------------------------------------------------------- 1 | import json, base64 2 | from functools import wraps 3 | import logging 4 | 5 | 6 | try: # Py3 7 | from urllib.request import urlopen, Request, HTTPError, URLError 8 | from urllib.parse import urlsplit, urlunsplit 9 | except ImportError: # Py2 10 | from urllib2 import urlopen, Request, HTTPError, URLError 11 | from urlparse import urlsplit, urlunsplit 12 | 13 | from smsframework import IProvider, exc 14 | from .jsonex import JsonExEncoder, JsonExDecoder 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | try: from asynctools.threading import Parallel 19 | except ImportError: Parallel = None 20 | 21 | 22 | #region JsonEx 23 | 24 | from smsframework.data import * 25 | from smsframework.data.OutgoingMessageOptions import OutgoingMessageOptions 26 | from smsframework.exc import * 27 | 28 | classes = {C.__name__: C for C in ( 29 | IncomingMessage, OutgoingMessage, 30 | OutgoingMessageOptions, 31 | MessageStatus, MessageAccepted, MessageDelivered, MessageExpired, MessageError, 32 | )} 33 | 34 | exceptions = {E.__name__: E for E in ( 35 | ProviderError, ConnectionError, 36 | MessageSendError, RequestError, UnsupportedError, ServerError, AuthError, LimitsError, CreditError 37 | )} 38 | 39 | try: 40 | from flask import make_response 41 | from werkzeug.exceptions import HTTPException 42 | except ImportError: pass 43 | 44 | def jsonex_dumps(data): 45 | """ Serialize with JsonEx 46 | :rtype: basestring 47 | """ 48 | return json.dumps(data, cls=JsonExEncoder).encode() 49 | 50 | 51 | def jsonex_loads(s): 52 | """ Unserialize with JsonEx 53 | :rtype: dict 54 | """ 55 | return json.loads(s.decode('utf-8'), cls=JsonExDecoder, classes=classes, exceptions=exceptions) 56 | 57 | 58 | def jsonex_api(f): 59 | """ View wrapper for JsonEx responses. Catches exceptions as well """ 60 | @wraps(f) 61 | def wrapper(*args, **kwargs): 62 | # Call, catch exceptions 63 | try: 64 | code, res = 200, f(*args, **kwargs) 65 | except HTTPException as e: 66 | code, res = e.code, {'error': e} 67 | except Exception as e: 68 | code, res = 500, {'error': e} 69 | logger.exception('Method error') 70 | 71 | # Response 72 | response = make_response(jsonex_dumps(res), code) 73 | response.headers['Content-Type'] = 'application/json' 74 | return response 75 | return wrapper 76 | 77 | 78 | def _parse_authentication(url): 79 | """ Parse authentication data from the URL and put it in the `headers` dict. With caching behavior 80 | :param url: URL 81 | :type url: str 82 | :return: (URL without authentication info, headers dict) 83 | :rtype: str, dict 84 | """ 85 | u = url 86 | h = {} # New headers 87 | 88 | # Cache? 89 | if url in _parse_authentication._memoize: 90 | u, h = _parse_authentication._memoize[url] 91 | else: 92 | # Parse 93 | p = urlsplit(url, 'http') 94 | if p.username and p.password: 95 | # Prepare header 96 | h['Authorization'] = b'Basic ' + base64.b64encode(p.username.encode() + b':' + p.password.encode()) 97 | # Remove authentication info since urllib2.Request() does not understand it 98 | u = urlunsplit((p.scheme, p.netloc.split('@', 1)[1], p.path, p.query, p.fragment)) 99 | # Cache 100 | _parse_authentication._memoize[url] = (u, h) 101 | 102 | # Finish 103 | return u, h 104 | _parse_authentication._memoize = {} 105 | 106 | 107 | def jsonex_request(url, data, headers=None): 108 | """ Make a request with JsonEx 109 | :param url: URL 110 | :type url: str 111 | :param data: Data to POST 112 | :type data: dict 113 | :return: Response 114 | :rtype: dict 115 | :raises exc.ConnectionError: Connection error 116 | :raises exc.ServerError: Remote server error (unknown) 117 | :raises exc.ProviderError: any errors reported by the remote 118 | """ 119 | # Authentication? 120 | url, headers = _parse_authentication(url) 121 | headers['Content-Type'] = 'application/json' 122 | 123 | # Request 124 | try: 125 | req = Request(url, headers=headers) 126 | response = urlopen(req, jsonex_dumps(data)) 127 | res_str = response.read() 128 | res = jsonex_loads(res_str) 129 | except HTTPError as e: 130 | if 'Content-Type' in e.headers and e.headers['Content-Type'] == 'application/json': 131 | res = jsonex_loads(e.read()) 132 | else: 133 | raise exc.ServerError('Server at "{}" failed: {}'.format(url, e)) 134 | except URLError as e: 135 | raise exc.ConnectionError('Connection to "{}" failed: {}'.format(url, e)) 136 | 137 | # Errors? 138 | if 'error' in res: # Exception object 139 | raise res['error'] # Error raised by the remote side 140 | 141 | return res 142 | 143 | #endregion 144 | 145 | 146 | class ForwardClientProvider(IProvider): 147 | """ Forwarding client Provider 148 | 149 | - Sends messages through a remote ForwardServerProvider 150 | - Receives messages from a remote ForwardServerProvider 151 | """ 152 | 153 | def __init__(self, gateway, name, server_url): 154 | """ Init the forwarding client 155 | :param server_url: Server URL. 156 | The URL should point to ForwardServerProvider registered on the server 157 | :type server_url: str 158 | """ 159 | self.server_url = server_url.rstrip('/') + '/' # ensure trailing slash 160 | super(ForwardClientProvider, self).__init__(gateway, name) 161 | 162 | def send(self, message): 163 | """ Send a message by forwarding it to the server 164 | :param message: Message 165 | :type message: smsframework.data.OutgoingMessage 166 | :rtype: smsframework.data.OutgoingMessage 167 | :raise Exception: any exception reported by the other side 168 | :raise urllib2.URLError: Connection error 169 | """ 170 | res = jsonex_request(self.server_url + '/im'.lstrip('/'), {'message': message}) 171 | msg = res['message'] # OutgoingMessage object 172 | 173 | # Replace properties in the original object (so it's the same object, like with other providers) 174 | for k, v in msg.__dict__.items(): 175 | setattr(message, k, v) 176 | return message 177 | 178 | def make_receiver_blueprint(self): 179 | """ Create the receiver so server can send messages to us 180 | :rtype: flask.Blueprint 181 | """ 182 | from .receiver_client import bp 183 | return bp 184 | 185 | def _receive_message(self, message): 186 | # Overriden method to preserve the original provider name 187 | self.gateway.onReceive(message) 188 | return message 189 | 190 | def _receive_status(self, status): 191 | # Overriden method to preserve the original provider name 192 | self.gateway.onStatus(status) 193 | return status 194 | 195 | 196 | class ForwardServerProvider(IProvider): 197 | """ Forwarding server Provider 198 | 199 | Hooks into the gateway and: 200 | - Forwards all received messages and statuses to clients 201 | - Receives messages from clients and sends them through the gateway 202 | """ 203 | def __init__(self, gateway, name, clients): 204 | """ Init server 205 | :param clients: List of client URLs to forward the messages to. 206 | The URL should point to ForwardClientProvider registered on the client 207 | :type clients: list[str] 208 | """ 209 | self.clients = clients 210 | super(ForwardServerProvider, self).__init__(gateway, name) 211 | 212 | # Hook into the gateway 213 | self.gateway.onReceive += self.forward 214 | self.gateway.onStatus += self.forward 215 | 216 | def choose_clients(self, obj): 217 | """ Given a message, decides which clients will receive it. 218 | 219 | Override to have custom routing. Default: send to all clients 220 | 221 | :param obj: The object to be forwarded 222 | :type obj: smsframework.data.IncomingMessage|smsframework.data.MessageStatus 223 | :return: List of client URLs to forward the message to 224 | :rtype: list[str] 225 | """ 226 | return self.clients 227 | 228 | def _forward_object_to_client(self, client, obj): 229 | """ Forward an object to client 230 | :type client: str 231 | :type obj: smsframework.data.IncomingMessage|smsframework.data.MessageStatus 232 | :rtype: smsframework.data.IncomingMessage|smsframework.data.MessageStatus 233 | :raise Exception: any exception reported by the other side 234 | """ 235 | url, name = ('/im', 'message') if isinstance(obj, IncomingMessage) else ('/status', 'status') 236 | res = jsonex_request(client.rstrip('/') + '/' + url.lstrip('/'), {name: obj}) 237 | return res[name] 238 | 239 | def forward(self, obj): 240 | """ Forward an object to clients. 241 | 242 | :param obj: The object to be forwarded 243 | :type obj: smsframework.data.IncomingMessage|smsframework.data.MessageStatus 244 | :raises Exception: if any of the clients failed 245 | """ 246 | assert isinstance(obj, (IncomingMessage, MessageStatus)), 'Tried to forward an object of an unsupported type: {}'.format(obj) 247 | clients = self.choose_clients(obj) 248 | 249 | if Parallel: 250 | pll = Parallel(self._forward_object_to_client) 251 | for client in clients: 252 | pll(client, obj) 253 | results, errors = pll.join() 254 | if errors: 255 | raise errors[0] 256 | else: 257 | for client in clients: 258 | self._forward_object_to_client(client, obj) 259 | 260 | def send(self, message): 261 | """ Send a message by looping back to gateway so it sends with some other provider 262 | :param message: Message 263 | :type message: data.OutgoingMessage 264 | :rtype: data.OutgoingMessage 265 | """ 266 | message.provider = None # Make sure that no provider was set by the Client 267 | return self.gateway.send(message) 268 | 269 | def make_receiver_blueprint(self): 270 | """ Create the receiver: it gets messages from clients and actually sends them by looping to the own gateway 271 | :rtype: flask.Blueprint 272 | """ 273 | from .receiver_server import bp 274 | return bp 275 | -------------------------------------------------------------------------------- /smsframework/providers/forward/receiver_client.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | from flask.globals import request, g 3 | 4 | from .provider import jsonex_loads, jsonex_api 5 | 6 | bp = Blueprint('smsframework-forward-client', __name__, url_prefix='/') 7 | 8 | 9 | @bp.route('/im', methods=['POST']) 10 | @jsonex_api 11 | def im(): 12 | """ Incoming message handler: forwarded by ForwardServerProvider """ 13 | req = jsonex_loads(request.get_data()) 14 | message = g.provider._receive_message(req['message']) 15 | return {'message': message} 16 | 17 | 18 | @bp.route('/status', methods=['POST']) 19 | @jsonex_api 20 | def status(): 21 | """ Incoming status handler: forwarded by ForwardServerProvider """ 22 | req = jsonex_loads(request.get_data()) 23 | status = g.provider._receive_status(req['status']) 24 | return {'status': status} 25 | -------------------------------------------------------------------------------- /smsframework/providers/forward/receiver_server.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | from flask.globals import request, g 3 | 4 | from .provider import jsonex_loads, jsonex_dumps, jsonex_api 5 | 6 | bp = Blueprint('smsframework-forward-server', __name__, url_prefix='/') 7 | 8 | 9 | @bp.route('/im', methods=['POST']) 10 | @jsonex_api 11 | def im(): 12 | """ Incoming message handler: sent by ForwardClientProvider """ 13 | req = jsonex_loads(request.get_data()) 14 | message = g.provider.send(req['message']) 15 | return {'message': message} 16 | -------------------------------------------------------------------------------- /smsframework/providers/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from .null import NullProvider 4 | 5 | 6 | class LogProvider(NullProvider): 7 | """ Log Provider 8 | 9 | Logs the outgoing messages to a python logger provided as the config option. 10 | 11 | Configuration: target logger 12 | 13 | Sending: does nothing, increments message.msgid, prints the message to the log 14 | 15 | Receipt: Not implemented 16 | 17 | Status: Not implemented 18 | """ 19 | 20 | def __init__(self, gateway, name, logger=None): 21 | """ Configure provider 22 | 23 | :type logger: logging.Logger | None 24 | :param logger: The logger to use. Default logger is used if nothing provided 25 | """ 26 | super(LogProvider, self).__init__(gateway, name) 27 | self.logger = logger or logging.getLogger(__name__) 28 | 29 | def send(self, message): 30 | # Log 31 | self.logger.info('Sent SMS to {message.dst}: {message.body}'.format(message=message)) 32 | 33 | # Finish 34 | return super(LogProvider, self).send(message) 35 | -------------------------------------------------------------------------------- /smsframework/providers/loopback.py: -------------------------------------------------------------------------------- 1 | from .null import NullProvider 2 | from ..lib import digits_only 3 | from ..data import IncomingMessage, MessageAccepted, MessageDelivered 4 | 5 | 6 | class LoopbackProvider(NullProvider): 7 | """ Loopback Provider 8 | 9 | Sends messages to registered subscriber callbacks. 10 | 11 | Configuration: none 12 | 13 | Sending: sends message to a registered subscriber (see: :meth:`LoopbackProvider.subscribe`), 14 | silently ignores other messages 15 | 16 | Receipt: simulation with a method 17 | 18 | Status: always reports success (if was requested for the message) 19 | """ 20 | 21 | def __init__(self, gateway, name): 22 | super(LoopbackProvider, self).__init__(gateway, name) 23 | 24 | #: Virtual subscribers 25 | #: { Phone number : callable(message) } 26 | self._subscribers = {} 27 | 28 | #: Message traffic 29 | #: list 30 | self._traffic = [] 31 | 32 | 33 | #region Public API 34 | 35 | def get_traffic(self): 36 | """ Fetch the accumulated messages and reset 37 | 38 | :rtype: list 39 | :returns: List of both IncomingMessage & OutgoingMessage objects 40 | """ 41 | try: 42 | return self._traffic 43 | finally: 44 | self._traffic = [] 45 | 46 | def received(self, src, body): 47 | """ Simulate an incoming message 48 | 49 | :type src: str 50 | :param src: Message source 51 | :type boby: str | unicode 52 | :param body: Message body 53 | :rtype: IncomingMessage 54 | """ 55 | # Create the message 56 | self._msgid += 1 57 | message = IncomingMessage(src, body, self._msgid) 58 | 59 | # Log traffic 60 | self._traffic.append(message) 61 | 62 | # Handle it 63 | self._receive_message(message) 64 | 65 | # Finish 66 | return message 67 | 68 | def subscribe(self, number, callback): 69 | """ Register a virtual subscriber which receives messages to the matching number. 70 | 71 | :type number: str 72 | :param number: Subscriber phone number 73 | :type callback: callable 74 | :param callback: A callback(OutgoingMessage) which handles the messages directed to the subscriber. 75 | The message object is augmented with the .reply(str) method which allows to send a reply easily! 76 | :rtype: LoopbackProvider 77 | """ 78 | self._subscribers[digits_only(number)] = callback 79 | return self 80 | 81 | #endregion 82 | 83 | 84 | def send(self, message): 85 | message = super(LoopbackProvider, self).send(message) 86 | 87 | # Log traffic 88 | self._traffic.append(message) 89 | 90 | # Deliver to the subscriber 91 | subscriber_found = message.dst in self._subscribers 92 | if subscriber_found: 93 | # Augment the message with .reply(str) 94 | def reply(body): 95 | if message.provider_options.allow_reply: 96 | self.received(message.dst, body) 97 | message.reply = reply 98 | 99 | # Deliver 100 | self._subscribers[message.dst](message) 101 | 102 | # Delivery notification 103 | if message.provider_options.status_report: 104 | # Decide on the MessageStatus class to use 105 | StatusCls = MessageDelivered if subscriber_found else MessageAccepted 106 | 107 | # Handle status 108 | status = StatusCls(message.msgid) 109 | status.status = 'OK' 110 | self._receive_status(status) 111 | 112 | return message 113 | -------------------------------------------------------------------------------- /smsframework/providers/null.py: -------------------------------------------------------------------------------- 1 | from ..IProvider import IProvider 2 | 3 | 4 | class NullProvider(IProvider): 5 | """ Null Provider 6 | 7 | Configuration: none 8 | 9 | Sending: does nothing, but increments message.msgid 10 | 11 | Receipt: Not implemented 12 | 13 | Status: Not implemented 14 | """ 15 | 16 | def __init__(self, gateway, name): 17 | super(NullProvider, self).__init__(gateway, name) 18 | self._msgid = 0 19 | 20 | def send(self, message): 21 | self._msgid += 1 22 | message.msgid = str(self._msgid) 23 | return message 24 | -------------------------------------------------------------------------------- /tests/forward_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import time 3 | import threading 4 | from datetime import datetime 5 | 6 | from flask import Flask, request 7 | 8 | from smsframework import Gateway, exc 9 | from smsframework.providers import ForwardClientProvider, ForwardServerProvider, LoopbackProvider 10 | from smsframework import OutgoingMessage, IncomingMessage 11 | from smsframework.providers.forward.provider import jsonex_dumps, jsonex_loads 12 | 13 | try: # Py3 14 | from urllib.request import urlopen, Request 15 | except ImportError: # Py2 16 | from urllib2 import urlopen, Request 17 | 18 | 19 | 20 | 21 | class ForwardProviderTest(unittest.TestCase): 22 | """ Test ForwardClientProvider and ForwardServerProvider """ 23 | 24 | def _runFlask(self, gw, port): 25 | """ 26 | :param gw: Gateway 27 | :type gw: smsframework.Gateway.Gateway 28 | :return: 29 | :rtype: 30 | """ 31 | # Init flask 32 | app = Flask(__name__) 33 | app.debug = True 34 | app.testing = True 35 | 36 | # Register gateway receivers 37 | gw.receiver_blueprints_register(app, prefix='/sms') 38 | 39 | # Stop manually 40 | @app.route('/kill', methods=['GET','POST']) 41 | def kill(): 42 | func = request.environ.get('werkzeug.server.shutdown') 43 | if func is None: 44 | raise RuntimeError('Not running with the Werkzeug Server') 45 | func() 46 | return '' 47 | 48 | # Run 49 | app.run('0.0.0.0', port, threaded=False, use_reloader=False, passthrough_errors=True) 50 | 51 | def setUp(self): 52 | # Init: client gateway 53 | # This "client" sends messages through a remote server 54 | self.gw_client = Gateway() 55 | self.gw_client.add_provider('fwd', ForwardClientProvider, server_url='http://a:b@localhost:5001/sms/fwd') 56 | 57 | # Init: server gateway 58 | # This "server" receives messages from an external SMS server and forwards them to the client 59 | self.gw_server = Gateway() 60 | self.gw_server.add_provider('lo', LoopbackProvider) 61 | self.gw_server.add_provider('fwd', ForwardServerProvider, clients=['http://a:b@localhost:5000/sms/fwd']) 62 | self.lo = self.gw_server.get_provider('lo') # This is run in another thread, but we should have access to it 63 | ' :type: LoopbackProvider ' 64 | 65 | # Run client in a thread 66 | self.t_client = threading.Thread(target=self._runFlask, args=(self.gw_client, 5000)) 67 | self.t_client.start() 68 | 69 | # Run server in a thread 70 | self.t_server = threading.Thread(target=self._runFlask, args=(self.gw_server, 5001)) 71 | self.t_server.start() 72 | 73 | # Give Flask some time to initialize 74 | time.sleep(0.5) 75 | 76 | def tearDown(self): 77 | # Kill threads 78 | for port, thread in ((5000, self.t_client), (5001, self.t_server)): 79 | #try: 80 | response = urlopen(Request('http://localhost:{}/kill'.format(port))) 81 | #except RemoteDisconnected: pass 82 | thread.join() 83 | 84 | def test_jsonex(self): 85 | """ Test de/coding messages """ 86 | ### OutgoingMessage 87 | om_in = OutgoingMessage('+123', 'Test', '+987', 'fwd') 88 | om_in.options(allow_reply=True) 89 | # Encode, Decode 90 | j = jsonex_dumps(om_in) 91 | om_out = jsonex_loads(j) 92 | """ :type om_out: OutgoingMessage """ 93 | # Check 94 | self.assertEqual(om_out.dst, '123') 95 | self.assertEqual(om_out.src, om_in.src) 96 | self.assertEqual(om_out.body, om_in.body) 97 | self.assertEqual(om_out.provider, om_in.provider) 98 | self.assertEqual(om_out.meta, None) 99 | self.assertEqual(om_out.provider_options.allow_reply, om_in.provider_options.allow_reply) 100 | self.assertEqual(om_out.provider_params, {}) 101 | 102 | ### IncomingMessage 103 | im_in = IncomingMessage('+123', 'Test', 'abc123def', '+987', datetime(2019,1,1,15,0,0,875), {'a':1}) 104 | # Encode, Decode 105 | j = jsonex_dumps(im_in) 106 | im_out = jsonex_loads(j) 107 | """ :type im_out: IncomingMessage """ 108 | # Check 109 | self.assertEqual(im_out.src, im_in.src) 110 | self.assertEqual(im_out.body, im_in.body) 111 | self.assertEqual(im_out.msgid, im_in.msgid) 112 | self.assertEqual(im_out.dst, im_in.dst) 113 | self.assertEqual(im_out.rtime, im_in.rtime) 114 | self.assertEqual(im_out.meta, im_in.meta) 115 | 116 | 117 | def testSend(self): 118 | """ Send messages """ 119 | 120 | # Send a message 121 | om = OutgoingMessage('+1234', 'Hi man!').options(senderId='me').params(a=1).route(1, 2, 3) 122 | rom = self.gw_client.send(om) 123 | 124 | # Check traffic 125 | traffic = self.lo.get_traffic() 126 | self.assertEqual(len(traffic), 1) 127 | tom = traffic.pop() 128 | 129 | for m in (om, rom, tom): 130 | self.assertEqual(m.src, None) 131 | self.assertEqual(m.dst, '1234') 132 | self.assertEqual(m.body, 'Hi man!') 133 | self.assertEqual(m.provider, 'lo') # Remote provider should be exposed 134 | self.assertEqual(m.provider_options.senderId, 'me') 135 | self.assertEqual(m.provider_params, {'a': 1}) 136 | self.assertEqual(m.routing_values, [1, 2, 3]) 137 | self.assertEqual(m.msgid, '1') 138 | self.assertEqual(m.meta, None) 139 | 140 | def testReceive(self): 141 | """ Receive messages """ 142 | 143 | # Message receiver 144 | received = [] 145 | def onReceive(message): received.append(message) 146 | self.gw_client.onReceive += onReceive 147 | 148 | # Receive a message 149 | self.lo.received('1111', 'Yo') 150 | 151 | # Check 152 | self.assertEqual(len(received), 1) 153 | msg = received.pop() 154 | ':type: IncomingMessage' 155 | 156 | self.assertEqual(msg.msgid, 1) 157 | self.assertEqual(msg.src, '1111') 158 | self.assertEqual(msg.body, 'Yo') 159 | self.assertEqual(msg.dst, None) 160 | self.assertIsInstance(msg.rtime, datetime) 161 | self.assertEqual(msg.meta, {}) 162 | self.assertEqual(msg.provider, 'lo') # Remote provider should be exposed 163 | 164 | def testStatus(self): 165 | """ Receive statuses """ 166 | 167 | # Status receiver 168 | statuses = [] 169 | def onStatus(status): statuses.append(status) 170 | self.gw_client.onStatus += onStatus 171 | 172 | # Subscriber 173 | incoming = [] 174 | def subscriber(message): incoming.append(message) 175 | self.lo.subscribe('1234', subscriber) 176 | 177 | # Send a message, request status report 178 | om = OutgoingMessage('+1234', 'Hi man!').options(status_report=True) 179 | rom = self.gw_client.send(om) 180 | 181 | # Check 182 | self.assertEqual(len(statuses), 1) 183 | status = statuses.pop() 184 | ':type: MessageStatus' 185 | self.assertEqual(status.msgid, '1') 186 | self.assertIsInstance(status.rtime, datetime) 187 | self.assertEqual(status.provider, 'lo') 188 | self.assertEqual(status.accepted, True) 189 | self.assertEqual(status.delivered, True) 190 | self.assertEqual(status.expired, False) 191 | self.assertEqual(status.error, False) 192 | self.assertEqual(status.status_code, None) 193 | self.assertEqual(status.status, 'OK') 194 | self.assertEqual(status.meta, {}) 195 | 196 | def testServerError(self): 197 | """ Test how errors are transferred from the server """ 198 | 199 | # Erroneous subscribers 200 | def tired_subscriber(message): 201 | raise OverflowError('Tired') 202 | self.lo.subscribe('1234', tired_subscriber) 203 | 204 | def offline_subscriber(message): 205 | raise exc.ServerError('Offline') 206 | self.lo.subscribe('5678', offline_subscriber) 207 | 208 | # Send: 1 209 | om = OutgoingMessage('+1234', 'Hi man!') 210 | self.assertRaises(RuntimeError, self.gw_client.send, om) # Unknown error classes are converted to RuntimeError 211 | 212 | # Send: 2 213 | om = OutgoingMessage('+5678', 'Hi man!') 214 | self.assertRaises(exc.ServerError, self.gw_client.send, om) # Known errors: as is 215 | 216 | def testClientError(self): 217 | """ Test how server behaves when the client cannot receive """ 218 | 219 | # Message receiver 220 | def failing_receiver(message): 221 | print(message) 222 | raise OverflowError(':(') 223 | self.gw_client.onReceive += failing_receiver 224 | 225 | # Receive a message 226 | self.assertRaises(RuntimeError, self.lo.received, '1111', 'Yo') 227 | -------------------------------------------------------------------------------- /tests/gateway_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from smsframework import Gateway 4 | from smsframework.providers import NullProvider 5 | from smsframework import OutgoingMessage, IncomingMessage, MessageStatus 6 | 7 | 8 | class GatewayTest(unittest.TestCase): 9 | """ Test Gateway """ 10 | 11 | def setUp(self): 12 | self.gw = Gateway() 13 | 14 | # Providers 15 | self.gw.add_provider('one', NullProvider) 16 | self.gw.add_provider('two', NullProvider) 17 | self.gw.add_provider('three', NullProvider) 18 | 19 | # Router 20 | def router(message, module, method): 21 | if module == 'main': 22 | return None # use 'one' for module 'main' 23 | elif method == 'alarm': 24 | return 'two' # use 'three' for all alerting methods 25 | else: 26 | return 'three' # use 'two' for everything else 27 | self.gw.router = router 28 | 29 | def test_struct(self): 30 | """ Test structure """ 31 | 32 | # Default provider is fine 33 | self.assertEqual(self.gw.default_provider, 'one') 34 | 35 | # Getting providers 36 | self.assertIsInstance(self.gw.get_provider('one'), NullProvider) 37 | self.assertIsInstance(self.gw.get_provider('two'), NullProvider) 38 | self.assertIsInstance(self.gw.get_provider('three'), NullProvider) 39 | self.assertRaises(KeyError, self.gw.get_provider, 'none') 40 | 41 | # Redeclare a provider 42 | self.assertRaises(AssertionError, self.gw.add_provider, 'one', NullProvider) 43 | 44 | # Pass a non-IProvider class 45 | self.assertRaises(AssertionError, self.gw.add_provider, 'ok', Exception) 46 | 47 | def test_routing(self): 48 | """ Test routing """ 49 | 50 | # Sends through 'one' 51 | msg = self.gw.send(OutgoingMessage('', '').route('main', '')) 52 | self.assertEqual(msg.provider, 'one') 53 | 54 | # Sends through 'two' 55 | msg = self.gw.send(OutgoingMessage('', '').route('', 'alarm')) 56 | self.assertEqual(msg.provider, 'two') 57 | 58 | # Sends through 'three' 59 | msg = self.gw.send(OutgoingMessage('', '').route('', '')) 60 | self.assertEqual(msg.provider, 'three') 61 | 62 | # Send through 'one' (explicitly set) 63 | msg = self.gw.send(OutgoingMessage('', '', provider='one').route('', '')) 64 | self.assertEqual(msg.provider, 'one') 65 | 66 | # No routing specified: using the default route 67 | msg = self.gw.send(OutgoingMessage('', '', provider='one')) 68 | self.assertEqual(msg.provider, 'one') 69 | 70 | # Wrong provider specified 71 | self.assertRaises(AssertionError, self.gw.send, OutgoingMessage('', '', provider='zzz')) 72 | 73 | 74 | def test_events(self): 75 | """ Test events """ 76 | 77 | # Counters 78 | self.recv = 0 79 | self.send = 0 80 | self.status = 0 81 | 82 | def inc_recv(message): self.recv += 1 83 | def inc_send(message): self.send += 1 84 | def inc_status(message): self.status += 1 85 | 86 | # Hooks 87 | self.gw.onReceive += inc_recv 88 | self.gw.onSend += inc_send 89 | self.gw.onStatus += inc_status 90 | 91 | # Emit some events 92 | provider = self.gw.get_provider('one') 93 | self.gw.send(OutgoingMessage('', '')) 94 | provider._receive_message(IncomingMessage('', '')) 95 | provider._receive_status(MessageStatus('')) 96 | 97 | # Check 98 | self.assertEqual(self.recv, 1) 99 | self.assertEqual(self.send, 1) 100 | self.assertEqual(self.status, 1) 101 | -------------------------------------------------------------------------------- /tests/log_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from testfixtures import LogCapture 3 | 4 | from smsframework import Gateway 5 | from smsframework.providers import LogProvider 6 | from smsframework import OutgoingMessage 7 | 8 | 9 | class LoopbackProviderTest(unittest.TestCase): 10 | """ Test LoopbackProvider """ 11 | 12 | def setUp(self): 13 | self.gw = Gateway() 14 | # Providers 15 | self.gw.add_provider('main', LogProvider) 16 | 17 | def test_basic_send(self): 18 | with LogCapture() as l: 19 | msg = self.gw.send(OutgoingMessage('+1234', 'body')) 20 | l.check( 21 | ('smsframework.providers.log', 'INFO', 'Sent SMS to {}: {}'.format(msg.dst, msg.body)), 22 | ) 23 | -------------------------------------------------------------------------------- /tests/loopback_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from smsframework import Gateway 4 | from smsframework.providers import LoopbackProvider 5 | from smsframework import OutgoingMessage, IncomingMessage, MessageAccepted, MessageDelivered 6 | 7 | 8 | class LoopbackProviderTest(unittest.TestCase): 9 | """ Test LoopbackProvider """ 10 | 11 | def setUp(self): 12 | gw = self.gw = Gateway() 13 | 14 | # Providers 15 | self.gw.add_provider('main', LoopbackProvider) 16 | provider = self.provider = gw.get_provider('main') 17 | ' :type: LoopbackProvider ' 18 | 19 | # Add subscribers 20 | self.subscriber_log = [] 21 | 22 | def subscriber(name, replies=False): 23 | def callback(message): 24 | self.subscriber_log.append('{name}:{src}:{body}'.format(name=name, src=message.src, body=message.body)) 25 | if replies: 26 | message.reply('hello') 27 | return callback 28 | 29 | provider.subscribe('+1', subscriber('1')) 30 | provider.subscribe('+2', subscriber('2')) 31 | provider.subscribe('+3', subscriber('3', replies=True)) 32 | 33 | # Event handlers 34 | self.events_log = [] 35 | 36 | def out_msg(message): 37 | self.events_log.append(message) 38 | 39 | def in_msg(message): 40 | self.events_log.append(message) 41 | 42 | def in_status(status): 43 | self.events_log.append(status) 44 | 45 | gw.onSend += out_msg 46 | gw.onReceive += in_msg 47 | gw.onStatus += in_status 48 | 49 | def test_missing_number(self): 50 | """ Send to a missing number """ 51 | msg = self.gw.send(OutgoingMessage('+0', 'you there?')) 52 | 53 | self.assertListEqual(self.provider.get_traffic(), [msg]) # traffic works 54 | self.assertListEqual(self.events_log, [msg]) 55 | self.assertEqual(self.subscriber_log, []) # no log as there was no subscriber 56 | 57 | 58 | def test_missing_number_status(self): 59 | """ Send to a missing number with status report request """ 60 | msg = self.gw.send(OutgoingMessage('+0', 'you there?').options(status_report=True)) 61 | 62 | self.assertListEqual(self.provider.get_traffic(), [msg]) # traffic works 63 | self.assertEqual(len(self.events_log), 2) 64 | self.assertIsInstance(self.events_log[0], MessageAccepted) # accepted, but not delivered 65 | self.assertSetEqual(self.events_log[0].states, {'accepted'}) 66 | self.assertIs(self.events_log[1], msg) 67 | self.assertEqual(self.subscriber_log, []) # no log as there was no subscriber 68 | 69 | def test_subscriber1(self): 70 | """ Test delivering a message to a subscriber """ 71 | msg = self.gw.send(OutgoingMessage('+1', 'hi!').options(status_report=True)) 72 | 73 | self.assertListEqual(self.provider.get_traffic(), [msg]) 74 | self.assertEqual(len(self.events_log), 2) 75 | self.assertIsInstance(self.events_log[0], MessageDelivered) # delivered 76 | self.assertSetEqual(self.events_log[0].states, {'accepted', 'delivered'}) 77 | self.assertIs(self.events_log[1], msg) 78 | self.assertEqual(self.subscriber_log, ['1:None:hi!']) 79 | 80 | def test_subscriber3(self): 81 | """ Test replying subscriber """ 82 | msg = self.gw.send(OutgoingMessage('++3', 'hi!').options()) 83 | 84 | traffic = self.provider.get_traffic() 85 | self.assertIs(traffic[0], msg) 86 | self.assertIsInstance(traffic[1], IncomingMessage) 87 | self.assertEqual(traffic[1].src, '3') 88 | self.assertEqual(traffic[1].dst, None) 89 | self.assertEqual(traffic[1].body, 'hello') 90 | 91 | self.assertEqual(len(self.events_log), 2) 92 | self.assertIs(self.events_log[1], msg) # onSend is only emitted once the Provider has finished 93 | self.assertEqual(self.events_log[0].body, 'hello') # the reply 94 | self.assertEqual(self.subscriber_log, ['3:None:hi!']) 95 | 96 | def test_subscriber3_noreply(self): 97 | """ Test replying subscriber with disabled replies """ 98 | msg = self.gw.send(OutgoingMessage('3', 'hi!').options(status_report=True, allow_reply=False)) 99 | 100 | self.assertListEqual(self.provider.get_traffic(), [msg]) 101 | self.assertEqual(len(self.events_log), 2) 102 | self.assertIsInstance(self.events_log[0], MessageDelivered) # delivered 103 | self.assertIs(self.events_log[1], msg) 104 | self.assertEqual(self.subscriber_log, ['3:None:hi!']) 105 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist=py{27,34,35,36,37},pypy,pypy3 3 | skip_missing_interpreters=True 4 | 5 | [testenv] 6 | deps=-rrequirements-dev.txt 7 | commands= 8 | nosetests {posargs:tests/} 9 | whitelist_externals=make 10 | 11 | [testenv:dev] 12 | deps=-rrequirements-dev.txt 13 | usedevelop=True 14 | --------------------------------------------------------------------------------