├── stream_chat ├── py.typed ├── base │ ├── __init__.py │ ├── exceptions.py │ ├── query_threads.py │ ├── campaign.py │ ├── segment.py │ └── channel.py ├── tests │ ├── __init__.py │ ├── async_chat │ │ ├── __init__.py │ │ ├── test_query_threads.py │ │ ├── test_live_locations.py │ │ ├── test_segment.py │ │ ├── conftest.py │ │ ├── test_draft.py │ │ ├── test_campaign.py │ │ └── test_reminders.py │ ├── assets │ │ └── helloworld.jpg │ ├── test_filter_tags.py │ ├── utils.py │ ├── test_stream_response.py │ ├── test_query_threads.py │ ├── test_live_locations.py │ ├── conftest.py │ ├── test_segment.py │ ├── test_draft.py │ ├── test_campaign.py │ └── test_reminders.py ├── types │ ├── __init__.py │ ├── rate_limit.py │ ├── shared_locations.py │ ├── draft.py │ ├── base.py │ ├── segment.py │ ├── delivery_receipts.py │ ├── stream_response.py │ └── campaign.py ├── async_chat │ ├── __init__.py │ ├── campaign.py │ ├── segment.py │ └── channel.py ├── __init__.py ├── __pkg__.py ├── query_threads.py ├── campaign.py ├── segment.py └── channel.py ├── .coveragerc ├── .github ├── CODEOWNERS ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── reviewdog.yml │ ├── scheduled_test.yml │ ├── ci.yml │ ├── release.yml │ └── initiate_release.yml ├── MANIFEST.in ├── .versionrc.js ├── scripts └── get_changelog_diff.js ├── pyproject.toml ├── SECURITY.md ├── .gitignore ├── assets └── logo.svg ├── setup.py ├── Makefile ├── CONTRIBUTING.md ├── README.md ├── LICENSE └── CHANGELOG.md /stream_chat/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /stream_chat/base/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /stream_chat/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /stream_chat/types/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /stream_chat/tests/async_chat/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = stream_chat/tests/* -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @10printhello @totalimmersion @slavabobik 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include stream_chat/py.typed 3 | -------------------------------------------------------------------------------- /stream_chat/async_chat/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import StreamChatAsync 2 | 3 | __all__ = ["StreamChatAsync"] 4 | -------------------------------------------------------------------------------- /stream_chat/tests/assets/helloworld.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GetStream/stream-chat-python/HEAD/stream_chat/tests/assets/helloworld.jpg -------------------------------------------------------------------------------- /stream_chat/__init__.py: -------------------------------------------------------------------------------- 1 | from .async_chat import StreamChatAsync 2 | from .client import StreamChat 3 | 4 | __all__ = ["StreamChat", "StreamChatAsync"] 5 | -------------------------------------------------------------------------------- /stream_chat/types/rate_limit.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from datetime import datetime 3 | 4 | 5 | @dataclass(frozen=True) 6 | class RateLimitInfo: 7 | limit: int 8 | remaining: int 9 | reset: datetime 10 | -------------------------------------------------------------------------------- /stream_chat/__pkg__.py: -------------------------------------------------------------------------------- 1 | __author__ = "Tommaso Barbugli" 2 | __copyright__ = "Copyright 2019-2022, Stream.io, Inc" 3 | __version__ = "4.28.0" 4 | __maintainer__ = "Tommaso Barbugli" 5 | __email__ = "support@getstream.io" 6 | __status__ = "Production" 7 | -------------------------------------------------------------------------------- /stream_chat/types/shared_locations.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Optional, TypedDict 3 | 4 | 5 | class SharedLocationsOptions(TypedDict): 6 | longitude: Optional[int] 7 | latitude: Optional[int] 8 | end_at: Optional[datetime] 9 | -------------------------------------------------------------------------------- /stream_chat/types/draft.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Optional, TypedDict 3 | 4 | from stream_chat.types.base import Pager 5 | 6 | 7 | class QueryDraftsFilter(TypedDict): 8 | channel_cid: Optional[str] 9 | parent_id: Optional[str] 10 | created_at: Optional[datetime] 11 | 12 | 13 | class QueryDraftsOptions(Pager): 14 | pass 15 | -------------------------------------------------------------------------------- /.github/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 | -------------------------------------------------------------------------------- /.versionrc.js: -------------------------------------------------------------------------------- 1 | const pkgUpdater = { 2 | VERSION_REGEX: /__version__ = "(.+)"/, 3 | 4 | readVersion: function (contents) { 5 | const version = this.VERSION_REGEX.exec(contents)[1]; 6 | return version; 7 | }, 8 | 9 | writeVersion: function (contents, version) { 10 | return contents.replace(this.VERSION_REGEX.exec(contents)[0], `__version__ = "${version}"`); 11 | } 12 | } 13 | 14 | module.exports = { 15 | bumpFiles: [{ filename: './stream_chat/__pkg__.py', updater: pkgUpdater }], 16 | } 17 | -------------------------------------------------------------------------------- /stream_chat/query_threads.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Awaitable, Dict, List, Union 2 | 3 | from stream_chat.base.query_threads import QueryThreadsInterface 4 | from stream_chat.types.stream_response import StreamResponse 5 | 6 | 7 | class QueryThreads(QueryThreadsInterface): 8 | def query_threads( 9 | self, 10 | filter: Dict[str, Dict[str, Any]], 11 | sort: List[Dict[str, Any]], 12 | **options: Any, 13 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 14 | payload = {"filter": filter, "sort": sort, **options} 15 | return self.client.post(self.url, data=payload) 16 | -------------------------------------------------------------------------------- /stream_chat/tests/test_filter_tags.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from stream_chat.channel import Channel 4 | 5 | 6 | @pytest.mark.incremental 7 | class TestFilterTags: 8 | def test_add_and_remove_filter_tags(self, channel: Channel): 9 | # Add tags 10 | add_resp = channel.add_filter_tags(["vip", "premium"]) 11 | assert "channel" in add_resp 12 | assert set(add_resp["channel"].get("filter_tags", [])) >= {"vip", "premium"} 13 | 14 | # Remove one tag 15 | remove_resp = channel.remove_filter_tags(["premium"]) 16 | assert "channel" in remove_resp 17 | remaining = remove_resp["channel"].get("filter_tags", []) 18 | assert "premium" not in remaining 19 | assert "vip" in remaining 20 | -------------------------------------------------------------------------------- /.github/workflows/reviewdog.yml: -------------------------------------------------------------------------------- 1 | name: reviewdog 2 | on: 3 | pull_request: 4 | 5 | concurrency: 6 | group: ${{ github.workflow }}-${{ github.head_ref }} 7 | cancel-in-progress: true 8 | 9 | jobs: 10 | reviewdog: 11 | name: 🐶 Reviewdog 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - uses: reviewdog/action-setup@v1 17 | with: 18 | reviewdog_version: latest 19 | 20 | - uses: actions/setup-python@v3 21 | with: 22 | python-version: "3.10" 23 | 24 | - name: Install deps 25 | run: pip install ".[ci]" 26 | 27 | - name: Reviewdog 28 | env: 29 | REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | run: make reviewdog 31 | -------------------------------------------------------------------------------- /scripts/get_changelog_diff.js: -------------------------------------------------------------------------------- 1 | /* 2 | Here we're trying to parse the latest changes from CHANGELOG.md file. 3 | The changelog looks like this: 4 | 5 | ## 0.0.3 6 | - Something #3 7 | ## 0.0.2 8 | - Something #2 9 | ## 0.0.1 10 | - Something #1 11 | 12 | In this case we're trying to extract "- Something #3" since that's the latest change. 13 | */ 14 | module.exports = () => { 15 | const fs = require('fs') 16 | 17 | changelog = fs.readFileSync('CHANGELOG.md', 'utf8') 18 | releases = changelog.match(/## [?[0-9](.+)/g) 19 | 20 | current_release = changelog.indexOf(releases[0]) 21 | previous_release = changelog.indexOf(releases[1]) 22 | 23 | latest_changes = changelog.substr(current_release, previous_release - current_release) 24 | 25 | return latest_changes 26 | } 27 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 88 3 | target-version = ['py38'] 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 | | fabfile.py 19 | | lib 20 | | docs 21 | | buck-out 22 | | build 23 | | dist 24 | )/ 25 | ''' 26 | 27 | [tool.isort] 28 | profile = "black" 29 | src_paths = ["stream_chat"] 30 | known_first_party = ["stream_chat"] 31 | 32 | [tool.pytest.ini_options] 33 | testpaths = ["stream_chat/tests"] 34 | asyncio_mode = "auto" 35 | 36 | [tool.mypy] 37 | disallow_untyped_defs = true 38 | disallow_untyped_calls = true 39 | check_untyped_defs = true 40 | warn_unused_configs = true 41 | strict_optional = false 42 | 43 | [[tool.mypy.overrides]] 44 | module = "stream_chat.tests.*" 45 | ignore_errors = true -------------------------------------------------------------------------------- /stream_chat/tests/utils.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import time 3 | from typing import Any, Awaitable, Callable 4 | 5 | 6 | def wait_for(condition: Callable[[], Any], timeout: int = 5): 7 | start = time.time() 8 | 9 | while True: 10 | if timeout < (time.time() - start): 11 | raise Exception("Timeout") 12 | 13 | try: 14 | if condition(): 15 | break 16 | except Exception: 17 | pass 18 | 19 | time.sleep(1) 20 | 21 | 22 | async def wait_for_async( 23 | condition: Callable[..., Awaitable[Any]], timeout: int = 5, **kwargs 24 | ): 25 | start = time.time() 26 | 27 | while True: 28 | if timeout < (time.time() - start): 29 | raise Exception("Timeout") 30 | 31 | try: 32 | if await condition(**kwargs): 33 | break 34 | except Exception: 35 | pass 36 | 37 | await asyncio.sleep(1) 38 | -------------------------------------------------------------------------------- /stream_chat/base/exceptions.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Dict 3 | 4 | 5 | class StreamChannelException(Exception): 6 | pass 7 | 8 | 9 | class StreamAPIException(Exception): 10 | def __init__(self, text: str, status_code: int) -> None: 11 | self.response_text = text 12 | self.status_code = status_code 13 | self.json_response = False 14 | 15 | try: 16 | parsed_response: Dict = json.loads(text) 17 | self.error_code = parsed_response.get("code", "unknown") 18 | self.error_message = parsed_response.get("message", "unknown") 19 | self.json_response = True 20 | except ValueError: 21 | pass 22 | 23 | def __str__(self) -> str: 24 | if self.json_response: 25 | return f'StreamChat error code {self.error_code}: {self.error_message}"' 26 | else: 27 | return f"StreamChat error HTTP code: {self.status_code}" 28 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Reporting a Vulnerability 2 | At Stream we are committed to the security of our Software. We appreciate your efforts in disclosing vulnerabilities responsibly and we will make every effort to acknowledge your contributions. 3 | 4 | Report security vulnerabilities at the following email address: 5 | ``` 6 | [security@getstream.io](mailto:security@getstream.io) 7 | ``` 8 | Alternatively it is also possible to open a new issue in the affected repository, tagging it with the `security` tag. 9 | 10 | A team member will acknowledge the vulnerability and will follow-up with more detailed information. A representative of the security team will be in touch if more information is needed. 11 | 12 | # Information to include in a report 13 | While we appreciate any information that you are willing to provide, please make sure to include the following: 14 | * Which repository is affected 15 | * Which branch, if relevant 16 | * Be as descriptive as possible, the team will replicate the vulnerability before working on a fix. 17 | -------------------------------------------------------------------------------- /.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 | .DS_Store 26 | 27 | # Installer logs 28 | pip-log.txt 29 | pip-delete-this-directory.txt 30 | 31 | # Unit test / coverage reports 32 | htmlcov/ 33 | .tox/ 34 | .coverage 35 | .cache 36 | nosetests.xml 37 | coverage.xml 38 | 39 | # Translations 40 | *.mo 41 | 42 | # Mr Developer 43 | .mr.developer.cfg 44 | .project 45 | .pydevproject 46 | .coverage* 47 | !.coveragerc 48 | 49 | # Rope 50 | .ropeproject 51 | 52 | # Django stuff: 53 | *.log 54 | *.pot 55 | 56 | # Sphinx documentation 57 | docs/_build/ 58 | 59 | secrets.*sh 60 | .idea 61 | 62 | .venv 63 | venv 64 | .python-version 65 | pip-selfcheck.json 66 | .idea 67 | .vscode 68 | *,cover 69 | .mypy_cache 70 | .eggs 71 | .env 72 | .envrc 73 | -------------------------------------------------------------------------------- /stream_chat/types/base.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from enum import IntEnum 3 | from typing import Optional 4 | 5 | if sys.version_info >= (3, 8): 6 | from typing import TypedDict 7 | else: 8 | from typing_extensions import TypedDict 9 | 10 | 11 | class SortOrder(IntEnum): 12 | """ 13 | Represents the sort order for a query. 14 | """ 15 | 16 | ASC = 1 17 | DESC = -1 18 | 19 | 20 | class SortParam(TypedDict, total=False): 21 | """ 22 | Represents a sort parameter for a query. 23 | 24 | Parameters: 25 | field: The field to sort by. 26 | direction: The direction to sort by. 27 | """ 28 | 29 | field: str 30 | direction: SortOrder 31 | 32 | 33 | class Pager(TypedDict, total=False): 34 | """ 35 | Represents the data structure for a pager. 36 | 37 | Parameters: 38 | limit: The maximum number of items to return. 39 | next: The next page token. 40 | prev: The previous page token. 41 | """ 42 | 43 | limit: Optional[int] 44 | next: Optional[str] 45 | prev: Optional[str] 46 | -------------------------------------------------------------------------------- /stream_chat/base/query_threads.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Any, Awaitable, Dict, List, Union 3 | 4 | from stream_chat.base.client import StreamChatInterface 5 | from stream_chat.types.stream_response import StreamResponse 6 | 7 | 8 | class QueryThreadsInterface(abc.ABC): 9 | @abc.abstractmethod 10 | def __init__(self, client: StreamChatInterface): 11 | self.client = client 12 | 13 | @property 14 | def url(self) -> str: 15 | return "threads" 16 | 17 | @abc.abstractmethod 18 | def query_threads( 19 | self, 20 | filter: Dict[str, Dict[str, Any]], 21 | sort: List[Dict[str, Any]], 22 | **options: Any, 23 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 24 | """ 25 | Get a list of threads given filter and sort options 26 | 27 | :param filter: filter conditions (e.g. {"created_by_user_id": {"$eq": "user_123"}}) 28 | :param sort: sort options (e.g. [{"field": "last_message_at", "direction": -1}]) 29 | :return: the Server Response 30 | """ 31 | pass 32 | -------------------------------------------------------------------------------- /.github/workflows/scheduled_test.yml: -------------------------------------------------------------------------------- 1 | name: Scheduled tests 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | # Monday at 9:00 UTC 7 | - cron: "0 9 * * 1" 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - uses: actions/setup-python@v3 16 | with: 17 | python-version: "3.10" 18 | 19 | - name: Install deps with ${{ matrix.python }} 20 | run: pip install ".[test, ci]" 21 | 22 | - name: Test 23 | env: 24 | STREAM_KEY: ${{ secrets.STREAM_KEY }} 25 | STREAM_SECRET: ${{ secrets.STREAM_SECRET }} 26 | PYTHONPATH: ${{ github.workspace }} 27 | run: | 28 | # Retry 3 times because tests can be flaky 29 | for _ in 1 2 3; 30 | do 31 | make test && break 32 | done 33 | 34 | - name: Notify Slack if failed 35 | uses: voxmedia/github-action-slack-notify-build@v1 36 | if: failure() 37 | with: 38 | channel_id: C02RPDF7T63 39 | color: danger 40 | status: FAILED 41 | env: 42 | SLACK_BOT_TOKEN: ${{ secrets.SLACK_NOTIFICATIONS_BOT_TOKEN }} 43 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | push: 4 | branches: 5 | - "master" 6 | pull_request: 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.head_ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | build: 14 | name: 🧪 Test & lint 15 | runs-on: ubuntu-latest 16 | strategy: 17 | fail-fast: false 18 | max-parallel: 1 19 | matrix: 20 | python: ["3.8", "3.9", "3.10", "3.11", "3.12"] 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v3 24 | with: 25 | fetch-depth: 0 # gives the commit message linter access to all previous commits 26 | 27 | - uses: actions/setup-python@v3 28 | with: 29 | python-version: ${{ matrix.python }} 30 | 31 | - name: Install deps with ${{ matrix.python }} 32 | run: pip install ".[test, ci]" 33 | 34 | - name: Lint with ${{ matrix.python }} 35 | if: ${{ matrix.python == '3.8' }} 36 | run: make lint 37 | 38 | - name: Test with ${{ matrix.python }} 39 | env: 40 | STREAM_KEY: ${{ secrets.STREAM_KEY }} 41 | STREAM_SECRET: ${{ secrets.STREAM_SECRET }} 42 | PYTHONPATH: ${{ github.workspace }} 43 | run: make test 44 | -------------------------------------------------------------------------------- /stream_chat/types/segment.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from enum import Enum 3 | from typing import Dict, Optional 4 | 5 | if sys.version_info >= (3, 8): 6 | from typing import TypedDict 7 | else: 8 | from typing_extensions import TypedDict 9 | 10 | from stream_chat.types.base import Pager 11 | 12 | 13 | class SegmentType(Enum): 14 | """ 15 | Represents the type of segment. 16 | 17 | Attributes: 18 | CHANNEL: A segment targeting channels. 19 | USER: A segment targeting users. 20 | """ 21 | 22 | CHANNEL = "channel" 23 | USER = "user" 24 | 25 | 26 | class SegmentUpdatableFields(TypedDict, total=False): 27 | """ 28 | Represents the updatable data structure for a segment. 29 | 30 | Parameters: 31 | name: The name of the segment. 32 | description: A description of the segment. 33 | filter: A filter to apply to the segment. 34 | """ 35 | 36 | name: Optional[str] 37 | description: Optional[str] 38 | filter: Optional[Dict] 39 | 40 | 41 | class SegmentData(SegmentUpdatableFields, total=False): 42 | """ 43 | Represents the data structure for a segment. 44 | 45 | Parameters: 46 | all_users: Whether to target all users. 47 | all_sender_channels: Whether to target all sender channels. 48 | """ 49 | 50 | all_users: Optional[bool] 51 | all_sender_channels: Optional[bool] 52 | 53 | 54 | class QuerySegmentsOptions(Pager, total=False): 55 | pass 56 | 57 | 58 | class QuerySegmentTargetsOptions(Pager, total=False): 59 | pass 60 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | pull_request: 5 | types: [closed] 6 | branches: 7 | - master 8 | 9 | jobs: 10 | Release: 11 | name: 🚀 Release 12 | if: github.event.pull_request.merged && startsWith(github.head_ref, 'release-') 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | with: 17 | fetch-depth: 0 18 | 19 | - uses: actions/github-script@v5 20 | with: 21 | script: | 22 | const get_change_log_diff = require('./scripts/get_changelog_diff.js') 23 | core.exportVariable('CHANGELOG', get_change_log_diff()) 24 | 25 | // Getting the release version from the PR source branch 26 | // Source branch looks like this: release-1.0.0 27 | const version = context.payload.pull_request.head.ref.split('-')[1] 28 | core.exportVariable('VERSION', version) 29 | 30 | - uses: actions/setup-python@v3 31 | with: 32 | python-version: "3.10" 33 | 34 | - name: Publish to PyPi 35 | env: 36 | TWINE_USERNAME: "__token__" 37 | TWINE_PASSWORD: "${{ secrets.PYPI_TOKEN }}" 38 | run: | 39 | pip install -q twine==3.7.1 wheel==0.37.1 40 | python setup.py sdist bdist_wheel 41 | twine upload --non-interactive dist/* 42 | 43 | - name: Create release on GitHub 44 | uses: ncipollo/release-action@v1 45 | with: 46 | body: ${{ env.CHANGELOG }} 47 | tag: ${{ env.VERSION }} 48 | token: ${{ secrets.GITHUB_TOKEN }} 49 | -------------------------------------------------------------------------------- /stream_chat/tests/test_stream_response.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | 3 | from stream_chat.types.stream_response import StreamResponse 4 | 5 | 6 | class TestResponse: 7 | def test_clean_header(self): 8 | headers = { 9 | "x-ratelimit-limit": "300", 10 | "x-ratelimit-remaining": "299", 11 | "x-ratelimit-reset": "1598806800", 12 | } 13 | response = StreamResponse({}, headers, 200) 14 | assert response.rate_limit().limit == 300 15 | assert response.rate_limit().remaining == 299 16 | assert response.rate_limit().reset == datetime.fromtimestamp( 17 | 1598806800, timezone.utc 18 | ) 19 | 20 | headers_2 = { 21 | "x-ratelimit-limit": "300, 300", 22 | "x-ratelimit-remaining": "299, 299", 23 | "x-ratelimit-reset": "1598806800, 1598806800", 24 | } 25 | response = StreamResponse({}, headers_2, 200) 26 | assert response.rate_limit().limit == 300 27 | assert response.rate_limit().remaining == 299 28 | assert response.rate_limit().reset == datetime.fromtimestamp( 29 | 1598806800, timezone.utc 30 | ) 31 | 32 | headers_3 = { 33 | "x-ratelimit-limit": "garbage", 34 | "x-ratelimit-remaining": "garbage", 35 | "x-ratelimit-reset": "garbage", 36 | } 37 | response = StreamResponse({}, headers_3, 200) 38 | assert response.rate_limit().limit == 0 39 | assert response.rate_limit().remaining == 0 40 | assert response.rate_limit().reset == datetime.fromtimestamp(0, timezone.utc) 41 | -------------------------------------------------------------------------------- /stream_chat/base/campaign.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import datetime 3 | from typing import Awaitable, Optional, Union 4 | 5 | from stream_chat.base.client import StreamChatInterface 6 | from stream_chat.types.campaign import CampaignData 7 | from stream_chat.types.stream_response import StreamResponse 8 | 9 | 10 | class CampaignInterface(abc.ABC): 11 | def __init__( 12 | self, 13 | client: StreamChatInterface, 14 | campaign_id: Optional[str] = None, 15 | data: CampaignData = None, 16 | ): 17 | self.client = client 18 | self.campaign_id = campaign_id 19 | self.data = data 20 | 21 | @abc.abstractmethod 22 | def create( 23 | self, campaign_id: Optional[str], data: Optional[CampaignData] 24 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 25 | pass 26 | 27 | @abc.abstractmethod 28 | def get(self) -> Union[StreamResponse, Awaitable[StreamResponse]]: 29 | pass 30 | 31 | @abc.abstractmethod 32 | def update( 33 | self, data: CampaignData 34 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 35 | pass 36 | 37 | @abc.abstractmethod 38 | def delete(self) -> Union[StreamResponse, Awaitable[StreamResponse]]: 39 | pass 40 | 41 | @abc.abstractmethod 42 | def start( 43 | self, 44 | scheduled_for: Optional[Union[str, datetime.datetime]] = None, 45 | stop_at: Optional[Union[str, datetime.datetime]] = None, 46 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 47 | pass 48 | 49 | @abc.abstractmethod 50 | def stop(self) -> Union[StreamResponse, Awaitable[StreamResponse]]: 51 | pass 52 | -------------------------------------------------------------------------------- /stream_chat/types/delivery_receipts.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import Dict, List, Optional 3 | 4 | if sys.version_info >= (3, 8): 5 | from typing import TypedDict 6 | else: 7 | from typing_extensions import TypedDict 8 | 9 | 10 | class DeliveredMessageConfirmation(TypedDict): 11 | """ 12 | Confirmation of a delivered message. 13 | 14 | Parameters: 15 | cid: Channel CID (channel_type:channel_id) 16 | id: Message ID 17 | """ 18 | 19 | cid: str 20 | id: str 21 | 22 | 23 | class MarkDeliveredOptions(TypedDict, total=False): 24 | """ 25 | Options for marking messages as delivered. 26 | 27 | Parameters: 28 | latest_delivered_messages: List of delivered message confirmations 29 | user: Optional user object 30 | user_id: Optional user ID 31 | """ 32 | 33 | latest_delivered_messages: List[DeliveredMessageConfirmation] 34 | user: Optional[Dict] # UserResponse equivalent 35 | user_id: Optional[str] 36 | 37 | 38 | class ChannelReadStatus(TypedDict, total=False): 39 | """ 40 | Channel read status information. 41 | 42 | Parameters: 43 | last_read: Last read timestamp 44 | unread_messages: Number of unread messages 45 | user: User information 46 | first_unread_message_id: ID of first unread message 47 | last_read_message_id: ID of last read message 48 | last_delivered_at: Last delivered timestamp 49 | last_delivered_message_id: ID of last delivered message 50 | """ 51 | 52 | last_read: str # ISO format string for timestamp 53 | unread_messages: int 54 | user: Dict # UserResponse equivalent 55 | first_unread_message_id: Optional[str] 56 | last_read_message_id: Optional[str] 57 | last_delivered_at: Optional[str] # ISO format string for timestamp 58 | last_delivered_message_id: Optional[str] 59 | -------------------------------------------------------------------------------- /.github/workflows/initiate_release.yml: -------------------------------------------------------------------------------- 1 | name: Create release PR 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: "The new version number with 'v' prefix. Example: v1.40.1" 8 | required: true 9 | 10 | jobs: 11 | init_release: 12 | name: 🚀 Create release PR 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | with: 17 | fetch-depth: 0 # gives the changelog generator access to all previous commits 18 | 19 | - name: Update CHANGELOG.md, __pkg__.py and push release branch 20 | env: 21 | VERSION: ${{ github.event.inputs.version }} 22 | run: | 23 | npx --yes standard-version@9.3.2 --release-as "$VERSION" --skip.tag --skip.commit --tag-prefix=v 24 | git config --global user.name 'github-actions' 25 | git config --global user.email 'release@getstream.io' 26 | git checkout -q -b "release-$VERSION" 27 | git commit -am "chore(release): $VERSION" 28 | git push -q -u origin "release-$VERSION" 29 | 30 | - name: Get changelog diff 31 | uses: actions/github-script@v5 32 | with: 33 | script: | 34 | const get_change_log_diff = require('./scripts/get_changelog_diff.js') 35 | core.exportVariable('CHANGELOG', get_change_log_diff()) 36 | 37 | - name: Open pull request 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | run: | 41 | gh pr create \ 42 | -t "Release ${{ github.event.inputs.version }}" \ 43 | -b "# :rocket: ${{ github.event.inputs.version }} 44 | Make sure to use squash & merge when merging! 45 | Once this is merged, another job will kick off automatically and publish the package. 46 | # :memo: Changelog 47 | ${{ env.CHANGELOG }}" 48 | -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | STREAM MARK 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /stream_chat/async_chat/campaign.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import Any, Optional, Union 3 | 4 | from stream_chat.base.campaign import CampaignInterface 5 | from stream_chat.types.campaign import CampaignData 6 | from stream_chat.types.stream_response import StreamResponse 7 | 8 | 9 | class Campaign(CampaignInterface): 10 | async def create( 11 | self, campaign_id: Optional[str] = None, data: Optional[CampaignData] = None 12 | ) -> StreamResponse: 13 | if campaign_id is not None: 14 | self.campaign_id = campaign_id 15 | if data is not None: 16 | self.data = data 17 | state = await self.client.create_campaign( # type: ignore 18 | campaign_id=self.campaign_id, data=self.data 19 | ) 20 | 21 | if self.campaign_id is None and state.is_ok() and "campaign" in state: 22 | self.campaign_id = state["campaign"]["id"] 23 | return state 24 | 25 | async def get(self) -> StreamResponse: 26 | return await self.client.get_campaign( # type: ignore 27 | campaign_id=self.campaign_id 28 | ) 29 | 30 | async def update(self, data: CampaignData) -> StreamResponse: 31 | return await self.client.update_campaign( # type: ignore 32 | campaign_id=self.campaign_id, data=data 33 | ) 34 | 35 | async def delete(self, **options: Any) -> StreamResponse: 36 | return await self.client.delete_campaign( # type: ignore 37 | campaign_id=self.campaign_id, **options 38 | ) 39 | 40 | async def start( 41 | self, 42 | scheduled_for: Optional[Union[str, datetime.datetime]] = None, 43 | stop_at: Optional[Union[str, datetime.datetime]] = None, 44 | ) -> StreamResponse: 45 | return await self.client.start_campaign( # type: ignore 46 | campaign_id=self.campaign_id, scheduled_for=scheduled_for, stop_at=stop_at 47 | ) 48 | 49 | async def stop(self) -> StreamResponse: 50 | return await self.client.stop_campaign( # type: ignore 51 | campaign_id=self.campaign_id 52 | ) 53 | -------------------------------------------------------------------------------- /stream_chat/campaign.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import Any, Optional, Union 3 | 4 | from stream_chat.base.campaign import CampaignInterface 5 | from stream_chat.types.campaign import CampaignData 6 | from stream_chat.types.stream_response import StreamResponse 7 | 8 | 9 | class Campaign(CampaignInterface): 10 | def create( 11 | self, campaign_id: Optional[str] = None, data: Optional[CampaignData] = None 12 | ) -> StreamResponse: 13 | if campaign_id is not None: 14 | self.campaign_id = campaign_id 15 | if data is not None: 16 | self.data = self._merge_campaign_data(self.data, data) 17 | state = self.client.create_campaign( 18 | campaign_id=self.campaign_id, data=self.data 19 | ) 20 | 21 | if self.campaign_id is None and state.is_ok() and "campaign" in state: # type: ignore 22 | self.campaign_id = state["campaign"]["id"] # type: ignore 23 | return state # type: ignore 24 | 25 | def get(self) -> StreamResponse: 26 | return self.client.get_campaign(campaign_id=self.campaign_id) # type: ignore 27 | 28 | def update(self, data: CampaignData) -> StreamResponse: 29 | return self.client.update_campaign( # type: ignore 30 | campaign_id=self.campaign_id, data=data 31 | ) 32 | 33 | def delete(self, **options: Any) -> StreamResponse: 34 | return self.client.delete_campaign( # type: ignore 35 | campaign_id=self.campaign_id, **options 36 | ) 37 | 38 | def start( 39 | self, 40 | scheduled_for: Optional[Union[str, datetime.datetime]] = None, 41 | stop_at: Optional[Union[str, datetime.datetime]] = None, 42 | ) -> StreamResponse: 43 | return self.client.start_campaign( # type: ignore 44 | campaign_id=self.campaign_id, scheduled_for=scheduled_for, stop_at=stop_at 45 | ) 46 | 47 | def stop(self) -> StreamResponse: 48 | return self.client.stop_campaign(campaign_id=self.campaign_id) # type: ignore 49 | 50 | @staticmethod 51 | def _merge_campaign_data( 52 | data1: Optional[CampaignData], 53 | data2: Optional[CampaignData], 54 | ) -> CampaignData: 55 | if data1 is None: 56 | return data2 57 | if data2 is None: 58 | return data1 59 | data1.update(data2) # type: ignore 60 | return data1 61 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | install_requires = [ 4 | "requests>=2.22.0,<3", 5 | "aiodns>=2.0.0", 6 | "aiohttp>=3.6.0,<4", 7 | "aiofile>=3.1,<4", 8 | "pyjwt>=2.0.0,<3", 9 | "typing_extensions; python_version < '3.8'", 10 | ] 11 | tests_require = ["pytest == 8.1.1", "pytest-asyncio <= 0.21.1"] 12 | ci_require = [ 13 | "black", 14 | "flake8", 15 | "flake8-isort", 16 | "flake8-bugbear", 17 | "pytest-cov", 18 | "mypy", 19 | "types-requests", 20 | ] 21 | 22 | with open("README.md", "r") as f: 23 | long_description = f.read() 24 | 25 | about = {} 26 | 27 | with open("stream_chat/__pkg__.py") as fp: 28 | exec(fp.read(), about) 29 | 30 | setup( 31 | name="stream-chat", 32 | version=about["__version__"], 33 | author=about["__maintainer__"], 34 | author_email=about["__email__"], 35 | url="https://github.com/GetStream/stream-chat-python", 36 | project_urls={ 37 | "Bug Tracker": "https://github.com/GetStream/stream-chat-python/issues", 38 | "Documentation": "https://getstream.io/activity-feeds/docs/python/?language=python", 39 | "Release Notes": "https://github.com/GetStream/stream-chat-python/releases/tag/v{}".format( 40 | about["__version__"] 41 | ), 42 | }, 43 | description="Client for Stream Chat.", 44 | long_description=long_description, 45 | long_description_content_type="text/markdown", 46 | packages=find_packages(exclude=["*tests*"]), 47 | zip_safe=False, 48 | install_requires=install_requires, 49 | extras_require={"test": tests_require, "ci": ci_require}, 50 | include_package_data=True, 51 | python_requires=">=3.8", 52 | classifiers=[ 53 | "Intended Audience :: Developers", 54 | "Intended Audience :: System Administrators", 55 | "Operating System :: OS Independent", 56 | "Topic :: Software Development", 57 | "Development Status :: 5 - Production/Stable", 58 | "Natural Language :: English", 59 | "Programming Language :: Python :: 3", 60 | "Programming Language :: Python :: 3.8", 61 | "Programming Language :: Python :: 3.9", 62 | "Programming Language :: Python :: 3.10", 63 | "Programming Language :: Python :: 3.11", 64 | "Programming Language :: Python :: 3.12", 65 | "Topic :: Software Development :: Libraries :: Python Modules", 66 | ], 67 | ) 68 | -------------------------------------------------------------------------------- /stream_chat/tests/test_query_threads.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | import pytest 4 | 5 | from stream_chat import StreamChat 6 | from stream_chat.types.stream_response import StreamResponse 7 | 8 | 9 | @pytest.mark.incremental 10 | class TestQueryThreads: 11 | def test_query_threads(self, client: StreamChat, channel, random_user: Dict): 12 | parent_message = channel.send_message( 13 | {"text": "Parent message"}, random_user["id"] 14 | ) 15 | thread_message = channel.send_message( 16 | {"text": "Thread message", "parent_id": parent_message["message"]["id"]}, 17 | random_user["id"], 18 | ) 19 | 20 | filter_conditions = {"parent_message_id": parent_message["message"]["id"]} 21 | sort_conditions = [{"field": "created_at", "direction": -1}] 22 | 23 | response = client.query_threads( 24 | filter=filter_conditions, sort=sort_conditions, user_id=random_user["id"] 25 | ) 26 | 27 | assert isinstance(response, StreamResponse) 28 | assert "threads" in response 29 | assert len(response["threads"]) > 0 30 | 31 | thread = response["threads"][0] 32 | assert "latest_replies" in thread 33 | assert len(thread["latest_replies"]) > 0 34 | assert thread["latest_replies"][0]["text"] == thread_message["message"]["text"] 35 | 36 | def test_query_threads_with_options( 37 | self, client: StreamChat, channel, random_user: Dict 38 | ): 39 | for i in range(3): 40 | parent_msg = channel.send_message( 41 | {"text": f"Parent message {i}"}, random_user["id"] 42 | ) 43 | 44 | channel.send_message( 45 | { 46 | "text": f"Thread message {i}", 47 | "parent_id": parent_msg["message"]["id"], 48 | }, 49 | random_user["id"], 50 | ) 51 | 52 | filter_conditions = {"channel_cid": channel.cid} 53 | sort_conditions = [{"field": "created_at", "direction": -1}] 54 | 55 | response = client.query_threads( 56 | filter=filter_conditions, 57 | sort=sort_conditions, 58 | limit=1, 59 | user_id=random_user["id"], 60 | ) 61 | 62 | assert isinstance(response, StreamResponse) 63 | assert "threads" in response 64 | assert len(response["threads"]) == 1 65 | assert "next" in response 66 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | STREAM_KEY ?= NOT_EXIST 2 | STREAM_SECRET ?= NOT_EXIST 3 | PYTHON_VERSION ?= 3.8 4 | 5 | # These targets are not files 6 | .PHONY: help check test lint lint-fix test_with_docker lint_with_docker lint-fix_with_docker 7 | 8 | help: ## Display this help message 9 | @echo "Please use \`make \` where is one of" 10 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; \ 11 | {printf "\033[36m%-40s\033[0m %s\n", $$1, $$2}' 12 | 13 | lint: ## Run linters 14 | black --check stream_chat 15 | flake8 --ignore=E501,W503 stream_chat 16 | mypy stream_chat 17 | 18 | lint-fix: ## Fix linting issues 19 | black stream_chat 20 | isort stream_chat 21 | 22 | test: ## Run tests 23 | STREAM_KEY=$(STREAM_KEY) STREAM_SECRET=$(STREAM_SECRET) pytest --cov=stream_chat stream_chat/tests 24 | 25 | check: lint test ## Run linters + tests 26 | 27 | lint_with_docker: ## Run linters in Docker (set PYTHON_VERSION to change Python version) 28 | docker run -t -i -w /code -v $(PWD):/code python:$(PYTHON_VERSION) sh -c "pip install black flake8 mypy types-requests && black --check stream_chat && flake8 --ignore=E501,W503 stream_chat && mypy stream_chat || true" 29 | 30 | lint-fix_with_docker: ## Fix linting issues in Docker (set PYTHON_VERSION to change Python version) 31 | docker run -t -i -w /code -v $(PWD):/code python:$(PYTHON_VERSION) sh -c "pip install black isort && black stream_chat && isort stream_chat" 32 | 33 | test_with_docker: ## Run tests in Docker (set PYTHON_VERSION to change Python version) 34 | docker run -t -i -w /code -v $(PWD):/code --add-host=host.docker.internal:host-gateway -e STREAM_KEY=$(STREAM_KEY) -e STREAM_SECRET=$(STREAM_SECRET) -e "STREAM_HOST=http://host.docker.internal:3030" python:$(PYTHON_VERSION) sh -c "pip install -e .[test,ci] && sed -i 's/Optional\[datetime\]/Optional\[datetime.datetime\]/g' stream_chat/client.py && pytest --cov=stream_chat stream_chat/tests || true" 35 | 36 | check_with_docker: lint_with_docker test_with_docker ## Run linters + tests in Docker (set PYTHON_VERSION to change Python version) 37 | 38 | reviewdog: 39 | black --check --diff --quiet stream_chat | reviewdog -f=diff -f.diff.strip=0 -filter-mode="diff_context" -name=black -reporter=github-pr-review 40 | flake8 --ignore=E501,W503 stream_chat | reviewdog -f=flake8 -name=flake8 -reporter=github-pr-review 41 | mypy --show-column-numbers --show-absolute-path stream_chat | reviewdog -efm="%f:%l:%c: %t%*[^:]: %m" -name=mypy -reporter=github-pr-review 42 | -------------------------------------------------------------------------------- /stream_chat/types/stream_response.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | from typing import Any, Dict, Optional 3 | 4 | from stream_chat.types.rate_limit import RateLimitInfo 5 | 6 | 7 | class StreamResponse(dict): 8 | """ 9 | A custom dictionary where you can access the response data the same way 10 | as a normal dictionary. Additionally, we expose some methods to access other metadata. 11 | 12 | :: 13 | 14 | resp = client.get_app_settings() 15 | print(resp["app"]["webhook_url"]) 16 | # "https://mycompany.com/webhook" 17 | rate_limit = resp.rate_limit() 18 | print(rate_limit.remaining) 19 | # 99 20 | headers = resp.headers() 21 | print(headers["content-type"]) 22 | # "application/json;charset=utf-8" 23 | status_code = resp.status_code() 24 | print(status_code) 25 | # 200 26 | """ 27 | 28 | def __init__( 29 | self, response_dict: Dict[str, Any], headers: Dict[str, Any], status_code: int 30 | ) -> None: 31 | self.__headers = headers 32 | self.__status_code = status_code 33 | self.__rate_limit: Optional[RateLimitInfo] = None 34 | limit, remaining, reset = ( 35 | headers.get("x-ratelimit-limit"), 36 | headers.get("x-ratelimit-remaining"), 37 | headers.get("x-ratelimit-reset"), 38 | ) 39 | if limit and remaining and reset: 40 | self.__rate_limit = RateLimitInfo( 41 | limit=int(self._clean_header(limit)), 42 | remaining=int(self._clean_header(remaining)), 43 | reset=datetime.fromtimestamp( 44 | float(self._clean_header(reset)), timezone.utc 45 | ), 46 | ) 47 | 48 | super(StreamResponse, self).__init__(response_dict) 49 | 50 | def _clean_header(self, header: str) -> int: 51 | try: 52 | values = (v.strip() for v in header.split(",")) 53 | return int(next(v for v in values if v)) 54 | except ValueError: 55 | return 0 56 | 57 | def rate_limit(self) -> Optional[RateLimitInfo]: 58 | """Returns the ratelimit info of your API operation.""" 59 | return self.__rate_limit 60 | 61 | def headers(self) -> Dict[str, Any]: 62 | """Returns the headers of the response.""" 63 | return self.__headers 64 | 65 | def status_code(self) -> int: 66 | """Returns the HTTP status code of the response.""" 67 | return self.__status_code 68 | 69 | def is_ok(self) -> bool: 70 | """Returns True if the status code is in the 200 range.""" 71 | return 200 <= self.__status_code < 300 72 | -------------------------------------------------------------------------------- /stream_chat/base/segment.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Awaitable, Dict, List, Optional, Union 3 | 4 | from stream_chat.base.client import StreamChatInterface 5 | from stream_chat.types.base import SortParam 6 | from stream_chat.types.segment import ( 7 | QuerySegmentTargetsOptions, 8 | SegmentData, 9 | SegmentType, 10 | SegmentUpdatableFields, 11 | ) 12 | from stream_chat.types.stream_response import StreamResponse 13 | 14 | 15 | class SegmentInterface(abc.ABC): 16 | def __init__( 17 | self, 18 | client: StreamChatInterface, 19 | segment_type: SegmentType, 20 | segment_id: Optional[str] = None, 21 | data: Optional[SegmentData] = None, 22 | ): 23 | self.segment_type = segment_type 24 | self.segment_id = segment_id 25 | self.client = client 26 | self.data = data 27 | 28 | @abc.abstractmethod 29 | def create( 30 | self, segment_id: Optional[str] = None, data: Optional[SegmentData] = None 31 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 32 | pass 33 | 34 | @abc.abstractmethod 35 | def get(self) -> Union[StreamResponse, Awaitable[StreamResponse]]: 36 | pass 37 | 38 | @abc.abstractmethod 39 | def update( 40 | self, data: SegmentUpdatableFields 41 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 42 | pass 43 | 44 | @abc.abstractmethod 45 | def delete(self) -> Union[StreamResponse, Awaitable[StreamResponse]]: 46 | pass 47 | 48 | @abc.abstractmethod 49 | def target_exists( 50 | self, target_id: str 51 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 52 | pass 53 | 54 | @abc.abstractmethod 55 | def add_targets( 56 | self, target_ids: List[str] 57 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 58 | pass 59 | 60 | @abc.abstractmethod 61 | def query_targets( 62 | self, 63 | filter_conditions: Optional[Dict] = None, 64 | sort: Optional[List[SortParam]] = None, 65 | options: Optional[QuerySegmentTargetsOptions] = None, 66 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 67 | pass 68 | 69 | @abc.abstractmethod 70 | def remove_targets( 71 | self, target_ids: List[str] 72 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 73 | pass 74 | 75 | def verify_segment_id(self) -> None: 76 | if not self.segment_id: 77 | raise ValueError( 78 | "Segment id is missing. Either create the segment using segment.create() " 79 | "or set the id during instantiation - segment = Segment(segment_id=segment_id)" 80 | ) 81 | -------------------------------------------------------------------------------- /stream_chat/tests/async_chat/test_query_threads.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | import pytest 4 | 5 | from stream_chat.async_chat import StreamChatAsync 6 | from stream_chat.types.stream_response import StreamResponse 7 | 8 | 9 | @pytest.mark.incremental 10 | class TestQueryThreads: 11 | @pytest.mark.asyncio 12 | async def test_query_threads( 13 | self, client: StreamChatAsync, channel, random_user: Dict 14 | ): 15 | # Create a thread with some messages 16 | parent_message = await channel.send_message( 17 | {"text": "Parent message"}, random_user["id"] 18 | ) 19 | thread_message = await channel.send_message( 20 | {"text": "Thread message", "parent_id": parent_message["message"]["id"]}, 21 | random_user["id"], 22 | ) 23 | 24 | # Query threads with filter and sort 25 | filter_conditions = {"parent_message_id": parent_message["message"]["id"]} 26 | sort_conditions = [{"field": "created_at", "direction": -1}] 27 | 28 | response = await client.query_threads( 29 | filter=filter_conditions, sort=sort_conditions, user_id=random_user["id"] 30 | ) 31 | 32 | assert isinstance(response, StreamResponse) 33 | assert "threads" in response 34 | assert len(response["threads"]) > 0 35 | 36 | # Verify the thread message is in the response 37 | thread = response["threads"][0] 38 | assert "latest_replies" in thread 39 | assert len(thread["latest_replies"]) > 0 40 | assert thread["latest_replies"][0]["text"] == thread_message["message"]["text"] 41 | 42 | @pytest.mark.asyncio 43 | async def test_query_threads_with_options( 44 | self, client: StreamChatAsync, channel, random_user: Dict 45 | ): 46 | 47 | for i in range(3): 48 | parent_msg = await channel.send_message( 49 | {"text": f"Parent message {i}"}, random_user["id"] 50 | ) 51 | 52 | await channel.send_message( 53 | { 54 | "text": f"Thread message {i}", 55 | "parent_id": parent_msg["message"]["id"], 56 | }, 57 | random_user["id"], 58 | ) 59 | 60 | # Query threads with limit and offset 61 | filter_conditions = {"channel_cid": channel.cid} 62 | sort_conditions = [{"field": "created_at", "direction": -1}] 63 | 64 | response = await client.query_threads( 65 | filter=filter_conditions, 66 | sort=sort_conditions, 67 | limit=1, 68 | user_id=random_user["id"], 69 | ) 70 | 71 | assert isinstance(response, StreamResponse) 72 | assert "threads" in response 73 | assert len(response["threads"]) == 1 74 | assert "next" in response 75 | -------------------------------------------------------------------------------- /stream_chat/segment.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Optional 2 | 3 | from stream_chat.base.segment import SegmentInterface 4 | from stream_chat.types.base import SortParam 5 | from stream_chat.types.segment import ( 6 | QuerySegmentTargetsOptions, 7 | SegmentData, 8 | SegmentUpdatableFields, 9 | ) 10 | from stream_chat.types.stream_response import StreamResponse 11 | 12 | 13 | class Segment(SegmentInterface): 14 | def create( 15 | self, segment_id: Optional[str] = None, data: Optional[SegmentData] = None 16 | ) -> StreamResponse: 17 | if segment_id is not None: 18 | self.segment_id = segment_id 19 | if data is not None: 20 | self.data = data 21 | 22 | state = self.client.create_segment( 23 | segment_type=self.segment_type, segment_id=self.segment_id, data=self.data 24 | ) 25 | 26 | if self.segment_id is None and state.is_ok() and "segment" in state: # type: ignore 27 | self.segment_id = state["segment"]["id"] # type: ignore 28 | return state # type: ignore 29 | 30 | def get(self) -> StreamResponse: 31 | super().verify_segment_id() 32 | return self.client.get_segment(segment_id=self.segment_id) # type: ignore 33 | 34 | def update(self, data: SegmentUpdatableFields) -> StreamResponse: 35 | super().verify_segment_id() 36 | return self.client.update_segment( # type: ignore 37 | segment_id=self.segment_id, data=data 38 | ) 39 | 40 | def delete(self) -> StreamResponse: 41 | super().verify_segment_id() 42 | return self.client.delete_segment(segment_id=self.segment_id) # type: ignore 43 | 44 | def target_exists(self, target_id: str) -> StreamResponse: 45 | super().verify_segment_id() 46 | return self.client.segment_target_exists( # type: ignore 47 | segment_id=self.segment_id, target_id=target_id 48 | ) 49 | 50 | def add_targets(self, target_ids: list) -> StreamResponse: 51 | super().verify_segment_id() 52 | return self.client.add_segment_targets( # type: ignore 53 | segment_id=self.segment_id, target_ids=target_ids 54 | ) 55 | 56 | def query_targets( 57 | self, 58 | filter_conditions: Optional[Dict] = None, 59 | sort: Optional[List[SortParam]] = None, 60 | options: Optional[QuerySegmentTargetsOptions] = None, 61 | ) -> StreamResponse: 62 | super().verify_segment_id() 63 | return self.client.query_segment_targets( # type: ignore 64 | segment_id=self.segment_id, 65 | sort=sort, 66 | filter_conditions=filter_conditions, 67 | options=options, 68 | ) 69 | 70 | def remove_targets(self, target_ids: list) -> StreamResponse: 71 | super().verify_segment_id() 72 | return self.client.remove_segment_targets( # type: ignore 73 | segment_id=self.segment_id, target_ids=target_ids 74 | ) 75 | -------------------------------------------------------------------------------- /stream_chat/async_chat/segment.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Optional 2 | 3 | from stream_chat.base.segment import SegmentInterface 4 | from stream_chat.types.base import SortParam 5 | from stream_chat.types.segment import ( 6 | QuerySegmentTargetsOptions, 7 | SegmentData, 8 | SegmentUpdatableFields, 9 | ) 10 | from stream_chat.types.stream_response import StreamResponse 11 | 12 | 13 | class Segment(SegmentInterface): 14 | async def create( 15 | self, segment_id: Optional[str] = None, data: Optional[SegmentData] = None 16 | ) -> StreamResponse: 17 | if segment_id is not None: 18 | self.segment_id = segment_id 19 | if data is not None: 20 | self.data = data 21 | 22 | state = await self.client.create_segment( # type: ignore 23 | segment_type=self.segment_type, segment_id=self.segment_id, data=self.data 24 | ) 25 | 26 | if self.segment_id is None and state.is_ok() and "segment" in state: 27 | self.segment_id = state["segment"]["id"] 28 | return state 29 | 30 | async def get(self) -> StreamResponse: 31 | super().verify_segment_id() 32 | return await self.client.get_segment(segment_id=self.segment_id) # type: ignore 33 | 34 | async def update(self, data: SegmentUpdatableFields) -> StreamResponse: 35 | super().verify_segment_id() 36 | return await self.client.update_segment( # type: ignore 37 | segment_id=self.segment_id, data=data 38 | ) 39 | 40 | async def delete(self) -> StreamResponse: 41 | super().verify_segment_id() 42 | return await self.client.delete_segment( # type: ignore 43 | segment_id=self.segment_id 44 | ) 45 | 46 | async def target_exists(self, target_id: str) -> StreamResponse: 47 | super().verify_segment_id() 48 | return await self.client.segment_target_exists( # type: ignore 49 | segment_id=self.segment_id, target_id=target_id 50 | ) 51 | 52 | async def add_targets(self, target_ids: list) -> StreamResponse: 53 | super().verify_segment_id() 54 | return await self.client.add_segment_targets( # type: ignore 55 | segment_id=self.segment_id, target_ids=target_ids 56 | ) 57 | 58 | async def query_targets( 59 | self, 60 | filter_conditions: Optional[Dict] = None, 61 | sort: Optional[List[SortParam]] = None, 62 | options: Optional[QuerySegmentTargetsOptions] = None, 63 | ) -> StreamResponse: 64 | super().verify_segment_id() 65 | return await self.client.query_segment_targets( # type: ignore 66 | segment_id=self.segment_id, 67 | filter_conditions=filter_conditions, 68 | sort=sort, 69 | options=options, 70 | ) 71 | 72 | async def remove_targets(self, target_ids: list) -> StreamResponse: 73 | super().verify_segment_id() 74 | return await self.client.remove_segment_targets( # type: ignore 75 | segment_id=self.segment_id, target_ids=target_ids 76 | ) 77 | -------------------------------------------------------------------------------- /stream_chat/tests/test_live_locations.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import Dict 3 | 4 | import pytest 5 | 6 | from stream_chat import StreamChat 7 | 8 | 9 | @pytest.mark.incremental 10 | class TestLiveLocations: 11 | @pytest.fixture(autouse=True) 12 | def setup_channel_for_shared_locations(self, channel): 13 | channel.update_partial( 14 | {"config_overrides": {"shared_locations": True}}, 15 | ) 16 | yield 17 | channel.update_partial( 18 | {"config_overrides": {"shared_locations": False}}, 19 | ) 20 | 21 | def test_get_user_locations(self, client: StreamChat, channel, random_user: Dict): 22 | # Create a message to attach location to 23 | now = datetime.datetime.now(datetime.timezone.utc) 24 | one_hour_later = now + datetime.timedelta(hours=1) 25 | shared_location = { 26 | "created_by_device_id": "test_device_id", 27 | "latitude": 37.7749, 28 | "longitude": -122.4194, 29 | "end_at": one_hour_later.isoformat(), 30 | } 31 | 32 | channel.send_message( 33 | {"text": "Message with location", "shared_location": shared_location}, 34 | random_user["id"], 35 | ) 36 | 37 | # Get user locations 38 | response = client.get_user_locations(random_user["id"]) 39 | 40 | assert "active_live_locations" in response 41 | assert isinstance(response["active_live_locations"], list) 42 | 43 | def test_update_user_location(self, client: StreamChat, channel, random_user: Dict): 44 | # Create a message to attach location to 45 | now = datetime.datetime.now(datetime.timezone.utc) 46 | one_hour_later = now + datetime.timedelta(hours=1) 47 | shared_location = { 48 | "created_by_device_id": "test_device_id", 49 | "latitude": 37.7749, 50 | "longitude": -122.4194, 51 | "end_at": one_hour_later.isoformat(), 52 | } 53 | 54 | msg = channel.send_message( 55 | {"text": "Message with location", "shared_location": shared_location}, 56 | random_user["id"], 57 | ) 58 | message_id = msg["message"]["id"] 59 | 60 | # Update user location 61 | location_data = { 62 | "created_by_device_id": "test_device_id", 63 | "latitude": 37.7749, 64 | "longitude": -122.4194, 65 | } 66 | response = client.update_user_location( 67 | random_user["id"], message_id, location_data 68 | ) 69 | 70 | assert response["latitude"] == location_data["latitude"] 71 | assert response["longitude"] == location_data["longitude"] 72 | 73 | # Get user locations to verify 74 | locations_response = client.get_user_locations(random_user["id"]) 75 | assert "active_live_locations" in locations_response 76 | assert len(locations_response["active_live_locations"]) > 0 77 | location = locations_response["active_live_locations"][0] 78 | assert location["latitude"] == location_data["latitude"] 79 | assert location["longitude"] == location_data["longitude"] 80 | -------------------------------------------------------------------------------- /stream_chat/types/campaign.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import Dict, List, Optional 3 | 4 | if sys.version_info >= (3, 8): 5 | from typing import TypedDict 6 | else: 7 | from typing_extensions import TypedDict 8 | 9 | from stream_chat.types.base import Pager 10 | 11 | 12 | class MessageTemplate(TypedDict, total=False): 13 | """ 14 | Represents the data structure for a message template. 15 | 16 | Parameters: 17 | text: The text of the message. 18 | attachments: List of the message attachments. 19 | custom: Custom data. 20 | """ 21 | 22 | text: str 23 | attachments: Optional[List[Dict]] 24 | custom: Optional[Dict] 25 | 26 | 27 | class MemberTemplate(TypedDict, total=False): 28 | """ 29 | Represents the data structure for a member in the members_template. 30 | 31 | Parameters: 32 | user_id: The ID of the user. 33 | channel_role: The role of the user in the channel. 34 | custom: Custom data for the member. 35 | """ 36 | 37 | user_id: str 38 | channel_role: Optional[str] 39 | custom: Optional[Dict] 40 | 41 | 42 | class ChannelTemplate(TypedDict, total=False): 43 | """ 44 | Represents the data structure for a channel template. 45 | 46 | Parameters: 47 | type: The type of channel. 48 | id: The ID of the channel. 49 | members: List of member IDs. 50 | members_template: List of member templates with roles and custom data. 51 | custom: Custom data. 52 | """ 53 | 54 | type: str 55 | id: Optional[str] 56 | members: Optional[List[str]] 57 | members_template: Optional[List[MemberTemplate]] 58 | custom: Optional[Dict] 59 | 60 | 61 | class CampaignData(TypedDict, total=False): 62 | """ 63 | Represents the data structure for a campaign. 64 | 65 | Either `segment_ids` or `user_ids` must be provided, but not both. 66 | 67 | If `create_channels` is True, `channel_template` must be provided. 68 | 69 | Parameters: 70 | message_template: The template for the message to be sent in the campaign. 71 | sender_id: The ID of the user who is sending the campaign. 72 | segment_ids: List of segment IDs the campaign is targeting. 73 | user_ids: List of individual user IDs the campaign is targeting. 74 | create_channels: Flag to indicate if new channels should be created for the campaign. 75 | channel_template: The template for channels to be created, if applicable. 76 | name: The name of the campaign. 77 | description: A description of the campaign. 78 | skip_push: Flag to indicate if push notifications should be skipped. 79 | skip_webhook: Flag to indicate if webhooks should be skipped. 80 | """ 81 | 82 | message_template: MessageTemplate 83 | sender_id: str 84 | segment_ids: Optional[List[str]] 85 | user_ids: Optional[List[str]] 86 | create_channels: Optional[bool] 87 | channel_template: Optional[ChannelTemplate] 88 | name: Optional[str] 89 | description: Optional[str] 90 | skip_push: Optional[bool] 91 | skip_webhook: Optional[bool] 92 | 93 | 94 | class QueryCampaignsOptions(Pager, total=False): 95 | pass 96 | -------------------------------------------------------------------------------- /stream_chat/tests/async_chat/test_live_locations.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import Dict 3 | 4 | import pytest 5 | 6 | from stream_chat.async_chat.client import StreamChatAsync 7 | 8 | 9 | @pytest.mark.incremental 10 | class TestLiveLocations: 11 | @pytest.fixture(autouse=True) 12 | @pytest.mark.asyncio 13 | async def setup_channel_for_shared_locations(self, channel): 14 | await channel.update_partial( 15 | {"config_overrides": {"shared_locations": True}}, 16 | ) 17 | yield 18 | await channel.update_partial( 19 | {"config_overrides": {"shared_locations": False}}, 20 | ) 21 | 22 | async def test_get_user_locations( 23 | self, client: StreamChatAsync, channel, random_user: Dict 24 | ): 25 | # Create a message to attach location to 26 | now = datetime.datetime.now(datetime.timezone.utc) 27 | one_hour_later = now + datetime.timedelta(hours=1) 28 | shared_location = { 29 | "created_by_device_id": "test_device_id", 30 | "latitude": 37.7749, 31 | "longitude": -122.4194, 32 | "end_at": one_hour_later.isoformat(), 33 | } 34 | 35 | channel.send_message( 36 | {"text": "Message with location", "shared_location": shared_location}, 37 | random_user["id"], 38 | ) 39 | 40 | # Get user locations 41 | response = await client.get_user_locations(random_user["id"]) 42 | 43 | assert "active_live_locations" in response 44 | assert isinstance(response["active_live_locations"], list) 45 | 46 | async def test_update_user_location( 47 | self, client: StreamChatAsync, channel, random_user: Dict 48 | ): 49 | # Create a message to attach location to 50 | now = datetime.datetime.now(datetime.timezone.utc) 51 | one_hour_later = now + datetime.timedelta(hours=1) 52 | shared_location = { 53 | "created_by_device_id": "test_device_id", 54 | "latitude": 37.7749, 55 | "longitude": -122.4194, 56 | "end_at": one_hour_later.isoformat(), 57 | } 58 | 59 | msg = await channel.send_message( 60 | {"text": "Message with location", "shared_location": shared_location}, 61 | random_user["id"], 62 | ) 63 | message_id = msg["message"]["id"] 64 | 65 | # Update user location 66 | location_data = { 67 | "created_by_device_id": "test_device_id", 68 | "latitude": 37.7749, 69 | "longitude": -122.4194, 70 | } 71 | response = await client.update_user_location( 72 | random_user["id"], message_id, location_data 73 | ) 74 | 75 | assert response["latitude"] == location_data["latitude"] 76 | assert response["longitude"] == location_data["longitude"] 77 | 78 | # Get user locations to verify 79 | locations_response = await client.get_user_locations(random_user["id"]) 80 | assert "active_live_locations" in locations_response 81 | assert len(locations_response["active_live_locations"]) > 0 82 | location = locations_response["active_live_locations"][0] 83 | assert location["latitude"] == location_data["latitude"] 84 | assert location["longitude"] == location_data["longitude"] 85 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # :recycle: Contributing 2 | 3 | Contributions to this project are very much welcome, please make sure that your code changes are tested and that they follow Python best-practices. 4 | 5 | ## Getting started 6 | 7 | ### Install dependencies 8 | 9 | ```shell 10 | $ pip install ".[test, ci]" 11 | ``` 12 | 13 | ### Required environmental variables 14 | 15 | The tests require at least two environment variables: `STREAM_KEY` and `STREAM_SECRET`. There are multiple ways to provide that: 16 | - simply set it in your current shell (`export STREAM_KEY=xyz`) 17 | - you could use [direnv](https://direnv.net/) 18 | 19 | ### Run tests 20 | 21 | Make sure you can run the test suite. Tests are run via `pytest`. 22 | 23 | ```shell 24 | $ export STREAM_KEY=my_api_key 25 | $ export STREAM_SECRET=my_api_secret 26 | $ make test 27 | ``` 28 | 29 | > 💡 If you're on a Unix system, you could also use [direnv](https://direnv.net/) to set up these env vars. 30 | 31 | ### Run linters 32 | 33 | We use Black (code formatter), isort (code formatter), flake8 (linter) and mypy (static type checker) to ensure high code quality. To execute these checks, just run this command in your virtual environment: 34 | 35 | ```shell 36 | $ make lint 37 | ``` 38 | 39 | ### Using Docker for development 40 | 41 | You can also use Docker to run tests and linters without setting up a local Python environment. This is especially useful for ensuring consistent behavior across different environments. 42 | 43 | #### Available Docker targets 44 | 45 | - `lint_with_docker`: Run linters in Docker 46 | - `lint-fix_with_docker`: Fix linting issues in Docker 47 | - `test_with_docker`: Run tests in Docker 48 | - `check_with_docker`: Run both linters and tests in Docker 49 | 50 | #### Specifying Python version 51 | 52 | You can specify which Python version to use by setting the `PYTHON_VERSION` environment variable: 53 | 54 | ```shell 55 | $ PYTHON_VERSION=3.9 make lint_with_docker 56 | ``` 57 | 58 | The default Python version is 3.8 if not specified. 59 | 60 | #### Accessing host services from Docker 61 | 62 | When running tests in Docker, the container needs to access services running on your host machine (like a local Stream Chat server). The Docker targets use `host.docker.internal` to access the host machine, which is automatically configured with the `--add-host=host.docker.internal:host-gateway` flag. 63 | 64 | > ⚠️ **Note**: The `host.docker.internal` DNS name works on Docker for Mac, Docker for Windows, and recent versions of Docker for Linux. If you're using an older version of Docker for Linux, you might need to use your host's actual IP address instead. 65 | 66 | For tests that need to access a Stream Chat server running on your host machine, the Docker targets automatically set `STREAM_HOST=http://host.docker.internal:3030`. 67 | 68 | #### Examples 69 | 70 | Run linters in Docker: 71 | ```shell 72 | $ make lint_with_docker 73 | ``` 74 | 75 | Fix linting issues in Docker: 76 | ```shell 77 | $ make lint-fix_with_docker 78 | ``` 79 | 80 | Run tests in Docker: 81 | ```shell 82 | $ make test_with_docker 83 | ``` 84 | 85 | Run both linters and tests in Docker: 86 | ```shell 87 | $ make check_with_docker 88 | ``` 89 | 90 | ## Commit message convention 91 | 92 | Since we're autogenerating our [CHANGELOG](./CHANGELOG.md), we need to follow a specific commit message convention. 93 | You can read about conventional commits [here](https://www.conventionalcommits.org/). Here's how a usual commit message looks like for a new feature: `feat: allow provided config object to extend other configs`. A bugfix: `fix: prevent racing of requests`. 94 | 95 | ## Release (for Stream developers) 96 | 97 | Releasing this package involves two GitHub Action steps: 98 | 99 | - Kick off a job called `initiate_release` ([link](https://github.com/GetStream/stream-chat-python/actions/workflows/initiate_release.yml)). 100 | 101 | The job creates a pull request with the changelog. Check if it looks good. 102 | 103 | - Merge the pull request. 104 | 105 | Once the PR is merged, it automatically kicks off another job which will publish the package to Pypi, create the tag and created a GitHub release. -------------------------------------------------------------------------------- /stream_chat/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import uuid 3 | from typing import Dict, List 4 | 5 | import pytest 6 | 7 | from stream_chat import StreamChat 8 | 9 | 10 | def pytest_runtest_makereport(item, call): 11 | if "incremental" in item.keywords: 12 | if call.excinfo is not None: 13 | parent = item.parent 14 | parent._previousfailed = item 15 | 16 | 17 | def pytest_runtest_setup(item): 18 | if "incremental" in item.keywords: 19 | previousfailed = getattr(item.parent, "_previousfailed", None) 20 | if previousfailed is not None: 21 | pytest.xfail(f"previous test failed ({previousfailed.name})") 22 | 23 | 24 | def pytest_configure(config): 25 | config.addinivalue_line("markers", "incremental: mark test incremental") 26 | 27 | 28 | @pytest.fixture(scope="module") 29 | def client(): 30 | base_url = os.environ.get("STREAM_HOST") 31 | options = {"base_url": base_url} if base_url else {} 32 | return StreamChat( 33 | api_key=os.environ["STREAM_KEY"], 34 | api_secret=os.environ["STREAM_SECRET"], 35 | timeout=10, 36 | **options, 37 | ) 38 | 39 | 40 | @pytest.fixture(scope="function") 41 | def random_user(client: StreamChat): 42 | user = {"id": str(uuid.uuid4())} 43 | response = client.upsert_user(user) 44 | assert "users" in response 45 | assert user["id"] in response["users"] 46 | yield user 47 | hard_delete_users(client, [user["id"]]) 48 | 49 | 50 | @pytest.fixture(scope="function") 51 | def server_user(client: StreamChat): 52 | user = {"id": str(uuid.uuid4())} 53 | response = client.upsert_user(user) 54 | assert "users" in response 55 | assert user["id"] in response["users"] 56 | yield user 57 | hard_delete_users(client, [user["id"]]) 58 | 59 | 60 | @pytest.fixture(scope="function") 61 | def random_users(client: StreamChat): 62 | user1 = {"id": str(uuid.uuid4())} 63 | user2 = {"id": str(uuid.uuid4())} 64 | user3 = {"id": str(uuid.uuid4())} 65 | client.upsert_users([user1, user2, user3]) 66 | yield [user1, user2, user3] 67 | hard_delete_users(client, [user1["id"], user2["id"], user3["id"]]) 68 | 69 | 70 | @pytest.fixture(scope="function") 71 | def channel(client: StreamChat, random_user: Dict): 72 | channel = client.channel( 73 | "messaging", str(uuid.uuid4()), {"test": True, "language": "python"} 74 | ) 75 | channel.create(random_user["id"]) 76 | 77 | yield channel 78 | 79 | try: 80 | client.delete_channels([channel.cid], hard_delete=True) 81 | except Exception: 82 | pass 83 | 84 | 85 | @pytest.fixture(scope="function") 86 | def command(client: StreamChat): 87 | response = client.create_command( 88 | dict(name=str(uuid.uuid4()), description="My command") 89 | ) 90 | 91 | yield response["command"] 92 | 93 | client.delete_command(response["command"]["name"]) 94 | 95 | 96 | @pytest.fixture(scope="module") 97 | def fellowship_of_the_ring(client: StreamChat): 98 | members: List[Dict] = [ 99 | {"id": "frodo-baggins", "name": "Frodo Baggins", "race": "Hobbit", "age": 50}, 100 | {"id": "sam-gamgee", "name": "Samwise Gamgee", "race": "Hobbit", "age": 38}, 101 | {"id": "gandalf", "name": "Gandalf the Grey", "race": "Istari"}, 102 | {"id": "legolas", "name": "Legolas", "race": "Elf", "age": 500}, 103 | {"id": "gimli", "name": "Gimli", "race": "Dwarf", "age": 139}, 104 | {"id": "aragorn", "name": "Aragorn", "race": "Man", "age": 87}, 105 | {"id": "boromir", "name": "Boromir", "race": "Man", "age": 40}, 106 | { 107 | "id": "meriadoc-brandybuck", 108 | "name": "Meriadoc Brandybuck", 109 | "race": "Hobbit", 110 | "age": 36, 111 | }, 112 | {"id": "peregrin-took", "name": "Peregrin Took", "race": "Hobbit", "age": 28}, 113 | ] 114 | client.upsert_users(members) 115 | channel = client.channel( 116 | "team", "fellowship-of-the-ring", {"members": [m["id"] for m in members]} 117 | ) 118 | channel.create("gandalf") 119 | 120 | yield 121 | 122 | try: 123 | channel.delete(hard=True) 124 | hard_delete_users(client, [m["id"] for m in members]) 125 | except Exception: 126 | pass 127 | 128 | 129 | def hard_delete_users(client: StreamChat, user_ids: List[str]): 130 | try: 131 | client.delete_users(user_ids, "hard", conversations="hard", messages="hard") 132 | except Exception: 133 | pass 134 | -------------------------------------------------------------------------------- /stream_chat/tests/test_segment.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import pytest 4 | 5 | from stream_chat import StreamChat 6 | from stream_chat.types.base import SortOrder 7 | from stream_chat.types.segment import SegmentType 8 | 9 | 10 | @pytest.mark.incremental 11 | @pytest.mark.skip(reason="endpoints are not available in the API yet.") 12 | class TestSegment: 13 | def test_segment_crud(self, client: StreamChat): 14 | segment = client.segment( 15 | SegmentType.USER, 16 | data={ 17 | "name": "test_segment", 18 | "description": "test_description", 19 | }, 20 | ) 21 | created = segment.create() 22 | assert created.is_ok() 23 | assert "segment" in created 24 | assert "id" in created["segment"] 25 | assert "name" in created["segment"] 26 | 27 | received = segment.get() 28 | assert received.is_ok() 29 | assert "segment" in received 30 | assert "id" in received["segment"] 31 | assert "name" in received["segment"] 32 | assert received["segment"]["name"] == created["segment"]["name"] 33 | 34 | updated = segment.update( 35 | { 36 | "name": "updated_name", 37 | "description": "updated_description", 38 | } 39 | ) 40 | assert updated.is_ok() 41 | assert "segment" in updated 42 | assert "id" in updated["segment"] 43 | assert "name" in updated["segment"] 44 | assert updated["segment"]["name"] == "updated_name" 45 | assert updated["segment"]["description"] == "updated_description" 46 | 47 | deleted = segment.delete() 48 | assert deleted.is_ok() 49 | 50 | def test_segment_targets(self, client: StreamChat): 51 | segment = client.segment(segment_type=SegmentType.USER) 52 | created = segment.create() 53 | assert created.is_ok() 54 | assert "segment" in created 55 | assert "id" in created["segment"] 56 | assert "name" in created["segment"] 57 | 58 | target_ids = [str(uuid.uuid4()) for _ in range(10)] 59 | target_added = segment.add_targets(target_ids=target_ids) 60 | assert target_added.is_ok() 61 | 62 | target_exists = segment.target_exists(target_id=target_ids[0]) 63 | assert target_exists.is_ok() 64 | 65 | query_targets_1 = segment.query_targets( 66 | options={ 67 | "limit": 3, 68 | } 69 | ) 70 | assert query_targets_1.is_ok() 71 | assert "targets" in query_targets_1 72 | assert "next" in query_targets_1 73 | assert len(query_targets_1["targets"]) == 3 74 | 75 | query_targets_2 = segment.query_targets( 76 | filter_conditions={"target_id": {"$lte": ""}}, 77 | sort=[{"field": "target_id", "direction": SortOrder.DESC}], 78 | options={ 79 | "limit": 3, 80 | "next": query_targets_1["next"], 81 | }, 82 | ) 83 | assert query_targets_2.is_ok() 84 | assert "targets" in query_targets_2 85 | assert "next" in query_targets_2 86 | assert len(query_targets_2["targets"]) == 3 87 | 88 | target_deleted = segment.remove_targets(target_ids=target_ids) 89 | assert target_deleted.is_ok() 90 | 91 | deleted = segment.delete() 92 | assert deleted.is_ok() 93 | 94 | def test_query_segments(self, client: StreamChat): 95 | created = client.create_segment(segment_type=SegmentType.USER) 96 | assert created.is_ok() 97 | assert "segment" in created 98 | assert "id" in created["segment"] 99 | assert "name" in created["segment"] 100 | segment_id = created["segment"]["id"] 101 | 102 | target_ids = [str(uuid.uuid4()) for _ in range(10)] 103 | target_added = client.add_segment_targets( 104 | segment_id=segment_id, target_ids=target_ids 105 | ) 106 | assert target_added.is_ok() 107 | 108 | query_segments = client.query_segments( 109 | filter_conditions={"id": {"$eq": segment_id}}, 110 | sort=[{"field": "created_at", "direction": SortOrder.DESC}], 111 | ) 112 | assert query_segments.is_ok() 113 | assert "segments" in query_segments 114 | assert len(query_segments["segments"]) == 1 115 | 116 | target_deleted = client.remove_segment_targets( 117 | segment_id=segment_id, target_ids=target_ids 118 | ) 119 | assert target_deleted.is_ok() 120 | 121 | deleted = client.delete_segment(segment_id=segment_id) 122 | assert deleted.is_ok() 123 | -------------------------------------------------------------------------------- /stream_chat/tests/async_chat/test_segment.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import pytest 4 | 5 | from stream_chat.async_chat.client import StreamChatAsync 6 | from stream_chat.types.base import SortOrder 7 | from stream_chat.types.segment import SegmentType 8 | 9 | 10 | @pytest.mark.incremental 11 | @pytest.mark.skip(reason="endpoints are not available in the API yet.") 12 | class TestSegment: 13 | async def test_segment_crud(self, client: StreamChatAsync): 14 | segment = client.segment( 15 | SegmentType.USER, 16 | data={ 17 | "name": "test_segment", 18 | "description": "test_description", 19 | }, 20 | ) 21 | created = await segment.create() 22 | assert created.is_ok() 23 | assert "segment" in created 24 | assert "id" in created["segment"] 25 | assert "name" in created["segment"] 26 | 27 | received = await segment.get() 28 | assert received.is_ok() 29 | assert "segment" in received 30 | assert "id" in received["segment"] 31 | assert "name" in received["segment"] 32 | assert received["segment"]["name"] == created["segment"]["name"] 33 | 34 | updated = await segment.update( 35 | { 36 | "name": "updated_name", 37 | "description": "updated_description", 38 | } 39 | ) 40 | assert updated.is_ok() 41 | assert "segment" in updated 42 | assert "id" in updated["segment"] 43 | assert "name" in updated["segment"] 44 | assert updated["segment"]["name"] == "updated_name" 45 | assert updated["segment"]["description"] == "updated_description" 46 | 47 | deleted = await segment.delete() 48 | assert deleted.is_ok() 49 | 50 | async def test_segment_targets(self, client: StreamChatAsync): 51 | segment = client.segment(segment_type=SegmentType.USER) 52 | created = await segment.create() 53 | assert created.is_ok() 54 | assert "segment" in created 55 | assert "id" in created["segment"] 56 | assert "name" in created["segment"] 57 | 58 | target_ids = [str(uuid.uuid4()) for _ in range(10)] 59 | target_added = await segment.add_targets(target_ids=target_ids) 60 | assert target_added.is_ok() 61 | 62 | target_exists = await segment.target_exists(target_id=target_ids[0]) 63 | assert target_exists.is_ok() 64 | 65 | query_targets_1 = await segment.query_targets( 66 | options={ 67 | "limit": 3, 68 | }, 69 | ) 70 | assert query_targets_1.is_ok() 71 | assert "targets" in query_targets_1 72 | assert "next" in query_targets_1 73 | assert len(query_targets_1["targets"]) == 3 74 | 75 | query_targets_2 = await segment.query_targets( 76 | filter_conditions={"target_id": {"$lte": ""}}, 77 | sort=[{"field": "target_id", "direction": SortOrder.DESC}], 78 | options={ 79 | "limit": 3, 80 | "next": query_targets_1["next"], 81 | }, 82 | ) 83 | assert query_targets_2.is_ok() 84 | assert "targets" in query_targets_2 85 | assert "next" in query_targets_2 86 | assert len(query_targets_2["targets"]) == 3 87 | 88 | target_deleted = await segment.remove_targets(target_ids=target_ids) 89 | assert target_deleted.is_ok() 90 | 91 | deleted = await segment.delete() 92 | assert deleted.is_ok() 93 | 94 | async def test_query_segments(self, client: StreamChatAsync): 95 | created = await client.create_segment(segment_type=SegmentType.USER) 96 | assert created.is_ok() 97 | assert "segment" in created 98 | assert "id" in created["segment"] 99 | assert "name" in created["segment"] 100 | segment_id = created["segment"]["id"] 101 | 102 | target_ids = [str(uuid.uuid4()) for _ in range(10)] 103 | target_added = await client.add_segment_targets( 104 | segment_id=segment_id, target_ids=target_ids 105 | ) 106 | assert target_added.is_ok() 107 | 108 | query_segments = await client.query_segments( 109 | filter_conditions={"id": {"$eq": segment_id}}, 110 | sort=[{"field": "created_at", "direction": SortOrder.DESC}], 111 | ) 112 | assert query_segments.is_ok() 113 | assert "segments" in query_segments 114 | assert len(query_segments["segments"]) == 1 115 | 116 | target_deleted = await client.remove_segment_targets( 117 | segment_id=segment_id, target_ids=target_ids 118 | ) 119 | assert target_deleted.is_ok() 120 | 121 | deleted = await client.delete_segment(segment_id=segment_id) 122 | assert deleted.is_ok() 123 | -------------------------------------------------------------------------------- /stream_chat/tests/async_chat/conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | import uuid 4 | from typing import Dict, List 5 | 6 | import pytest 7 | 8 | from stream_chat.async_chat import StreamChatAsync 9 | 10 | 11 | def pytest_runtest_makereport(item, call): 12 | if "incremental" in item.keywords: 13 | if call.excinfo is not None: 14 | parent = item.parent 15 | parent._previousfailed = item 16 | 17 | 18 | def pytest_runtest_setup(item): 19 | if "incremental" in item.keywords: 20 | previousfailed = getattr(item.parent, "_previousfailed", None) 21 | if previousfailed is not None: 22 | pytest.xfail(f"previous test failed ({previousfailed.name})") 23 | 24 | 25 | def pytest_configure(config): 26 | config.addinivalue_line("markers", "incremental: mark test incremental") 27 | 28 | 29 | @pytest.fixture(scope="module") 30 | def event_loop(): 31 | loop = asyncio.get_event_loop_policy().new_event_loop() 32 | yield loop 33 | loop.close() 34 | 35 | 36 | @pytest.fixture(scope="function", autouse=True) 37 | @pytest.mark.asyncio 38 | async def client(): 39 | base_url = os.environ.get("STREAM_HOST") 40 | options = {"base_url": base_url} if base_url else {} 41 | async with StreamChatAsync( 42 | api_key=os.environ["STREAM_KEY"], 43 | api_secret=os.environ["STREAM_SECRET"], 44 | timeout=10, 45 | **options, 46 | ) as stream_client: 47 | yield stream_client 48 | 49 | 50 | @pytest.fixture(scope="function") 51 | async def random_user(client: StreamChatAsync): 52 | user = {"id": str(uuid.uuid4())} 53 | response = await client.upsert_user(user) 54 | assert "users" in response 55 | assert user["id"] in response["users"] 56 | yield user 57 | await hard_delete_users(client, [user["id"]]) 58 | 59 | 60 | @pytest.fixture(scope="function") 61 | async def server_user(client: StreamChatAsync): 62 | user = {"id": str(uuid.uuid4())} 63 | response = await client.upsert_user(user) 64 | assert "users" in response 65 | assert user["id"] in response["users"] 66 | yield user 67 | await hard_delete_users(client, [user["id"]]) 68 | 69 | 70 | @pytest.fixture(scope="function") 71 | async def random_users(client: StreamChatAsync): 72 | user1 = {"id": str(uuid.uuid4())} 73 | user2 = {"id": str(uuid.uuid4())} 74 | user3 = {"id": str(uuid.uuid4())} 75 | await client.upsert_users([user1, user2, user3]) 76 | yield [user1, user2, user3] 77 | await hard_delete_users(client, [user1["id"], user2["id"], user3["id"]]) 78 | 79 | 80 | @pytest.fixture(scope="function") 81 | async def channel(client: StreamChatAsync, random_user: Dict): 82 | channel = client.channel( 83 | "messaging", str(uuid.uuid4()), {"test": True, "language": "python"} 84 | ) 85 | await channel.create(random_user["id"]) 86 | yield channel 87 | 88 | try: 89 | await client.delete_channels([channel.cid], hard_delete=True) 90 | except Exception: 91 | pass 92 | 93 | 94 | @pytest.fixture(scope="function") 95 | async def command(client: StreamChatAsync): 96 | response = await client.create_command( 97 | dict(name=str(uuid.uuid4()), description="My command") 98 | ) 99 | 100 | yield response["command"] 101 | 102 | await client.delete_command(response["command"]["name"]) 103 | 104 | 105 | @pytest.fixture(scope="function") 106 | @pytest.mark.asyncio 107 | async def fellowship_of_the_ring(client: StreamChatAsync): 108 | members: List[Dict] = [ 109 | {"id": "frodo-baggins", "name": "Frodo Baggins", "race": "Hobbit", "age": 50}, 110 | {"id": "sam-gamgee", "name": "Samwise Gamgee", "race": "Hobbit", "age": 38}, 111 | {"id": "gandalf", "name": "Gandalf the Grey", "race": "Istari"}, 112 | {"id": "legolas", "name": "Legolas", "race": "Elf", "age": 500}, 113 | {"id": "gimli", "name": "Gimli", "race": "Dwarf", "age": 139}, 114 | {"id": "aragorn", "name": "Aragorn", "race": "Man", "age": 87}, 115 | {"id": "boromir", "name": "Boromir", "race": "Man", "age": 40}, 116 | { 117 | "id": "meriadoc-brandybuck", 118 | "name": "Meriadoc Brandybuck", 119 | "race": "Hobbit", 120 | "age": 36, 121 | }, 122 | {"id": "peregrin-took", "name": "Peregrin Took", "race": "Hobbit", "age": 28}, 123 | ] 124 | await client.upsert_users(members) 125 | channel = client.channel( 126 | "team", "fellowship-of-the-ring", {"members": [m["id"] for m in members]} 127 | ) 128 | await channel.create("gandalf") 129 | yield 130 | try: 131 | await channel.delete(hard=True) 132 | await hard_delete_users(client, [m["id"] for m in members]) 133 | except Exception: 134 | pass 135 | 136 | 137 | async def hard_delete_users(client: StreamChatAsync, user_ids: List[str]): 138 | try: 139 | await client.delete_users( 140 | user_ids, "hard", conversations="hard", messages="hard" 141 | ) 142 | except Exception: 143 | pass 144 | -------------------------------------------------------------------------------- /stream_chat/tests/test_draft.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from typing import Dict 3 | 4 | import pytest 5 | 6 | from stream_chat import StreamChat 7 | from stream_chat.channel import Channel 8 | from stream_chat.types.base import SortOrder 9 | 10 | 11 | @pytest.mark.incremental 12 | class TestDraft: 13 | def test_create_draft(self, channel: Channel, random_user: Dict): 14 | draft_message = {"text": "This is a draft message"} 15 | response = channel.create_draft(draft_message, random_user["id"]) 16 | 17 | assert "draft" in response 18 | assert response["draft"]["message"]["text"] == "This is a draft message" 19 | assert response["draft"]["channel_cid"] == channel.cid 20 | 21 | def test_get_draft(self, channel: Channel, random_user: Dict): 22 | # First create a draft 23 | draft_message = {"text": "This is a draft to retrieve"} 24 | channel.create_draft(draft_message, random_user["id"]) 25 | 26 | # Then get the draft 27 | response = channel.get_draft(random_user["id"]) 28 | 29 | assert "draft" in response 30 | assert response["draft"]["message"]["text"] == "This is a draft to retrieve" 31 | assert response["draft"]["channel_cid"] == channel.cid 32 | 33 | def test_delete_draft(self, channel: Channel, random_user: Dict): 34 | # First create a draft 35 | draft_message = {"text": "This is a draft to delete"} 36 | channel.create_draft(draft_message, random_user["id"]) 37 | 38 | # Then delete the draft 39 | channel.delete_draft(random_user["id"]) 40 | 41 | # Verify it's deleted by trying to get it 42 | try: 43 | channel.get_draft(random_user["id"]) 44 | raise AssertionError("Draft should be deleted") 45 | except Exception: 46 | # Expected behavior, draft should not be found 47 | pass 48 | 49 | def test_thread_draft(self, channel: Channel, random_user: Dict): 50 | # First create a parent message 51 | msg = channel.send_message({"text": "Parent message"}, random_user["id"]) 52 | parent_id = msg["message"]["id"] 53 | 54 | # Create a draft reply 55 | draft_reply = {"text": "This is a draft reply", "parent_id": parent_id} 56 | response = channel.create_draft(draft_reply, random_user["id"]) 57 | 58 | assert "draft" in response 59 | assert response["draft"]["message"]["text"] == "This is a draft reply" 60 | assert response["draft"]["parent_id"] == parent_id 61 | 62 | # Get the draft reply 63 | response = channel.get_draft(random_user["id"], parent_id=parent_id) 64 | 65 | assert "draft" in response 66 | assert response["draft"]["message"]["text"] == "This is a draft reply" 67 | assert response["draft"]["parent_id"] == parent_id 68 | 69 | # Delete the draft reply 70 | channel.delete_draft(random_user["id"], parent_id=parent_id) 71 | 72 | # Verify it's deleted 73 | try: 74 | channel.get_draft(random_user["id"], parent_id=parent_id) 75 | raise AssertionError("Thread draft should be deleted") 76 | except Exception: 77 | # Expected behavior 78 | pass 79 | 80 | def test_query_drafts( 81 | self, client: StreamChat, channel: Channel, random_user: Dict 82 | ): 83 | # Create multiple drafts in different channels 84 | draft1 = {"text": "Draft in channel 1"} 85 | channel.create_draft(draft1, random_user["id"]) 86 | 87 | # Create another channel with a draft 88 | channel2 = client.channel("messaging", str(uuid.uuid4())) 89 | channel2.create(random_user["id"]) 90 | 91 | draft2 = {"text": "Draft in channel 2"} 92 | channel2.create_draft(draft2, random_user["id"]) 93 | 94 | # Query all drafts for the user 95 | response = client.query_drafts(random_user["id"]) 96 | 97 | assert "drafts" in response 98 | assert len(response["drafts"]) == 2 99 | 100 | # Query drafts for a specific channel 101 | response = client.query_drafts( 102 | random_user["id"], filter={"channel_cid": channel2.cid} 103 | ) 104 | 105 | assert "drafts" in response 106 | assert len(response["drafts"]) == 1 107 | draft = response["drafts"][0] 108 | assert draft["channel_cid"] == channel2.cid 109 | assert draft["message"]["text"] == "Draft in channel 2" 110 | 111 | # Query drafts with sort 112 | response = client.query_drafts( 113 | random_user["id"], 114 | sort=[{"field": "created_at", "direction": SortOrder.ASC}], 115 | ) 116 | 117 | assert "drafts" in response 118 | assert len(response["drafts"]) == 2 119 | assert response["drafts"][0]["channel_cid"] == channel.cid 120 | assert response["drafts"][1]["channel_cid"] == channel2.cid 121 | 122 | # Query drafts with pagination 123 | response = client.query_drafts( 124 | random_user["id"], 125 | options={"limit": 1}, 126 | ) 127 | 128 | assert "drafts" in response 129 | assert len(response["drafts"]) == 1 130 | assert response["drafts"][0]["channel_cid"] == channel2.cid 131 | 132 | assert response["next"] is not None 133 | 134 | # Query drafts with pagination 135 | response = client.query_drafts( 136 | random_user["id"], 137 | options={"limit": 1, "next": response["next"]}, 138 | ) 139 | 140 | assert "drafts" in response 141 | assert len(response["drafts"]) == 1 142 | assert response["drafts"][0]["channel_cid"] == channel.cid 143 | 144 | # cleanup 145 | try: 146 | channel2.delete() 147 | except Exception: 148 | pass 149 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Official Python SDK for [Stream Chat](https://getstream.io/chat/) 2 | 3 | [![build](https://github.com/GetStream/stream-chat-python/workflows/build/badge.svg)](https://github.com/GetStream/stream-chat-python/actions) [![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) [![Checked with mypy](http://www.mypy-lang.org/static/mypy_badge.svg)](http://mypy-lang.org/) 4 | 5 |

6 | 7 |

8 |

9 | Official Python API client for Stream Chat, a service for building chat applications. 10 |
11 | Explore the docs » 12 |
13 |
14 | Code Samples 15 | · 16 | Report Bug 17 | · 18 | Request Feature 19 |

20 | 21 | --- 22 | > ### :bulb: Major update in v4.0 < 23 | > The returned response objects are instances of [`StreamResponse`](https://github.com/GetStream/stream-chat-python/blob/master/stream_chat/types/stream_response.py) class. It inherits from `dict`, so it's fully backward compatible. Additionally, it provides other benefits such as rate limit information (`resp.rate_limit()`), response headers (`resp.headers()`) or status code (`resp.status_code()`). 24 | --- 25 | 26 | ## 📝 About Stream 27 | 28 | You can sign up for a Stream account at our [Get Started](https://getstream.io/chat/get_started/) page. 29 | 30 | You can use this library to access chat API endpoints server-side. 31 | 32 | For the client-side integrations (web and mobile) have a look at the JavaScript, iOS and Android SDK libraries ([docs](https://getstream.io/chat/)). 33 | 34 | ## ⚙️ Installation 35 | 36 | ```shell 37 | $ pip install stream-chat 38 | ``` 39 | 40 | ## ✨ Getting started 41 | 42 | > :bulb: The library is almost 100% typed. Feel free to enable [mypy](https://github.com/python/mypy) for our library. We will introduce more improvements in the future in this area. 43 | 44 | ```python 45 | from stream_chat import StreamChat 46 | 47 | chat = StreamChat(api_key="STREAM_KEY", api_secret="STREAM_SECRET") 48 | 49 | # add a user 50 | chat.upsert_user({"id": "chuck", "name": "Chuck"}) 51 | 52 | # create a channel about kung-fu 53 | channel = chat.channel("messaging", "kung-fu") 54 | channel.create("chuck") 55 | 56 | # add a first message to the channel 57 | channel.send_message({"text": "AMA about kung-fu"}, "chuck") 58 | 59 | # we also expose some response metadata through a custom dictionary 60 | resp = chat.deactivate_user("bruce_lee") 61 | 62 | print(type(resp)) # 63 | print(resp["user"]["id"]) # bruce_lee 64 | 65 | rate_limit = resp.rate_limit() 66 | print(f"{rate_limit.limit} / {rate_limit.remaining} / {rate_limit.reset}") # 60 / 59 /2022-01-06 12:35:00+00:00 67 | 68 | headers = resp.headers() 69 | print(headers) # { 'Content-Encoding': 'gzip', 'Content-Length': '33', ... } 70 | 71 | status_code = resp.status_code() 72 | print(status_code) # 200 73 | 74 | ``` 75 | 76 | ### Async 77 | 78 | ```python 79 | import asyncio 80 | from stream_chat import StreamChatAsync 81 | 82 | 83 | async def main(): 84 | async with StreamChatAsync(api_key="STREAM_KEY", api_secret="STREAM_SECRET") as chat: 85 | # add a user 86 | await chat.upsert_user({"id": "chuck", "name": "Chuck"}) 87 | 88 | # create a channel about kung-fu 89 | channel = chat.channel("messaging", "kung-fu") 90 | await channel.create("chuck") 91 | 92 | # add a first message to the channel 93 | await channel.send_message({"text": "AMA about kung-fu"}, "chuck") 94 | 95 | # we also expose some response metadata through a custom dictionary 96 | resp = await chat.deactivate_user("bruce_lee") 97 | print(type(resp)) # 98 | print(resp["user"]["id"]) # bruce_lee 99 | 100 | rate_limit = resp.rate_limit() 101 | print(f"{rate_limit.limit} / {rate_limit.remaining} / {rate_limit.reset}") # 60 / 59 / 2022-01-06 12:35:00+00:00 102 | 103 | headers = resp.headers() 104 | print(headers) # { 'Content-Encoding': 'gzip', 'Content-Length': '33', ... } 105 | 106 | status_code = resp.status_code() 107 | print(status_code) # 200 108 | 109 | 110 | if __name__ == '__main__': 111 | loop = asyncio.get_event_loop() 112 | try: 113 | loop.run_until_complete(main()) 114 | finally: 115 | loop.run_until_complete(loop.shutdown_asyncgens()) 116 | loop.close() 117 | 118 | ``` 119 | 120 | ## ✍️ Contributing 121 | 122 | We welcome code changes that improve this library or fix a problem, please make sure to follow all best practices and add tests if applicable before submitting a Pull Request on Github. We are very happy to merge your code in the official repository. Make sure to sign our [Contributor License Agreement (CLA)](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) first. See our [license file](./LICENSE) for more details. 123 | 124 | Head over to [CONTRIBUTING.md](./CONTRIBUTING.md) for some development tips. 125 | 126 | ## 🧑‍💻 We are hiring! 127 | 128 | We've recently closed a [$38 million Series B funding round](https://techcrunch.com/2021/03/04/stream-raises-38m-as-its-chat-and-activity-feed-apis-power-communications-for-1b-users/) and we keep actively growing. 129 | Our APIs are used by more than a billion end-users, and you'll have a chance to make a huge impact on the product within a team of the strongest engineers all over the world. 130 | 131 | Check out our current openings and apply via [Stream's website](https://getstream.io/team/#jobs). 132 | -------------------------------------------------------------------------------- /stream_chat/tests/async_chat/test_draft.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from typing import Dict 3 | 4 | import pytest 5 | 6 | from stream_chat.async_chat.channel import Channel 7 | from stream_chat.async_chat.client import StreamChatAsync 8 | from stream_chat.types.base import SortOrder 9 | 10 | 11 | @pytest.mark.incremental 12 | class TestDraft: 13 | async def test_create_draft(self, channel: Channel, random_user: Dict): 14 | draft_message = {"text": "This is a draft message"} 15 | response = await channel.create_draft(draft_message, random_user["id"]) 16 | 17 | assert "draft" in response 18 | assert response["draft"]["message"]["text"] == "This is a draft message" 19 | assert response["draft"]["channel_cid"] == channel.cid 20 | 21 | async def test_get_draft(self, channel: Channel, random_user: Dict): 22 | # First create a draft 23 | draft_message = {"text": "This is a draft to retrieve"} 24 | await channel.create_draft(draft_message, random_user["id"]) 25 | 26 | # Then get the draft 27 | response = await channel.get_draft(random_user["id"]) 28 | 29 | assert "draft" in response 30 | assert response["draft"]["message"]["text"] == "This is a draft to retrieve" 31 | assert response["draft"]["channel_cid"] == channel.cid 32 | 33 | async def test_delete_draft(self, channel: Channel, random_user: Dict): 34 | # First create a draft 35 | draft_message = {"text": "This is a draft to delete"} 36 | await channel.create_draft(draft_message, random_user["id"]) 37 | 38 | # Then delete the draft 39 | await channel.delete_draft(random_user["id"]) 40 | 41 | # Verify it's deleted by trying to get it 42 | try: 43 | await channel.get_draft(random_user["id"]) 44 | raise AssertionError("Draft should be deleted") 45 | except Exception: 46 | # Expected behavior, draft should not be found 47 | pass 48 | 49 | async def test_thread_draft(self, channel: Channel, random_user: Dict): 50 | # First create a parent message 51 | msg = await channel.send_message({"text": "Parent message"}, random_user["id"]) 52 | parent_id = msg["message"]["id"] 53 | 54 | # Create a draft reply 55 | draft_reply = {"text": "This is a draft reply", "parent_id": parent_id} 56 | response = await channel.create_draft(draft_reply, random_user["id"]) 57 | 58 | assert "draft" in response 59 | assert response["draft"]["message"]["text"] == "This is a draft reply" 60 | assert response["draft"]["parent_id"] == parent_id 61 | 62 | # Get the draft reply 63 | response = await channel.get_draft(random_user["id"], parent_id=parent_id) 64 | 65 | assert "draft" in response 66 | assert response["draft"]["message"]["text"] == "This is a draft reply" 67 | assert response["draft"]["parent_id"] == parent_id 68 | 69 | # Delete the draft reply 70 | await channel.delete_draft(random_user["id"], parent_id=parent_id) 71 | 72 | # Verify it's deleted 73 | try: 74 | await channel.get_draft(random_user["id"], parent_id=parent_id) 75 | raise AssertionError("Thread draft should be deleted") 76 | except Exception: 77 | # Expected behavior 78 | pass 79 | 80 | async def test_query_drafts( 81 | self, client: StreamChatAsync, channel: Channel, random_user: Dict 82 | ): 83 | # Create multiple drafts in different channels 84 | draft1 = {"text": "Draft in channel 1"} 85 | await channel.create_draft(draft1, random_user["id"]) 86 | 87 | # Create another channel with a draft 88 | channel2 = client.channel("messaging", str(uuid.uuid4())) 89 | await channel2.create(random_user["id"]) 90 | 91 | draft2 = {"text": "Draft in channel 2"} 92 | await channel2.create_draft(draft2, random_user["id"]) 93 | 94 | # Query all drafts for the user 95 | response = await client.query_drafts(random_user["id"]) 96 | 97 | assert "drafts" in response 98 | assert len(response["drafts"]) == 2 99 | 100 | # Query drafts for a specific channel 101 | response = await client.query_drafts( 102 | random_user["id"], filter={"channel_cid": channel2.cid} 103 | ) 104 | 105 | assert "drafts" in response 106 | assert len(response["drafts"]) == 1 107 | draft = response["drafts"][0] 108 | assert draft["channel_cid"] == channel2.cid 109 | assert draft["message"]["text"] == "Draft in channel 2" 110 | 111 | # Query drafts with sort 112 | response = await client.query_drafts( 113 | random_user["id"], 114 | sort=[{"field": "created_at", "direction": SortOrder.ASC}], 115 | ) 116 | 117 | assert "drafts" in response 118 | assert len(response["drafts"]) == 2 119 | assert response["drafts"][0]["channel_cid"] == channel.cid 120 | assert response["drafts"][1]["channel_cid"] == channel2.cid 121 | 122 | # Query drafts with pagination 123 | response = await client.query_drafts( 124 | random_user["id"], 125 | options={"limit": 1}, 126 | ) 127 | 128 | assert "drafts" in response 129 | assert len(response["drafts"]) == 1 130 | assert response["drafts"][0]["channel_cid"] == channel2.cid 131 | 132 | assert response["next"] is not None 133 | 134 | # Query drafts with pagination 135 | response = await client.query_drafts( 136 | random_user["id"], 137 | options={"limit": 1, "next": response["next"]}, 138 | ) 139 | 140 | assert "drafts" in response 141 | assert len(response["drafts"]) == 1 142 | assert response["drafts"][0]["channel_cid"] == channel.cid 143 | 144 | # Cleanup 145 | try: 146 | await channel2.delete() 147 | except Exception: 148 | pass 149 | -------------------------------------------------------------------------------- /stream_chat/tests/async_chat/test_campaign.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import Dict 3 | 4 | import pytest 5 | 6 | from stream_chat import StreamChatAsync 7 | from stream_chat.types.base import SortOrder 8 | from stream_chat.types.segment import SegmentType 9 | 10 | 11 | @pytest.mark.incremental 12 | @pytest.mark.skip(reason="endpoints are not available in the API yet.") 13 | class TestCampaign: 14 | async def test_campaign_crud(self, client: StreamChatAsync, random_user: Dict): 15 | segment = await client.create_segment(segment_type=SegmentType.USER) 16 | segment_id = segment["segment"]["id"] 17 | 18 | sender_id = random_user["id"] 19 | 20 | campaign = client.campaign( 21 | data={ 22 | "message_template": { 23 | "text": "{Hello}", 24 | }, 25 | "segment_ids": [segment_id], 26 | "sender_id": sender_id, 27 | "name": "some name", 28 | } 29 | ) 30 | created = await campaign.create() 31 | assert created.is_ok() 32 | assert "campaign" in created 33 | assert "id" in created["campaign"] 34 | assert "name" in created["campaign"] 35 | 36 | received = await campaign.get() 37 | assert received.is_ok() 38 | assert "campaign" in received 39 | assert "id" in received["campaign"] 40 | assert "name" in received["campaign"] 41 | assert received["campaign"]["name"] == created["campaign"]["name"] 42 | 43 | updated = await campaign.update( 44 | { 45 | "message_template": { 46 | "text": "{Hello}", 47 | }, 48 | "segment_ids": [segment_id], 49 | "sender_id": sender_id, 50 | "name": "updated_name", 51 | } 52 | ) 53 | assert updated.is_ok() 54 | assert "campaign" in updated 55 | assert "id" in updated["campaign"] 56 | assert "name" in updated["campaign"] 57 | assert updated["campaign"]["name"] == "updated_name" 58 | 59 | deleted = await campaign.delete() 60 | assert deleted.is_ok() 61 | 62 | await client.delete_segment(segment_id=segment_id) 63 | 64 | async def test_campaign_start_stop( 65 | self, client: StreamChatAsync, random_user: Dict 66 | ): 67 | segment = await client.create_segment(segment_type=SegmentType.USER) 68 | segment_id = segment["segment"]["id"] 69 | 70 | sender_id = random_user["id"] 71 | 72 | target_added = await client.add_segment_targets( 73 | segment_id=segment_id, target_ids=[sender_id] 74 | ) 75 | assert target_added.is_ok() 76 | 77 | campaign = client.campaign( 78 | data={ 79 | "message_template": { 80 | "text": "{Hello}", 81 | }, 82 | "segment_ids": [segment_id], 83 | "sender_id": sender_id, 84 | "name": "some name", 85 | } 86 | ) 87 | created = await campaign.create() 88 | assert created.is_ok() 89 | assert "campaign" in created 90 | assert "id" in created["campaign"] 91 | assert "name" in created["campaign"] 92 | 93 | now = datetime.datetime.now(datetime.timezone.utc) 94 | one_hour_later = now + datetime.timedelta(hours=1) 95 | two_hours_later = now + datetime.timedelta(hours=2) 96 | 97 | started = await campaign.start( 98 | scheduled_for=one_hour_later, stop_at=two_hours_later 99 | ) 100 | assert started.is_ok() 101 | assert "campaign" in started 102 | assert "id" in started["campaign"] 103 | assert "name" in started["campaign"] 104 | 105 | stopped = await campaign.stop() 106 | assert stopped.is_ok() 107 | assert "campaign" in stopped 108 | assert "id" in stopped["campaign"] 109 | assert "name" in stopped["campaign"] 110 | 111 | deleted = await campaign.delete() 112 | assert deleted.is_ok() 113 | 114 | await client.delete_segment(segment_id=segment_id) 115 | 116 | async def test_query_campaigns(self, client: StreamChatAsync, random_user: Dict): 117 | segment_created = await client.create_segment(segment_type=SegmentType.USER) 118 | segment_id = segment_created["segment"]["id"] 119 | 120 | sender_id = random_user["id"] 121 | 122 | target_added = await client.add_segment_targets( 123 | segment_id=segment_id, target_ids=[sender_id] 124 | ) 125 | assert target_added.is_ok() 126 | 127 | created = await client.create_campaign( 128 | data={ 129 | "message_template": { 130 | "text": "{Hello}", 131 | }, 132 | "segment_ids": [segment_id], 133 | "sender_id": sender_id, 134 | "name": "some name", 135 | } 136 | ) 137 | assert created.is_ok() 138 | assert "campaign" in created 139 | assert "id" in created["campaign"] 140 | assert "name" in created["campaign"] 141 | campaign_id = created["campaign"]["id"] 142 | 143 | query_campaigns = await client.query_campaigns( 144 | filter_conditions={ 145 | "id": { 146 | "$eq": campaign_id, 147 | } 148 | }, 149 | sort=[{"field": "created_at", "direction": SortOrder.DESC}], 150 | options={ 151 | "limit": 10, 152 | }, 153 | ) 154 | assert query_campaigns.is_ok() 155 | assert "campaigns" in query_campaigns 156 | assert len(query_campaigns["campaigns"]) == 1 157 | assert query_campaigns["campaigns"][0]["id"] == campaign_id 158 | 159 | deleted = await client.delete_campaign(campaign_id=campaign_id) 160 | assert deleted.is_ok() 161 | 162 | segment_deleted = await client.delete_segment(segment_id=segment_id) 163 | assert segment_deleted.is_ok() 164 | -------------------------------------------------------------------------------- /stream_chat/tests/test_campaign.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import Dict 3 | 4 | import pytest 5 | 6 | from stream_chat import StreamChat 7 | from stream_chat.types.base import SortOrder 8 | from stream_chat.types.segment import SegmentType 9 | 10 | 11 | @pytest.mark.incremental 12 | @pytest.mark.skip(reason="endpoints are not available in the API yet.") 13 | class TestCampaign: 14 | def test_campaign_crud(self, client: StreamChat, random_user: Dict): 15 | segment = client.create_segment(segment_type=SegmentType.USER) 16 | segment_id = segment["segment"]["id"] 17 | 18 | sender_id = random_user["id"] 19 | 20 | campaign = client.campaign( 21 | data={ 22 | "message_template": { 23 | "text": "{Hello}", 24 | }, 25 | "segment_ids": [segment_id], 26 | "sender_id": sender_id, 27 | "name": "some name", 28 | } 29 | ) 30 | created = campaign.create( 31 | data={ 32 | "name": "created name", 33 | } 34 | ) 35 | assert created.is_ok() 36 | assert "campaign" in created 37 | assert "id" in created["campaign"] 38 | assert "name" in created["campaign"] 39 | assert created["campaign"]["name"] == "created name" 40 | 41 | received = campaign.get() 42 | assert received.is_ok() 43 | assert "campaign" in received 44 | assert "id" in received["campaign"] 45 | assert "name" in received["campaign"] 46 | assert received["campaign"]["name"] == created["campaign"]["name"] 47 | 48 | updated = campaign.update( 49 | { 50 | "message_template": { 51 | "text": "{Hello}", 52 | }, 53 | "segment_ids": [segment_id], 54 | "sender_id": sender_id, 55 | "name": "updated_name", 56 | } 57 | ) 58 | assert updated.is_ok() 59 | assert "campaign" in updated 60 | assert "id" in updated["campaign"] 61 | assert "name" in updated["campaign"] 62 | assert updated["campaign"]["name"] == "updated_name" 63 | 64 | deleted = campaign.delete() 65 | assert deleted.is_ok() 66 | 67 | segment_deleted = client.delete_segment(segment_id=segment_id) 68 | assert segment_deleted.is_ok() 69 | 70 | def test_campaign_start_stop(self, client: StreamChat, random_user: Dict): 71 | segment = client.create_segment(segment_type=SegmentType.USER) 72 | segment_id = segment["segment"]["id"] 73 | 74 | sender_id = random_user["id"] 75 | 76 | target_added = client.add_segment_targets( 77 | segment_id=segment_id, target_ids=[sender_id] 78 | ) 79 | assert target_added.is_ok() 80 | 81 | campaign = client.campaign( 82 | data={ 83 | "message_template": { 84 | "text": "{Hello}", 85 | }, 86 | "segment_ids": [segment_id], 87 | "sender_id": sender_id, 88 | "name": "some name", 89 | } 90 | ) 91 | created = campaign.create() 92 | assert created.is_ok() 93 | assert "campaign" in created 94 | assert "id" in created["campaign"] 95 | assert "name" in created["campaign"] 96 | 97 | now = datetime.datetime.now(datetime.timezone.utc) 98 | one_hour_later = now + datetime.timedelta(hours=1) 99 | two_hours_later = now + datetime.timedelta(hours=2) 100 | 101 | started = campaign.start(scheduled_for=one_hour_later, stop_at=two_hours_later) 102 | assert started.is_ok() 103 | assert "campaign" in started 104 | assert "id" in started["campaign"] 105 | assert "name" in started["campaign"] 106 | 107 | stopped = campaign.stop() 108 | assert stopped.is_ok() 109 | assert "campaign" in stopped 110 | assert "id" in stopped["campaign"] 111 | assert "name" in stopped["campaign"] 112 | 113 | deleted = campaign.delete() 114 | assert deleted.is_ok() 115 | 116 | client.delete_segment(segment_id=segment_id) 117 | 118 | def test_query_campaigns(self, client: StreamChat, random_user: Dict): 119 | segment_created = client.create_segment(segment_type=SegmentType.USER) 120 | segment_id = segment_created["segment"]["id"] 121 | 122 | sender_id = random_user["id"] 123 | 124 | target_added = client.add_segment_targets( 125 | segment_id=segment_id, target_ids=[sender_id] 126 | ) 127 | assert target_added.is_ok() 128 | 129 | created = client.create_campaign( 130 | data={ 131 | "message_template": { 132 | "text": "{Hello}", 133 | }, 134 | "segment_ids": [segment_id], 135 | "sender_id": sender_id, 136 | "name": "some name", 137 | } 138 | ) 139 | assert created.is_ok() 140 | assert "campaign" in created 141 | assert "id" in created["campaign"] 142 | assert "name" in created["campaign"] 143 | campaign_id = created["campaign"]["id"] 144 | 145 | query_campaigns = client.query_campaigns( 146 | filter_conditions={ 147 | "id": { 148 | "$eq": campaign_id, 149 | } 150 | }, 151 | sort=[{"field": "created_at", "direction": SortOrder.DESC}], 152 | options={ 153 | "limit": 10, 154 | }, 155 | ) 156 | assert query_campaigns.is_ok() 157 | assert "campaigns" in query_campaigns 158 | assert len(query_campaigns["campaigns"]) == 1 159 | assert query_campaigns["campaigns"][0]["id"] == campaign_id 160 | 161 | deleted = client.delete_campaign(campaign_id=campaign_id) 162 | assert deleted.is_ok() 163 | 164 | segment_deleted = client.delete_segment(segment_id=segment_id) 165 | assert segment_deleted.is_ok() 166 | -------------------------------------------------------------------------------- /stream_chat/tests/test_reminders.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta, timezone 2 | 3 | import pytest 4 | 5 | from stream_chat import StreamChat 6 | from stream_chat.base.exceptions import StreamAPIException 7 | 8 | 9 | class TestReminders: 10 | @pytest.fixture(autouse=True) 11 | def setup_channel_for_reminders(self, channel): 12 | channel.update_partial( 13 | {"config_overrides": {"user_message_reminders": True}}, 14 | ) 15 | yield 16 | channel.update_partial( 17 | {"config_overrides": {"user_message_reminders": False}}, 18 | ) 19 | 20 | def test_create_reminder(self, client: StreamChat, channel, random_user): 21 | # First, send a message to create a reminder for 22 | message_data = { 23 | "text": "This is a test message for reminder", 24 | } 25 | response = channel.send_message(message_data, random_user["id"]) 26 | message_id = response["message"]["id"] 27 | 28 | # Create a reminder without remind_at 29 | response = client.create_reminder(message_id, random_user["id"]) 30 | # Verify the response contains the expected data 31 | assert response is not None 32 | assert "reminder" in response 33 | assert response["reminder"]["message_id"] == message_id 34 | assert "user_id" in response["reminder"] 35 | 36 | # Clean up - try to delete the reminder 37 | try: 38 | client.delete_reminder(message_id, random_user["id"]) 39 | except StreamAPIException: 40 | pass # It's okay if deletion fails 41 | 42 | def test_create_reminder_with_remind_at( 43 | self, client: StreamChat, channel, random_user 44 | ): 45 | # First, send a message to create a reminder for 46 | message_data = { 47 | "text": "This is a test message for reminder with time", 48 | } 49 | response = channel.send_message(message_data, random_user["id"]) 50 | message_id = response["message"]["id"] 51 | 52 | # Create a reminder with remind_at 53 | remind_at = datetime.now(timezone.utc) + timedelta(days=1) 54 | response = client.create_reminder(message_id, random_user["id"], remind_at) 55 | # Verify the response contains the expected data 56 | assert response is not None 57 | assert "reminder" in response 58 | assert response["reminder"]["message_id"] == message_id 59 | assert "user_id" in response["reminder"] 60 | assert "remind_at" in response["reminder"] 61 | 62 | # Clean up - try to delete the reminder 63 | try: 64 | client.delete_reminder(message_id, random_user["id"]) 65 | except StreamAPIException: 66 | pass # It's okay if deletion fails 67 | 68 | def test_update_reminder(self, client: StreamChat, channel, random_user): 69 | # First, send a message to create a reminder for 70 | message_data = { 71 | "text": "This is a test message for updating reminder", 72 | } 73 | response = channel.send_message(message_data, random_user["id"]) 74 | message_id = response["message"]["id"] 75 | 76 | # Create a reminder 77 | client.create_reminder(message_id, random_user["id"]) 78 | 79 | # Update the reminder with a remind_at time 80 | remind_at = datetime.now(timezone.utc) + timedelta(days=2) 81 | response = client.update_reminder(message_id, random_user["id"], remind_at) 82 | # Verify the response contains the expected data 83 | assert response is not None 84 | assert "reminder" in response 85 | assert response["reminder"]["message_id"] == message_id 86 | assert "user_id" in response["reminder"] 87 | assert "remind_at" in response["reminder"] 88 | 89 | # Clean up - try to delete the reminder 90 | try: 91 | client.delete_reminder(message_id, random_user["id"]) 92 | except StreamAPIException: 93 | pass # It's okay if deletion fails 94 | 95 | def test_delete_reminder(self, client: StreamChat, channel, random_user): 96 | # First, send a message to create a reminder for 97 | message_data = { 98 | "text": "This is a test message for deleting reminder", 99 | } 100 | response = channel.send_message(message_data, random_user["id"]) 101 | message_id = response["message"]["id"] 102 | 103 | # Create a reminder 104 | client.create_reminder(message_id, random_user["id"]) 105 | 106 | # Delete the reminder 107 | response = client.delete_reminder(message_id, random_user["id"]) 108 | # Verify the response contains the expected data 109 | assert response is not None 110 | # The delete response may not include the reminder object 111 | 112 | def test_query_reminders(self, client: StreamChat, channel, random_user): 113 | # First, send messages to create reminders for 114 | message_ids = [] 115 | channel_cid = channel.cid 116 | 117 | for i in range(3): 118 | message_data = { 119 | "text": f"This is test message {i} for querying reminders", 120 | } 121 | response = channel.send_message(message_data, random_user["id"]) 122 | message_id = response["message"]["id"] 123 | message_ids.append(message_id) 124 | 125 | # Create a reminder with different remind_at times 126 | remind_at = datetime.now(timezone.utc) + timedelta(days=i + 1) 127 | client.create_reminder(message_id, random_user["id"], remind_at) 128 | 129 | # Test case 1: Query reminders without filters 130 | response = client.query_reminders(random_user["id"]) 131 | assert response is not None 132 | assert "reminders" in response 133 | # Check that we have at least our 3 reminders 134 | assert len(response["reminders"]) >= 3 135 | 136 | # Check that at least some of our message IDs are in the results 137 | found_ids = [ 138 | r["message_id"] 139 | for r in response["reminders"] 140 | if r["message_id"] in message_ids 141 | ] 142 | assert len(found_ids) > 0 143 | 144 | # Test case 2: Query reminders by message ID 145 | if len(message_ids) > 0: 146 | filter_conditions = {"message_id": {"$in": [message_ids[0]]}} 147 | response = client.query_reminders(random_user["id"], filter_conditions) 148 | assert response is not None 149 | assert "reminders" in response 150 | # Verify all returned reminders match the filter 151 | for reminder in response["reminders"]: 152 | assert reminder["message_id"] in [message_ids[0]] 153 | 154 | # Test case 3: Query reminders by single message ID 155 | filter_conditions = {"message_id": message_ids[0]} 156 | response = client.query_reminders(random_user["id"], filter_conditions) 157 | assert response is not None 158 | assert "reminders" in response 159 | assert len(response["reminders"]) >= 1 160 | # Verify all returned reminders have the exact message_id 161 | for reminder in response["reminders"]: 162 | assert reminder["message_id"] == message_ids[0] 163 | 164 | # Test case 4: Query reminders by channel CID 165 | filter_conditions = {"channel_cid": channel_cid} 166 | response = client.query_reminders(random_user["id"], filter_conditions) 167 | assert response is not None 168 | assert "reminders" in response 169 | assert len(response["reminders"]) >= 3 170 | # Verify all returned reminders belong to the channel 171 | for reminder in response["reminders"]: 172 | assert reminder["channel_cid"] == channel_cid 173 | 174 | # Clean up - try to delete the reminders 175 | for message_id in message_ids: 176 | try: 177 | client.delete_reminder(message_id, random_user["id"]) 178 | except StreamAPIException: 179 | pass # It's okay if deletion fails 180 | -------------------------------------------------------------------------------- /stream_chat/tests/async_chat/test_reminders.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta, timezone 2 | 3 | import pytest 4 | 5 | from stream_chat.async_chat import StreamChatAsync 6 | from stream_chat.base.exceptions import StreamAPIException 7 | 8 | 9 | class TestReminders: 10 | @pytest.fixture(autouse=True) 11 | @pytest.mark.asyncio 12 | async def setup_channel_for_reminders(self, channel): 13 | await channel.update_partial( 14 | {"config_overrides": {"user_message_reminders": True}}, 15 | ) 16 | yield 17 | await channel.update_partial( 18 | {"config_overrides": {"user_message_reminders": False}}, 19 | ) 20 | 21 | @pytest.mark.asyncio 22 | async def test_create_reminder(self, client: StreamChatAsync, channel, random_user): 23 | # First, send a message to create a reminder for 24 | message_data = { 25 | "text": "This is a test message for reminder", 26 | } 27 | response = await channel.send_message(message_data, random_user["id"]) 28 | message_id = response["message"]["id"] 29 | 30 | # Create a reminder without remind_at 31 | response = await client.create_reminder(message_id, random_user["id"]) 32 | # Verify the response contains the expected data 33 | assert response is not None 34 | assert "reminder" in response 35 | assert response["reminder"]["message_id"] == message_id 36 | assert "user_id" in response["reminder"] 37 | 38 | # Clean up - try to delete the reminder 39 | try: 40 | await client.delete_reminder(message_id, random_user["id"]) 41 | except StreamAPIException: 42 | pass # It's okay if deletion fails 43 | 44 | @pytest.mark.asyncio 45 | async def test_create_reminder_with_remind_at( 46 | self, client: StreamChatAsync, channel, random_user 47 | ): 48 | # First, send a message to create a reminder for 49 | message_data = { 50 | "text": "This is a test message for reminder with time", 51 | } 52 | response = await channel.send_message(message_data, random_user["id"]) 53 | message_id = response["message"]["id"] 54 | 55 | # Create a reminder with remind_at 56 | remind_at = datetime.now(timezone.utc) + timedelta(days=1) 57 | response = await client.create_reminder( 58 | message_id, random_user["id"], remind_at 59 | ) 60 | # Verify the response contains the expected data 61 | assert response is not None 62 | assert "reminder" in response 63 | assert response["reminder"]["message_id"] == message_id 64 | assert "user_id" in response["reminder"] 65 | assert "remind_at" in response["reminder"] 66 | 67 | # Clean up - try to delete the reminder 68 | try: 69 | await client.delete_reminder(message_id, random_user["id"]) 70 | except StreamAPIException: 71 | pass # It's okay if deletion fails 72 | 73 | @pytest.mark.asyncio 74 | async def test_update_reminder(self, client: StreamChatAsync, channel, random_user): 75 | # First, send a message to create a reminder for 76 | message_data = { 77 | "text": "This is a test message for updating reminder", 78 | } 79 | response = await channel.send_message(message_data, random_user["id"]) 80 | message_id = response["message"]["id"] 81 | 82 | # Create a reminder 83 | await client.create_reminder(message_id, random_user["id"]) 84 | 85 | # Update the reminder with a remind_at time 86 | remind_at = datetime.now(timezone.utc) + timedelta(days=2) 87 | response = await client.update_reminder( 88 | message_id, random_user["id"], remind_at 89 | ) 90 | # Verify the response contains the expected data 91 | assert response is not None 92 | assert "reminder" in response 93 | assert response["reminder"]["message_id"] == message_id 94 | assert "user_id" in response["reminder"] 95 | assert "remind_at" in response["reminder"] 96 | 97 | # Clean up - try to delete the reminder 98 | try: 99 | await client.delete_reminder(message_id, random_user["id"]) 100 | except StreamAPIException: 101 | pass # It's okay if deletion fails 102 | 103 | @pytest.mark.asyncio 104 | async def test_delete_reminder(self, client: StreamChatAsync, channel, random_user): 105 | # First, send a message to create a reminder for 106 | message_data = { 107 | "text": "This is a test message for deleting reminder", 108 | } 109 | response = await channel.send_message(message_data, random_user["id"]) 110 | message_id = response["message"]["id"] 111 | 112 | # Create a reminder 113 | await client.create_reminder(message_id, random_user["id"]) 114 | 115 | # Delete the reminder 116 | response = await client.delete_reminder(message_id, random_user["id"]) 117 | # Verify the response contains the expected data 118 | assert response is not None 119 | # The delete response may not include the reminder object 120 | 121 | @pytest.mark.asyncio 122 | async def test_query_reminders(self, client: StreamChatAsync, channel, random_user): 123 | # First, send messages to create reminders for 124 | message_ids = [] 125 | channel_cid = channel.cid 126 | 127 | for i in range(3): 128 | message_data = { 129 | "text": f"This is test message {i} for querying reminders", 130 | } 131 | response = await channel.send_message(message_data, random_user["id"]) 132 | message_id = response["message"]["id"] 133 | message_ids.append(message_id) 134 | 135 | # Create a reminder with different remind_at times 136 | remind_at = datetime.now(timezone.utc) + timedelta(hours=i + 1) 137 | await client.create_reminder(message_id, random_user["id"], remind_at) 138 | 139 | # Test case 1: Query reminders without filters 140 | response = await client.query_reminders(random_user["id"]) 141 | assert response is not None 142 | assert "reminders" in response 143 | # Check that we have at least our 3 reminders 144 | assert len(response["reminders"]) >= 3 145 | 146 | # Check that at least some of our message IDs are in the results 147 | found_ids = [ 148 | r["message_id"] 149 | for r in response["reminders"] 150 | if r["message_id"] in message_ids 151 | ] 152 | assert len(found_ids) > 0 153 | 154 | # Test case 2: Query reminders by message ID 155 | if len(message_ids) > 0: 156 | filter_conditions = {"message_id": {"$in": [message_ids[0]]}} 157 | response = await client.query_reminders( 158 | random_user["id"], filter_conditions 159 | ) 160 | assert response is not None 161 | assert "reminders" in response 162 | # Verify all returned reminders match the filter 163 | for reminder in response["reminders"]: 164 | assert reminder["message_id"] in [message_ids[0]] 165 | 166 | # Test case 3: Query reminders by single message ID 167 | filter_conditions = {"message_id": message_ids[0]} 168 | response = await client.query_reminders(random_user["id"], filter_conditions) 169 | assert response is not None 170 | assert "reminders" in response 171 | assert len(response["reminders"]) >= 1 172 | # Verify all returned reminders have the exact message_id 173 | for reminder in response["reminders"]: 174 | assert reminder["message_id"] == message_ids[0] 175 | 176 | # Test case 4: Query reminders by channel CID 177 | filter_conditions = {"channel_cid": channel_cid} 178 | response = await client.query_reminders(random_user["id"], filter_conditions) 179 | assert response is not None 180 | assert "reminders" in response 181 | assert len(response["reminders"]) >= 3 182 | # Verify all returned reminders belong to the channel 183 | for reminder in response["reminders"]: 184 | assert reminder["channel_cid"] == channel_cid 185 | 186 | # Clean up - try to delete the reminders 187 | for message_id in message_ids: 188 | try: 189 | await client.delete_reminder(message_id, random_user["id"]) 190 | except StreamAPIException: 191 | pass # It's okay if deletion fails 192 | -------------------------------------------------------------------------------- /stream_chat/channel.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | from typing import Any, Dict, Iterable, List, Optional, Union 4 | 5 | from stream_chat.base.channel import ChannelInterface, add_user_id 6 | from stream_chat.base.exceptions import StreamChannelException 7 | from stream_chat.types.stream_response import StreamResponse 8 | 9 | 10 | class Channel(ChannelInterface): 11 | def send_message( 12 | self, message: Dict, user_id: str, **options: Any 13 | ) -> StreamResponse: 14 | payload = {"message": add_user_id(message, user_id), **options} 15 | return self.client.post(f"{self.url}/message", data=payload) 16 | 17 | def send_event(self, event: Dict, user_id: str) -> StreamResponse: 18 | payload = {"event": add_user_id(event, user_id)} 19 | return self.client.post(f"{self.url}/event", data=payload) 20 | 21 | def send_reaction( 22 | self, message_id: str, reaction: Dict, user_id: str 23 | ) -> StreamResponse: 24 | payload = {"reaction": add_user_id(reaction, user_id)} 25 | return self.client.post(f"messages/{message_id}/reaction", data=payload) 26 | 27 | def delete_reaction( 28 | self, message_id: str, reaction_type: str, user_id: str 29 | ) -> StreamResponse: 30 | return self.client.delete( 31 | f"messages/{message_id}/reaction/{reaction_type}", 32 | params={"user_id": user_id}, 33 | ) 34 | 35 | def create(self, user_id: str, **options: Any) -> StreamResponse: 36 | self.custom_data["created_by"] = {"id": user_id} 37 | options["watch"] = False 38 | options["state"] = False 39 | options["presence"] = False 40 | return self.query(**options) 41 | 42 | def get_messages(self, message_ids: List[str]) -> StreamResponse: 43 | return self.client.get( 44 | f"{self.url}/messages", params={"ids": ",".join(message_ids)} 45 | ) 46 | 47 | def query(self, **options: Any) -> StreamResponse: 48 | payload = {"state": True, "data": self.custom_data, **options} 49 | 50 | url = f"channels/{self.channel_type}" 51 | if self.id is not None: 52 | url = f"{url}/{self.id}" 53 | 54 | state = self.client.post(f"{url}/query", data=payload) 55 | 56 | if self.id is None: 57 | self.id: str = state["channel"]["id"] 58 | 59 | return state 60 | 61 | def query_members( 62 | self, filter_conditions: Dict, sort: List[Dict] = None, **options: Any 63 | ) -> List[Dict]: 64 | payload = { 65 | "id": self.id, 66 | "type": self.channel_type, 67 | "filter_conditions": filter_conditions, 68 | "sort": self.client.normalize_sort(sort), 69 | **options, 70 | } 71 | response: StreamResponse = self.client.get( 72 | "members", params={"payload": json.dumps(payload)} 73 | ) 74 | return response["members"] 75 | 76 | def update(self, channel_data: Dict, update_message: Dict = None) -> StreamResponse: 77 | payload = {"data": channel_data, "message": update_message} 78 | return self.client.post(self.url, data=payload) 79 | 80 | def update_partial( 81 | self, to_set: Dict = None, to_unset: Iterable[str] = None 82 | ) -> StreamResponse: 83 | payload = {"set": to_set or {}, "unset": to_unset or []} 84 | return self.client.patch(self.url, data=payload) 85 | 86 | def delete(self, hard: bool = False) -> StreamResponse: 87 | return self.client.delete(self.url, params={"hard_delete": hard}) 88 | 89 | def truncate(self, **options: Any) -> StreamResponse: 90 | return self.client.post(f"{self.url}/truncate", data=options) 91 | 92 | def add_members( 93 | self, 94 | members: Union[Iterable[Dict], Iterable[str]], 95 | message: Dict = None, 96 | **options: Any, 97 | ) -> StreamResponse: 98 | payload = {"add_members": members, "message": message, **options} 99 | if "hide_history_before" in payload and isinstance( 100 | payload["hide_history_before"], datetime.datetime 101 | ): 102 | payload["hide_history_before"] = payload["hide_history_before"].isoformat() 103 | return self.client.post(self.url, data=payload) 104 | 105 | def assign_roles( 106 | self, members: Iterable[Dict], message: Dict = None 107 | ) -> StreamResponse: 108 | return self.client.post( 109 | self.url, data={"assign_roles": members, "message": message} 110 | ) 111 | 112 | def invite_members( 113 | self, user_ids: Iterable[str], message: Dict = None 114 | ) -> StreamResponse: 115 | return self.client.post( 116 | self.url, data={"invites": user_ids, "message": message} 117 | ) 118 | 119 | def add_moderators( 120 | self, user_ids: Iterable[str], message: Dict = None 121 | ) -> StreamResponse: 122 | return self.client.post( 123 | self.url, data={"add_moderators": user_ids, "message": message} 124 | ) 125 | 126 | def remove_members( 127 | self, user_ids: Iterable[str], message: Dict = None 128 | ) -> StreamResponse: 129 | return self.client.post( 130 | self.url, data={"remove_members": user_ids, "message": message} 131 | ) 132 | 133 | def demote_moderators( 134 | self, user_ids: Iterable[str], message: Dict = None 135 | ) -> StreamResponse: 136 | return self.client.post( 137 | self.url, data={"demote_moderators": user_ids, "message": message} 138 | ) 139 | 140 | def mark_read(self, user_id: str, **data: Any) -> StreamResponse: 141 | payload = add_user_id(data, user_id) 142 | return self.client.post(f"{self.url}/read", data=payload) 143 | 144 | def mark_unread(self, user_id: str, **data: Any) -> StreamResponse: 145 | payload = add_user_id(data, user_id) 146 | return self.client.post(f"{self.url}/unread", data=payload) 147 | 148 | def get_replies(self, parent_id: str, **options: Any) -> StreamResponse: 149 | return self.client.get(f"messages/{parent_id}/replies", params=options) 150 | 151 | def get_reactions(self, message_id: str, **options: Any) -> StreamResponse: 152 | return self.client.get(f"messages/{message_id}/reactions", params=options) 153 | 154 | def ban_user(self, target_id: str, **options: Any) -> StreamResponse: 155 | return self.client.ban_user( # type: ignore 156 | target_id, type=self.channel_type, id=self.id, **options 157 | ) 158 | 159 | def unban_user(self, target_id: str, **options: Any) -> StreamResponse: 160 | return self.client.unban_user( # type: ignore 161 | target_id, type=self.channel_type, id=self.id, **options 162 | ) 163 | 164 | def accept_invite(self, user_id: str, **data: Any) -> StreamResponse: 165 | payload = add_user_id(data, user_id) 166 | payload["accept_invite"] = True 167 | response = self.client.post(self.url, data=payload) 168 | self.custom_data = response["channel"] 169 | return response 170 | 171 | def reject_invite(self, user_id: str, **data: Any) -> StreamResponse: 172 | payload = add_user_id(data, user_id) 173 | payload["reject_invite"] = True 174 | response = self.client.post(self.url, data=payload) 175 | self.custom_data = response["channel"] 176 | return response 177 | 178 | def send_file( 179 | self, url: str, name: str, user: Dict, content_type: str = None 180 | ) -> StreamResponse: 181 | return self.client.send_file( # type: ignore 182 | f"{self.url}/file", url, name, user, content_type=content_type 183 | ) 184 | 185 | def send_image( 186 | self, url: str, name: str, user: Dict, content_type: str = None 187 | ) -> StreamResponse: 188 | return self.client.send_file( # type: ignore 189 | f"{self.url}/image", url, name, user, content_type=content_type 190 | ) 191 | 192 | def delete_file(self, url: str) -> StreamResponse: 193 | return self.client.delete(f"{self.url}/file", {"url": url}) 194 | 195 | def delete_image(self, url: str) -> StreamResponse: 196 | return self.client.delete(f"{self.url}/image", {"url": url}) 197 | 198 | def hide(self, user_id: str) -> StreamResponse: 199 | return self.client.post(f"{self.url}/hide", data={"user_id": user_id}) 200 | 201 | def show(self, user_id: str) -> StreamResponse: 202 | return self.client.post(f"{self.url}/show", data={"user_id": user_id}) 203 | 204 | def mute(self, user_id: str, expiration: int = None) -> StreamResponse: 205 | params: Dict[str, Union[str, int]] = { 206 | "user_id": user_id, 207 | "channel_cid": self.cid, 208 | } 209 | if expiration: 210 | params["expiration"] = expiration 211 | return self.client.post("moderation/mute/channel", data=params) 212 | 213 | def unmute(self, user_id: str) -> StreamResponse: 214 | params = { 215 | "user_id": user_id, 216 | "channel_cid": self.cid, 217 | } 218 | return self.client.post("moderation/unmute/channel", data=params) 219 | 220 | def pin(self, user_id: str) -> StreamResponse: 221 | if not user_id: 222 | raise StreamChannelException("user_id must not be empty") 223 | 224 | payload = {"set": {"pinned": True}} 225 | return self.client.patch(f"{self.url}/member/{user_id}", data=payload) 226 | 227 | def unpin(self, user_id: str) -> StreamResponse: 228 | if not user_id: 229 | raise StreamChannelException("user_id must not be empty") 230 | 231 | payload = {"set": {"pinned": False}} 232 | return self.client.patch(f"{self.url}/member/{user_id}", data=payload) 233 | 234 | def archive(self, user_id: str) -> StreamResponse: 235 | if not user_id: 236 | raise StreamChannelException("user_id must not be empty") 237 | 238 | payload = {"set": {"archived": True}} 239 | return self.client.patch(f"{self.url}/member/{user_id}", data=payload) 240 | 241 | def unarchive(self, user_id: str) -> StreamResponse: 242 | if not user_id: 243 | raise StreamChannelException("user_id must not be empty") 244 | 245 | payload = {"set": {"archived": False}} 246 | return self.client.patch(f"{self.url}/member/{user_id}", data=payload) 247 | 248 | def update_member_partial( 249 | self, user_id: str, to_set: Dict = None, to_unset: Iterable[str] = None 250 | ) -> StreamResponse: 251 | if not user_id: 252 | raise StreamChannelException("user_id must not be empty") 253 | 254 | payload = {"set": to_set or {}, "unset": to_unset or []} 255 | return self.client.patch(f"{self.url}/member/{user_id}", data=payload) 256 | 257 | def create_draft(self, message: Dict, user_id: str) -> StreamResponse: 258 | message["user_id"] = user_id 259 | payload = {"message": message} 260 | return self.client.post(f"{self.url}/draft", data=payload) 261 | 262 | def delete_draft( 263 | self, user_id: str, parent_id: Optional[str] = None 264 | ) -> StreamResponse: 265 | params = {"user_id": user_id} 266 | if parent_id: 267 | params["parent_id"] = parent_id 268 | 269 | return self.client.delete(f"{self.url}/draft", params=params) 270 | 271 | def get_draft( 272 | self, user_id: str, parent_id: Optional[str] = None 273 | ) -> StreamResponse: 274 | params = {"user_id": user_id} 275 | if parent_id: 276 | params["parent_id"] = parent_id 277 | 278 | return self.client.get(f"{self.url}/draft", params=params) 279 | 280 | def add_filter_tags( 281 | self, 282 | tags: Iterable[str], 283 | message: Dict = None, 284 | ) -> StreamResponse: 285 | payload = {"add_filter_tags": tags, "message": message} 286 | return self.client.post(self.url, data=payload) 287 | 288 | def remove_filter_tags( 289 | self, 290 | tags: Iterable[str], 291 | message: Dict = None, 292 | ) -> StreamResponse: 293 | payload = {"remove_filter_tags": tags, "message": message} 294 | return self.client.post(self.url, data=payload) 295 | -------------------------------------------------------------------------------- /stream_chat/async_chat/channel.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | from typing import Any, Dict, Iterable, List, Optional, Union 4 | 5 | from stream_chat.base.channel import ChannelInterface, add_user_id 6 | from stream_chat.base.exceptions import StreamChannelException 7 | from stream_chat.types.stream_response import StreamResponse 8 | 9 | 10 | class Channel(ChannelInterface): 11 | async def send_message( 12 | self, message: Dict, user_id: str, **options: Any 13 | ) -> StreamResponse: 14 | payload = {"message": add_user_id(message, user_id), **options} 15 | return await self.client.post(f"{self.url}/message", data=payload) 16 | 17 | async def get_messages(self, message_ids: List[str]) -> StreamResponse: 18 | return await self.client.get( 19 | f"{self.url}/messages", params={"ids": ",".join(message_ids)} 20 | ) 21 | 22 | async def send_event(self, event: Dict, user_id: str) -> StreamResponse: 23 | payload = {"event": add_user_id(event, user_id)} 24 | return await self.client.post(f"{self.url}/event", data=payload) 25 | 26 | async def send_reaction( 27 | self, message_id: str, reaction: Dict, user_id: str 28 | ) -> StreamResponse: 29 | payload = {"reaction": add_user_id(reaction, user_id)} 30 | return await self.client.post(f"messages/{message_id}/reaction", data=payload) 31 | 32 | async def delete_reaction( 33 | self, message_id: str, reaction_type: str, user_id: str 34 | ) -> StreamResponse: 35 | return await self.client.delete( 36 | f"messages/{message_id}/reaction/{reaction_type}", 37 | params={"user_id": user_id}, 38 | ) 39 | 40 | async def create(self, user_id: str, **options: Any) -> StreamResponse: 41 | self.custom_data["created_by"] = {"id": user_id} 42 | options["watch"] = False 43 | options["state"] = False 44 | options["presence"] = False 45 | return await self.query(**options) 46 | 47 | async def query(self, **options: Any) -> StreamResponse: 48 | payload = {"state": True, "data": self.custom_data, **options} 49 | 50 | url = f"channels/{self.channel_type}" 51 | if self.id is not None: 52 | url = f"{url}/{self.id}" 53 | 54 | state = await self.client.post(f"{url}/query", data=payload) 55 | 56 | if self.id is None: 57 | self.id = state["channel"]["id"] 58 | 59 | return state 60 | 61 | async def query_members( 62 | self, filter_conditions: Dict, sort: List[Dict] = None, **options: Any 63 | ) -> List[Dict]: 64 | payload = { 65 | "id": self.id, 66 | "type": self.channel_type, 67 | "filter_conditions": filter_conditions, 68 | "sort": self.client.normalize_sort(sort), 69 | **options, 70 | } 71 | response: StreamResponse = await self.client.get( 72 | "members", params={"payload": json.dumps(payload)} 73 | ) 74 | return response["members"] 75 | 76 | async def update( 77 | self, channel_data: Dict, update_message: Dict = None 78 | ) -> StreamResponse: 79 | payload = {"data": channel_data, "message": update_message} 80 | return await self.client.post(self.url, data=payload) 81 | 82 | async def update_partial( 83 | self, to_set: Dict = None, to_unset: Iterable[str] = None 84 | ) -> StreamResponse: 85 | payload = {"set": to_set or {}, "unset": to_unset or []} 86 | return await self.client.patch(self.url, data=payload) 87 | 88 | async def delete(self, hard: bool = False) -> StreamResponse: 89 | return await self.client.delete(self.url, {"hard_delete": hard}) 90 | 91 | async def truncate(self, **options: Any) -> StreamResponse: 92 | return await self.client.post(f"{self.url}/truncate", data=options) 93 | 94 | async def add_members( 95 | self, members: Iterable[Dict], message: Dict = None, **options: Any 96 | ) -> StreamResponse: 97 | payload = {"add_members": members, "message": message, **options} 98 | if "hide_history_before" in payload and isinstance( 99 | payload["hide_history_before"], datetime.datetime 100 | ): 101 | payload["hide_history_before"] = payload["hide_history_before"].isoformat() 102 | return await self.client.post(self.url, data=payload) 103 | 104 | async def assign_roles( 105 | self, members: Iterable[Dict], message: Dict = None 106 | ) -> StreamResponse: 107 | return await self.client.post( 108 | self.url, data={"assign_roles": members, "message": message} 109 | ) 110 | 111 | async def invite_members( 112 | self, user_ids: Iterable[str], message: Dict = None 113 | ) -> StreamResponse: 114 | return await self.client.post( 115 | self.url, data={"invites": user_ids, "message": message} 116 | ) 117 | 118 | async def add_moderators( 119 | self, user_ids: Iterable[str], message: Dict = None 120 | ) -> StreamResponse: 121 | return await self.client.post( 122 | self.url, data={"add_moderators": user_ids, "message": message} 123 | ) 124 | 125 | async def remove_members( 126 | self, user_ids: Iterable[str], message: Dict = None 127 | ) -> StreamResponse: 128 | return await self.client.post( 129 | self.url, data={"remove_members": user_ids, "message": message} 130 | ) 131 | 132 | async def demote_moderators( 133 | self, user_ids: Iterable[str], message: Dict = None 134 | ) -> StreamResponse: 135 | return await self.client.post( 136 | self.url, data={"demote_moderators": user_ids, "message": message} 137 | ) 138 | 139 | async def mark_read(self, user_id: str, **data: Any) -> StreamResponse: 140 | payload = add_user_id(data, user_id) 141 | return await self.client.post(f"{self.url}/read", data=payload) 142 | 143 | async def mark_unread(self, user_id: str, **data: Any) -> StreamResponse: 144 | payload = add_user_id(data, user_id) 145 | return await self.client.post(f"{self.url}/unread", data=payload) 146 | 147 | async def get_replies(self, parent_id: str, **options: Any) -> StreamResponse: 148 | return await self.client.get(f"messages/{parent_id}/replies", params=options) 149 | 150 | async def get_reactions(self, message_id: str, **options: Any) -> StreamResponse: 151 | return await self.client.get(f"messages/{message_id}/reactions", params=options) 152 | 153 | async def ban_user(self, target_id: str, **options: Any) -> StreamResponse: 154 | return await self.client.ban_user( # type: ignore 155 | target_id, type=self.channel_type, id=self.id, **options 156 | ) 157 | 158 | async def unban_user(self, target_id: str, **options: Any) -> StreamResponse: 159 | return await self.client.unban_user( # type: ignore 160 | target_id, type=self.channel_type, id=self.id, **options 161 | ) 162 | 163 | async def accept_invite(self, user_id: str, **data: Any) -> StreamResponse: 164 | payload = add_user_id(data, user_id) 165 | payload["accept_invite"] = True 166 | response = await self.client.post(self.url, data=payload) 167 | self.custom_data = response["channel"] 168 | return response 169 | 170 | async def reject_invite(self, user_id: str, **data: Any) -> StreamResponse: 171 | payload = add_user_id(data, user_id) 172 | payload["reject_invite"] = True 173 | response = await self.client.post(self.url, data=payload) 174 | self.custom_data = response["channel"] 175 | return response 176 | 177 | async def send_file( 178 | self, url: str, name: str, user: Dict, content_type: str = None 179 | ) -> StreamResponse: 180 | return await self.client.send_file( # type: ignore 181 | f"{self.url}/file", url, name, user, content_type=content_type 182 | ) 183 | 184 | async def send_image( 185 | self, url: str, name: str, user: Dict, content_type: str = None 186 | ) -> StreamResponse: 187 | return await self.client.send_file( # type: ignore 188 | f"{self.url}/image", url, name, user, content_type=content_type 189 | ) 190 | 191 | async def delete_file(self, url: str) -> StreamResponse: 192 | return await self.client.delete(f"{self.url}/file", {"url": url}) 193 | 194 | async def delete_image(self, url: str) -> StreamResponse: 195 | return await self.client.delete(f"{self.url}/image", {"url": url}) 196 | 197 | async def hide(self, user_id: str) -> StreamResponse: 198 | return await self.client.post(f"{self.url}/hide", data={"user_id": user_id}) 199 | 200 | async def show(self, user_id: str) -> StreamResponse: 201 | return await self.client.post(f"{self.url}/show", data={"user_id": user_id}) 202 | 203 | async def mute(self, user_id: str, expiration: int = None) -> StreamResponse: 204 | params: Dict[str, Union[str, int]] = { 205 | "user_id": user_id, 206 | "channel_cid": self.cid, 207 | } 208 | if expiration: 209 | params["expiration"] = expiration 210 | return await self.client.post("moderation/mute/channel", data=params) 211 | 212 | async def unmute(self, user_id: str) -> StreamResponse: 213 | params = { 214 | "user_id": user_id, 215 | "channel_cid": self.cid, 216 | } 217 | return await self.client.post("moderation/unmute/channel", data=params) 218 | 219 | async def pin(self, user_id: str) -> StreamResponse: 220 | if not user_id: 221 | raise StreamChannelException("user_id must not be empty") 222 | 223 | payload = {"set": {"pinned": True}} 224 | return await self.client.patch(f"{self.url}/member/{user_id}", data=payload) 225 | 226 | async def unpin(self, user_id: str) -> StreamResponse: 227 | if not user_id: 228 | raise StreamChannelException("user_id must not be empty") 229 | 230 | payload = {"set": {"pinned": False}} 231 | return await self.client.patch(f"{self.url}/member/{user_id}", data=payload) 232 | 233 | async def archive(self, user_id: str) -> StreamResponse: 234 | if not user_id: 235 | raise StreamChannelException("user_id must not be empty") 236 | 237 | payload = {"set": {"archived": True}} 238 | return await self.client.patch(f"{self.url}/member/{user_id}", data=payload) 239 | 240 | async def unarchive(self, user_id: str) -> StreamResponse: 241 | if not user_id: 242 | raise StreamChannelException("user_id must not be empty") 243 | 244 | payload = {"set": {"archived": False}} 245 | return await self.client.patch(f"{self.url}/member/{user_id}", data=payload) 246 | 247 | async def update_member_partial( 248 | self, user_id: str, to_set: Dict = None, to_unset: Iterable[str] = None 249 | ) -> StreamResponse: 250 | if not user_id: 251 | raise StreamChannelException("user_id must not be empty") 252 | 253 | payload = {"set": to_set or {}, "unset": to_unset or []} 254 | return await self.client.patch(f"{self.url}/member/{user_id}", data=payload) 255 | 256 | async def create_draft(self, message: Dict, user_id: str) -> StreamResponse: 257 | payload = {"message": add_user_id(message, user_id)} 258 | return await self.client.post(f"{self.url}/draft", data=payload) 259 | 260 | async def delete_draft( 261 | self, user_id: str, parent_id: Optional[str] = None 262 | ) -> StreamResponse: 263 | params = {"user_id": user_id} 264 | if parent_id: 265 | params["parent_id"] = parent_id 266 | return await self.client.delete(f"{self.url}/draft", params=params) 267 | 268 | async def get_draft( 269 | self, user_id: str, parent_id: Optional[str] = None 270 | ) -> StreamResponse: 271 | params = {"user_id": user_id} 272 | if parent_id: 273 | params["parent_id"] = parent_id 274 | return await self.client.get(f"{self.url}/draft", params=params) 275 | 276 | async def add_filter_tags( 277 | self, 278 | tags: Iterable[str], 279 | message: Dict = None, 280 | ) -> StreamResponse: 281 | payload = {"add_filter_tags": tags, "message": message} 282 | return await self.client.post(self.url, data=payload) 283 | 284 | async def remove_filter_tags( 285 | self, 286 | tags: Iterable[str], 287 | message: Dict = None, 288 | ) -> StreamResponse: 289 | payload = {"remove_filter_tags": tags, "message": message} 290 | return await self.client.post(self.url, data=payload) 291 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ## [4.28.0](https://github.com/GetStream/stream-chat-python/compare/v4.27.0...v4.28.0) (2025-11-13) 6 | 7 | 8 | ### Features 9 | 10 | * add message_timestamp option to MarkUnread ([#211](https://github.com/GetStream/stream-chat-python/issues/211)) ([1e239cd](https://github.com/GetStream/stream-chat-python/commit/1e239cd0688027442e169c968e63ef21e1a3c018)) 11 | 12 | ## [4.27.0](https://github.com/GetStream/stream-chat-python/compare/v4.26.0...v4.27.0) (2025-11-12) 13 | 14 | 15 | ### Features 16 | 17 | * add filter tags to channels ([#210](https://github.com/GetStream/stream-chat-python/issues/210)) ([85d7a38](https://github.com/GetStream/stream-chat-python/commit/85d7a38fb99067259a1944956aa0c5eed285f909)) 18 | * add hide_history_before option for adding members ([#208](https://github.com/GetStream/stream-chat-python/issues/208)) ([0c96e70](https://github.com/GetStream/stream-chat-python/commit/0c96e703c7365b6abad4710a684e0e9aaada8790)) 19 | * Added Delete for me support on behalf of a user ([#204](https://github.com/GetStream/stream-chat-python/issues/204)) ([c911a8b](https://github.com/GetStream/stream-chat-python/commit/c911a8b3126b74ff2104edb4be373a9c8465d179)) 20 | * support members_template property for campaigns ([#209](https://github.com/GetStream/stream-chat-python/issues/209)) ([21997ee](https://github.com/GetStream/stream-chat-python/commit/21997ee97c64f496e71f28de5c9aeb225856254f)) 21 | 22 | 23 | ### Bug Fixes 24 | 25 | * failing webhook and blocklist tests ([#207](https://github.com/GetStream/stream-chat-python/issues/207)) ([bd46569](https://github.com/GetStream/stream-chat-python/commit/bd46569c2d59572b2ca3198a876b5f8b7debd8cb)) 26 | 27 | ## [4.26.0](https://github.com/GetStream/stream-chat-python/compare/v4.25.0...v4.26.0) (2025-07-08) 28 | 29 | ## [4.25.0](https://github.com/GetStream/stream-chat-python/compare/v4.24.0...v4.25.0) (2025-06-18) 30 | 31 | 32 | ### Bug Fixes 33 | 34 | * add block user methods ([#199](https://github.com/GetStream/stream-chat-python/issues/199)) ([6364604](https://github.com/GetStream/stream-chat-python/commit/6364604e6ce17491e66c7b2e68b197e5de0ec25c)) 35 | * make sure we don't have left over users and channels ([#197](https://github.com/GetStream/stream-chat-python/issues/197)) ([564e947](https://github.com/GetStream/stream-chat-python/commit/564e94741a7069e26670c57bb3183e41db1c955e)) 36 | 37 | ## [4.24.0](https://github.com/GetStream/stream-chat-python/compare/v4.23.0...v4.24.0) (2025-04-07) 38 | 39 | ## [4.23.0](https://github.com/GetStream/stream-chat-python/compare/v4.22.0...v4.23.0) (2025-03-11) 40 | 41 | ## [4.22.0](https://github.com/GetStream/stream-chat-python/compare/v4.21.0...v4.22.0) (2025-02-18) 42 | 43 | ## [4.21.0](https://github.com/GetStream/stream-chat-python/compare/v4.20.0...v4.21.0) (2025-02-11) 44 | 45 | ## [4.20.0](https://github.com/GetStream/stream-chat-python/compare/v4.19.0...v4.20.0) (2024-12-07) 46 | 47 | ## [4.19.0](https://github.com/GetStream/stream-chat-python/compare/v4.18.0...v4.19.0) (2024-09-05) 48 | 49 | ## [4.18.0](https://github.com/GetStream/stream-chat-python/compare/v4.17.0...v4.18.0) (2024-07-03) 50 | 51 | 52 | ### Features 53 | 54 | * Add support for mark_unread msg or thread ([#172](https://github.com/GetStream/stream-chat-python/issues/172)) ([1981ab7](https://github.com/GetStream/stream-chat-python/commit/1981ab7e75dcbf5d13ef105dfae50d46a8e39d70)) 55 | 56 | ## [4.17.0](https://github.com/GetStream/stream-chat-python/compare/v4.16.0...v4.17.0) (2024-05-29) 57 | 58 | 59 | ### Features 60 | 61 | * add support for undeleting messages ([#162](https://github.com/GetStream/stream-chat-python/issues/162)) ([c1da19d](https://github.com/GetStream/stream-chat-python/commit/c1da19d3915c455f9c0cf37434ffbeac4e9a09b0)) 62 | 63 | ## [4.16.0](https://github.com/GetStream/stream-chat-python/compare/v4.15.0...v4.16.0) (2024-05-17) 64 | 65 | 66 | ### Features 67 | 68 | * new API endpoint query_message_history ([#168](https://github.com/GetStream/stream-chat-python/issues/168)) ([f3f4c9f](https://github.com/GetStream/stream-chat-python/commit/f3f4c9fe1ad94fd11065bf742664fb69339df757)) 69 | 70 | ## [4.15.0](https://github.com/GetStream/stream-chat-python/compare/v4.14.0...v4.15.0) (2024-03-19) 71 | 72 | 73 | ### Features 74 | 75 | * support all_users & all_sender_channels for segment ([#164](https://github.com/GetStream/stream-chat-python/issues/164)) ([7d57dcc](https://github.com/GetStream/stream-chat-python/commit/7d57dcc6e603863140588f5e02927a13e4c0f595)) 76 | 77 | ## [4.14.0](https://github.com/GetStream/stream-chat-python/compare/v4.13.0...v4.14.0) (2024-03-08) 78 | 79 | 80 | ### Features 81 | 82 | * add support for show_deleted_message in getMessage ([#161](https://github.com/GetStream/stream-chat-python/issues/161)) ([e42aa94](https://github.com/GetStream/stream-chat-python/commit/e42aa94fe8aac489a33f288479c08e5f31d7e6d8)) 83 | 84 | ## [4.13.0](https://github.com/GetStream/stream-chat-python/compare/v4.12.1...v4.13.0) (2024-03-01) 85 | 86 | ### [4.12.1](https://github.com/GetStream/stream-chat-python/compare/v4.12.0...v4.12.1) (2024-02-27) 87 | 88 | ## [4.12.0](https://github.com/GetStream/stream-chat-python/compare/v4.11.0...v4.12.0) (2024-02-23) 89 | 90 | ## [4.11.0](https://github.com/GetStream/stream-chat-python/compare/v4.10.0...v4.11.0) (2023-11-23) 91 | 92 | 93 | ### Features 94 | 95 | * Add type field support on create_blocklist() ([#149](https://github.com/GetStream/stream-chat-python/issues/149)) ([786a50c](https://github.com/GetStream/stream-chat-python/commit/786a50cf1b4d56071a11a5d3bf91fe5e0b8fba75)) 96 | 97 | ## [4.10.0](https://github.com/GetStream/stream-chat-python/compare/v4.9.0...v4.10.0) (2023-10-26) 98 | 99 | 100 | ### Features 101 | 102 | * sns ([#147](https://github.com/GetStream/stream-chat-python/issues/147)) ([616206e](https://github.com/GetStream/stream-chat-python/commit/616206ec62ab96a3d2e4f6468ce17888ccd3fbb9)) 103 | 104 | ## [4.9.0](https://github.com/GetStream/stream-chat-python/compare/v4.8.0...v4.9.0) (2023-07-20) 105 | 106 | 107 | ### Features 108 | 109 | * pending messages ([#143](https://github.com/GetStream/stream-chat-python/issues/143)) ([c9445d3](https://github.com/GetStream/stream-chat-python/commit/c9445d32c7c6759b49c02632078783052e1cc7b6)) 110 | 111 | ## [4.8.0](https://github.com/GetStream/stream-chat-python/compare/v4.7.0...v4.8.0) (2023-05-03) 112 | 113 | 114 | ### Features 115 | 116 | * add multi user mute/unmute ([#140](https://github.com/GetStream/stream-chat-python/issues/140)) ([2a9ebd7](https://github.com/GetStream/stream-chat-python/commit/2a9ebd7d9d7b20445e1040467737b37b67900e2b)) 117 | 118 | ## [4.7.0](https://github.com/GetStream/stream-chat-python/compare/v4.6.0...v4.7.0) (2023-03-29) 119 | 120 | 121 | ### Features 122 | 123 | * enable creation of channels with query options ([#137](https://github.com/GetStream/stream-chat-python/issues/137)) ([e33ae40](https://github.com/GetStream/stream-chat-python/commit/e33ae40d2680ad0a16ed84cdc582031d18e23c6f)) 124 | 125 | ## [4.6.0](https://github.com/GetStream/stream-chat-python/compare/v4.5.0...v4.6.0) (2022-11-08) 126 | 127 | 128 | ### Features 129 | 130 | * add restore_users ([#134](https://github.com/GetStream/stream-chat-python/issues/134)) ([0d57324](https://github.com/GetStream/stream-chat-python/commit/0d573241b6d4942c60def4fb076f33a662ad4ae5)) 131 | 132 | ## [4.5.0](https://github.com/GetStream/stream-chat-python/compare/v4.4.2...v4.5.0) (2022-09-07) 133 | 134 | 135 | ### Features 136 | 137 | * add gdpr flag for campaign deletion ([#132](https://github.com/GetStream/stream-chat-python/issues/132)) ([5dc0c2e](https://github.com/GetStream/stream-chat-python/commit/5dc0c2e4bda97a5ee8f67c28eca89244bfeb5333)) 138 | 139 | ### [4.4.2](https://github.com/GetStream/stream-chat-python/compare/v4.4.1...v4.4.2) (2022-08-22) 140 | 141 | ### [4.4.1](https://github.com/GetStream/stream-chat-python/compare/v4.4.0...v4.4.1) (2022-08-17) 142 | 143 | ## [4.4.0](https://github.com/GetStream/stream-chat-python/compare/v4.3.0...v4.4.0) (2022-08-17) 144 | 145 | 146 | ### Features 147 | 148 | * add new campaign endpoints ([#123](https://github.com/GetStream/stream-chat-python/issues/123)) ([a63992f](https://github.com/GetStream/stream-chat-python/commit/a63992faf1cd1ca530cc1b290e2afd28f451a06e)) 149 | 150 | 151 | ### Bug Fixes 152 | 153 | * handle broken rate limit as best effort ([#126](https://github.com/GetStream/stream-chat-python/issues/126)) ([683529d](https://github.com/GetStream/stream-chat-python/commit/683529d6684454434580c8d6768e7dd541bfa86c)) 154 | 155 | ## [4.3.0](https://github.com/GetStream/stream-chat-python/compare/v4.2.2...v4.3.0) (2022-05-30) 156 | 157 | 158 | ### Features 159 | 160 | * **import:** add import endpoints ([#121](https://github.com/GetStream/stream-chat-python/issues/121)) ([44d1a6a](https://github.com/GetStream/stream-chat-python/commit/44d1a6a9b3bd9c29cbbcc8ac3500598de01f1897)) 161 | 162 | ### [4.2.2](https://github.com/GetStream/stream-chat-python/compare/v4.2.1...v4.2.2) (2022-05-10) 163 | 164 | 165 | ### Features 166 | 167 | * **verify_webhook:** bytes as signature header ([#119](https://github.com/GetStream/stream-chat-python/issues/119)) ([1658d0b](https://github.com/GetStream/stream-chat-python/commit/1658d0b2374a7edc2b9c006f4af3171e3ee85965)) 168 | 169 | ### [4.2.1](https://github.com/GetStream/stream-chat-python/compare/v4.2.0...v4.2.1) (2022-04-21) 170 | 171 | ### Features 172 | 173 | * add documentation to public methods ([#111](https://github.com/GetStream/stream-chat-python/issues/111)) ([758faa4](https://github.com/GetStream/stream-chat-python/commit/758faa475cd801c3fdecc1759b30b72629fc7786)) 174 | 175 | ## [4.2.0](https://github.com/GetStream/stream-chat-python/compare/v4.1.0...v4.2.0) (2022-04-08) 176 | 177 | 178 | ### Features 179 | 180 | * add custom event endpoint ([#103](https://github.com/GetStream/stream-chat-python/issues/103)) ([b619130](https://github.com/GetStream/stream-chat-python/commit/b61913091409f4aba2f8fa2b0f2fe97ed6da9ab0)) 181 | * add device fields ([#107](https://github.com/GetStream/stream-chat-python/issues/107)) ([3b582f5](https://github.com/GetStream/stream-chat-python/commit/3b582f51e5b9f81e618f0a6a5e81356bebc9bb3f)) 182 | * add options to export channel ([#100](https://github.com/GetStream/stream-chat-python/issues/100)) ([82f07ff](https://github.com/GetStream/stream-chat-python/commit/82f07ff5364dd1a5ee8be1b5f983cf156dcc84f3)) 183 | * add provider management ([#106](https://github.com/GetStream/stream-chat-python/issues/106)) ([9710a90](https://github.com/GetStream/stream-chat-python/commit/9710a9099d6c3d2e73a347aada07aabf4f4515d4)) 184 | * set keepalive timeout to 59s ([#101](https://github.com/GetStream/stream-chat-python/issues/101)) ([e7707ea](https://github.com/GetStream/stream-chat-python/commit/e7707ea9d500e3eb48dac38f08b2c8c33db456d3)) 185 | * swappable http client ([#102](https://github.com/GetStream/stream-chat-python/issues/102)) ([1343f43](https://github.com/GetStream/stream-chat-python/commit/1343f43caf82e415fccc6f82e8db1f42a0ca969b)) 186 | * **upser_user:** deprecated update_user in favor of upsert_user ([#109](https://github.com/GetStream/stream-chat-python/issues/109)) ([0b2a8aa](https://github.com/GetStream/stream-chat-python/commit/0b2a8aa627f386da8bcaa6a2b11e8ebe91f36f22)) 187 | 188 | 189 | ### Bug Fixes 190 | 191 | * report_id is is a uuid ([#105](https://github.com/GetStream/stream-chat-python/issues/105)) ([c4a5e24](https://github.com/GetStream/stream-chat-python/commit/c4a5e24fc658a9cebcf17ed9e3200b10f6caf94b)) 192 | 193 | ## [4.1.0](https://github.com/GetStream/stream-chat-python/compare/v4.0.0...v4.1.0) (2022-01-20) 194 | 195 | 196 | ### Features 197 | 198 | * add full feature parity ([#98](https://github.com/GetStream/stream-chat-python/issues/98)) ([02ea360](https://github.com/GetStream/stream-chat-python/commit/02ea3602df45ec6ff0a34f27cc3b2ecdb8c33faa)) 199 | 200 | ## [4.0.0](https://github.com/GetStream/stream-chat-python/compare/v3.17.0...v4.0.0) (2022-01-18) 201 | 202 | 203 | ### Features 204 | 205 | * Add ratelimit info to response object, Plus remove Python 3.6 support ([c472c0b](https://github.com/GetStream/stream-chat-python/commit/c472c0b7c43d7741a092d29920a1d31a413e9dc0)) 206 | > The returned response objects are instances of `StreamResponse` class. It inherits from `dict`, so it's fully backward compatible. Additionally, it provides other benefits such as rate limit information (`resp.rate_limit()`), response headers (`resp.headers()`) or status code (`resp.status_code()`). 207 | 208 | ## [3.17.0](https://github.com/GetStream/stream-chat-python/compare/v3.16.0...v3.17.0) (2022-01-06) 209 | 210 | - Add options support into channel truncate 211 | - Add options support into add members 212 | - Add type hints 213 | - Add internal flag report query and review endpoint support 214 | - Improve tests and docs 215 | 216 | ## Nov 15, 2021 - 3.16.0 217 | 218 | - Add support for assign_roles feature 219 | 220 | ## Nov 12, 2021 - 3.15.0 221 | 222 | - Add update message partial support 223 | - Add pin message and unpin message helpers 224 | 225 | ## Nov 1, 2021 - 3.14.0 226 | 227 | - Add support for async endpoints 228 | - get_task 229 | - delete_users 230 | - delete_channels 231 | - Add support for permissions v2 232 | - Add convenience helpers for shadow ban 233 | - Use json helper for unmarshal response in async 234 | - Add support for Python 3.10 235 | 236 | ## Sep 14, 2021 - 3.13.1 237 | 238 | - Tweak connection pool configuration for idle timeouts 239 | 240 | ## Sep 7, 2021 - 3.13.0 241 | 242 | - Add optional message into member updates 243 | 244 | ## Aug 31, 2021 - 3.12.2 245 | 246 | - Use post instead of get requests in query channels 247 | 248 | ## Aug 24, 2021 - 3.12.1 249 | 250 | - Add namespace for ease of use and consistency in campaign update endpoints. 251 | 252 | ## Aug 23, 2021 - 3.12.0 253 | 254 | - Add support for channel exports. 255 | 256 | ## Aug 20, 2021 - 3.11.2 257 | 258 | - Set base url to edge, there is no need to set a region anymore. 259 | - Fix file uploads from a local file. 260 | 261 | ## Aug 19, 2021 - 3.11.1 262 | 263 | - Fix base path for listing campaigns 264 | 265 | ## Jul 5, 2021 - 3.11.0 266 | 267 | - Update existing permission related API (/roles and /permissions endpoints) 268 | 269 | ## Jul 2, 2021 - 3.10.0 270 | 271 | - Add support for campaign API (early alpha, can change) 272 | 273 | ## Jul 1, 2021 - 3.9.0 274 | 275 | - Add support for search improvements (i.e. next, prev, sorting, more filters) 276 | 277 | ## May 26, 2021 - 3.8.0 278 | 279 | - Add query_message_flags endpoint support 280 | - Add token revoke support 281 | - Run CI sequentially for different Python versions 282 | - Drop codecov 283 | 284 | ## March 10, 2021 - 3.7.0 285 | 286 | - Add get_rate_limits endpoint support 287 | 288 | ## March 10, 2021 - 3.6.0 289 | 290 | - Add custom permission/role lifecycle endpoints 291 | 292 | ## February 26, 2021 - 3.5.0 293 | 294 | - Support additional claims for jwt token generation 295 | 296 | ## February 22, 2021 - 3.4.0 297 | 298 | - Add channel mute/unmute for a user 299 | 300 | ## February 22, 2021 - 3.3.0 301 | 302 | - Add options to send message 303 | - for example to silence push notification on this message: `channel.send_message({"text": "hi"}, user_id, skip_push=True)` 304 | 305 | ## February 9, 2021 - 3.2.1 306 | 307 | - Drop brotli dependency in async, causes install issues in Darwin 308 | - upstream needs updates 309 | 310 | ## February 8, 2021 - 3.2.0 311 | 312 | - Add channel partial update 313 | 314 | ## January 22, 2021 - 3.1.1 315 | 316 | - Bump pyjwt to 2.x 317 | 318 | ## January 5, 2021 - 3.1.0 319 | 320 | - Add check SQS helper 321 | 322 | ## December 17, 2020 - 3.0.1 323 | 324 | - Use f strings internally 325 | - Use github actions and CI requirements to setup 326 | 327 | ## December 9, 2020 - 3.0.0 328 | 329 | - Add async version of the client 330 | - Make double invite accept/reject noop 331 | 332 | ## November 12, 2020 - 2.0.0 333 | 334 | - Drop Python 3.5 and add 3.9 335 | 336 | ## November 12, 2020 - 1.7.1 337 | 338 | - Normalize sort parameter in query endpoints 339 | 340 | ## October 20, 2020 - 1.7.0 341 | 342 | - Added support for blocklists 343 | 344 | ## September 24, 2020 - 1.6.0 345 | 346 | - Support for creating custom commands 347 | 348 | ## September 10, 2020 - 1.5.0 349 | 350 | - Support for query members 351 | - Prefer literals over constructors to simplify code 352 | 353 | ## July 23, 2020 - 1.4.0 354 | 355 | - Support timeout while muting a user 356 | 357 | ## June 23, 2020 - 1.3.1 358 | 359 | - Set a generic user agent for file/image get to prevent bot detection 360 | 361 | ## Apr 28, 2020 - 1.3.0 362 | 363 | - Drop six dependency 364 | - `verify_webhook` is affected and expects bytes for body parameter 365 | - Add 3.8 support 366 | 367 | ## Apr 17, 2020 - 1.2.2 368 | 369 | - Fix version number 370 | 371 | ## Apr 17, 2020 - 1.2.1 372 | 373 | - Allow to override client.base_url 374 | 375 | ## Mar 29, 2020 - 1.2.0 376 | 377 | - Add support for invites 378 | 379 | ## Mar 29, 2020 - 1.1.1 380 | 381 | - Fix client.create_token: returns a string now 382 | 383 | ## Mar 3, 2020 - 1.1. 384 | 385 | - Add support for client.get_message 386 | 387 | ## Nov 7, 2019 - 1.0.2 388 | 389 | - Bump crypto requirements 390 | 391 | ## Oct 21th, 2019 - 1.0.1 392 | 393 | - Fixed app update method parameter passing 394 | 395 | ## Oct 19th, 2019 - 1.0.0 396 | 397 | - Added support for user partial update endpoint 398 | -------------------------------------------------------------------------------- /stream_chat/base/channel.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Any, Awaitable, Dict, Iterable, List, Optional, Union 3 | 4 | from stream_chat.base.client import StreamChatInterface 5 | from stream_chat.base.exceptions import StreamChannelException 6 | from stream_chat.types.stream_response import StreamResponse 7 | 8 | 9 | class ChannelInterface(abc.ABC): 10 | def __init__( 11 | self, 12 | client: StreamChatInterface, 13 | channel_type: str, 14 | channel_id: str = None, 15 | custom_data: Dict = None, 16 | ): 17 | self.channel_type = channel_type 18 | self.id = channel_id 19 | self.client = client 20 | self.custom_data = custom_data or {} 21 | 22 | @property 23 | def url(self) -> str: 24 | if self.id is None: 25 | raise StreamChannelException("channel does not have an id") 26 | return f"channels/{self.channel_type}/{self.id}" 27 | 28 | @property 29 | def cid(self) -> str: 30 | if self.id is None: 31 | raise StreamChannelException("channel does not have an id") 32 | return f"{self.channel_type}:{self.id}" 33 | 34 | @abc.abstractmethod 35 | def send_message( 36 | self, message: Dict, user_id: str, **options: Any 37 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 38 | """ 39 | Send a message to this channel 40 | 41 | :param message: the Message object 42 | :param user_id: the ID of the user that created the message 43 | :return: the Server Response 44 | """ 45 | pass 46 | 47 | @abc.abstractmethod 48 | def send_event( 49 | self, event: Dict, user_id: str 50 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 51 | """ 52 | Send an event on this channel 53 | 54 | :param event: event data, ie {type: 'message.read'} 55 | :param user_id: the ID of the user sending the event 56 | :return: the Server Response 57 | """ 58 | pass 59 | 60 | @abc.abstractmethod 61 | def send_reaction( 62 | self, message_id: str, reaction: Dict, user_id: str 63 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 64 | """ 65 | Send a reaction about a message 66 | 67 | :param message_id: the message id 68 | :param reaction: the reaction object, ie {type: 'love'} 69 | :param user_id: the ID of the user that created the reaction 70 | :return: the Server Response 71 | """ 72 | pass 73 | 74 | @abc.abstractmethod 75 | def delete_reaction( 76 | self, message_id: str, reaction_type: str, user_id: str 77 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 78 | """ 79 | Delete a reaction by user and type 80 | 81 | :param message_id: the id of the message from which te remove the reaction 82 | :param reaction_type: the type of reaction that should be removed 83 | :param user_id: the id of the user 84 | :return: the Server Response 85 | """ 86 | pass 87 | 88 | @abc.abstractmethod 89 | def create( 90 | self, user_id: str, **options: Any 91 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 92 | """ 93 | Create the channel 94 | 95 | :param user_id: the ID of the user creating this channel 96 | :return: 97 | """ 98 | pass 99 | 100 | @abc.abstractmethod 101 | def get_messages( 102 | self, message_ids: List[str] 103 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 104 | """ 105 | Gets many messages 106 | 107 | :param message_ids: list of message ids to returns 108 | :return: 109 | """ 110 | pass 111 | 112 | @abc.abstractmethod 113 | def query(self, **options: Any) -> Union[StreamResponse, Awaitable[StreamResponse]]: 114 | """ 115 | Query the API for this channel, get messages, members or other channel fields 116 | 117 | :param options: the query options, check docs on https://getstream.io/chat/docs/ 118 | :return: Returns a query response 119 | """ 120 | pass 121 | 122 | @abc.abstractmethod 123 | def query_members( 124 | self, filter_conditions: Dict, sort: List[Dict] = None, **options: Any 125 | ) -> Union[List[Dict], Awaitable[List[Dict]]]: 126 | """ 127 | Query the API for this channel to filter, sort and paginate its members efficiently. 128 | 129 | :param filter_conditions: filters, checks docs on https://getstream.io/chat/docs/ 130 | :param sort: sorting field and direction slice, check docs on https://getstream.io/chat/docs/ 131 | :param options: pagination or members based channel searching details 132 | :return: Returns members response 133 | 134 | eg. 135 | channel.query_members(filter_conditions={"name": "tommaso"}, 136 | sort=[{"field": "created_at", "direction": -1}], 137 | offset=0, 138 | limit=10) 139 | """ 140 | pass 141 | 142 | @abc.abstractmethod 143 | def update( 144 | self, channel_data: Dict, update_message: Dict = None 145 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 146 | """ 147 | Edit the channel's custom properties 148 | 149 | :param channel_data: the object to update the custom properties of this channel with 150 | :param update_message: optional update message 151 | :return: The server response 152 | """ 153 | pass 154 | 155 | @abc.abstractmethod 156 | def update_partial( 157 | self, to_set: Dict = None, to_unset: Iterable[str] = None 158 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 159 | """ 160 | Update channel partially 161 | 162 | :param to_set: a dictionary of key/value pairs to set or to override 163 | :param to_unset: a list of keys to clear 164 | """ 165 | pass 166 | 167 | @abc.abstractmethod 168 | def delete( 169 | self, hard: bool = False 170 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 171 | """ 172 | Delete the channel. Messages are permanently removed. 173 | 174 | :return: The server response 175 | """ 176 | pass 177 | 178 | @abc.abstractmethod 179 | def truncate( 180 | self, **options: Any 181 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 182 | """ 183 | Removes all messages from the channel 184 | 185 | :param options: the query options, check docs on https://getstream.io/chat/docs/python/channel_delete/?language=python#truncating-a-channel 186 | :return: The server response 187 | """ 188 | pass 189 | 190 | @abc.abstractmethod 191 | def add_members( 192 | self, members: Iterable[Dict], message: Dict = None, **options: Any 193 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 194 | """ 195 | Adds members to the channel 196 | 197 | :param members: member objects to add 198 | :param message: An optional to show 199 | :param options: additional options such as hide_history or hide_history_before 200 | :return: 201 | """ 202 | pass 203 | 204 | @abc.abstractmethod 205 | def assign_roles( 206 | self, members: Iterable[Dict], message: Dict = None 207 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 208 | """ 209 | Assigns new roles to specified channel members 210 | 211 | :param members: member objects with role information 212 | :param message: An optional to show 213 | :return: 214 | """ 215 | pass 216 | 217 | @abc.abstractmethod 218 | def invite_members( 219 | self, user_ids: Iterable[str], message: Dict = None 220 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 221 | """ 222 | invite members to the channel 223 | 224 | :param user_ids: user IDs to invite 225 | :param message: An optional to show 226 | :return: 227 | """ 228 | pass 229 | 230 | @abc.abstractmethod 231 | def add_moderators( 232 | self, user_ids: Iterable[str], message: Dict = None 233 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 234 | """ 235 | Adds moderators to the channel 236 | 237 | :param user_ids: user IDs to add as moderators 238 | :param message: An optional to show 239 | :return: 240 | """ 241 | pass 242 | 243 | @abc.abstractmethod 244 | def remove_members( 245 | self, user_ids: Iterable[str], message: Dict = None 246 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 247 | """ 248 | Remove members from the channel 249 | 250 | :param user_ids: user IDs to remove from the member list 251 | :param message: An optional to show 252 | :return: 253 | """ 254 | pass 255 | 256 | @abc.abstractmethod 257 | def demote_moderators( 258 | self, user_ids: Iterable[str], message: Dict = None 259 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 260 | """ 261 | Demotes moderators from the channel 262 | 263 | :param user_ids: user IDs to demote 264 | :param message: An optional to show 265 | :return: 266 | """ 267 | pass 268 | 269 | @abc.abstractmethod 270 | def mark_read( 271 | self, user_id: str, **data: Any 272 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 273 | """ 274 | Send the mark read event for this user, only works if the `read_events` setting is enabled 275 | 276 | :param user_id: the user ID for the event 277 | :param data: additional data, ie {"message_id": last_message_id} 278 | :return: The server response 279 | """ 280 | pass 281 | 282 | @abc.abstractmethod 283 | def mark_unread( 284 | self, user_id: str, **data: Any 285 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 286 | """ 287 | Marks channel as unread from a specific message, thread, or timestamp, if thread_id is provided in data 288 | a thread will be searched, otherwise a message. 289 | 290 | :param user_id: the user ID for the event 291 | :param data: additional data, ie {"message_id": last_message_id}, {"thread_id": thread_id}, or {"message_timestamp": timestamp} 292 | :return: The server response 293 | """ 294 | pass 295 | 296 | @abc.abstractmethod 297 | def get_replies( 298 | self, parent_id: str, **options: Any 299 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 300 | """ 301 | List the message replies for a parent message 302 | 303 | :param parent_id: The message parent id, ie the top of the thread 304 | :param options: Pagination params, ie {limit:10, id_lte: 10} 305 | :return: A response with a list of messages 306 | """ 307 | pass 308 | 309 | @abc.abstractmethod 310 | def get_reactions( 311 | self, message_id: str, **options: Any 312 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 313 | """ 314 | List the reactions, supports pagination 315 | 316 | :param message_id: The message id 317 | :param options: Pagination params, ie {"limit":10, "id_lte": 10} 318 | :return: A response with a list of reactions 319 | """ 320 | pass 321 | 322 | @abc.abstractmethod 323 | def ban_user( 324 | self, target_id: str, **options: Any 325 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 326 | """ 327 | Bans a user from this channel 328 | 329 | :param target_id: the ID of the user to ban 330 | :param options: additional ban options, ie {"timeout": 3600, "reason": "offensive language is not allowed here"} 331 | :return: The server response 332 | """ 333 | pass 334 | 335 | @abc.abstractmethod 336 | def unban_user( 337 | self, target_id: str, **options: Any 338 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 339 | """ 340 | Removes the ban for a user on this channel 341 | 342 | :param target_id: the ID of the user to unban 343 | :return: The server response 344 | """ 345 | pass 346 | 347 | @abc.abstractmethod 348 | def accept_invite( 349 | self, user_id: str, **data: Any 350 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 351 | """ 352 | Accepts an invitation to this channel. 353 | """ 354 | pass 355 | 356 | @abc.abstractmethod 357 | def reject_invite( 358 | self, user_id: str, **data: Any 359 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 360 | """ 361 | Rejects an invitation to this channel. 362 | """ 363 | pass 364 | 365 | @abc.abstractmethod 366 | def send_file( 367 | self, url: str, name: str, user: Dict, content_type: str = None 368 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 369 | """ 370 | Uploads a file. 371 | This functionality defaults to using the Stream CDN. If you would like, you can 372 | easily change the logic to upload to your own CDN of choice. 373 | """ 374 | pass 375 | 376 | @abc.abstractmethod 377 | def send_image( 378 | self, url: str, name: str, user: Dict, content_type: str = None 379 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 380 | """ 381 | Uploads an image. 382 | Stream supported image types are: image/bmp, image/gif, image/jpeg, image/png, image/webp, 383 | image/heic, image/heic-sequence, image/heif, image/heif-sequence, image/svg+xml. 384 | You can set a more restrictive list for your application if needed. 385 | The maximum file size is 100MB. 386 | """ 387 | pass 388 | 389 | @abc.abstractmethod 390 | def delete_file(self, url: str) -> Union[StreamResponse, Awaitable[StreamResponse]]: 391 | """ 392 | Deletes a file by file url. 393 | """ 394 | pass 395 | 396 | @abc.abstractmethod 397 | def delete_image( 398 | self, url: str 399 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 400 | """ 401 | Deletes an image by image url. 402 | """ 403 | pass 404 | 405 | @abc.abstractmethod 406 | def hide(self, user_id: str) -> Union[StreamResponse, Awaitable[StreamResponse]]: 407 | """ 408 | Removes a channel from query channel requests for that user until a new message is added. 409 | Use `show` to cancel this operation. 410 | """ 411 | pass 412 | 413 | @abc.abstractmethod 414 | def show(self, user_id: str) -> Union[StreamResponse, Awaitable[StreamResponse]]: 415 | """ 416 | Shows a previously hidden channel. 417 | Use `hide` to hide a channel. 418 | """ 419 | pass 420 | 421 | @abc.abstractmethod 422 | def mute( 423 | self, user_id: str, expiration: int = None 424 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 425 | """ 426 | Mutes a channel. 427 | Messages added to a muted channel will not trigger push notifications, nor change the 428 | unread count for the users that muted it. By default, mutes stay in place indefinitely 429 | until the user removes it; however, you can optionally set an expiration time. The list 430 | of muted channels and their expiration time is returned when the user connects. 431 | """ 432 | pass 433 | 434 | @abc.abstractmethod 435 | def unmute(self, user_id: str) -> Union[StreamResponse, Awaitable[StreamResponse]]: 436 | """ 437 | Unmutes a channel. 438 | Messages added to a muted channel will not trigger push notifications, nor change the 439 | unread count for the users that muted it. By default, mutes stay in place indefinitely 440 | until the user removes it; however, you can optionally set an expiration time. The list 441 | of muted channels and their expiration time is returned when the user connects. 442 | """ 443 | pass 444 | 445 | @abc.abstractmethod 446 | def pin(self, user_id: str) -> Union[StreamResponse, Awaitable[StreamResponse]]: 447 | """ 448 | Pins a channel 449 | Allows a user to pin the channel (only for themselves) 450 | """ 451 | pass 452 | 453 | @abc.abstractmethod 454 | def unpin(self, user_id: str) -> Union[StreamResponse, Awaitable[StreamResponse]]: 455 | """ 456 | Unpins a channel 457 | Allows a user to unpin the channel (only for themselves) 458 | """ 459 | pass 460 | 461 | @abc.abstractmethod 462 | def archive(self, user_id: str) -> Union[StreamResponse, Awaitable[StreamResponse]]: 463 | """ 464 | Pins a channel 465 | Allows a user to archive the channel (only for themselves) 466 | """ 467 | pass 468 | 469 | @abc.abstractmethod 470 | def unarchive( 471 | self, user_id: str 472 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 473 | """ 474 | Unpins a channel 475 | Allows a user to unpin the channel (only for themselves) 476 | """ 477 | pass 478 | 479 | @abc.abstractmethod 480 | def update_member_partial( 481 | self, user_id: str, to_set: Dict = None, to_unset: Iterable[str] = None 482 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 483 | """ 484 | Update channel member partially 485 | 486 | :param to_set: a dictionary of key/value pairs to set or to override 487 | :param to_unset: a list of keys to clear 488 | """ 489 | pass 490 | 491 | @abc.abstractmethod 492 | def create_draft( 493 | self, message: Dict, user_id: str 494 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 495 | """ 496 | Creates or updates a draft message in a channel. 497 | 498 | :param message: The message object 499 | :param user_id: The ID of the user creating the draft 500 | :return: The Server Response 501 | """ 502 | pass 503 | 504 | @abc.abstractmethod 505 | def delete_draft( 506 | self, user_id: str, parent_id: Optional[str] = None 507 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 508 | """ 509 | Deletes a draft message from a channel. 510 | 511 | :param user_id: The ID of the user who owns the draft 512 | :param parent_id: Optional ID of the parent message if this is a thread draft 513 | :return: The Server Response 514 | """ 515 | pass 516 | 517 | @abc.abstractmethod 518 | def get_draft( 519 | self, user_id: str, parent_id: Optional[str] = None 520 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 521 | """ 522 | Retrieves a draft message from a channel. 523 | 524 | :param user_id: The ID of the user who owns the draft 525 | :param parent_id: Optional ID of the parent message if this is a thread draft 526 | :return: The Server Response 527 | """ 528 | pass 529 | 530 | @abc.abstractmethod 531 | def add_filter_tags( 532 | self, tags: Iterable[str], message: Dict = None 533 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 534 | """ 535 | Adds filter tags to the channel 536 | 537 | :param tags: list of tags to add 538 | :param message: optional system message 539 | :return: The server response 540 | """ 541 | pass 542 | 543 | @abc.abstractmethod 544 | def remove_filter_tags( 545 | self, tags: Iterable[str], message: Dict = None 546 | ) -> Union[StreamResponse, Awaitable[StreamResponse]]: 547 | """ 548 | Removes filter tags from the channel 549 | 550 | :param tags: list of tags to remove 551 | :param message: optional system message 552 | :return: The server response 553 | """ 554 | pass 555 | 556 | 557 | def add_user_id(payload: Dict, user_id: str) -> Dict: 558 | return {**payload, "user": {"id": user_id}} 559 | --------------------------------------------------------------------------------