├── .github └── workflows │ ├── test-manual.yml │ └── test.yml ├── .gitignore ├── CHANGELOG.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── RELEASING.md ├── examples ├── callback.py ├── e2e.py ├── e2e_blocking.py ├── lookup.py ├── lookup_blocking.py ├── res │ └── threema.jpg ├── simple.py └── simple_blocking.py ├── mypy.ini ├── setup.cfg ├── setup.py ├── tests ├── conftest.py ├── res │ ├── cert.pem │ ├── threema.jpg │ └── threema.mp4 ├── test_api.py ├── test_base.py ├── test_blocking_api.py ├── test_callback.py └── test_cli.py └── threema ├── __init__.py └── gateway ├── __init__.py ├── _gateway.py ├── bin ├── __init__.py └── gateway_client.py ├── e2e.py ├── exception.py ├── key.py ├── memoization.py ├── simple.py └── util.py /.github/workflows/test-manual.yml: -------------------------------------------------------------------------------- 1 | name: Manual Test 2 | 3 | on: [workflow_dispatch] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ["3.12", "3.11", "3.10", "3.9", "3.8"] 11 | event-loop: [asyncio, uvloop] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install dependencies 20 | run: | 21 | sudo apt-get install libsodium23 22 | python -m pip install -U pip setuptools 23 | - name: Install threema.gateway 24 | run: | 25 | python -m pip install .[dev] 26 | - name: Install uvloop for threema.gateway 27 | if: ${{ matrix.event-loop == 'uvloop' }} 28 | run: | 29 | python -m pip install .[uvloop] 30 | - name: Lint with flake8 31 | run: | 32 | flake8 . 33 | - name: Lint with isort 34 | run: | 35 | isort -c . || (isort --df . && return 1) 36 | - name: Lint with checkdocs 37 | run: | 38 | python setup.py checkdocs 39 | - name: Lint with mypy 40 | run: | 41 | mypy setup.py tests examples threema 42 | - name: Test with pytest 43 | run: | 44 | py.test --loop=${{ matrix.event-loop }} 45 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: ["3.12", "3.11", "3.10", "3.9", "3.8"] 15 | event-loop: [asyncio, uvloop] 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: Install dependencies 24 | run: | 25 | sudo apt-get install libsodium23 26 | python -m pip install -U pip setuptools 27 | - name: Install threema.gateway 28 | run: | 29 | python -m pip install .[dev] 30 | - name: Install uvloop for threema.gateway 31 | if: ${{ matrix.event-loop == 'uvloop' }} 32 | run: | 33 | python -m pip install .[uvloop] 34 | - name: Lint with flake8 35 | run: | 36 | flake8 . 37 | - name: Lint with isort 38 | run: | 39 | isort -c . || (isort --df . && return 1) 40 | - name: Lint with checkdocs 41 | run: | 42 | python setup.py checkdocs 43 | - name: Lint with mypy 44 | run: | 45 | mypy setup.py tests examples threema 46 | - name: Test with pytest 47 | run: | 48 | py.test --loop=${{ matrix.event-loop }} 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Python template 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | env/ 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | .venv/ 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *,cover 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | 56 | # Sphinx documentation 57 | docs/_build/ 58 | 59 | # PyBuilder 60 | target/ 61 | 62 | # IntelliJ project files 63 | .idea 64 | *.iml 65 | out 66 | gen 67 | # Created by .ignore support plugin (hsz.mobi) 68 | 69 | # Vim 70 | *.swp 71 | 72 | # VS Code 73 | .vscode/ 74 | 75 | # MyPy Cache 76 | .mypy_cache/ 77 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ********* 3 | 4 | `8.0.0`_ (2024-08-21) 5 | --------------------- 6 | 7 | - Add support for Python 3.10/3.11/3.12 8 | - Drop support for Python versions below 3.8 9 | - Bump dependencies 10 | - Add `caption` to file message and to the CLI 11 | - Add an optional parameter to the CLI for `send-simple` and `send-e2e` to allow 12 | passing the text as an argument instead from stdin. 13 | - Make the random padding spec compliant 14 | - Add an optional environment variable `GATEWAY_API_URL` to override the Gateway 15 | API Endpoint URL 16 | 17 | `7.0.1`_ (2023-02-21) 18 | --------------------- 19 | 20 | - Fix parsing of unknown reception capabilities 21 | - Add new `ReceptionCapability` items 22 | - Remove `ReceptionCapabilitiesError` (breaking) 23 | 24 | `6.0.0`_ (2022-06-13) 25 | --------------------- 26 | 27 | General: 28 | 29 | - Add support for Python 3.10 30 | - Drop support for Python versions below 3.7 31 | - Major dependencies bump to increase compatibility with other packages 32 | - Updated all tests to work with the newest dependencies 33 | - Changed CLI syntax: All commands are now `dash-case` instead of `snake_case`, 34 | e.g. the `send_e2e` command is now called `send-e2e`. 35 | 36 | 37 | `5.0.0`_ (2021-05-17) 38 | --------------------- 39 | 40 | - Add custom session and session arguments to `Connection` (#55, #56) 41 | - Remove the `fingerprint` and `verify_fingerprint` arguments, see #55 for a 42 | detailed explanation and how to achieve pinning 43 | 44 | `4.0.0`_ (2021-01-23) 45 | --------------------- 46 | 47 | General: 48 | 49 | - Drop support for Python versions below 3.6.1. 50 | - Remove `ReceiptType.user_ack` after deprecation. Use 51 | `ReceiptType.user_acknowledge` instead. 52 | - Simplify `util.aio_run`. It does not allow for passing a specific event loop 53 | or closing the event loop on completion any longer. 54 | - Rename `util.aio_run_proxy_decorator` to `aio_run_proxy`. It now always 55 | creates the class instance within a running event loop. 56 | 57 | Client: 58 | 59 | - In async mode, creation of the `Connection` instance must now be done within 60 | an `async` function. 61 | - If you have used a `with` context manager block in async mode before, you 62 | must now do this within an `async with` asynchronous context manager. No 63 | change is required in blocking mode. 64 | - `Connection.close` is now an async function. 65 | 66 | Server: 67 | 68 | - The callback server has been refactored and the `AbstractCallback` class has 69 | been removed for more flexibility and control of the underlying 70 | `aiohttp `_ server. Take a look at 71 | `examples/callback.py` on how to use it. 72 | - The callback server CLI has been removed because it was redundant. The 73 | example provides the same functionality. 74 | 75 | `3.1.0`_ (2020-04-21) 76 | --------------------- 77 | 78 | - Add video message 79 | - Fix slightly off calculation of image byte length 80 | 81 | `3.0.6`_ (2017-09-22) 82 | --------------------- 83 | 84 | - Migrate to aiohttp2 85 | 86 | `3.0.5`_ (2017-07-25) 87 | --------------------- 88 | 89 | - Fix to handle new `libnacl `_ 90 | exceptions. 91 | 92 | `3.0.4`_ (2017-05-23) 93 | --------------------- 94 | 95 | - Fix CLI 96 | 97 | `3.0.2`_ (2017-05-12) 98 | --------------------- 99 | 100 | - Initial publication on PyPI 101 | 102 | .. _8.0.0: https://github.com/threema-ch/threema-msgapi-sdk-python/compare/v7.0.1...v8.0.0 103 | .. _7.0.1: https://github.com/threema-ch/threema-msgapi-sdk-python/compare/v6.0.0...v7.0.1 104 | .. _6.0.0: https://github.com/threema-ch/threema-msgapi-sdk-python/compare/v5.0.0...v6.0.0 105 | .. _5.0.0: https://github.com/threema-ch/threema-msgapi-sdk-python/compare/v4.0.0...v5.0.0 106 | .. _4.0.0: https://github.com/threema-ch/threema-msgapi-sdk-python/compare/v3.1.0...v4.0.0 107 | .. _3.1.0: https://github.com/threema-ch/threema-msgapi-sdk-python/compare/v3.0.6...v3.1.0 108 | .. _3.0.6: https://github.com/threema-ch/threema-msgapi-sdk-python/compare/v3.0.5...v3.0.6 109 | .. _3.0.5: https://github.com/threema-ch/threema-msgapi-sdk-python/compare/v3.0.4...v3.0.5 110 | .. _3.0.4: https://github.com/threema-ch/threema-msgapi-sdk-python/compare/v3.0.2...v3.0.4 111 | .. _3.0.2: https://github.com/threema-ch/threema-msgapi-sdk-python/compare/e982c74cbe564c76cc58322d3154916ee7f6863b...v3.0.2 112 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Lennart Grahl 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | recursive-include docs * 4 | prune docs/_build 5 | recursive-include examples * 6 | recursive-include tests * 7 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Threema Gateway API 2 | =================== 3 | 4 | **threema-gateway** is a Python 3 module for the Threema gateway service. 5 | This API can be used to send and receive text messages to and from any Threema 6 | user. 7 | 8 | Note 9 | **** 10 | 11 | On machines where Python 3 is not the default Python runtime, you should 12 | use ``pip3`` instead of ``pip``. 13 | 14 | Prerequisites 15 | ************* 16 | 17 | .. code-block:: bash 18 | 19 | $ sudo apt-get install python3 python3-pip 20 | 21 | We recommend using `venv`_ to create an isolated Python environment: 22 | 23 | .. code-block:: bash 24 | 25 | $ pyvenv venv 26 | 27 | You can switch into the created virtual environment *venv* by running 28 | this command: 29 | 30 | .. code-block:: bash 31 | 32 | $ source venv/bin/activate 33 | 34 | While the virtual environment is active, all packages installed using 35 | ``pip`` will be installed into this environment. 36 | 37 | To deactivate the virtual environment, just run: 38 | 39 | .. code-block:: bash 40 | 41 | $ deactivate 42 | 43 | If you want easier handling of your virtualenvs, you might also want to 44 | take a look at `virtualenvwrapper`_. 45 | 46 | Installation 47 | ------------ 48 | 49 | If you are using a virtual environment, activate it first. 50 | 51 | Install the module by running: 52 | 53 | .. code-block:: bash 54 | 55 | $ pip install threema.gateway 56 | 57 | The dependency ``libnacl`` will be installed automatically. However, you 58 | may need to install `libsodium`_ for ``libnacl`` to work. 59 | 60 | Command Line Usage 61 | ****************** 62 | 63 | The script ``threema-gateway`` provides a command line interface for 64 | the Threema gateway. Run the following command to see usage information: 65 | 66 | .. code-block:: bash 67 | 68 | $ threema-gateway --help 69 | 70 | Gateway API Endpoint 71 | -------------------- 72 | 73 | The default Gateway API Endpoint URL used is https://msgapi.threema.ch/. 74 | 75 | If you are a Threema OnPrem customer or have another reason to use a different 76 | Gateway API Endpoint, you may override the URL as follows: 77 | 78 | .. code-block:: bash 79 | 80 | $ export GATEWAY_API_URL=https://onprem.myinstance.tld/msgapi 81 | 82 | Any following calls to ``threema-gateway`` will then use the supplied Gateway 83 | API Endpoint URL. 84 | 85 | Examples 86 | ******** 87 | 88 | You can find a few example scripts in the ``examples/`` directory. 89 | 90 | Note that most of them need to be adjusted to at least add your gateway ID 91 | credentials before they run successfully. 92 | 93 | Feature Levels 94 | ************** 95 | 96 | +---------+--------+----------------+---------+--------+-----------+ 97 | | Level | Text | Capabilities | Image | File | Credits | 98 | +=========+========+================+=========+========+===========+ 99 | | 1 | X | | | | | 100 | +---------+--------+----------------+---------+--------+-----------+ 101 | | 2 | X | X | X | X | | 102 | +---------+--------+----------------+---------+--------+-----------+ 103 | | 3 | X | X | X | X | X | 104 | +---------+--------+----------------+---------+--------+-----------+ 105 | 106 | You can see the implemented feature level by invoking the following 107 | command: 108 | 109 | .. code-block:: bash 110 | 111 | $ threema-gateway version 112 | 113 | Contributing 114 | ************ 115 | 116 | If you want to contribute to this project, you should install the 117 | optional ``dev`` requirements of the project in an editable environment: 118 | 119 | .. code-block:: bash 120 | 121 | $ git clone https://github.com/threema-ch/threema-msgapi-sdk-python.git 122 | $ cd threema-msgapi-sdk-python 123 | $ pip install -e .[dev] 124 | 125 | Before creating a pull request, it is recommended to run the following 126 | commands to check for code style violations (``flake8``), optimise 127 | imports (``isort``) and run the project's tests: 128 | 129 | .. code-block:: bash 130 | 131 | $ flake8 . 132 | $ isort . 133 | $ py.test 134 | 135 | You should also run the type checker that might catch some additional bugs: 136 | 137 | .. code-block:: bash 138 | 139 | $ mypy setup.py tests examples threema 140 | 141 | .. _asyncio: https://docs.python.org/3/library/asyncio.html 142 | .. _venv: https://docs.python.org/3/library/venv.html 143 | .. _virtualenvwrapper: https://virtualenvwrapper.readthedocs.io/ 144 | .. _libsodium: https://download.libsodium.org/doc/installation/index.html 145 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | Release Process 2 | =============== 3 | 4 | Signing key: 5 | 6 | 1. Check the code: 7 | 8 | ```bash 9 | flake8 . 10 | isort -c . || isort --df . 11 | mypy setup.py tests examples threema 12 | py.test 13 | ``` 14 | 15 | 2. Set variables: 16 | 17 | ```bash 18 | export VERSION= 19 | export GPG_KEY=3FDB14868A2B36D638F3C495F98FBED10482ABA6 20 | ``` 21 | 22 | 3. Update version number in ``threema/gateway/__init__.py`` and 23 | ``CHANGELOG.rst``, also update the URL with the corresponding tags. 24 | 25 | Run `python setup.py checkdocs`. 26 | 27 | 4. Do a signed commit and signed tag of the release: 28 | 29 | ```bash 30 | git add threema/gateway/__init__.py CHANGELOG.rst 31 | git commit -S${GPG_KEY} -m "Release v${VERSION}" 32 | git tag -u ${GPG_KEY} -m "Release v${VERSION}" v${VERSION} 33 | ``` 34 | 35 | 5. Build source and binary distributions: 36 | 37 | ```bash 38 | rm -rf build dist threema.gateway.egg-info 39 | find . \( -name \*.pyc -o -name \*.pyo -o -name __pycache__ \) -prune -exec rm -rf {} + 40 | python setup.py sdist bdist_wheel 41 | ``` 42 | 43 | 6. Sign files: 44 | 45 | ```bash 46 | gpg --detach-sign -u ${GPG_KEY} -a dist/threema.gateway-${VERSION}.tar.gz 47 | gpg --detach-sign -u ${GPG_KEY} -a dist/threema.gateway-${VERSION}*.whl 48 | ``` 49 | 50 | 7. Upload package to PyPI and push: 51 | 52 | ```bash 53 | twine upload "dist/threema.gateway-${VERSION}*" 54 | git push 55 | git push --tags 56 | ``` 57 | 58 | 8. Create a new release on GitHub. 59 | 60 | 9. Prepare CHANGELOG.rst for upcoming changes: 61 | 62 | ```rst 63 | `Unreleased`_ (YYYY-MM-DD) 64 | -------------------------- 65 | 66 | ... 67 | 68 | .. _Unreleased: https://github.com/threema-ch/threema-msgapi-sdk-python/compare/...HEAD 69 | ``` 70 | 71 | 10. Pat yourself on the back and celebrate! 72 | -------------------------------------------------------------------------------- /examples/callback.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import ssl 3 | 4 | import logbook 5 | import logbook.more 6 | from aiohttp import web 7 | 8 | from threema.gateway import ( 9 | Connection, 10 | e2e, 11 | util, 12 | ) 13 | 14 | 15 | async def handle_message(message): 16 | print('Got message ({}): {}'.format(repr(message), message)) 17 | 18 | 19 | async def on_startup(connection, application): 20 | message = e2e.TextMessage( 21 | connection=connection, 22 | to_id='ECHOECHO', 23 | text='Hi!' 24 | ) 25 | await message.send() 26 | 27 | 28 | async def create_application(): 29 | # Create connection instance 30 | connection = Connection( 31 | identity='*YOUR_GATEWAY_THREEMA_ID', 32 | secret='YOUR_GATEWAY_THREEMA_ID_SECRET', 33 | key='private:YOUR_PRIVATE_KEY' 34 | ) 35 | 36 | # Create the web server application 37 | application = e2e.create_application(connection) 38 | 39 | # Register the handler for incoming messages 40 | e2e.add_callback_route( 41 | connection, application, handle_message, path='/gateway_callback') 42 | 43 | # Register startup hook (to send an outgoing message in this example) 44 | application.on_startup.append(functools.partial(on_startup, connection)) 45 | 46 | return application 47 | 48 | 49 | def main(): 50 | # Create an SSL context to terminate TLS. 51 | # Note: It is usually advisable to use a reverse proxy instead in front of 52 | # the server that terminates TLS, e.g. Nginx. 53 | ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) 54 | ssl_context.load_cert_chain(certfile='YOUR_CERTFILE', keyfile='YOUR_KEYFILE') 55 | 56 | # Run a server that listens on any interface via port 8443. It will 57 | # gracefully shut down when Ctrl+C has been pressed. 58 | web.run_app(create_application(), port=8443, ssl_context=ssl_context) 59 | 60 | 61 | if __name__ == '__main__': 62 | util.enable_logging(logbook.WARNING) 63 | log_handler = logbook.more.ColorizedStderrHandler() 64 | with log_handler.applicationbound(): 65 | main() 66 | -------------------------------------------------------------------------------- /examples/e2e.py: -------------------------------------------------------------------------------- 1 | """ 2 | You can modify and use one of the functions below to test the gateway 3 | service with your end-to-end account. 4 | """ 5 | import asyncio 6 | 7 | import logbook 8 | import logbook.more 9 | 10 | from threema.gateway import ( 11 | Connection, 12 | GatewayError, 13 | util, 14 | ) 15 | from threema.gateway.e2e import ( 16 | FileMessage, 17 | ImageMessage, 18 | TextMessage, 19 | VideoMessage, 20 | ) 21 | 22 | 23 | async def send(connection): 24 | """ 25 | Send a message to a specific Threema ID. 26 | 27 | Note that the public key will be automatically fetched from the 28 | Threema servers. It is strongly recommended that you cache 29 | public keys to avoid querying the API for each message. 30 | """ 31 | message = TextMessage( 32 | connection=connection, 33 | to_id='ECHOECHO', 34 | text='私はガラスを食べられます。それは私を傷つけません。' 35 | ) 36 | return await message.send() 37 | 38 | 39 | async def send_cached_key(connection): 40 | """ 41 | Send a message to a specific Threema ID with an already cached 42 | public key of that recipient. 43 | """ 44 | message = TextMessage( 45 | connection=connection, 46 | to_id='ECHOECHO', 47 | key='public:4a6a1b34dcef15d43cb74de2fd36091be99fbbaf126d099d47d83d919712c72b', 48 | text='私はガラスを食べられます。それは私を傷つけません。' 49 | ) 50 | return await message.send() 51 | 52 | 53 | async def send_cached_key_file(connection): 54 | """ 55 | Send a message to a specific Threema ID with an already cached 56 | public key (stored in a file) of that recipient. 57 | """ 58 | message = TextMessage( 59 | connection=connection, 60 | to_id='ECHOECHO', 61 | key_file='ECHOECHO.txt', 62 | text='私はガラスを食べられます。それは私を傷つけません。' 63 | ) 64 | return await message.send() 65 | 66 | 67 | async def send_image(connection): 68 | """ 69 | Send an image to a specific Threema ID. 70 | 71 | Note that the public key will be automatically fetched from the 72 | Threema servers. It is strongly recommended that you cache 73 | public keys to avoid querying the API for each message. 74 | """ 75 | message = ImageMessage( 76 | connection=connection, 77 | to_id='ECHOECHO', 78 | image_path='res/threema.jpg' 79 | ) 80 | return await message.send() 81 | 82 | 83 | async def send_video(connection): 84 | """ 85 | Send a video including a thumbnail to a specific Threema ID. 86 | 87 | Note that the public key will be automatically fetched from the 88 | Threema servers. It is strongly recommended that you cache 89 | public keys to avoid querying the API for each message. 90 | """ 91 | message = VideoMessage( 92 | connection=connection, 93 | to_id='ECHOECHO', 94 | duration=1, 95 | video_path='res/threema.mp4', 96 | thumbnail_path='res/threema.jpg', 97 | ) 98 | return await message.send() 99 | 100 | 101 | async def send_file(connection): 102 | """ 103 | Send a file to a specific Threema ID. 104 | 105 | Note that the public key will be automatically fetched from the 106 | Threema servers. It is strongly recommended that you cache 107 | public keys to avoid querying the API for each message. 108 | """ 109 | message = FileMessage( 110 | connection=connection, 111 | to_id='ECHOECHO', 112 | file_path='res/some_file.zip', 113 | caption="Here's that file I mentioned", 114 | ) 115 | return await message.send() 116 | 117 | 118 | async def send_file_with_thumbnail(connection): 119 | """ 120 | Send a file to a specific Threema ID including a thumbnail. 121 | 122 | Note that the public key will be automatically fetched from the 123 | Threema servers. It is strongly recommended that you cache 124 | public keys to avoid querying the API for each message. 125 | """ 126 | message = FileMessage( 127 | connection=connection, 128 | to_id='ECHOECHO', 129 | file_path='res/some_file.zip', 130 | thumbnail_path='res/some_file_thumb.png' 131 | ) 132 | return await message.send() 133 | 134 | 135 | async def main(): 136 | connection = Connection( 137 | identity='*YOUR_GATEWAY_THREEMA_ID', 138 | secret='YOUR_GATEWAY_THREEMA_ID_SECRET', 139 | key='private:YOUR_PRIVATE_KEY', 140 | ) 141 | try: 142 | async with connection: 143 | await send(connection) 144 | await send_cached_key(connection) 145 | await send_cached_key_file(connection) 146 | await send_image(connection) 147 | await send_video(connection) 148 | await send_file(connection) 149 | await send_file_with_thumbnail(connection) 150 | except GatewayError as exc: 151 | print('Error:', exc) 152 | 153 | 154 | if __name__ == '__main__': 155 | util.enable_logging(logbook.WARNING) 156 | log_handler = logbook.more.ColorizedStderrHandler() 157 | with log_handler.applicationbound(): 158 | asyncio.run(main()) 159 | -------------------------------------------------------------------------------- /examples/e2e_blocking.py: -------------------------------------------------------------------------------- 1 | """ 2 | You can modify and use one of the functions below to test the gateway 3 | service with your end-to-end account. 4 | """ 5 | import logbook 6 | import logbook.more 7 | 8 | from threema.gateway import ( 9 | Connection, 10 | GatewayError, 11 | util, 12 | ) 13 | from threema.gateway.e2e import ( 14 | FileMessage, 15 | ImageMessage, 16 | TextMessage, 17 | VideoMessage, 18 | ) 19 | 20 | 21 | def send(connection): 22 | """ 23 | Send a message to a specific Threema ID. 24 | 25 | Note that the public key will be automatically fetched from the 26 | Threema servers. It is strongly recommended that you cache 27 | public keys to avoid querying the API for each message. 28 | """ 29 | message = TextMessage( 30 | connection=connection, 31 | to_id='ECHOECHO', 32 | text='私はガラスを食べられます。それは私を傷つけません。' 33 | ) 34 | return message.send() 35 | 36 | 37 | def send_cached_key(connection): 38 | """ 39 | Send a message to a specific Threema ID with an already cached 40 | public key of that recipient. 41 | """ 42 | message = TextMessage( 43 | connection=connection, 44 | to_id='ECHOECHO', 45 | key='public:4a6a1b34dcef15d43cb74de2fd36091be99fbbaf126d099d47d83d919712c72b', 46 | text='私はガラスを食べられます。それは私を傷つけません。' 47 | ) 48 | return message.send() 49 | 50 | 51 | def send_cached_key_file(connection): 52 | """ 53 | Send a message to a specific Threema ID with an already cached 54 | public key (stored in a file) of that recipient. 55 | """ 56 | message = TextMessage( 57 | connection=connection, 58 | to_id='ECHOECHO', 59 | key_file='ECHOECHO.txt', 60 | text='私はガラスを食べられます。それは私を傷つけません。' 61 | ) 62 | return message.send() 63 | 64 | 65 | def send_image(connection): 66 | """ 67 | Send an image to a specific Threema ID. 68 | 69 | Note that the public key will be automatically fetched from the 70 | Threema servers. It is strongly recommended that you cache 71 | public keys to avoid querying the API for each message. 72 | """ 73 | message = ImageMessage( 74 | connection=connection, 75 | to_id='ECHOECHO', 76 | image_path='res/threema.jpg' 77 | ) 78 | return message.send() 79 | 80 | 81 | def send_video(connection): 82 | """ 83 | Send a video including a thumbnail to a specific Threema ID. 84 | 85 | Note that the public key will be automatically fetched from the 86 | Threema servers. It is strongly recommended that you cache 87 | public keys to avoid querying the API for each message. 88 | """ 89 | message = VideoMessage( 90 | connection=connection, 91 | to_id='ECHOECHO', 92 | duration=1, 93 | video_path='res/threema.mp4', 94 | thumbnail_path='res/threema.jpg', 95 | ) 96 | return message.send() 97 | 98 | 99 | def send_file(connection): 100 | """ 101 | Send a file to a specific Threema ID. 102 | 103 | Note that the public key will be automatically fetched from the 104 | Threema servers. It is strongly recommended that you cache 105 | public keys to avoid querying the API for each message. 106 | """ 107 | message = FileMessage( 108 | connection=connection, 109 | to_id='ECHOECHO', 110 | file_path='res/some_file.zip', 111 | caption="Here's that file I mentioned", 112 | ) 113 | return message.send() 114 | 115 | 116 | def send_file_with_thumbnail(connection): 117 | """ 118 | Send a file to a specific Threema ID including a thumbnail. 119 | 120 | Note that the public key will be automatically fetched from the 121 | Threema servers. It is strongly recommended that you cache 122 | public keys to avoid querying the API for each message. 123 | """ 124 | message = FileMessage( 125 | connection=connection, 126 | to_id='ECHOECHO', 127 | file_path='res/some_file.zip', 128 | thumbnail_path='res/some_file_thumb.png' 129 | ) 130 | return message.send() 131 | 132 | 133 | def main(): 134 | connection = Connection( 135 | identity='*YOUR_GATEWAY_THREEMA_ID', 136 | secret='YOUR_GATEWAY_THREEMA_ID_SECRET', 137 | key='private:YOUR_PRIVATE_KEY', 138 | blocking=True, 139 | ) 140 | try: 141 | with connection: 142 | send(connection) 143 | send_cached_key(connection) 144 | send_cached_key_file(connection) 145 | send_image(connection) 146 | send_video(connection) 147 | send_file(connection) 148 | send_file_with_thumbnail(connection) 149 | except GatewayError as exc: 150 | print('Error:', exc) 151 | 152 | 153 | if __name__ == '__main__': 154 | util.enable_logging(logbook.WARNING) 155 | log_handler = logbook.more.ColorizedStderrHandler() 156 | with log_handler.applicationbound(): 157 | main() 158 | -------------------------------------------------------------------------------- /examples/lookup.py: -------------------------------------------------------------------------------- 1 | """ 2 | You can modify and use one of the lines below to test the lookup 3 | functionality of the gateway service. 4 | """ 5 | import asyncio 6 | 7 | import logbook 8 | import logbook.more 9 | 10 | from threema.gateway import ( 11 | Connection, 12 | GatewayError, 13 | util, 14 | ) 15 | from threema.gateway.key import Key 16 | 17 | 18 | async def main(): 19 | connection = Connection( 20 | identity='*YOUR_GATEWAY_THREEMA_ID', 21 | secret='YOUR_GATEWAY_THREEMA_ID_SECRET', 22 | ) 23 | try: 24 | async with connection: 25 | print(await connection.get_credits()) 26 | print(await connection.get_id(phone='41791234567')) 27 | hash_ = 'ad398f4d7ebe63c6550a486cc6e07f9baa09bd9d8b3d8cb9d9be106d35a7fdbc' 28 | print(await connection.get_id(phone_hash=hash_)) 29 | print(await connection.get_id(email='test@threema.ch')) 30 | hash_ = '1ea093239cc5f0e1b6ec81b866265b921f26dc4033025410063309f4d1a8ee2c' 31 | print(await connection.get_id(email_hash=hash_)) 32 | key = await connection.get_public_key('ECHOECHO') 33 | print(Key.encode(key)) 34 | print(await connection.get_reception_capabilities('ECHOECHO')) 35 | except GatewayError as exc: 36 | print('Error:', exc) 37 | 38 | 39 | if __name__ == '__main__': 40 | util.enable_logging(logbook.WARNING) 41 | log_handler = logbook.more.ColorizedStderrHandler() 42 | with log_handler.applicationbound(): 43 | asyncio.run(main()) 44 | -------------------------------------------------------------------------------- /examples/lookup_blocking.py: -------------------------------------------------------------------------------- 1 | """ 2 | You can modify and use one of the lines below to test the lookup 3 | functionality of the gateway service. 4 | """ 5 | import logbook 6 | import logbook.more 7 | 8 | from threema.gateway import ( 9 | Connection, 10 | GatewayError, 11 | util, 12 | ) 13 | from threema.gateway.key import Key 14 | 15 | 16 | def main(): 17 | connection = Connection( 18 | identity='*YOUR_GATEWAY_THREEMA_ID', 19 | secret='YOUR_GATEWAY_THREEMA_ID_SECRET', 20 | blocking=True, 21 | ) 22 | try: 23 | with connection: 24 | print(connection.get_credits()) 25 | print(connection.get_id(phone='41791234567')) 26 | hash_ = 'ad398f4d7ebe63c6550a486cc6e07f9baa09bd9d8b3d8cb9d9be106d35a7fdbc' 27 | print(connection.get_id(phone_hash=hash_)) 28 | print(connection.get_id(email='test@threema.ch')) 29 | hash_ = '1ea093239cc5f0e1b6ec81b866265b921f26dc4033025410063309f4d1a8ee2c' 30 | print(connection.get_id(email_hash=hash_)) 31 | key = connection.get_public_key('ECHOECHO') 32 | print(Key.encode(key)) 33 | print(connection.get_reception_capabilities('ECHOECHO')) 34 | except GatewayError as exc: 35 | print('Error:', exc) 36 | 37 | 38 | if __name__ == '__main__': 39 | util.enable_logging(logbook.WARNING) 40 | log_handler = logbook.more.ColorizedStderrHandler() 41 | with log_handler.applicationbound(): 42 | main() 43 | -------------------------------------------------------------------------------- /examples/res/threema.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/threema-ch/threema-msgapi-sdk-python/19ba796a386a76c8f978b77246ab878a495bfce7/examples/res/threema.jpg -------------------------------------------------------------------------------- /examples/simple.py: -------------------------------------------------------------------------------- 1 | """ 2 | You can modify and use one of the functions below to test the gateway 3 | service with your account. 4 | """ 5 | import asyncio 6 | 7 | import logbook 8 | import logbook.more 9 | 10 | from threema.gateway import ( 11 | Connection, 12 | GatewayError, 13 | util, 14 | ) 15 | from threema.gateway.simple import TextMessage 16 | 17 | 18 | async def send_via_id(connection): 19 | """ 20 | Send a message to a specific Threema ID. 21 | """ 22 | message = TextMessage( 23 | connection=connection, 24 | to_id='ECHOECHO', 25 | text='Hello from the world of Python!' 26 | ) 27 | return await message.send() 28 | 29 | 30 | async def send_via_email(connection): 31 | """ 32 | Send a message via an email address. 33 | """ 34 | message = TextMessage( 35 | connection=connection, 36 | email='test@threema.ch', 37 | text='Hello from the world of Python!' 38 | ) 39 | return await message.send() 40 | 41 | 42 | async def send_via_phone(connection): 43 | """ 44 | Send a message via a phone number. 45 | """ 46 | message = TextMessage( 47 | connection=connection, 48 | phone='41791234567', 49 | text='Hello from the world of Python!' 50 | ) 51 | return await message.send() 52 | 53 | 54 | async def main(): 55 | connection = Connection( 56 | identity='*YOUR_GATEWAY_THREEMA_ID', 57 | secret='YOUR_GATEWAY_THREEMA_ID_SECRET', 58 | ) 59 | try: 60 | async with connection: 61 | await send_via_id(connection) 62 | await send_via_email(connection) 63 | await send_via_phone(connection) 64 | except GatewayError as exc: 65 | print('Error:', exc) 66 | 67 | 68 | if __name__ == '__main__': 69 | util.enable_logging(logbook.WARNING) 70 | log_handler = logbook.more.ColorizedStderrHandler() 71 | with log_handler.applicationbound(): 72 | asyncio.run(main()) 73 | -------------------------------------------------------------------------------- /examples/simple_blocking.py: -------------------------------------------------------------------------------- 1 | """ 2 | You can modify and use one of the functions below to test the gateway 3 | service with your account. 4 | """ 5 | import logbook 6 | import logbook.more 7 | 8 | from threema.gateway import ( 9 | Connection, 10 | GatewayError, 11 | util, 12 | ) 13 | from threema.gateway.simple import TextMessage 14 | 15 | 16 | def send_via_id(connection): 17 | """ 18 | Send a message to a specific Threema ID. 19 | """ 20 | message = TextMessage( 21 | connection=connection, 22 | to_id='ECHOECHO', 23 | text='Hello from the world of Python!' 24 | ) 25 | return message.send() 26 | 27 | 28 | def send_via_email(connection): 29 | """ 30 | Send a message via an email address. 31 | """ 32 | message = TextMessage( 33 | connection=connection, 34 | email='test@threema.ch', 35 | text='Hello from the world of Python!' 36 | ) 37 | return message.send() 38 | 39 | 40 | def send_via_phone(connection): 41 | """ 42 | Send a message via a phone number. 43 | """ 44 | message = TextMessage( 45 | connection=connection, 46 | phone='41791234567', 47 | text='Hello from the world of Python!' 48 | ) 49 | return message.send() 50 | 51 | 52 | def main(): 53 | connection = Connection( 54 | identity='*YOUR_GATEWAY_THREEMA_ID', 55 | secret='YOUR_GATEWAY_THREEMA_ID_SECRET', 56 | blocking=True, 57 | ) 58 | try: 59 | with connection: 60 | send_via_id(connection) 61 | send_via_email(connection) 62 | send_via_phone(connection) 63 | except GatewayError as exc: 64 | print('Error:', exc) 65 | 66 | 67 | if __name__ == '__main__': 68 | util.enable_logging(logbook.WARNING) 69 | log_handler = logbook.more.ColorizedStderrHandler() 70 | with log_handler.applicationbound(): 71 | main() 72 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | python_version = 3.10 3 | ignore_missing_imports = True 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | python-tag = py38.py39.py310.py311.py312 3 | 4 | [flake8] 5 | max-line-length = 90 6 | 7 | [isort] 8 | line_length = 90 9 | multi_line_output = 3 10 | force_grid_wrap = true 11 | balanced_wrapping = true 12 | atomic = true 13 | include_trailing_comma = true 14 | 15 | [tool:pytest] 16 | asyncio_mode=strict 17 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import os 3 | import sys 4 | 5 | from setuptools import ( 6 | find_packages, 7 | setup, 8 | ) 9 | 10 | 11 | def get_version(): 12 | path = os.path.join(os.path.dirname(__file__), 'threema', 'gateway', '__init__.py') 13 | with open(path) as file: 14 | for line in file: 15 | if line.startswith('__version__'): 16 | _, value = line.split('=', maxsplit=1) 17 | return ast.literal_eval(value.strip()) 18 | else: 19 | raise Exception('Version not found in {}'.format(path)) 20 | 21 | 22 | def read(file): 23 | return open(os.path.join(os.path.dirname(__file__), file)).read().strip() 24 | 25 | 26 | # Allow setup.py to be run from any path 27 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) 28 | 29 | # Import long description 30 | long_description = '\n\n'.join((read('README.rst'), read('CHANGELOG.rst'))) 31 | 32 | # Check python version 33 | py_version = sys.version_info[:3] 34 | if py_version < (3, 7, 0): 35 | raise Exception("threema.gateway requires Python >= 3.7") 36 | 37 | # Test requirements 38 | # Note: These are just tools that aren't required, so a version range 39 | # is not necessary here. 40 | tests_require = [ 41 | 'pytest>=8.3.2,<9', 42 | 'pytest-asyncio>=0.21.2,<0.23', 43 | 'flake8==7.1.1', 44 | 'isort==5.13.2', 45 | 'collective.checkdocs>=0.2,<0.3', 46 | 'Pygments>=2.18.0', # required by checkdocs 47 | 'mypy==1.11.1', 48 | ] 49 | 50 | setup( 51 | name='threema.gateway', 52 | version=get_version(), 53 | packages=find_packages(include=["threema.*"]), 54 | install_requires=[ 55 | 'logbook>=1.1.0,<2', 56 | 'libnacl>=1.5.2,<3', 57 | 'click>=8,<9', 58 | 'aiohttp>=3.7.3,<4', 59 | 'wrapt>=1.10.10,<2', 60 | ], 61 | tests_require=tests_require, 62 | extras_require={ 63 | 'dev': tests_require, 64 | 'uvloop': ['uvloop>=0.8.0,<2'], 65 | }, 66 | include_package_data=True, 67 | entry_points={ 68 | 'console_scripts': [ 69 | 'threema-gateway = threema.gateway.bin.gateway_client:main', 70 | ], 71 | }, 72 | 73 | # PyPI metadata 74 | author='Lennart Grahl', 75 | author_email='lennart.grahl@threema.ch', 76 | description=('An API for the Threema gateway service to send and receive ' 77 | 'messages including text, images, files and delivery reports.'), 78 | long_description=long_description, 79 | license='MIT License', 80 | keywords='threema gateway service sdk api', 81 | url='https://gateway.threema.ch/', 82 | classifiers=[ 83 | 'Development Status :: 5 - Production/Stable', 84 | 'Environment :: Console', 85 | 'Intended Audience :: Developers', 86 | 'Intended Audience :: System Administrators', 87 | 'License :: OSI Approved :: MIT License', 88 | 'Natural Language :: English', 89 | 'Operating System :: OS Independent', 90 | 'Programming Language :: Python :: 3 :: Only', 91 | 'Topic :: Communications :: Chat', 92 | 'Topic :: Internet :: WWW/HTTP', 93 | 'Topic :: Security', 94 | 'Topic :: Software Development :: Libraries :: Python Modules', 95 | 'Topic :: System :: Logging', 96 | ], 97 | ) 98 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import asyncio.subprocess 3 | import binascii 4 | import collections 5 | import copy 6 | import hashlib 7 | import hmac 8 | import os 9 | import socket 10 | import ssl 11 | import subprocess 12 | import sys 13 | import time 14 | from contextlib import closing 15 | 16 | import aiohttp 17 | import pytest 18 | from aiohttp import web 19 | 20 | import threema.gateway 21 | from threema.gateway import e2e 22 | from threema.gateway.key import Key 23 | 24 | _res_path = os.path.normpath(os.path.join( 25 | os.path.abspath(__file__), os.pardir, 'res')) 26 | 27 | 28 | class RawMessage(e2e.Message): 29 | def __init__(self, connection, nonce=None, message=None, **kwargs): 30 | super().__init__(connection, e2e.Message.Type.text_message, **kwargs) 31 | self.nonce = nonce 32 | self.message = message 33 | 34 | async def pack(self, writer): 35 | raise NotImplementedError 36 | 37 | @classmethod 38 | async def unpack(cls, connection, parameters, key_pair, reader): 39 | raise NotImplementedError 40 | 41 | async def send(self, get_data_only=False): 42 | """ 43 | Send the raw message 44 | 45 | Return the ID of the message. 46 | """ 47 | # Send message 48 | if get_data_only: 49 | return self.nonce, self.message 50 | else: 51 | return await self._connection.send_e2e(**{ 52 | 'to': self.to_id, 53 | 'nonce': binascii.hexlify(self.nonce).decode(), 54 | 'box': binascii.hexlify(self.message).decode() 55 | }) 56 | 57 | 58 | class Server: 59 | def __init__(self): 60 | self.threema_jpg = os.path.join(_res_path, 'threema.jpg') 61 | self.threema_mp4 = os.path.join(_res_path, 'threema.mp4') 62 | key = b'4a6a1b34dcef15d43cb74de2fd36091be99fbbaf126d099d47d83d919712c72b' 63 | self.echoecho_key = key 64 | self.echoecho_encoded_key = 'public:' + key.decode('ascii') 65 | decoded_private_key = Key.decode( 66 | pytest.msgapi['msgapi']['private'], Key.Type.private) 67 | self.mocking_key = Key.derive_public(decoded_private_key).hex_pk() 68 | self.blobs = {} 69 | self.latest_blob_ids = [] 70 | self.routes = [ 71 | web.get('/pubkeys/{key}', self.pubkeys), 72 | web.get('/lookup/phone/{phone}', self.lookup_phone), 73 | web.get('/lookup/phone_hash/{phone_hash}', self.lookup_phone_hash), 74 | web.get('/lookup/email/{email}', self.lookup_email), 75 | web.get('/lookup/email_hash/{email_hash}', self.lookup_email_hash), 76 | web.get('/capabilities/{id}', self.capabilities), 77 | web.get('/credits', self.credits), 78 | web.post('/send_simple', self.send_simple), 79 | web.post('/send_e2e', self.send_e2e), 80 | web.post('/upload_blob', self.upload_blob), 81 | web.get('/blobs/{blob_id}', self.download_blob), 82 | ] 83 | 84 | async def pubkeys(self, request): 85 | key = request.match_info['key'] 86 | from_, secret = request.query['from'], request.query['secret'] 87 | if (from_, secret) not in pytest.values['msgapi']['api_identities']: 88 | return web.Response(status=401) 89 | elif len(key) != 8: 90 | return web.Response(status=404) 91 | elif key == 'ECHOECHO': 92 | return web.Response(body=self.echoecho_key) 93 | elif key == '*MOCKING': 94 | return web.Response(body=self.mocking_key) 95 | return web.Response(status=404) 96 | 97 | async def lookup_phone(self, request): 98 | phone = request.match_info['phone'] 99 | from_, secret = request.query['from'], request.query['secret'] 100 | if (from_, secret) not in pytest.values['msgapi']['api_identities']: 101 | return web.Response(status=401) 102 | elif not phone.isdigit(): 103 | return web.Response(status=404) 104 | elif phone == '44123456789': 105 | return web.Response(body=b'ECHOECHO') 106 | return web.Response(status=404) 107 | 108 | async def lookup_phone_hash(self, request): 109 | phone_hash = request.match_info['phone_hash'] 110 | from_, secret = request.query['from'], request.query['secret'] 111 | hash_ = '98b05f6eda7a878f6f016bdcdc9db6eb61a6b190e814ff787142115af144214c' 112 | if (from_, secret) not in pytest.values['msgapi']['api_identities']: 113 | return web.Response(status=401) 114 | elif len(phone_hash) % 2 != 0: 115 | # Note: This status code might not be intended and may change in the future 116 | return web.Response(status=500) 117 | elif len(phone_hash) != 64: 118 | return web.Response(status=400) 119 | elif phone_hash == hash_: 120 | return web.Response(body=b'ECHOECHO') 121 | return web.Response(status=404) 122 | 123 | async def lookup_email(self, request): 124 | email = request.match_info['email'] 125 | from_, secret = request.query['from'], request.query['secret'] 126 | if (from_, secret) not in pytest.values['msgapi']['api_identities']: 127 | return web.Response(status=401) 128 | elif email == 'echoecho@example.com': 129 | return web.Response(body=b'ECHOECHO') 130 | return web.Response(status=404) 131 | 132 | async def lookup_email_hash(self, request): 133 | email_hash = request.match_info['email_hash'] 134 | from_, secret = request.query['from'], request.query['secret'] 135 | hash_ = '45a13d422b40f81936a9987245d3f6d9064c90607273af4f578246b4484669e2' 136 | if (from_, secret) not in pytest.values['msgapi']['api_identities']: 137 | return web.Response(status=401) 138 | elif len(email_hash) % 2 != 0: 139 | # Note: This status code might not be intended and may change in the future 140 | return web.Response(status=500) 141 | elif len(email_hash) != 64: 142 | return web.Response(status=400) 143 | elif email_hash == hash_: 144 | return web.Response(body=b'ECHOECHO') 145 | return web.Response(status=404) 146 | 147 | async def capabilities(self, request): 148 | id_ = request.match_info['id'] 149 | from_, secret = request.query['from'], request.query['secret'] 150 | if (from_, secret) not in pytest.values['msgapi']['api_identities']: 151 | return web.Response(status=401) 152 | elif id_ == 'ECHOECHO': 153 | return web.Response(body=b'text,image,video,file,quatsch') 154 | elif id_ == '*MOCKING': 155 | return web.Response(body=b'text,image,video,file') 156 | return web.Response(status=404) 157 | 158 | async def credits(self, request): 159 | from_, secret = request.query['from'], request.query['secret'] 160 | if (from_, secret) not in pytest.values['msgapi']['api_identities']: 161 | return web.Response(status=401) 162 | return web.Response(body=b'100') 163 | 164 | async def send_simple(self, request): 165 | post = await request.post() 166 | 167 | # Check API identity 168 | if (post['from'], post['secret']) not in \ 169 | pytest.values['msgapi']['api_identities']: 170 | return web.Response(status=401) 171 | 172 | # Get ID from to, email or phone 173 | if 'to' in post: 174 | id_ = post['to'] 175 | elif post.get('email', None) == 'echoecho@example.com': 176 | id_ = 'ECHOECHO' 177 | elif post.get('phone', None) == '44123456789': 178 | id_ = 'ECHOECHO' 179 | else: 180 | return web.Response(status=404) 181 | 182 | # Process 183 | text = post['text'] 184 | if post['from'] == pytest.msgapi['msgapi']['nocredit_id']: 185 | return web.Response(status=402) 186 | elif id_ != 'ECHOECHO': 187 | return web.Response(status=400) 188 | elif len(text) > 3500: 189 | return web.Response(status=413) 190 | return web.Response(body=b'0' * 16) 191 | 192 | async def send_e2e(self, request): 193 | post = await request.post() 194 | 195 | # Check API identity 196 | if (post['from'], post['secret']) not in \ 197 | pytest.values['msgapi']['api_identities']: 198 | return web.Response(status=401) 199 | 200 | # Get ID, nonce and box 201 | id_ = post['to'] 202 | nonce, box = binascii.unhexlify(post['nonce']), binascii.unhexlify(post['box']) 203 | 204 | # Process 205 | if post['from'] == pytest.msgapi['msgapi']['nocredit_id']: 206 | return web.Response(status=402) 207 | elif id_ != 'ECHOECHO': 208 | return web.Response(status=400) 209 | elif len(nonce) != 24: 210 | # Note: This status code might not be intended and may change in the future 211 | return web.Response(status=400) 212 | elif len(box) > 4000: 213 | return web.Response(status=413) 214 | return web.Response(body=b'1' * 16) 215 | 216 | async def upload_blob(self, request): 217 | try: 218 | data = await request.post() 219 | 220 | # Check API identity 221 | api_identity = (request.query['from'], request.query['secret']) 222 | if api_identity not in pytest.values['msgapi']['api_identities']: 223 | return web.Response(status=401) 224 | except KeyError: 225 | return web.Response(status=401) 226 | 227 | try: 228 | # Get blob 229 | blob = data['blob'].file.read() 230 | except KeyError: 231 | # Note: This status code might not be intended and may change in the future 232 | return web.Response(status=500) 233 | 234 | # Generate ID 235 | blob_id = hashlib.sha256(blob).hexdigest()[:32] 236 | 237 | # Process 238 | if request.query['from'] == pytest.msgapi['msgapi']['nocredit_id']: 239 | return web.Response(status=402) 240 | elif len(blob) == 0: 241 | return web.Response(status=400) 242 | elif len(blob) > 20 * (2**20): 243 | return web.Response(status=413) 244 | 245 | # Store blob and return 246 | self.blobs[blob_id] = blob 247 | self.latest_blob_ids.append(blob_id) 248 | return web.Response(body=blob_id.encode()) 249 | 250 | async def download_blob(self, request): 251 | blob_id = request.match_info['blob_id'] 252 | 253 | # Check API identity 254 | from_, secret = request.query['from'], request.query['secret'] 255 | if (from_, secret) not in pytest.values['msgapi']['api_identities']: 256 | return web.Response(status=401) 257 | 258 | # Get blob 259 | try: 260 | blob = self.blobs[blob_id] 261 | except KeyError: 262 | return web.Response(status=404) 263 | else: 264 | return web.Response( 265 | body=blob, 266 | content_type='application/octet-stream' 267 | ) 268 | 269 | 270 | def pytest_addoption(parser): 271 | help_ = 'loop: Use a different event loop, supported: asyncio, uvloop' 272 | parser.addoption("--loop", action="store", help=help_) 273 | 274 | 275 | def pytest_report_header(config): 276 | return 'Using event loop: {}'.format(default_event_loop(config=config)) 277 | 278 | 279 | def values_plugin(): 280 | values = msgapi_plugin() 281 | values['msgapi']['api_identities'] = { 282 | (values['msgapi']['id'], values['msgapi']['secret']), 283 | (values['msgapi']['nocredit_id'], values['msgapi']['secret']) 284 | } 285 | return values 286 | 287 | 288 | def msgapi_plugin(): 289 | private = 'private:dd9413d597092b004fedc4895db978425efa328ba1f1ec6729e46e09231b8a7e' 290 | public = Key.encode(Key.derive_public(Key.decode(private, Key.Type.private))) 291 | msgapi = {'msgapi': { 292 | 'cli_path': os.path.join( 293 | os.path.dirname(__file__), 294 | '../threema/gateway/bin/gateway_client.py', 295 | ), 296 | 'cert_path': os.path.join(_res_path, 'cert.pem'), 297 | 'base_url': 'https://msgapi.threema.ch', 298 | 'ip': '127.0.0.1', 299 | 'id': '*MOCKING', 300 | 'secret': 'mock', 301 | 'private': private, 302 | 'public': public, 303 | 'nocredit_id': 'NOCREDIT', 304 | 'noexist_id': '*NOEXIST', 305 | }} 306 | return msgapi 307 | 308 | 309 | def pytest_configure(): 310 | pytest.values = values_plugin() 311 | pytest.msgapi = msgapi_plugin() 312 | 313 | 314 | def default_event_loop(request=None, config=None): 315 | if request is not None: 316 | config = request.config 317 | loop = config.getoption("--loop") 318 | if loop == 'uvloop': 319 | import uvloop # noqa 320 | asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) 321 | else: 322 | loop = 'asyncio' 323 | return loop 324 | 325 | 326 | def unused_tcp_port(): 327 | """ 328 | Find an unused localhost TCP port from 1024-65535 and return it. 329 | """ 330 | with closing(socket.socket()) as sock: 331 | sock.bind((pytest.msgapi['msgapi']['ip'], 0)) 332 | return sock.getsockname()[1] 333 | 334 | 335 | def identity(): 336 | return pytest.msgapi['msgapi']['id'], pytest.msgapi['msgapi']['secret'] 337 | 338 | 339 | @pytest.fixture(scope='module') 340 | def server(): 341 | return Server() 342 | 343 | 344 | @pytest.fixture(scope='module') 345 | def raw_message(): 346 | return RawMessage 347 | 348 | 349 | @pytest.fixture(scope='module') 350 | def event_loop(request): 351 | """ 352 | Create an instance of the requested event loop. 353 | """ 354 | default_event_loop(request=request) 355 | 356 | # Create new event loop 357 | _event_loop = asyncio.new_event_loop() 358 | asyncio.set_event_loop(_event_loop) 359 | 360 | def fin(): 361 | _event_loop.close() 362 | 363 | # Add finaliser and return new event loop 364 | request.addfinalizer(fin) 365 | return _event_loop 366 | 367 | 368 | @pytest.fixture(scope='module') 369 | def api_server_port(): 370 | return unused_tcp_port() 371 | 372 | 373 | @pytest.fixture(scope='module') 374 | def api_server(request, event_loop, api_server_port, server): 375 | async def start_server(): 376 | port = api_server_port 377 | app = web.Application(client_max_size=100 * (2**20)) 378 | app.router.add_routes(server.routes) 379 | runner = web.AppRunner(app) 380 | await runner.setup() 381 | site = web.TCPSite( 382 | runner, host=pytest.msgapi['msgapi']['ip'], port=port, shutdown_timeout=1.0) 383 | await site.start() 384 | return app, runner, site 385 | app, runner, site = event_loop.run_until_complete(start_server()) 386 | 387 | def fin(): 388 | async def stop_server(): 389 | await site.stop() 390 | await runner.cleanup() 391 | await app.cleanup() 392 | event_loop.run_until_complete(stop_server()) 393 | 394 | request.addfinalizer(fin) 395 | 396 | 397 | @pytest.fixture(scope='module') 398 | def mock_url(api_server_port): 399 | """ 400 | Return the URL where the test server can be reached. 401 | """ 402 | return 'http://{}:{}'.format(pytest.msgapi['msgapi']['ip'], api_server_port) 403 | 404 | 405 | @pytest.fixture(scope='module') 406 | def connection(request, event_loop, api_server, mock_url): 407 | async def create_connection(): 408 | # Note: We're not doing anything with the server but obviously the 409 | # server needs to be started to be able to connect 410 | return threema.gateway.Connection( 411 | identity=pytest.msgapi['msgapi']['id'], 412 | secret=pytest.msgapi['msgapi']['secret'], 413 | key=pytest.msgapi['msgapi']['private'], 414 | ) 415 | connection_ = event_loop.run_until_complete(create_connection()) 416 | 417 | # Patch URLs 418 | connection_.urls = {key: value.replace(pytest.msgapi['msgapi']['base_url'], mock_url) 419 | for key, value in connection_.urls.items()} 420 | 421 | def fin(): 422 | event_loop.run_until_complete(connection_.close()) 423 | 424 | request.addfinalizer(fin) 425 | return connection_ 426 | 427 | 428 | @pytest.fixture(scope='module') 429 | def connection_blocking(request, event_loop, api_server, mock_url): 430 | async def create_connection(): 431 | # Note: We're not doing anything with the server but obviously the 432 | # server needs to be started to be able to connect 433 | return threema.gateway.Connection( 434 | identity=pytest.msgapi['msgapi']['id'], 435 | secret=pytest.msgapi['msgapi']['secret'], 436 | key=pytest.msgapi['msgapi']['private'], 437 | blocking=True, 438 | ) 439 | connection_ = event_loop.run_until_complete(create_connection()) 440 | 441 | # Patch URLs 442 | connection_.urls = {key: value.replace(pytest.msgapi['msgapi']['base_url'], mock_url) 443 | for key, value in connection_.urls.items()} 444 | 445 | def fin(): 446 | event_loop.run_until_complete(connection_.close()) 447 | 448 | request.addfinalizer(fin) 449 | return connection_ 450 | 451 | 452 | @pytest.fixture(scope='module') 453 | def invalid_connection(connection): 454 | invalid_connection_ = copy.copy(connection) 455 | invalid_connection_.id = pytest.msgapi['msgapi']['noexist_id'] 456 | return invalid_connection_ 457 | 458 | 459 | @pytest.fixture(scope='module') 460 | def nocredit_connection(connection): 461 | nocredit_connection_ = copy.copy(connection) 462 | nocredit_connection_.id = pytest.msgapi['msgapi']['nocredit_id'] 463 | return nocredit_connection_ 464 | 465 | 466 | @pytest.fixture(scope='module') 467 | def blob(): 468 | return b'\x01\x02\x03' 469 | 470 | 471 | @pytest.fixture(scope='module') 472 | def blob_id(event_loop, connection, blob): 473 | coroutine = connection.upload(blob) 474 | return event_loop.run_until_complete(coroutine) 475 | 476 | 477 | @pytest.fixture(scope='module') 478 | def cli(api_server, api_server_port, event_loop): 479 | async def call_cli(*args, input=None, timeout=3.0): 480 | # Prepare environment 481 | env = os.environ.copy() 482 | env['THREEMA_TEST_API'] = str(api_server_port) 483 | test_api_mode = 'WARNING: Currently running in test mode!' 484 | 485 | # Call CLI in subprocess and get output 486 | parameters = [sys.executable, pytest.msgapi['msgapi']['cli_path']] + list(args) 487 | if isinstance(input, str): 488 | input = input.encode('utf-8') 489 | 490 | # Create process 491 | create = asyncio.create_subprocess_exec( 492 | *parameters, env=env, stdin=asyncio.subprocess.PIPE, 493 | stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT) 494 | process = await create 495 | 496 | # Wait for process to terminate 497 | coroutine = process.communicate(input=input) 498 | output, _ = await asyncio.wait_for(coroutine, timeout) 499 | 500 | # Process output 501 | output = output.decode('utf-8') 502 | if test_api_mode not in output: 503 | print(output) 504 | raise ValueError('Not running in test mode') 505 | 506 | # Strip leading empty lines and pydev debugger output 507 | rubbish = [ 508 | 'pydev debugger: process', 509 | 'Traceback (most recent call last):', 510 | test_api_mode, 511 | ] 512 | lines = [] 513 | skip_following_empty_lines = True 514 | for line in output.splitlines(keepends=True): 515 | if any((line.startswith(s) for s in rubbish)): 516 | skip_following_empty_lines = True 517 | elif not skip_following_empty_lines or len(line.strip()) > 0: 518 | lines.append(line) 519 | skip_following_empty_lines = False 520 | 521 | # Strip trailing empty lines 522 | empty_lines_count = 0 523 | for line in reversed(lines): 524 | if len(line.strip()) > 0: 525 | break 526 | empty_lines_count += 1 527 | if empty_lines_count > 0: 528 | lines = lines[:-empty_lines_count] 529 | output = ''.join(lines) 530 | 531 | # Check return code 532 | if process.returncode != 0: 533 | raise subprocess.CalledProcessError(process.returncode, parameters, 534 | output=output) 535 | return output 536 | return call_cli 537 | 538 | 539 | @pytest.fixture(scope='module') 540 | def private_key_file(tmpdir_factory): 541 | file = tmpdir_factory.mktemp('keys').join('private_key') 542 | file.write(pytest.msgapi['msgapi']['private']) 543 | return str(file) 544 | 545 | 546 | @pytest.fixture(scope='module') 547 | def public_key_file(tmpdir_factory): 548 | file = tmpdir_factory.mktemp('keys').join('public_key') 549 | file.write(pytest.msgapi['msgapi']['public']) 550 | return str(file) 551 | 552 | 553 | Callback = collections.namedtuple('Callback', ['queue', 'handle_message']) 554 | 555 | 556 | @pytest.fixture(scope='module') 557 | def callback(event_loop, connection): 558 | async def create_callback(): 559 | queue = asyncio.Queue() 560 | 561 | async def handle_message(message): 562 | await queue.put(message) 563 | 564 | return Callback(queue, handle_message) 565 | return event_loop.run_until_complete(create_callback()) 566 | 567 | 568 | @pytest.fixture(scope='module') 569 | def callback_server_port(): 570 | return unused_tcp_port() 571 | 572 | 573 | @pytest.fixture(scope='module') 574 | def callback_server(request, event_loop, connection, callback, callback_server_port): 575 | async def start_callback_server(): 576 | app = e2e.create_application(connection) 577 | e2e.add_callback_route(connection, app, callback.handle_message) 578 | runner = web.AppRunner(app) 579 | await runner.setup() 580 | 581 | cert_path = pytest.msgapi['msgapi']['cert_path'] 582 | ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) 583 | ssl_context.load_cert_chain(certfile=cert_path) 584 | site = web.TCPSite( 585 | runner, host=pytest.msgapi['msgapi']['ip'], port=callback_server_port, 586 | ssl_context=ssl_context) 587 | await site.start() 588 | 589 | return app, runner, site 590 | app, runner, site = event_loop.run_until_complete(start_callback_server()) 591 | 592 | def fin(): 593 | async def stop_callback_server(): 594 | await site.stop() 595 | await runner.cleanup() 596 | await app.cleanup() 597 | event_loop.run_until_complete(stop_callback_server()) 598 | 599 | request.addfinalizer(fin) 600 | 601 | 602 | @pytest.fixture(scope='module') 603 | def callback_client(request, event_loop, callback_server): 604 | async def create_client(): 605 | # Note: This is ONLY required because we are using a self-signed certificate 606 | # for test purposes. 607 | connector_ = aiohttp.TCPConnector(ssl=False) 608 | session_ = aiohttp.ClientSession(connector=connector_) 609 | return connector_, session_ 610 | connector, session = event_loop.run_until_complete(create_client()) 611 | 612 | def fin(): 613 | event_loop.run_until_complete(session.close()) 614 | 615 | request.addfinalizer(fin) 616 | return session 617 | 618 | 619 | @pytest.fixture(scope='module') 620 | def callback_send(callback_client, callback_server_port, connection): 621 | async def send(message): 622 | # Get data from message 623 | nonce, data = await message.send(get_data_only=True) 624 | 625 | # Create callback parameters 626 | params = { 627 | 'from': connection.id, 628 | 'to': message.to_id, 629 | 'messageId': hashlib.sha256(message.to_id.encode('ascii')).hexdigest()[16:], 630 | 'date': str(time.time()), 631 | 'nonce': binascii.hexlify(nonce).decode('ascii'), 632 | 'box': binascii.hexlify(data).decode('ascii'), 633 | } 634 | 635 | # Calculate MAC 636 | message = ''.join((params['from'], params['to'], params['messageId'], 637 | params['date'], params['nonce'], params['box'])) 638 | message = message.encode('ascii') 639 | encoded_secret = connection.secret.encode('ascii') 640 | hmac_ = hmac.new(encoded_secret, msg=message, digestmod=hashlib.sha256) 641 | params['mac'] = hmac_.hexdigest() 642 | 643 | # Send message 644 | url = 'https://{}:{}/gateway_callback'.format( 645 | pytest.msgapi['msgapi']['ip'], callback_server_port) 646 | return await callback_client.post(url, data=params) 647 | 648 | return send 649 | 650 | 651 | @pytest.fixture(scope='module') 652 | def callback_receive(event_loop, callback, callback_server): 653 | async def receive(timeout=3.0): 654 | coroutine = asyncio.wait_for(callback.queue.get(), timeout) 655 | return await coroutine 656 | 657 | return receive 658 | -------------------------------------------------------------------------------- /tests/res/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDFInmjIIczoqRq 3 | UQOAm0/ddboZdfFf1TTdQtdADrv3kp7LMWRHy7+CITTT/gImUV5Zll/g727kCUED 4 | 4VRnprqFPra5pKWSocVoDre2dWnvOy4aVv0qgSydpzlyMI4HDbMszLb4N/ZQDqIU 5 | Alr9QnQ+bIANyhgWg9/b3LPMSBaiXyDzk7cb3Q7taP5WR4uEauD74TXOhHNT1fWG 6 | hZhI+PT9/GGoNxiXctLAyd5PlS8+pe4GNKXPuY1l+mJhP9uMMr37g6PCE/H6ehqI 7 | f3Djm00iHrOz/rTQGSTrogx2L0OajJ2CpWOJB8+D5Q/bjERh5HUiUixeoOn2mBnF 8 | 44WnNP9TAgMBAAECggEAKE3XHqHs4oKzKMVteOLIHlgOd1wkwFof18jtpzwb9A73 9 | BkYP4ZnnipxtZ5Y8LEdgieJzsdJiEp9NupRcJGDzK4DZ7PSboXIPoSm5J8WzpeSs 10 | lVgJpKIKVCU3WoBQ2WJUqqkkE3Wll1KWko60uajXiVe3ipox+JB3uUTTQcXPUtzS 11 | DUEr33wEpfMWnsXDekkByIaDOgRyhg6spftMOmPmB2hugl7rGQtX4rYx9QFxJzRj 12 | DNhj+6aRZlnLWtkDEyeXM6aPOkdEoU6ROrnJ6vJLTkyzpCtLpPZVRw9GIKslsGaw 13 | Bi/MvQAVy4rTVoHG7dvaa7Lnr7hZIQmUNo7xDAyTGQKBgQDxyOXVmCqNCsNoiiwy 14 | X6rT3clPRYrChGk/O5JO5n7+Q7zmfXx4gVIpY5TdvpHMeF6mwoQxep+vr+cHehqA 15 | 98Btfh6mEk0ERV8gbMJI7Q3OglVmkl+hsO6pTEx6JSEFwX9UzDvDp5sIS4W9L8Zq 16 | iyyxePSzs2nelG30litTon5L/wKBgQDQuYyFAp6xJ2n7omkQ9eStCvtXiU5vIjTt 17 | r/ba/ylZFDCUgU8M1oP+mC93w6ypPmzV8V9kL+kRkPL56LmFhtl6v1pGXlt6i5C8 18 | S4+E14ovOlCSfPytlopBlxuyOX4oOoXkN8gzP4tBixbMmTgE/KDLLcNHhDgeBgDa 19 | RQAi+3RcrQKBgH85nB6xjCpdMIewtSLojiYfvQ8WY7aJICxit1EHHmnC0QJjo3xx 20 | Z/9ZY/rujR+PcxbCofa7NI2ovKOFj66vLzUuOQhf9uC5dB3GvNDM1AgHMtLfUKzv 21 | QhYZjOB06xRxRgQj14rThdNukfgDzJ9BjonwQKrSTHIPnnAmGLRQe66XAoGAATDt 22 | 4lxvd2dYLX1xyAz/LxWe9ZLtBalWT/zvFbTbEY0R8ecDAnm+6xcHPlG5jIW0rUvh 23 | VXsIg3cmS9LOLDrmxtKMu1YSg5KEUu7DdOid+0MD7rIT5xGy3Ej2eX/mfmhHF1RS 24 | Kii0rL0UdjpxnWWrrT0nniLdBx7Vpmk6ZPi2Y0kCgYAK6IGcgDghdZ2JeI5OmPKl 25 | +ZK5ukogckcYwzkE0Wq2iZcRUOz96k1P8L2hCmdFZ31xBDJvalW3ByQyOgkR65wk 26 | C5fs2BMmmikZCspN1Gfaljg9hlVobRAzbHmkuBAtHI888Cwo0dWCr1EII65ZFavL 27 | msf0c268rB3vXrHv0Sk3+A== 28 | -----END PRIVATE KEY----- 29 | -----BEGIN CERTIFICATE----- 30 | MIIC+zCCAeOgAwIBAgIJAKVxSurgV9/2MA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV 31 | BAMMCTEyNy4wLjAuMTAeFw0xNjAyMjYyMzMwNTRaFw0xNzAyMjUyMzMwNTRaMBQx 32 | EjAQBgNVBAMMCTEyNy4wLjAuMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC 33 | ggEBAMUieaMghzOipGpRA4CbT911uhl18V/VNN1C10AOu/eSnssxZEfLv4IhNNP+ 34 | AiZRXlmWX+DvbuQJQQPhVGemuoU+trmkpZKhxWgOt7Z1ae87LhpW/SqBLJ2nOXIw 35 | jgcNsyzMtvg39lAOohQCWv1CdD5sgA3KGBaD39vcs8xIFqJfIPOTtxvdDu1o/lZH 36 | i4Rq4PvhNc6Ec1PV9YaFmEj49P38Yag3GJdy0sDJ3k+VLz6l7gY0pc+5jWX6YmE/ 37 | 24wyvfuDo8IT8fp6Goh/cOObTSIes7P+tNAZJOuiDHYvQ5qMnYKlY4kHz4PlD9uM 38 | RGHkdSJSLF6g6faYGcXjhac0/1MCAwEAAaNQME4wHQYDVR0OBBYEFBpI+h6gXuTm 39 | uWwB6P5+WhiCG4lvMB8GA1UdIwQYMBaAFBpI+h6gXuTmuWwB6P5+WhiCG4lvMAwG 40 | A1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAKfGRfCjLPgsnva52EBaHlam 41 | vThbeOdc+oSSTg0zhJgiotE8cFeIK7UjfWjo07+dft/ppCg0Uyo/fJORt+9jUrVW 42 | VZGEAQJ5StDVmcn2mPFAhH+rVryaqYGw7YwGXc8Pj2qn4dY5mv+ljpE/9cxHqixn 43 | H1foOBqlvJf1hqeoOASwWPF3bv6cQP9HopZlOypkvmEwFtzlENMcHUxYOSFRMgN1 44 | TwWA2J+ySEpqYBUhnqi+5F0vZvjRAdFdzeY9Fxo2XNHwmlJc4e1OZ1+TIp3C0uDB 45 | WwRvOZgbDX+jRMzAp8I54EFOExkwsZW21bKH/fv2JtSNbk0DK5pMT5Dz7HWkcO8= 46 | -----END CERTIFICATE----- 47 | -------------------------------------------------------------------------------- /tests/res/threema.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/threema-ch/threema-msgapi-sdk-python/19ba796a386a76c8f978b77246ab878a495bfce7/tests/res/threema.jpg -------------------------------------------------------------------------------- /tests/res/threema.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/threema-ch/threema-msgapi-sdk-python/19ba796a386a76c8f978b77246ab878a495bfce7/tests/res/threema.mp4 -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | """ 2 | The tests provided in this module have been tested for compliance with 3 | the Threema Gateway server. Obviously, the simulated server does not 4 | completely mimic the behaviour of the Gateway server. 5 | """ 6 | import asyncio 7 | 8 | import pytest 9 | 10 | from threema.gateway import ( 11 | ReceptionCapability, 12 | e2e, 13 | simple, 14 | ) 15 | from threema.gateway.exception import ( 16 | BlobServerError, 17 | CreditsServerError, 18 | IDServerError, 19 | KeyServerError, 20 | MessageServerError, 21 | ReceptionCapabilitiesServerError, 22 | ) 23 | 24 | 25 | async def download_blob(connection, blob_id): 26 | response = await connection.download(blob_id) 27 | return await response.read() 28 | 29 | 30 | async def get_latest_blob_ids(server, connection): 31 | return await asyncio.gather(*[download_blob(connection, blob_id) 32 | for blob_id in server.latest_blob_ids]) 33 | 34 | 35 | class TestLookupPublicKey: 36 | @pytest.mark.asyncio 37 | async def test_invalid_identitiy(self, invalid_connection): 38 | with pytest.raises(KeyServerError) as exc_info: 39 | await invalid_connection.get_public_key('ECHOECHO') 40 | assert exc_info.value.status == 401 41 | 42 | @pytest.mark.asyncio 43 | async def test_invalid_length(self, connection): 44 | with pytest.raises(KeyServerError) as exc_info: 45 | await connection.get_public_key('TEST') 46 | assert exc_info.value.status == 404 47 | 48 | @pytest.mark.asyncio 49 | async def test_unknown_id(self, connection): 50 | with pytest.raises(KeyServerError) as exc_info: 51 | await connection.get_public_key('00000000') 52 | assert exc_info.value.status == 404 53 | 54 | @pytest.mark.asyncio 55 | async def test_valid_id(self, connection, server): 56 | key = await connection.get_public_key('ECHOECHO') 57 | assert key.hex_pk() == server.echoecho_key 58 | 59 | @pytest.mark.asyncio 60 | async def test_cache_expiration(self, connection, server): 61 | connection.get_public_key.cache_clear() 62 | for _ in range(10): 63 | await connection.get_public_key('ECHOECHO') 64 | cache_info = connection.get_public_key.cache_info() 65 | assert cache_info.misses == 1 66 | assert cache_info.hits == 9 67 | await asyncio.sleep(0.2) 68 | await connection.get_public_key('ECHOECHO') 69 | cache_info = connection.get_public_key.cache_info() 70 | # For some reason, the cache is not always being cleared and I don't want 71 | # Travis to fail all the time 72 | assert cache_info.misses == 1 or cache_info.misses == 2 73 | 74 | @pytest.mark.asyncio 75 | async def test_cache_hits(self, connection, server): 76 | connection.get_public_key.cache_clear() 77 | for _ in range(1000): 78 | await self.test_valid_id(connection, server) 79 | cache_info = connection.get_public_key.cache_info() 80 | assert cache_info.misses == 1 81 | assert cache_info.hits == 999 82 | 83 | 84 | class TestLookupIDByPhone: 85 | @pytest.mark.asyncio 86 | async def test_invalid_identity(self, invalid_connection): 87 | with pytest.raises(IDServerError) as exc_info: 88 | await invalid_connection.get_id(phone='44123456789') 89 | assert exc_info.value.status == 401 90 | 91 | @pytest.mark.asyncio 92 | async def test_unknown_phone(self, connection): 93 | with pytest.raises(IDServerError) as exc_info: 94 | await connection.get_id(phone='44987654321') 95 | assert exc_info.value.status == 404 96 | 97 | @pytest.mark.asyncio 98 | async def test_invalid_phone(self, connection): 99 | with pytest.raises(IDServerError) as exc_info: 100 | await connection.get_id(phone='-12537825318') 101 | assert exc_info.value.status == 404 102 | 103 | @pytest.mark.asyncio 104 | async def test_valid_phone(self, connection): 105 | id_ = await connection.get_id(phone='44123456789') 106 | assert id_ == 'ECHOECHO' 107 | 108 | @pytest.mark.asyncio 109 | async def test_cache_hits(self, connection): 110 | connection.get_id.cache_clear() 111 | for _ in range(1000): 112 | await self.test_valid_phone(connection) 113 | cache_info = connection.get_id.cache_info() 114 | assert cache_info.misses == 1 115 | assert cache_info.hits == 999 116 | 117 | 118 | class TestLookupIDByPhoneHash: 119 | @pytest.mark.asyncio 120 | async def test_invalid_identity(self, invalid_connection): 121 | with pytest.raises(IDServerError) as exc_info: 122 | await invalid_connection.get_id(phone_hash='invalid_hash') 123 | assert exc_info.value.status == 401 124 | 125 | @pytest.mark.asyncio 126 | async def test_invalid_length(self, connection): 127 | phone_hash = '98b05f6eda7a878f6f016bdcdc9db6eb61a6b190e814ff787142115af14421' 128 | with pytest.raises(IDServerError) as exc_info: 129 | await connection.get_id(phone_hash=phone_hash) 130 | assert exc_info.value.status == 400 131 | 132 | @pytest.mark.asyncio 133 | async def test_odd_length(self, connection): 134 | phone_hash = '98b05f6eda7a878f6f016bdcdc9db6eb61a6b190e814ff787142115af144214' 135 | with pytest.raises(IDServerError) as exc_info: 136 | await connection.get_id(phone_hash=phone_hash) 137 | # Note: This status code might not be intended and may change in the future 138 | assert exc_info.value.status == 500 139 | 140 | @pytest.mark.asyncio 141 | async def test_invalid_phone_hash(self, connection): 142 | with pytest.raises(IDServerError) as exc_info: 143 | await connection.get_id(phone_hash='1234') 144 | assert exc_info.value.status == 400 145 | 146 | @pytest.mark.asyncio 147 | async def test_unknown_phone_hash(self, connection): 148 | phone_hash = '98b05f6eda7a878f6f016bdcdc9db6eb61a6b190e814ff787142115af144214a' 149 | with pytest.raises(IDServerError) as exc_info: 150 | await connection.get_id(phone_hash=phone_hash) 151 | assert exc_info.value.status == 404 152 | 153 | @pytest.mark.asyncio 154 | async def test_valid_phone_hash(self, connection): 155 | phone_hash = '98b05f6eda7a878f6f016bdcdc9db6eb61a6b190e814ff787142115af144214c' 156 | id_ = await connection.get_id(phone_hash=phone_hash) 157 | assert id_ == 'ECHOECHO' 158 | 159 | @pytest.mark.asyncio 160 | async def test_cache_hits(self, connection): 161 | connection.get_id.cache_clear() 162 | for _ in range(1000): 163 | await self.test_valid_phone_hash(connection) 164 | cache_info = connection.get_id.cache_info() 165 | assert cache_info.misses == 1 166 | assert cache_info.hits == 999 167 | 168 | 169 | class TestLookupIDByEmail: 170 | @pytest.mark.asyncio 171 | async def test_invalid_identity(self, invalid_connection): 172 | with pytest.raises(IDServerError) as exc_info: 173 | await invalid_connection.get_id(email='echoecho@example.com') 174 | assert exc_info.value.status == 401 175 | 176 | @pytest.mark.asyncio 177 | async def test_unknown_email(self, connection): 178 | with pytest.raises(IDServerError) as exc_info: 179 | await connection.get_id(email='somemail@example.com') 180 | assert exc_info.value.status == 404 181 | 182 | @pytest.mark.asyncio 183 | async def test_invalid_email(self, connection): 184 | with pytest.raises(IDServerError) as exc_info: 185 | await connection.get_id(email='invalid') 186 | assert exc_info.value.status == 404 187 | 188 | @pytest.mark.asyncio 189 | async def test_valid_email(self, connection): 190 | id_ = await connection.get_id(email='echoecho@example.com') 191 | assert id_ == 'ECHOECHO' 192 | 193 | @pytest.mark.asyncio 194 | async def test_cache_hits(self, connection): 195 | connection.get_id.cache_clear() 196 | for _ in range(1000): 197 | await self.test_valid_email(connection) 198 | cache_info = connection.get_id.cache_info() 199 | assert cache_info.misses == 1 200 | assert cache_info.hits == 999 201 | 202 | 203 | class TestLookupIDByEmailHash: 204 | @pytest.mark.asyncio 205 | async def test_invalid_identity(self, invalid_connection): 206 | with pytest.raises(IDServerError) as exc_info: 207 | await invalid_connection.get_id(email_hash='invalid_hash') 208 | assert exc_info.value.status == 401 209 | 210 | @pytest.mark.asyncio 211 | async def test_invalid_length(self, connection): 212 | email_hash = '45a13d422b40f81936a9987245d3f6d9064c90607273af4f578246b4484669' 213 | with pytest.raises(IDServerError) as exc_info: 214 | await connection.get_id(email_hash=email_hash) 215 | assert exc_info.value.status == 400 216 | 217 | @pytest.mark.asyncio 218 | async def test_odd_length(self, connection): 219 | email_hash = '45a13d422b40f81936a9987245d3f6d9064c90607273af4f578246b4484669e' 220 | with pytest.raises(IDServerError) as exc_info: 221 | await connection.get_id(email_hash=email_hash) 222 | # Note: This status code might not be intended and may change in the future 223 | assert exc_info.value.status == 500 224 | 225 | @pytest.mark.asyncio 226 | async def test_invalid_email_hash(self, connection): 227 | with pytest.raises(IDServerError) as exc_info: 228 | await connection.get_id(email_hash='1234') 229 | assert exc_info.value.status == 400 230 | 231 | @pytest.mark.asyncio 232 | async def test_unknown_email_hash(self, connection): 233 | email_hash = '45a13d422b40f81936a9987245d3f6d9064c90607273af4f578246b4484669e1' 234 | with pytest.raises(IDServerError) as exc_info: 235 | await connection.get_id(email_hash=email_hash) 236 | assert exc_info.value.status == 404 237 | 238 | @pytest.mark.asyncio 239 | async def test_valid_email_hash(self, connection): 240 | email_hash = '45a13d422b40f81936a9987245d3f6d9064c90607273af4f578246b4484669e2' 241 | id_ = await connection.get_id(email_hash=email_hash) 242 | assert id_ == 'ECHOECHO' 243 | 244 | @pytest.mark.asyncio 245 | async def test_cache_hits(self, connection): 246 | connection.get_id.cache_clear() 247 | for _ in range(1000): 248 | await self.test_valid_email_hash(connection) 249 | cache_info = connection.get_id.cache_info() 250 | assert cache_info.misses == 1 251 | assert cache_info.hits == 999 252 | 253 | 254 | class TestReceptionCapabilities: 255 | @pytest.mark.asyncio 256 | async def test_invalid_identity(self, invalid_connection): 257 | with pytest.raises(ReceptionCapabilitiesServerError) as exc_info: 258 | await invalid_connection.get_reception_capabilities('ECHOECHO') 259 | assert exc_info.value.status == 401 260 | 261 | @pytest.mark.asyncio 262 | async def test_invalid_length(self, connection): 263 | with pytest.raises(ReceptionCapabilitiesServerError) as exc_info: 264 | await connection.get_reception_capabilities('TEST') 265 | assert exc_info.value.status == 404 266 | 267 | @pytest.mark.asyncio 268 | async def test_unknown_id(self, connection): 269 | with pytest.raises(ReceptionCapabilitiesServerError) as exc_info: 270 | await connection.get_reception_capabilities('00000000') 271 | assert exc_info.value.status == 404 272 | 273 | @pytest.mark.asyncio 274 | async def test_valid_id(self, connection): 275 | key = await connection.get_reception_capabilities('ECHOECHO') 276 | assert key == { 277 | ReceptionCapability.text, 278 | ReceptionCapability.image, 279 | ReceptionCapability.video, 280 | ReceptionCapability.file 281 | } 282 | 283 | @pytest.mark.asyncio 284 | async def test_cache_hits(self, connection): 285 | connection.get_reception_capabilities.cache_clear() 286 | for _ in range(1000): 287 | await self.test_valid_id(connection) 288 | cache_info = connection.get_reception_capabilities.cache_info() 289 | assert cache_info.misses == 1 290 | assert cache_info.hits == 999 291 | 292 | 293 | class TestCredits: 294 | @pytest.mark.asyncio 295 | async def test_invalid_identity(self, invalid_connection): 296 | with pytest.raises(CreditsServerError) as exc_info: 297 | await invalid_connection.get_credits() 298 | assert exc_info.value.status == 401 299 | 300 | @pytest.mark.asyncio 301 | async def test_valid(self, connection): 302 | assert (await connection.get_credits()) == 100 303 | 304 | 305 | class TestUploadBlob: 306 | @pytest.mark.asyncio 307 | async def test_invalid_identity(self, invalid_connection): 308 | with pytest.raises(BlobServerError) as exc_info: 309 | await invalid_connection.upload(b'\x01') 310 | assert exc_info.value.status == 401 311 | 312 | @pytest.mark.asyncio 313 | async def test_insufficient_credits(self, nocredit_connection): 314 | with pytest.raises(BlobServerError) as exc_info: 315 | await nocredit_connection.upload(b'\x01') 316 | assert exc_info.value.status == 402 317 | 318 | @pytest.mark.asyncio 319 | async def test_just_ok(self, connection, server): 320 | blob_id = await connection.upload(bytes(20 * (2**20))) 321 | assert len(blob_id) == 32 322 | # Note: Remove big blob because further tests may hang 323 | del server.blobs[blob_id] 324 | 325 | @pytest.mark.asyncio 326 | async def test_too_big(self, connection, server): 327 | with pytest.raises(BlobServerError) as exc_info: 328 | blob_id = await connection.upload(bytes((20 * (2**20)) + 1)) 329 | # Note: Remove big blob because further tests may hang 330 | del server.blobs[blob_id] 331 | assert exc_info.value.status == 413 332 | 333 | @pytest.mark.asyncio 334 | async def test_zero(self, connection): 335 | with pytest.raises(BlobServerError) as exc_info: 336 | await connection.upload(b'') 337 | assert exc_info.value.status == 400 338 | 339 | @pytest.mark.asyncio 340 | async def test_file(self, connection): 341 | assert len(await connection.upload(b'\x01')) == 32 342 | 343 | 344 | class TestDownloadBlob: 345 | @pytest.mark.asyncio 346 | async def test_invalid_identity(self, invalid_connection, blob_id): 347 | with pytest.raises(BlobServerError) as exc_info: 348 | await (await invalid_connection.download(blob_id)).read() 349 | assert exc_info.value.status == 401 350 | 351 | @pytest.mark.asyncio 352 | async def test_invalid_id(self, connection): 353 | with pytest.raises(BlobServerError) as exc_info: 354 | await (await connection.download('f' * 15)).read() 355 | assert exc_info.value.status == 404 356 | 357 | @pytest.mark.asyncio 358 | async def test_unknown_id(self, connection): 359 | with pytest.raises(BlobServerError) as exc_info: 360 | await (await connection.download('f' * 16)).read() 361 | assert exc_info.value.status == 404 362 | 363 | @pytest.mark.asyncio 364 | async def test_file(self, connection, blob_id, blob): 365 | response = await connection.download(blob_id) 366 | assert (await response.read()) == blob 367 | 368 | @pytest.mark.asyncio 369 | async def test_no_credits(self, nocredit_connection, blob_id, blob): 370 | response = await nocredit_connection.download(blob_id) 371 | assert (await response.read()) == blob 372 | 373 | 374 | class TestSendSimple: 375 | @pytest.mark.asyncio 376 | async def test_invalid_identity(self, invalid_connection): 377 | with pytest.raises(MessageServerError) as exc_info: 378 | await simple.TextMessage( 379 | connection=invalid_connection, 380 | to_id='ECHOECHO', 381 | text='Hello' 382 | ).send() 383 | assert exc_info.value.status == 401 384 | 385 | @pytest.mark.asyncio 386 | async def test_insufficient_credits(self, nocredit_connection): 387 | with pytest.raises(MessageServerError) as exc_info: 388 | await simple.TextMessage( 389 | connection=nocredit_connection, 390 | to_id='ECHOECHO', 391 | text='Hello' 392 | ).send() 393 | assert exc_info.value.status == 402 394 | 395 | @pytest.mark.asyncio 396 | async def test_message_too_long(self, connection): 397 | with pytest.raises(MessageServerError) as exc_info: 398 | await simple.TextMessage( 399 | connection=connection, 400 | to_id='ECHOECHO', 401 | text='0' * 3501 402 | ).send() 403 | assert exc_info.value.status == 413 404 | 405 | @pytest.mark.asyncio 406 | async def test_unknown_id(self, connection): 407 | with pytest.raises(MessageServerError) as exc_info: 408 | await simple.TextMessage( 409 | connection=connection, 410 | to_id='00000000', 411 | text='Hello' 412 | ).send() 413 | assert exc_info.value.status == 400 414 | 415 | @pytest.mark.asyncio 416 | async def test_unknown_email(self, connection): 417 | with pytest.raises(MessageServerError) as exc_info: 418 | await simple.TextMessage( 419 | connection=connection, 420 | email='somemail@example.com', 421 | text='Hello' 422 | ).send() 423 | assert exc_info.value.status == 404 424 | 425 | @pytest.mark.asyncio 426 | async def test_unknown_phone(self, connection): 427 | with pytest.raises(MessageServerError) as exc_info: 428 | await simple.TextMessage( 429 | connection=connection, 430 | phone='44987654321', 431 | text='Hello' 432 | ).send() 433 | assert exc_info.value.status == 404 434 | 435 | @pytest.mark.asyncio 436 | async def test_via_id(self, connection): 437 | id_ = await simple.TextMessage( 438 | connection=connection, 439 | to_id='ECHOECHO', 440 | text='0' * 3500 441 | ).send() 442 | assert id_ == '0' * 16 443 | 444 | @pytest.mark.asyncio 445 | async def test_via_email(self, connection): 446 | id_ = await simple.TextMessage( 447 | connection=connection, 448 | email='echoecho@example.com', 449 | text='Hello' 450 | ).send() 451 | assert id_ == '0' * 16 452 | 453 | @pytest.mark.asyncio 454 | async def test_via_phone(self, connection): 455 | id_ = await simple.TextMessage( 456 | connection=connection, 457 | phone='44123456789', 458 | text='Hello' 459 | ).send() 460 | assert id_ == '0' * 16 461 | 462 | 463 | class TestSendE2E: 464 | @pytest.mark.asyncio 465 | async def test_invalid_identity(self, invalid_connection, server): 466 | with pytest.raises(MessageServerError) as exc_info: 467 | await e2e.TextMessage( 468 | connection=invalid_connection, 469 | to_id='ECHOECHO', 470 | key=server.echoecho_encoded_key, 471 | text='Hello' 472 | ).send() 473 | assert exc_info.value.status == 401 474 | 475 | @pytest.mark.asyncio 476 | async def test_insufficient_credits(self, nocredit_connection, server): 477 | with pytest.raises(MessageServerError) as exc_info: 478 | await e2e.TextMessage( 479 | connection=nocredit_connection, 480 | to_id='ECHOECHO', 481 | key=server.echoecho_encoded_key, 482 | text='Hello' 483 | ).send() 484 | assert exc_info.value.status == 402 485 | 486 | @pytest.mark.asyncio 487 | async def test_message_too_long(self, connection, raw_message): 488 | with pytest.raises(MessageServerError) as exc_info: 489 | await raw_message( 490 | connection=connection, 491 | to_id='ECHOECHO', 492 | nonce=b'0' * 24, 493 | message=b'1' * 4001 494 | ).send() 495 | assert exc_info.value.status == 413 496 | 497 | @pytest.mark.asyncio 498 | async def test_unknown_id(self, connection, server): 499 | with pytest.raises(MessageServerError) as exc_info: 500 | await e2e.TextMessage( 501 | connection=connection, 502 | to_id='00000000', 503 | key=server.echoecho_encoded_key, 504 | text='Hello' 505 | ).send() 506 | assert exc_info.value.status == 400 507 | 508 | @pytest.mark.asyncio 509 | async def test_raw(self, connection, raw_message): 510 | id_ = await raw_message( 511 | connection=connection, 512 | to_id='ECHOECHO', 513 | nonce=b'0' * 24, 514 | message=b'1' * 4000 515 | ).send() 516 | assert id_ == '1' * 16 517 | 518 | @pytest.mark.asyncio 519 | async def test_via_id(self, connection): 520 | id_ = await e2e.TextMessage( 521 | connection=connection, 522 | to_id='ECHOECHO', 523 | text='Hello' 524 | ).send() 525 | assert id_ == '1' * 16 526 | 527 | @pytest.mark.asyncio 528 | async def test_via_id_and_key(self, connection, server): 529 | id_ = await e2e.TextMessage( 530 | connection=connection, 531 | to_id='ECHOECHO', 532 | key=server.echoecho_encoded_key, 533 | text='Hello' 534 | ).send() 535 | assert id_ == '1' * 16 536 | 537 | @pytest.mark.asyncio 538 | async def test_image(self, connection, server): 539 | server.latest_blob_ids = [] 540 | id_ = await e2e.ImageMessage( 541 | connection=connection, 542 | to_id='ECHOECHO', 543 | key=server.echoecho_encoded_key, 544 | image_path=server.threema_jpg 545 | ).send() 546 | assert id_ == '1' * 16 547 | assert len(server.latest_blob_ids) == 1 548 | assert all((await get_latest_blob_ids(server, connection))) 549 | 550 | @pytest.mark.asyncio 551 | async def test_video(self, connection, server): 552 | server.latest_blob_ids = [] 553 | id_ = await e2e.VideoMessage( 554 | connection=connection, 555 | to_id='ECHOECHO', 556 | key=server.echoecho_encoded_key, 557 | duration=1, 558 | video_path=server.threema_mp4, 559 | thumbnail_path=server.threema_jpg, 560 | ).send() 561 | assert id_ == '1' * 16 562 | assert len(server.latest_blob_ids) == 2 563 | assert all((await get_latest_blob_ids(server, connection))) 564 | 565 | @pytest.mark.asyncio 566 | async def test_file(self, connection, server): 567 | server.latest_blob_ids = [] 568 | id_ = await e2e.FileMessage( 569 | connection=connection, 570 | to_id='ECHOECHO', 571 | key=server.echoecho_encoded_key, 572 | file_path=server.threema_jpg 573 | ).send() 574 | assert id_ == '1' * 16 575 | assert len(server.latest_blob_ids) == 1 576 | assert all((await get_latest_blob_ids(server, connection))) 577 | 578 | @pytest.mark.asyncio 579 | async def test_file_with_thumbnail(self, connection, server): 580 | server.latest_blob_ids = [] 581 | id_ = await e2e.FileMessage( 582 | connection=connection, 583 | to_id='ECHOECHO', 584 | key=server.echoecho_encoded_key, 585 | file_path=server.threema_jpg, 586 | thumbnail_path=server.threema_jpg 587 | ).send() 588 | assert id_ == '1' * 16 589 | assert len(server.latest_blob_ids) == 2 590 | assert all((await get_latest_blob_ids(server, connection))) 591 | -------------------------------------------------------------------------------- /tests/test_base.py: -------------------------------------------------------------------------------- 1 | import libnacl 2 | import pytest 3 | 4 | from threema.gateway import ( 5 | e2e, 6 | key, 7 | ) 8 | 9 | 10 | class TestCrypto: 11 | def test_incorrect_nonce(self): 12 | key_pair = key.Key.generate_pair() 13 | data_in = b'meow' 14 | nonce = b'0' * 23 15 | with pytest.raises(ValueError) as exc_info: 16 | e2e._pk_encrypt(key_pair, data_in, nonce=nonce) 17 | assert 'Invalid nonce size' in str(exc_info.value) 18 | 19 | def test_incorrect_ciphertext(self): 20 | key_pair = key.Key.generate_pair() 21 | data_in = b'meow' 22 | nonce = b'0' * 24 23 | with pytest.raises(libnacl.CryptError) as exc_info: 24 | _, data_encrypted = e2e._pk_encrypt(key_pair, data_in, nonce=nonce) 25 | e2e._pk_decrypt(key_pair, nonce, data_encrypted + b'0') 26 | assert 'decrypt' in str(exc_info.value) 27 | 28 | def test_valid(self): 29 | key_pair = key.Key.generate_pair() 30 | data_in = b'meow' 31 | nonce = b'0' * 24 32 | _, data_encrypted = e2e._pk_encrypt(key_pair, data_in, nonce=nonce) 33 | data_out = e2e._pk_decrypt(key_pair, nonce, data_encrypted) 34 | assert data_in == data_out 35 | -------------------------------------------------------------------------------- /tests/test_blocking_api.py: -------------------------------------------------------------------------------- 1 | from threema.gateway import ( 2 | e2e, 3 | simple, 4 | ) 5 | 6 | 7 | def test_lookup_id_by_phone(connection_blocking): 8 | identity = connection_blocking.get_id(phone='44123456789') 9 | assert identity == 'ECHOECHO' 10 | 11 | 12 | def test_lookup_id_by_phone_hash(connection_blocking): 13 | hash_ = '98b05f6eda7a878f6f016bdcdc9db6eb61a6b190e814ff787142115af144214c' 14 | identity = connection_blocking.get_id(phone_hash=hash_) 15 | assert identity == 'ECHOECHO' 16 | 17 | 18 | def test_lookup_public_key(connection_blocking, server): 19 | key = connection_blocking.get_public_key('ECHOECHO') 20 | assert key.hex_pk() == server.echoecho_key 21 | 22 | 23 | def test_lookup_reception_capabilities(connection_blocking): 24 | capabilities = connection_blocking.get_reception_capabilities('ECHOECHO') 25 | assert len(capabilities) == 4 26 | 27 | 28 | def test_send_e2e_text_message(connection_blocking): 29 | message = e2e.TextMessage( 30 | connection=connection_blocking, 31 | to_id='ECHOECHO', 32 | text='Hello. This works quite nicely!', 33 | ) 34 | id_ = message.send() 35 | assert id_ == '1' * 16 36 | 37 | 38 | def test_send_simple_text_message(connection_blocking): 39 | message = simple.TextMessage( 40 | connection=connection_blocking, 41 | to_id='ECHOECHO', 42 | text='Hello. This works quite nicely!', 43 | ) 44 | id_ = message.send() 45 | assert id_ == '0' * 16 46 | -------------------------------------------------------------------------------- /tests/test_callback.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from threema.gateway import e2e 4 | 5 | 6 | class TestCallback: 7 | @pytest.mark.asyncio 8 | async def test_invalid_message(self, connection, callback_send, raw_message): 9 | outgoing = raw_message( 10 | connection=connection, 11 | to_id=pytest.msgapi['msgapi']['id'], 12 | nonce=b'0' * 24, 13 | message=b'1' * 200 14 | ) 15 | response = await callback_send(outgoing) 16 | assert response.status == 400 17 | await response.release() 18 | 19 | @pytest.mark.asyncio 20 | async def test_delivery_receipt(self, connection, callback_send, callback_receive): 21 | outgoing = e2e.DeliveryReceipt( 22 | connection=connection, 23 | to_id=pytest.msgapi['msgapi']['id'], 24 | receipt_type=e2e.DeliveryReceipt.ReceiptType.read, 25 | message_ids=[b'0' * 8, b'1' * 8], 26 | ) 27 | response = await callback_send(outgoing) 28 | await response.release() 29 | incoming = await callback_receive() 30 | assert outgoing.from_id == incoming.from_id 31 | assert outgoing.to_id == incoming.to_id 32 | assert outgoing.receipt_type == incoming.receipt_type 33 | assert outgoing.message_ids == incoming.message_ids 34 | 35 | @pytest.mark.asyncio 36 | async def test_text_message(self, connection, callback_send, callback_receive): 37 | outgoing = e2e.TextMessage( 38 | connection, 39 | to_id=pytest.msgapi['msgapi']['id'], 40 | text='私はガラスを食べられます。それは私を傷つけません。!', 41 | ) 42 | response = await callback_send(outgoing) 43 | await response.release() 44 | incoming = await callback_receive() 45 | assert outgoing.from_id == incoming.from_id 46 | assert outgoing.to_id == incoming.to_id 47 | assert outgoing.text == incoming.text 48 | 49 | @pytest.mark.asyncio 50 | async def test_image_message( 51 | self, connection, callback_send, callback_receive, server 52 | ): 53 | outgoing = e2e.ImageMessage( 54 | connection, 55 | to_id=pytest.msgapi['msgapi']['id'], 56 | image_path=server.threema_jpg, 57 | ) 58 | response = await callback_send(outgoing) 59 | await response.release() 60 | incoming = await callback_receive() 61 | assert outgoing.from_id == incoming.from_id 62 | assert outgoing.to_id == incoming.to_id 63 | assert outgoing.image == incoming.image 64 | 65 | @pytest.mark.asyncio 66 | async def test_video(self, connection, callback_send, callback_receive, server): 67 | outgoing = e2e.VideoMessage( 68 | connection, 69 | to_id=pytest.msgapi['msgapi']['id'], 70 | duration=1, 71 | video_path=server.threema_mp4, 72 | thumbnail_path=server.threema_jpg, 73 | ) 74 | response = await callback_send(outgoing) 75 | await response.release() 76 | incoming = await callback_receive() 77 | assert outgoing.from_id == incoming.from_id 78 | assert outgoing.to_id == incoming.to_id 79 | assert outgoing.duration == incoming.duration 80 | assert outgoing.video == incoming.video 81 | assert outgoing.thumbnail_content == incoming.thumbnail_content 82 | 83 | @pytest.mark.asyncio 84 | async def test_file_message( 85 | self, connection, callback_send, callback_receive, server 86 | ): 87 | outgoing = e2e.FileMessage( 88 | connection, 89 | to_id=pytest.msgapi['msgapi']['id'], 90 | file_path=server.threema_jpg, 91 | ) 92 | response = await callback_send(outgoing) 93 | await response.release() 94 | incoming = await callback_receive() 95 | assert outgoing.from_id == incoming.from_id 96 | assert outgoing.to_id == incoming.to_id 97 | assert outgoing.file_content == incoming.file_content 98 | 99 | @pytest.mark.asyncio 100 | async def test_file_message_thumb( 101 | self, connection, callback_send, callback_receive, server 102 | ): 103 | outgoing = e2e.FileMessage( 104 | connection, 105 | to_id=pytest.msgapi['msgapi']['id'], 106 | file_path=server.threema_jpg, 107 | thumbnail_path=server.threema_jpg, 108 | ) 109 | response = await callback_send(outgoing) 110 | await response.release() 111 | incoming = await callback_receive() 112 | assert outgoing.from_id == incoming.from_id 113 | assert outgoing.to_id == incoming.to_id 114 | assert outgoing.file_content == incoming.file_content 115 | assert outgoing.thumbnail_content == incoming.thumbnail_content 116 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | import pytest 4 | 5 | from threema.gateway import ReceptionCapability 6 | from threema.gateway import __version__ as _version 7 | from threema.gateway import feature_level 8 | from threema.gateway.key import Key 9 | 10 | 11 | class TestCLI: 12 | @pytest.mark.asyncio 13 | async def test_invalid_command(self, cli): 14 | with pytest.raises(subprocess.CalledProcessError): 15 | await cli('meow') 16 | 17 | @pytest.mark.asyncio 18 | async def test_get_version(self, cli): 19 | output = await cli('version') 20 | assert 'Version: {}'.format(_version) in output 21 | assert 'Feature Level: {}'.format(feature_level) in output 22 | 23 | @pytest.mark.asyncio 24 | async def test_invalid_key(self, cli): 25 | with pytest.raises(subprocess.CalledProcessError) as exc_info: 26 | await cli('encrypt', 'meow', 'meow', input='meow') 27 | assert 'Invalid key format' in exc_info.value.output 28 | with pytest.raises(subprocess.CalledProcessError) as exc_info: 29 | await cli( 30 | 'encrypt', pytest.msgapi['msgapi']['public'], 31 | pytest.msgapi['msgapi']['private'], input='meow') 32 | assert 'Invalid key type' in exc_info.value.output 33 | 34 | @pytest.mark.asyncio 35 | async def test_encrypt_decrypt_stdin(self, cli): 36 | input = '私はガラスを食べられます。それは私を傷つけません。' 37 | output = await cli( 38 | 'encrypt', pytest.msgapi['msgapi']['private'], 39 | pytest.msgapi['msgapi']['public'], input=input) 40 | nonce, data = output.splitlines() 41 | output = await cli( 42 | 'decrypt', pytest.msgapi['msgapi']['private'], 43 | pytest.msgapi['msgapi']['public'], nonce, input=data) 44 | assert input in output 45 | 46 | @pytest.mark.asyncio 47 | async def test_encrypt_decrypt_parameter(self, cli): 48 | input = '私はガラスを食べられます。それは私を傷つけません。' 49 | output = await cli( 50 | 'encrypt', pytest.msgapi['msgapi']['private'], 51 | pytest.msgapi['msgapi']['public'], input=input) 52 | nonce, data = output.splitlines() 53 | output = await cli( 54 | 'decrypt', pytest.msgapi['msgapi']['private'], 55 | pytest.msgapi['msgapi']['public'], nonce, data) 56 | assert input in output 57 | 58 | @pytest.mark.asyncio 59 | async def test_encrypt_decrypt_by_file(self, cli, private_key_file, public_key_file): 60 | input = '私はガラスを食べられます。それは私を傷つけません。' 61 | output = await cli( 62 | 'encrypt', private_key_file, public_key_file, input=input) 63 | nonce, data = output.splitlines() 64 | output = await cli( 65 | 'decrypt', private_key_file, public_key_file, nonce, input=data) 66 | assert input in output 67 | 68 | @pytest.mark.asyncio 69 | async def test_generate(self, cli, tmpdir): 70 | private_key_file = tmpdir.join('tmp_private_key') 71 | public_key_file = tmpdir.join('tmp_public_key') 72 | await cli('generate', str(private_key_file), str(public_key_file)) 73 | private_key = Key.decode(private_key_file.read().strip(), Key.Type.private) 74 | public_key = Key.decode(public_key_file.read().strip(), Key.Type.public) 75 | assert private_key 76 | assert public_key 77 | 78 | @pytest.mark.asyncio 79 | async def test_hash_no_option(self, cli): 80 | with pytest.raises(subprocess.CalledProcessError): 81 | await cli('hash') 82 | 83 | @pytest.mark.asyncio 84 | async def test_hash_valid_email(self, cli): 85 | hash_ = '1ea093239cc5f0e1b6ec81b866265b921f26dc4033025410063309f4d1a8ee2c' 86 | output = await cli('hash', '-e', 'test@threema.ch') 87 | assert hash_ in output 88 | output = await cli('hash', '--email', 'test@threema.ch') 89 | assert hash_ in output 90 | 91 | @pytest.mark.asyncio 92 | async def test_hash_valid_phone_number(self, cli): 93 | hash_ = 'ad398f4d7ebe63c6550a486cc6e07f9baa09bd9d8b3d8cb9d9be106d35a7fdbc' 94 | output = await cli('hash', '-p', '41791234567') 95 | assert hash_ in output 96 | output = await cli('hash', '--phone', '41791234567') 97 | assert hash_ in output 98 | 99 | @pytest.mark.asyncio 100 | async def test_derive(self, cli): 101 | output = await cli('derive', pytest.msgapi['msgapi']['private']) 102 | assert pytest.msgapi['msgapi']['public'] in output 103 | 104 | @pytest.mark.asyncio 105 | async def test_send_simple(self, cli): 106 | id_, secret = pytest.msgapi['msgapi']['id'], pytest.msgapi['msgapi']['secret'] 107 | output = await cli('send-simple', 'ECHOECHO', id_, secret, input='Hello!') 108 | assert output 109 | 110 | @pytest.mark.asyncio 111 | async def test_send_e2e(self, cli, server): 112 | output_1 = await cli( 113 | 'send-e2e', 'ECHOECHO', pytest.msgapi['msgapi']['id'], 114 | pytest.msgapi['msgapi']['secret'], pytest.msgapi['msgapi']['private'], 115 | input='Hello!') 116 | assert output_1 117 | output_2 = await cli( 118 | 'send-e2e', 'ECHOECHO', pytest.msgapi['msgapi']['id'], 119 | pytest.msgapi['msgapi']['secret'], pytest.msgapi['msgapi']['private'], '-k', 120 | server.echoecho_encoded_key, input='Hello!') 121 | assert output_2 122 | assert output_1 == output_2 123 | 124 | @pytest.mark.asyncio 125 | async def test_send_image(self, cli, server): 126 | server.latest_blob_ids = [] 127 | output_1 = await cli( 128 | 'send-image', 'ECHOECHO', pytest.msgapi['msgapi']['id'], 129 | pytest.msgapi['msgapi']['secret'], pytest.msgapi['msgapi']['private'], 130 | server.threema_jpg) 131 | assert output_1 132 | assert len(server.latest_blob_ids) == 1 133 | output_2 = await cli( 134 | 'send-image', 'ECHOECHO', pytest.msgapi['msgapi']['id'], 135 | pytest.msgapi['msgapi']['secret'], pytest.msgapi['msgapi']['private'], 136 | server.threema_jpg, '-k', server.echoecho_encoded_key) 137 | assert output_2 138 | assert output_1 == output_2 139 | assert len(server.latest_blob_ids) == 2 140 | 141 | @pytest.mark.asyncio 142 | async def test_send_video(self, cli, server): 143 | server.latest_blob_ids = [] 144 | output_1 = await cli( 145 | 'send-video', 'ECHOECHO', pytest.msgapi['msgapi']['id'], 146 | pytest.msgapi['msgapi']['secret'], pytest.msgapi['msgapi']['private'], 147 | server.threema_mp4, server.threema_jpg) 148 | assert output_1 149 | assert len(server.latest_blob_ids) == 2 150 | output_2 = await cli( 151 | 'send-video', 'ECHOECHO', pytest.msgapi['msgapi']['id'], 152 | pytest.msgapi['msgapi']['secret'], pytest.msgapi['msgapi']['private'], 153 | server.threema_mp4, server.threema_jpg, '-k', server.echoecho_encoded_key) 154 | assert output_2 155 | assert output_1 == output_2 156 | assert len(server.latest_blob_ids) == 4 157 | output = await cli( 158 | 'send-video', 'ECHOECHO', pytest.msgapi['msgapi']['id'], 159 | pytest.msgapi['msgapi']['secret'], pytest.msgapi['msgapi']['private'], 160 | server.threema_mp4, server.threema_jpg, '-d', '1337') 161 | assert output 162 | assert len(server.latest_blob_ids) == 6 163 | 164 | @pytest.mark.asyncio 165 | async def test_send_file(self, cli, server): 166 | server.latest_blob_ids = [] 167 | output_1 = await cli( 168 | 'send-file', 'ECHOECHO', pytest.msgapi['msgapi']['id'], 169 | pytest.msgapi['msgapi']['secret'], pytest.msgapi['msgapi']['private'], 170 | server.threema_jpg, '-c', 'See the picture?') 171 | assert output_1 172 | assert len(server.latest_blob_ids) == 1 173 | output_2 = await cli( 174 | 'send-file', 'ECHOECHO', pytest.msgapi['msgapi']['id'], 175 | pytest.msgapi['msgapi']['secret'], pytest.msgapi['msgapi']['private'], 176 | server.threema_jpg, '-k', server.echoecho_encoded_key) 177 | assert output_2 178 | assert output_1 == output_2 179 | assert len(server.latest_blob_ids) == 2 180 | output = await cli( 181 | 'send-file', 'ECHOECHO', pytest.msgapi['msgapi']['id'], 182 | pytest.msgapi['msgapi']['secret'], pytest.msgapi['msgapi']['private'], 183 | server.threema_jpg, '-t', server.threema_jpg) 184 | assert output 185 | assert len(server.latest_blob_ids) == 4 186 | output = await cli( 187 | 'send-file', 'ECHOECHO', pytest.msgapi['msgapi']['id'], 188 | pytest.msgapi['msgapi']['secret'], pytest.msgapi['msgapi']['private'], 189 | server.threema_jpg, '-k', server.echoecho_encoded_key, '-t', 190 | server.threema_jpg) 191 | assert output 192 | assert len(server.latest_blob_ids) == 6 193 | 194 | @pytest.mark.asyncio 195 | async def test_lookup_no_option(self, cli): 196 | with pytest.raises(subprocess.CalledProcessError): 197 | await cli('lookup', pytest.msgapi['msgapi']['id'], 198 | pytest.msgapi['msgapi']['secret']) 199 | 200 | @pytest.mark.asyncio 201 | async def test_lookup_id_by_email(self, cli): 202 | output = await cli( 203 | 'lookup', pytest.msgapi['msgapi']['id'], pytest.msgapi['msgapi']['secret'], 204 | '-e', 'echoecho@example.com') 205 | assert 'ECHOECHO' in output 206 | output = await cli( 207 | 'lookup', pytest.msgapi['msgapi']['id'], pytest.msgapi['msgapi']['secret'], 208 | '--email', 'echoecho@example.com') 209 | assert 'ECHOECHO' in output 210 | 211 | @pytest.mark.asyncio 212 | async def test_lookup_id_by_phone(self, cli): 213 | output = await cli( 214 | 'lookup', pytest.msgapi['msgapi']['id'], pytest.msgapi['msgapi']['secret'], 215 | '-p', '44123456789') 216 | assert 'ECHOECHO' in output 217 | output = await cli( 218 | 'lookup', pytest.msgapi['msgapi']['id'], pytest.msgapi['msgapi']['secret'], 219 | '--phone', '44123456789') 220 | assert 'ECHOECHO' in output 221 | 222 | @pytest.mark.asyncio 223 | async def test_lookup_pk_by_id(self, cli, server): 224 | output = await cli( 225 | 'lookup', pytest.msgapi['msgapi']['id'], pytest.msgapi['msgapi']['secret'], 226 | '-i', 'ECHOECHO') 227 | assert server.echoecho_encoded_key in output 228 | output = await cli( 229 | 'lookup', pytest.msgapi['msgapi']['id'], pytest.msgapi['msgapi']['secret'], 230 | '--id', 'ECHOECHO') 231 | assert server.echoecho_encoded_key in output 232 | 233 | @pytest.mark.asyncio 234 | async def test_capabilities(self, cli): 235 | output = await cli( 236 | 'capabilities', pytest.msgapi['msgapi']['id'], 237 | pytest.msgapi['msgapi']['secret'], 'ECHOECHO') 238 | capabilities = { 239 | ReceptionCapability.text, 240 | ReceptionCapability.image, 241 | ReceptionCapability.video, 242 | ReceptionCapability.file 243 | } 244 | assert all((capability.value in output for capability in capabilities)) 245 | 246 | @pytest.mark.asyncio 247 | async def test_credits(self, cli): 248 | output = await cli('credits', pytest.msgapi['msgapi']['id'], 249 | pytest.msgapi['msgapi']['secret']) 250 | assert '100' in output 251 | output = await cli( 252 | 'credits', pytest.msgapi['msgapi']['nocredit_id'], 253 | pytest.msgapi['msgapi']['secret']) 254 | assert '0' in output 255 | 256 | @pytest.mark.asyncio 257 | async def test_invalid_id(self, cli): 258 | with pytest.raises(subprocess.CalledProcessError) as exc_info: 259 | await cli( 260 | 'credits', pytest.msgapi['msgapi']['noexist_id'], 261 | pytest.msgapi['msgapi']['secret']) 262 | assert 'API identity or secret incorrect' in exc_info.value.output 263 | 264 | @pytest.mark.asyncio 265 | async def test_insufficient_credits(self, cli): 266 | with pytest.raises(subprocess.CalledProcessError) as exc_info: 267 | id_, secret = pytest.msgapi['msgapi']['nocredit_id'], \ 268 | pytest.msgapi['msgapi']['secret'] 269 | await cli('send-simple', 'ECHOECHO', id_, secret, input='!') 270 | assert 'Insufficient credits' in exc_info.value.output 271 | -------------------------------------------------------------------------------- /threema/__init__.py: -------------------------------------------------------------------------------- 1 | __import__('pkg_resources').declare_namespace(__name__) 2 | -------------------------------------------------------------------------------- /threema/gateway/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This API can be used to send text messages to any Threema user, and to 3 | receive incoming messages and delivery receipts. 4 | 5 | There are two main modes of operation: 6 | 7 | * Basic mode (server-based encryption) 8 | - The server handles all encryption for you. 9 | - The server needs to know the private key associated with your 10 | Threema API identity. 11 | - Incoming messages and delivery receipts are not supported. 12 | 13 | * End-to-end encrypted mode 14 | - The server doesn't know your private key. 15 | - Incoming messages and delivery receipts are supported. 16 | - You need to run software on your side to encrypt each message 17 | before it can be sent, and to decrypt any incoming messages or 18 | delivery receipts. 19 | 20 | The mode that you can use depends on the way your account was set up. 21 | 22 | .. moduleauthor:: Lennart Grahl 23 | """ 24 | import itertools 25 | 26 | from . import _gateway 27 | from . import exception as _exception 28 | from ._gateway import * # noqa 29 | from .exception import * # noqa 30 | 31 | __author__ = 'Lennart Grahl ' 32 | __status__ = 'Production' 33 | __version__ = '8.0.0' 34 | feature_level = 3 35 | 36 | __all__ = tuple(itertools.chain( 37 | ('feature_level',), 38 | ('bin', 'simple', 'e2e', 'key', 'util'), 39 | _gateway.__all__, 40 | _exception.__all__, 41 | )) 42 | -------------------------------------------------------------------------------- /threema/gateway/_gateway.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import io 3 | 4 | import aiohttp 5 | import libnacl.encode 6 | import libnacl.public 7 | 8 | from .exception import ( 9 | BlobServerError, 10 | CreditsServerError, 11 | GatewayKeyError, 12 | IDError, 13 | IDServerError, 14 | KeyServerError, 15 | MessageServerError, 16 | ReceptionCapabilitiesServerError, 17 | ) 18 | from .key import Key 19 | from .util import ( 20 | AioRunMixin, 21 | aio_run_proxy, 22 | async_ttl_cache, 23 | raise_server_error, 24 | ) 25 | 26 | __all__ = ( 27 | 'ReceptionCapability', 28 | 'Connection', 29 | ) 30 | 31 | 32 | @enum.unique 33 | class ReceptionCapability(enum.Enum): 34 | """ 35 | The reception capability of a Threema ID. 36 | """ 37 | text = 'text' 38 | image = 'image' 39 | group = 'group' 40 | audio = 'audio' 41 | video = 'video' 42 | file = 'file' 43 | poll = 'ballot' 44 | one_to_one_audio_call = 'call' 45 | one_to_one_video_call = 'videocall' 46 | perfect_forward_security = 'pfs' 47 | group_call = 'groupcall' 48 | 49 | 50 | @aio_run_proxy 51 | class Connection(AioRunMixin): 52 | """ 53 | Container for the sender's Threema ID and the Threema Gateway 54 | secret. Can be applied to multiple messages for both simple and 55 | end-to-end mode. 56 | 57 | You should either use the `with` statement on this class or call 58 | :func:`~Connection.close` after you are done querying the Threema 59 | Gateway Service API. Be aware that the connection instance cannot be 60 | reused once it has been closed. This also applies to the `with` 61 | statement (e.g. the instance can be used in one `with` block only). 62 | A closed connection instance will raise :exc:`RuntimeError` 63 | indicating that the underlying HTTP session has been closed. 64 | 65 | The connection can work both in non-blocking (through asyncio) and 66 | blocking mode. If you want to use the API in a blocking way (which 67 | implicitly starts an event loop to process the requests), then 68 | instantiate this class with ``blocking=True``. 69 | 70 | Arguments: 71 | - `id`: Threema ID of the sender. 72 | - `secret`: Threema Gateway secret. 73 | - `key`: Private key of the sender. Only required for 74 | end-to-end mode. 75 | - `key_file`: A file where the private key is stored 76 | in. Can be used instead of passing the key directly. 77 | - `blocking`: Whether to use a blocking API, without the need 78 | for an explicit event loop. 79 | - `session`: An optional :class:`aiohttp.ClientSession`. 80 | - `session_kwargs`: Additional key value arguments passed to the 81 | client session on each call to `get` and `post`. 82 | """ 83 | async_functions = { 84 | '__exit__', 85 | 'get_public_key', 86 | 'get_id', 87 | 'get_reception_capabilities', 88 | 'get_credits', 89 | 'send_simple', 90 | 'send_e2e', 91 | 'upload', 92 | 'download', 93 | } 94 | urls = { 95 | 'get_public_key': 'https://msgapi.threema.ch/pubkeys/{}', 96 | 'get_id_by_phone': 'https://msgapi.threema.ch/lookup/phone/{}', 97 | 'get_id_by_phone_hash': 'https://msgapi.threema.ch/lookup/phone_hash/{}', 98 | 'get_id_by_email': 'https://msgapi.threema.ch/lookup/email/{}', 99 | 'get_id_by_email_hash': 'https://msgapi.threema.ch/lookup/email_hash/{}', 100 | 'get_reception_capabilities': 'https://msgapi.threema.ch/capabilities/{}', 101 | 'get_credits': 'https://msgapi.threema.ch/credits', 102 | 'send_simple': 'https://msgapi.threema.ch/send_simple', 103 | 'send_e2e': 'https://msgapi.threema.ch/send_e2e', 104 | 'upload_blob': 'https://msgapi.threema.ch/upload_blob', 105 | 'download_blob': 'https://msgapi.threema.ch/blobs/{}' 106 | } 107 | 108 | def __init__( 109 | self, identity, secret, 110 | key=None, key_file=None, 111 | blocking=False, session=None, session_kwargs=None, 112 | ): 113 | super().__init__(blocking=blocking) 114 | self._session = session if session is not None else aiohttp.ClientSession() 115 | self._session_kwargs = session_kwargs if session_kwargs is not None else {} 116 | self._key = None 117 | self._key_file = None 118 | self.id = identity 119 | self.secret = secret 120 | self.key = key 121 | self.key_file = key_file 122 | 123 | def __enter__(self): 124 | if not self.blocking: 125 | raise RuntimeError("Use `async with` in async mode") 126 | return self 127 | 128 | async def __exit__(self, *_): 129 | await self.close() 130 | 131 | async def __aenter__(self): 132 | if self.blocking: 133 | raise RuntimeError("Use `with` in blocking mode") 134 | return self 135 | 136 | async def __aexit__(self, *_): 137 | await self.close() 138 | 139 | async def close(self): 140 | """ 141 | Close the underlying :class:`aiohttp.ClientSession`. 142 | """ 143 | await self._session.close() 144 | 145 | @property 146 | def key(self): 147 | """Return the private key.""" 148 | if self._key is None: 149 | raise GatewayKeyError("Sender's private key not specified") 150 | return self._key 151 | 152 | @key.setter 153 | def key(self, key): 154 | """Set the private key. The key will be decoded if required.""" 155 | if isinstance(key, str): 156 | key = Key.decode(key, Key.Type.private) 157 | self._key = key 158 | 159 | @property 160 | def key_file(self): 161 | """Get the path of the private key file.""" 162 | return self._key_file 163 | 164 | @key_file.setter 165 | def key_file(self, key_file): 166 | """Set the private key by reading it from a file.""" 167 | if key_file is not None: 168 | with open(key_file) as file: 169 | self.key = file.readline().strip() 170 | self._key_file = key_file 171 | 172 | @async_ttl_cache(ttl=60 * 60) 173 | async def get_public_key(self, id_): 174 | """ 175 | Get the public key of a Threema ID. 176 | 177 | Arguments: 178 | - `id_`: A Threema ID. 179 | 180 | Return a :class:`libnacl.public.PublicKey` for a Threema ID. 181 | """ 182 | response = await self._get(self.urls['get_public_key'].format(id_)) 183 | if response.status == 200: 184 | text = await response.text() 185 | key = libnacl.encode.hex_decode(text) 186 | return libnacl.public.PublicKey(key) 187 | else: 188 | await raise_server_error(response, KeyServerError) 189 | 190 | @async_ttl_cache(ttl=60 * 60) 191 | async def get_id(self, **mode): 192 | """ 193 | Get a user's Threema ID. 194 | 195 | Use **only one** of the arguments described below. 196 | 197 | Arguments: 198 | - `phone`: A phone number in E.164 format without the 199 | leading `+`. 200 | - `phone_hash`: An HMAC-SHA256 hash of an E.164 phone 201 | number without the leading `+`. 202 | - `email`: A lowercase email address. 203 | - `email_hash`: An HMAC-SHA256 hash of a lowercase and 204 | whitespace-trimmed email address. 205 | 206 | Return the Threema ID. 207 | """ 208 | modes = { 209 | 'phone': 'get_id_by_phone', 210 | 'phone_hash': 'get_id_by_phone_hash', 211 | 'email': 'get_id_by_email', 212 | 'email_hash': 'get_id_by_email_hash' 213 | } 214 | 215 | # Check mode 216 | if len(set(mode) - set(modes)) > 0: 217 | raise IDError('Unknown mode selected: {}'.format(set(mode))) 218 | mode_length = len(mode) 219 | if mode_length > 1 or mode_length == 0: 220 | raise IDError('Use (only) one of the possible modes to get a Threema ID') 221 | 222 | # Select mode and start request 223 | mode, value = mode.popitem() 224 | response = await self._get(self.urls[modes[mode]].format(value)) 225 | if response.status == 200: 226 | return await response.text() 227 | else: 228 | await raise_server_error(response, IDServerError) 229 | 230 | @async_ttl_cache(ttl=5 * 60) 231 | async def get_reception_capabilities(self, id_): 232 | """ 233 | Get the reception capabilities of a Threema ID. Unknown capabilities are 234 | being discarded. 235 | 236 | Arguments: 237 | - `id_`: A Threema ID. 238 | 239 | Return a set containing items from :class:`ReceptionCapability`. 240 | """ 241 | get_coroutine = self._get(self.urls['get_reception_capabilities'].format(id_)) 242 | response = await get_coroutine 243 | if response.status == 200: 244 | text = await response.text() 245 | capabilities = set() 246 | for capability in text.split(','): 247 | try: 248 | capabilities.add(ReceptionCapability(capability.strip())) 249 | except ValueError: 250 | pass 251 | return capabilities 252 | else: 253 | await raise_server_error(response, ReceptionCapabilitiesServerError) 254 | 255 | async def get_credits(self): 256 | """ 257 | Return the number of credits left on the account. 258 | """ 259 | response = await self._get(self.urls['get_credits']) 260 | if response.status == 200: 261 | text = await response.text() 262 | return int(text) 263 | else: 264 | await raise_server_error(response, CreditsServerError) 265 | 266 | async def send_simple(self, **data): 267 | """ 268 | Send a message by using the simple mode. 269 | 270 | Arguments: 271 | - `data`: A dictionary containing POST data. 272 | 273 | Return the ID of the message. 274 | """ 275 | return await self._send(self.urls['send_simple'], data) 276 | 277 | async def send_e2e(self, **data): 278 | """ 279 | Send a message by using the end-to-end mode. 280 | 281 | Arguments: 282 | - `data`: A dictionary containing POST data. 283 | 284 | Return the ID of the message. 285 | """ 286 | return await self._send(self.urls['send_e2e'], data) 287 | 288 | async def upload(self, data): 289 | """ 290 | Upload a blob. 291 | 292 | Arguments: 293 | - `data`: Binary data. 294 | 295 | Return the hex-encoded ID of the blob. 296 | """ 297 | return await self._upload(self.urls['upload_blob'], data=io.BytesIO(data)) 298 | 299 | async def download(self, blob_id): 300 | """ 301 | Download a blob. 302 | 303 | Arguments: 304 | - `id`: The hex-encoded blob ID. 305 | 306 | Return a :class:`asyncio.StreamReader` instance. 307 | """ 308 | response = await self._get(self.urls['download_blob'].format(blob_id)) 309 | if response.status == 200: 310 | return response.content 311 | else: 312 | await raise_server_error(response, BlobServerError) 313 | 314 | async def _get(self, *args, **kwargs): 315 | """ 316 | Wrapper for :func:`requests.get` that injects the connection's 317 | Threema ID and its secret. 318 | 319 | Return a :class:`aiohttp.ClientResponse` instance. 320 | """ 321 | kwargs = {**self._session_kwargs, **kwargs} 322 | kwargs.setdefault('params', {}) 323 | kwargs['params'].setdefault('from', self.id) 324 | kwargs['params'].setdefault('secret', self.secret) 325 | return await self._session.get(*args, **kwargs) 326 | 327 | async def _send(self, url, data, **kwargs): 328 | """ 329 | Send a message. 330 | 331 | Arguments: 332 | - `url`: URL for the request. 333 | - `data`: A dictionary containing POST data. 334 | 335 | Return the ID of the message. 336 | """ 337 | # Inject Threema ID and secret 338 | data.setdefault('from', self.id) 339 | data.setdefault('secret', self.secret) 340 | 341 | # Send message 342 | kwargs = {**self._session_kwargs, **kwargs} 343 | response = await self._session.post(url, data=data, **kwargs) 344 | if response.status == 200: 345 | return await response.text() 346 | else: 347 | await raise_server_error(response, MessageServerError) 348 | 349 | async def _upload(self, url, data, **kwargs): 350 | """ 351 | Upload a blob. 352 | 353 | Arguments: 354 | - `data`: Binary data. 355 | 356 | Return the ID of the blob. 357 | """ 358 | # Inject Threema ID and secret 359 | params = {'from': self.id, 'secret': self.secret} 360 | 361 | # Prepare multipart encoded file 362 | files = {'blob': data} 363 | 364 | # Send message 365 | kwargs = {**self._session_kwargs, **kwargs} 366 | response = await self._session.post(url, params=params, data=files, **kwargs) 367 | if response.status == 200: 368 | return await response.text() 369 | else: 370 | await raise_server_error(response, BlobServerError) 371 | -------------------------------------------------------------------------------- /threema/gateway/bin/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/threema-ch/threema-msgapi-sdk-python/19ba796a386a76c8f978b77246ab878a495bfce7/threema/gateway/bin/__init__.py -------------------------------------------------------------------------------- /threema/gateway/bin/gateway_client.py: -------------------------------------------------------------------------------- 1 | """ 2 | The command line interface for the Threema gateway service. 3 | """ 4 | import binascii 5 | import os 6 | import re 7 | 8 | import click 9 | import logbook 10 | import logbook.more 11 | 12 | from threema.gateway import Connection 13 | from threema.gateway import __version__ as _version 14 | from threema.gateway import ( 15 | e2e, 16 | feature_level, 17 | simple, 18 | util, 19 | ) 20 | from threema.gateway.key import ( 21 | HMAC, 22 | Key, 23 | ) 24 | from threema.gateway.util import AioRunMixin 25 | 26 | _logging_handler = None 27 | _logging_levels = { 28 | 1: logbook.CRITICAL, 29 | 2: logbook.ERROR, 30 | 3: logbook.WARNING, 31 | 4: logbook.NOTICE, 32 | 5: logbook.INFO, 33 | 6: logbook.DEBUG, 34 | 7: logbook.TRACE, 35 | } 36 | 37 | # Apply mock URL when starting CLI in debug mode 38 | _test_port = os.environ.get('THREEMA_TEST_API') 39 | _api_url = os.environ.get('GATEWAY_API_URL') 40 | if _test_port is not None: 41 | if _api_url is not None: 42 | raise RuntimeError('GATEWAY_API_URL cannot be set alongside THREEMA_TEST_API') 43 | _mock_url = 'http://{}:{}'.format('127.0.0.1', _test_port) 44 | Connection.urls = {key: value.replace('https://msgapi.threema.ch', _mock_url) 45 | for key, value in Connection.urls.items()} 46 | click.echo(('WARNING: Currently running in test mode!' 47 | 'The Threema Gateway Server will not be contacted!'), err=True) 48 | else: 49 | if _api_url is not None: 50 | if not _api_url.startswith('https://'): 51 | raise RuntimeError('GATEWAY_API_URL must begin with "https://"') 52 | Connection.urls = {key: value.replace( 53 | 'https://msgapi.threema.ch', 54 | _api_url.rstrip('/') 55 | ) 56 | for key, value in Connection.urls.items()} 57 | 58 | 59 | class _MockConnection(AioRunMixin): 60 | def __init__(self, private_key, public_key, identity=None): 61 | super().__init__(blocking=False) 62 | self.key = private_key 63 | self._public_key = public_key 64 | self.id = identity 65 | 66 | async def get_public_key(self, _): 67 | return self._public_key 68 | 69 | 70 | @click.group() 71 | @click.option('-v', '--verbosity', type=click.IntRange(0, len(_logging_levels)), 72 | default=0, help="Logging verbosity.") 73 | @click.option('-c', '--colored', is_flag=True, help='Colourise logging output.') 74 | @click.pass_context 75 | def cli(ctx, verbosity, colored): 76 | """ 77 | Command Line Interface. Use --help for details. 78 | """ 79 | if verbosity > 0: 80 | # Enable logging 81 | util.enable_logging(level=_logging_levels[verbosity]) 82 | 83 | # Get handler class 84 | if colored: 85 | handler_class = logbook.more.ColorizedStderrHandler 86 | else: 87 | handler_class = logbook.StderrHandler 88 | 89 | # Set up logging handler 90 | handler = handler_class(level=_logging_levels[verbosity]) 91 | handler.push_application() 92 | global _logging_handler 93 | _logging_handler = handler 94 | 95 | # Store on context 96 | ctx.obj = {} 97 | 98 | 99 | @cli.command(short_help='Show version information.', help=""" 100 | Show the current version of the Python SDK and the implemented feature 101 | level. 102 | """) 103 | def version(): 104 | click.echo('Version: {}'.format(_version)) 105 | click.echo('Feature Level: {}'.format(feature_level)) 106 | 107 | 108 | @cli.command(short_help='Encrypt a text message.', help=""" 109 | Encrypt standard input (or alternatively [TEXT]) using the given sender PRIVATE 110 | KEY and recipient PUBLIC KEY. 111 | 112 | Prints two lines to standard output: First the nonce (hex), and then the 113 | encrypted box (hex). 114 | """) 115 | @click.argument('private_key') 116 | @click.argument('public_key') 117 | @click.argument('text', required=False) 118 | @util.aio_run 119 | async def encrypt(private_key, public_key, text): 120 | # Get key instances 121 | private_key = util.read_key_or_key_file(private_key, Key.Type.private) 122 | public_key = util.read_key_or_key_file(public_key, Key.Type.public) 123 | 124 | # Read text 125 | if text is None: 126 | text = click.get_text_stream('stdin').read() 127 | 128 | # Print nonce and message as hex 129 | connection = _MockConnection(private_key, public_key) 130 | message = e2e.TextMessage(connection, text=text, to_id='') 131 | nonce, message = await message.send(get_data_only=True) 132 | click.echo(binascii.hexlify(nonce)) 133 | click.echo(binascii.hexlify(message)) 134 | 135 | 136 | @cli.command(short_help='Decrypt a text message.', help=""" 137 | Decrypt standard input (or alternatively [MESSAGE]) using the given recipient 138 | PRIVATE KEY and sender PUBLIC KEY. The NONCE must be given on the command line. 139 | 140 | Prints the decrypted text message to standard output. 141 | """) 142 | @click.argument('private_key') 143 | @click.argument('public_key') 144 | @click.argument('nonce') 145 | @click.argument('message', required=False) 146 | @util.aio_run 147 | async def decrypt(private_key, public_key, nonce, message): 148 | # Get key instances 149 | private_key = util.read_key_or_key_file(private_key, Key.Type.private) 150 | public_key = util.read_key_or_key_file(public_key, Key.Type.public) 151 | 152 | # Convert nonce to bytes 153 | nonce = binascii.unhexlify(nonce) 154 | 155 | # Read message and convert to bytes 156 | if message is None: 157 | message = click.get_text_stream('stdin').read() 158 | message = binascii.unhexlify(message) 159 | 160 | # Unpack message 161 | connection = _MockConnection(private_key, public_key) 162 | parameters = {'from_id': '', 'message_id': '', 'date': ''} 163 | message = await e2e.Message.receive(connection, parameters, nonce, message) 164 | 165 | # Ensure that this is a text message 166 | if message.type is not e2e.Message.Type.text_message: 167 | raise TypeError('Cannot decrypt message type {} in CLI'.format(message.type)) 168 | 169 | # Print text 170 | click.echo(message.text) 171 | 172 | 173 | @cli.command(short_help='Generate a new key pair.', help=""" 174 | Generate a new key pair and write the PRIVATE and PUBLIC keys to the respective 175 | files. 176 | """) 177 | @click.argument('private_key_file') 178 | @click.argument('public_key_file') 179 | def generate(private_key_file, public_key_file): 180 | # Generate key pair and hexlify both keys 181 | private_key, public_key = [Key.encode(key) for key in Key.generate_pair()] 182 | 183 | # Write keys to files 184 | with open(private_key_file, 'w') as sk_file, open(public_key_file, 'w') as pk_file: 185 | sk_file.write(private_key + '\n') 186 | pk_file.write(public_key + '\n') 187 | 188 | 189 | # noinspection PyShadowingBuiltins 190 | @cli.command(short_help='Hash an email address or phone number.', help=""" 191 | Hash an email address or a phone number for identity lookup. 192 | 193 | Prints the hash in hex. 194 | """) 195 | @click.option('-e', '--email', help='An email address.') 196 | @click.option('-p', '--phone', help='A phone number in E.164 format.') 197 | def hash(**arguments): 198 | mode = {key: value for key, value in arguments.items() if value is not None} 199 | 200 | # Check that either email or phone has been specified 201 | if len(mode) != 1: 202 | error = 'Please specify exactly one email address or one phone number.' 203 | raise click.ClickException(error) 204 | 205 | # Unpack message and hash type 206 | hash_type, message = mode.popitem() 207 | 208 | # Email or phone? 209 | if hash_type == 'email': 210 | message = message.lower().strip() 211 | else: 212 | message = re.sub(r'[^0-9]', '', message) 213 | 214 | click.echo(HMAC.hash(message, hash_type).hexdigest()) 215 | 216 | 217 | @cli.command(short_help='Derive the public key from the private key.', help=""" 218 | Derive the public key that corresponds with the given PRIVATE KEY. 219 | """) 220 | @click.argument('private_key') 221 | def derive(private_key): 222 | # Get private key instance and derive public key 223 | private_key = util.read_key_or_key_file(private_key, Key.Type.private) 224 | public_key = Key.derive_public(private_key) 225 | 226 | # Return hex encoded public key 227 | click.echo(Key.encode(public_key)) 228 | 229 | 230 | @cli.command(short_help='Send a text message using simple mode.', help=""" 231 | Send a text message from standard input (or alternatively [TEXT]) with 232 | server-side encryption to the given ID. FROM is the API identity and SECRET is 233 | the API secret. 234 | 235 | Prints the message ID on success. 236 | """) 237 | @click.argument('to') 238 | @click.argument('from') 239 | @click.argument('secret') 240 | @click.argument('text', required=False) 241 | @click.pass_context 242 | @util.aio_run 243 | async def send_simple(ctx, **arguments): 244 | # Read message 245 | if arguments['text'] is not None: 246 | text = arguments['text'] 247 | else: 248 | text = click.get_text_stream('stdin').read().strip() 249 | 250 | # Create connection 251 | connection = Connection(arguments['from'], arguments['secret'], **ctx.obj) 252 | async with connection: 253 | # Create message 254 | message = simple.TextMessage( 255 | connection=connection, 256 | to_id=arguments['to'], 257 | text=text 258 | ) 259 | 260 | # Send message 261 | click.echo(await message.send()) 262 | 263 | 264 | @cli.command(short_help='Send a text message using end-to-end mode.', help=""" 265 | Encrypt and send a text message from standard input (or alternatively [TEXT]) to 266 | the given ID. FROM is the API identity and SECRET is the API secret. 267 | 268 | Prints the message ID on success. 269 | """) 270 | @click.argument('to') 271 | @click.argument('from') 272 | @click.argument('secret') 273 | @click.argument('private_key') 274 | @click.argument('text', required=False) 275 | @click.option('-k', '--public-key', help=""" 276 | The public key of the recipient. Will be fetched automatically if not provided. 277 | """) 278 | @click.pass_context 279 | @util.aio_run 280 | async def send_e2e(ctx, **arguments): 281 | # Get key instances 282 | private_key = util.read_key_or_key_file(arguments['private_key'], Key.Type.private) 283 | if arguments['public_key'] is not None: 284 | public_key = util.read_key_or_key_file(arguments['public_key'], Key.Type.public) 285 | else: 286 | public_key = None 287 | 288 | # Read message 289 | if arguments['text'] is not None: 290 | text = arguments['text'] 291 | else: 292 | text = click.get_text_stream('stdin').read().strip() 293 | 294 | # Create connection 295 | connection = Connection( 296 | identity=arguments['from'], 297 | secret=arguments['secret'], 298 | key=private_key, 299 | **ctx.obj 300 | ) 301 | 302 | async with connection: 303 | # Create message 304 | message = e2e.TextMessage( 305 | connection=connection, 306 | to_id=arguments['to'], 307 | key=public_key, 308 | text=text 309 | ) 310 | 311 | # Send message 312 | click.echo(await message.send()) 313 | 314 | 315 | @cli.command(short_help='Send an image using end-to-end mode.', help=""" 316 | Encrypt and send an image ('jpeg' or 'png') to the given ID. FROM is the API 317 | identity and SECRET is the API secret. IMAGE_PATH is a relative or absolute path 318 | to an image. 319 | 320 | Prints the message ID on success. 321 | """) 322 | @click.argument('to') 323 | @click.argument('from') 324 | @click.argument('secret') 325 | @click.argument('private_key') 326 | @click.argument('image_path') 327 | @click.option('-k', '--public-key', help=""" 328 | The public key of the recipient. Will be fetched automatically if not provided. 329 | """) 330 | @click.pass_context 331 | @util.aio_run 332 | async def send_image(ctx, **arguments): 333 | # Get key instances 334 | private_key = util.read_key_or_key_file(arguments['private_key'], Key.Type.private) 335 | if arguments['public_key'] is not None: 336 | public_key = util.read_key_or_key_file(arguments['public_key'], Key.Type.public) 337 | else: 338 | public_key = None 339 | 340 | # Create connection 341 | connection = Connection( 342 | identity=arguments['from'], 343 | secret=arguments['secret'], 344 | key=private_key, 345 | **ctx.obj 346 | ) 347 | 348 | async with connection: 349 | # Create message 350 | message = e2e.ImageMessage( 351 | connection=connection, 352 | to_id=arguments['to'], 353 | key=public_key, 354 | image_path=arguments['image_path'] 355 | ) 356 | 357 | # Send message 358 | click.echo(await message.send()) 359 | 360 | 361 | @cli.command(short_help='Send a video using end-to-end mode.', help=""" 362 | Encrypt and send a video ('mp4') including a thumbnail to the given ID. FROM is 363 | the API identity and SECRET is the API secret. VIDEO_PATH is a relative or 364 | absolute path to a video. THUMBNAIL_PATH is a relative or absolute path to a 365 | thumbnail. 366 | 367 | Prints the message ID on success. 368 | """) 369 | @click.argument('to') 370 | @click.argument('from') 371 | @click.argument('secret') 372 | @click.argument('private_key') 373 | @click.argument('video_path') 374 | @click.argument('thumbnail_path') 375 | @click.option('-k', '--public-key', help=""" 376 | The public key of the recipient. Will be fetched automatically if not provided. 377 | """) 378 | @click.option('-d', '--duration', help=""" 379 | Duration of the video in seconds. Defaults to 0. 380 | """, default=0) 381 | @click.pass_context 382 | @util.aio_run 383 | async def send_video(ctx, **arguments): 384 | # Get key instances 385 | private_key = util.read_key_or_key_file(arguments['private_key'], Key.Type.private) 386 | if arguments['public_key'] is not None: 387 | public_key = util.read_key_or_key_file(arguments['public_key'], Key.Type.public) 388 | else: 389 | public_key = None 390 | 391 | # Create connection 392 | connection = Connection( 393 | identity=arguments['from'], 394 | secret=arguments['secret'], 395 | key=private_key, 396 | **ctx.obj 397 | ) 398 | 399 | async with connection: 400 | # Create message 401 | message = e2e.VideoMessage( 402 | connection=connection, 403 | to_id=arguments['to'], 404 | key=public_key, 405 | duration=arguments['duration'], 406 | video_path=arguments['video_path'], 407 | thumbnail_path=arguments['thumbnail_path'] 408 | ) 409 | 410 | # Send message 411 | click.echo(await message.send()) 412 | 413 | 414 | @cli.command(short_help='Send a file using end-to-end mode.', help=""" 415 | Encrypt and send a file to the given ID, optionally with a thumbnail and/ or 416 | caption. FROM is the API identity and SECRET is the API secret. FILE_PATH is a 417 | relative or absolute path to a file. 418 | 419 | Prints the message ID on success. 420 | """) 421 | @click.argument('to') 422 | @click.argument('from') 423 | @click.argument('secret') 424 | @click.argument('private_key') 425 | @click.argument('file_path') 426 | @click.option('-k', '--public-key', help=""" 427 | The public key of the recipient. Will be fetched automatically if not provided. 428 | """) 429 | @click.option('-t', '--thumbnail-path', help=""" 430 | The relative or absolute path to a thumbnail. 431 | """) 432 | @click.option('-c', '--caption', help=""" 433 | An optional caption to send alongside the attached file. 434 | """) 435 | @click.pass_context 436 | @util.aio_run 437 | async def send_file(ctx, **arguments): 438 | # Get key instances 439 | private_key = util.read_key_or_key_file(arguments['private_key'], Key.Type.private) 440 | if arguments['public_key'] is not None: 441 | public_key = util.read_key_or_key_file(arguments['public_key'], Key.Type.public) 442 | else: 443 | public_key = None 444 | 445 | # Create connection 446 | connection = Connection( 447 | identity=arguments['from'], 448 | secret=arguments['secret'], 449 | key=private_key, 450 | **ctx.obj 451 | ) 452 | 453 | async with connection: 454 | # Create message 455 | message = e2e.FileMessage( 456 | connection=connection, 457 | to_id=arguments['to'], 458 | key=public_key, 459 | file_path=arguments['file_path'], 460 | thumbnail_path=arguments['thumbnail_path'], 461 | caption=arguments['caption'] 462 | ) 463 | 464 | # Send message 465 | click.echo(await message.send()) 466 | 467 | 468 | @cli.command(short_help='Lookup a Threema ID or the public key.', help=""" 469 | Lookup the public key of the Threema ID or the ID linked to either the given 470 | email address or the given phone number. FROM is the API identity and SECRET is 471 | the API secret. 472 | """) 473 | @click.argument('from') 474 | @click.argument('secret') 475 | @click.option('-e', '--email', help='An email address.') 476 | @click.option('-p', '--phone', help='A phone number in E.164 format.') 477 | @click.option('-i', '--id', help='A Threema ID.') 478 | @click.pass_context 479 | @util.aio_run 480 | async def lookup(ctx, **arguments): 481 | modes = ['email', 'phone', 'id'] 482 | mode = {key: value for key, value in arguments.items() 483 | if key in modes and value is not None} 484 | 485 | # Check that one of the modes has been selected 486 | if len(mode) != 1: 487 | error = 'Please specify exactly one ID, one email address or one phone number.' 488 | raise click.ClickException(error) 489 | 490 | # Create connection 491 | connection = Connection(arguments['from'], secret=arguments['secret'], **ctx.obj) 492 | async with connection: 493 | # Do lookup 494 | if 'id' in mode: 495 | public_key = await connection.get_public_key(arguments['id']) 496 | click.echo(Key.encode(public_key)) 497 | else: 498 | click.echo(await connection.get_id(**mode)) 499 | 500 | 501 | @cli.command(short_help='Lookup the reception capabilities of a Threema ID', help=""" 502 | Lookup the reception capabilities of a Threema ID. FROM is the API identity and 503 | SECRET is the API secret. 504 | 505 | Prints a set of capabilities in alphabetical order on success. 506 | """) 507 | @click.argument('from') 508 | @click.argument('secret') 509 | @click.argument('id') 510 | @click.pass_context 511 | @util.aio_run 512 | async def capabilities(ctx, **arguments): 513 | # Create connection 514 | connection = Connection(arguments['from'], arguments['secret'], **ctx.obj) 515 | async with connection: 516 | # Lookup and format returned capabilities 517 | coroutine = connection.get_reception_capabilities(arguments['id']) 518 | capabilities_ = await coroutine 519 | click.echo(', '.join(sorted(capability.value for capability in capabilities_))) 520 | 521 | 522 | # noinspection PyShadowingBuiltins 523 | @cli.command(short_help='Get the number of credits left on the account', help=""" 524 | Retrieve the number of credits left on the used account. FROM is the API 525 | identity and SECRET is the API secret. 526 | """) 527 | @click.argument('from') 528 | @click.argument('secret') 529 | @click.pass_context 530 | @util.aio_run 531 | async def credits(ctx, **arguments): 532 | # Create connection 533 | connection = Connection(arguments['from'], arguments['secret'], **ctx.obj) 534 | async with connection: 535 | # Get and print credits 536 | click.echo(await connection.get_credits()) 537 | 538 | 539 | def main(): 540 | exc = None 541 | try: 542 | cli() 543 | except Exception as exc_: 544 | error = str(exc_) 545 | exc = exc_ 546 | else: 547 | error = None 548 | 549 | # Print error (if any) 550 | if error is not None: 551 | click.echo('An error occurred:', err=True) 552 | click.echo(error, err=True) 553 | 554 | # Re-raise 555 | if exc is not None: 556 | raise exc 557 | 558 | # Remove logging handler 559 | if _logging_handler is not None: 560 | _logging_handler.pop_application() 561 | 562 | 563 | if __name__ == '__main__': 564 | main() 565 | -------------------------------------------------------------------------------- /threema/gateway/exception.py: -------------------------------------------------------------------------------- 1 | """ 2 | Contains all exceptions used for the Threema gateway service. 3 | """ 4 | from typing import Dict # noqa 5 | 6 | __all__ = ( 7 | 'GatewayError', 8 | 'CallbackError', 9 | 'GatewayServerError', 10 | 'IDError', 11 | 'IDServerError', 12 | 'GatewayKeyError', 13 | 'KeyServerError', 14 | 'ReceptionCapabilitiesServerError', 15 | 'CreditsServerError', 16 | 'DirectionError', 17 | 'MessageError', 18 | 'UnsupportedMimeTypeError', 19 | 'MissingCapabilityError', 20 | 'MessageServerError', 21 | 'BlobError', 22 | 'BlobServerError', 23 | ) 24 | 25 | 26 | class GatewayError(Exception): 27 | """ 28 | General error of this module. All other exceptions are derived from 29 | this class. 30 | """ 31 | 32 | 33 | class CallbackError(GatewayError): 34 | """ 35 | Indicates that the callback does not accept the message. The 36 | *status* code will be returned to the sender. 37 | 38 | Arguments: 39 | - `status`: An HTTP status code. 40 | - `reason`: Reason of the *status*. 41 | """ 42 | def __init__(self, status, reason): 43 | self.status = status 44 | self.reason = reason 45 | 46 | def __str__(self): 47 | return '[{}] {}'.format(self.status, self.reason) 48 | 49 | 50 | class GatewayServerError(GatewayError): 51 | """ 52 | The server has responded with an error code. All other server 53 | exceptions are derived from this class. 54 | 55 | .. versionchanged:: 1.1.9 56 | Now only contains an HTTP status code instead of holding whole 57 | request objects which may not be released. 58 | Does now return the status code when printed. 59 | 60 | Arguments: 61 | - `status`: An HTTP status code. 62 | """ 63 | status_description = {} # type: Dict[int, str] 64 | 65 | def __init__(self, status): 66 | self.status = status 67 | 68 | def __str__(self): 69 | status = self.status 70 | 71 | # Return description for status code 72 | try: 73 | return '[{}] {}'.format(status, self.status_description[status]) 74 | except KeyError: 75 | return 'Unknown error, status code: {}'.format(status) 76 | 77 | 78 | class IDError(GatewayError): 79 | """ 80 | A problem before fetching a Threema ID occurred. 81 | """ 82 | 83 | 84 | class IDServerError(IDError, GatewayServerError): 85 | """ 86 | The server has responded with an error code while looking up a 87 | Threema ID. 88 | """ 89 | status_description = { 90 | 400: 'Supplied hash invalid', 91 | 401: 'API identity or secret incorrect', 92 | 404: 'No matching Threema ID could be found', 93 | 500: 'Temporary internal server error occurred' 94 | } 95 | 96 | 97 | class GatewayKeyError(GatewayError): 98 | """ 99 | A problem with a key occurred. 100 | 101 | .. versionchanged:: 1.1.6 102 | Previous versions shadowed the builtin exception 103 | :class:`KeyError`. 104 | """ 105 | 106 | 107 | class KeyServerError(GatewayKeyError, GatewayServerError): 108 | """ 109 | The server has responded with an error code while fetching a 110 | public key. 111 | """ 112 | status_description = { 113 | 401: 'API identity or secret incorrect', 114 | 404: 'No matching Threema ID could be found', 115 | 500: 'Temporary internal server error occurred' 116 | } 117 | 118 | 119 | class ReceptionCapabilitiesServerError(GatewayServerError): 120 | """ 121 | The server responded with an error code while fetching the reception 122 | capabilities of a Threema ID. 123 | """ 124 | status_description = { 125 | 401: 'API identity or secret incorrect', 126 | 404: 'No matching Threema ID could be found', 127 | 500: 'Temporary internal server error occurred' 128 | } 129 | 130 | 131 | class CreditsServerError(GatewayServerError): 132 | """ 133 | The server has responded with an error code while fetching the 134 | remaining credits. 135 | """ 136 | status_description = { 137 | 401: 'API identity or secret incorrect', 138 | 500: 'Temporary internal server error occurred' 139 | } 140 | 141 | 142 | class DirectionError(GatewayError): 143 | """ 144 | Indicates that a message can not be processed for the specified 145 | direction because required parameters are missing. 146 | """ 147 | 148 | 149 | class MessageError(GatewayError): 150 | """ 151 | Indicates that a message is invalid. 152 | """ 153 | 154 | 155 | class UnsupportedMimeTypeError(MessageError): 156 | """ 157 | Indicates that the supplied file or binary of a message has an 158 | unsupported mime type. 159 | """ 160 | def __init__(self, mime_type): 161 | self.mime_type = mime_type 162 | 163 | def __str__(self): 164 | return 'Unsupported mime type: {}'.format(self.mime_type) 165 | 166 | 167 | class MissingCapabilityError(MessageError): 168 | """ 169 | A capability is missing to send a specific message type. 170 | """ 171 | def __init__(self, missing_capabilities): 172 | self.missing_capabilities = missing_capabilities 173 | 174 | def __str__(self): 175 | return 'Missing capabilities: {}'.format(self.missing_capabilities) 176 | 177 | 178 | class MessageServerError(MessageError, GatewayServerError): 179 | """ 180 | The server has responded with an error code while sending a 181 | message. 182 | """ 183 | status_description = { 184 | 400: 'Recipient identity is invalid or the account is not setup for the ' 185 | 'requested mode', 186 | 401: 'API identity or secret incorrect', 187 | 402: 'Insufficient credits', 188 | 404: 'Phone or email address could not be resolved to a Threema ID', 189 | 413: 'Message too long', 190 | 500: 'Temporary internal server error occurred' 191 | } 192 | 193 | 194 | class BlobError(GatewayError): 195 | """ 196 | Indicates that a blob is invalid. The server has not been contacted, 197 | yet. 198 | """ 199 | 200 | 201 | class BlobServerError(BlobError, GatewayServerError): 202 | """ 203 | The server has responded with an error code while uploading or 204 | downloading a blob. 205 | """ 206 | status_description = { 207 | 400: 'Required parameters missing or blob is empty', 208 | 401: 'API identity or secret incorrect', 209 | 402: 'Insufficient credits', 210 | 404: 'No matching blob found for the supplied ID', 211 | 413: 'Blob too big', 212 | 500: 'Temporary internal server error occurred' 213 | } 214 | -------------------------------------------------------------------------------- /threema/gateway/key.py: -------------------------------------------------------------------------------- 1 | """ 2 | Contains functions to decode, encode and generate keys. 3 | """ 4 | import enum 5 | import hashlib 6 | import hmac 7 | 8 | import libnacl.encode 9 | import libnacl.public 10 | import libnacl.secret 11 | 12 | from .exception import GatewayKeyError 13 | 14 | __all__ = ( 15 | 'HMAC', 16 | 'Key', 17 | ) 18 | 19 | 20 | class HMAC: 21 | """ 22 | A collection of HMAC functions used for the gateway service. 23 | """ 24 | keys = { 25 | 'email': b'\x30\xa5\x50\x0f\xed\x97\x01\xfa\x6d\xef\xdb\x61\x08\x41\x90\x0f' 26 | b'\xeb\xb8\xe4\x30\x88\x1f\x7a\xd8\x16\x82\x62\x64\xec\x09\xba\xd7', 27 | 'phone': b'\x85\xad\xf8\x22\x69\x53\xf3\xd9\x6c\xfd\x5d\x09\xbf\x29\x55\x5e' 28 | b'\xb9\x55\xfc\xd8\xaa\x5e\xc4\xf9\xfc\xd8\x69\xe2\x58\x37\x07\x23' 29 | } 30 | 31 | @staticmethod 32 | def hash(message, hash_type): 33 | """ 34 | Generate the hash for a message type. 35 | 36 | Arguments: 37 | - `message`: A message. 38 | - `hash_type`: `email` or `phone`. 39 | 40 | Return a :class:`hmac.HMAC` instance. 41 | """ 42 | return hmac.new(HMAC.keys[hash_type], message.encode('ascii'), hashlib.sha256) 43 | 44 | 45 | class Key: 46 | """ 47 | Encode or decode a key. 48 | """ 49 | separator = ':' 50 | 51 | @enum.unique 52 | class Type(enum.Enum): 53 | """ 54 | The type of a key. 55 | """ 56 | private = 'private' 57 | public = 'public' 58 | 59 | @staticmethod 60 | def decode(encoded_key, expected_type): 61 | """ 62 | Decode a key and check its type if required. 63 | 64 | Arguments: 65 | - `encoded_key`: The encoded key. 66 | - `expected_type`: One of the types of :class:`Key.Type`. 67 | 68 | Return the key as an :class:`libnacl.public.SecretKey` or 69 | :class:`libnacl.public.PublicKey` instance. 70 | """ 71 | # Split key 72 | try: 73 | type_, key = encoded_key.split(Key.separator) 74 | except ValueError as exc: 75 | raise GatewayKeyError('Invalid key format') from exc 76 | type_ = Key.Type(type_) 77 | 78 | # Check type 79 | if type_ != expected_type: 80 | raise GatewayKeyError('Invalid key type: {}, expected: {}'.format( 81 | type_, expected_type 82 | )) 83 | 84 | # De-hexlify 85 | key = libnacl.encode.hex_decode(key) 86 | 87 | # Convert to SecretKey or PublicKey 88 | if type_ == Key.Type.private: 89 | key = libnacl.public.SecretKey(key) 90 | elif type_ == Key.Type.public: 91 | key = libnacl.public.PublicKey(key) 92 | 93 | return key 94 | 95 | @staticmethod 96 | def encode(libnacl_key): 97 | """ 98 | Encode a key. 99 | 100 | Arguments: 101 | - `libnacl_key`: An instance of either a 102 | :class:`libnacl.public.SecretKey` or a 103 | :class:`libnacl.public.PublicKey`. 104 | 105 | Return the encoded key. 106 | """ 107 | # Detect key type and hexlify 108 | if isinstance(libnacl_key, libnacl.public.SecretKey): 109 | type_ = Key.Type.private 110 | key = libnacl_key.hex_sk() 111 | elif isinstance(libnacl_key, libnacl.public.PublicKey): 112 | type_ = Key.Type.public 113 | key = libnacl.encode.hex_encode(libnacl_key.pk) 114 | else: 115 | raise GatewayKeyError('Unknown key type: {}'.format(libnacl_key)) 116 | 117 | # Encode key 118 | return Key.separator.join((type_.value, key.decode('utf-8'))) 119 | 120 | @staticmethod 121 | def generate_pair(): 122 | """ 123 | Generate a new key pair. 124 | 125 | Return the key pair as a tuple of a 126 | :class:`libnacl.public.SecretKey` instance and a 127 | :class:`libnacl.public.PublicKey` instance. 128 | """ 129 | private_key = libnacl.public.SecretKey() 130 | public_key = libnacl.public.PublicKey(private_key.pk) 131 | return private_key, public_key 132 | 133 | @staticmethod 134 | def generate_secret_key(): 135 | """ 136 | Generate a new secret key box. 137 | 138 | Return a tuple of the key's :class:`bytes` and hex-encoded 139 | representation. 140 | """ 141 | box = libnacl.secret.SecretBox() 142 | return box.sk, box.hex_sk() 143 | 144 | @staticmethod 145 | def derive_public(private_key): 146 | """ 147 | Derive a public key from a class:`libnacl.public.SecretKey` 148 | instance. 149 | 150 | Arguments: 151 | - `private_key`: A class:`libnacl.public.SecretKey` 152 | instance. 153 | 154 | Return the :class:`libnacl.public.PublicKey` instance. 155 | """ 156 | return libnacl.public.PublicKey(private_key.pk) 157 | -------------------------------------------------------------------------------- /threema/gateway/memoization.py: -------------------------------------------------------------------------------- 1 | # Inlined from python-memoization (https://github.com/lonelyenvoy/python-memoization) 2 | # 3 | # ----------- 4 | # 5 | # MIT License 6 | # 7 | # Copyright (c) 2018-2020 lonelyenvoy 8 | # 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy 10 | # of this software and associated documentation files (the "Software"), to deal 11 | # in the Software without restriction, including without limitation the rights 12 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | # copies of the Software, and to permit persons to whom the Software is 14 | # furnished to do so, subject to the following conditions: 15 | # 16 | # The above copyright notice and this permission notice shall be included in all 17 | # copies or substantial portions of the Software. 18 | # 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | # SOFTWARE. 26 | 27 | import time 28 | 29 | __all__ = ( 30 | 'HashedList', 31 | 'make_key', 32 | 'make_cache_value', 33 | 'is_cache_value_valid', 34 | 'retrieve_result_from_cache_value', 35 | ) 36 | 37 | 38 | class HashedList(list): 39 | """ 40 | This class guarantees that hash() will be called no more than once per element. 41 | """ 42 | 43 | __slots__ = ('hash_value', ) 44 | 45 | def __init__(self, tup, hash_value): 46 | super().__init__(tup) 47 | self.hash_value = hash_value 48 | 49 | def __hash__(self): 50 | return self.hash_value 51 | 52 | 53 | def make_key(args, kwargs, kwargs_mark=(object(), )): 54 | """ 55 | Make a cache key 56 | """ 57 | key = args 58 | if kwargs: 59 | key += kwargs_mark 60 | for item in kwargs.items(): 61 | key += item 62 | try: 63 | hash_value = hash(key) 64 | except TypeError: # process unhashable types 65 | return str(key) 66 | else: 67 | return HashedList(key, hash_value) 68 | 69 | 70 | def make_cache_value(result, ttl): 71 | return result, time.time() + ttl 72 | 73 | 74 | def is_cache_value_valid(value): 75 | return time.time() < value[1] 76 | 77 | 78 | def retrieve_result_from_cache_value(value): 79 | return value[0] 80 | -------------------------------------------------------------------------------- /threema/gateway/simple.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides classes for the simple mode. 3 | """ 4 | import abc 5 | 6 | from .exception import MessageError 7 | from .util import ( 8 | AioRunMixin, 9 | aio_run_proxy, 10 | ) 11 | 12 | __all__ = ( 13 | 'Message', 14 | 'TextMessage', 15 | ) 16 | 17 | 18 | class Message(AioRunMixin, metaclass=abc.ABCMeta): 19 | """ 20 | A message class all simple mode messages are derived from. 21 | 22 | If the connection passed to the constructor is in blocking mode, then all 23 | methods on this class will be blocking too. 24 | 25 | Attributes: 26 | - `connection`: An instance of a connection. 27 | - `to_id`: Threema ID of the recipient. 28 | """ 29 | async_functions = { 30 | 'send', 31 | } 32 | 33 | def __init__(self, connection=None, to_id=None): 34 | super().__init__(blocking=connection.blocking) 35 | self._connection = connection.unwrap 36 | self.to_id = to_id 37 | 38 | @abc.abstractmethod 39 | async def send(self): 40 | """ 41 | Send a message. 42 | """ 43 | 44 | 45 | @aio_run_proxy 46 | class TextMessage(Message): 47 | """ 48 | Create and send a text message to a recipient. 49 | 50 | If the connection passed to the constructor is in blocking mode, then all 51 | methods on this class will be blocking too. 52 | 53 | Arguments / Attributes: 54 | - `connection`: An instance of a connection. 55 | - `id`: Threema ID of the recipient. 56 | - `phone`: Phone number of the recipient. 57 | - `email`: Email address of the recipient. 58 | - `text`: Message text. 59 | """ 60 | async_functions = { 61 | 'send', 62 | } 63 | 64 | def __init__(self, phone=None, email=None, text=None, **kwargs): 65 | super().__init__(**kwargs) 66 | self.phone = phone 67 | self.email = email 68 | self.text = text 69 | 70 | async def send(self): 71 | """ 72 | Send the created message. 73 | 74 | Return the ID of the sent message. 75 | """ 76 | recipient = { 77 | 'to': self.to_id, 78 | 'phone': self.phone, 79 | 'email': self.email 80 | } 81 | 82 | # Validate parameters 83 | if self._connection is None: 84 | raise MessageError('No connection set') 85 | if not any(recipient.values()): 86 | raise MessageError('No recipient specified') 87 | if sum([0 if to is None else 1 for to in recipient.values()]) > 1: 88 | raise MessageError('Only one recipient type can be used at the same time') 89 | if self.text is None: 90 | raise MessageError('Message text not specified') 91 | 92 | # Send message 93 | data = {key: value for key, value in recipient.items() if value is not None} 94 | data['text'] = self.text 95 | return await self._connection.send_simple(**data) 96 | -------------------------------------------------------------------------------- /threema/gateway/util.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utility functions. 3 | """ 4 | import asyncio 5 | import collections 6 | import functools 7 | import inspect 8 | import io 9 | import logging 10 | import os 11 | from typing import Set # noqa 12 | 13 | import libnacl 14 | import logbook 15 | import logbook.compat 16 | import logbook.more 17 | import wrapt 18 | 19 | from .key import Key 20 | from .memoization import ( 21 | is_cache_value_valid, 22 | make_cache_value, 23 | make_key, 24 | retrieve_result_from_cache_value, 25 | ) 26 | 27 | __all__ = ( 28 | 'enable_logging', 29 | 'disable_logging', 30 | 'get_logger', 31 | 'read_key_or_key_file', 32 | 'raise_server_error', 33 | 'randint', 34 | 'ViewIOReader', 35 | 'ViewIOWriter', 36 | 'async_ttl_cache', 37 | 'aio_run', 38 | 'aio_run_proxy', 39 | 'AioRunMixin', 40 | ) 41 | 42 | _logger_group = logbook.LoggerGroup() 43 | _logger_group.disabled = True 44 | _logger_redirect_handler = logbook.compat.RedirectLoggingHandler() 45 | _logger_convert_level_handler = logbook.compat.LoggingHandler() 46 | 47 | 48 | def _convert_level(logging_level): 49 | return _logger_convert_level_handler.convert_level(logging_level) 50 | 51 | 52 | def enable_logging(level=logbook.WARNING, asyncio_level=None, aiohttp_level=None): 53 | # Determine levels 54 | level = logbook.lookup_level(level) 55 | converted_level = _convert_level(level) 56 | if asyncio_level is None: 57 | asyncio_level = converted_level 58 | else: 59 | asyncio_level = _convert_level(asyncio_level) 60 | if aiohttp_level is None: 61 | aiohttp_level = converted_level 62 | else: 63 | aiohttp_level = _convert_level(aiohttp_level) 64 | 65 | # Enable logger group 66 | _logger_group.disabled = False 67 | 68 | # Enable asyncio debug logging 69 | os.environ['PYTHONASYNCIODEBUG'] = '1' 70 | 71 | # Redirect asyncio logger 72 | logger = logging.getLogger('asyncio') 73 | logger.setLevel(asyncio_level) 74 | logger.addHandler(_logger_redirect_handler) 75 | 76 | # Redirect aiohttp logger 77 | logger = logging.getLogger('aiohttp') 78 | logger.setLevel(aiohttp_level) 79 | logger.addHandler(_logger_redirect_handler) 80 | 81 | 82 | def disable_logging(): 83 | # Reset aiohttp logger 84 | logger = logging.getLogger('aiohttp') 85 | logger.removeHandler(_logger_redirect_handler) 86 | logger.setLevel(logging.NOTSET) 87 | 88 | # Reset asyncio logger 89 | logger = logging.getLogger('asyncio') 90 | logger.removeHandler(_logger_redirect_handler) 91 | logger.setLevel(logging.NOTSET) 92 | 93 | # Disable asyncio debug logging 94 | del os.environ['PYTHONASYNCIODEBUG'] 95 | 96 | # Disable logger group 97 | _logger_group.disabled = True 98 | 99 | 100 | def get_logger(name=None, level=logbook.NOTSET): 101 | """ 102 | Return a :class:`logbook.Logger`. 103 | 104 | Arguments: 105 | - `name`: The name of a specific sub-logger. 106 | """ 107 | base_name = 'threema.gateway' 108 | name = base_name if name is None else '.'.join((base_name, name)) 109 | 110 | # Create new logger and add to group 111 | logger = logbook.Logger(name=name, level=level) 112 | _logger_group.add_logger(logger) 113 | return logger 114 | 115 | 116 | # TODO: Raises 117 | def read_key_or_key_file(key, expected_type): 118 | """ 119 | Decode a hex-encoded key or read it from a file. 120 | 121 | Arguments: 122 | - `key`: A hex-encoded key or the name of a file which contains 123 | a key. 124 | - `expected_type`: One of the types of :class:`Key.Type`. 125 | 126 | Return a:class:`libnacl.public.SecretKey` or 127 | :class:`libnacl.public.PublicKey` instance. 128 | """ 129 | # Read key file (if any) 130 | try: 131 | with open(key) as file: 132 | key = file.readline().strip() 133 | except IOError: 134 | pass 135 | 136 | # Convert to key instance 137 | return Key.decode(key, expected_type) 138 | 139 | 140 | async def raise_server_error(response, error): 141 | """ 142 | Raise a :class:`GatewayServerError` exception from a 143 | HTTP response. Releases the response before raising. 144 | 145 | 146 | Arguments: 147 | - `response`: A :class:`aiohttp.ClientResponse` instance. 148 | - `error`: The :class:`GatewayServerError`. to instantiate. 149 | 150 | Always raises :class:`GatewayServerError`. 151 | """ 152 | status = response.status 153 | await response.release() 154 | raise error(status) 155 | 156 | 157 | def randint(a, b): 158 | """ 159 | Return a cryptographically secure random integer N such that 160 | ``a <= N <= b``. 161 | """ 162 | n = libnacl.randombytes_uniform(b) + a 163 | assert a <= n <= b 164 | return n 165 | 166 | 167 | # TODO: Document properly 168 | class ViewIOReader(io.RawIOBase): 169 | def __init__(self, bytes_or_view): 170 | super().__init__() 171 | if isinstance(bytes_or_view, bytes): 172 | bytes_or_view = memoryview(bytes_or_view) 173 | self._view = bytes_or_view 174 | self._offset = 0 175 | self._length = len(self._view) 176 | 177 | # IOBase methods 178 | 179 | def fileno(self): 180 | raise OSError('No file descriptors used') 181 | 182 | def isatty(self): 183 | return False 184 | 185 | def readable(self): 186 | return True 187 | 188 | def readline(self, size=-1): 189 | raise NotImplementedError 190 | 191 | def readlines(self, hint=-1): 192 | raise NotImplementedError 193 | 194 | def seek(self, offset, whence=os.SEEK_SET): 195 | if whence == os.SEEK_SET: 196 | pass 197 | elif whence == os.SEEK_CUR: 198 | offset += self._offset 199 | elif whence == os.SEEK_END: 200 | offset = self._length - offset 201 | else: 202 | raise ValueError('Invalid whence value') 203 | if not 0 < offset <= self._length: 204 | raise ValueError('Offset is greater than view length') 205 | self._offset = offset 206 | return offset 207 | 208 | def seekable(self): 209 | return True 210 | 211 | def tell(self): 212 | return self._offset 213 | 214 | def writable(self): 215 | return False 216 | 217 | # RawIOBase methods 218 | 219 | def read(self, size=-1): 220 | if size == -1: 221 | return self.readall() 222 | elif size < 0: 223 | raise ValueError('Negative size') 224 | start, end = self._offset, min(self._offset + size, self._length) 225 | self._offset = end 226 | return self._view[start:end] 227 | 228 | def readall(self): 229 | return self.read(self._length - self._offset) 230 | 231 | def readinto(self, b): 232 | data = self.readall() 233 | b.extend(data) 234 | return len(data) 235 | 236 | # Custom methods 237 | 238 | def __len__(self): 239 | return self._length - self._offset 240 | 241 | def readexactly(self, size): 242 | data = self.read(size) 243 | if len(data) < size: 244 | raise asyncio.IncompleteReadError(data, size) 245 | else: 246 | return data 247 | 248 | 249 | # TODO: Document properly 250 | class ViewIOWriter(io.RawIOBase): 251 | def __init__(self, bytes_or_views=None): 252 | super().__init__() 253 | self._views = [] 254 | self._length = 0 255 | if bytes_or_views is not None: 256 | for bytes_or_view in bytes_or_views: 257 | self.writeexactly(bytes_or_view) 258 | 259 | # IOBase methods 260 | 261 | def fileno(self): 262 | raise OSError('No file descriptors used') 263 | 264 | def isatty(self): 265 | return False 266 | 267 | def readable(self): 268 | return False 269 | 270 | def seekable(self): 271 | return False 272 | 273 | def writable(self): 274 | return True 275 | 276 | # RawIOBase methods 277 | 278 | def write(self, bytes_or_view): 279 | # Convert to memoryview if necessary 280 | if isinstance(bytes_or_view, bytes): 281 | bytes_or_view = memoryview(bytes_or_view) 282 | 283 | # Append 284 | length = len(bytes_or_view) 285 | self._length += length 286 | self._views.append(bytes_or_view) 287 | return length 288 | 289 | def writelines(self, lines): 290 | raise NotImplementedError 291 | 292 | # Custom methods 293 | 294 | def __radd__(self, other): 295 | self.extend(other) 296 | return self 297 | 298 | def __len__(self): 299 | return self._length 300 | 301 | def getvalue(self): 302 | return b''.join(self._views) 303 | 304 | # noinspection PyProtectedMember 305 | def extend(self, other): 306 | self._views += other._views 307 | self._length += other._length 308 | 309 | def writeexactly(self, bytes_or_view): 310 | return self.write(bytes_or_view) 311 | 312 | 313 | _CacheInfo = collections.namedtuple('_CacheInfo', ('hits', 'misses', 'length', 'ttl')) 314 | 315 | 316 | def async_ttl_cache(ttl): 317 | """ 318 | Cache with expiration (TTL) for asyncio coroutines. 319 | 320 | If *ttl* is set, cached values will be cleared after 321 | *ttl* seconds. 322 | 323 | View the cache statistics named tuple (hits, misses, maxsize, 324 | currsize) with f.cache_info(). Clear the cache and statistics 325 | with f.cache_clear(). Access the underlying function with 326 | f.__wrapped__. 327 | """ 328 | def _decorator(func): 329 | # Ensure it is a coroutine 330 | if not asyncio.iscoroutinefunction(func): 331 | raise ValueError('Function is not a coroutine') 332 | 333 | # Cache storage 334 | cache = {} 335 | hits = misses = 0 336 | 337 | async def _wrapper(*args, **kwargs): 338 | nonlocal hits, misses 339 | key = make_key(args, kwargs) 340 | 341 | # Attempt to get value from cache 342 | try: 343 | node = cache[key] 344 | except KeyError: 345 | pass 346 | else: 347 | # Hit. Return cached value if still valid. 348 | if is_cache_value_valid(node): 349 | hits += 1 350 | return retrieve_result_from_cache_value(node) 351 | 352 | # Miss. Get value from function and insert into cache. 353 | misses += 1 354 | value = await func(*args, **kwargs) 355 | cache[key] = make_cache_value(value, ttl) 356 | return value 357 | 358 | def cache_clear(): 359 | """ 360 | Clear the cache and its statistics information 361 | """ 362 | nonlocal hits, misses 363 | cache.clear() 364 | hits = misses = 0 365 | 366 | def cache_info(): 367 | """ 368 | Show statistics information 369 | """ 370 | return _CacheInfo(hits, misses, len(cache), ttl) 371 | 372 | # Expose functions to wrapper 373 | _wrapper.cache_clear = cache_clear 374 | _wrapper.cache_info = cache_info 375 | _wrapper._cache = cache 376 | 377 | return functools.update_wrapper(_wrapper, func) 378 | return _decorator 379 | 380 | 381 | def aio_run(func): 382 | """ 383 | Decorate an async function to run as a normal blocking function. 384 | 385 | The async function will be executed in the currently running event 386 | loop (or automatically create one if none exists). 387 | 388 | Example: 389 | 390 | .. code-block:: 391 | async def coroutine(timeout): 392 | await asyncio.sleep(timeout) 393 | return True 394 | 395 | @aio_run 396 | async def runner(*args, **kwargs): 397 | return await coroutine(*args, **kwargs) 398 | 399 | # Call coroutine in a blocking manner 400 | result = runner(timeout=1.0) 401 | print(result) 402 | """ 403 | def _wrapper(*args, **kwargs): 404 | loop = asyncio.get_event_loop() 405 | return loop.run_until_complete(func(*args, **kwargs)) 406 | return functools.update_wrapper(_wrapper, func) 407 | 408 | 409 | def aio_run_proxy(cls): 410 | """ 411 | Proxy a publicly accessible class and run all methods marked as 412 | async inside it (using the class attribute `async_functions`) with 413 | an event loop to make it appear as a traditional blocking method. 414 | 415 | Arguments: 416 | - `cls`: A class to be wrapped. The class must inherit 417 | :class:`AioRunMixin`. The class and all base classes must 418 | supply a class attribute `async_functions` which is an 419 | iterable of method names that should appear as traditional 420 | blocking functions from the outside. 421 | 422 | Returns a class factory. 423 | 424 | .. note:: The `unwrap` property of the resulting instance can be 425 | used to get the original instance. 426 | """ 427 | # Ensure each base class has added a class-level iterable of async functions 428 | async_functions = set() 429 | for base_class in inspect.getmro(cls)[:-1]: 430 | try: 431 | async_functions.update(base_class.__dict__.get('async_functions', None)) 432 | except TypeError: 433 | message = "Class {} is missing 'async_functions' iterable" 434 | raise ValueError(message.format(base_class.__name__)) 435 | 436 | # Sanity-check 437 | if not issubclass(cls, AioRunMixin): 438 | raise TypeError("Class {} did not inherit 'AioRunMixin'".format( 439 | cls.__name__)) 440 | 441 | class _AioRunProxyDecoratorFactory(wrapt.ObjectProxy): 442 | def __call__(self, *args, **kwargs): 443 | # Create the instance while an event loop is running 444 | loop = asyncio.get_event_loop() 445 | if loop.is_running(): 446 | instance = cls(*args, **kwargs) 447 | else: 448 | async def _create_instance(): 449 | return cls(*args, **kwargs) 450 | instance = loop.run_until_complete(_create_instance()) 451 | 452 | # Sanity-check 453 | if not isinstance(instance, AioRunMixin): 454 | raise TypeError("Class {} did not inherit 'AioRunMixin'".format( 455 | cls.__name__)) 456 | 457 | # Wrap with proxy (if required) 458 | if instance.blocking: 459 | class _AioRunProxy(wrapt.ObjectProxy): 460 | @property 461 | def unwrap(self): 462 | """ 463 | Get the wrapped instance. 464 | """ 465 | return self.__wrapped__ 466 | 467 | # Wrap all async functions with `aio_run` 468 | for name in async_functions: 469 | def _method(instance_, name_, *args_, **kwargs_): 470 | method = aio_run(getattr(instance_, name_)) 471 | return method(*args_, **kwargs_) 472 | 473 | _method = functools.partial(_method, instance, name) 474 | setattr(_AioRunProxy, name, _method) 475 | 476 | return _AioRunProxy(instance) 477 | else: 478 | return instance 479 | 480 | return _AioRunProxyDecoratorFactory(cls) 481 | 482 | 483 | class AioRunMixin: 484 | """ 485 | Must be inherited when using :func:`aio_run_proxy`. 486 | 487 | Arguments: 488 | - `blocking`: Switch to turn the blocking API on or off. 489 | """ 490 | async_functions = set() # type: Set[str] 491 | 492 | def __init__(self, blocking=False): 493 | self.blocking = blocking 494 | 495 | @property 496 | def unwrap(self): 497 | """ 498 | Get the wrapped instance. 499 | """ 500 | return self 501 | --------------------------------------------------------------------------------