├── MANIFEST.in ├── stream_chat ├── tests │ ├── __init__.py │ ├── conftest.py │ ├── test_channel.py │ └── test_client.py ├── __init__.py ├── __pkg__.py ├── exceptions.py ├── channel.py └── client.py ├── PULL_REQUEST_TEMPLATE.md ├── pyproject.toml ├── .travis.yml ├── .gitignore ├── CHANGELOG ├── setup.py ├── README.md └── LICENSE /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md -------------------------------------------------------------------------------- /stream_chat/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /stream_chat/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import StreamChat 2 | -------------------------------------------------------------------------------- /stream_chat/__pkg__.py: -------------------------------------------------------------------------------- 1 | __author__ = "Tommaso Barbugli" 2 | __copyright__ = "Copyright 2019-2020, Stream.io, Inc" 3 | __version__ = "1.7.0" 4 | __maintainer__ = "Tommaso Barbugli" 5 | __email__ = "support@getstream.io" 6 | __status__ = "Production" 7 | -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Submit a pull request 2 | 3 | ## CLA 4 | 5 | - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required). 6 | - [ ] The code changes follow best practices 7 | - [ ] Code changes are tested (add some information if not applicable) 8 | 9 | ## Description of the pull request 10 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 88 3 | target-version = ["py36"] 4 | include = '\.pyi?$' 5 | exclude = ''' 6 | /( 7 | \.git 8 | | \.hg 9 | | \.egg 10 | | \.eggs 11 | | \.mypy_cache 12 | | \.tox 13 | | _build 14 | | \.venv 15 | | src 16 | | bin 17 | | stream_chat\.egg-info 18 | | lib 19 | | docs 20 | | buck-out 21 | | build 22 | | dist 23 | )/ 24 | ''' 25 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | matrix: 3 | include: 4 | - python: 3.5 5 | - python: 3.6 6 | - python: 3.7 7 | - python: 3.8 8 | cache: pip 9 | install: 10 | - if [[ $TRAVIS_PYTHON_VERSION == 3.7 ]]; then pip install black; fi 11 | - if [[ $TRAVIS_PYTHON_VERSION == 3.7 ]]; then pip install pycodestyle; fi 12 | - if [[ $TRAVIS_PYTHON_VERSION == 3.7 ]]; then pip install pytest-cov; fi 13 | - if [[ $TRAVIS_PYTHON_VERSION == 3.7 ]]; then pip install codecov; fi 14 | script: 15 | - echo $STREAM_KEY 16 | - python setup.py test 17 | - if [[ $TRAVIS_PYTHON_VERSION == 3.7 ]]; then black --check stream_chat; fi 18 | - if [[ $TRAVIS_PYTHON_VERSION == 3.7 ]]; then pycodestyle --ignore=E501,E225,W293 stream_chat; fi 19 | - python setup.py install 20 | after_script: 21 | - if [[ $TRAVIS_PYTHON_VERSION == 3.7 ]]; then codecov; fi 22 | -------------------------------------------------------------------------------- /stream_chat/exceptions.py: -------------------------------------------------------------------------------- 1 | class StreamChannelException(Exception): 2 | pass 3 | 4 | 5 | class StreamAPIException(Exception): 6 | def __init__(self, response): 7 | self.response = response 8 | self.json_response = False 9 | 10 | try: 11 | parsed_response = response.json() 12 | self.error_code = parsed_response.get("code", "unknown") 13 | self.error_message = parsed_response.get("message", "unknown") 14 | self.json_response = True 15 | except ValueError: 16 | pass 17 | 18 | def __str__(self): 19 | if self.json_response: 20 | return "StreamChat error code {}: {}".format( 21 | self.error_code, self.error_message 22 | ) 23 | else: 24 | return "StreamChat error HTTP code: {}".format(self.response.status_code) 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL filesA 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | include/ 25 | 26 | # Installer logs 27 | pip-log.txt 28 | pip-delete-this-directory.txt 29 | 30 | # Unit test / coverage reports 31 | htmlcov/ 32 | .tox/ 33 | .coverage 34 | .cache 35 | nosetests.xml 36 | coverage.xml 37 | 38 | # Translations 39 | *.mo 40 | 41 | # Mr Developer 42 | .mr.developer.cfg 43 | .project 44 | .pydevproject 45 | 46 | # Rope 47 | .ropeproject 48 | 49 | # Django stuff: 50 | *.log 51 | *.pot 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | secrets.*sh 57 | .idea 58 | 59 | .venv 60 | pip-selfcheck.json 61 | .idea 62 | .vscode 63 | *,cover 64 | .eggs 65 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | ## October 20, 2020 - 1.7.0 2 | - Added support for blocklists 3 | 4 | ## September 24, 2020 - 1.6.0 5 | - Support for creating custom commands 6 | 7 | ## September 10, 2020 - 1.5.0 8 | - Support for query members 9 | - Prefer literals over constructors to simplify code 10 | 11 | ## July 23, 2020 - 1.4.0 12 | - Support timeout while muting a user 13 | 14 | ## June 23, 2020 - 1.3.1 15 | - Set a generic user agent for file/image get to prevent bot detection 16 | 17 | ## Apr 28, 2020 - 1.3.0 18 | - Drop six dependency 19 | - `verify_webhook` is affected and expects bytes for body parameter 20 | - Add 3.8 support 21 | 22 | ## Apr 17, 2020 - 1.2.2 23 | - Fix version number 24 | 25 | ## Apr 17, 2020 - 1.2.1 26 | - Allow to override client.base_url 27 | 28 | ## Mar 29, 2020 - 1.2.0 29 | - Add support for invites 30 | 31 | ## Mar 29, 2020 - 1.1.1 32 | - Fix client.create_token: returns a string now 33 | 34 | ## Mar 3, 2020 - 1.1. 35 | - Add support for client.get_message 36 | 37 | ## Nov 7, 2019 - 1.0.2 38 | - Bump crypto requirements 39 | 40 | ## Oct 21th, 2019 - 1.0.1 41 | - Fixed app update method parameter passing 42 | 43 | ## Oct 19th, 2019 - 1.0.0 44 | 45 | - Added support for user partial update endpoint 46 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from setuptools import find_packages, setup 4 | from setuptools.command.test import test as TestCommand 5 | 6 | install_requires = ["pycryptodomex>=3.8.1,<4", "requests>=2.22.0,<3", "pyjwt==1.7.1"] 7 | long_description = open("README.md", "r").read() 8 | tests_require = ["pytest"] 9 | 10 | about = {} 11 | with open("stream_chat/__pkg__.py") as fp: 12 | exec(fp.read(), about) 13 | 14 | 15 | class PyTest(TestCommand): 16 | def finalize_options(self): 17 | TestCommand.finalize_options(self) 18 | self.test_args = [] 19 | self.test_suite = True 20 | 21 | def run_tests(self): 22 | # import here, cause outside the eggs aren't loaded 23 | import pytest 24 | 25 | pytest_cmd = ["stream_chat/", "-v"] 26 | 27 | try: 28 | import pytest_cov 29 | 30 | pytest_cmd += [ 31 | "--cov=stream_chat/", 32 | "--cov-report=html", 33 | "--cov-report=annotate", 34 | ] 35 | except ImportError: 36 | pass 37 | 38 | errno = pytest.main(pytest_cmd) 39 | sys.exit(errno) 40 | 41 | 42 | setup( 43 | name="stream-chat", 44 | cmdclass={"test": PyTest}, 45 | version=about["__version__"], 46 | author=about["__maintainer__"], 47 | author_email=about["__email__"], 48 | url="http://github.com/GetStream/chat-py", 49 | description="Client for Stream Chat.", 50 | long_description=long_description, 51 | long_description_content_type="text/markdown", 52 | packages=find_packages(), 53 | zip_safe=False, 54 | install_requires=install_requires, 55 | extras_require={"test": tests_require}, 56 | tests_require=tests_require, 57 | include_package_data=True, 58 | python_requires=">=3.5", 59 | classifiers=[ 60 | "Intended Audience :: Developers", 61 | "Intended Audience :: System Administrators", 62 | "Operating System :: OS Independent", 63 | "Topic :: Software Development", 64 | "Development Status :: 5 - Production/Stable", 65 | "Natural Language :: English", 66 | "Programming Language :: Python :: 3", 67 | "Programming Language :: Python :: 3.5", 68 | "Programming Language :: Python :: 3.6", 69 | "Programming Language :: Python :: 3.7", 70 | "Programming Language :: Python :: 3.8", 71 | "Topic :: Software Development :: Libraries :: Python Modules", 72 | ], 73 | ) 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # stream-chat-python 2 | 3 | [![Build Status](https://travis-ci.com/GetStream/stream-chat-python.svg?token=WystDPP9vxKnwsd8NwW1&branch=master)](https://travis-ci.com/GetStream/stream-chat-python) [![codecov](https://codecov.io/gh/GetStream/stream-chat-python/branch/master/graph/badge.svg?token=DM7rr9M7Kl)](https://codecov.io/gh/GetStream/stream-chat-python) [![PyPI version](https://badge.fury.io/py/stream-chat.svg)](http://badge.fury.io/py/stream-chat) ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/stream-chat.svg) 4 | 5 | the official Python API client for [Stream chat](https://getstream.io/chat/) a service for building chat applications. 6 | 7 | You can sign up for a Stream account at https://getstream.io/chat/get_started/. 8 | 9 | You can use this library to access chat API endpoints server-side, for the client-side integrations (web and mobile) have a look at the Javascript, iOS and Android SDK libraries (https://getstream.io/chat/). 10 | 11 | ### Installation 12 | 13 | ```bash 14 | pip install stream-chat 15 | ``` 16 | 17 | ### Documentation 18 | 19 | [Official API docs](https://getstream.io/chat/docs/) 20 | 21 | ### How to build a chat app with Python tutorial 22 | 23 | [Chat with Python, Django and React](https://github.com/GetStream/python-chat-example) 24 | 25 | ### Supported features 26 | 27 | - Chat channels 28 | - Messages 29 | - Chat channel types 30 | - User management 31 | - Moderation API 32 | - Push configuration 33 | - User devices 34 | - User search 35 | - Channel search 36 | 37 | ### Quickstart 38 | 39 | ```python 40 | chat = StreamChat(api_key="STREAM_KEY", api_secret="STREAM_SECRET") 41 | 42 | # add a user 43 | chat.update_user({"id": "chuck", "name": "Chuck"}) 44 | 45 | # create a channel about kung-fu 46 | channel = chat.channel("messaging", "kung-fu") 47 | channel.create("chuck") 48 | 49 | # add a first message to the channel 50 | channel.send_message({"text": "AMA about kung-fu"}, "chuck") 51 | 52 | ``` 53 | 54 | ### Contributing 55 | 56 | First, make sure you can run the test suite. Tests are run via py.test 57 | 58 | ```bash 59 | STREAM_KEY=my_api_key STREAM_SECRET=my_api_secret py.test stream_chat/ -v 60 | ``` 61 | 62 | Install black and pycodestyle 63 | 64 | ``` 65 | pip install black 66 | pip install pycodestyle 67 | ``` 68 | 69 | 70 | ### Releasing a new version 71 | 72 | In order to release new version you need to be a maintainer on Pypi. 73 | 74 | - Update CHANGELOG 75 | - Make sure you have twine installed (pip install twine) 76 | - Update the version on setup.py 77 | - Commit and push to Github 78 | - Create a new tag for the version (eg. `v2.9.0`) 79 | - Create a new dist with python `python setup.py sdist` 80 | - Upload the new distributable with twine `twine upload dist/stream-chat-VERSION-NAME.tar.gz` 81 | 82 | If unsure you can also test using the Pypi test servers `twine upload --repository-url https://test.pypi.org/legacy/ dist/stream-chat-VERSION-NAME.tar.gz` 83 | -------------------------------------------------------------------------------- /stream_chat/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import pytest 4 | from stream_chat import StreamChat 5 | import os 6 | 7 | 8 | def pytest_runtest_makereport(item, call): 9 | if "incremental" in item.keywords: 10 | if call.excinfo is not None: 11 | parent = item.parent 12 | parent._previousfailed = item 13 | 14 | 15 | def pytest_runtest_setup(item): 16 | if "incremental" in item.keywords: 17 | previousfailed = getattr(item.parent, "_previousfailed", None) 18 | if previousfailed is not None: 19 | pytest.xfail("previous test failed (%s)" % previousfailed.name) 20 | 21 | 22 | def pytest_configure(config): 23 | config.addinivalue_line("markers", "incremental: mark test incremental") 24 | 25 | 26 | @pytest.fixture(scope="module") 27 | def client(): 28 | base_url = os.environ.get("STREAM_HOST") 29 | options = {"base_url": base_url} if base_url else {} 30 | return StreamChat( 31 | api_key=os.environ["STREAM_KEY"], 32 | api_secret=os.environ["STREAM_SECRET"], 33 | timeout=10, 34 | **options, 35 | ) 36 | 37 | 38 | @pytest.fixture(scope="function") 39 | def random_user(client): 40 | user = {"id": str(uuid.uuid4())} 41 | response = client.update_user(user) 42 | assert "users" in response 43 | assert user["id"] in response["users"] 44 | return user 45 | 46 | 47 | @pytest.fixture(scope="function") 48 | def server_user(client): 49 | user = {"id": str(uuid.uuid4())} 50 | response = client.update_user(user) 51 | assert "users" in response 52 | assert user["id"] in response["users"] 53 | return user 54 | 55 | 56 | @pytest.fixture(scope="function") 57 | def random_users(client): 58 | user1 = {"id": str(uuid.uuid4())} 59 | user2 = {"id": str(uuid.uuid4())} 60 | client.update_users([user1, user2]) 61 | return [user1, user2] 62 | 63 | 64 | @pytest.fixture(scope="function") 65 | def channel(client, random_user): 66 | channel = client.channel( 67 | "messaging", str(uuid.uuid4()), {"test": True, "language": "python"} 68 | ) 69 | channel.create(random_user["id"]) 70 | return channel 71 | 72 | 73 | @pytest.fixture(scope="function") 74 | def command(client): 75 | response = client.create_command( 76 | dict(name=str(uuid.uuid4()), description="My command") 77 | ) 78 | 79 | return response["command"] 80 | 81 | 82 | @pytest.fixture(scope="module") 83 | def fellowship_of_the_ring(client): 84 | members = [ 85 | {"id": "frodo-baggins", "name": "Frodo Baggins", "race": "Hobbit", "age": 50}, 86 | {"id": "sam-gamgee", "name": "Samwise Gamgee", "race": "Hobbit", "age": 38}, 87 | {"id": "gandalf", "name": "Gandalf the Grey", "race": "Istari"}, 88 | {"id": "legolas", "name": "Legolas", "race": "Elf", "age": 500}, 89 | {"id": "gimli", "name": "Gimli", "race": "Dwarf", "age": 139}, 90 | {"id": "aragorn", "name": "Aragorn", "race": "Man", "age": 87}, 91 | {"id": "boromir", "name": "Boromir", "race": "Man", "age": 40}, 92 | { 93 | "id": "meriadoc-brandybuck", 94 | "name": "Meriadoc Brandybuck", 95 | "race": "Hobbit", 96 | "age": 36, 97 | }, 98 | {"id": "peregrin-took", "name": "Peregrin Took", "race": "Hobbit", "age": 28}, 99 | ] 100 | client.update_users(members) 101 | channel = client.channel( 102 | "team", "fellowship-of-the-ring", {"members": [m["id"] for m in members]} 103 | ) 104 | channel.create("gandalf") 105 | -------------------------------------------------------------------------------- /stream_chat/tests/test_channel.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import pytest 4 | 5 | from stream_chat.exceptions import StreamAPIException 6 | 7 | 8 | @pytest.mark.incremental 9 | class TestChannel(object): 10 | def test_ban_user(self, channel, random_user, server_user): 11 | channel.ban_user(random_user["id"], user_id=server_user["id"]) 12 | channel.ban_user( 13 | random_user["id"], 14 | timeout=3600, 15 | reason="offensive language is not allowed here", 16 | user_id=server_user["id"], 17 | ) 18 | channel.unban_user(random_user["id"]) 19 | 20 | def test_create_without_id(self, client, random_users): 21 | channel = client.channel( 22 | "messaging", data={"members": [u["id"] for u in random_users]} 23 | ) 24 | assert channel.id is None 25 | 26 | channel.create(random_users[0]["id"]) 27 | assert channel.id is not None 28 | 29 | def test_send_event(self, channel, random_user): 30 | response = channel.send_event({"type": "typing.start"}, random_user["id"]) 31 | assert "event" in response 32 | assert response["event"]["type"] == "typing.start" 33 | 34 | def test_send_reaction(self, channel, random_user): 35 | msg = channel.send_message({"text": "hi"}, random_user["id"]) 36 | response = channel.send_reaction( 37 | msg["message"]["id"], {"type": "love"}, random_user["id"] 38 | ) 39 | assert "message" in response 40 | assert len(response["message"]["latest_reactions"]) == 1 41 | assert response["message"]["latest_reactions"][0]["type"] == "love" 42 | 43 | def test_delete_reaction(self, channel, random_user): 44 | msg = channel.send_message({"text": "hi"}, random_user["id"]) 45 | channel.send_reaction(msg["message"]["id"], {"type": "love"}, random_user["id"]) 46 | response = channel.delete_reaction( 47 | msg["message"]["id"], "love", random_user["id"] 48 | ) 49 | assert "message" in response 50 | assert len(response["message"]["latest_reactions"]) == 0 51 | 52 | def test_update(self, channel): 53 | response = channel.update({"motd": "one apple a day..."}) 54 | assert "channel" in response 55 | assert response["channel"]["motd"] == "one apple a day..." 56 | 57 | def test_delete(self, channel): 58 | response = channel.delete() 59 | assert "channel" in response 60 | assert response["channel"].get("deleted_at") is not None 61 | 62 | def test_truncate(self, channel): 63 | response = channel.truncate() 64 | assert "channel" in response 65 | 66 | def test_add_members(self, channel, random_user): 67 | response = channel.remove_members([random_user["id"]]) 68 | assert len(response["members"]) == 0 69 | 70 | response = channel.add_members([random_user["id"]]) 71 | assert len(response["members"]) == 1 72 | assert not response["members"][0].get("is_moderator", False) 73 | 74 | def test_invite_members(self, channel, random_user): 75 | response = channel.remove_members([random_user["id"]]) 76 | assert len(response["members"]) == 0 77 | 78 | response = channel.invite_members([random_user["id"]]) 79 | assert len(response["members"]) == 1 80 | assert response["members"][0].get("invited", True) 81 | 82 | def test_add_moderators(self, channel, random_user): 83 | response = channel.add_moderators([random_user["id"]]) 84 | assert response["members"][0]["is_moderator"] 85 | 86 | response = channel.demote_moderators([random_user["id"]]) 87 | assert not response["members"][0].get("is_moderator", False) 88 | 89 | def test_mark_read(self, channel, random_user): 90 | response = channel.mark_read(random_user["id"]) 91 | assert "event" in response 92 | assert response["event"]["type"] == "message.read" 93 | 94 | def test_get_replies(self, channel, random_user): 95 | msg = channel.send_message({"text": "hi"}, random_user["id"]) 96 | response = channel.get_replies(msg["message"]["id"]) 97 | assert "messages" in response 98 | assert len(response["messages"]) == 0 99 | 100 | for i in range(10): 101 | channel.send_message( 102 | {"text": "hi", "index": i, "parent_id": msg["message"]["id"]}, 103 | random_user["id"], 104 | ) 105 | 106 | response = channel.get_replies(msg["message"]["id"]) 107 | assert "messages" in response 108 | assert len(response["messages"]) == 10 109 | 110 | response = channel.get_replies(msg["message"]["id"], limit=3, offset=3) 111 | assert "messages" in response 112 | assert len(response["messages"]) == 3 113 | assert response["messages"][0]["index"] == 7 114 | 115 | def test_get_reactions(self, channel, random_user): 116 | msg = channel.send_message({"text": "hi"}, random_user["id"]) 117 | response = channel.get_reactions(msg["message"]["id"]) 118 | 119 | assert "reactions" in response 120 | assert len(response["reactions"]) == 0 121 | 122 | channel.send_reaction( 123 | msg["message"]["id"], {"type": "love", "count": 42}, random_user["id"] 124 | ) 125 | 126 | channel.send_reaction(msg["message"]["id"], {"type": "clap"}, random_user["id"]) 127 | 128 | response = channel.get_reactions(msg["message"]["id"]) 129 | assert len(response["reactions"]) == 2 130 | 131 | response = channel.get_reactions(msg["message"]["id"], offset=1) 132 | assert len(response["reactions"]) == 1 133 | 134 | assert response["reactions"][0]["count"] == 42 135 | 136 | def test_send_and_delete_file(self, channel, random_user): 137 | url = "https://homepages.cae.wisc.edu/~ece533/images/lena.png" 138 | resp = channel.send_file(url, "lena.png", random_user) 139 | assert "lena.png" in resp["file"] 140 | resp = channel.delete_file(resp["file"]) 141 | 142 | def test_send_and_delete_image(self, channel, random_user): 143 | url = "https://homepages.cae.wisc.edu/~ece533/images/lena.png" 144 | resp = channel.send_image( 145 | url, "lena.png", random_user, content_type="image/png" 146 | ) 147 | assert "lena.png" in resp["file"] 148 | # resp = channel.delete_image(resp['file']) 149 | 150 | def test_send_image_with_bot_blocked(self, channel, random_user): 151 | # following url blocks bots and we set a generic header to skip it 152 | # but it can start failing again, see initial discussion here: https://github.com/GetStream/stream-chat-python/pull/30#discussion_r444209891 153 | url = "https://api.twilio.com/2010-04-01/Accounts/AC3e136e1a00279f4dadcb10a9f1a1e8a3/Messages/MM547edf6f4846a30231c3033fa20f8419/Media/ME498020f8fe0b0ba2ff83ac99e4782e02" 154 | resp = channel.send_image(url, "js.png", random_user, content_type="image/png") 155 | assert "js.png" in resp["file"] 156 | 157 | def test_channel_hide_show(self, client, channel, random_users): 158 | # setup 159 | channel.add_members([u["id"] for u in random_users]) 160 | # verify 161 | response = client.query_channels({"id": channel.id}) 162 | assert len(response["channels"]) == 1 163 | response = client.query_channels( 164 | {"id": channel.id}, user_id=random_users[0]["id"] 165 | ) 166 | assert len(response["channels"]) == 1 167 | # hide 168 | channel.hide(random_users[0]["id"]) 169 | response = client.query_channels( 170 | {"id": channel.id}, user_id=random_users[0]["id"] 171 | ) 172 | assert len(response["channels"]) == 0 173 | # search hidden channels 174 | response = client.query_channels( 175 | {"id": channel.id, "hidden": True}, user_id=random_users[0]["id"] 176 | ) 177 | assert len(response["channels"]) == 1 178 | # unhide 179 | channel.show(random_users[0]["id"]) 180 | response = client.query_channels( 181 | {"id": channel.id}, user_id=random_users[0]["id"] 182 | ) 183 | assert len(response["channels"]) == 1 184 | # hide again 185 | channel.hide(random_users[0]["id"]) 186 | response = client.query_channels( 187 | {"id": channel.id}, user_id=random_users[0]["id"] 188 | ) 189 | assert len(response["channels"]) == 0 190 | # send message 191 | channel.send_message({"text": "hi"}, random_users[1]["id"]) 192 | # channel should be listed now 193 | response = client.query_channels( 194 | {"id": channel.id}, user_id=random_users[0]["id"] 195 | ) 196 | assert len(response["channels"]) == 1 197 | 198 | def test_invites(self, client, channel): 199 | members = ["john", "paul", "george", "pete", "ringo", "eric"] 200 | client.update_users([{"id": m} for m in members]) 201 | channel = client.channel( 202 | "team", 203 | "beatles-" + str(uuid.uuid4()), 204 | {"members": members, "invites": ["ringo", "eric"]}, 205 | ) 206 | channel.create("john") 207 | # accept the invite when not a member 208 | with pytest.raises(StreamAPIException): 209 | channel.accept_invite("brian") 210 | # accept the invite when a member 211 | accept = channel.accept_invite("ringo") 212 | for m in accept["members"]: 213 | if m["user_id"] == "ringo": 214 | assert m["invited"] is True 215 | assert "invite_accepted_at" in m 216 | # cannot accept again 217 | with pytest.raises(StreamAPIException): 218 | channel.accept_invite("ringo") 219 | reject = channel.reject_invite("eric") 220 | for m in reject["members"]: 221 | if m["user_id"] == "eric": 222 | assert m["invited"] is True 223 | assert "invite_rejected_at" in m 224 | # cannot reject again 225 | with pytest.raises(StreamAPIException): 226 | reject = channel.reject_invite("eric") 227 | 228 | def test_query_members(self, client, channel): 229 | members = ["paul", "george", "john", "jessica", "john2"] 230 | client.update_users([{"id": m, "name": m} for m in members]) 231 | for member in members: 232 | channel.add_members([member]) 233 | 234 | response = channel.query_members( 235 | filter_conditions={"name": {"$autocomplete": "j"}}, 236 | sort=[{"field": "created_at", "direction": 1}], 237 | offset=1, 238 | limit=10, 239 | ) 240 | 241 | assert len(response) == 2 242 | assert response[0]["user_id"] == "jessica" 243 | assert response[1]["user_id"] == "john2" 244 | -------------------------------------------------------------------------------- /stream_chat/channel.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from stream_chat.exceptions import StreamChannelException 4 | 5 | 6 | class Channel(object): 7 | def __init__(self, client, channel_type, channel_id=None, custom_data=None): 8 | self.channel_type = channel_type 9 | self.id = channel_id 10 | self.client = client 11 | self.custom_data = custom_data 12 | if self.custom_data is None: 13 | self.custom_data = {} 14 | 15 | @property 16 | def url(self): 17 | if self.id is None: 18 | raise StreamChannelException("channel does not have an id") 19 | return "channels/{}/{}".format(self.channel_type, self.id) 20 | 21 | def send_message(self, message, user_id): 22 | """ 23 | Send a message to this channel 24 | 25 | :param message: the Message object 26 | :param user_id: the ID of the user that created the message 27 | :return: the Server Response 28 | """ 29 | payload = {"message": add_user_id(message, user_id)} 30 | return self.client.post("{}/message".format(self.url), data=payload) 31 | 32 | def send_event(self, event, user_id): 33 | """ 34 | Send an event on this channel 35 | 36 | :param event: event data, ie {type: 'message.read'} 37 | :param user_id: the ID of the user sending the event 38 | :return: the Server Response 39 | """ 40 | payload = {"event": add_user_id(event, user_id)} 41 | return self.client.post("{}/event".format(self.url), data=payload) 42 | 43 | def send_reaction(self, message_id, reaction, user_id): 44 | """ 45 | Send a reaction about a message 46 | 47 | :param message_id: the message id 48 | :param reaction: the reaction object, ie {type: 'love'} 49 | :param user_id: the ID of the user that created the reaction 50 | :return: the Server Response 51 | """ 52 | payload = {"reaction": add_user_id(reaction, user_id)} 53 | return self.client.post("messages/{}/reaction".format(message_id), data=payload) 54 | 55 | def delete_reaction(self, message_id, reaction_type, user_id): 56 | """ 57 | Delete a reaction by user and type 58 | 59 | :param message_id: the id of the message from which te remove the reaction 60 | :param reaction_type: the type of reaction that should be removed 61 | :param user_id: the id of the user 62 | :return: the Server Response 63 | """ 64 | return self.client.delete( 65 | "messages/{}/reaction/{}".format(message_id, reaction_type), 66 | params={"user_id": user_id}, 67 | ) 68 | 69 | def create(self, user_id): 70 | """ 71 | Create the channel 72 | 73 | :param user_id: the ID of the user creating this channel 74 | :return: 75 | """ 76 | self.custom_data["created_by"] = {"id": user_id} 77 | return self.query(watch=False, state=False, presence=False) 78 | 79 | def query(self, **options): 80 | """ 81 | Query the API for this channel, get messages, members or other channel fields 82 | 83 | :param options: the query options, check docs on https://getstream.io/chat/docs/ 84 | :return: Returns a query response 85 | """ 86 | payload = {"state": True, "data": self.custom_data, **options} 87 | 88 | url = "channels/{}".format(self.channel_type) 89 | if self.id is not None: 90 | url = "{}/{}".format(url, self.id) 91 | 92 | state = self.client.post("{}/query".format(url), data=payload) 93 | 94 | if self.id is None: 95 | self.id = state["channel"]["id"] 96 | 97 | return state 98 | 99 | def query_members(self, filter_conditions, sort=None, **options): 100 | """ 101 | Query the API for this channel to filter, sort and paginate its members efficiently. 102 | 103 | :param filter_conditions: filters, checks docs on https://getstream.io/chat/docs/ 104 | :param sort: sorting field and direction slice, check docs on https://getstream.io/chat/docs/ 105 | :param options: pagination or members based channel searching details 106 | :return: Returns members response 107 | 108 | eg. 109 | channel.query_members(filter_conditions={"name": "tommaso"}, 110 | sort=[{"field": "created_at", "direction": -1}], 111 | offset=0, 112 | limit=10) 113 | """ 114 | 115 | payload = { 116 | "id": self.id, 117 | "type": self.channel_type, 118 | "filter_conditions": filter_conditions, 119 | "sort": sort or [], 120 | **options, 121 | } 122 | response = self.client.get("members", params={"payload": json.dumps(payload)}) 123 | return response["members"] 124 | 125 | def update(self, channel_data, update_message=None): 126 | """ 127 | Edit the channel's custom properties 128 | 129 | :param channel_data: the object to update the custom properties of this channel with 130 | :param update_message: optional update message 131 | :return: The server response 132 | """ 133 | payload = {"data": channel_data, "message": update_message} 134 | return self.client.post(self.url, data=payload) 135 | 136 | def delete(self): 137 | """ 138 | Delete the channel. Messages are permanently removed. 139 | 140 | :return: The server response 141 | """ 142 | return self.client.delete(self.url) 143 | 144 | def truncate(self): 145 | """ 146 | Removes all messages from the channel 147 | 148 | :return: The server response 149 | """ 150 | return self.client.post("{}/truncate".format(self.url)) 151 | 152 | def add_members(self, user_ids): 153 | """ 154 | Adds members to the channel 155 | 156 | :param user_ids: user IDs to add as members 157 | :return: 158 | """ 159 | return self.client.post(self.url, data={"add_members": user_ids}) 160 | 161 | def invite_members(self, user_ids): 162 | """ 163 | invite members to the channel 164 | 165 | :param user_ids: user IDs to invite 166 | :return: 167 | """ 168 | return self.client.post(self.url, data={"invites": user_ids}) 169 | 170 | def add_moderators(self, user_ids): 171 | """ 172 | Adds moderators to the channel 173 | 174 | :param user_ids: user IDs to add as moderators 175 | :return: 176 | """ 177 | return self.client.post(self.url, data={"add_moderators": user_ids}) 178 | 179 | def remove_members(self, user_ids): 180 | """ 181 | Remove members from the channel 182 | 183 | :param user_ids: user IDs to remove from the member list 184 | :return: 185 | """ 186 | return self.client.post(self.url, data={"remove_members": user_ids}) 187 | 188 | def demote_moderators(self, user_ids): 189 | """ 190 | Demotes moderators from the channel 191 | 192 | :param user_ids: user IDs to demote 193 | :return: 194 | """ 195 | return self.client.post(self.url, data={"demote_moderators": user_ids}) 196 | 197 | def mark_read(self, user_id, **data): 198 | """ 199 | Send the mark read event for this user, only works if the `read_events` setting is enabled 200 | 201 | :param user_id: the user ID for the event 202 | :param data: additional data, ie {"message_id": last_message_id} 203 | :return: The server response 204 | """ 205 | payload = add_user_id(data, user_id) 206 | return self.client.post("{}/read".format(self.url), data=payload) 207 | 208 | def get_replies(self, parent_id, **options): 209 | """ 210 | List the message replies for a parent message 211 | 212 | :param parent_id: The message parent id, ie the top of the thread 213 | :param options: Pagination params, ie {limit:10, idlte: 10} 214 | :return: A response with a list of messages 215 | """ 216 | return self.client.get("messages/{}/replies".format(parent_id), params=options) 217 | 218 | def get_reactions(self, message_id, **options): 219 | """ 220 | List the reactions, supports pagination 221 | 222 | :param message_id: The message id 223 | :param options: Pagination params, ie {"limit":10, "idlte": 10} 224 | :return: A response with a list of reactions 225 | """ 226 | return self.client.get( 227 | "messages/{}/reactions".format(message_id), params=options 228 | ) 229 | 230 | def ban_user(self, target_id, **options): 231 | """ 232 | Bans a user from this channel 233 | 234 | :param user_id: the ID of the user to ban 235 | :param options: additional ban options, ie {"timeout": 3600, "reason": "offensive language is not allowed here"} 236 | :return: The server response 237 | """ 238 | return self.client.ban_user( 239 | target_id, type=self.channel_type, id=self.id, **options 240 | ) 241 | 242 | def unban_user(self, target_id, **options): 243 | """ 244 | Removes the ban for a user on this channel 245 | 246 | :param user_id: the ID of the user to unban 247 | :return: The server response 248 | """ 249 | return self.client.unban_user( 250 | target_id, type=self.channel_type, id=self.id, **options 251 | ) 252 | 253 | def accept_invite(self, user_id, **data): 254 | payload = add_user_id(data, user_id) 255 | payload["accept_invite"] = True 256 | response = self.client.post(self.url, data=payload) 257 | self.custom_data = response["channel"] 258 | return response 259 | 260 | def reject_invite(self, user_id, **data): 261 | payload = add_user_id(data, user_id) 262 | payload["reject_invite"] = True 263 | response = self.client.post(self.url, data=payload) 264 | self.custom_data = response["channel"] 265 | return response 266 | 267 | def send_file(self, url, name, user, content_type=None): 268 | return self.client.send_file( 269 | "{}/file".format(self.url), url, name, user, content_type=content_type 270 | ) 271 | 272 | def send_image(self, url, name, user, content_type=None): 273 | return self.client.send_file( 274 | "{}/image".format(self.url), url, name, user, content_type=content_type 275 | ) 276 | 277 | def delete_file(self, url): 278 | return self.client.delete("{}/file".format(self.url), {"url": url}) 279 | 280 | def delete_image(self, url): 281 | return self.client.delete("{}/image".format(self.url), {"url": url}) 282 | 283 | def hide(self, user_id): 284 | return self.client.post("{}/hide".format(self.url), data={"user_id": user_id}) 285 | 286 | def show(self, user_id): 287 | return self.client.post("{}/show".format(self.url), data={"user_id": user_id}) 288 | 289 | 290 | def add_user_id(payload, user_id): 291 | return {**payload, "user": {"id": user_id}} 292 | -------------------------------------------------------------------------------- /stream_chat/tests/test_client.py: -------------------------------------------------------------------------------- 1 | import jwt 2 | import pytest 3 | import uuid 4 | from stream_chat import StreamChat 5 | from stream_chat.exceptions import StreamAPIException 6 | 7 | 8 | @pytest.mark.incremental 9 | class TestClient(object): 10 | def test_mute_user(self, client, random_users): 11 | response = client.mute_user(random_users[0]["id"], random_users[1]["id"]) 12 | assert "mute" in response 13 | assert "expires" not in response["mute"] 14 | assert response["mute"]["target"]["id"] == random_users[0]["id"] 15 | assert response["mute"]["user"]["id"] == random_users[1]["id"] 16 | client.unmute_user(random_users[0]["id"], random_users[1]["id"]) 17 | 18 | def test_mute_user_with_timeout(self, client, random_users): 19 | response = client.mute_user( 20 | random_users[0]["id"], random_users[1]["id"], timeout=10 21 | ) 22 | assert "mute" in response 23 | assert "expires" in response["mute"] 24 | assert response["mute"]["target"]["id"] == random_users[0]["id"] 25 | assert response["mute"]["user"]["id"] == random_users[1]["id"] 26 | client.unmute_user(random_users[0]["id"], random_users[1]["id"]) 27 | 28 | def test_get_message(self, client, channel, random_user): 29 | msg_id = str(uuid.uuid4()) 30 | channel.send_message({"id": msg_id, "text": "helloworld"}, random_user["id"]) 31 | client.delete_message(msg_id) 32 | msg_id = str(uuid.uuid4()) 33 | channel.send_message({"id": msg_id, "text": "helloworld"}, random_user["id"]) 34 | message = client.get_message(msg_id) 35 | assert message["message"]["id"] == msg_id 36 | 37 | def test_auth_exception(self): 38 | client = StreamChat(api_key="bad", api_secret="guy") 39 | with pytest.raises(StreamAPIException): 40 | client.get_channel_type("team") 41 | 42 | def test_get_channel_types(self, client): 43 | response = client.get_channel_type("team") 44 | assert "permissions" in response 45 | 46 | def test_list_channel_types(self, client): 47 | response = client.list_channel_types() 48 | assert "channel_types" in response 49 | 50 | def test_update_channel_type(self, client): 51 | response = client.update_channel_type("team", commands=["ban", "unban"]) 52 | assert "commands" in response 53 | assert response["commands"] == ["ban", "unban"] 54 | 55 | def test_get_command(self, client, command): 56 | response = client.get_command(command["name"]) 57 | assert command["name"] == response["name"] 58 | 59 | def test_update_command(self, client, command): 60 | response = client.update_command(command["name"], description="My new command") 61 | assert "command" in response 62 | assert "My new command" == response["command"]["description"] 63 | 64 | def test_delete_command(self, client, command): 65 | response = client.delete_command(command["name"]) 66 | with pytest.raises(StreamAPIException): 67 | client.get_command(command["name"]) 68 | 69 | def test_list_commands(self, client): 70 | response = client.list_commands() 71 | assert "commands" in response 72 | 73 | def test_create_token(self, client): 74 | token = client.create_token("tommaso") 75 | assert type(token) is str 76 | payload = jwt.decode(token, client.api_secret, algorithms=["HS256"]) 77 | assert payload.get("user_id") == "tommaso" 78 | 79 | def test_get_app_settings(self, client): 80 | configs = client.get_app_settings() 81 | assert "app" in configs 82 | 83 | def test_update_user(self, client): 84 | user = {"id": str(uuid.uuid4())} 85 | response = client.update_user(user) 86 | assert "users" in response 87 | assert user["id"] in response["users"] 88 | 89 | def test_update_users(self, client): 90 | user = {"id": str(uuid.uuid4())} 91 | response = client.update_users([user]) 92 | assert "users" in response 93 | assert user["id"] in response["users"] 94 | 95 | def test_update_user_partial(self, client): 96 | user_id = str(uuid.uuid4()) 97 | client.update_user({"id": user_id, "field": "value"}) 98 | 99 | response = client.update_user_partial( 100 | {"id": user_id, "set": {"field": "updated"}} 101 | ) 102 | 103 | assert "users" in response 104 | assert user_id in response["users"] 105 | assert response["users"][user_id]["field"] == "updated" 106 | 107 | def test_delete_user(self, client, random_user): 108 | response = client.delete_user(random_user["id"]) 109 | assert "user" in response 110 | assert random_user["id"] == response["user"]["id"] 111 | 112 | def test_deactivate_user(self, client, random_user): 113 | response = client.deactivate_user(random_user["id"]) 114 | assert "user" in response 115 | assert random_user["id"] == response["user"]["id"] 116 | 117 | def test_reactivate_user(self, client, random_user): 118 | response = client.deactivate_user(random_user["id"]) 119 | assert "user" in response 120 | assert random_user["id"] == response["user"]["id"] 121 | response = client.reactivate_user(random_user["id"]) 122 | assert "user" in response 123 | assert random_user["id"] == response["user"]["id"] 124 | 125 | def test_export_user(self, client, fellowship_of_the_ring): 126 | response = client.export_user("gandalf") 127 | assert "user" in response 128 | assert response["user"]["name"] == "Gandalf the Grey" 129 | 130 | def test_ban_user(self, client, random_user, server_user): 131 | client.ban_user(random_user["id"], user_id=server_user["id"]) 132 | 133 | def test_unban_user(self, client, random_user, server_user): 134 | client.ban_user(random_user["id"], user_id=server_user["id"]) 135 | client.unban_user(random_user["id"], user_id=server_user["id"]) 136 | 137 | def test_flag_user(self, client, random_user, server_user): 138 | client.flag_user(random_user["id"], user_id=server_user["id"]) 139 | 140 | def test_unflag_user(self, client, random_user, server_user): 141 | client.flag_user(random_user["id"], user_id=server_user["id"]) 142 | client.unflag_user(random_user["id"], user_id=server_user["id"]) 143 | 144 | def test_mark_all_read(self, client, random_user): 145 | client.mark_all_read(random_user["id"]) 146 | 147 | def test_update_message(self, client, channel, random_user): 148 | msg_id = str(uuid.uuid4()) 149 | response = channel.send_message( 150 | {"id": msg_id, "text": "hello world"}, random_user["id"] 151 | ) 152 | assert response["message"]["text"] == "hello world" 153 | client.update_message( 154 | { 155 | "id": msg_id, 156 | "awesome": True, 157 | "text": "helloworld", 158 | "user": {"id": response["message"]["user"]["id"]}, 159 | } 160 | ) 161 | 162 | def test_delete_message(self, client, channel, random_user): 163 | msg_id = str(uuid.uuid4()) 164 | channel.send_message({"id": msg_id, "text": "helloworld"}, random_user["id"]) 165 | client.delete_message(msg_id) 166 | msg_id = str(uuid.uuid4()) 167 | channel.send_message({"id": msg_id, "text": "helloworld"}, random_user["id"]) 168 | client.delete_message(msg_id, hard=True) 169 | 170 | def test_flag_message(self, client, channel, random_user, server_user): 171 | msg_id = str(uuid.uuid4()) 172 | channel.send_message({"id": msg_id, "text": "helloworld"}, random_user["id"]) 173 | client.flag_message(msg_id, user_id=server_user["id"]) 174 | 175 | def test_unflag_message(self, client, channel, random_user, server_user): 176 | msg_id = str(uuid.uuid4()) 177 | channel.send_message({"id": msg_id, "text": "helloworld"}, random_user["id"]) 178 | client.flag_message(msg_id, user_id=server_user["id"]) 179 | client.unflag_message(msg_id, user_id=server_user["id"]) 180 | 181 | def test_query_users_young_hobbits(self, client, fellowship_of_the_ring): 182 | response = client.query_users({"race": {"$eq": "Hobbit"}}, {"age": -1}) 183 | assert len(response["users"]) == 4 184 | assert [50, 38, 36, 28] == [u["age"] for u in response["users"]] 185 | 186 | def test_devices(self, client, random_user): 187 | response = client.get_devices(random_user["id"]) 188 | assert "devices" in response 189 | assert len(response["devices"]) == 0 190 | 191 | client.add_device(str(uuid.uuid4()), "apn", random_user["id"]) 192 | response = client.get_devices(random_user["id"]) 193 | assert len(response["devices"]) == 1 194 | 195 | client.delete_device(response["devices"][0]["id"], random_user["id"]) 196 | client.add_device(str(uuid.uuid4()), "apn", random_user["id"]) 197 | response = client.get_devices(random_user["id"]) 198 | assert len(response["devices"]) == 1 199 | 200 | def test_search(self, client, channel, random_user): 201 | query = "supercalifragilisticexpialidocious" 202 | channel.send_message( 203 | {"text": "How many syllables are there in {}?".format(query)}, 204 | random_user["id"], 205 | ) 206 | channel.send_message( 207 | {"text": "Does 'cious' count as one or two?"}, random_user["id"] 208 | ) 209 | response = client.search( 210 | {"type": "messaging"}, query, **{"limit": 2, "offset": 0} 211 | ) 212 | # searches all channels so make sure at least one is found 213 | assert len(response["results"]) >= 1 214 | assert query in response["results"][0]["message"]["text"] 215 | response = client.search( 216 | {"type": "messaging"}, "cious", **{"limit": 12, "offset": 0} 217 | ) 218 | for message in response["results"]: 219 | assert query not in message["message"]["text"] 220 | 221 | def test_query_channels_members_in(self, client, fellowship_of_the_ring): 222 | response = client.query_channels({"members": {"$in": ["gimli"]}}, {"id": 1}) 223 | assert len(response["channels"]) == 1 224 | assert response["channels"][0]["channel"]["id"] == "fellowship-of-the-ring" 225 | assert len(response["channels"][0]["members"]) == 9 226 | 227 | def test_create_blocklist(self, client): 228 | response = client.create_blocklist(name="Foo", words=["fudge", "heck"]) 229 | 230 | def test_list_blocklists(self, client): 231 | response = client.list_blocklists() 232 | assert len(response["blocklists"]) == 2 233 | blocklist_names = {blocklist["name"] for blocklist in response["blocklists"]} 234 | assert "Foo" in blocklist_names 235 | 236 | def test_get_blocklist(self, client): 237 | response = client.get_blocklist("Foo") 238 | assert response["blocklist"]["name"] == "Foo" 239 | assert response["blocklist"]["words"] == ["fudge", "heck"] 240 | 241 | def test_update_blocklist(self, client): 242 | client.update_blocklist("Foo", words=["dang"]) 243 | response = client.get_blocklist("Foo") 244 | assert response["blocklist"]["words"] == ["dang"] 245 | 246 | def test_delete_blocklist(self, client): 247 | client.delete_blocklist("Foo") 248 | -------------------------------------------------------------------------------- /stream_chat/client.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlparse 2 | import hmac 3 | import hashlib 4 | import json 5 | import urllib 6 | from urllib.request import Request, urlopen 7 | 8 | import jwt 9 | import requests 10 | 11 | from stream_chat.__pkg__ import __version__ 12 | from stream_chat.channel import Channel 13 | from stream_chat.exceptions import StreamAPIException 14 | 15 | 16 | def get_user_agent(): 17 | return "stream-python-client-%s" % __version__ 18 | 19 | 20 | def get_default_header(): 21 | base_headers = { 22 | "Content-type": "application/json", 23 | "X-Stream-Client": get_user_agent(), 24 | } 25 | return base_headers 26 | 27 | 28 | class StreamChat(object): 29 | def __init__(self, api_key, api_secret, timeout=6.0, **options): 30 | self.api_key = api_key 31 | self.api_secret = api_secret 32 | self.timeout = timeout 33 | self.options = options 34 | self.base_url = options.get( 35 | "base_url", "https://chat-us-east-1.stream-io-api.com" 36 | ) 37 | self.auth_token = jwt.encode( 38 | {"server": True}, self.api_secret, algorithm="HS256" 39 | ) 40 | self.session = requests.Session() 41 | 42 | def get_default_params(self): 43 | return {"api_key": self.api_key} 44 | 45 | def _parse_response(self, response): 46 | try: 47 | parsed_result = json.loads(response.text) if response.text else {} 48 | except ValueError: 49 | raise StreamAPIException(response) 50 | if response.status_code >= 399: 51 | raise StreamAPIException(response) 52 | return parsed_result 53 | 54 | def _make_request(self, method, relative_url, params=None, data=None): 55 | params = params or {} 56 | data = data or {} 57 | serialized = None 58 | default_params = self.get_default_params() 59 | default_params.update(params) 60 | headers = get_default_header() 61 | headers["Authorization"] = self.auth_token 62 | headers["stream-auth-type"] = "jwt" 63 | 64 | url = "{}/{}".format(self.base_url, relative_url) 65 | 66 | if method.__name__ in ["post", "put", "patch"]: 67 | serialized = json.dumps(data) 68 | 69 | response = method( 70 | url, 71 | data=serialized, 72 | headers=headers, 73 | params=default_params, 74 | timeout=self.timeout, 75 | ) 76 | return self._parse_response(response) 77 | 78 | def put(self, relative_url, params=None, data=None): 79 | return self._make_request(self.session.put, relative_url, params, data) 80 | 81 | def post(self, relative_url, params=None, data=None): 82 | return self._make_request(self.session.post, relative_url, params, data) 83 | 84 | def get(self, relative_url, params=None): 85 | return self._make_request(self.session.get, relative_url, params, None) 86 | 87 | def delete(self, relative_url, params=None): 88 | return self._make_request(self.session.delete, relative_url, params, None) 89 | 90 | def patch(self, relative_url, params=None, data=None): 91 | return self._make_request(self.session.patch, relative_url, params, data) 92 | 93 | def create_token(self, user_id, exp=None): 94 | payload = {"user_id": user_id} 95 | if exp is not None: 96 | payload["exp"] = exp 97 | return jwt.encode(payload, self.api_secret, algorithm="HS256").decode() 98 | 99 | def update_app_settings(self, **settings): 100 | return self.patch("app", data=settings) 101 | 102 | def get_app_settings(self): 103 | return self.get("app") 104 | 105 | def update_users(self, users): 106 | return self.post("users", data={"users": {u["id"]: u for u in users}}) 107 | 108 | def update_user(self, user): 109 | return self.update_users([user]) 110 | 111 | def update_users_partial(self, updates): 112 | return self.patch("users", data={"users": updates}) 113 | 114 | def update_user_partial(self, update): 115 | return self.update_users_partial([update]) 116 | 117 | def delete_user(self, user_id, **options): 118 | return self.delete("users/{}".format(user_id), options) 119 | 120 | def deactivate_user(self, user_id, **options): 121 | return self.post("users/{}/deactivate".format(user_id), data=options) 122 | 123 | def reactivate_user(self, user_id, **options): 124 | return self.post("users/{}/reactivate".format(user_id), data=options) 125 | 126 | def export_user(self, user_id, **options): 127 | return self.get("users/{}/export".format(user_id), options) 128 | 129 | def ban_user(self, target_id, **options): 130 | data = {"target_user_id": target_id, **options} 131 | return self.post("moderation/ban", data=data) 132 | 133 | def unban_user(self, target_id, **options): 134 | params = {"target_user_id": target_id, **options} 135 | return self.delete("moderation/ban", params) 136 | 137 | def flag_message(self, target_id, **options): 138 | data = {"target_message_id": target_id, **options} 139 | return self.post("moderation/flag", data=data) 140 | 141 | def unflag_message(self, target_id, **options): 142 | data = {"target_message_id": target_id, **options} 143 | return self.post("moderation/unflag", data=data) 144 | 145 | def flag_user(self, target_id, **options): 146 | data = {"target_user_id": target_id, **options} 147 | return self.post("moderation/flag", data=data) 148 | 149 | def unflag_user(self, target_id, **options): 150 | data = {"target_user_id": target_id, **options} 151 | return self.post("moderation/unflag", data=data) 152 | 153 | def mute_user(self, target_id, user_id, **options): 154 | """ 155 | Create a mute 156 | 157 | :param target_id: the user getting muted 158 | :param user_id: the user muting the target 159 | :param options: additional mute options 160 | :return: 161 | """ 162 | data = {"target_id": target_id, "user_id": user_id, **options} 163 | return self.post("moderation/mute", data=data) 164 | 165 | def unmute_user(self, target_id, user_id): 166 | """ 167 | Removes a mute 168 | 169 | :param target_id: the user getting un-muted 170 | :param user_id: the user muting the target 171 | :return: 172 | """ 173 | 174 | data = {"target_id": target_id, "user_id": user_id} 175 | return self.post("moderation/unmute", data=data) 176 | 177 | def mark_all_read(self, user_id): 178 | return self.post("channels/read", data={"user": {"id": user_id}}) 179 | 180 | def update_message(self, message): 181 | if message.get("id") is None: 182 | raise ValueError("message must have an id") 183 | return self.post("messages/{}".format(message["id"]), data={"message": message}) 184 | 185 | def delete_message(self, message_id, **options): 186 | return self.delete("messages/{}".format(message_id), options) 187 | 188 | def get_message(self, message_id): 189 | return self.get("messages/{}".format(message_id)) 190 | 191 | def query_users(self, filter_conditions, sort=None, **options): 192 | sort_fields = [] 193 | if sort is not None: 194 | sort_fields = [{"field": k, "direction": v} for k, v in sort.items()] 195 | params = options.copy() 196 | params.update({"filter_conditions": filter_conditions, "sort": sort_fields}) 197 | return self.get("users", params={"payload": json.dumps(params)}) 198 | 199 | def query_channels(self, filter_conditions, sort=None, **options): 200 | params = {"state": True, "watch": False, "presence": False} 201 | sort_fields = [] 202 | if sort is not None: 203 | sort_fields = [{"field": k, "direction": v} for k, v in sort.items()] 204 | params.update(options) 205 | params.update({"filter_conditions": filter_conditions, "sort": sort_fields}) 206 | return self.get("channels", params={"payload": json.dumps(params)}) 207 | 208 | def create_channel_type(self, data): 209 | if "commands" not in data or not data["commands"]: 210 | data["commands"] = ["all"] 211 | return self.post("channeltypes", data=data) 212 | 213 | def get_channel_type(self, channel_type): 214 | return self.get("channeltypes/{}".format(channel_type)) 215 | 216 | def list_channel_types(self): 217 | return self.get("channeltypes") 218 | 219 | def update_channel_type(self, channel_type, **settings): 220 | return self.put("channeltypes/{}".format(channel_type), data=settings) 221 | 222 | def delete_channel_type(self, channel_type): 223 | """ 224 | Delete a type of channel 225 | 226 | :param channel_type: the channel type 227 | :return: 228 | """ 229 | return self.delete("channeltypes/{}".format(channel_type)) 230 | 231 | def channel(self, channel_type, channel_id=None, data=None): 232 | """ 233 | Creates a channel object 234 | 235 | :param channel_type: the channel type 236 | :param channel_id: the id of the channel 237 | :param data: additional data, ie: {"members":[id1, id2, ...]} 238 | :return: Channel 239 | """ 240 | return Channel(self, channel_type, channel_id, data) 241 | 242 | def list_commands(self): 243 | return self.get("commands") 244 | 245 | def create_command(self, data): 246 | return self.post("commands", data=data) 247 | 248 | def delete_command(self, name): 249 | return self.delete("commands/{}".format(name)) 250 | 251 | def get_command(self, name): 252 | return self.get("commands/{}".format(name)) 253 | 254 | def update_command(self, name, **settings): 255 | return self.put("commands/{}".format(name), data=settings) 256 | 257 | def add_device(self, device_id, push_provider, user_id): 258 | """ 259 | Add a device to a user 260 | 261 | :param device_id: the id of the device 262 | :param push_provider: the push provider used (apn or firebase) 263 | :param user_id: the id of the user 264 | :return: 265 | """ 266 | return self.post( 267 | "devices", 268 | data={"id": device_id, "push_provider": push_provider, "user_id": user_id}, 269 | ) 270 | 271 | def delete_device(self, device_id, user_id): 272 | """ 273 | Delete a device for a user 274 | 275 | :param device_id: the id of the device 276 | :param user_id: the id of the user 277 | :return: 278 | """ 279 | return self.delete("devices", {"id": device_id, "user_id": user_id}) 280 | 281 | def get_devices(self, user_id): 282 | """ 283 | Get the list of devices for a user 284 | 285 | :param user_id: the id of the user 286 | :return: list of devices 287 | """ 288 | return self.get("devices", {"user_id": user_id}) 289 | 290 | def verify_webhook(self, request_body, x_signature): 291 | """ 292 | Verify the signature added to a webhook event 293 | 294 | :param request_body: the request body received from webhook 295 | :param x_signature: the x-signature header included in the request 296 | :return: bool 297 | """ 298 | signature = hmac.new( 299 | key=self.api_secret.encode(), msg=request_body, digestmod=hashlib.sha256 300 | ).hexdigest() 301 | return signature == x_signature 302 | 303 | def search(self, filter_conditions, query, **options): 304 | params = {**options, "filter_conditions": filter_conditions, "query": query} 305 | return self.get("search", params={"payload": json.dumps(params)}) 306 | 307 | def send_file(self, uri, url, name, user, content_type=None): 308 | headers = {} 309 | headers["Authorization"] = self.auth_token 310 | headers["stream-auth-type"] = "jwt" 311 | headers["X-Stream-Client"] = get_user_agent() 312 | parts = urlparse(url) 313 | if parts[0] == "": 314 | url = "file://" + url 315 | content = urlopen(Request(url, headers={"User-Agent": "Mozilla/5.0"})).read() 316 | response = requests.post( 317 | "{}/{}".format(self.base_url, uri), 318 | params=self.get_default_params(), 319 | data={"user": json.dumps(user)}, 320 | files={"file": (name, content, content_type)}, 321 | headers=headers, 322 | ) 323 | return self._parse_response(response) 324 | 325 | def create_blocklist(self, name, words): 326 | """ 327 | Create a blocklist 328 | 329 | :param name: the name of the blocklist 330 | :param words: list of blocked words 331 | :return: 332 | """ 333 | return self.post("blocklists", data={"name": name, "words": words}) 334 | 335 | def list_blocklists(self): 336 | """ 337 | List blocklists 338 | 339 | :return: list of blocklists 340 | """ 341 | return self.get("blocklists") 342 | 343 | def get_blocklist(self, name): 344 | """Get a blocklist by name 345 | 346 | :param name: the name of the blocklist 347 | :return: blocklist dict representation 348 | """ 349 | return self.get("blocklists/{}".format(name)) 350 | 351 | def update_blocklist(self, name, words): 352 | """ 353 | Update a blocklist 354 | 355 | :param name: the name of the blocklist 356 | :param words: the list of blocked words (replaces the current list) 357 | :return: 358 | """ 359 | return self.put("blocklists/{}".format(name), data={"words": words}) 360 | 361 | def delete_blocklist(self, name): 362 | """Delete a blocklist by name 363 | 364 | :param: the name of the blocklist 365 | :return: 366 | """ 367 | return self.delete("blocklists/{}".format(name)) 368 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | SOURCE CODE LICENSE AGREEMENT 2 | 3 | IMPORTANT - READ THIS CAREFULLY BEFORE DOWNLOADING, INSTALLING, USING OR 4 | ELECTRONICALLY ACCESSING THIS PROPRIETARY PRODUCT. 5 | 6 | THIS IS A LEGAL AGREEMENT BETWEEN STREAM.IO, INC. (“STREAM.IO”) AND THE 7 | BUSINESS ENTITY OR PERSON FOR WHOM YOU (“YOU”) ARE ACTING (“CUSTOMER”) AS THE 8 | LICENSEE OF THE PROPRIETARY SOFTWARE INTO WHICH THIS AGREEMENT HAS BEEN 9 | INCLUDED (THE “AGREEMENT”). YOU AGREE THAT YOU ARE THE CUSTOMER, OR YOU ARE AN 10 | EMPLOYEE OR AGENT OF CUSTOMER AND ARE ENTERING INTO THIS AGREEMENT FOR LICENSE 11 | OF THE SOFTWARE BY CUSTOMER FOR CUSTOMER’S BUSINESS PURPOSES AS DESCRIBED IN 12 | AND IN ACCORDANCE WITH THIS AGREEMENT. YOU HEREBY AGREE THAT YOU ENTER INTO 13 | THIS AGREEMENT ON BEHALF OF CUSTOMER AND THAT YOU HAVE THE AUTHORITY TO BIND 14 | CUSTOMER TO THIS AGREEMENT. 15 | 16 | STREAM.IO IS WILLING TO LICENSE THE SOFTWARE TO CUSTOMER ONLY ON THE FOLLOWING 17 | CONDITIONS: (1) YOU ARE A CURRENT CUSTOMER OF STREAM.IO; (2) YOU ARE NOT A 18 | COMPETITOR OF STREAM.IO; AND (3) THAT YOU ACCEPT ALL THE TERMS IN THIS 19 | AGREEMENT. BY DOWNLOADING, INSTALLING, CONFIGURING, ACCESSING OR OTHERWISE 20 | USING THE SOFTWARE, INCLUDING ANY UPDATES, UPGRADES, OR NEWER VERSIONS, YOU 21 | REPRESENT, WARRANT AND ACKNOWLEDGE THAT (A) CUSTOMER IS A CURRENT CUSTOMER OF 22 | STREAM.IO; (B) CUSTOMER IS NOT A COMPETITOR OF STREAM.IO; AND THAT (C) YOU HAVE 23 | READ THIS AGREEMENT, UNDERSTAND THIS AGREEMENT, AND THAT CUSTOMER AGREES TO BE 24 | BOUND BY ALL THE TERMS OF THIS AGREEMENT. 25 | 26 | IF YOU DO NOT AGREE TO ALL THE TERMS AND CONDITIONS OF THIS AGREEMENT, 27 | STREAM.IO IS UNWILLING TO LICENSE THE SOFTWARE TO CUSTOMER, AND THEREFORE, DO 28 | NOT COMPLETE THE DOWNLOAD PROCESS, ACCESS OR OTHERWISE USE THE SOFTWARE, AND 29 | CUSTOMER SHOULD IMMEDIATELY RETURN THE SOFTWARE AND CEASE ANY USE OF THE 30 | SOFTWARE. 31 | 32 | 1. SOFTWARE. The Stream.io software accompanying this Agreement, may include 33 | Source Code, Executable Object Code, associated media, printed materials and 34 | documentation (collectively, the “Software”). The Software also includes any 35 | updates or upgrades to or new versions of the original Software, if and when 36 | made available to you by Stream.io. “Source Code” means computer programming 37 | code in human readable form that is not suitable for machine execution without 38 | the intervening steps of interpretation or compilation. “Executable Object 39 | Code" means the computer programming code in any other form than Source Code 40 | that is not readily perceivable by humans and suitable for machine execution 41 | without the intervening steps of interpretation or compilation. “Site” means a 42 | Customer location controlled by Customer. “Authorized User” means any employee 43 | or contractor of Customer working at the Site, who has signed a written 44 | confidentiality agreement with Customer or is otherwise bound in writing by 45 | confidentiality and use obligations at least as restrictive as those imposed 46 | under this Agreement. 47 | 48 | 2. LICENSE GRANT. Subject to the terms and conditions of this Agreement, in 49 | consideration for the representations, warranties, and covenants made by 50 | Customer in this Agreement, Stream.io grants to Customer, during the term of 51 | this Agreement, a personal, non-exclusive, non-transferable, non-sublicensable 52 | license to: 53 | 54 | a. install and use Software Source Code on password protected computers at a Site, 55 | restricted to Authorized Users; 56 | 57 | b. create derivative works, improvements (whether or not patentable), extensions 58 | and other modifications to the Software Source Code (“Modifications”) to build 59 | unique scalable newsfeeds, activity streams, and in-app messaging via Stream’s 60 | application program interface (“API”); 61 | 62 | c. compile the Software Source Code to create Executable Object Code versions of 63 | the Software Source Code and Modifications to build such newsfeeds, activity 64 | streams, and in-app messaging via the API; 65 | 66 | d. install, execute and use such Executable Object Code versions solely for 67 | Customer’s internal business use (including development of websites through 68 | which data generated by Stream services will be streamed (“Apps”)); 69 | 70 | e. use and distribute such Executable Object Code as part of Customer’s Apps; and 71 | 72 | f. make electronic copies of the Software and Modifications as required for backup 73 | or archival purposes. 74 | 75 | 3. RESTRICTIONS. Customer is responsible for all activities that occur in 76 | connection with the Software. Customer will not, and will not attempt to: (a) 77 | sublicense or transfer the Software or any Source Code related to the Software 78 | or any of Customer’s rights under this Agreement, except as otherwise provided 79 | in this Agreement, (b) use the Software Source Code for the benefit of a third 80 | party or to operate a service; (c) allow any third party to access or use the 81 | Software Source Code; (d) sublicense or distribute the Software Source Code or 82 | any Modifications in Source Code or other derivative works based on any part of 83 | the Software Source Code; (e) use the Software in any manner that competes with 84 | Stream.io or its business; or (e) otherwise use the Software in any manner that 85 | exceeds the scope of use permitted in this Agreement. Customer shall use the 86 | Software in compliance with any accompanying documentation any laws applicable 87 | to Customer. 88 | 89 | 4. OPEN SOURCE. Customer and its Authorized Users shall not use any software or 90 | software components that are open source in conjunction with the Software 91 | Source Code or any Modifications in Source Code or in any way that could 92 | subject the Software to any open source licenses. 93 | 94 | 5. CONTRACTORS. Under the rights granted to Customer under this Agreement, 95 | Customer may permit its employees, contractors, and agencies of Customer to 96 | become Authorized Users to exercise the rights to the Software granted to 97 | Customer in accordance with this Agreement solely on behalf of Customer to 98 | provide services to Customer; provided that Customer shall be liable for the 99 | acts and omissions of all Authorized Users to the extent any of such acts or 100 | omissions, if performed by Customer, would constitute a breach of, or otherwise 101 | give rise to liability to Customer under, this Agreement. Customer shall not 102 | and shall not permit any Authorized User to use the Software except as 103 | expressly permitted in this Agreement. 104 | 105 | 6. COMPETITIVE PRODUCT DEVELOPMENT. Customer shall not use the Software in any way 106 | to engage in the development of products or services which could be reasonably 107 | construed to provide a complete or partial functional or commercial alternative 108 | to Stream.io’s products or services (a “Competitive Product”). Customer shall 109 | ensure that there is no direct or indirect use of, or sharing of, Software 110 | source code, or other information based upon or derived from the Software to 111 | develop such products or services. Without derogating from the generality of 112 | the foregoing, development of Competitive Products shall include having direct 113 | or indirect access to, supervising, consulting or assisting in the development 114 | of, or producing any specifications, documentation, object code or source code 115 | for, all or part of a Competitive Product. 116 | 117 | 7. LIMITATION ON MODIFICATIONS. Notwithstanding any provision in this Agreement, 118 | Modifications may only be created and used by Customer as permitted by this 119 | Agreement and Modification Source Code may not be distributed to third parties. 120 | Customer will not assert against Stream.io, its affiliates, or their customers, 121 | direct or indirect, agents and contractors, in any way, any patent rights that 122 | Customer may obtain relating to any Modifications for Stream.io, its 123 | affiliates’, or their customers’, direct or indirect, agents’ and contractors’ 124 | manufacture, use, import, offer for sale or sale of any Stream.io products or 125 | services. 126 | 127 | 8. DELIVERY AND ACCEPTANCE. The Software will be delivered electronically pursuant 128 | to Stream.io standard download procedures. The Software is deemed accepted upon 129 | delivery. 130 | 131 | 9. IMPLEMENTATION AND SUPPORT. Stream.io has no obligation under this Agreement to 132 | provide any support or consultation concerning the Software. 133 | 134 | 10. TERM AND TERMINATION. The term of this Agreement begins when the Software is 135 | downloaded or accessed and shall continue until terminated. Either party may 136 | terminate this Agreement upon written notice. This Agreement shall 137 | automatically terminate if Customer is or becomes a competitor of Stream.io or 138 | makes or sells any Competitive Products. Upon termination of this Agreement for 139 | any reason, (a) all rights granted to Customer in this Agreement immediately 140 | cease to exist, (b) Customer must promptly discontinue all use of the Software 141 | and return to Stream.io or destroy all copies of the Software in Customer’s 142 | possession or control. Any continued use of the Software by Customer or attempt 143 | by Customer to exercise any rights under this Agreement after this Agreement 144 | has terminated shall be considered copyright infringement and subject Customer 145 | to applicable remedies for copyright infringement. Sections 2, 5, 6, 8 and 9 146 | shall survive expiration or termination of this Agreement for any reason. 147 | 148 | 11. OWNERSHIP. As between the parties, the Software and all worldwide intellectual 149 | property rights and proprietary rights relating thereto or embodied therein, 150 | are the exclusive property of Stream.io and its suppliers. Stream.io and its 151 | suppliers reserve all rights in and to the Software not expressly granted to 152 | Customer in this Agreement, and no other licenses or rights are granted by 153 | implication, estoppel or otherwise. 154 | 155 | 12. WARRANTY DISCLAIMER. USE OF THIS SOFTWARE IS ENTIRELY AT YOURS AND CUSTOMER’S 156 | OWN RISK. THE SOFTWARE IS PROVIDED “AS IS” WITHOUT ANY WARRANTY OF ANY KIND 157 | WHATSOEVER. STREAM.IO DOES NOT MAKE, AND HEREBY DISCLAIMS, ANY WARRANTY OF ANY 158 | KIND, WHETHER EXPRESS, IMPLIED, STATUTORY OR OTHERWISE, INCLUDING WITHOUT 159 | LIMITATION, THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR 160 | PURPOSE, TITLE, NON-INFRINGEMENT OF THIRD-PARTY RIGHTS, RESULTS, EFFORTS, 161 | QUALITY OR QUIET ENJOYMENT. STREAM.IO DOES NOT WARRANT THAT THE SOFTWARE IS 162 | ERROR-FREE, WILL FUNCTION WITHOUT INTERRUPTION, WILL MEET ANY SPECIFIC NEED 163 | THAT CUSTOMER HAS, THAT ALL DEFECTS WILL BE CORRECTED OR THAT IT IS 164 | SUFFICIENTLY DOCUMENTED TO BE USABLE BY CUSTOMER. TO THE EXTENT THAT STREAM.IO 165 | MAY NOT DISCLAIM ANY WARRANTY AS A MATTER OF APPLICABLE LAW, THE SCOPE AND 166 | DURATION OF SUCH WARRANTY WILL BE THE MINIMUM PERMITTED UNDER SUCH LAW. 167 | CUSTOMER ACKNOWLEDGES THAT IT HAS RELIED ON NO WARRANTIES OTHER THAN THE 168 | EXPRESS WARRANTIES IN THIS AGREEMENT. 169 | 170 | 13. LIMITATION OF LIABILITY. TO THE FULLEST EXTENT PERMISSIBLE BY LAW, STREAM.IO’S 171 | TOTAL LIABILITY FOR ALL DAMAGES ARISING OUT OF OR RELATED TO THE SOFTWARE OR 172 | THIS AGREEMENT, WHETHER IN CONTRACT, TORT (INCLUDING NEGLIGENCE) OR OTHERWISE, 173 | SHALL NOT EXCEED $100. IN NO EVENT WILL STREAM.IO BE LIABLE FOR ANY INDIRECT, 174 | CONSEQUENTIAL, EXEMPLARY, PUNITIVE, SPECIAL OR INCIDENTAL DAMAGES OF ANY KIND 175 | WHATSOEVER, INCLUDING ANY LOST DATA AND LOST PROFITS, ARISING FROM OR RELATING 176 | TO THE SOFTWARE EVEN IF STREAM.IO HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH 177 | DAMAGES. CUSTOMER ACKNOWLEDGES THAT THIS PROVISION REFLECTS THE AGREED UPON 178 | ALLOCATION OF RISK FOR THIS AGREEMENT AND THAT STREAM.IO WOULD NOT ENTER INTO 179 | THIS AGREEMENT WITHOUT THESE LIMITATIONS ON ITS LIABILITY. 180 | 181 | 14. General. Customer may not assign or transfer this Agreement, by operation of 182 | law or otherwise, or any of its rights under this Agreement (including the 183 | license rights granted to Customer) to any third party without Stream.io’s 184 | prior written consent, which consent will not be unreasonably withheld or 185 | delayed. Stream.io may assign this Agreement, without consent, including, but 186 | limited to, affiliate or any successor to all or substantially all its business 187 | or assets to which this Agreement relates, whether by merger, sale of assets, 188 | sale of stock, reorganization or otherwise. Any attempted assignment or 189 | transfer in violation of the foregoing will be null and void. Stream.io shall 190 | not be liable hereunder by reason of any failure or delay in the performance of 191 | its obligations hereunder for any cause which is beyond the reasonable control. 192 | All notices, consents, and approvals under this Agreement must be delivered in 193 | writing by courier, by electronic mail, or by certified or registered mail, 194 | (postage prepaid and return receipt requested) to the other party at the 195 | address set forth in the customer agreement between Stream.io and Customer and 196 | will be effective upon receipt or when delivery is refused. This Agreement will 197 | be governed by and interpreted in accordance with the laws of the State of 198 | Colorado, without reference to its choice of laws rules. The United Nations 199 | Convention on Contracts for the International Sale of Goods does not apply to 200 | this Agreement. Any action or proceeding arising from or relating to this 201 | Agreement shall be brought in a federal or state court in Denver, Colorado, and 202 | each party irrevocably submits to the jurisdiction and venue of any such court 203 | in any such action or proceeding. All waivers must be in writing. Any waiver or 204 | failure to enforce any provision of this Agreement on one occasion will not be 205 | deemed a waiver of any other provision or of such provision on any other 206 | occasion. If any provision of this Agreement is unenforceable, such provision 207 | will be changed and interpreted to accomplish the objectives of such provision 208 | to the greatest extent possible under applicable law and the remaining 209 | provisions will continue in full force and effect. Customer shall not violate 210 | any applicable law, rule or regulation, including those regarding the export of 211 | technical data. The headings of Sections of this Agreement are for convenience 212 | and are not to be used in interpreting this Agreement. As used in this 213 | Agreement, the word “including” means “including but not limited to.” This 214 | Agreement (including all exhibits and attachments) constitutes the entire 215 | agreement between the parties regarding the subject hereof and supersedes all 216 | prior or contemporaneous agreements, understandings and communication, whether 217 | written or oral. This Agreement may be amended only by a written document 218 | signed by both parties. The terms of any purchase order or similar document 219 | submitted by Customer to Stream.io will have no effect. 220 | --------------------------------------------------------------------------------