Skip to content

Commit 1bff284

Browse files
committed
add rcs message type, revoke rcs message, mark whatsapp as read
1 parent 1ae0990 commit 1bff284

File tree

8 files changed

+294
-1
lines changed

8 files changed

+294
-1
lines changed

messages/CHANGES.md

+4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
# 1.2.0
2+
- Add RCS channel support
3+
- Add methods to revoke an RCS message and mark a WhatsApp message as read
4+
15
# 1.1.1
26
- Update minimum dependency version
37

messages/src/vonage_messages/messages.py

+40
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,43 @@ def send(self, message: BaseMessage) -> SendMessageResponse:
4444
message.model_dump(by_alias=True, exclude_none=True) or message,
4545
)
4646
return SendMessageResponse(**response)
47+
48+
@validate_call
49+
def mark_whatsapp_message_read(self, message_uuid: str) -> None:
50+
"""Mark a WhatsApp message as read.
51+
52+
Note: to use this method, update the `api_host` attribute of the
53+
`vonage_http_client.HttpClientOptions` object to the API endpoint
54+
corresponding to the region where the WhatsApp number is hosted.
55+
56+
For example, to use the EU API endpoint, set the `api_host`
57+
attribute to 'https://api-eu.vonage.com'.
58+
59+
Args:
60+
message_uuid (str): The unique identifier of the WhatsApp message to mark as read.
61+
"""
62+
self._http_client.patch(
63+
self._http_client.api_host,
64+
f'/v1/messages/{message_uuid}',
65+
{'status': 'read'},
66+
)
67+
68+
@validate_call
69+
def revoke_rcs_message(self, message_uuid: str) -> None:
70+
"""Revoke an RCS message.
71+
72+
Note: to use this method, update the `api_host` attribute of the
73+
`vonage_http_client.HttpClientOptions` object to the API endpoint
74+
corresponding to the region where the RCS number is hosted.
75+
76+
For example, to use the EU API endpoint, set the `api_host`
77+
attribute to 'https://api-eu.vonage.com'.
78+
79+
Args:
80+
message_uuid (str): The unique identifier of the RCS message to revoke.
81+
"""
82+
self._http_client.patch(
83+
self._http_client.api_host,
84+
f'/v1/messages/{message_uuid}',
85+
{'status': 'revoked'},
86+
)

messages/src/vonage_messages/models/__init__.py

+7
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
MessengerVideo,
1111
)
1212
from .mms import MmsAudio, MmsImage, MmsResource, MmsVcard, MmsVideo
13+
from .rcs import RcsCustom, RcsFile, RcsImage, RcsResource, RcsText, RcsVideo
1314
from .sms import Sms, SmsOptions
1415
from .viber import (
1516
ViberAction,
@@ -62,6 +63,12 @@
6263
'MmsResource',
6364
'MmsVcard',
6465
'MmsVideo',
66+
'RcsCustom',
67+
'RcsFile',
68+
'RcsImage',
69+
'RcsResource',
70+
'RcsText',
71+
'RcsVideo',
6572
'Sms',
6673
'SmsOptions',
6774
'ViberAction',

messages/src/vonage_messages/models/enums.py

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class MessageType(str, Enum):
1616
class ChannelType(str, Enum):
1717
SMS = 'sms'
1818
MMS = 'mms'
19+
RCS = 'rcs'
1920
WHATSAPP = 'whatsapp'
2021
MESSENGER = 'messenger'
2122
VIBER = 'viber_service'
+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from typing import Optional
2+
3+
from pydantic import BaseModel, Field
4+
from vonage_utils.types import PhoneNumber
5+
6+
from .base_message import BaseMessage
7+
from .enums import ChannelType, MessageType
8+
9+
10+
class RcsResource(BaseModel):
11+
url: str
12+
13+
14+
class BaseRcs(BaseMessage):
15+
to: PhoneNumber
16+
from_: str = Field(..., serialization_alias='from', pattern='^[a-zA-Z0-9]+$')
17+
ttl: Optional[int] = Field(None, ge=300, le=259200)
18+
channel: ChannelType = ChannelType.RCS
19+
20+
21+
class RcsText(BaseRcs):
22+
text: str = Field(..., min_length=1, max_length=3072)
23+
message_type: MessageType = MessageType.TEXT
24+
25+
26+
class RcsImage(BaseRcs):
27+
image: RcsResource
28+
message_type: MessageType = MessageType.IMAGE
29+
30+
31+
class RcsVideo(BaseRcs):
32+
video: RcsResource
33+
message_type: MessageType = MessageType.VIDEO
34+
35+
36+
class RcsFile(BaseRcs):
37+
file: RcsResource
38+
message_type: MessageType = MessageType.FILE
39+
40+
41+
class RcsCustom(BaseRcs):
42+
custom: dict
43+
message_type: MessageType = MessageType.CUSTOM

messages/tests/data/not_found.json

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"type": "https://developer.vonage.com/api-errors#not-found",
3+
"title": "Not Found",
4+
"detail": "Message with ID asdf not found",
5+
"instance": "617431f2-06b7-4798-af36-1b8151df8359"
6+
}

messages/tests/test_messages.py

+65-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import responses
44
from pytest import raises
55
from vonage_http_client.errors import HttpRequestError
6-
from vonage_http_client.http_client import HttpClient
6+
from vonage_http_client.http_client import HttpClient, HttpClientOptions
77
from vonage_messages.messages import Messages
88
from vonage_messages.models import Sms
99
from vonage_messages.models.messenger import (
@@ -81,3 +81,67 @@ def test_http_client_property():
8181
http_client = HttpClient(get_mock_jwt_auth())
8282
messages = Messages(http_client)
8383
assert messages.http_client == http_client
84+
85+
86+
@responses.activate
87+
def test_mark_whatsapp_message_read():
88+
responses.add(
89+
responses.PATCH,
90+
'https://api-eu.vonage.com/v1/messages/1234567890',
91+
)
92+
messages = Messages(
93+
HttpClient(get_mock_jwt_auth(), HttpClientOptions(api_host='api-eu.vonage.com'))
94+
)
95+
messages.http_client.http_client_options.api_host = 'api-eu.vonage.com'
96+
messages.mark_whatsapp_message_read('1234567890')
97+
98+
99+
@responses.activate
100+
def test_mark_whatsapp_message_read_not_found():
101+
build_response(
102+
path,
103+
'PATCH',
104+
'https://api-eu.vonage.com/v1/messages/asdf',
105+
'not_found.json',
106+
404,
107+
)
108+
messages = Messages(
109+
HttpClient(get_mock_jwt_auth(), HttpClientOptions(api_host='api-eu.vonage.com'))
110+
)
111+
with raises(HttpRequestError) as e:
112+
messages.mark_whatsapp_message_read('asdf')
113+
114+
assert e.value.response.status_code == 404
115+
assert e.value.response.json()['title'] == 'Not Found'
116+
117+
118+
@responses.activate
119+
def test_revoke_rcs_message():
120+
responses.add(
121+
responses.PATCH,
122+
'https://api-eu.vonage.com/v1/messages/asdf',
123+
)
124+
messages = Messages(
125+
HttpClient(get_mock_jwt_auth(), HttpClientOptions(api_host='api-eu.vonage.com'))
126+
)
127+
messages.http_client.http_client_options.api_host = 'api-eu.vonage.com'
128+
messages.revoke_rcs_message('asdf')
129+
130+
131+
@responses.activate
132+
def test_revoke_rcs_message_not_found():
133+
build_response(
134+
path,
135+
'PATCH',
136+
'https://api-eu.vonage.com/v1/messages/asdf',
137+
'not_found.json',
138+
404,
139+
)
140+
messages = Messages(
141+
HttpClient(get_mock_jwt_auth(), HttpClientOptions(api_host='api-eu.vonage.com'))
142+
)
143+
with raises(HttpRequestError) as e:
144+
messages.revoke_rcs_message('asdf')
145+
146+
assert e.value.response.status_code == 404
147+
assert e.value.response.json()['title'] == 'Not Found'

messages/tests/test_rcs_models.py

+128
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
from vonage_messages.models import (
2+
RcsCustom,
3+
RcsFile,
4+
RcsImage,
5+
RcsResource,
6+
RcsText,
7+
RcsVideo,
8+
)
9+
10+
11+
def test_create_rcs_text():
12+
rcs_model = RcsText(
13+
to='1234567890',
14+
from_='asdf1234',
15+
text='Hello, World!',
16+
)
17+
rcs_dict = {
18+
'to': '1234567890',
19+
'from': 'asdf1234',
20+
'text': 'Hello, World!',
21+
'channel': 'rcs',
22+
'message_type': 'text',
23+
}
24+
25+
assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict
26+
27+
28+
def test_create_rcs_text_all_fields():
29+
rcs_model = RcsText(
30+
to='1234567890',
31+
from_='asdf1234',
32+
text='Hello, World!',
33+
client_ref='client-ref',
34+
webhook_url='https://example.com',
35+
ttl=600,
36+
)
37+
rcs_dict = {
38+
'to': '1234567890',
39+
'from': 'asdf1234',
40+
'text': 'Hello, World!',
41+
'client_ref': 'client-ref',
42+
'webhook_url': 'https://example.com',
43+
'ttl': 600,
44+
'channel': 'rcs',
45+
'message_type': 'text',
46+
}
47+
48+
assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict
49+
50+
51+
def test_create_rcs_image():
52+
rcs_model = RcsImage(
53+
to='1234567890',
54+
from_='asdf1234',
55+
image=RcsResource(
56+
url='https://example.com/image.jpg',
57+
),
58+
)
59+
rcs_dict = {
60+
'to': '1234567890',
61+
'from': 'asdf1234',
62+
'image': {
63+
'url': 'https://example.com/image.jpg',
64+
},
65+
'channel': 'rcs',
66+
'message_type': 'image',
67+
}
68+
69+
assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict
70+
71+
72+
def test_create_rcs_video():
73+
rcs_model = RcsVideo(
74+
to='1234567890',
75+
from_='asdf1234',
76+
video=RcsResource(
77+
url='https://example.com/video.mp4',
78+
),
79+
)
80+
rcs_dict = {
81+
'to': '1234567890',
82+
'from': 'asdf1234',
83+
'video': {
84+
'url': 'https://example.com/video.mp4',
85+
},
86+
'channel': 'rcs',
87+
'message_type': 'video',
88+
}
89+
90+
assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict
91+
92+
93+
def test_create_rcs_file():
94+
rcs_model = RcsFile(
95+
to='1234567890',
96+
from_='asdf1234',
97+
file=RcsResource(
98+
url='https://example.com/file.pdf',
99+
),
100+
)
101+
rcs_dict = {
102+
'to': '1234567890',
103+
'from': 'asdf1234',
104+
'file': {
105+
'url': 'https://example.com/file.pdf',
106+
},
107+
'channel': 'rcs',
108+
'message_type': 'file',
109+
}
110+
111+
assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict
112+
113+
114+
def test_create_rcs_custom():
115+
rcs_model = RcsCustom(
116+
to='1234567890',
117+
from_='asdf1234',
118+
custom={'key': 'value'},
119+
)
120+
rcs_dict = {
121+
'to': '1234567890',
122+
'from': 'asdf1234',
123+
'custom': {'key': 'value'},
124+
'channel': 'rcs',
125+
'message_type': 'custom',
126+
}
127+
128+
assert rcs_model.model_dump(by_alias=True, exclude_none=True) == rcs_dict

0 commit comments

Comments
 (0)