ergon-framework-python 0.2.1__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.
Files changed (32) hide show
  1. ergon/__init__.py +1 -1
  2. ergon/connector/__init__.py +20 -0
  3. ergon/connector/ergon_platform/__init__.py +78 -11
  4. ergon/connector/ergon_platform/async_connector.py +3 -221
  5. ergon/connector/ergon_platform/channels/__init__.py +54 -0
  6. ergon/connector/ergon_platform/channels/adapters.py +230 -0
  7. ergon/connector/ergon_platform/channels/async_connector.py +327 -0
  8. ergon/connector/ergon_platform/channels/connector.py +294 -0
  9. ergon/connector/ergon_platform/channels/models.py +460 -0
  10. ergon/connector/ergon_platform/channels/services/__init__.py +39 -0
  11. ergon/connector/ergon_platform/channels/services/activity.py +300 -0
  12. ergon/connector/ergon_platform/channels/services/addresses.py +325 -0
  13. ergon/connector/ergon_platform/channels/services/attachments.py +283 -0
  14. ergon/connector/ergon_platform/channels/services/messages.py +127 -0
  15. ergon/connector/ergon_platform/channels/services/records.py +75 -0
  16. ergon/connector/ergon_platform/channels/services/service.py +34 -0
  17. ergon/connector/ergon_platform/connector.py +3 -198
  18. ergon/connector/ergon_platform/models.py +1 -73
  19. ergon/connector/ergon_platform/utils.py +39 -248
  20. ergon/connector/ergon_platform/workflows/__init__.py +52 -0
  21. ergon/connector/ergon_platform/{_operations.py → workflows/_operations.py} +8 -3
  22. ergon/connector/ergon_platform/workflows/async_connector.py +224 -0
  23. ergon/connector/ergon_platform/workflows/connector.py +201 -0
  24. ergon/connector/ergon_platform/workflows/models.py +90 -0
  25. ergon/connector/ergon_platform/workflows/utils.py +250 -0
  26. ergon/task/mixins/consumer.py +0 -2
  27. {ergon_framework_python-0.2.1.dist-info → ergon_framework_python-0.2.2.dist-info}/METADATA +3 -3
  28. {ergon_framework_python-0.2.1.dist-info → ergon_framework_python-0.2.2.dist-info}/RECORD +32 -15
  29. {ergon_framework_python-0.2.1.dist-info → ergon_framework_python-0.2.2.dist-info}/WHEEL +0 -0
  30. {ergon_framework_python-0.2.1.dist-info → ergon_framework_python-0.2.2.dist-info}/entry_points.txt +0 -0
  31. {ergon_framework_python-0.2.1.dist-info → ergon_framework_python-0.2.2.dist-info}/licenses/LICENSE +0 -0
  32. {ergon_framework_python-0.2.1.dist-info → ergon_framework_python-0.2.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,327 @@
1
+ import asyncio
2
+ import logging
3
+ from pathlib import Path
4
+ from typing import Any, Dict, List, Optional, Union
5
+
6
+ from ...connector import AsyncConnector
7
+ from ...transaction import Transaction
8
+ from .._client import create_ergon_client
9
+ from ..models import ErgonPlatformClient
10
+ from .models import (
11
+ DEFAULT_CHANNEL,
12
+ ErgonPlatformChannelsConfig,
13
+ ErgonPlatformChannelsConsumerConfig,
14
+ ErgonPlatformChannelsProducerConfig,
15
+ InboxAttachmentFile,
16
+ ResolvedInboxAddress,
17
+ SendMessageAttachment,
18
+ SendMessageInput,
19
+ SendMessagePayload,
20
+ )
21
+ from .services import ErgonPlatformChannelsService
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class AsyncErgonPlatformChannelsConnector(AsyncConnector):
27
+ """Async connector for one Ergon Platform channels inbox."""
28
+
29
+ def __init__(
30
+ self,
31
+ client: ErgonPlatformClient,
32
+ channels_config: Optional[ErgonPlatformChannelsConfig] = None,
33
+ consumer_config: Optional[ErgonPlatformChannelsConsumerConfig] = None,
34
+ producer_config: Optional[ErgonPlatformChannelsProducerConfig] = None,
35
+ ) -> None:
36
+ if channels_config is not None:
37
+ consumer_config = consumer_config or channels_config.to_consumer_config()
38
+ producer_config = producer_config or channels_config.to_producer_config()
39
+ self._consumer_config = consumer_config
40
+ self._producer_config = producer_config or ErgonPlatformChannelsProducerConfig()
41
+ self.client = create_ergon_client(client)
42
+ self.service = ErgonPlatformChannelsService(
43
+ client,
44
+ self.client,
45
+ download_client=self._attachment_download_client(client),
46
+ )
47
+ self._seen_event_ids: set[str] = set()
48
+
49
+ async def fetch_transactions_async(
50
+ self,
51
+ batch_size: Optional[int] = None,
52
+ *args,
53
+ **kwargs,
54
+ ) -> List[Transaction]:
55
+ config = self._require_consumer_config("fetch transactions")
56
+ inbox = await self._resolve_inbox_async(config)
57
+ inbox.ensure_can_receive()
58
+ limit = batch_size or config.batch_size
59
+ if kwargs:
60
+ raise TypeError(f"Unsupported inbox claim options: {', '.join(sorted(kwargs))}")
61
+ transactions = await asyncio.to_thread(
62
+ lambda: self.service.activity.claim_inbox_transactions(
63
+ config_id=inbox.config_id,
64
+ address_id=inbox.address_id,
65
+ config=config,
66
+ limit=limit,
67
+ seen_ids=self._seen_event_ids if config.deduplicate_fetched_events else None,
68
+ )
69
+ )
70
+ try:
71
+ return await self._hydrate_fetched_transactions_async(config, inbox.config_id, transactions)
72
+ except Exception:
73
+ await asyncio.to_thread(
74
+ lambda: self.service.activity.release_claims(
75
+ inbox.config_id,
76
+ transactions,
77
+ delay_seconds=config.nack_delay_seconds,
78
+ )
79
+ )
80
+ for transaction in transactions:
81
+ if transaction.id:
82
+ self._seen_event_ids.discard(transaction.id)
83
+ raise
84
+
85
+ async def _hydrate_fetched_transactions_async(
86
+ self,
87
+ config: ErgonPlatformChannelsConsumerConfig,
88
+ config_id: str,
89
+ transactions: List[Transaction],
90
+ ) -> List[Transaction]:
91
+ if not config.download_attachments:
92
+ return transactions
93
+ return await asyncio.to_thread(
94
+ lambda: self.service.attachments.hydrate_inbox_transactions(
95
+ config_id,
96
+ transactions,
97
+ failure_policy=config.attachment_failure_policy,
98
+ )
99
+ )
100
+
101
+ async def fetch_transaction_by_id_async(self, transaction_id: str, *args, **kwargs) -> Transaction:
102
+ config = self._require_consumer_config("fetch a transaction by id")
103
+ inbox = await self._resolve_inbox_async(config)
104
+ inbox.ensure_can_receive()
105
+ transaction = await asyncio.to_thread(
106
+ lambda: self.service.activity.get_inbox_event(
107
+ inbox.config_id,
108
+ transaction_id,
109
+ **kwargs,
110
+ )
111
+ )
112
+ hydrated = await self._hydrate_fetched_transactions_async(config, inbox.config_id, [transaction])
113
+ return hydrated[0]
114
+
115
+ async def get_transactions_count_async(self, *args, **kwargs) -> int:
116
+ config = self._require_consumer_config("count transactions")
117
+ inbox = await self._resolve_inbox_async(config)
118
+ inbox.ensure_can_receive()
119
+ return await asyncio.to_thread(
120
+ lambda: self.service.activity.get_inbox_events_count(
121
+ inbox.config_id,
122
+ address_id=inbox.address_id,
123
+ **kwargs,
124
+ )
125
+ )
126
+
127
+ async def dispatch_transactions_async(
128
+ self,
129
+ transactions: List[Transaction],
130
+ *args,
131
+ **kwargs,
132
+ ) -> List[str]:
133
+ sent_ids: List[str] = []
134
+ for transaction in transactions:
135
+ result = await self._send_from_payload(transaction.payload)
136
+ sent_ids.append(self.service.messages.send_response_id(result))
137
+ return sent_ids
138
+
139
+ async def send_message(self, payload: SendMessagePayload) -> Any:
140
+ return await self._send_from_payload(payload)
141
+
142
+ async def send_email_async(
143
+ self,
144
+ *,
145
+ to: Union[str, List[str]],
146
+ subject: str,
147
+ text: Optional[str] = None,
148
+ html: Optional[str] = None,
149
+ cc: Optional[List[str]] = None,
150
+ bcc: Optional[List[str]] = None,
151
+ reply_to: Optional[str] = None,
152
+ in_reply_to: Optional[str] = None,
153
+ attachments: Optional[List[SendMessageAttachment]] = None,
154
+ ) -> str:
155
+ """Send one email and return the platform log/message id."""
156
+ payload = SendMessageInput(
157
+ to=self.service.messages.normalize_recipients(to),
158
+ subject=subject,
159
+ text=text,
160
+ html=html,
161
+ cc=cc,
162
+ bcc=bcc,
163
+ reply_to=reply_to,
164
+ in_reply_to=in_reply_to,
165
+ attachments=attachments,
166
+ )
167
+ result = await self._send_from_payload(payload)
168
+ return self.service.messages.send_response_id(result)
169
+
170
+ async def list_thread_messages(self, thread_id: str, **params: Any) -> List[Transaction]:
171
+ return await asyncio.to_thread(lambda: self.service.messages.fetch_thread_messages(thread_id, **params))
172
+
173
+ async def list_company_activity(self, **params: Any) -> List[Transaction]:
174
+ return await asyncio.to_thread(lambda: self.service.activity.fetch_activity_events(**params))
175
+
176
+ async def resolve_inbox_async(self) -> ResolvedInboxAddress:
177
+ """Resolve ``address`` (+ optional ``config_id``) to platform routing metadata."""
178
+ config = self._require_consumer_config("resolve inbox")
179
+ return await self._resolve_inbox_async(config)
180
+
181
+ async def resolve_address_info(self, *, address: Optional[str] = None) -> Dict[str, Any]:
182
+ config = self._require_consumer_config("resolve address info")
183
+ if address and address != config.address:
184
+ config = config.model_copy(update={"address": address})
185
+ inbox = await self._resolve_inbox_async(config)
186
+ return inbox.as_info_dict()
187
+
188
+ async def ack_transaction(
189
+ self,
190
+ transaction: Transaction,
191
+ allow_attachment_failures: bool = False,
192
+ ) -> None:
193
+ """Acknowledge a processed inbox event on the platform."""
194
+ config = self._require_consumer_config("ack a transaction")
195
+ inbox = await self._resolve_inbox_async(config)
196
+ failures = (transaction.metadata or {}).get("attachment_failures")
197
+ if failures and not allow_attachment_failures:
198
+ raise ValueError(
199
+ "Transaction has failed attachment downloads; nack it or pass "
200
+ "allow_attachment_failures=True to acknowledge explicitly"
201
+ )
202
+ await asyncio.to_thread(
203
+ lambda: self.service.activity.ack_inbox_event(
204
+ inbox.config_id,
205
+ transaction,
206
+ )
207
+ )
208
+ if transaction.id:
209
+ self._seen_event_ids.add(transaction.id)
210
+
211
+ async def nack_transaction(
212
+ self,
213
+ transaction: Transaction,
214
+ requeue: bool = True,
215
+ delay_seconds: Optional[int] = None,
216
+ ) -> None:
217
+ """Requeue or fail an inbox event on the platform."""
218
+ config = self._require_consumer_config("nack a transaction")
219
+ inbox = await self._resolve_inbox_async(config)
220
+ delay = config.nack_delay_seconds if delay_seconds is None else delay_seconds
221
+ await asyncio.to_thread(
222
+ lambda: self.service.activity.nack_inbox_event(
223
+ inbox.config_id,
224
+ transaction,
225
+ requeue=requeue,
226
+ delay_seconds=delay,
227
+ )
228
+ )
229
+ if requeue and transaction.id:
230
+ self._seen_event_ids.discard(transaction.id)
231
+
232
+ async def download_attachments(
233
+ self,
234
+ transaction: Transaction,
235
+ dest: Optional[Union[str, Path]] = None,
236
+ ) -> List[InboxAttachmentFile]:
237
+ """Download inbound attachments from a fetched inbox event.
238
+
239
+ Attachment ids and filenames come from the transaction payload. Pass
240
+ ``dest`` to write ``{dest}/{event_id}/{filename}`` and fill ``path``.
241
+ """
242
+ config = self._require_consumer_config("download attachments")
243
+ inbox = await self._resolve_inbox_async(config)
244
+ inbox.ensure_can_receive()
245
+ return await asyncio.to_thread(
246
+ lambda: self.service.attachments.download_inbox_attachments(
247
+ inbox.config_id,
248
+ transaction,
249
+ dest=dest,
250
+ )
251
+ )
252
+
253
+ async def close(self) -> None:
254
+ await asyncio.to_thread(self.service.close)
255
+ await asyncio.to_thread(self.client.close)
256
+
257
+ def _attachment_download_client(self, config: ErgonPlatformClient) -> Any:
258
+ timeout = 20.0
259
+ if self._consumer_config is not None:
260
+ timeout = self._consumer_config.attachment_download_timeout
261
+ download_config = config.model_copy(update={"timeout": timeout, "max_retries": 0})
262
+ return create_ergon_client(download_config)
263
+
264
+ def _require_consumer_config(self, action: str) -> ErgonPlatformChannelsConsumerConfig:
265
+ if self._consumer_config is None:
266
+ raise ValueError(f"AsyncErgonPlatformChannelsConnector requires a consumer_config to {action}")
267
+ return self._consumer_config
268
+
269
+ async def _resolve_inbox_async(
270
+ self,
271
+ config: ErgonPlatformChannelsConsumerConfig,
272
+ ) -> ResolvedInboxAddress:
273
+ return await asyncio.to_thread(lambda: self._resolve_inbox(config))
274
+
275
+ def _resolve_inbox(self, config: ErgonPlatformChannelsConsumerConfig) -> ResolvedInboxAddress:
276
+ return self.service.addresses.resolve_consumer_inbox(config)
277
+
278
+ def _resolve_send_inbox(
279
+ self,
280
+ *,
281
+ address_id: Optional[str],
282
+ address: Optional[str],
283
+ ) -> ResolvedInboxAddress:
284
+ return self.service.addresses.resolve_sender_inbox(
285
+ address=address,
286
+ address_id=address_id,
287
+ config_id=(
288
+ self._producer_config.config_id or (self._consumer_config.config_id if self._consumer_config else None)
289
+ ),
290
+ )
291
+
292
+ def _default_inbox_address(self) -> Optional[str]:
293
+ if self._consumer_config and self._consumer_config.address:
294
+ return self._consumer_config.address
295
+ return self._producer_config.address
296
+
297
+ async def _send_from_payload(self, payload: SendMessagePayload) -> Any:
298
+ parts = self.service.messages.normalize_send_payload(payload)
299
+ top = parts["top"]
300
+ config: Dict[str, Any] = parts["config"]
301
+ producer = self._producer_config
302
+
303
+ send_address = producer.address or self._default_inbox_address()
304
+ inbox = await asyncio.to_thread(
305
+ lambda: self._resolve_send_inbox(
306
+ address_id=top.get("address_id"),
307
+ address=send_address if top.get("address_id") is None else None,
308
+ )
309
+ )
310
+ inbox.ensure_can_send()
311
+
312
+ channel = top.get("channel") or DEFAULT_CHANNEL
313
+ resource_id = top.get("resource_id")
314
+ service_name = top.get("service_name") or producer.service_name
315
+
316
+ if producer.default_reply_to and not config.get("reply_to"):
317
+ config["reply_to"] = producer.default_reply_to
318
+
319
+ return await asyncio.to_thread(
320
+ lambda: self.service.messages.send_message(
321
+ address_id=inbox.address_id,
322
+ channel=channel,
323
+ config=config,
324
+ resource_id=resource_id,
325
+ service_name=service_name,
326
+ )
327
+ )
@@ -0,0 +1,294 @@
1
+ import logging
2
+ from pathlib import Path
3
+ from typing import Any, Dict, List, Optional, Union
4
+
5
+ from ...connector import Connector
6
+ from ...transaction import Transaction
7
+ from .._client import create_ergon_client
8
+ from ..models import ErgonPlatformClient
9
+ from .models import (
10
+ DEFAULT_CHANNEL,
11
+ ErgonPlatformChannelsConfig,
12
+ ErgonPlatformChannelsConsumerConfig,
13
+ ErgonPlatformChannelsProducerConfig,
14
+ InboxAttachmentFile,
15
+ ResolvedInboxAddress,
16
+ SendMessageAttachment,
17
+ SendMessageInput,
18
+ SendMessagePayload,
19
+ )
20
+ from .services import ErgonPlatformChannelsService
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class ErgonPlatformChannelsConnector(Connector):
26
+ """Sync connector for one Ergon Platform channels inbox."""
27
+
28
+ def __init__(
29
+ self,
30
+ client: ErgonPlatformClient,
31
+ channels_config: Optional[ErgonPlatformChannelsConfig] = None,
32
+ consumer_config: Optional[ErgonPlatformChannelsConsumerConfig] = None,
33
+ producer_config: Optional[ErgonPlatformChannelsProducerConfig] = None,
34
+ ) -> None:
35
+ if channels_config is not None:
36
+ consumer_config = consumer_config or channels_config.to_consumer_config()
37
+ producer_config = producer_config or channels_config.to_producer_config()
38
+ self._consumer_config = consumer_config
39
+ self._producer_config = producer_config or ErgonPlatformChannelsProducerConfig()
40
+ self.client = create_ergon_client(client)
41
+ self.service = ErgonPlatformChannelsService(
42
+ client,
43
+ self.client,
44
+ download_client=self._attachment_download_client(client),
45
+ )
46
+ self._seen_event_ids: set[str] = set()
47
+
48
+ def fetch_transactions(
49
+ self,
50
+ batch_size: Optional[int] = None,
51
+ *args,
52
+ **kwargs,
53
+ ) -> List[Transaction]:
54
+ config = self._require_consumer_config("fetch transactions")
55
+ inbox = self._resolve_inbox(config)
56
+ inbox.ensure_can_receive()
57
+ limit = batch_size or config.batch_size
58
+ if kwargs:
59
+ raise TypeError(f"Unsupported inbox claim options: {', '.join(sorted(kwargs))}")
60
+ transactions = self.service.activity.claim_inbox_transactions(
61
+ config_id=inbox.config_id,
62
+ address_id=inbox.address_id,
63
+ config=config,
64
+ limit=limit,
65
+ seen_ids=self._seen_event_ids if config.deduplicate_fetched_events else None,
66
+ )
67
+ try:
68
+ return self._hydrate_fetched_transactions(config, inbox.config_id, transactions)
69
+ except Exception:
70
+ self.service.activity.release_claims(
71
+ inbox.config_id,
72
+ transactions,
73
+ delay_seconds=config.nack_delay_seconds,
74
+ )
75
+ for transaction in transactions:
76
+ if transaction.id:
77
+ self._seen_event_ids.discard(transaction.id)
78
+ raise
79
+
80
+ def _hydrate_fetched_transactions(
81
+ self,
82
+ config: ErgonPlatformChannelsConsumerConfig,
83
+ config_id: str,
84
+ transactions: List[Transaction],
85
+ ) -> List[Transaction]:
86
+ if not config.download_attachments:
87
+ return transactions
88
+ return self.service.attachments.hydrate_inbox_transactions(
89
+ config_id,
90
+ transactions,
91
+ failure_policy=config.attachment_failure_policy,
92
+ )
93
+
94
+ def fetch_transaction_by_id(self, transaction_id: str, *args, **kwargs) -> Transaction:
95
+ config = self._require_consumer_config("fetch a transaction by id")
96
+ inbox = self._resolve_inbox(config)
97
+ inbox.ensure_can_receive()
98
+ transaction = self.service.activity.get_inbox_event(
99
+ inbox.config_id,
100
+ transaction_id,
101
+ **kwargs,
102
+ )
103
+ hydrated = self._hydrate_fetched_transactions(config, inbox.config_id, [transaction])
104
+ return hydrated[0]
105
+
106
+ def get_transactions_count(self, *args, **kwargs) -> int:
107
+ config = self._require_consumer_config("count transactions")
108
+ inbox = self._resolve_inbox(config)
109
+ inbox.ensure_can_receive()
110
+ return self.service.activity.get_inbox_events_count(
111
+ inbox.config_id,
112
+ address_id=inbox.address_id,
113
+ **kwargs,
114
+ )
115
+
116
+ def dispatch_transactions(self, transactions: List[Transaction], *args, **kwargs) -> List[str]:
117
+ sent_ids: List[str] = []
118
+ for transaction in transactions:
119
+ result = self._send_from_payload(transaction.payload)
120
+ sent_ids.append(self.service.messages.send_response_id(result))
121
+ return sent_ids
122
+
123
+ def send_message(self, payload: SendMessagePayload) -> Any:
124
+ return self._send_from_payload(payload)
125
+
126
+ def send_email(
127
+ self,
128
+ to: Union[str, List[str]],
129
+ subject: str,
130
+ text: Optional[str] = None,
131
+ html: Optional[str] = None,
132
+ cc: Optional[List[str]] = None,
133
+ bcc: Optional[List[str]] = None,
134
+ reply_to: Optional[str] = None,
135
+ in_reply_to: Optional[str] = None,
136
+ attachments: Optional[List[SendMessageAttachment]] = None,
137
+ ) -> str:
138
+ """Send one email and return the platform log/message id."""
139
+ payload = SendMessageInput(
140
+ to=self.service.messages.normalize_recipients(to),
141
+ subject=subject,
142
+ text=text,
143
+ html=html,
144
+ cc=cc,
145
+ bcc=bcc,
146
+ reply_to=reply_to,
147
+ in_reply_to=in_reply_to,
148
+ attachments=attachments,
149
+ )
150
+ result = self._send_from_payload(payload)
151
+ return self.service.messages.send_response_id(result)
152
+
153
+ def list_thread_messages(self, thread_id: str, **params: Any) -> List[Transaction]:
154
+ """Read messages of a specific thread (outside the normal consumer loop)."""
155
+ return self.service.messages.fetch_thread_messages(thread_id, **params)
156
+
157
+ def list_company_activity(self, **params: Any) -> List[Transaction]:
158
+ """Read the company-wide channels activity feed (admin/reporting)."""
159
+ return self.service.activity.fetch_activity_events(**params)
160
+
161
+ def resolve_inbox(self) -> ResolvedInboxAddress:
162
+ """Resolve ``address`` (+ optional ``config_id``) to platform routing metadata."""
163
+ config = self._require_consumer_config("resolve inbox")
164
+ return self._resolve_inbox(config)
165
+
166
+ def resolve_address_info(self, *, address: Optional[str] = None) -> Dict[str, Any]:
167
+ """Return resolved inbox metadata including receive/send capabilities."""
168
+ config = self._require_consumer_config("resolve address info")
169
+ if address and address != config.address:
170
+ config = config.model_copy(update={"address": address})
171
+ return self._resolve_inbox(config).as_info_dict()
172
+
173
+ def ack_transaction(
174
+ self,
175
+ transaction: Transaction,
176
+ *,
177
+ allow_attachment_failures: bool = False,
178
+ ) -> None:
179
+ """Acknowledge a processed inbox event on the platform."""
180
+ config = self._require_consumer_config("ack a transaction")
181
+ inbox = self._resolve_inbox(config)
182
+ failures = (transaction.metadata or {}).get("attachment_failures")
183
+ if failures and not allow_attachment_failures:
184
+ raise ValueError(
185
+ "Transaction has failed attachment downloads; nack it or pass "
186
+ "allow_attachment_failures=True to acknowledge explicitly"
187
+ )
188
+ self.service.activity.ack_inbox_event(inbox.config_id, transaction)
189
+ if transaction.id:
190
+ self._seen_event_ids.add(transaction.id)
191
+
192
+ def nack_transaction(
193
+ self,
194
+ transaction: Transaction,
195
+ requeue: bool = True,
196
+ delay_seconds: Optional[int] = None,
197
+ ) -> None:
198
+ """Requeue or fail an inbox event on the platform."""
199
+ config = self._require_consumer_config("nack a transaction")
200
+ inbox = self._resolve_inbox(config)
201
+ delay = config.nack_delay_seconds if delay_seconds is None else delay_seconds
202
+ self.service.activity.nack_inbox_event(
203
+ inbox.config_id,
204
+ transaction,
205
+ requeue=requeue,
206
+ delay_seconds=delay,
207
+ )
208
+ if requeue and transaction.id:
209
+ self._seen_event_ids.discard(transaction.id)
210
+
211
+ def download_attachments(
212
+ self,
213
+ transaction: Transaction,
214
+ dest: Optional[Union[str, Path]] = None,
215
+ ) -> List[InboxAttachmentFile]:
216
+ """Download inbound attachments from a fetched inbox event.
217
+
218
+ Attachment ids and filenames come from the transaction payload. Pass
219
+ ``dest`` to write ``{dest}/{event_id}/{filename}`` and fill ``path``.
220
+ """
221
+ config = self._require_consumer_config("download attachments")
222
+ inbox = self._resolve_inbox(config)
223
+ inbox.ensure_can_receive()
224
+ return self.service.attachments.download_inbox_attachments(
225
+ inbox.config_id,
226
+ transaction,
227
+ dest=dest,
228
+ )
229
+
230
+ def close(self) -> None:
231
+ self.service.close()
232
+ self.client.close()
233
+
234
+ def _attachment_download_client(self, config: ErgonPlatformClient) -> Any:
235
+ timeout = 20.0
236
+ if self._consumer_config is not None:
237
+ timeout = self._consumer_config.attachment_download_timeout
238
+ download_config = config.model_copy(update={"timeout": timeout, "max_retries": 0})
239
+ return create_ergon_client(download_config)
240
+
241
+ def _require_consumer_config(self, action: str) -> ErgonPlatformChannelsConsumerConfig:
242
+ if self._consumer_config is None:
243
+ raise ValueError(f"ErgonPlatformChannelsConnector requires a consumer_config to {action}")
244
+ return self._consumer_config
245
+
246
+ def _resolve_inbox(self, config: ErgonPlatformChannelsConsumerConfig) -> ResolvedInboxAddress:
247
+ return self.service.addresses.resolve_consumer_inbox(config)
248
+
249
+ def _resolve_send_inbox(
250
+ self,
251
+ *,
252
+ address_id: Optional[str],
253
+ address: Optional[str],
254
+ ) -> ResolvedInboxAddress:
255
+ return self.service.addresses.resolve_sender_inbox(
256
+ address=address,
257
+ address_id=address_id,
258
+ config_id=(
259
+ self._producer_config.config_id or (self._consumer_config.config_id if self._consumer_config else None)
260
+ ),
261
+ )
262
+
263
+ def _default_inbox_address(self) -> Optional[str]:
264
+ if self._consumer_config and self._consumer_config.address:
265
+ return self._consumer_config.address
266
+ return self._producer_config.address
267
+
268
+ def _send_from_payload(self, payload: SendMessagePayload) -> Any:
269
+ parts = self.service.messages.normalize_send_payload(payload)
270
+ top = parts["top"]
271
+ config: Dict[str, Any] = parts["config"]
272
+ producer = self._producer_config
273
+
274
+ send_address = producer.address or self._default_inbox_address()
275
+ inbox = self._resolve_send_inbox(
276
+ address_id=top.get("address_id"),
277
+ address=send_address if top.get("address_id") is None else None,
278
+ )
279
+ inbox.ensure_can_send()
280
+
281
+ channel = top.get("channel") or DEFAULT_CHANNEL
282
+ resource_id = top.get("resource_id")
283
+ service_name = top.get("service_name") or producer.service_name
284
+
285
+ if producer.default_reply_to and not config.get("reply_to"):
286
+ config["reply_to"] = producer.default_reply_to
287
+
288
+ return self.service.messages.send_message(
289
+ address_id=inbox.address_id,
290
+ channel=channel,
291
+ config=config,
292
+ resource_id=resource_id,
293
+ service_name=service_name,
294
+ )