Skip to content

Commit b3702c9

Browse files
[CHAT-530] Message reminders (#191)
* feat: implemented message reminders feature * chore: added docker targets for development envs * chore: fixed linting errors * updated testcases to enable reminders on channel * chore: lint fixes * chore: lint fixes * chore: fixed, failing specs
1 parent 6364604 commit b3702c9

File tree

9 files changed

+628
-2
lines changed

9 files changed

+628
-2
lines changed

CONTRIBUTING.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,57 @@ We use Black (code formatter), isort (code formatter), flake8 (linter) and mypy
3636
$ make lint
3737
```
3838

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+
3990
## Commit message convention
4091

4192
Since we're autogenerating our [CHANGELOG](./CHANGELOG.md), we need to follow a specific commit message convention.

Makefile

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
STREAM_KEY ?= NOT_EXIST
22
STREAM_SECRET ?= NOT_EXIST
3+
PYTHON_VERSION ?= 3.8
34

45
# These targets are not files
5-
.PHONY: help check test lint lint-fix
6+
.PHONY: help check test lint lint-fix test_with_docker lint_with_docker lint-fix_with_docker
67

78
help: ## Display this help message
89
@echo "Please use \`make <target>\` where <target> is one of"
@@ -14,7 +15,7 @@ lint: ## Run linters
1415
flake8 --ignore=E501,W503 stream_chat
1516
mypy stream_chat
1617

17-
lint-fix:
18+
lint-fix: ## Fix linting issues
1819
black stream_chat
1920
isort stream_chat
2021

@@ -23,6 +24,17 @@ test: ## Run tests
2324

2425
check: lint test ## Run linters + tests
2526

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+
2638
reviewdog:
2739
black --check --diff --quiet stream_chat | reviewdog -f=diff -f.diff.strip=0 -filter-mode="diff_context" -name=black -reporter=github-pr-review
2840
flake8 --ignore=E501,W503 stream_chat | reviewdog -f=flake8 -name=flake8 -reporter=github-pr-review

stream_chat/async_chat/client.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -871,6 +871,91 @@ async def query_drafts(
871871

872872
return await self.post("drafts/query", data=data)
873873

874+
async def create_reminder(
875+
self,
876+
message_id: str,
877+
user_id: str,
878+
remind_at: Optional[datetime.datetime] = None,
879+
) -> StreamResponse:
880+
"""
881+
Creates a reminder for a message.
882+
883+
:param message_id: The ID of the message to create a reminder for
884+
:param user_id: The ID of the user creating the reminder
885+
:param remind_at: When to remind the user (optional)
886+
:return: API response
887+
"""
888+
data = {"user_id": user_id}
889+
remind_at_timestamp = ""
890+
if remind_at is not None:
891+
if isinstance(remind_at, datetime.datetime):
892+
remind_at_timestamp = remind_at.isoformat()
893+
else:
894+
remind_at_timestamp = str(remind_at)
895+
896+
data["remind_at"] = remind_at_timestamp
897+
898+
return await self.post(f"messages/{message_id}/reminders", data=data)
899+
900+
async def update_reminder(
901+
self,
902+
message_id: str,
903+
user_id: str,
904+
remind_at: Optional[datetime.datetime] = None,
905+
) -> StreamResponse:
906+
"""
907+
Updates a reminder for a message.
908+
909+
:param message_id: The ID of the message with the reminder
910+
:param user_id: The ID of the user who owns the reminder
911+
:param remind_at: When to remind the user (optional)
912+
:return: API response
913+
"""
914+
data = {"user_id": user_id}
915+
remind_at_timestamp = ""
916+
if remind_at is not None:
917+
if isinstance(remind_at, datetime.datetime):
918+
remind_at_timestamp = remind_at.isoformat()
919+
else:
920+
remind_at_timestamp = str(remind_at)
921+
922+
data["remind_at"] = remind_at_timestamp
923+
return await self.patch(f"messages/{message_id}/reminders", data=data)
924+
925+
async def delete_reminder(self, message_id: str, user_id: str) -> StreamResponse:
926+
"""
927+
Deletes a reminder for a message.
928+
929+
:param message_id: The ID of the message with the reminder
930+
:param user_id: The ID of the user who owns the reminder
931+
:return: API response
932+
"""
933+
return await self.delete(
934+
f"messages/{message_id}/reminders", params={"user_id": user_id}
935+
)
936+
937+
async def query_reminders(
938+
self,
939+
user_id: str,
940+
filter_conditions: Dict = None,
941+
sort: List[Dict] = None,
942+
**options: Any,
943+
) -> StreamResponse:
944+
"""
945+
Queries reminders based on filter conditions.
946+
947+
:param user_id: The ID of the user whose reminders to query
948+
:param filter_conditions: Conditions to filter reminders
949+
:param sort: Sort parameters (default: [{ field: 'remind_at', direction: 1 }])
950+
:param options: Additional query options like limit, offset
951+
:return: API response with reminders
952+
"""
953+
params = options.copy()
954+
params["filter_conditions"] = filter_conditions or {}
955+
params["sort"] = sort or [{"field": "remind_at", "direction": 1}]
956+
params["user_id"] = user_id
957+
return await self.post("reminders/query", data=params)
958+
874959
async def close(self) -> None:
875960
await self.session.close()
876961

stream_chat/base/client.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1439,6 +1439,72 @@ def query_drafts(
14391439
) -> Union[StreamResponse, Awaitable[StreamResponse]]:
14401440
pass
14411441

1442+
@abc.abstractmethod
1443+
def create_reminder(
1444+
self,
1445+
message_id: str,
1446+
user_id: str,
1447+
remind_at: Optional[datetime.datetime] = None,
1448+
) -> Union[StreamResponse, Awaitable[StreamResponse]]:
1449+
"""
1450+
Creates a reminder for a message.
1451+
1452+
:param message_id: The ID of the message to create a reminder for
1453+
:param user_id: The ID of the user creating the reminder
1454+
:param remind_at: When to remind the user (optional)
1455+
:return: API response
1456+
"""
1457+
pass
1458+
1459+
@abc.abstractmethod
1460+
def update_reminder(
1461+
self,
1462+
message_id: str,
1463+
user_id: str,
1464+
remind_at: Optional[datetime.datetime] = None,
1465+
) -> Union[StreamResponse, Awaitable[StreamResponse]]:
1466+
"""
1467+
Updates a reminder for a message.
1468+
1469+
:param message_id: The ID of the message with the reminder
1470+
:param user_id: The ID of the user who owns the reminder
1471+
:param remind_at: When to remind the user (optional)
1472+
:return: API response
1473+
"""
1474+
pass
1475+
1476+
@abc.abstractmethod
1477+
def delete_reminder(
1478+
self, message_id: str, user_id: str
1479+
) -> Union[StreamResponse, Awaitable[StreamResponse]]:
1480+
"""
1481+
Deletes a reminder for a message.
1482+
1483+
:param message_id: The ID of the message with the reminder
1484+
:param user_id: The ID of the user who owns the reminder
1485+
:return: API response
1486+
"""
1487+
pass
1488+
1489+
@abc.abstractmethod
1490+
def query_reminders(
1491+
self,
1492+
user_id: str,
1493+
filter_conditions: Dict = None,
1494+
sort: List[Dict] = None,
1495+
**options: Any,
1496+
) -> Union[StreamResponse, Awaitable[StreamResponse]]:
1497+
"""
1498+
Queries reminders based on filter conditions.
1499+
1500+
:param user_id: The ID of the user whose reminders to query
1501+
:param filter_conditions: Conditions to filter reminders
1502+
:param sort: Sort parameters (default: [{ field: 'remind_at', direction: 1 }])
1503+
:param options: Additional query options like limit, offset
1504+
:return: API response with reminders
1505+
"""
1506+
pass
1507+
14421508
#####################
14431509
# Private methods #
14441510
#####################

stream_chat/client.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -824,3 +824,77 @@ def query_drafts(
824824
if options is not None:
825825
data.update(cast(dict, options))
826826
return self.post("drafts/query", data=data)
827+
828+
def create_reminder(
829+
self,
830+
message_id: str,
831+
user_id: str,
832+
remind_at: Optional[datetime.datetime] = None,
833+
) -> StreamResponse:
834+
"""
835+
Creates a reminder for a message.
836+
837+
:param message_id: The ID of the message to create a reminder for
838+
:param user_id: The ID of the user creating the reminder
839+
:param remind_at: When to remind the user (optional)
840+
:return: API response
841+
"""
842+
data = {"user_id": user_id}
843+
if remind_at is not None:
844+
# Format as ISO 8601 date string without microseconds
845+
data["remind_at"] = remind_at.strftime("%Y-%m-%dT%H:%M:%SZ")
846+
return self.post(f"messages/{message_id}/reminders", data=data)
847+
848+
def update_reminder(
849+
self,
850+
message_id: str,
851+
user_id: str,
852+
remind_at: Optional[datetime.datetime] = None,
853+
) -> StreamResponse:
854+
"""
855+
Updates a reminder for a message.
856+
857+
:param message_id: The ID of the message with the reminder
858+
:param user_id: The ID of the user who owns the reminder
859+
:param remind_at: When to remind the user (optional)
860+
:return: API response
861+
"""
862+
data = {"user_id": user_id}
863+
if remind_at is not None:
864+
# Format as ISO 8601 date string without microseconds
865+
data["remind_at"] = remind_at.strftime("%Y-%m-%dT%H:%M:%SZ")
866+
return self.patch(f"messages/{message_id}/reminders", data=data)
867+
868+
def delete_reminder(self, message_id: str, user_id: str) -> StreamResponse:
869+
"""
870+
Deletes a reminder for a message.
871+
872+
:param message_id: The ID of the message with the reminder
873+
:param user_id: The ID of the user who owns the reminder
874+
:return: API response
875+
"""
876+
return self.delete(
877+
f"messages/{message_id}/reminders", params={"user_id": user_id}
878+
)
879+
880+
def query_reminders(
881+
self,
882+
user_id: str,
883+
filter_conditions: Dict = None,
884+
sort: List[Dict] = None,
885+
**options: Any,
886+
) -> StreamResponse:
887+
"""
888+
Queries reminders based on filter conditions.
889+
890+
:param user_id: The ID of the user whose reminders to query
891+
:param filter_conditions: Conditions to filter reminders
892+
:param sort: Sort parameters (default: [{ field: 'remind_at', direction: 1 }])
893+
:param options: Additional query options like limit, offset
894+
:return: API response with reminders
895+
"""
896+
params = options.copy()
897+
params["filter_conditions"] = filter_conditions or {}
898+
params["sort"] = sort or [{"field": "remind_at", "direction": 1}]
899+
params["user_id"] = user_id
900+
return self.post("reminders/query", data=params)

stream_chat/tests/async_chat/test_channel.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,9 @@ async def test_get_messages(self, channel: Channel, random_user: Dict):
187187
assert len(resp["messages"]) == 1
188188

189189
async def test_mark_read(self, channel: Channel, random_user: Dict):
190+
member = {"user_id": random_user["id"]}
191+
await channel.add_members([member])
192+
190193
response = await channel.mark_read(random_user["id"])
191194
assert "event" in response
192195
assert response["event"]["type"] == "message.read"

0 commit comments

Comments
 (0)