ReticulumTelemetryHub 0.1.0__py3-none-any.whl → 0.143.0__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.
- reticulum_telemetry_hub/api/__init__.py +23 -0
- reticulum_telemetry_hub/api/models.py +323 -0
- reticulum_telemetry_hub/api/service.py +836 -0
- reticulum_telemetry_hub/api/storage.py +528 -0
- reticulum_telemetry_hub/api/storage_base.py +156 -0
- reticulum_telemetry_hub/api/storage_models.py +118 -0
- reticulum_telemetry_hub/atak_cot/__init__.py +49 -0
- reticulum_telemetry_hub/atak_cot/base.py +277 -0
- reticulum_telemetry_hub/atak_cot/chat.py +506 -0
- reticulum_telemetry_hub/atak_cot/detail.py +235 -0
- reticulum_telemetry_hub/atak_cot/event.py +181 -0
- reticulum_telemetry_hub/atak_cot/pytak_client.py +569 -0
- reticulum_telemetry_hub/atak_cot/tak_connector.py +848 -0
- reticulum_telemetry_hub/config/__init__.py +25 -0
- reticulum_telemetry_hub/config/constants.py +7 -0
- reticulum_telemetry_hub/config/manager.py +515 -0
- reticulum_telemetry_hub/config/models.py +215 -0
- reticulum_telemetry_hub/embedded_lxmd/__init__.py +5 -0
- reticulum_telemetry_hub/embedded_lxmd/embedded.py +418 -0
- reticulum_telemetry_hub/internal_api/__init__.py +21 -0
- reticulum_telemetry_hub/internal_api/bus.py +344 -0
- reticulum_telemetry_hub/internal_api/core.py +690 -0
- reticulum_telemetry_hub/internal_api/v1/__init__.py +74 -0
- reticulum_telemetry_hub/internal_api/v1/enums.py +109 -0
- reticulum_telemetry_hub/internal_api/v1/manifest.json +8 -0
- reticulum_telemetry_hub/internal_api/v1/schemas.py +478 -0
- reticulum_telemetry_hub/internal_api/versioning.py +63 -0
- reticulum_telemetry_hub/lxmf_daemon/Handlers.py +122 -0
- reticulum_telemetry_hub/lxmf_daemon/LXMF.py +252 -0
- reticulum_telemetry_hub/lxmf_daemon/LXMPeer.py +898 -0
- reticulum_telemetry_hub/lxmf_daemon/LXMRouter.py +4227 -0
- reticulum_telemetry_hub/lxmf_daemon/LXMessage.py +1006 -0
- reticulum_telemetry_hub/lxmf_daemon/LXStamper.py +490 -0
- reticulum_telemetry_hub/lxmf_daemon/__init__.py +10 -0
- reticulum_telemetry_hub/lxmf_daemon/_version.py +1 -0
- reticulum_telemetry_hub/lxmf_daemon/lxmd.py +1655 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/fields/field_telemetry_stream.py +6 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/__init__.py +3 -0
- {lxmf_telemetry → reticulum_telemetry_hub/lxmf_telemetry}/model/persistance/appearance.py +19 -19
- {lxmf_telemetry → reticulum_telemetry_hub/lxmf_telemetry}/model/persistance/peer.py +17 -13
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/__init__.py +65 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/acceleration.py +68 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/ambient_light.py +37 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/angular_velocity.py +68 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/battery.py +68 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/connection_map.py +258 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/generic.py +841 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/gravity.py +68 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/humidity.py +37 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/information.py +42 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/location.py +110 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/lxmf_propagation.py +429 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/magnetic_field.py +68 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/physical_link.py +53 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/pressure.py +37 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/proximity.py +37 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/received.py +75 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/rns_transport.py +209 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/sensor.py +65 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/sensor_enum.py +27 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/sensor_mapping.py +58 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/temperature.py +37 -0
- {lxmf_telemetry → reticulum_telemetry_hub/lxmf_telemetry}/model/persistance/sensors/time.py +36 -32
- {lxmf_telemetry → reticulum_telemetry_hub/lxmf_telemetry}/model/persistance/telemeter.py +26 -23
- reticulum_telemetry_hub/lxmf_telemetry/sampler.py +229 -0
- reticulum_telemetry_hub/lxmf_telemetry/telemeter_manager.py +409 -0
- reticulum_telemetry_hub/lxmf_telemetry/telemetry_controller.py +804 -0
- reticulum_telemetry_hub/northbound/__init__.py +5 -0
- reticulum_telemetry_hub/northbound/app.py +195 -0
- reticulum_telemetry_hub/northbound/auth.py +119 -0
- reticulum_telemetry_hub/northbound/gateway.py +310 -0
- reticulum_telemetry_hub/northbound/internal_adapter.py +302 -0
- reticulum_telemetry_hub/northbound/models.py +213 -0
- reticulum_telemetry_hub/northbound/routes_chat.py +123 -0
- reticulum_telemetry_hub/northbound/routes_files.py +119 -0
- reticulum_telemetry_hub/northbound/routes_rest.py +345 -0
- reticulum_telemetry_hub/northbound/routes_subscribers.py +150 -0
- reticulum_telemetry_hub/northbound/routes_topics.py +178 -0
- reticulum_telemetry_hub/northbound/routes_ws.py +107 -0
- reticulum_telemetry_hub/northbound/serializers.py +72 -0
- reticulum_telemetry_hub/northbound/services.py +373 -0
- reticulum_telemetry_hub/northbound/websocket.py +855 -0
- reticulum_telemetry_hub/reticulum_server/__main__.py +2237 -0
- reticulum_telemetry_hub/reticulum_server/command_manager.py +1268 -0
- reticulum_telemetry_hub/reticulum_server/command_text.py +399 -0
- reticulum_telemetry_hub/reticulum_server/constants.py +1 -0
- reticulum_telemetry_hub/reticulum_server/event_log.py +357 -0
- reticulum_telemetry_hub/reticulum_server/internal_adapter.py +358 -0
- reticulum_telemetry_hub/reticulum_server/outbound_queue.py +312 -0
- reticulum_telemetry_hub/reticulum_server/services.py +422 -0
- reticulumtelemetryhub-0.143.0.dist-info/METADATA +181 -0
- reticulumtelemetryhub-0.143.0.dist-info/RECORD +97 -0
- {reticulumtelemetryhub-0.1.0.dist-info → reticulumtelemetryhub-0.143.0.dist-info}/WHEEL +1 -1
- reticulumtelemetryhub-0.143.0.dist-info/licenses/LICENSE +277 -0
- lxmf_telemetry/model/fields/field_telemetry_stream.py +0 -7
- lxmf_telemetry/model/persistance/__init__.py +0 -3
- lxmf_telemetry/model/persistance/sensors/location.py +0 -69
- lxmf_telemetry/model/persistance/sensors/magnetic_field.py +0 -36
- lxmf_telemetry/model/persistance/sensors/sensor.py +0 -44
- lxmf_telemetry/model/persistance/sensors/sensor_enum.py +0 -24
- lxmf_telemetry/model/persistance/sensors/sensor_mapping.py +0 -9
- lxmf_telemetry/telemetry_controller.py +0 -124
- reticulum_server/main.py +0 -182
- reticulumtelemetryhub-0.1.0.dist-info/METADATA +0 -15
- reticulumtelemetryhub-0.1.0.dist-info/RECORD +0 -19
- {lxmf_telemetry → reticulum_telemetry_hub}/__init__.py +0 -0
- {lxmf_telemetry/model/persistance/sensors → reticulum_telemetry_hub/lxmf_telemetry}/__init__.py +0 -0
- {reticulum_server → reticulum_telemetry_hub/reticulum_server}/__init__.py +0 -0
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
"""Reticulum Telemetry Hub API service operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from datetime import timedelta
|
|
8
|
+
from datetime import timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import List
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from reticulum_telemetry_hub.config import HubConfigurationManager
|
|
14
|
+
|
|
15
|
+
from .models import ChatAttachment
|
|
16
|
+
from .models import ChatMessage
|
|
17
|
+
from .models import Client
|
|
18
|
+
from .models import FileAttachment
|
|
19
|
+
from .models import IdentityStatus
|
|
20
|
+
from .models import ReticulumInfo
|
|
21
|
+
from .models import Subscriber
|
|
22
|
+
from .models import Topic
|
|
23
|
+
from .storage import HubStorage
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ReticulumTelemetryHubAPI: # pylint: disable=too-many-public-methods
|
|
27
|
+
"""Persistence-backed implementation of the ReticulumTelemetryHub API."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
config_manager: Optional[HubConfigurationManager] = None,
|
|
32
|
+
storage: Optional[HubStorage] = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Initialize the API service with configuration and storage providers.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
config_manager (Optional[HubConfigurationManager]): Manager
|
|
38
|
+
supplying hub configuration. When omitted, a default manager
|
|
39
|
+
loads the hub configuration and database path.
|
|
40
|
+
storage (Optional[HubStorage]): Persistence provider for clients,
|
|
41
|
+
topics, and subscribers. Defaults to storage built with the
|
|
42
|
+
configuration's database path.
|
|
43
|
+
|
|
44
|
+
"""
|
|
45
|
+
self._config_manager = config_manager or HubConfigurationManager()
|
|
46
|
+
hub_db_path = self._config_manager.config.hub_database_path
|
|
47
|
+
self._storage = storage or HubStorage(hub_db_path)
|
|
48
|
+
self._file_category = "file"
|
|
49
|
+
self._image_category = "image"
|
|
50
|
+
|
|
51
|
+
# ------------------------------------------------------------------ #
|
|
52
|
+
# RTH operations
|
|
53
|
+
# ------------------------------------------------------------------ #
|
|
54
|
+
def join(self, identity: str) -> bool:
|
|
55
|
+
"""Register a client with the Reticulum Telemetry Hub.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
identity (str): Unique Reticulum identity string.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
bool: ``True`` when the identity is recorded or updated.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
ValueError: If ``identity`` is empty.
|
|
65
|
+
|
|
66
|
+
Examples:
|
|
67
|
+
>>> api.join("ABCDE")
|
|
68
|
+
True
|
|
69
|
+
"""
|
|
70
|
+
if not identity:
|
|
71
|
+
raise ValueError("identity is required")
|
|
72
|
+
self._storage.upsert_client(identity)
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
def leave(self, identity: str) -> bool:
|
|
76
|
+
"""Remove a client from the hub.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
identity (str): Identity previously joined to the hub.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
bool: ``True`` if the client existed and was removed; ``False``
|
|
83
|
+
otherwise.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
ValueError: If ``identity`` is empty.
|
|
87
|
+
"""
|
|
88
|
+
if not identity:
|
|
89
|
+
raise ValueError("identity is required")
|
|
90
|
+
return self._storage.remove_client(identity)
|
|
91
|
+
|
|
92
|
+
# ------------------------------------------------------------------ #
|
|
93
|
+
# Client operations
|
|
94
|
+
# ------------------------------------------------------------------ #
|
|
95
|
+
def list_clients(self) -> List[Client]:
|
|
96
|
+
"""Return all clients that have joined the hub.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
List[Client]: All persisted client records in insertion order.
|
|
100
|
+
"""
|
|
101
|
+
return self._storage.list_clients()
|
|
102
|
+
|
|
103
|
+
def has_client(self, identity: str) -> bool:
|
|
104
|
+
"""Return ``True`` when the client is registered with the hub.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
identity (str): Identity to look up.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
bool: ``True`` if the identity exists in the client registry.
|
|
111
|
+
"""
|
|
112
|
+
if not identity:
|
|
113
|
+
return False
|
|
114
|
+
return self._storage.get_client(identity) is not None
|
|
115
|
+
|
|
116
|
+
def record_identity_announce(
|
|
117
|
+
self,
|
|
118
|
+
identity: str,
|
|
119
|
+
*,
|
|
120
|
+
display_name: str | None = None,
|
|
121
|
+
source_interface: str | None = None,
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Persist announce metadata for a Reticulum identity.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
identity (str): Destination hash in hex form.
|
|
127
|
+
display_name (str | None): Optional display name from announce data.
|
|
128
|
+
source_interface (str | None): Optional source interface label.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
if not identity:
|
|
132
|
+
raise ValueError("identity is required")
|
|
133
|
+
identity = identity.lower()
|
|
134
|
+
self._storage.upsert_identity_announce(
|
|
135
|
+
identity,
|
|
136
|
+
display_name=display_name,
|
|
137
|
+
source_interface=source_interface,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def resolve_identity_display_name(self, identity: str) -> str | None:
|
|
141
|
+
"""Return the stored display name for an identity when available."""
|
|
142
|
+
|
|
143
|
+
if not identity:
|
|
144
|
+
return None
|
|
145
|
+
record = self._storage.get_identity_announce(identity.lower())
|
|
146
|
+
if record is None:
|
|
147
|
+
return None
|
|
148
|
+
return record.display_name
|
|
149
|
+
|
|
150
|
+
# ------------------------------------------------------------------ #
|
|
151
|
+
# File operations
|
|
152
|
+
# ------------------------------------------------------------------ #
|
|
153
|
+
def store_file(
|
|
154
|
+
self,
|
|
155
|
+
file_path: str | Path,
|
|
156
|
+
*,
|
|
157
|
+
name: Optional[str] = None,
|
|
158
|
+
media_type: str | None = None,
|
|
159
|
+
topic_id: Optional[str] = None,
|
|
160
|
+
) -> FileAttachment:
|
|
161
|
+
"""Persist metadata for a file stored on disk.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
file_path (str | Path): Location of the file to record.
|
|
165
|
+
name (Optional[str]): Human readable name for the file. Defaults
|
|
166
|
+
to the filename.
|
|
167
|
+
media_type (Optional[str]): MIME type if known.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
FileAttachment: Stored file metadata with an ID.
|
|
171
|
+
|
|
172
|
+
Raises:
|
|
173
|
+
ValueError: If the file path is invalid or cannot be read.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
return self._store_attachment(
|
|
177
|
+
file_path=file_path,
|
|
178
|
+
name=name,
|
|
179
|
+
media_type=media_type,
|
|
180
|
+
topic_id=topic_id,
|
|
181
|
+
category=self._file_category,
|
|
182
|
+
base_path=self._config_manager.config.file_storage_path,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def store_image(
|
|
186
|
+
self,
|
|
187
|
+
image_path: str | Path,
|
|
188
|
+
*,
|
|
189
|
+
name: Optional[str] = None,
|
|
190
|
+
media_type: str | None = None,
|
|
191
|
+
topic_id: Optional[str] = None,
|
|
192
|
+
) -> FileAttachment:
|
|
193
|
+
"""Persist metadata for an image stored on disk."""
|
|
194
|
+
|
|
195
|
+
return self._store_attachment(
|
|
196
|
+
file_path=image_path,
|
|
197
|
+
name=name,
|
|
198
|
+
media_type=media_type,
|
|
199
|
+
topic_id=topic_id,
|
|
200
|
+
category=self._image_category,
|
|
201
|
+
base_path=self._config_manager.config.image_storage_path,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
def list_files(self) -> List[FileAttachment]:
|
|
205
|
+
"""Return stored file records."""
|
|
206
|
+
|
|
207
|
+
return self._storage.list_file_records(category=self._file_category)
|
|
208
|
+
|
|
209
|
+
def list_images(self) -> List[FileAttachment]:
|
|
210
|
+
"""Return stored image records."""
|
|
211
|
+
|
|
212
|
+
return self._storage.list_file_records(category=self._image_category)
|
|
213
|
+
|
|
214
|
+
def retrieve_file(self, record_id: int) -> FileAttachment:
|
|
215
|
+
"""Fetch stored file metadata by ID."""
|
|
216
|
+
|
|
217
|
+
return self._retrieve_attachment(record_id, expected_category=self._file_category)
|
|
218
|
+
|
|
219
|
+
def retrieve_image(self, record_id: int) -> FileAttachment:
|
|
220
|
+
"""Fetch stored image metadata by ID."""
|
|
221
|
+
|
|
222
|
+
return self._retrieve_attachment(record_id, expected_category=self._image_category)
|
|
223
|
+
|
|
224
|
+
def store_uploaded_attachment(
|
|
225
|
+
self,
|
|
226
|
+
*,
|
|
227
|
+
content: bytes,
|
|
228
|
+
filename: str,
|
|
229
|
+
media_type: Optional[str],
|
|
230
|
+
category: str,
|
|
231
|
+
topic_id: Optional[str] = None,
|
|
232
|
+
) -> FileAttachment:
|
|
233
|
+
"""Persist uploaded attachment bytes to disk and record metadata."""
|
|
234
|
+
|
|
235
|
+
safe_name = Path(filename).name
|
|
236
|
+
if not safe_name:
|
|
237
|
+
raise ValueError("filename is required")
|
|
238
|
+
if category == self._image_category:
|
|
239
|
+
base_path = self._config_manager.config.image_storage_path
|
|
240
|
+
elif category == self._file_category:
|
|
241
|
+
base_path = self._config_manager.config.file_storage_path
|
|
242
|
+
else:
|
|
243
|
+
raise ValueError("unsupported category")
|
|
244
|
+
base_path.mkdir(parents=True, exist_ok=True)
|
|
245
|
+
suffix = Path(safe_name).suffix
|
|
246
|
+
stored_name = f"{uuid.uuid4().hex}{suffix}"
|
|
247
|
+
target_path = base_path / stored_name
|
|
248
|
+
target_path.write_bytes(content)
|
|
249
|
+
return self._store_attachment(
|
|
250
|
+
file_path=target_path,
|
|
251
|
+
name=safe_name,
|
|
252
|
+
media_type=media_type,
|
|
253
|
+
topic_id=topic_id,
|
|
254
|
+
category=category,
|
|
255
|
+
base_path=base_path,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
@staticmethod
|
|
259
|
+
def chat_attachment_from_file(attachment: FileAttachment) -> ChatAttachment:
|
|
260
|
+
"""Convert a FileAttachment into a ChatAttachment reference."""
|
|
261
|
+
|
|
262
|
+
return ChatAttachment(
|
|
263
|
+
file_id=attachment.file_id or 0,
|
|
264
|
+
category=attachment.category,
|
|
265
|
+
name=attachment.name,
|
|
266
|
+
size=attachment.size,
|
|
267
|
+
media_type=attachment.media_type,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def record_chat_message(self, message: ChatMessage) -> ChatMessage:
|
|
271
|
+
"""Persist a chat message and return the stored record."""
|
|
272
|
+
|
|
273
|
+
message.message_id = message.message_id or uuid.uuid4().hex
|
|
274
|
+
return self._storage.create_chat_message(message)
|
|
275
|
+
|
|
276
|
+
def list_chat_messages(
|
|
277
|
+
self,
|
|
278
|
+
*,
|
|
279
|
+
limit: int = 200,
|
|
280
|
+
direction: Optional[str] = None,
|
|
281
|
+
topic_id: Optional[str] = None,
|
|
282
|
+
destination: Optional[str] = None,
|
|
283
|
+
source: Optional[str] = None,
|
|
284
|
+
) -> List[ChatMessage]:
|
|
285
|
+
"""Return persisted chat messages."""
|
|
286
|
+
|
|
287
|
+
return self._storage.list_chat_messages(
|
|
288
|
+
limit=limit,
|
|
289
|
+
direction=direction,
|
|
290
|
+
topic_id=topic_id,
|
|
291
|
+
destination=destination,
|
|
292
|
+
source=source,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
def update_chat_message_state(self, message_id: str, state: str) -> ChatMessage | None:
|
|
296
|
+
"""Update a chat message delivery state."""
|
|
297
|
+
|
|
298
|
+
return self._storage.update_chat_message_state(message_id, state)
|
|
299
|
+
|
|
300
|
+
def chat_message_stats(self) -> dict[str, int]:
|
|
301
|
+
"""Return aggregated chat message counters."""
|
|
302
|
+
|
|
303
|
+
return self._storage.chat_message_stats()
|
|
304
|
+
|
|
305
|
+
# ------------------------------------------------------------------ #
|
|
306
|
+
# Topic operations
|
|
307
|
+
# ------------------------------------------------------------------ #
|
|
308
|
+
def create_topic(self, topic: Topic) -> Topic:
|
|
309
|
+
"""Create a topic in the hub database.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
topic (Topic): Topic definition to store. ``topic_id`` is
|
|
313
|
+
auto-generated when not provided.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
Topic: Persisted topic record with a guaranteed ``topic_id``.
|
|
317
|
+
|
|
318
|
+
Raises:
|
|
319
|
+
ValueError: If ``topic.topic_name`` or ``topic.topic_path`` is
|
|
320
|
+
missing.
|
|
321
|
+
|
|
322
|
+
Notes:
|
|
323
|
+
A hex UUID is generated for ``topic_id`` when it is absent to
|
|
324
|
+
ensure unique topic identifiers across requests.
|
|
325
|
+
"""
|
|
326
|
+
if not topic.topic_name or not topic.topic_path:
|
|
327
|
+
raise ValueError("TopicName and TopicPath are required")
|
|
328
|
+
topic.topic_id = topic.topic_id or uuid.uuid4().hex
|
|
329
|
+
return self._storage.create_topic(topic)
|
|
330
|
+
|
|
331
|
+
def list_topics(self) -> List[Topic]:
|
|
332
|
+
"""List all topics known to the hub.
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
List[Topic]: Current topic catalog from storage.
|
|
336
|
+
"""
|
|
337
|
+
return self._storage.list_topics()
|
|
338
|
+
|
|
339
|
+
def retrieve_topic(self, topic_id: str) -> Topic:
|
|
340
|
+
"""Fetch a topic by its identifier.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
topic_id (str): Identifier of the topic to retrieve.
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
Topic: The matching topic.
|
|
347
|
+
|
|
348
|
+
Raises:
|
|
349
|
+
KeyError: If the topic does not exist.
|
|
350
|
+
"""
|
|
351
|
+
topic = self._storage.get_topic(topic_id)
|
|
352
|
+
if not topic:
|
|
353
|
+
raise KeyError(f"Topic '{topic_id}' not found")
|
|
354
|
+
return topic
|
|
355
|
+
|
|
356
|
+
def delete_topic(self, topic_id: str) -> Topic:
|
|
357
|
+
"""Delete a topic by its identifier.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
topic_id (str): Identifier of the topic to delete.
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
Topic: The removed topic record.
|
|
364
|
+
|
|
365
|
+
Raises:
|
|
366
|
+
KeyError: If the topic does not exist.
|
|
367
|
+
"""
|
|
368
|
+
topic = self._storage.delete_topic(topic_id)
|
|
369
|
+
if not topic:
|
|
370
|
+
raise KeyError(f"Topic '{topic_id}' not found")
|
|
371
|
+
return topic
|
|
372
|
+
|
|
373
|
+
def patch_topic(self, topic_id: str, **updates) -> Topic:
|
|
374
|
+
"""Update selected fields of a topic.
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
topic_id (str): Identifier of the topic to update.
|
|
378
|
+
**updates: Optional fields to modify, accepting either snake_case
|
|
379
|
+
or title-cased keys (``topic_name``/``TopicName``,
|
|
380
|
+
``topic_path``/``TopicPath``, ``topic_description``/
|
|
381
|
+
``TopicDescription``).
|
|
382
|
+
|
|
383
|
+
Returns:
|
|
384
|
+
Topic: Updated topic. If no update fields are provided, the
|
|
385
|
+
existing topic is returned unchanged.
|
|
386
|
+
|
|
387
|
+
Raises:
|
|
388
|
+
KeyError: If the topic does not exist.
|
|
389
|
+
|
|
390
|
+
Notes:
|
|
391
|
+
``topic_description`` defaults to an empty string when explicitly
|
|
392
|
+
set to ``None`` or an empty value.
|
|
393
|
+
"""
|
|
394
|
+
topic = self.retrieve_topic(topic_id)
|
|
395
|
+
update_fields = {}
|
|
396
|
+
if "topic_name" in updates or "TopicName" in updates:
|
|
397
|
+
topic.topic_name = updates.get("topic_name") or updates.get("TopicName")
|
|
398
|
+
update_fields["topic_name"] = topic.topic_name
|
|
399
|
+
if "topic_path" in updates or "TopicPath" in updates:
|
|
400
|
+
topic.topic_path = updates.get("topic_path") or updates.get("TopicPath")
|
|
401
|
+
update_fields["topic_path"] = topic.topic_path
|
|
402
|
+
if "topic_description" in updates or "TopicDescription" in updates:
|
|
403
|
+
description = updates.get(
|
|
404
|
+
"topic_description", updates.get("TopicDescription")
|
|
405
|
+
)
|
|
406
|
+
topic.topic_description = description or ""
|
|
407
|
+
update_fields["topic_description"] = topic.topic_description
|
|
408
|
+
if not update_fields:
|
|
409
|
+
return topic
|
|
410
|
+
updated_topic = self._storage.update_topic(topic.topic_id, **update_fields)
|
|
411
|
+
if not updated_topic:
|
|
412
|
+
raise KeyError(f"Topic '{topic_id}' not found")
|
|
413
|
+
return updated_topic
|
|
414
|
+
|
|
415
|
+
def subscribe_topic(
|
|
416
|
+
self,
|
|
417
|
+
topic_id: str,
|
|
418
|
+
destination: str,
|
|
419
|
+
reject_tests: Optional[int] = None,
|
|
420
|
+
metadata: Optional[dict] = None,
|
|
421
|
+
) -> Subscriber:
|
|
422
|
+
"""Subscribe a destination to a topic.
|
|
423
|
+
|
|
424
|
+
Args:
|
|
425
|
+
topic_id (str): Identifier of the topic to subscribe to.
|
|
426
|
+
destination (str): Destination identity or address.
|
|
427
|
+
reject_tests (Optional[int]): Value indicating whether to reject
|
|
428
|
+
test messages; stored as provided.
|
|
429
|
+
metadata (Optional[dict]): Subscriber metadata. Defaults to an
|
|
430
|
+
empty dict when not provided.
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
Subscriber: Persisted subscriber with a generated ``subscriber_id``
|
|
434
|
+
and the topic's resolved ``topic_id``.
|
|
435
|
+
|
|
436
|
+
Raises:
|
|
437
|
+
KeyError: If the referenced topic does not exist.
|
|
438
|
+
ValueError: If ``destination`` is empty.
|
|
439
|
+
|
|
440
|
+
Examples:
|
|
441
|
+
>>> api.subscribe_topic(topic_id, "dest")
|
|
442
|
+
Subscriber(..., subscriber_id="<uuid>", metadata={})
|
|
443
|
+
"""
|
|
444
|
+
topic = self.retrieve_topic(topic_id)
|
|
445
|
+
subscriber = Subscriber(
|
|
446
|
+
destination=destination,
|
|
447
|
+
topic_id=topic.topic_id,
|
|
448
|
+
reject_tests=reject_tests,
|
|
449
|
+
metadata=metadata or {},
|
|
450
|
+
)
|
|
451
|
+
return self.create_subscriber(subscriber)
|
|
452
|
+
|
|
453
|
+
# ------------------------------------------------------------------ #
|
|
454
|
+
# Subscriber operations
|
|
455
|
+
# ------------------------------------------------------------------ #
|
|
456
|
+
def create_subscriber(self, subscriber: Subscriber) -> Subscriber:
|
|
457
|
+
"""Create a subscriber record.
|
|
458
|
+
|
|
459
|
+
Args:
|
|
460
|
+
subscriber (Subscriber): Subscriber definition.
|
|
461
|
+
``subscriber_id`` is auto-generated when missing. ``topic_id``
|
|
462
|
+
defaults to an empty string when not provided.
|
|
463
|
+
|
|
464
|
+
Returns:
|
|
465
|
+
Subscriber: Persisted subscriber with ensured identifiers.
|
|
466
|
+
|
|
467
|
+
Raises:
|
|
468
|
+
ValueError: If ``subscriber.destination`` is empty.
|
|
469
|
+
|
|
470
|
+
Notes:
|
|
471
|
+
``subscriber.metadata`` is stored as-is; callers should supply an
|
|
472
|
+
empty dict when no metadata is required to avoid ``None`` values.
|
|
473
|
+
"""
|
|
474
|
+
if not subscriber.destination:
|
|
475
|
+
raise ValueError("Subscriber destination is required")
|
|
476
|
+
subscriber.topic_id = subscriber.topic_id or ""
|
|
477
|
+
subscriber.subscriber_id = subscriber.subscriber_id or uuid.uuid4().hex
|
|
478
|
+
return self._storage.create_subscriber(subscriber)
|
|
479
|
+
|
|
480
|
+
def list_subscribers(self) -> List[Subscriber]:
|
|
481
|
+
"""List all subscribers.
|
|
482
|
+
|
|
483
|
+
Returns:
|
|
484
|
+
List[Subscriber]: Subscribers currently stored in the hub.
|
|
485
|
+
"""
|
|
486
|
+
return self._storage.list_subscribers()
|
|
487
|
+
|
|
488
|
+
def list_subscribers_for_topic(self, topic_id: str) -> List[Subscriber]:
|
|
489
|
+
"""Return subscribers for a specific topic.
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
topic_id (str): Topic identifier to filter by.
|
|
493
|
+
|
|
494
|
+
Returns:
|
|
495
|
+
List[Subscriber]: Subscribers attached to the topic.
|
|
496
|
+
|
|
497
|
+
Raises:
|
|
498
|
+
KeyError: If the topic does not exist.
|
|
499
|
+
"""
|
|
500
|
+
self.retrieve_topic(topic_id)
|
|
501
|
+
return [
|
|
502
|
+
subscriber
|
|
503
|
+
for subscriber in self._storage.list_subscribers()
|
|
504
|
+
if subscriber.topic_id == topic_id
|
|
505
|
+
]
|
|
506
|
+
|
|
507
|
+
def list_topics_for_destination(self, destination: str) -> List[Topic]:
|
|
508
|
+
"""Return topics a destination is subscribed to.
|
|
509
|
+
|
|
510
|
+
Args:
|
|
511
|
+
destination (str): Destination identity hash to query.
|
|
512
|
+
|
|
513
|
+
Returns:
|
|
514
|
+
List[Topic]: Topics matching the destination's subscriptions.
|
|
515
|
+
"""
|
|
516
|
+
topic_ids = {
|
|
517
|
+
subscriber.topic_id
|
|
518
|
+
for subscriber in self._storage.list_subscribers()
|
|
519
|
+
if subscriber.destination == destination and subscriber.topic_id
|
|
520
|
+
}
|
|
521
|
+
return [topic for topic in self.list_topics() if topic.topic_id in topic_ids]
|
|
522
|
+
|
|
523
|
+
def retrieve_subscriber(self, subscriber_id: str) -> Subscriber:
|
|
524
|
+
"""Fetch a subscriber by identifier.
|
|
525
|
+
|
|
526
|
+
Args:
|
|
527
|
+
subscriber_id (str): Identifier of the subscriber to retrieve.
|
|
528
|
+
|
|
529
|
+
Returns:
|
|
530
|
+
Subscriber: The matching subscriber.
|
|
531
|
+
|
|
532
|
+
Raises:
|
|
533
|
+
KeyError: If the subscriber does not exist.
|
|
534
|
+
"""
|
|
535
|
+
subscriber = self._storage.get_subscriber(subscriber_id)
|
|
536
|
+
if not subscriber:
|
|
537
|
+
raise KeyError(f"Subscriber '{subscriber_id}' not found")
|
|
538
|
+
return subscriber
|
|
539
|
+
|
|
540
|
+
def delete_subscriber(self, subscriber_id: str) -> Subscriber:
|
|
541
|
+
"""Delete a subscriber by identifier.
|
|
542
|
+
|
|
543
|
+
Args:
|
|
544
|
+
subscriber_id (str): Identifier of the subscriber to delete.
|
|
545
|
+
|
|
546
|
+
Returns:
|
|
547
|
+
Subscriber: The removed subscriber record.
|
|
548
|
+
|
|
549
|
+
Raises:
|
|
550
|
+
KeyError: If the subscriber does not exist.
|
|
551
|
+
"""
|
|
552
|
+
subscriber = self._storage.delete_subscriber(subscriber_id)
|
|
553
|
+
if not subscriber:
|
|
554
|
+
raise KeyError(f"Subscriber '{subscriber_id}' not found")
|
|
555
|
+
return subscriber
|
|
556
|
+
|
|
557
|
+
def patch_subscriber(self, subscriber_id: str, **updates) -> Subscriber:
|
|
558
|
+
"""Update selected subscriber fields.
|
|
559
|
+
|
|
560
|
+
Args:
|
|
561
|
+
subscriber_id (str): Identifier of the subscriber to update.
|
|
562
|
+
**updates: Optional fields to modify, accepting either snake_case
|
|
563
|
+
or title-cased keys (``destination``/``Destination``,
|
|
564
|
+
``topic_id``/``TopicID``, ``reject_tests``/``RejectTests``,
|
|
565
|
+
``metadata``/``Metadata``).
|
|
566
|
+
|
|
567
|
+
Returns:
|
|
568
|
+
Subscriber: Updated subscriber record.
|
|
569
|
+
|
|
570
|
+
Raises:
|
|
571
|
+
KeyError: If the subscriber does not exist.
|
|
572
|
+
|
|
573
|
+
Notes:
|
|
574
|
+
The metadata dictionary is replaced only when provided; otherwise,
|
|
575
|
+
existing metadata remains unchanged. Topic existence is not
|
|
576
|
+
validated during updates.
|
|
577
|
+
"""
|
|
578
|
+
subscriber = self.retrieve_subscriber(subscriber_id)
|
|
579
|
+
if "destination" in updates or "Destination" in updates:
|
|
580
|
+
subscriber.destination = updates.get("destination") or updates.get(
|
|
581
|
+
"Destination"
|
|
582
|
+
)
|
|
583
|
+
if "topic_id" in updates or "TopicID" in updates:
|
|
584
|
+
subscriber.topic_id = updates.get("topic_id") or updates.get("TopicID")
|
|
585
|
+
if "reject_tests" in updates:
|
|
586
|
+
subscriber.reject_tests = updates["reject_tests"]
|
|
587
|
+
elif "RejectTests" in updates:
|
|
588
|
+
subscriber.reject_tests = updates["RejectTests"]
|
|
589
|
+
metadata_key = None
|
|
590
|
+
if "metadata" in updates:
|
|
591
|
+
metadata_key = "metadata"
|
|
592
|
+
elif "Metadata" in updates:
|
|
593
|
+
metadata_key = "Metadata"
|
|
594
|
+
|
|
595
|
+
if metadata_key is not None:
|
|
596
|
+
subscriber.metadata = updates[metadata_key]
|
|
597
|
+
return self._storage.update_subscriber(subscriber)
|
|
598
|
+
|
|
599
|
+
def add_subscriber(self, subscriber: Subscriber) -> Subscriber:
|
|
600
|
+
"""Alias for :meth:`create_subscriber`.
|
|
601
|
+
|
|
602
|
+
Args:
|
|
603
|
+
subscriber (Subscriber): Subscriber definition to persist.
|
|
604
|
+
|
|
605
|
+
Returns:
|
|
606
|
+
Subscriber: Persisted subscriber record.
|
|
607
|
+
"""
|
|
608
|
+
return self.create_subscriber(subscriber)
|
|
609
|
+
|
|
610
|
+
# ------------------------------------------------------------------ #
|
|
611
|
+
# Reticulum info
|
|
612
|
+
# ------------------------------------------------------------------ #
|
|
613
|
+
def get_app_info(self) -> ReticulumInfo:
|
|
614
|
+
"""Return the current Reticulum configuration snapshot.
|
|
615
|
+
|
|
616
|
+
Returns:
|
|
617
|
+
ReticulumInfo: Configuration values sourced from the configuration
|
|
618
|
+
manager, including the app name, version, and description.
|
|
619
|
+
"""
|
|
620
|
+
info_dict = self._config_manager.reticulum_info_snapshot()
|
|
621
|
+
return ReticulumInfo(**info_dict)
|
|
622
|
+
|
|
623
|
+
def get_config_text(self) -> str:
|
|
624
|
+
"""Return the raw hub configuration file content."""
|
|
625
|
+
|
|
626
|
+
return self._config_manager.get_config_text()
|
|
627
|
+
|
|
628
|
+
def validate_config_text(self, config_text: str) -> dict:
|
|
629
|
+
"""Validate the provided configuration payload."""
|
|
630
|
+
|
|
631
|
+
return self._config_manager.validate_config_text(config_text)
|
|
632
|
+
|
|
633
|
+
def apply_config_text(self, config_text: str) -> dict:
|
|
634
|
+
"""Persist a new configuration payload and reload."""
|
|
635
|
+
|
|
636
|
+
result = self._config_manager.apply_config_text(config_text)
|
|
637
|
+
self._config_manager.reload()
|
|
638
|
+
return result
|
|
639
|
+
|
|
640
|
+
def rollback_config_text(self, backup_path: str | None = None) -> dict:
|
|
641
|
+
"""Rollback configuration from the latest backup."""
|
|
642
|
+
|
|
643
|
+
result = self._config_manager.rollback_config_text(backup_path=backup_path)
|
|
644
|
+
self._config_manager.reload()
|
|
645
|
+
return result
|
|
646
|
+
|
|
647
|
+
def reload_config(self) -> ReticulumInfo:
|
|
648
|
+
"""Reload the configuration from disk."""
|
|
649
|
+
|
|
650
|
+
config = self._config_manager.reload()
|
|
651
|
+
return ReticulumInfo(**config.to_reticulum_info_dict())
|
|
652
|
+
|
|
653
|
+
def list_identity_statuses(self) -> List[IdentityStatus]:
|
|
654
|
+
"""Return identity statuses merged with client data."""
|
|
655
|
+
|
|
656
|
+
clients = {client.identity: client for client in self._storage.list_clients()}
|
|
657
|
+
states = {
|
|
658
|
+
state.identity: state for state in self._storage.list_identity_states()
|
|
659
|
+
}
|
|
660
|
+
announces = {
|
|
661
|
+
record.destination_hash: record
|
|
662
|
+
for record in self._storage.list_identity_announces()
|
|
663
|
+
}
|
|
664
|
+
identities = sorted(
|
|
665
|
+
set(clients.keys()) | set(states.keys()) | set(announces.keys())
|
|
666
|
+
)
|
|
667
|
+
statuses: List[IdentityStatus] = []
|
|
668
|
+
cutoff = datetime.now(timezone.utc) - timedelta(minutes=60)
|
|
669
|
+
for identity in identities:
|
|
670
|
+
client = clients.get(identity)
|
|
671
|
+
state = states.get(identity)
|
|
672
|
+
announce = announces.get(identity.lower())
|
|
673
|
+
display_name = announce.display_name if announce else None
|
|
674
|
+
metadata = dict(client.metadata if client else {})
|
|
675
|
+
if display_name and "display_name" not in metadata:
|
|
676
|
+
metadata["display_name"] = display_name
|
|
677
|
+
is_banned = bool(state.is_banned) if state else False
|
|
678
|
+
is_blackholed = bool(state.is_blackholed) if state else False
|
|
679
|
+
announce_last_seen = None
|
|
680
|
+
if announce and announce.last_seen:
|
|
681
|
+
announce_last_seen = announce.last_seen
|
|
682
|
+
if announce_last_seen.tzinfo is None:
|
|
683
|
+
announce_last_seen = announce_last_seen.replace(tzinfo=timezone.utc)
|
|
684
|
+
last_seen = announce_last_seen or (client.last_seen if client else None)
|
|
685
|
+
status = "inactive"
|
|
686
|
+
if announce_last_seen and announce_last_seen >= cutoff:
|
|
687
|
+
status = "active"
|
|
688
|
+
if is_blackholed:
|
|
689
|
+
status = "blackholed"
|
|
690
|
+
elif is_banned:
|
|
691
|
+
status = "banned"
|
|
692
|
+
statuses.append(
|
|
693
|
+
IdentityStatus(
|
|
694
|
+
identity=identity,
|
|
695
|
+
status=status,
|
|
696
|
+
last_seen=last_seen,
|
|
697
|
+
display_name=display_name,
|
|
698
|
+
metadata=metadata,
|
|
699
|
+
is_banned=is_banned,
|
|
700
|
+
is_blackholed=is_blackholed,
|
|
701
|
+
)
|
|
702
|
+
)
|
|
703
|
+
return statuses
|
|
704
|
+
|
|
705
|
+
def ban_identity(self, identity: str) -> IdentityStatus:
|
|
706
|
+
"""Mark an identity as banned."""
|
|
707
|
+
|
|
708
|
+
if not identity:
|
|
709
|
+
raise ValueError("identity is required")
|
|
710
|
+
state = self._storage.upsert_identity_state(identity, is_banned=True)
|
|
711
|
+
client = self._storage.get_client(identity)
|
|
712
|
+
announce = self._storage.get_identity_announce(identity.lower())
|
|
713
|
+
display_name = announce.display_name if announce else None
|
|
714
|
+
metadata = dict(client.metadata if client else {})
|
|
715
|
+
if display_name and "display_name" not in metadata:
|
|
716
|
+
metadata["display_name"] = display_name
|
|
717
|
+
last_seen = announce.last_seen if announce else None
|
|
718
|
+
if last_seen and last_seen.tzinfo is None:
|
|
719
|
+
last_seen = last_seen.replace(tzinfo=timezone.utc)
|
|
720
|
+
return IdentityStatus(
|
|
721
|
+
identity=identity,
|
|
722
|
+
status="banned",
|
|
723
|
+
last_seen=last_seen or (client.last_seen if client else None),
|
|
724
|
+
display_name=display_name,
|
|
725
|
+
metadata=metadata,
|
|
726
|
+
is_banned=state.is_banned,
|
|
727
|
+
is_blackholed=state.is_blackholed,
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
def unban_identity(self, identity: str) -> IdentityStatus:
|
|
731
|
+
"""Clear ban/blackhole flags for an identity."""
|
|
732
|
+
|
|
733
|
+
if not identity:
|
|
734
|
+
raise ValueError("identity is required")
|
|
735
|
+
state = self._storage.upsert_identity_state(
|
|
736
|
+
identity, is_banned=False, is_blackholed=False
|
|
737
|
+
)
|
|
738
|
+
client = self._storage.get_client(identity)
|
|
739
|
+
announce = self._storage.get_identity_announce(identity.lower())
|
|
740
|
+
display_name = announce.display_name if announce else None
|
|
741
|
+
metadata = dict(client.metadata if client else {})
|
|
742
|
+
if display_name and "display_name" not in metadata:
|
|
743
|
+
metadata["display_name"] = display_name
|
|
744
|
+
last_seen = announce.last_seen if announce else None
|
|
745
|
+
if last_seen and last_seen.tzinfo is None:
|
|
746
|
+
last_seen = last_seen.replace(tzinfo=timezone.utc)
|
|
747
|
+
status = "inactive"
|
|
748
|
+
if last_seen and last_seen >= datetime.now(timezone.utc) - timedelta(minutes=60):
|
|
749
|
+
status = "active"
|
|
750
|
+
return IdentityStatus(
|
|
751
|
+
identity=identity,
|
|
752
|
+
status=status,
|
|
753
|
+
last_seen=last_seen or (client.last_seen if client else None),
|
|
754
|
+
display_name=display_name,
|
|
755
|
+
metadata=metadata,
|
|
756
|
+
is_banned=state.is_banned,
|
|
757
|
+
is_blackholed=state.is_blackholed,
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
def blackhole_identity(self, identity: str) -> IdentityStatus:
|
|
761
|
+
"""Mark an identity as blackholed."""
|
|
762
|
+
|
|
763
|
+
if not identity:
|
|
764
|
+
raise ValueError("identity is required")
|
|
765
|
+
state = self._storage.upsert_identity_state(identity, is_blackholed=True)
|
|
766
|
+
client = self._storage.get_client(identity)
|
|
767
|
+
announce = self._storage.get_identity_announce(identity.lower())
|
|
768
|
+
display_name = announce.display_name if announce else None
|
|
769
|
+
metadata = dict(client.metadata if client else {})
|
|
770
|
+
if display_name and "display_name" not in metadata:
|
|
771
|
+
metadata["display_name"] = display_name
|
|
772
|
+
last_seen = announce.last_seen if announce else None
|
|
773
|
+
if last_seen and last_seen.tzinfo is None:
|
|
774
|
+
last_seen = last_seen.replace(tzinfo=timezone.utc)
|
|
775
|
+
return IdentityStatus(
|
|
776
|
+
identity=identity,
|
|
777
|
+
status="blackholed",
|
|
778
|
+
last_seen=last_seen or (client.last_seen if client else None),
|
|
779
|
+
display_name=display_name,
|
|
780
|
+
metadata=metadata,
|
|
781
|
+
is_banned=state.is_banned,
|
|
782
|
+
is_blackholed=state.is_blackholed,
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
def _store_attachment( # pylint: disable=too-many-arguments
|
|
786
|
+
self,
|
|
787
|
+
*,
|
|
788
|
+
file_path: str | Path,
|
|
789
|
+
name: Optional[str],
|
|
790
|
+
media_type: str | None,
|
|
791
|
+
topic_id: Optional[str],
|
|
792
|
+
category: str,
|
|
793
|
+
base_path: Path,
|
|
794
|
+
) -> FileAttachment:
|
|
795
|
+
"""Validate inputs and persist file metadata."""
|
|
796
|
+
|
|
797
|
+
if category not in {self._file_category, self._image_category}:
|
|
798
|
+
raise ValueError("unsupported category")
|
|
799
|
+
if not file_path:
|
|
800
|
+
raise ValueError("file_path is required")
|
|
801
|
+
path_obj = Path(file_path)
|
|
802
|
+
if not path_obj.is_file():
|
|
803
|
+
raise ValueError(f"File '{file_path}' does not exist")
|
|
804
|
+
resolved_name = name or path_obj.name
|
|
805
|
+
if not resolved_name:
|
|
806
|
+
raise ValueError("name is required")
|
|
807
|
+
base_path.mkdir(parents=True, exist_ok=True)
|
|
808
|
+
resolved_base_path = base_path.resolve()
|
|
809
|
+
resolved_path = path_obj.resolve()
|
|
810
|
+
try:
|
|
811
|
+
resolved_path.relative_to(resolved_base_path)
|
|
812
|
+
except ValueError as exc:
|
|
813
|
+
raise ValueError(
|
|
814
|
+
f"File '{file_path}' must be stored within '{resolved_base_path}'"
|
|
815
|
+
) from exc
|
|
816
|
+
stat_result = resolved_path.stat()
|
|
817
|
+
timestamp = datetime.now(timezone.utc)
|
|
818
|
+
attachment = FileAttachment(
|
|
819
|
+
name=resolved_name,
|
|
820
|
+
path=str(resolved_path),
|
|
821
|
+
category=category,
|
|
822
|
+
size=stat_result.st_size,
|
|
823
|
+
media_type=media_type,
|
|
824
|
+
topic_id=topic_id,
|
|
825
|
+
created_at=timestamp,
|
|
826
|
+
updated_at=timestamp,
|
|
827
|
+
)
|
|
828
|
+
return self._storage.create_file_record(attachment)
|
|
829
|
+
|
|
830
|
+
def _retrieve_attachment(self, record_id: int, *, expected_category: str) -> FileAttachment:
|
|
831
|
+
"""Return an attachment by ID, ensuring it matches the category."""
|
|
832
|
+
|
|
833
|
+
record = self._storage.get_file_record(record_id)
|
|
834
|
+
if not record or record.category != expected_category:
|
|
835
|
+
raise KeyError(f"File '{record_id}' not found")
|
|
836
|
+
return record
|