ergon-framework-python 0.2.1__py3-none-any.whl → 0.2.3__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 (33) 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/connector/rabbitmq/async_service.py +61 -18
  27. ergon/task/mixins/consumer.py +1 -3
  28. {ergon_framework_python-0.2.1.dist-info → ergon_framework_python-0.2.3.dist-info}/METADATA +3 -3
  29. {ergon_framework_python-0.2.1.dist-info → ergon_framework_python-0.2.3.dist-info}/RECORD +33 -16
  30. {ergon_framework_python-0.2.1.dist-info → ergon_framework_python-0.2.3.dist-info}/WHEEL +0 -0
  31. {ergon_framework_python-0.2.1.dist-info → ergon_framework_python-0.2.3.dist-info}/entry_points.txt +0 -0
  32. {ergon_framework_python-0.2.1.dist-info → ergon_framework_python-0.2.3.dist-info}/licenses/LICENSE +0 -0
  33. {ergon_framework_python-0.2.1.dist-info → ergon_framework_python-0.2.3.dist-info}/top_level.txt +0 -0
ergon/__init__.py CHANGED
@@ -10,4 +10,4 @@ __all__ = [
10
10
  "manager",
11
11
  ]
12
12
 
13
- __version__ = "0.2.1"
13
+ __version__ = "0.2.3"
@@ -7,13 +7,23 @@ from .transaction import Transaction
7
7
 
8
8
  if TYPE_CHECKING:
9
9
  from .ergon_platform import (
10
+ AsyncErgonPlatformChannelsConnector,
10
11
  AsyncErgonPlatformConnector,
11
12
  CreateItemInput,
13
+ ErgonPlatformChannelsConfig,
14
+ ErgonPlatformChannelsConnector,
15
+ ErgonPlatformChannelsConsumerConfig,
16
+ ErgonPlatformChannelsProducerConfig,
12
17
  ErgonPlatformClient,
13
18
  ErgonPlatformConnector,
14
19
  ErgonPlatformConsumerConfig,
15
20
  ErgonPlatformProducerConfig,
16
21
  )
22
+
23
+ # NOTE: ``SendMessageInput`` is defined by both Nylas and Channels with
24
+ # different shapes. The top-level alias here points to the Nylas type
25
+ # (matches the pre-split lazy-export map); channels users must import it
26
+ # from ``ergon.connector.ergon_platform.channels``.
17
27
  from .excel import ExcelConnector, ExcelFetchConfig, ExcelRow, ExcelService
18
28
  from .nylas import (
19
29
  AckActionConfig,
@@ -76,6 +86,7 @@ _CONNECTOR_SUBPACKAGES = (
76
86
 
77
87
  _LAZY_EXPORTS = {
78
88
  "AckActionConfig": "nylas",
89
+ "AsyncErgonPlatformChannelsConnector": "ergon_platform",
79
90
  "AsyncErgonPlatformConnector": "ergon_platform",
80
91
  "AsyncNylasAuthService": "nylas",
81
92
  "AsyncNylasConnector": "nylas",
@@ -94,6 +105,10 @@ _LAZY_EXPORTS = {
94
105
  "AuthUrlConfig": "nylas",
95
106
  "CodeExchangeInput": "nylas",
96
107
  "CreateItemInput": "ergon_platform",
108
+ "ErgonPlatformChannelsConfig": "ergon_platform",
109
+ "ErgonPlatformChannelsConnector": "ergon_platform",
110
+ "ErgonPlatformChannelsConsumerConfig": "ergon_platform",
111
+ "ErgonPlatformChannelsProducerConfig": "ergon_platform",
97
112
  "ErgonPlatformClient": "ergon_platform",
98
113
  "ErgonPlatformConnector": "ergon_platform",
99
114
  "ErgonPlatformConsumerConfig": "ergon_platform",
@@ -148,6 +163,7 @@ def __dir__() -> list[str]:
148
163
  __all__ = [
149
164
  "AckActionConfig",
150
165
  "AsyncConnector",
166
+ "AsyncErgonPlatformChannelsConnector",
151
167
  "AsyncErgonPlatformConnector",
152
168
  "AsyncNylasAuthService",
153
169
  "AsyncNylasConnector",
@@ -169,6 +185,10 @@ __all__ = [
169
185
  "Connector",
170
186
  "ConnectorConfig",
171
187
  "CreateItemInput",
188
+ "ErgonPlatformChannelsConfig",
189
+ "ErgonPlatformChannelsConnector",
190
+ "ErgonPlatformChannelsConsumerConfig",
191
+ "ErgonPlatformChannelsProducerConfig",
172
192
  "ErgonPlatformClient",
173
193
  "ErgonPlatformConnector",
174
194
  "ErgonPlatformConsumerConfig",
@@ -1,24 +1,81 @@
1
+ """Ergon Platform connectors — Workflows and Channels.
2
+
3
+ The Ergon Platform SDK exposes several services (workflows, channels, agents,
4
+ buckets, ...). This package groups every framework connector against that
5
+ platform. Each service lives in its own sub-package (``workflows``,
6
+ ``channels``, ...) and reuses the shared credentials model
7
+ (:class:`ErgonPlatformClient`) so a single API key drives every connector.
8
+
9
+ Public workflow names are re-exported here for backward compatibility with the
10
+ pre-split layout::
11
+
12
+ from ergon.connector.ergon_platform import ErgonPlatformConnector # workflows
13
+
14
+ New code is encouraged to import directly from the sub-package::
15
+
16
+ from ergon.connector.ergon_platform.workflows import ErgonPlatformConnector
17
+ from ergon.connector.ergon_platform.channels import ErgonPlatformChannelsConnector
18
+ """
19
+
20
+ from importlib import import_module
1
21
  from typing import TYPE_CHECKING, Any
2
22
 
3
23
  from .._lazy import exported_names, load_optional_export
4
- from .models import (
5
- CreateItemInput,
6
- ErgonPlatformClient,
7
- ErgonPlatformConsumerConfig,
8
- ErgonPlatformProducerConfig,
9
- )
24
+ from .models import ErgonPlatformClient
10
25
 
11
26
  if TYPE_CHECKING:
12
- from .async_connector import AsyncErgonPlatformConnector
13
- from .connector import ErgonPlatformConnector
27
+ from .channels import (
28
+ AsyncErgonPlatformChannelsConnector,
29
+ ErgonPlatformChannelsConfig,
30
+ ErgonPlatformChannelsConnector,
31
+ ErgonPlatformChannelsConsumerConfig,
32
+ ErgonPlatformChannelsProducerConfig,
33
+ ErgonPlatformChannelsService,
34
+ SendMessageInput,
35
+ SendMessagePayload,
36
+ )
37
+ from .workflows import (
38
+ AsyncErgonPlatformConnector,
39
+ CreateItemInput,
40
+ CreateItemPayload,
41
+ ErgonPlatformConnector,
42
+ ErgonPlatformConsumerConfig,
43
+ ErgonPlatformProducerConfig,
44
+ ErgonPlatformWorkflowsService,
45
+ )
46
+
47
+ _SUBPACKAGES = ("channels", "workflows")
14
48
 
49
+ # Backward-compat aliases: names that used to live under
50
+ # ``ergon.connector.ergon_platform`` before the workflows/ split, plus the new
51
+ # channels API surface. Every entry is loaded lazily from its sub-package to
52
+ # keep the platform SDK out of the core import path.
15
53
  _LAZY_EXPORTS = {
16
- "AsyncErgonPlatformConnector": "async_connector",
17
- "ErgonPlatformConnector": "connector",
54
+ # Workflows (pre-split names — kept for backward compatibility)
55
+ "AsyncErgonPlatformConnector": "workflows",
56
+ "CreateItemInput": "workflows",
57
+ "CreateItemPayload": "workflows",
58
+ "ErgonPlatformConnector": "workflows",
59
+ "ErgonPlatformConsumerConfig": "workflows",
60
+ "ErgonPlatformProducerConfig": "workflows",
61
+ "ErgonPlatformWorkflowsService": "workflows",
62
+ # Channels
63
+ "AsyncErgonPlatformChannelsConnector": "channels",
64
+ "ErgonPlatformChannelsConfig": "channels",
65
+ "ErgonPlatformChannelsConnector": "channels",
66
+ "ErgonPlatformChannelsConsumerConfig": "channels",
67
+ "ErgonPlatformChannelsProducerConfig": "channels",
68
+ "ErgonPlatformChannelsService": "channels",
69
+ "SendMessageInput": "channels",
70
+ "SendMessagePayload": "channels",
18
71
  }
19
72
 
20
73
 
21
74
  def __getattr__(name: str) -> Any:
75
+ if name in _SUBPACKAGES:
76
+ module = import_module(f".{name}", __name__)
77
+ globals()[name] = module
78
+ return module
22
79
  return load_optional_export(
23
80
  name=name,
24
81
  package=__name__,
@@ -30,14 +87,24 @@ def __getattr__(name: str) -> Any:
30
87
 
31
88
 
32
89
  def __dir__() -> list[str]:
33
- return exported_names(globals(), _LAZY_EXPORTS)
90
+ return sorted(set(exported_names(globals(), _LAZY_EXPORTS)) | set(_SUBPACKAGES))
34
91
 
35
92
 
36
93
  __all__ = [
94
+ "AsyncErgonPlatformChannelsConnector",
37
95
  "AsyncErgonPlatformConnector",
38
96
  "CreateItemInput",
97
+ "CreateItemPayload",
98
+ "ErgonPlatformChannelsConfig",
99
+ "ErgonPlatformChannelsConnector",
100
+ "ErgonPlatformChannelsConsumerConfig",
101
+ "ErgonPlatformChannelsProducerConfig",
102
+ "ErgonPlatformChannelsService",
39
103
  "ErgonPlatformClient",
40
104
  "ErgonPlatformConnector",
41
105
  "ErgonPlatformConsumerConfig",
42
106
  "ErgonPlatformProducerConfig",
107
+ "ErgonPlatformWorkflowsService",
108
+ "SendMessageInput",
109
+ "SendMessagePayload",
43
110
  ]
@@ -1,223 +1,5 @@
1
- import asyncio
2
- import logging
3
- from typing import Any, Dict, List, Optional
1
+ """Deprecated compatibility import for the async Workflows connector."""
4
2
 
5
- from ..connector import AsyncConnector
6
- from ..transaction import Transaction
7
- from ._client import create_ergon_client
8
- from ._operations import _ErgonPlatformOperations
9
- from .models import (
10
- CreateItemPayload,
11
- ErgonPlatformClient,
12
- ErgonPlatformConsumerConfig,
13
- ErgonPlatformProducerConfig,
14
- )
15
- from .utils import get_value, normalize_create_payload
3
+ from .workflows.async_connector import AsyncErgonPlatformConnector
16
4
 
17
- logger = logging.getLogger(__name__)
18
-
19
-
20
- class AsyncErgonPlatformConnector(AsyncConnector):
21
- def __init__(
22
- self,
23
- client: ErgonPlatformClient,
24
- consumer_config: Optional[ErgonPlatformConsumerConfig] = None,
25
- producer_config: Optional[ErgonPlatformProducerConfig] = None,
26
- ) -> None:
27
- self.client = create_ergon_client(client)
28
- self._operations = _ErgonPlatformOperations(client, self.client)
29
- self._consumer_config = consumer_config
30
- self._producer_config = producer_config or ErgonPlatformProducerConfig()
31
-
32
- async def fetch_transactions_async(
33
- self,
34
- batch_size: Optional[int] = None,
35
- *args,
36
- **kwargs,
37
- ) -> List[Transaction]:
38
- if self._consumer_config is None:
39
- raise ValueError("AsyncErgonPlatformConnector requires a consumer_config to fetch transactions")
40
-
41
- config = self._consumer_config
42
- limit = batch_size or config.batch_size
43
- params: Dict[str, Any] = {**config.list_params, **kwargs}
44
- if config.unassigned:
45
- params.pop("assigned_to", None)
46
- params["assigned"] = "no"
47
-
48
- transactions = await asyncio.to_thread(
49
- lambda: self._operations.fetch_items(
50
- config.workflow_id,
51
- config.phase_id,
52
- limit=limit,
53
- offset=config.offset,
54
- **params,
55
- )
56
- )
57
- if not config.unassigned:
58
- return transactions
59
-
60
- claimed_transactions: List[Transaction] = []
61
- for transaction in transactions:
62
- try:
63
- await asyncio.to_thread(lambda: self.client.workflows.items.claim(transaction.id, {}))
64
- claimed_transactions.append(await self.fetch_transaction_by_id_async(transaction.id))
65
- except Exception:
66
- logger.warning(
67
- "Failed to claim item %s during unassigned fetch; skipping it",
68
- transaction.id,
69
- exc_info=True,
70
- )
71
- return claimed_transactions
72
-
73
- async def dispatch_transactions_async(
74
- self,
75
- transactions: List[Transaction],
76
- *args,
77
- **kwargs,
78
- ) -> List[str]:
79
- created_ids: List[str] = []
80
- for transaction in transactions:
81
- result = await self._create_from_payload(transaction.payload)
82
- created_ids.append(self._created_item_id(result))
83
- return created_ids
84
-
85
- async def fetch_child_transactions_async(
86
- self,
87
- parent_item_id: str,
88
- *args,
89
- **kwargs,
90
- ) -> List[Transaction]:
91
- return await asyncio.to_thread(lambda: self._operations.fetch_child_items(parent_item_id, **kwargs))
92
-
93
- async def fetch_transaction_by_id_async(self, transaction_id: str, *args, **kwargs) -> Transaction:
94
- workflow_id = self._consumer_config.workflow_id if self._consumer_config else ""
95
- return await asyncio.to_thread(
96
- lambda: self._operations.get_item_transaction(transaction_id, workflow_id, **kwargs)
97
- )
98
-
99
- async def fetch_items_by_query(
100
- self, workflow_id: str, query: Optional[Dict[str, Any]] = None, **fields: Any
101
- ) -> List[Transaction]:
102
- return await asyncio.to_thread(lambda: self._operations.fetch_items_by_query(workflow_id, query, **fields))
103
-
104
- async def get_transactions_count_async(self, *args, **kwargs) -> int:
105
- if self._consumer_config is None:
106
- raise ValueError("AsyncErgonPlatformConnector requires a consumer_config to count transactions")
107
-
108
- config = self._consumer_config
109
- params: Dict[str, Any] = {**config.list_params, **kwargs}
110
- return await asyncio.to_thread(
111
- lambda: self._operations.get_phase_items_count(
112
- config.workflow_id,
113
- config.phase_id,
114
- **params,
115
- )
116
- )
117
-
118
- async def ack_transaction(self, transaction: Transaction, phase_id: Optional[str] = None) -> None:
119
- target_phase = phase_id
120
- if target_phase is None and self._consumer_config is not None:
121
- target_phase = self._consumer_config.ack_phase_id
122
- if not target_phase:
123
- return
124
- await asyncio.to_thread(lambda: self.client.workflows.items.route(transaction.id, to_phase_id=target_phase))
125
-
126
- async def release_item(
127
- self,
128
- item_id: str,
129
- data: Optional[Dict[str, Any]] = None,
130
- *,
131
- delay_seconds: Optional[int] = None,
132
- **fields: Any,
133
- ) -> Any:
134
- return await asyncio.to_thread(
135
- lambda: self._operations.release_item(
136
- item_id,
137
- data,
138
- delay_seconds=delay_seconds,
139
- **fields,
140
- )
141
- )
142
-
143
- async def nack_transaction(self, transaction: Transaction, requeue: bool = True, delay_seconds: int = 10) -> None:
144
- if requeue:
145
- await asyncio.to_thread(lambda: self._operations.release_item(transaction.id, delay_seconds=delay_seconds))
146
- return
147
-
148
- target_phase = self._consumer_config.nack_phase_id if self._consumer_config is not None else None
149
- if not target_phase:
150
- raise ValueError("nack_phase_id is required when nack_transaction is called with requeue=False")
151
-
152
- await asyncio.to_thread(lambda: self.client.workflows.items.route(transaction.id, to_phase_id=target_phase))
153
-
154
- async def close(self) -> None:
155
- await asyncio.to_thread(self.client.close)
156
-
157
- async def list_phase_fields(
158
- self,
159
- phase_id: str,
160
- *,
161
- workflow_id: Optional[str] = None,
162
- include_workflow_fields: bool = True,
163
- **params: Any,
164
- ) -> Any:
165
- return await asyncio.to_thread(
166
- lambda: self._operations.list_phase_fields(
167
- phase_id,
168
- workflow_id=workflow_id,
169
- include_workflow_fields=include_workflow_fields,
170
- **params,
171
- )
172
- )
173
-
174
- async def get_pipeline_result(
175
- self,
176
- workflow_id: str,
177
- item_id: str,
178
- field_id: str,
179
- buckets_file_id: Optional[str] = None,
180
- ) -> Dict[str, Any]:
181
- return await asyncio.to_thread(
182
- self._operations.get_pipeline_result,
183
- workflow_id,
184
- item_id,
185
- field_id,
186
- buckets_file_id,
187
- )
188
-
189
- async def _create_from_payload(self, payload: CreateItemPayload) -> Any:
190
- data = normalize_create_payload(payload)
191
- producer = self._producer_config
192
-
193
- workflow_id = data.get("workflow_id") or producer.workflow_id
194
- phase_id = data.get("phase_id") or producer.phase_id
195
- if not workflow_id:
196
- raise ValueError("workflow_id is required to create an item (set it in payload or producer_config)")
197
- if not phase_id:
198
- raise ValueError("phase_id is required to create an item (set it in payload or producer_config)")
199
-
200
- attachment = data.get("attachment")
201
- attachment_field_id = data.get("attachment_field_id") or producer.attachment_field_id
202
- content_type = data.get("content_type") or producer.default_content_type
203
- parent_item_id = data.get("parent_item_id") or producer.parent_item_id
204
-
205
- return await asyncio.to_thread(
206
- lambda: self._operations.create_item(
207
- workflow_id,
208
- phase_id,
209
- data["title"],
210
- parent_item_id=parent_item_id,
211
- field_values=data.get("field_values"),
212
- attachment=attachment,
213
- attachment_field_id=attachment_field_id,
214
- content_type=content_type,
215
- **data.get("fields", {}),
216
- )
217
- )
218
-
219
- @staticmethod
220
- def _created_item_id(result: Any) -> str:
221
- if isinstance(result, dict) and "item" in result:
222
- return str(get_value(result["item"], "id", ""))
223
- return str(get_value(result, "id", ""))
5
+ __all__ = ["AsyncErgonPlatformConnector"]
@@ -0,0 +1,54 @@
1
+ from typing import TYPE_CHECKING, Any
2
+
3
+ from ..._lazy import exported_names, load_optional_export
4
+ from .models import (
5
+ ChannelsActivityFilter,
6
+ ErgonPlatformChannelsConfig,
7
+ ErgonPlatformChannelsConsumerConfig,
8
+ ErgonPlatformChannelsProducerConfig,
9
+ InboxAttachmentFile,
10
+ SendMessageAttachment,
11
+ SendMessageInput,
12
+ SendMessagePayload,
13
+ )
14
+
15
+ if TYPE_CHECKING:
16
+ from .async_connector import AsyncErgonPlatformChannelsConnector
17
+ from .connector import ErgonPlatformChannelsConnector
18
+ from .services import ErgonPlatformChannelsService
19
+
20
+ _LAZY_EXPORTS = {
21
+ "AsyncErgonPlatformChannelsConnector": "async_connector",
22
+ "ErgonPlatformChannelsConnector": "connector",
23
+ "ErgonPlatformChannelsService": "services",
24
+ }
25
+
26
+
27
+ def __getattr__(name: str) -> Any:
28
+ return load_optional_export(
29
+ name=name,
30
+ package=__name__,
31
+ exports=_LAZY_EXPORTS,
32
+ namespace=globals(),
33
+ extra="ergon-platform",
34
+ dependencies=("ergon_platform", "httpx"),
35
+ )
36
+
37
+
38
+ def __dir__() -> list[str]:
39
+ return exported_names(globals(), _LAZY_EXPORTS)
40
+
41
+
42
+ __all__ = [
43
+ "AsyncErgonPlatformChannelsConnector",
44
+ "ChannelsActivityFilter",
45
+ "ErgonPlatformChannelsConnector",
46
+ "ErgonPlatformChannelsConfig",
47
+ "ErgonPlatformChannelsConsumerConfig",
48
+ "ErgonPlatformChannelsProducerConfig",
49
+ "ErgonPlatformChannelsService",
50
+ "InboxAttachmentFile",
51
+ "SendMessageAttachment",
52
+ "SendMessageInput",
53
+ "SendMessagePayload",
54
+ ]
@@ -0,0 +1,230 @@
1
+ from typing import Any, Dict, List, Optional
2
+
3
+ from ...transaction import Transaction
4
+ from .models import ErgonPlatformChannelsConsumerConfig
5
+ from .services.records import SdkRecord
6
+
7
+
8
+ class ActivityAdapter:
9
+ """Map Channels activity events into the connector Transaction shape."""
10
+
11
+ _META_KEYS = (
12
+ "event_type",
13
+ "channel",
14
+ "direction",
15
+ "status",
16
+ "thread_id",
17
+ "correlation_id",
18
+ "provider_message_id",
19
+ "subject",
20
+ "from_address",
21
+ "to_addresses",
22
+ "address_id",
23
+ "consumption_status",
24
+ "acked_at",
25
+ "available_at",
26
+ "consumer_id",
27
+ "attempt_count",
28
+ )
29
+ _DELIVERY_KEYS = (
30
+ "subscription_id",
31
+ "lease_token",
32
+ "lease_expires_at",
33
+ "consumer_id",
34
+ "attempt_count",
35
+ )
36
+
37
+ @classmethod
38
+ def to_transaction(cls, event: Any, *, source: str) -> Transaction:
39
+ """To transaction."""
40
+ payload = SdkRecord.serialize(event)
41
+ payload = payload if isinstance(payload, dict) else {"value": payload}
42
+ metadata: Dict[str, Any] = {"source": source}
43
+ for key in cls._META_KEYS:
44
+ value = payload.get(key)
45
+ if value is not None:
46
+ metadata[key] = value
47
+ nested = payload.get("payload")
48
+ if isinstance(nested, dict):
49
+ metadata["message_payload"] = nested
50
+ attachments = cls.public_attachments(nested.get("attachments"))
51
+ if attachments:
52
+ metadata["attachments"] = attachments
53
+ metadata["has_attachment"] = True
54
+ return Transaction(
55
+ id=SdkRecord.first_id(
56
+ payload,
57
+ "id",
58
+ "log_id",
59
+ "message_id",
60
+ "provider_message_id",
61
+ ),
62
+ payload=payload,
63
+ metadata=metadata,
64
+ )
65
+
66
+ @classmethod
67
+ def claimed_transaction(cls, item: Any) -> Transaction:
68
+ """Claimed transaction."""
69
+ claimed = SdkRecord.serialize(item)
70
+ claimed = claimed if isinstance(claimed, dict) else {}
71
+ transaction = cls.to_transaction(
72
+ claimed.get("event"),
73
+ source="config_activity_claim",
74
+ )
75
+ metadata = dict(transaction.metadata or {})
76
+ delivery = claimed.get("delivery")
77
+ if isinstance(delivery, dict):
78
+ metadata["delivery"] = {key: delivery[key] for key in cls._DELIVERY_KEYS if delivery.get(key) is not None}
79
+ return transaction.model_copy(update={"metadata": metadata})
80
+
81
+ @staticmethod
82
+ def delivery(transaction: Transaction) -> Dict[str, Any]:
83
+ """Delivery."""
84
+ metadata = transaction.metadata or {}
85
+ delivery = metadata.get("delivery")
86
+ if not isinstance(delivery, dict):
87
+ raise ValueError("Transaction was not claimed and cannot be acknowledged or nacked")
88
+ if not delivery.get("subscription_id") or not delivery.get("lease_token"):
89
+ raise ValueError("Transaction claim is missing subscription_id or lease_token")
90
+ return delivery
91
+
92
+ @staticmethod
93
+ def belongs_to_address(
94
+ transaction: Transaction,
95
+ address_id: str,
96
+ ) -> bool:
97
+ """Belongs to address."""
98
+ metadata = transaction.metadata or {}
99
+ nested = metadata.get("message_payload")
100
+ if isinstance(nested, dict):
101
+ candidate = nested.get("address_id") or nested.get("channel_address_id")
102
+ if candidate is not None:
103
+ return str(candidate) == address_id
104
+ event = transaction.payload if isinstance(transaction.payload, dict) else {}
105
+ candidate = event.get("address_id") or event.get("channel_address_id")
106
+ if candidate is not None:
107
+ return str(candidate) == address_id
108
+ return True
109
+
110
+ @staticmethod
111
+ def attachment_id(attachment: Any) -> Optional[str]:
112
+ """Attachment id."""
113
+ if not isinstance(attachment, dict):
114
+ return None
115
+ for key in ("id", "resend_attachment_id", "attachment_id"):
116
+ value = attachment.get(key)
117
+ if value:
118
+ return str(value)
119
+ return None
120
+
121
+ @classmethod
122
+ def public_attachment(
123
+ cls,
124
+ item: Any,
125
+ ) -> Optional[Dict[str, Any]]:
126
+ """Public attachment."""
127
+ if not isinstance(item, dict):
128
+ return None
129
+ filename = str(item.get("filename") or item.get("name") or "").strip()
130
+ if not filename:
131
+ return None
132
+ attachment: Dict[str, Any] = {"filename": filename}
133
+ attachment_id = cls.attachment_id(item)
134
+ if attachment_id:
135
+ attachment["id"] = attachment_id
136
+ content_type = item.get("content_type")
137
+ if content_type:
138
+ attachment["content_type"] = content_type
139
+ size = item.get("size")
140
+ if size is not None:
141
+ attachment["size"] = size
142
+ content = item.get("content")
143
+ if isinstance(content, bytes):
144
+ attachment["content"] = content
145
+ return attachment
146
+
147
+ @classmethod
148
+ def public_attachments(cls, items: Any) -> List[Dict[str, Any]]:
149
+ """Public attachments."""
150
+ if not isinstance(items, list):
151
+ return []
152
+ attachments: List[Dict[str, Any]] = []
153
+ for item in items:
154
+ attachment = cls.public_attachment(item)
155
+ if attachment is not None:
156
+ attachments.append(attachment)
157
+ return attachments
158
+
159
+ @classmethod
160
+ def attachments(
161
+ cls,
162
+ transaction: Transaction,
163
+ ) -> List[Dict[str, Any]]:
164
+ """Attachments."""
165
+ metadata = transaction.metadata or {}
166
+ items = metadata.get("attachments")
167
+ if isinstance(items, list):
168
+ return [item for item in items if isinstance(item, dict)]
169
+ nested = metadata.get("message_payload")
170
+ if isinstance(nested, dict):
171
+ return cls.public_attachments(nested.get("attachments"))
172
+ payload = transaction.payload
173
+ if isinstance(payload, dict):
174
+ nested = payload.get("payload")
175
+ if isinstance(nested, dict):
176
+ return cls.public_attachments(nested.get("attachments"))
177
+ return []
178
+
179
+ @staticmethod
180
+ def with_attachments(
181
+ transaction: Transaction,
182
+ attachments: List[Dict[str, Any]],
183
+ ) -> Transaction:
184
+ """With the attachments."""
185
+ metadata = dict(transaction.metadata or {})
186
+ metadata["attachments"] = attachments
187
+ metadata["has_attachment"] = bool(attachments)
188
+ nested = metadata.get("message_payload")
189
+ if isinstance(nested, dict):
190
+ metadata["message_payload"] = {
191
+ **nested,
192
+ "attachments": attachments,
193
+ }
194
+ payload = transaction.payload
195
+ if isinstance(payload, dict):
196
+ payload = dict(payload)
197
+ inner = payload.get("payload")
198
+ if isinstance(inner, dict):
199
+ payload["payload"] = {
200
+ **inner,
201
+ "attachments": attachments,
202
+ }
203
+ return transaction.model_copy(update={"payload": payload, "metadata": metadata})
204
+
205
+ @staticmethod
206
+ def unseen(
207
+ transactions: List[Transaction],
208
+ seen_ids: set[str],
209
+ ) -> List[Transaction]:
210
+ fresh: List[Transaction] = []
211
+ """Unseen transactions."""
212
+ for transaction in transactions:
213
+ if not transaction.id or transaction.id in seen_ids:
214
+ continue
215
+ seen_ids.add(transaction.id)
216
+ fresh.append(transaction)
217
+ return fresh
218
+
219
+ @classmethod
220
+ def finalize_fetch(
221
+ cls,
222
+ transactions: List[Transaction],
223
+ config: ErgonPlatformChannelsConsumerConfig,
224
+ seen_ids: Optional[set[str]] = None,
225
+ ) -> List[Transaction]:
226
+ """Finalize the fetched transactions."""
227
+ transactions = config.effective_activity_filter().select(transactions)
228
+ if seen_ids is not None:
229
+ transactions = cls.unseen(transactions, seen_ids)
230
+ return transactions