ergon-framework-python 0.2.0__py3-none-any.whl → 0.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ergon/__init__.py +1 -1
- ergon/connector/__init__.py +20 -0
- ergon/connector/ergon_platform/__init__.py +78 -11
- ergon/connector/ergon_platform/async_connector.py +3 -221
- ergon/connector/ergon_platform/channels/__init__.py +54 -0
- ergon/connector/ergon_platform/channels/adapters.py +230 -0
- ergon/connector/ergon_platform/channels/async_connector.py +327 -0
- ergon/connector/ergon_platform/channels/connector.py +294 -0
- ergon/connector/ergon_platform/channels/models.py +460 -0
- ergon/connector/ergon_platform/channels/services/__init__.py +39 -0
- ergon/connector/ergon_platform/channels/services/activity.py +300 -0
- ergon/connector/ergon_platform/channels/services/addresses.py +325 -0
- ergon/connector/ergon_platform/channels/services/attachments.py +283 -0
- ergon/connector/ergon_platform/channels/services/messages.py +127 -0
- ergon/connector/ergon_platform/channels/services/records.py +75 -0
- ergon/connector/ergon_platform/channels/services/service.py +34 -0
- ergon/connector/ergon_platform/connector.py +3 -198
- ergon/connector/ergon_platform/models.py +1 -73
- ergon/connector/ergon_platform/utils.py +39 -248
- ergon/connector/ergon_platform/workflows/__init__.py +52 -0
- ergon/connector/ergon_platform/{_operations.py → workflows/_operations.py} +8 -3
- ergon/connector/ergon_platform/workflows/async_connector.py +224 -0
- ergon/connector/ergon_platform/workflows/connector.py +201 -0
- ergon/connector/ergon_platform/workflows/models.py +90 -0
- ergon/connector/ergon_platform/workflows/utils.py +250 -0
- ergon/connector/nylas/async_connector.py +6 -0
- ergon/connector/nylas/async_service.py +3 -0
- ergon/connector/nylas/connector.py +6 -0
- ergon/connector/nylas/models.py +8 -0
- ergon/connector/nylas/service.py +9 -0
- ergon/connector/nylas/utils.py +2 -4
- ergon/task/mixins/consumer.py +0 -2
- {ergon_framework_python-0.2.0.dist-info → ergon_framework_python-0.2.2.dist-info}/METADATA +3 -3
- {ergon_framework_python-0.2.0.dist-info → ergon_framework_python-0.2.2.dist-info}/RECORD +38 -21
- {ergon_framework_python-0.2.0.dist-info → ergon_framework_python-0.2.2.dist-info}/WHEEL +0 -0
- {ergon_framework_python-0.2.0.dist-info → ergon_framework_python-0.2.2.dist-info}/entry_points.txt +0 -0
- {ergon_framework_python-0.2.0.dist-info → ergon_framework_python-0.2.2.dist-info}/licenses/LICENSE +0 -0
- {ergon_framework_python-0.2.0.dist-info → ergon_framework_python-0.2.2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
import unicodedata
|
|
4
|
+
from concurrent.futures import Future, ThreadPoolExecutor
|
|
5
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
6
|
+
from typing import Any, Dict, List, Literal, Optional, Union
|
|
7
|
+
|
|
8
|
+
from ....transaction import Transaction
|
|
9
|
+
from ..adapters import ActivityAdapter
|
|
10
|
+
from ..models import InboxAttachmentFile
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ChannelsAttachmentService:
|
|
16
|
+
"""Hydrate or download inbound attachment files."""
|
|
17
|
+
|
|
18
|
+
_MAX_CONCURRENT_DOWNLOADS = 4
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
client: Any,
|
|
23
|
+
download_client: Optional[Any] = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
self._client = download_client or client
|
|
26
|
+
|
|
27
|
+
def download_bytes(
|
|
28
|
+
self,
|
|
29
|
+
config_id: str,
|
|
30
|
+
event_id: str,
|
|
31
|
+
attachment_id: str,
|
|
32
|
+
) -> bytes:
|
|
33
|
+
"""Download the bytes of the attachment."""
|
|
34
|
+
return self._client.channels.configs.activity_attachment_file(
|
|
35
|
+
config_id,
|
|
36
|
+
event_id,
|
|
37
|
+
attachment_id,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def _download_attachment(
|
|
41
|
+
self,
|
|
42
|
+
config_id: str,
|
|
43
|
+
event_id: str,
|
|
44
|
+
attachment_id: str,
|
|
45
|
+
filename: str,
|
|
46
|
+
) -> bytes:
|
|
47
|
+
logger.info(
|
|
48
|
+
"Downloading attachment %s (%s)",
|
|
49
|
+
filename,
|
|
50
|
+
attachment_id,
|
|
51
|
+
)
|
|
52
|
+
return self.download_bytes(config_id, event_id, attachment_id)
|
|
53
|
+
|
|
54
|
+
def hydrate_inbox_attachments(
|
|
55
|
+
self,
|
|
56
|
+
config_id: str,
|
|
57
|
+
transaction: Transaction,
|
|
58
|
+
failure_policy: Literal["raise", "best_effort"] = "raise",
|
|
59
|
+
) -> Transaction:
|
|
60
|
+
"""Hydrate the inbox attachments."""
|
|
61
|
+
return self.hydrate_inbox_transactions(
|
|
62
|
+
config_id,
|
|
63
|
+
[transaction],
|
|
64
|
+
failure_policy=failure_policy,
|
|
65
|
+
)[0]
|
|
66
|
+
|
|
67
|
+
def hydrate_inbox_transactions(
|
|
68
|
+
self,
|
|
69
|
+
config_id: str,
|
|
70
|
+
transactions: List[Transaction],
|
|
71
|
+
failure_policy: Literal["raise", "best_effort"] = "raise",
|
|
72
|
+
) -> List[Transaction]:
|
|
73
|
+
"""Hydrate a batch with bounded concurrent attachment downloads."""
|
|
74
|
+
hydrated = list(transactions)
|
|
75
|
+
enriched: Dict[int, List[Dict[str, Any]]] = {}
|
|
76
|
+
failures: Dict[int, List[Dict[str, str]]] = {}
|
|
77
|
+
jobs: List[tuple[int, int, str, str, str]] = []
|
|
78
|
+
|
|
79
|
+
for transaction_index, transaction in enumerate(transactions):
|
|
80
|
+
event_id = transaction.id
|
|
81
|
+
if not event_id:
|
|
82
|
+
continue
|
|
83
|
+
attachments = ActivityAdapter.attachments(transaction)
|
|
84
|
+
enriched[transaction_index] = list(attachments)
|
|
85
|
+
for attachment_index, metadata in enumerate(attachments):
|
|
86
|
+
if metadata.get("content") is not None:
|
|
87
|
+
continue
|
|
88
|
+
attachment_id = ActivityAdapter.attachment_id(metadata)
|
|
89
|
+
if not attachment_id:
|
|
90
|
+
continue
|
|
91
|
+
filename = str(metadata.get("filename") or attachment_id)
|
|
92
|
+
jobs.append(
|
|
93
|
+
(
|
|
94
|
+
transaction_index,
|
|
95
|
+
attachment_index,
|
|
96
|
+
event_id,
|
|
97
|
+
attachment_id,
|
|
98
|
+
filename,
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if not jobs:
|
|
103
|
+
return hydrated
|
|
104
|
+
|
|
105
|
+
max_workers = min(self._MAX_CONCURRENT_DOWNLOADS, len(jobs))
|
|
106
|
+
future_jobs: List[tuple[int, int, str, str, str, Future[bytes]]] = []
|
|
107
|
+
with ThreadPoolExecutor(
|
|
108
|
+
max_workers=max_workers,
|
|
109
|
+
thread_name_prefix="channels-attachment",
|
|
110
|
+
) as executor:
|
|
111
|
+
for job in jobs:
|
|
112
|
+
transaction_index, attachment_index, event_id, attachment_id, filename = job
|
|
113
|
+
future = executor.submit(
|
|
114
|
+
self._download_attachment,
|
|
115
|
+
config_id,
|
|
116
|
+
event_id,
|
|
117
|
+
attachment_id,
|
|
118
|
+
filename,
|
|
119
|
+
)
|
|
120
|
+
future_jobs.append((*job, future))
|
|
121
|
+
|
|
122
|
+
changed: set[int] = set()
|
|
123
|
+
for (
|
|
124
|
+
transaction_index,
|
|
125
|
+
attachment_index,
|
|
126
|
+
event_id,
|
|
127
|
+
attachment_id,
|
|
128
|
+
filename,
|
|
129
|
+
future,
|
|
130
|
+
) in future_jobs:
|
|
131
|
+
try:
|
|
132
|
+
content = future.result()
|
|
133
|
+
except Exception as exc:
|
|
134
|
+
if failure_policy == "raise":
|
|
135
|
+
raise
|
|
136
|
+
logger.warning(
|
|
137
|
+
"Skipping attachment %s (%s) for event %s",
|
|
138
|
+
filename,
|
|
139
|
+
attachment_id,
|
|
140
|
+
event_id,
|
|
141
|
+
exc_info=True,
|
|
142
|
+
)
|
|
143
|
+
failures.setdefault(transaction_index, []).append(
|
|
144
|
+
{
|
|
145
|
+
"attachment_id": attachment_id,
|
|
146
|
+
"filename": filename,
|
|
147
|
+
"error": f"{type(exc).__name__}: {exc}",
|
|
148
|
+
}
|
|
149
|
+
)
|
|
150
|
+
continue
|
|
151
|
+
original = enriched[transaction_index][attachment_index]
|
|
152
|
+
enriched[transaction_index][attachment_index] = {
|
|
153
|
+
**original,
|
|
154
|
+
"content": content,
|
|
155
|
+
}
|
|
156
|
+
changed.add(transaction_index)
|
|
157
|
+
|
|
158
|
+
for transaction_index in changed | failures.keys():
|
|
159
|
+
transaction = ActivityAdapter.with_attachments(
|
|
160
|
+
transactions[transaction_index],
|
|
161
|
+
enriched[transaction_index],
|
|
162
|
+
)
|
|
163
|
+
transaction_failures = failures.get(transaction_index)
|
|
164
|
+
if transaction_failures:
|
|
165
|
+
metadata = dict(transaction.metadata or {})
|
|
166
|
+
metadata["attachment_failures"] = transaction_failures
|
|
167
|
+
transaction = transaction.model_copy(update={"metadata": metadata})
|
|
168
|
+
hydrated[transaction_index] = transaction
|
|
169
|
+
return hydrated
|
|
170
|
+
|
|
171
|
+
@staticmethod
|
|
172
|
+
def _safe_component(value: str, *, fallback: str) -> str:
|
|
173
|
+
"""Safe component."""
|
|
174
|
+
normalized = unicodedata.normalize("NFKC", value).replace("\\", "/").strip()
|
|
175
|
+
windows = PureWindowsPath(value)
|
|
176
|
+
if normalized.startswith("/") or value.startswith(("\\\\", "//")) or windows.drive:
|
|
177
|
+
raise ValueError(f"Unsafe absolute attachment filename: {value!r}")
|
|
178
|
+
name = PurePosixPath(normalized).name
|
|
179
|
+
if name in {"", ".", ".."}:
|
|
180
|
+
name = fallback
|
|
181
|
+
name = "".join("_" if unicodedata.category(char).startswith("C") else char for char in name)
|
|
182
|
+
name = re.sub(r"[/\\:*?\"<>|]", "_", name).strip(" .")
|
|
183
|
+
return name or fallback
|
|
184
|
+
|
|
185
|
+
@classmethod
|
|
186
|
+
def _output_filename(
|
|
187
|
+
cls,
|
|
188
|
+
filename: str,
|
|
189
|
+
attachment_id: str,
|
|
190
|
+
) -> str:
|
|
191
|
+
"""Output filename."""
|
|
192
|
+
safe_id = cls._safe_component(
|
|
193
|
+
attachment_id,
|
|
194
|
+
fallback="attachment",
|
|
195
|
+
)
|
|
196
|
+
safe_name = cls._safe_component(filename, fallback=safe_id)
|
|
197
|
+
path = PurePosixPath(safe_name)
|
|
198
|
+
return f"{path.stem}--{safe_id}{path.suffix}"
|
|
199
|
+
|
|
200
|
+
def download_inbox_attachments(
|
|
201
|
+
self,
|
|
202
|
+
config_id: str,
|
|
203
|
+
transaction: Transaction,
|
|
204
|
+
dest: Optional[Union[str, Path]] = None,
|
|
205
|
+
) -> List[InboxAttachmentFile]:
|
|
206
|
+
"""Download the inbox attachments."""
|
|
207
|
+
event_id = transaction.id
|
|
208
|
+
if not event_id:
|
|
209
|
+
raise ValueError("transaction.id is required to download attachments")
|
|
210
|
+
|
|
211
|
+
dest_root = Path(dest).resolve() if dest is not None else None
|
|
212
|
+
event_component = self._safe_component(
|
|
213
|
+
str(event_id),
|
|
214
|
+
fallback="event",
|
|
215
|
+
)
|
|
216
|
+
dest_dir = dest_root / event_component if dest_root is not None else None
|
|
217
|
+
jobs: List[tuple[str, str, Optional[str]]] = []
|
|
218
|
+
for metadata in ActivityAdapter.attachments(transaction):
|
|
219
|
+
attachment_id = ActivityAdapter.attachment_id(metadata)
|
|
220
|
+
if not attachment_id:
|
|
221
|
+
continue
|
|
222
|
+
filename = self._output_filename(
|
|
223
|
+
str(metadata.get("filename") or attachment_id),
|
|
224
|
+
attachment_id,
|
|
225
|
+
)
|
|
226
|
+
jobs.append(
|
|
227
|
+
(
|
|
228
|
+
attachment_id,
|
|
229
|
+
filename,
|
|
230
|
+
metadata.get("content_type"),
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
if not jobs:
|
|
235
|
+
return []
|
|
236
|
+
|
|
237
|
+
max_workers = min(self._MAX_CONCURRENT_DOWNLOADS, len(jobs))
|
|
238
|
+
futures: List[tuple[str, str, Optional[str], Future[bytes]]] = []
|
|
239
|
+
with ThreadPoolExecutor(
|
|
240
|
+
max_workers=max_workers,
|
|
241
|
+
thread_name_prefix="channels-attachment",
|
|
242
|
+
) as executor:
|
|
243
|
+
for attachment_id, filename, content_type in jobs:
|
|
244
|
+
future = executor.submit(
|
|
245
|
+
self._download_attachment,
|
|
246
|
+
config_id,
|
|
247
|
+
event_id,
|
|
248
|
+
attachment_id,
|
|
249
|
+
filename,
|
|
250
|
+
)
|
|
251
|
+
futures.append(
|
|
252
|
+
(
|
|
253
|
+
attachment_id,
|
|
254
|
+
filename,
|
|
255
|
+
content_type,
|
|
256
|
+
future,
|
|
257
|
+
)
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
downloaded: List[InboxAttachmentFile] = []
|
|
261
|
+
for attachment_id, filename, content_type, future in futures:
|
|
262
|
+
content = future.result()
|
|
263
|
+
path: Optional[str] = None
|
|
264
|
+
if dest_dir is not None:
|
|
265
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
266
|
+
resolved_dir = dest_dir.resolve()
|
|
267
|
+
file_path = (resolved_dir / filename).resolve()
|
|
268
|
+
try:
|
|
269
|
+
file_path.relative_to(resolved_dir)
|
|
270
|
+
except ValueError as exc:
|
|
271
|
+
raise ValueError("Attachment destination escapes the event directory") from exc
|
|
272
|
+
file_path.write_bytes(content)
|
|
273
|
+
path = str(file_path)
|
|
274
|
+
downloaded.append(
|
|
275
|
+
InboxAttachmentFile(
|
|
276
|
+
attachment_id=attachment_id,
|
|
277
|
+
filename=filename,
|
|
278
|
+
content=content,
|
|
279
|
+
content_type=content_type,
|
|
280
|
+
path=path,
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
return downloaded
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import html as html_module
|
|
2
|
+
from typing import Any, Dict, List, Optional, Union
|
|
3
|
+
|
|
4
|
+
from ....transaction import Transaction
|
|
5
|
+
from ..adapters import ActivityAdapter
|
|
6
|
+
from ..models import SendMessageInput
|
|
7
|
+
from .records import SdkRecord
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ChannelsMessageService:
|
|
11
|
+
"""Thread reads and outbound message operations."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, client: Any) -> None:
|
|
14
|
+
self.client = client
|
|
15
|
+
|
|
16
|
+
def fetch_thread_messages(
|
|
17
|
+
self,
|
|
18
|
+
thread_id: str,
|
|
19
|
+
limit: Optional[int] = None,
|
|
20
|
+
offset: int = 0,
|
|
21
|
+
**params: Any,
|
|
22
|
+
) -> List[Transaction]:
|
|
23
|
+
"""Fetch the thread messages."""
|
|
24
|
+
response = self.client.channels.thread_messages(thread_id, **params)
|
|
25
|
+
messages = SdkRecord.items(
|
|
26
|
+
response,
|
|
27
|
+
keys=["messages", "items", "data"],
|
|
28
|
+
)
|
|
29
|
+
if limit is not None:
|
|
30
|
+
messages = messages[offset : offset + limit]
|
|
31
|
+
elif offset:
|
|
32
|
+
messages = messages[offset:]
|
|
33
|
+
return [ActivityAdapter.to_transaction(message, source="thread") for message in messages]
|
|
34
|
+
|
|
35
|
+
def send_message(
|
|
36
|
+
self,
|
|
37
|
+
address_id: str,
|
|
38
|
+
channel: str,
|
|
39
|
+
config: Dict[str, Any],
|
|
40
|
+
resource_id: Optional[str] = None,
|
|
41
|
+
service_name: Optional[str] = None,
|
|
42
|
+
) -> Any:
|
|
43
|
+
"""Send the message."""
|
|
44
|
+
if not address_id:
|
|
45
|
+
raise ValueError("address_id is required to send a channel message")
|
|
46
|
+
if not channel:
|
|
47
|
+
raise ValueError("channel is required to send a channel message")
|
|
48
|
+
request: Dict[str, Any] = {
|
|
49
|
+
"address_id": address_id,
|
|
50
|
+
"channel": channel,
|
|
51
|
+
"config": config,
|
|
52
|
+
}
|
|
53
|
+
if resource_id is not None:
|
|
54
|
+
request["resource_id"] = resource_id
|
|
55
|
+
if service_name is not None:
|
|
56
|
+
request["service_name"] = service_name
|
|
57
|
+
return self.client.channels.send(request)
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def normalize_recipients(to: Union[str, List[str]]) -> List[str]:
|
|
61
|
+
"""Normalize the recipients."""
|
|
62
|
+
if isinstance(to, str):
|
|
63
|
+
return [to]
|
|
64
|
+
return list(to)
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def _drop_none(mapping: Dict[str, Any]) -> Dict[str, Any]:
|
|
68
|
+
"""Drop the none values."""
|
|
69
|
+
return {key: value for key, value in mapping.items() if value is not None}
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def normalize_send_payload(
|
|
73
|
+
cls,
|
|
74
|
+
payload: object,
|
|
75
|
+
) -> Dict[str, Any]:
|
|
76
|
+
if isinstance(payload, SendMessageInput):
|
|
77
|
+
config: Dict[str, Any] = cls._drop_none(
|
|
78
|
+
{
|
|
79
|
+
"to": list(payload.to) if payload.to else None,
|
|
80
|
+
"subject": payload.subject,
|
|
81
|
+
"html": payload.html,
|
|
82
|
+
"text": payload.text,
|
|
83
|
+
"cc": list(payload.cc) if payload.cc else None,
|
|
84
|
+
"bcc": list(payload.bcc) if payload.bcc else None,
|
|
85
|
+
"reply_to": payload.reply_to,
|
|
86
|
+
"in_reply_to": payload.in_reply_to,
|
|
87
|
+
"attachments": (
|
|
88
|
+
[attachment.model_dump(mode="json") for attachment in payload.attachments]
|
|
89
|
+
if payload.attachments
|
|
90
|
+
else None
|
|
91
|
+
),
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
if config.get("text") and not config.get("html"):
|
|
95
|
+
config["html"] = f"<p>{html_module.escape(config['text'])}</p>"
|
|
96
|
+
return {"top": {}, "config": config}
|
|
97
|
+
|
|
98
|
+
if isinstance(payload, dict):
|
|
99
|
+
data = dict(payload)
|
|
100
|
+
config = dict(data.pop("config", {}) or {})
|
|
101
|
+
top = {
|
|
102
|
+
"address_id": data.pop("address_id", None),
|
|
103
|
+
"channel": data.pop("channel", None),
|
|
104
|
+
"resource_id": data.pop("resource_id", None),
|
|
105
|
+
"service_name": data.pop("service_name", None),
|
|
106
|
+
}
|
|
107
|
+
for key, value in data.items():
|
|
108
|
+
config.setdefault(key, value)
|
|
109
|
+
return {"top": top, "config": cls._drop_none(config)}
|
|
110
|
+
|
|
111
|
+
raise TypeError(f"Unsupported send payload type: {type(payload)}")
|
|
112
|
+
|
|
113
|
+
@staticmethod
|
|
114
|
+
def send_response_id(result: Any) -> str:
|
|
115
|
+
"""Send the response id."""
|
|
116
|
+
record = SdkRecord(result)
|
|
117
|
+
for key in (
|
|
118
|
+
"log_id",
|
|
119
|
+
"provider_message_id",
|
|
120
|
+
"thread_id",
|
|
121
|
+
"provider_id",
|
|
122
|
+
"id",
|
|
123
|
+
):
|
|
124
|
+
value = record.get(key)
|
|
125
|
+
if value:
|
|
126
|
+
return str(value)
|
|
127
|
+
return ""
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from typing import Any, List, Optional
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SdkRecord:
|
|
5
|
+
"""Uniform read API over dict-or-object SDK payloads."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, raw: Any) -> None:
|
|
8
|
+
self.raw = raw
|
|
9
|
+
|
|
10
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
11
|
+
"""Get a value from the record."""
|
|
12
|
+
if isinstance(self.raw, dict):
|
|
13
|
+
return self.raw.get(key, default)
|
|
14
|
+
return getattr(self.raw, key, default)
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def items(
|
|
18
|
+
cls,
|
|
19
|
+
response: Any,
|
|
20
|
+
keys: Optional[List[str]] = None,
|
|
21
|
+
) -> List[Any]:
|
|
22
|
+
"""Get items from a response."""
|
|
23
|
+
candidate_keys = keys or ["items", "messages", "data", "results"]
|
|
24
|
+
if isinstance(response, list):
|
|
25
|
+
return response
|
|
26
|
+
if isinstance(response, dict):
|
|
27
|
+
for key in candidate_keys:
|
|
28
|
+
value = response.get(key)
|
|
29
|
+
if isinstance(value, list):
|
|
30
|
+
return value
|
|
31
|
+
return []
|
|
32
|
+
for key in candidate_keys:
|
|
33
|
+
value = getattr(response, key, None)
|
|
34
|
+
if isinstance(value, (list, tuple)):
|
|
35
|
+
return list(value)
|
|
36
|
+
return []
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def total(cls, response: Any) -> int:
|
|
40
|
+
"""Get the total from a response."""
|
|
41
|
+
record = cls(response)
|
|
42
|
+
for key in ("total", "total_count", "count"):
|
|
43
|
+
value = record.get(key)
|
|
44
|
+
if value is not None:
|
|
45
|
+
return int(value)
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def serialize(cls, obj: Any) -> Any:
|
|
50
|
+
"""Serialize an object to a dictionary."""
|
|
51
|
+
if obj is None or isinstance(
|
|
52
|
+
obj,
|
|
53
|
+
(str, int, float, bool, bytes),
|
|
54
|
+
):
|
|
55
|
+
return obj
|
|
56
|
+
if isinstance(obj, dict):
|
|
57
|
+
return {key: cls.serialize(value) for key, value in obj.items()}
|
|
58
|
+
if isinstance(obj, (list, tuple)):
|
|
59
|
+
return [cls.serialize(item) for item in obj]
|
|
60
|
+
if hasattr(obj, "model_dump"):
|
|
61
|
+
return obj.model_dump(mode="json")
|
|
62
|
+
if hasattr(obj, "to_dict"):
|
|
63
|
+
return obj.to_dict()
|
|
64
|
+
if hasattr(obj, "__dict__"):
|
|
65
|
+
return {key: cls.serialize(value) for key, value in obj.__dict__.items() if not key.startswith("_")}
|
|
66
|
+
return obj
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def first_id(cls, mapping: dict, *keys: str) -> str:
|
|
70
|
+
"""Get the first id from a mapping."""
|
|
71
|
+
for key in keys:
|
|
72
|
+
value = mapping.get(key)
|
|
73
|
+
if value:
|
|
74
|
+
return str(value)
|
|
75
|
+
return ""
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from typing import Any, Optional
|
|
2
|
+
|
|
3
|
+
from ...models import ErgonPlatformClient
|
|
4
|
+
from .activity import ChannelsActivityService
|
|
5
|
+
from .addresses import ChannelsAddressService
|
|
6
|
+
from .attachments import ChannelsAttachmentService
|
|
7
|
+
from .messages import ChannelsMessageService
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ErgonPlatformChannelsService:
|
|
11
|
+
"""Domain-organized services over one shared Channels SDK client."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
config: ErgonPlatformClient,
|
|
16
|
+
client: Any,
|
|
17
|
+
download_client: Optional[Any] = None,
|
|
18
|
+
) -> None:
|
|
19
|
+
self.config = config
|
|
20
|
+
self.client = client
|
|
21
|
+
self._download_client = download_client
|
|
22
|
+
self.activity = ChannelsActivityService(client)
|
|
23
|
+
self.addresses = ChannelsAddressService(client)
|
|
24
|
+
self.attachments = ChannelsAttachmentService(
|
|
25
|
+
client,
|
|
26
|
+
download_client=download_client,
|
|
27
|
+
)
|
|
28
|
+
self.messages = ChannelsMessageService(client)
|
|
29
|
+
|
|
30
|
+
def close(self) -> None:
|
|
31
|
+
"""Close the services."""
|
|
32
|
+
download = self._download_client
|
|
33
|
+
if download is not None and download is not self.client and hasattr(download, "close"):
|
|
34
|
+
download.close()
|