Skip to content

Commit 608e484

Browse files
committed
[client] add devices and new messages types support
1 parent 7626afa commit 608e484

File tree

4 files changed

+357
-15
lines changed

4 files changed

+357
-15
lines changed

android_sms_gateway/ahttp.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,16 @@ async def post(
1313
self, url: str, payload: dict, *, headers: t.Optional[t.Dict[str, str]] = None
1414
) -> dict: ...
1515

16+
@abc.abstractmethod
17+
async def put(
18+
self, url: str, payload: dict, *, headers: t.Optional[t.Dict[str, str]] = None
19+
) -> dict: ...
20+
21+
@abc.abstractmethod
22+
async def patch(
23+
self, url: str, payload: dict, *, headers: t.Optional[t.Dict[str, str]] = None
24+
) -> dict: ...
25+
1626
@abc.abstractmethod
1727
async def delete(
1828
self, url: str, *, headers: t.Optional[t.Dict[str, str]] = None
@@ -86,6 +96,38 @@ async def post(
8696
response.raise_for_status()
8797
return await response.json()
8898

99+
async def put(
100+
self,
101+
url: str,
102+
payload: dict,
103+
*,
104+
headers: t.Optional[t.Dict[str, str]] = None,
105+
) -> dict:
106+
if self._session is None:
107+
raise ValueError("Session not initialized")
108+
109+
async with self._session.put(
110+
url, headers=headers, json=payload
111+
) as response:
112+
response.raise_for_status()
113+
return await response.json()
114+
115+
async def patch(
116+
self,
117+
url: str,
118+
payload: dict,
119+
*,
120+
headers: t.Optional[t.Dict[str, str]] = None,
121+
) -> dict:
122+
if self._session is None:
123+
raise ValueError("Session not initialized")
124+
125+
async with self._session.patch(
126+
url, headers=headers, json=payload
127+
) as response:
128+
response.raise_for_status()
129+
return await response.json()
130+
89131
async def delete(
90132
self, url: str, *, headers: t.Optional[t.Dict[str, str]] = None
91133
) -> None:
@@ -145,6 +187,34 @@ async def post(
145187

146188
return response.raise_for_status().json()
147189

190+
async def put(
191+
self,
192+
url: str,
193+
payload: dict,
194+
*,
195+
headers: t.Optional[t.Dict[str, str]] = None,
196+
) -> dict:
197+
if self._client is None:
198+
raise ValueError("Client not initialized")
199+
200+
response = await self._client.put(url, headers=headers, json=payload)
201+
202+
return response.raise_for_status().json()
203+
204+
async def patch(
205+
self,
206+
url: str,
207+
payload: dict,
208+
*,
209+
headers: t.Optional[t.Dict[str, str]] = None,
210+
) -> dict:
211+
if self._client is None:
212+
raise ValueError("Client not initialized")
213+
214+
response = await self._client.patch(url, headers=headers, json=payload)
215+
216+
return response.raise_for_status().json()
217+
148218
async def delete(
149219
self, url: str, *, headers: t.Optional[t.Dict[str, str]] = None
150220
) -> None:

android_sms_gateway/client.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,21 @@ def _encrypt(self, message: domain.Message) -> domain.Message:
4242
message = dataclasses.replace(
4343
message,
4444
is_encrypted=True,
45-
message=self.encryptor.encrypt(message.message),
45+
text_message=(
46+
domain.TextMessage(
47+
text=self.encryptor.encrypt(message.text_message.text)
48+
)
49+
if message.text_message
50+
else None
51+
),
52+
data_message=(
53+
domain.DataMessage(
54+
data=self.encryptor.encrypt(message.data_message.data),
55+
port=message.data_message.port,
56+
)
57+
if message.data_message
58+
else None
59+
),
4660
phone_numbers=[
4761
self.encryptor.encrypt(phone) for phone in message.phone_numbers
4862
],
@@ -177,6 +191,32 @@ def delete_webhook(self, _id: str) -> None:
177191

178192
self.http.delete(f"{self.base_url}/webhooks/{_id}", headers=self.headers)
179193

194+
def list_devices(self) -> t.List[domain.Device]:
195+
"""Lists all devices."""
196+
if self.http is None:
197+
raise ValueError("HTTP client not initialized")
198+
199+
return [
200+
domain.Device.from_dict(device)
201+
for device in self.http.get(
202+
f"{self.base_url}/devices", headers=self.headers
203+
)
204+
]
205+
206+
def remove_device(self, _id: str) -> None:
207+
"""Removes a device."""
208+
if self.http is None:
209+
raise ValueError("HTTP client not initialized")
210+
211+
self.http.delete(f"{self.base_url}/devices/{_id}", headers=self.headers)
212+
213+
def health_check(self) -> dict:
214+
"""Performs a health check."""
215+
if self.http is None:
216+
raise ValueError("HTTP client not initialized")
217+
218+
return self.http.get(f"{self.base_url}/health", headers=self.headers)
219+
180220

181221
class AsyncAPIClient(BaseClient):
182222
def __init__(
@@ -286,3 +326,29 @@ async def delete_webhook(self, _id: str) -> None:
286326
raise ValueError("HTTP client not initialized")
287327

288328
await self.http.delete(f"{self.base_url}/webhooks/{_id}", headers=self.headers)
329+
330+
async def list_devices(self) -> t.List[domain.Device]:
331+
"""Lists all devices."""
332+
if self.http is None:
333+
raise ValueError("HTTP client not initialized")
334+
335+
return [
336+
domain.Device.from_dict(device)
337+
for device in await self.http.get(
338+
f"{self.base_url}/devices", headers=self.headers
339+
)
340+
]
341+
342+
async def remove_device(self, _id: str) -> None:
343+
"""Removes a device."""
344+
if self.http is None:
345+
raise ValueError("HTTP client not initialized")
346+
347+
await self.http.delete(f"{self.base_url}/devices/{_id}", headers=self.headers)
348+
349+
async def health_check(self) -> dict:
350+
"""Performs a health check."""
351+
if self.http is None:
352+
raise ValueError("HTTP client not initialized")
353+
354+
return await self.http.get(f"{self.base_url}/health", headers=self.headers)

android_sms_gateway/domain.py

Lines changed: 146 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import base64
12
import dataclasses
3+
import datetime
4+
from io import BytesIO
25
import typing as t
36

47
from .enums import ProcessState, WebhookEvent, MessagePriority
@@ -9,31 +12,47 @@ def snake_to_camel(snake_str):
912
return components[0] + "".join(x.title() for x in components[1:])
1013

1114

12-
@dataclasses.dataclass(frozen=True)
15+
@dataclasses.dataclass(frozen=True, kw_only=True)
1316
class Message:
1417
"""
1518
Represents an SMS message.
1619
1720
Attributes:
18-
message (str): The message text.
19-
phone_numbers (List[str]): A list of phone numbers to send the message to.
20-
with_delivery_report (bool): Whether to request a delivery report. Defaults to True.
21-
is_encrypted (bool): Whether the message is encrypted. Defaults to False.
22-
id (Optional[str]): The message ID. Defaults to None.
23-
ttl (Optional[int]): The time-to-live in seconds. Defaults to None.
24-
sim_number (Optional[int]): The SIM number to use. Defaults to None.
25-
priority (Optional[MessagePriority]): The priority of the message. Defaults to None.
21+
phone_numbers (List[str]): Recipients (phone numbers).
22+
tex_message (Optional[TextMessage]): Text message.
23+
data_message (Optional[DataMessage]): Data message.
24+
priority (Optional[MessagePriority]): Priority.
25+
sim_number (Optional[int]): SIM card number (1-3), if not set - default SIM will be used.
26+
with_delivery_report (Optional[bool]): With delivery report.
27+
is_encrypted (Optional[bool]): Is encrypted.
28+
ttl (Optional[int]): Time to live in seconds (conflicts with `validUntil`).
29+
valid_until (Optional[str]): Valid until (conflicts with `ttl`).
30+
id (Optional[str]): ID (if not set - will be generated).
31+
device_id (Optional[str]): Optional device ID for explicit selection.
2632
"""
2733

28-
message: str
2934
phone_numbers: t.List[str]
35+
text_message: t.Optional["TextMessage"] = None
36+
data_message: t.Optional["DataMessage"] = None
37+
38+
priority: t.Optional[MessagePriority] = None
39+
sim_number: t.Optional[int] = None
3040
with_delivery_report: bool = True
3141
is_encrypted: bool = False
3242

33-
id: t.Optional[str] = None
3443
ttl: t.Optional[int] = None
35-
sim_number: t.Optional[int] = None
36-
priority: t.Optional[MessagePriority] = None
44+
valid_until: t.Optional[datetime.datetime] = None
45+
46+
id: t.Optional[str] = None
47+
device_id: t.Optional[str] = None
48+
49+
@property
50+
def content(self) -> str:
51+
if self.text_message:
52+
return self.text_message.text
53+
if self.data_message:
54+
return self.data_message.data
55+
raise ValueError("Message has no content")
3756

3857
def asdict(self) -> t.Dict[str, t.Any]:
3958
"""
@@ -43,12 +62,89 @@ def asdict(self) -> t.Dict[str, t.Any]:
4362
Dict[str, Any]: A dictionary representation of the message.
4463
"""
4564
return {
46-
snake_to_camel(field.name): getattr(self, field.name)
65+
snake_to_camel(field.name): (
66+
getattr(self, field.name).asdict()
67+
if hasattr(getattr(self, field.name), "asdict")
68+
else getattr(self, field.name)
69+
)
4770
for field in dataclasses.fields(self)
4871
if getattr(self, field.name) is not None
4972
}
5073

5174

75+
@dataclasses.dataclass(frozen=True)
76+
class DataMessage:
77+
"""
78+
Represents a data message.
79+
80+
Attributes:
81+
data (str): Base64-encoded payload.
82+
port (int): Destination port.
83+
"""
84+
85+
data: str
86+
port: int
87+
88+
def asdict(self) -> t.Dict[str, t.Any]:
89+
return {
90+
"data": self.data,
91+
"port": self.port,
92+
}
93+
94+
@classmethod
95+
def with_bytes(cls, data: bytes, port: int) -> "DataMessage":
96+
return cls(
97+
data=base64.b64encode(data).decode("utf-8"),
98+
port=port,
99+
)
100+
101+
@classmethod
102+
def from_dict(cls, payload: t.Dict[str, t.Any]) -> "DataMessage":
103+
"""Creates a DataMessage instance from a dictionary.
104+
105+
Args:
106+
payload: A dictionary containing the data message's data.
107+
108+
Returns:
109+
A DataMessage instance.
110+
"""
111+
return cls(
112+
data=payload["data"],
113+
port=payload["port"],
114+
)
115+
116+
117+
@dataclasses.dataclass(frozen=True)
118+
class TextMessage:
119+
"""
120+
Represents a text message.
121+
122+
Attributes:
123+
text (str): Message text.
124+
"""
125+
126+
text: str
127+
128+
def asdict(self) -> t.Dict[str, t.Any]:
129+
return {
130+
"text": self.text,
131+
}
132+
133+
@classmethod
134+
def from_dict(cls, payload: t.Dict[str, t.Any]) -> "TextMessage":
135+
"""Creates a TextMessage instance from a dictionary.
136+
137+
Args:
138+
payload: A dictionary containing the text message's data.
139+
140+
Returns:
141+
A TextMessage instance.
142+
"""
143+
return cls(
144+
text=payload["text"],
145+
)
146+
147+
52148
@dataclasses.dataclass(frozen=True)
53149
class RecipientState:
54150
phone_number: str
@@ -124,3 +220,39 @@ def asdict(self) -> t.Dict[str, t.Any]:
124220
"url": self.url,
125221
"event": self.event.value,
126222
}
223+
224+
225+
@dataclasses.dataclass(frozen=True)
226+
class Device:
227+
"""Represents a device."""
228+
229+
id: str
230+
"""The unique identifier of the device."""
231+
name: str
232+
"""The name of the device."""
233+
234+
@classmethod
235+
def from_dict(cls, payload: t.Dict[str, t.Any]) -> "Device":
236+
"""Creates a Device instance from a dictionary."""
237+
return cls(
238+
id=payload["id"],
239+
name=payload["name"],
240+
)
241+
242+
243+
@dataclasses.dataclass(frozen=True)
244+
class ErrorResponse:
245+
"""Represents an error response from the API."""
246+
247+
code: int
248+
"""The error code."""
249+
message: str
250+
"""The error message."""
251+
252+
@classmethod
253+
def from_dict(cls, payload: t.Dict[str, t.Any]) -> "ErrorResponse":
254+
"""Creates an ErrorResponse instance from a dictionary."""
255+
return cls(
256+
code=payload["code"],
257+
message=payload["message"],
258+
)

0 commit comments

Comments
 (0)