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,1268 @@
|
|
|
1
|
+
# Command management for Reticulum Telemetry Hub
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from functools import partial
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
import RNS
|
|
11
|
+
import LXMF
|
|
12
|
+
|
|
13
|
+
from reticulum_telemetry_hub.api.models import Client, FileAttachment, Subscriber, Topic
|
|
14
|
+
from reticulum_telemetry_hub.api.service import ReticulumTelemetryHubAPI
|
|
15
|
+
from reticulum_telemetry_hub.config.manager import HubConfigurationManager
|
|
16
|
+
from reticulum_telemetry_hub.reticulum_server.event_log import EventLog
|
|
17
|
+
|
|
18
|
+
from .constants import PLUGIN_COMMAND
|
|
19
|
+
from .command_text import (
|
|
20
|
+
build_help_text,
|
|
21
|
+
build_examples_text,
|
|
22
|
+
format_attachment_list,
|
|
23
|
+
format_subscriber_list,
|
|
24
|
+
format_topic_list,
|
|
25
|
+
topic_subscribe_hint,
|
|
26
|
+
)
|
|
27
|
+
from ..lxmf_telemetry.telemetry_controller import TelemetryController
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CommandManager:
|
|
31
|
+
"""Manage RTH command execution."""
|
|
32
|
+
|
|
33
|
+
# Command names based on the API specification
|
|
34
|
+
CMD_HELP = "Help"
|
|
35
|
+
CMD_EXAMPLES = "Examples"
|
|
36
|
+
CMD_JOIN = "join"
|
|
37
|
+
CMD_LEAVE = "leave"
|
|
38
|
+
CMD_LIST_CLIENTS = "ListClients"
|
|
39
|
+
CMD_RETRIEVE_TOPIC = "RetrieveTopic"
|
|
40
|
+
CMD_CREATE_TOPIC = "CreateTopic"
|
|
41
|
+
CMD_DELETE_TOPIC = "DeleteTopic"
|
|
42
|
+
CMD_LIST_TOPIC = "ListTopic"
|
|
43
|
+
CMD_PATCH_TOPIC = "PatchTopic"
|
|
44
|
+
CMD_SUBSCRIBE_TOPIC = "SubscribeTopic"
|
|
45
|
+
CMD_RETRIEVE_SUBSCRIBER = "RetrieveSubscriber"
|
|
46
|
+
CMD_ADD_SUBSCRIBER = "AddSubscriber"
|
|
47
|
+
CMD_CREATE_SUBSCRIBER = "CreateSubscriber"
|
|
48
|
+
CMD_DELETE_SUBSCRIBER = "DeleteSubscriber"
|
|
49
|
+
CMD_LIST_SUBSCRIBER = "ListSubscriber"
|
|
50
|
+
CMD_PATCH_SUBSCRIBER = "PatchSubscriber"
|
|
51
|
+
CMD_REMOVE_SUBSCRIBER = "RemoveSubscriber"
|
|
52
|
+
CMD_GET_APP_INFO = "getAppInfo"
|
|
53
|
+
CMD_LIST_FILES = "ListFiles"
|
|
54
|
+
CMD_LIST_IMAGES = "ListImages"
|
|
55
|
+
CMD_RETRIEVE_FILE = "RetrieveFile"
|
|
56
|
+
CMD_RETRIEVE_IMAGE = "RetrieveImage"
|
|
57
|
+
CMD_ASSOCIATE_TOPIC_ID = "AssociateTopicID"
|
|
58
|
+
CMD_STATUS = "GetStatus"
|
|
59
|
+
CMD_LIST_EVENTS = "ListEvents"
|
|
60
|
+
CMD_BAN_IDENTITY = "BanIdentity"
|
|
61
|
+
CMD_UNBAN_IDENTITY = "UnbanIdentity"
|
|
62
|
+
CMD_BLACKHOLE_IDENTITY = "BlackholeIdentity"
|
|
63
|
+
CMD_LIST_IDENTITIES = "ListIdentities"
|
|
64
|
+
CMD_GET_CONFIG = "GetConfig"
|
|
65
|
+
CMD_VALIDATE_CONFIG = "ValidateConfig"
|
|
66
|
+
CMD_APPLY_CONFIG = "ApplyConfig"
|
|
67
|
+
CMD_ROLLBACK_CONFIG = "RollbackConfig"
|
|
68
|
+
CMD_FLUSH_TELEMETRY = "FlushTelemetry"
|
|
69
|
+
CMD_RELOAD_CONFIG = "ReloadConfig"
|
|
70
|
+
CMD_DUMP_ROUTING = "DumpRouting"
|
|
71
|
+
POSITIONAL_FIELDS: Dict[str, List[str]] = {
|
|
72
|
+
CMD_CREATE_TOPIC: ["TopicName", "TopicPath"],
|
|
73
|
+
CMD_RETRIEVE_TOPIC: ["TopicID"],
|
|
74
|
+
CMD_DELETE_TOPIC: ["TopicID"],
|
|
75
|
+
CMD_PATCH_TOPIC: ["TopicID", "TopicName", "TopicPath", "TopicDescription"],
|
|
76
|
+
CMD_SUBSCRIBE_TOPIC: ["TopicID", "RejectTests"],
|
|
77
|
+
CMD_CREATE_SUBSCRIBER: ["Destination", "TopicID"],
|
|
78
|
+
CMD_ADD_SUBSCRIBER: ["Destination", "TopicID"],
|
|
79
|
+
CMD_RETRIEVE_SUBSCRIBER: ["SubscriberID"],
|
|
80
|
+
CMD_DELETE_SUBSCRIBER: ["SubscriberID"],
|
|
81
|
+
CMD_REMOVE_SUBSCRIBER: ["SubscriberID"],
|
|
82
|
+
CMD_PATCH_SUBSCRIBER: ["SubscriberID"],
|
|
83
|
+
CMD_RETRIEVE_FILE: ["FileID"],
|
|
84
|
+
CMD_RETRIEVE_IMAGE: ["FileID"],
|
|
85
|
+
CMD_ASSOCIATE_TOPIC_ID: ["TopicID"],
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
connections: dict,
|
|
91
|
+
tel_controller: TelemetryController,
|
|
92
|
+
my_lxmf_dest: RNS.Destination,
|
|
93
|
+
api: ReticulumTelemetryHubAPI,
|
|
94
|
+
*,
|
|
95
|
+
config_manager: HubConfigurationManager | None = None,
|
|
96
|
+
event_log: EventLog | None = None,
|
|
97
|
+
):
|
|
98
|
+
self.connections = connections
|
|
99
|
+
self.tel_controller = tel_controller
|
|
100
|
+
self.my_lxmf_dest = my_lxmf_dest
|
|
101
|
+
self.api = api
|
|
102
|
+
self.config_manager = config_manager
|
|
103
|
+
self.event_log = event_log
|
|
104
|
+
self.pending_field_requests: Dict[str, Dict[str, Dict[str, Any]]] = {}
|
|
105
|
+
self._command_aliases_cache: Dict[str, str] = {}
|
|
106
|
+
self._start_time = time.time()
|
|
107
|
+
|
|
108
|
+
def _all_command_names(self) -> List[str]:
|
|
109
|
+
"""Return the list of supported command names."""
|
|
110
|
+
|
|
111
|
+
return [
|
|
112
|
+
self.CMD_HELP,
|
|
113
|
+
self.CMD_EXAMPLES,
|
|
114
|
+
self.CMD_JOIN,
|
|
115
|
+
self.CMD_LEAVE,
|
|
116
|
+
self.CMD_LIST_CLIENTS,
|
|
117
|
+
self.CMD_RETRIEVE_TOPIC,
|
|
118
|
+
self.CMD_CREATE_TOPIC,
|
|
119
|
+
self.CMD_DELETE_TOPIC,
|
|
120
|
+
self.CMD_LIST_TOPIC,
|
|
121
|
+
self.CMD_PATCH_TOPIC,
|
|
122
|
+
self.CMD_SUBSCRIBE_TOPIC,
|
|
123
|
+
self.CMD_RETRIEVE_SUBSCRIBER,
|
|
124
|
+
self.CMD_ADD_SUBSCRIBER,
|
|
125
|
+
self.CMD_CREATE_SUBSCRIBER,
|
|
126
|
+
self.CMD_DELETE_SUBSCRIBER,
|
|
127
|
+
self.CMD_REMOVE_SUBSCRIBER,
|
|
128
|
+
self.CMD_LIST_SUBSCRIBER,
|
|
129
|
+
self.CMD_PATCH_SUBSCRIBER,
|
|
130
|
+
self.CMD_GET_APP_INFO,
|
|
131
|
+
self.CMD_LIST_FILES,
|
|
132
|
+
self.CMD_LIST_IMAGES,
|
|
133
|
+
self.CMD_RETRIEVE_FILE,
|
|
134
|
+
self.CMD_RETRIEVE_IMAGE,
|
|
135
|
+
self.CMD_ASSOCIATE_TOPIC_ID,
|
|
136
|
+
self.CMD_STATUS,
|
|
137
|
+
self.CMD_LIST_EVENTS,
|
|
138
|
+
self.CMD_BAN_IDENTITY,
|
|
139
|
+
self.CMD_UNBAN_IDENTITY,
|
|
140
|
+
self.CMD_BLACKHOLE_IDENTITY,
|
|
141
|
+
self.CMD_LIST_IDENTITIES,
|
|
142
|
+
self.CMD_GET_CONFIG,
|
|
143
|
+
self.CMD_VALIDATE_CONFIG,
|
|
144
|
+
self.CMD_APPLY_CONFIG,
|
|
145
|
+
self.CMD_ROLLBACK_CONFIG,
|
|
146
|
+
self.CMD_FLUSH_TELEMETRY,
|
|
147
|
+
self.CMD_RELOAD_CONFIG,
|
|
148
|
+
self.CMD_DUMP_ROUTING,
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
def _command_alias_map(self) -> Dict[str, str]:
|
|
152
|
+
"""Return a mapping of lowercase aliases to canonical commands."""
|
|
153
|
+
|
|
154
|
+
if self._command_aliases_cache:
|
|
155
|
+
return self._command_aliases_cache
|
|
156
|
+
for command_name in self._all_command_names():
|
|
157
|
+
aliases = {
|
|
158
|
+
command_name.lower(),
|
|
159
|
+
self._lower_camel(command_name).lower(),
|
|
160
|
+
}
|
|
161
|
+
for alias in aliases:
|
|
162
|
+
self._command_aliases_cache.setdefault(alias, command_name)
|
|
163
|
+
self._command_aliases_cache.setdefault(
|
|
164
|
+
"retrievesubscriber", self.CMD_RETRIEVE_SUBSCRIBER
|
|
165
|
+
)
|
|
166
|
+
self._command_aliases_cache.setdefault(
|
|
167
|
+
"retreivesubscriber", self.CMD_RETRIEVE_SUBSCRIBER
|
|
168
|
+
)
|
|
169
|
+
return self._command_aliases_cache
|
|
170
|
+
|
|
171
|
+
@staticmethod
|
|
172
|
+
def _lower_camel(command_name: str) -> str:
|
|
173
|
+
"""Return the command name with a lowercase prefix."""
|
|
174
|
+
|
|
175
|
+
if not command_name:
|
|
176
|
+
return command_name
|
|
177
|
+
return command_name[0].lower() + command_name[1:]
|
|
178
|
+
|
|
179
|
+
def _normalize_command_name(self, name: Optional[str]) -> Optional[str]:
|
|
180
|
+
"""Normalize command names across casing variants."""
|
|
181
|
+
|
|
182
|
+
if name is None:
|
|
183
|
+
return None
|
|
184
|
+
normalized = name.strip()
|
|
185
|
+
if not normalized:
|
|
186
|
+
return None
|
|
187
|
+
alias_map = self._command_alias_map()
|
|
188
|
+
return alias_map.get(normalized.lower(), normalized)
|
|
189
|
+
|
|
190
|
+
# ------------------------------------------------------------------
|
|
191
|
+
# public API
|
|
192
|
+
# ------------------------------------------------------------------
|
|
193
|
+
def handle_commands(
|
|
194
|
+
self, commands: List[dict], message: LXMF.LXMessage
|
|
195
|
+
) -> List[LXMF.LXMessage]:
|
|
196
|
+
"""Process a list of commands and return generated responses."""
|
|
197
|
+
|
|
198
|
+
responses: List[LXMF.LXMessage] = []
|
|
199
|
+
for raw_command in commands:
|
|
200
|
+
normalized, error_response = self._normalize_command(raw_command, message)
|
|
201
|
+
if error_response is not None:
|
|
202
|
+
responses.append(error_response)
|
|
203
|
+
continue
|
|
204
|
+
if normalized is None:
|
|
205
|
+
continue
|
|
206
|
+
try:
|
|
207
|
+
msg = self.handle_command(normalized, message)
|
|
208
|
+
except Exception as exc: # pragma: no cover - defensive log
|
|
209
|
+
command_name = normalized.get(PLUGIN_COMMAND) or normalized.get(
|
|
210
|
+
"Command"
|
|
211
|
+
)
|
|
212
|
+
RNS.log(
|
|
213
|
+
f"Command '{command_name}' failed: {exc}",
|
|
214
|
+
getattr(RNS, "LOG_WARNING", 2),
|
|
215
|
+
)
|
|
216
|
+
msg = self._reply(
|
|
217
|
+
message, f"Command failed: {command_name or 'unknown'}"
|
|
218
|
+
)
|
|
219
|
+
if msg:
|
|
220
|
+
if isinstance(msg, list):
|
|
221
|
+
responses.extend(msg)
|
|
222
|
+
else:
|
|
223
|
+
responses.append(msg)
|
|
224
|
+
return responses
|
|
225
|
+
|
|
226
|
+
def _normalize_command(
|
|
227
|
+
self, raw_command: Any, message: LXMF.LXMessage
|
|
228
|
+
) -> tuple[Optional[dict], Optional[LXMF.LXMessage]]:
|
|
229
|
+
"""Normalize incoming command payloads, including JSON-wrapped strings.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
raw_command (Any): The incoming payload from LXMF.
|
|
233
|
+
message (LXMF.LXMessage): Source LXMF message for contextual replies.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
tuple[Optional[dict], Optional[LXMF.LXMessage]]: Normalized payload and
|
|
237
|
+
optional error reply when parsing fails.
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
if isinstance(raw_command, str):
|
|
241
|
+
raw_command, error_response = self._parse_json_object(raw_command, message)
|
|
242
|
+
if error_response is not None:
|
|
243
|
+
return None, error_response
|
|
244
|
+
|
|
245
|
+
if isinstance(raw_command, (list, tuple)):
|
|
246
|
+
raw_command = {index: value for index, value in enumerate(raw_command)}
|
|
247
|
+
|
|
248
|
+
if isinstance(raw_command, dict):
|
|
249
|
+
normalized, error_response = self._unwrap_sideband_payload(
|
|
250
|
+
raw_command, message
|
|
251
|
+
)
|
|
252
|
+
if error_response is not None:
|
|
253
|
+
return None, error_response
|
|
254
|
+
normalized = self._apply_positional_payload(normalized)
|
|
255
|
+
return normalized, None
|
|
256
|
+
|
|
257
|
+
return None, self._reply(
|
|
258
|
+
message, f"Unsupported command payload type: {type(raw_command).__name__}"
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
def _parse_json_object(
|
|
262
|
+
self, payload: str, message: LXMF.LXMessage
|
|
263
|
+
) -> tuple[Optional[dict], Optional[LXMF.LXMessage]]:
|
|
264
|
+
"""Parse a JSON string and ensure it represents an object.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
payload (str): Raw JSON string containing command data.
|
|
268
|
+
message (LXMF.LXMessage): Source LXMF message for error replies.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
tuple[Optional[dict], Optional[LXMF.LXMessage]]: Parsed JSON
|
|
272
|
+
object or an error response when parsing fails.
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
parsed = json.loads(payload)
|
|
277
|
+
except json.JSONDecodeError:
|
|
278
|
+
error = self._reply(
|
|
279
|
+
message, f"Command payload is not valid JSON: {payload!r}"
|
|
280
|
+
)
|
|
281
|
+
return None, error
|
|
282
|
+
if not isinstance(parsed, dict):
|
|
283
|
+
return None, self._reply(message, "Parsed command must be a JSON object")
|
|
284
|
+
return parsed, None
|
|
285
|
+
|
|
286
|
+
def _unwrap_sideband_payload(
|
|
287
|
+
self, payload: dict, message: LXMF.LXMessage
|
|
288
|
+
) -> tuple[dict, Optional[LXMF.LXMessage]]:
|
|
289
|
+
"""Remove Sideband numeric-key wrappers and parse nested JSON content.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
payload (dict): Incoming command payload.
|
|
293
|
+
message (LXMF.LXMessage): Source LXMF message for error replies.
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
tuple[dict, Optional[LXMF.LXMessage]]: Normalized command payload and
|
|
297
|
+
an optional error response when nested parsing fails.
|
|
298
|
+
"""
|
|
299
|
+
|
|
300
|
+
if len(payload) == 1:
|
|
301
|
+
key = next(iter(payload))
|
|
302
|
+
if isinstance(key, (int, str)) and str(key).isdigit():
|
|
303
|
+
inner_payload = payload[key]
|
|
304
|
+
if isinstance(inner_payload, dict):
|
|
305
|
+
return inner_payload, None
|
|
306
|
+
if isinstance(inner_payload, str) and inner_payload.lstrip().startswith(
|
|
307
|
+
"{"
|
|
308
|
+
):
|
|
309
|
+
parsed, error_response = self._parse_json_object(
|
|
310
|
+
inner_payload, message
|
|
311
|
+
)
|
|
312
|
+
if error_response is not None:
|
|
313
|
+
return payload, error_response
|
|
314
|
+
if parsed is not None:
|
|
315
|
+
return parsed, None
|
|
316
|
+
return payload, None
|
|
317
|
+
|
|
318
|
+
def _apply_positional_payload(self, payload: dict) -> dict:
|
|
319
|
+
"""Expand numeric-key payloads into named command dictionaries.
|
|
320
|
+
|
|
321
|
+
Sideband can emit command payloads as ``{0: "CreateTopic", 1: "Weather"}``
|
|
322
|
+
instead of JSON objects. This helper maps known positional arguments into
|
|
323
|
+
the expected named fields so downstream handlers receive structured data.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
payload (dict): Raw command payload.
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
dict: Normalized payload including "Command" and PLUGIN_COMMAND keys
|
|
330
|
+
when conversion succeeds; otherwise the original payload.
|
|
331
|
+
"""
|
|
332
|
+
|
|
333
|
+
if PLUGIN_COMMAND in payload or "Command" in payload:
|
|
334
|
+
has_named_fields = any(not self._is_numeric_key(key) for key in payload)
|
|
335
|
+
if has_named_fields:
|
|
336
|
+
return payload
|
|
337
|
+
|
|
338
|
+
numeric_keys = {key for key in payload if self._is_numeric_key(key)}
|
|
339
|
+
if not numeric_keys:
|
|
340
|
+
return payload
|
|
341
|
+
|
|
342
|
+
command_name_raw = payload.get(0) if 0 in payload else payload.get("0")
|
|
343
|
+
if not isinstance(command_name_raw, str):
|
|
344
|
+
return payload
|
|
345
|
+
|
|
346
|
+
command_name = self._normalize_command_name(command_name_raw) or command_name_raw
|
|
347
|
+
positional_fields = self._positional_fields_for_command(command_name)
|
|
348
|
+
if not positional_fields:
|
|
349
|
+
return payload
|
|
350
|
+
|
|
351
|
+
normalized: dict = {PLUGIN_COMMAND: command_name, "Command": command_name}
|
|
352
|
+
for index, field_name in enumerate(positional_fields, start=1):
|
|
353
|
+
value = self._numeric_lookup(payload, index)
|
|
354
|
+
if value is not None:
|
|
355
|
+
normalized[field_name] = value
|
|
356
|
+
|
|
357
|
+
for key, value in payload.items():
|
|
358
|
+
if self._is_numeric_key(key):
|
|
359
|
+
continue
|
|
360
|
+
normalized[key] = value
|
|
361
|
+
return normalized
|
|
362
|
+
|
|
363
|
+
def _positional_fields_for_command(self, command_name: str) -> List[str]:
|
|
364
|
+
"""Return positional field hints for known commands.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
command_name (str): Name of the incoming command.
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
List[str]: Ordered field names expected for positional payloads.
|
|
371
|
+
"""
|
|
372
|
+
|
|
373
|
+
return self.POSITIONAL_FIELDS.get(command_name, [])
|
|
374
|
+
|
|
375
|
+
@staticmethod
|
|
376
|
+
def _numeric_lookup(payload: dict, index: int) -> Any:
|
|
377
|
+
"""Fetch a value from digit-only keys in either int or str form.
|
|
378
|
+
|
|
379
|
+
Args:
|
|
380
|
+
payload (dict): Payload to search.
|
|
381
|
+
index (int): Numeric index to look up.
|
|
382
|
+
|
|
383
|
+
Returns:
|
|
384
|
+
Any: The value bound to the numeric key when present.
|
|
385
|
+
"""
|
|
386
|
+
|
|
387
|
+
if index in payload:
|
|
388
|
+
return payload.get(index)
|
|
389
|
+
index_key = str(index)
|
|
390
|
+
if index_key in payload:
|
|
391
|
+
return payload.get(index_key)
|
|
392
|
+
for key in payload:
|
|
393
|
+
if not CommandManager._is_numeric_key(key):
|
|
394
|
+
continue
|
|
395
|
+
try:
|
|
396
|
+
if int(str(key)) == index:
|
|
397
|
+
return payload.get(key)
|
|
398
|
+
except ValueError:
|
|
399
|
+
continue
|
|
400
|
+
return None
|
|
401
|
+
|
|
402
|
+
@staticmethod
|
|
403
|
+
def _has_numeric_key(payload: dict, index: int) -> bool:
|
|
404
|
+
"""Return True when the payload includes a matching numeric key.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
payload (dict): Payload to search.
|
|
408
|
+
index (int): Numeric index to look up.
|
|
409
|
+
|
|
410
|
+
Returns:
|
|
411
|
+
bool: True when the key exists in any numeric string form.
|
|
412
|
+
"""
|
|
413
|
+
|
|
414
|
+
for key in payload:
|
|
415
|
+
if not CommandManager._is_numeric_key(key):
|
|
416
|
+
continue
|
|
417
|
+
try:
|
|
418
|
+
if int(str(key)) == index:
|
|
419
|
+
return True
|
|
420
|
+
except ValueError:
|
|
421
|
+
continue
|
|
422
|
+
return False
|
|
423
|
+
|
|
424
|
+
@staticmethod
|
|
425
|
+
def _is_numeric_key(key: Any) -> bool:
|
|
426
|
+
"""Return True when the key is a digit-like identifier.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
key (Any): Key to evaluate.
|
|
430
|
+
|
|
431
|
+
Returns:
|
|
432
|
+
bool: True when the key contains only digits.
|
|
433
|
+
"""
|
|
434
|
+
|
|
435
|
+
try:
|
|
436
|
+
return str(key).isdigit()
|
|
437
|
+
except Exception:
|
|
438
|
+
return False
|
|
439
|
+
|
|
440
|
+
# ------------------------------------------------------------------
|
|
441
|
+
# individual command processing
|
|
442
|
+
# ------------------------------------------------------------------
|
|
443
|
+
def handle_command(
|
|
444
|
+
self, command: dict, message: LXMF.LXMessage
|
|
445
|
+
) -> Optional[LXMF.LXMessage]:
|
|
446
|
+
command = self._merge_pending_fields(command, message)
|
|
447
|
+
name = command.get(PLUGIN_COMMAND) or command.get("Command")
|
|
448
|
+
name = self._normalize_command_name(name)
|
|
449
|
+
telemetry_request_present = self._has_numeric_key(
|
|
450
|
+
command, TelemetryController.TELEMETRY_REQUEST
|
|
451
|
+
)
|
|
452
|
+
is_telemetry_command = (
|
|
453
|
+
isinstance(name, str) and name.strip().lower() == "telemetryrequest"
|
|
454
|
+
)
|
|
455
|
+
if name:
|
|
456
|
+
command[PLUGIN_COMMAND] = name
|
|
457
|
+
command["Command"] = name
|
|
458
|
+
if name is not None:
|
|
459
|
+
dispatch_map = {
|
|
460
|
+
self.CMD_HELP: lambda: self._handle_help(message),
|
|
461
|
+
self.CMD_EXAMPLES: lambda: self._handle_examples(message),
|
|
462
|
+
self.CMD_JOIN: lambda: self._handle_join(message),
|
|
463
|
+
self.CMD_LEAVE: lambda: self._handle_leave(message),
|
|
464
|
+
self.CMD_LIST_CLIENTS: lambda: self._handle_list_clients(message),
|
|
465
|
+
self.CMD_GET_APP_INFO: lambda: self._handle_get_app_info(message),
|
|
466
|
+
self.CMD_LIST_TOPIC: lambda: self._handle_list_topics(message),
|
|
467
|
+
self.CMD_LIST_FILES: lambda: self._handle_list_files(message),
|
|
468
|
+
self.CMD_LIST_IMAGES: lambda: self._handle_list_images(message),
|
|
469
|
+
self.CMD_CREATE_TOPIC: lambda: self._handle_create_topic(
|
|
470
|
+
command, message
|
|
471
|
+
),
|
|
472
|
+
self.CMD_RETRIEVE_TOPIC: lambda: self._handle_retrieve_topic(
|
|
473
|
+
command, message
|
|
474
|
+
),
|
|
475
|
+
self.CMD_DELETE_TOPIC: lambda: self._handle_delete_topic(
|
|
476
|
+
command, message
|
|
477
|
+
),
|
|
478
|
+
self.CMD_PATCH_TOPIC: lambda: self._handle_patch_topic(
|
|
479
|
+
command, message
|
|
480
|
+
),
|
|
481
|
+
self.CMD_SUBSCRIBE_TOPIC: lambda: self._handle_subscribe_topic(
|
|
482
|
+
command, message
|
|
483
|
+
),
|
|
484
|
+
self.CMD_LIST_SUBSCRIBER: lambda: self._handle_list_subscribers(
|
|
485
|
+
message
|
|
486
|
+
),
|
|
487
|
+
self.CMD_RETRIEVE_FILE: lambda: self._handle_retrieve_file(
|
|
488
|
+
command, message
|
|
489
|
+
),
|
|
490
|
+
self.CMD_RETRIEVE_IMAGE: lambda: self._handle_retrieve_image(
|
|
491
|
+
command, message
|
|
492
|
+
),
|
|
493
|
+
self.CMD_ASSOCIATE_TOPIC_ID: lambda: self._handle_associate_topic_id(
|
|
494
|
+
command, message
|
|
495
|
+
),
|
|
496
|
+
self.CMD_CREATE_SUBSCRIBER: lambda: self._handle_create_subscriber(
|
|
497
|
+
command, message
|
|
498
|
+
),
|
|
499
|
+
self.CMD_ADD_SUBSCRIBER: lambda: self._handle_create_subscriber(
|
|
500
|
+
command, message
|
|
501
|
+
),
|
|
502
|
+
self.CMD_RETRIEVE_SUBSCRIBER: partial(
|
|
503
|
+
self._handle_retrieve_subscriber, command, message
|
|
504
|
+
),
|
|
505
|
+
self.CMD_DELETE_SUBSCRIBER: lambda: self._handle_delete_subscriber(
|
|
506
|
+
command, message
|
|
507
|
+
),
|
|
508
|
+
self.CMD_REMOVE_SUBSCRIBER: lambda: self._handle_delete_subscriber(
|
|
509
|
+
command, message
|
|
510
|
+
),
|
|
511
|
+
self.CMD_PATCH_SUBSCRIBER: lambda: self._handle_patch_subscriber(
|
|
512
|
+
command, message
|
|
513
|
+
),
|
|
514
|
+
self.CMD_STATUS: lambda: self._handle_status(message),
|
|
515
|
+
self.CMD_LIST_EVENTS: lambda: self._handle_list_events(message),
|
|
516
|
+
self.CMD_BAN_IDENTITY: lambda: self._handle_ban_identity(
|
|
517
|
+
command, message
|
|
518
|
+
),
|
|
519
|
+
self.CMD_UNBAN_IDENTITY: lambda: self._handle_unban_identity(
|
|
520
|
+
command, message
|
|
521
|
+
),
|
|
522
|
+
self.CMD_BLACKHOLE_IDENTITY: lambda: self._handle_blackhole_identity(
|
|
523
|
+
command, message
|
|
524
|
+
),
|
|
525
|
+
self.CMD_LIST_IDENTITIES: lambda: self._handle_list_identities(message),
|
|
526
|
+
self.CMD_GET_CONFIG: lambda: self._handle_get_config(message),
|
|
527
|
+
self.CMD_VALIDATE_CONFIG: lambda: self._handle_validate_config(
|
|
528
|
+
command, message
|
|
529
|
+
),
|
|
530
|
+
self.CMD_APPLY_CONFIG: lambda: self._handle_apply_config(
|
|
531
|
+
command, message
|
|
532
|
+
),
|
|
533
|
+
self.CMD_ROLLBACK_CONFIG: lambda: self._handle_rollback_config(
|
|
534
|
+
command, message
|
|
535
|
+
),
|
|
536
|
+
self.CMD_FLUSH_TELEMETRY: lambda: self._handle_flush_telemetry(message),
|
|
537
|
+
self.CMD_RELOAD_CONFIG: lambda: self._handle_reload_config(message),
|
|
538
|
+
self.CMD_DUMP_ROUTING: lambda: self._handle_dump_routing(message),
|
|
539
|
+
}
|
|
540
|
+
handler = dispatch_map.get(name)
|
|
541
|
+
if handler is not None:
|
|
542
|
+
return handler()
|
|
543
|
+
if telemetry_request_present and is_telemetry_command:
|
|
544
|
+
return self.tel_controller.handle_command(
|
|
545
|
+
command, message, self.my_lxmf_dest
|
|
546
|
+
)
|
|
547
|
+
return self._handle_unknown_command(name, message)
|
|
548
|
+
# Delegate to telemetry controller for telemetry related commands
|
|
549
|
+
return self.tel_controller.handle_command(command, message, self.my_lxmf_dest)
|
|
550
|
+
|
|
551
|
+
# ------------------------------------------------------------------
|
|
552
|
+
# command implementations
|
|
553
|
+
# ------------------------------------------------------------------
|
|
554
|
+
def _create_dest(self, identity: RNS.Identity) -> RNS.Destination:
|
|
555
|
+
return RNS.Destination(
|
|
556
|
+
identity,
|
|
557
|
+
RNS.Destination.OUT,
|
|
558
|
+
RNS.Destination.SINGLE,
|
|
559
|
+
"lxmf",
|
|
560
|
+
"delivery",
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
def _handle_join(self, message: LXMF.LXMessage) -> LXMF.LXMessage:
|
|
564
|
+
dest = self._create_dest(message.source.identity)
|
|
565
|
+
self.connections[dest.identity.hash] = dest
|
|
566
|
+
identity_hex = self._identity_hex(dest.identity)
|
|
567
|
+
self.api.join(identity_hex)
|
|
568
|
+
RNS.log(f"Connection added: {message.source}")
|
|
569
|
+
display_name, label = self._resolve_identity_label(identity_hex)
|
|
570
|
+
self._record_event(
|
|
571
|
+
"client_join",
|
|
572
|
+
f"Client joined: {label}",
|
|
573
|
+
metadata={"identity": identity_hex, "display_name": display_name},
|
|
574
|
+
)
|
|
575
|
+
return LXMF.LXMessage(
|
|
576
|
+
dest,
|
|
577
|
+
self.my_lxmf_dest,
|
|
578
|
+
"Connection established",
|
|
579
|
+
desired_method=LXMF.LXMessage.DIRECT,
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
def _handle_leave(self, message: LXMF.LXMessage) -> LXMF.LXMessage:
|
|
583
|
+
dest = self._create_dest(message.source.identity)
|
|
584
|
+
self.connections.pop(dest.identity.hash, None)
|
|
585
|
+
identity_hex = self._identity_hex(dest.identity)
|
|
586
|
+
self.api.leave(identity_hex)
|
|
587
|
+
RNS.log(f"Connection removed: {message.source}")
|
|
588
|
+
display_name, label = self._resolve_identity_label(identity_hex)
|
|
589
|
+
self._record_event(
|
|
590
|
+
"client_leave",
|
|
591
|
+
f"Client left: {label}",
|
|
592
|
+
metadata={"identity": identity_hex, "display_name": display_name},
|
|
593
|
+
)
|
|
594
|
+
return LXMF.LXMessage(
|
|
595
|
+
dest,
|
|
596
|
+
self.my_lxmf_dest,
|
|
597
|
+
"Connection removed",
|
|
598
|
+
desired_method=LXMF.LXMessage.DIRECT,
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
def _handle_list_clients(self, message: LXMF.LXMessage) -> LXMF.LXMessage:
|
|
602
|
+
clients = self.api.list_clients()
|
|
603
|
+
client_hashes = [self._format_client_entry(client) for client in clients]
|
|
604
|
+
return self._reply(message, ",".join(client_hashes) or "")
|
|
605
|
+
|
|
606
|
+
def _handle_get_app_info(self, message: LXMF.LXMessage) -> LXMF.LXMessage:
|
|
607
|
+
app_info = self.api.get_app_info()
|
|
608
|
+
payload = {
|
|
609
|
+
"name": getattr(app_info, "app_name", ""),
|
|
610
|
+
"version": getattr(app_info, "app_version", ""),
|
|
611
|
+
"description": getattr(app_info, "app_description", ""),
|
|
612
|
+
}
|
|
613
|
+
return self._reply(message, json.dumps(payload, sort_keys=True))
|
|
614
|
+
|
|
615
|
+
def _handle_list_topics(self, message: LXMF.LXMessage) -> LXMF.LXMessage:
|
|
616
|
+
topics = self.api.list_topics()
|
|
617
|
+
content_lines = format_topic_list(topics)
|
|
618
|
+
content_lines.append(topic_subscribe_hint(self.CMD_SUBSCRIBE_TOPIC))
|
|
619
|
+
return self._reply(message, "\n".join(content_lines))
|
|
620
|
+
|
|
621
|
+
def _handle_list_files(self, message: LXMF.LXMessage) -> LXMF.LXMessage:
|
|
622
|
+
files = self.api.list_files()
|
|
623
|
+
lines = format_attachment_list(files, empty_text="No files stored yet.")
|
|
624
|
+
return self._reply(message, "\n".join(lines))
|
|
625
|
+
|
|
626
|
+
def _handle_list_images(self, message: LXMF.LXMessage) -> LXMF.LXMessage:
|
|
627
|
+
images = self.api.list_images()
|
|
628
|
+
lines = format_attachment_list(images, empty_text="No images stored yet.")
|
|
629
|
+
return self._reply(message, "\n".join(lines))
|
|
630
|
+
|
|
631
|
+
def _handle_associate_topic_id(
|
|
632
|
+
self, command: dict, message: LXMF.LXMessage
|
|
633
|
+
) -> LXMF.LXMessage:
|
|
634
|
+
topic_id = self._extract_topic_id(command)
|
|
635
|
+
if not topic_id:
|
|
636
|
+
return self._prompt_for_fields(
|
|
637
|
+
self.CMD_ASSOCIATE_TOPIC_ID, ["TopicID"], message, command
|
|
638
|
+
)
|
|
639
|
+
payload = json.dumps({"TopicID": topic_id}, sort_keys=True)
|
|
640
|
+
return self._reply(message, f"Attachment TopicID set: {payload}")
|
|
641
|
+
|
|
642
|
+
def _handle_create_topic(
|
|
643
|
+
self, command: dict, message: LXMF.LXMessage
|
|
644
|
+
) -> LXMF.LXMessage:
|
|
645
|
+
missing = self._missing_fields(command, ["TopicName", "TopicPath"])
|
|
646
|
+
if missing:
|
|
647
|
+
return self._prompt_for_fields(
|
|
648
|
+
self.CMD_CREATE_TOPIC, missing, message, command
|
|
649
|
+
)
|
|
650
|
+
topic = Topic.from_dict(command)
|
|
651
|
+
created = self.api.create_topic(topic)
|
|
652
|
+
payload = json.dumps(created.to_dict(), sort_keys=True)
|
|
653
|
+
self._record_event("topic_created", f"Topic created: {created.topic_id}")
|
|
654
|
+
return self._reply(message, f"Topic created: {payload}")
|
|
655
|
+
|
|
656
|
+
def _handle_retrieve_topic(
|
|
657
|
+
self, command: dict, message: LXMF.LXMessage
|
|
658
|
+
) -> LXMF.LXMessage:
|
|
659
|
+
topic_id = self._extract_topic_id(command)
|
|
660
|
+
if not topic_id:
|
|
661
|
+
return self._prompt_for_fields(
|
|
662
|
+
self.CMD_RETRIEVE_TOPIC, ["TopicID"], message, command
|
|
663
|
+
)
|
|
664
|
+
try:
|
|
665
|
+
topic = self.api.retrieve_topic(topic_id)
|
|
666
|
+
except KeyError as exc:
|
|
667
|
+
return self._reply(message, str(exc))
|
|
668
|
+
payload = json.dumps(topic.to_dict(), sort_keys=True)
|
|
669
|
+
return self._reply(message, payload)
|
|
670
|
+
|
|
671
|
+
def _handle_delete_topic(
|
|
672
|
+
self, command: dict, message: LXMF.LXMessage
|
|
673
|
+
) -> LXMF.LXMessage:
|
|
674
|
+
topic_id = self._extract_topic_id(command)
|
|
675
|
+
if not topic_id:
|
|
676
|
+
return self._prompt_for_fields(
|
|
677
|
+
self.CMD_DELETE_TOPIC, ["TopicID"], message, command
|
|
678
|
+
)
|
|
679
|
+
try:
|
|
680
|
+
topic = self.api.delete_topic(topic_id)
|
|
681
|
+
except KeyError as exc:
|
|
682
|
+
return self._reply(message, str(exc))
|
|
683
|
+
payload = json.dumps(topic.to_dict(), sort_keys=True)
|
|
684
|
+
self._record_event("topic_deleted", f"Topic deleted: {topic.topic_id}")
|
|
685
|
+
return self._reply(message, f"Topic deleted: {payload}")
|
|
686
|
+
|
|
687
|
+
def _handle_patch_topic(
|
|
688
|
+
self, command: dict, message: LXMF.LXMessage
|
|
689
|
+
) -> LXMF.LXMessage:
|
|
690
|
+
topic_id = self._extract_topic_id(command)
|
|
691
|
+
if not topic_id:
|
|
692
|
+
return self._prompt_for_fields(
|
|
693
|
+
self.CMD_PATCH_TOPIC, ["TopicID"], message, command
|
|
694
|
+
)
|
|
695
|
+
updates = {k: v for k, v in command.items() if k != PLUGIN_COMMAND}
|
|
696
|
+
try:
|
|
697
|
+
topic = self.api.patch_topic(topic_id, **updates)
|
|
698
|
+
except KeyError as exc:
|
|
699
|
+
return self._reply(message, str(exc))
|
|
700
|
+
payload = json.dumps(topic.to_dict(), sort_keys=True)
|
|
701
|
+
self._record_event("topic_updated", f"Topic updated: {topic.topic_id}")
|
|
702
|
+
return self._reply(message, f"Topic updated: {payload}")
|
|
703
|
+
|
|
704
|
+
def _handle_subscribe_topic(
|
|
705
|
+
self, command: dict, message: LXMF.LXMessage
|
|
706
|
+
) -> LXMF.LXMessage:
|
|
707
|
+
topic_id = self._extract_topic_id(command)
|
|
708
|
+
if not topic_id:
|
|
709
|
+
return self._prompt_for_fields(
|
|
710
|
+
self.CMD_SUBSCRIBE_TOPIC, ["TopicID"], message, command
|
|
711
|
+
)
|
|
712
|
+
destination = self._identity_hex(message.source.identity)
|
|
713
|
+
reject_tests = None
|
|
714
|
+
if "RejectTests" in command:
|
|
715
|
+
reject_tests = command["RejectTests"]
|
|
716
|
+
elif "reject_tests" in command:
|
|
717
|
+
reject_tests = command["reject_tests"]
|
|
718
|
+
metadata = command.get("Metadata") or command.get("metadata") or {}
|
|
719
|
+
try:
|
|
720
|
+
subscriber = self.api.subscribe_topic(
|
|
721
|
+
topic_id,
|
|
722
|
+
destination=destination,
|
|
723
|
+
reject_tests=reject_tests,
|
|
724
|
+
metadata=metadata,
|
|
725
|
+
)
|
|
726
|
+
except KeyError as exc:
|
|
727
|
+
return self._reply(message, str(exc))
|
|
728
|
+
payload = json.dumps(subscriber.to_dict(), sort_keys=True)
|
|
729
|
+
self._record_event(
|
|
730
|
+
"topic_subscribed",
|
|
731
|
+
f"Destination subscribed to {topic_id}",
|
|
732
|
+
)
|
|
733
|
+
return self._reply(message, f"Subscribed: {payload}")
|
|
734
|
+
|
|
735
|
+
def _handle_retrieve_file(
|
|
736
|
+
self, command: dict, message: LXMF.LXMessage
|
|
737
|
+
) -> LXMF.LXMessage:
|
|
738
|
+
file_id_value = self._extract_file_id(command)
|
|
739
|
+
file_id = self._coerce_int_id(file_id_value)
|
|
740
|
+
if file_id is None:
|
|
741
|
+
if file_id_value is None:
|
|
742
|
+
return self._prompt_for_fields(
|
|
743
|
+
self.CMD_RETRIEVE_FILE, ["FileID"], message, command
|
|
744
|
+
)
|
|
745
|
+
return self._reply(message, "FileID must be an integer")
|
|
746
|
+
try:
|
|
747
|
+
attachment = self.api.retrieve_file(file_id)
|
|
748
|
+
except KeyError as exc:
|
|
749
|
+
return self._reply(message, str(exc))
|
|
750
|
+
try:
|
|
751
|
+
fields = self._build_attachment_fields(attachment)
|
|
752
|
+
except FileNotFoundError:
|
|
753
|
+
return self._reply(
|
|
754
|
+
message, f"File '{file_id}' not found on disk; remove and re-upload."
|
|
755
|
+
)
|
|
756
|
+
payload = json.dumps(attachment.to_dict(), sort_keys=True)
|
|
757
|
+
return self._reply(message, f"File retrieved: {payload}", fields=fields)
|
|
758
|
+
|
|
759
|
+
def _handle_retrieve_image(
|
|
760
|
+
self, command: dict, message: LXMF.LXMessage
|
|
761
|
+
) -> LXMF.LXMessage:
|
|
762
|
+
image_id_value = self._extract_file_id(command)
|
|
763
|
+
image_id = self._coerce_int_id(image_id_value)
|
|
764
|
+
if image_id is None:
|
|
765
|
+
if image_id_value is None:
|
|
766
|
+
return self._prompt_for_fields(
|
|
767
|
+
self.CMD_RETRIEVE_IMAGE, ["FileID"], message, command
|
|
768
|
+
)
|
|
769
|
+
return self._reply(message, "FileID must be an integer")
|
|
770
|
+
try:
|
|
771
|
+
attachment = self.api.retrieve_image(image_id)
|
|
772
|
+
except KeyError as exc:
|
|
773
|
+
return self._reply(message, str(exc))
|
|
774
|
+
try:
|
|
775
|
+
fields = self._build_attachment_fields(attachment)
|
|
776
|
+
except FileNotFoundError:
|
|
777
|
+
return self._reply(
|
|
778
|
+
message, f"Image '{image_id}' not found on disk; remove and re-upload."
|
|
779
|
+
)
|
|
780
|
+
payload = json.dumps(attachment.to_dict(), sort_keys=True)
|
|
781
|
+
return self._reply(message, f"Image retrieved: {payload}", fields=fields)
|
|
782
|
+
|
|
783
|
+
def _handle_list_subscribers(self, message: LXMF.LXMessage) -> LXMF.LXMessage:
|
|
784
|
+
subscribers = self.api.list_subscribers()
|
|
785
|
+
lines = format_subscriber_list(subscribers)
|
|
786
|
+
return self._reply(message, "\n".join(lines))
|
|
787
|
+
|
|
788
|
+
def _handle_help(self, message: LXMF.LXMessage) -> LXMF.LXMessage:
|
|
789
|
+
return self._reply(message, build_help_text(self))
|
|
790
|
+
|
|
791
|
+
def _handle_examples(self, message: LXMF.LXMessage) -> LXMF.LXMessage:
|
|
792
|
+
return self._reply(message, build_examples_text(self))
|
|
793
|
+
|
|
794
|
+
def _handle_unknown_command(
|
|
795
|
+
self, name: str, message: LXMF.LXMessage
|
|
796
|
+
) -> LXMF.LXMessage:
|
|
797
|
+
sender = self._identity_hex(message.source.identity)
|
|
798
|
+
RNS.log(f"Unknown command '{name}' from {sender}", getattr(RNS, "LOG_ERROR", 1))
|
|
799
|
+
help_text = build_help_text(self)
|
|
800
|
+
payload = f"Unknown command\n\n{help_text}"
|
|
801
|
+
return self._reply(message, payload)
|
|
802
|
+
|
|
803
|
+
def _prompt_for_fields(
|
|
804
|
+
self,
|
|
805
|
+
command_name: str,
|
|
806
|
+
missing_fields: List[str],
|
|
807
|
+
message: LXMF.LXMessage,
|
|
808
|
+
command: dict,
|
|
809
|
+
) -> LXMF.LXMessage:
|
|
810
|
+
"""Store pending requests and prompt the sender for missing fields."""
|
|
811
|
+
|
|
812
|
+
sender_key = self._sender_key(message)
|
|
813
|
+
self._register_pending_request(
|
|
814
|
+
sender_key, command_name, missing_fields, command
|
|
815
|
+
)
|
|
816
|
+
example_payload = self._build_prompt_example(
|
|
817
|
+
command_name, missing_fields, command
|
|
818
|
+
)
|
|
819
|
+
lines = [
|
|
820
|
+
f"{command_name} is missing required fields: {', '.join(missing_fields)}.",
|
|
821
|
+
"Reply with the missing fields in JSON format to continue.",
|
|
822
|
+
f"Example: {example_payload}",
|
|
823
|
+
]
|
|
824
|
+
return self._reply(message, "\n".join(lines))
|
|
825
|
+
|
|
826
|
+
def _register_pending_request(
|
|
827
|
+
self,
|
|
828
|
+
sender_key: str,
|
|
829
|
+
command_name: str,
|
|
830
|
+
missing_fields: List[str],
|
|
831
|
+
command: dict,
|
|
832
|
+
) -> None:
|
|
833
|
+
"""Persist partial command data while waiting for required fields."""
|
|
834
|
+
|
|
835
|
+
stored_command = dict(command)
|
|
836
|
+
requests_for_sender = self.pending_field_requests.setdefault(sender_key, {})
|
|
837
|
+
requests_for_sender[command_name] = {
|
|
838
|
+
"command": stored_command,
|
|
839
|
+
"missing": list(missing_fields),
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
def _merge_pending_fields(self, command: dict, message: LXMF.LXMessage) -> dict:
|
|
843
|
+
"""Combine new command fragments with any pending prompt state."""
|
|
844
|
+
|
|
845
|
+
sender_key = self._sender_key(message)
|
|
846
|
+
pending_commands = self.pending_field_requests.get(sender_key)
|
|
847
|
+
if not pending_commands:
|
|
848
|
+
return command
|
|
849
|
+
command_name = command.get(PLUGIN_COMMAND) or command.get("Command")
|
|
850
|
+
if command_name is None:
|
|
851
|
+
return command
|
|
852
|
+
pending_entry = pending_commands.get(command_name)
|
|
853
|
+
if pending_entry is None:
|
|
854
|
+
return command
|
|
855
|
+
merged_command = dict(pending_entry.get("command", {}))
|
|
856
|
+
merged_command.update(command)
|
|
857
|
+
merged_command.setdefault(PLUGIN_COMMAND, command_name)
|
|
858
|
+
merged_command.setdefault("Command", command_name)
|
|
859
|
+
remaining_missing = self._missing_fields(
|
|
860
|
+
merged_command, pending_entry.get("missing", [])
|
|
861
|
+
)
|
|
862
|
+
if remaining_missing:
|
|
863
|
+
pending_entry["missing"] = remaining_missing
|
|
864
|
+
pending_entry["command"] = merged_command
|
|
865
|
+
else:
|
|
866
|
+
del pending_commands[command_name]
|
|
867
|
+
if not pending_commands:
|
|
868
|
+
self.pending_field_requests.pop(sender_key, None)
|
|
869
|
+
return merged_command
|
|
870
|
+
|
|
871
|
+
@staticmethod
|
|
872
|
+
def _field_value(command: dict, field: str) -> Any:
|
|
873
|
+
"""Return a field value supporting common casing variants."""
|
|
874
|
+
|
|
875
|
+
alternate_keys = {
|
|
876
|
+
field,
|
|
877
|
+
field.lower(),
|
|
878
|
+
field.replace("ID", "id"),
|
|
879
|
+
field.replace("ID", "_id"),
|
|
880
|
+
field.replace("Name", "name"),
|
|
881
|
+
field.replace("Name", "_name"),
|
|
882
|
+
field.replace("Path", "path"),
|
|
883
|
+
field.replace("Path", "_path"),
|
|
884
|
+
}
|
|
885
|
+
snake_key = re.sub(r"(?<!^)(?=[A-Z])", "_", field).lower()
|
|
886
|
+
alternate_keys.add(snake_key)
|
|
887
|
+
alternate_keys.add(snake_key.replace("_i_d", "_id"))
|
|
888
|
+
lower_camel = field[:1].lower() + field[1:]
|
|
889
|
+
alternate_keys.add(lower_camel)
|
|
890
|
+
alternate_keys.add(field.replace("ID", "Id"))
|
|
891
|
+
alternate_keys.add(lower_camel.replace("ID", "Id"))
|
|
892
|
+
for key in alternate_keys:
|
|
893
|
+
if key in command:
|
|
894
|
+
return command.get(key)
|
|
895
|
+
return command.get(field)
|
|
896
|
+
|
|
897
|
+
def _missing_fields(self, command: dict, required_fields: List[str]) -> List[str]:
|
|
898
|
+
"""Identify which required fields are still empty."""
|
|
899
|
+
|
|
900
|
+
missing: List[str] = []
|
|
901
|
+
for field in required_fields:
|
|
902
|
+
value = self._field_value(command, field)
|
|
903
|
+
if value is None or value == "":
|
|
904
|
+
missing.append(field)
|
|
905
|
+
return missing
|
|
906
|
+
|
|
907
|
+
def _build_prompt_example(
|
|
908
|
+
self, command_name: str, missing_fields: List[str], command: dict
|
|
909
|
+
) -> str:
|
|
910
|
+
"""Construct a JSON example showing the missing fields."""
|
|
911
|
+
|
|
912
|
+
template: Dict[str, Any] = {"Command": command_name}
|
|
913
|
+
for key, value in command.items():
|
|
914
|
+
if key in {PLUGIN_COMMAND, "Command"}:
|
|
915
|
+
continue
|
|
916
|
+
template[key] = value
|
|
917
|
+
for field in missing_fields:
|
|
918
|
+
if self._field_value(template, field) in {None, ""}:
|
|
919
|
+
template[field] = f"<{field}>"
|
|
920
|
+
return json.dumps(template, sort_keys=True)
|
|
921
|
+
|
|
922
|
+
def _sender_key(self, message: LXMF.LXMessage) -> str:
|
|
923
|
+
"""Return the hex identity key representing the message sender."""
|
|
924
|
+
|
|
925
|
+
return self._identity_hex(message.source.identity)
|
|
926
|
+
|
|
927
|
+
def _handle_create_subscriber(
|
|
928
|
+
self, command: dict, message: LXMF.LXMessage
|
|
929
|
+
) -> LXMF.LXMessage:
|
|
930
|
+
destination = self._field_value(command, "Destination")
|
|
931
|
+
if not destination:
|
|
932
|
+
command = dict(command)
|
|
933
|
+
command["Destination"] = self._sender_key(message)
|
|
934
|
+
missing = self._missing_fields(command, ["Destination"])
|
|
935
|
+
if missing:
|
|
936
|
+
return self._prompt_for_fields(
|
|
937
|
+
self.CMD_CREATE_SUBSCRIBER, missing, message, command
|
|
938
|
+
)
|
|
939
|
+
subscriber = Subscriber.from_dict(command)
|
|
940
|
+
try:
|
|
941
|
+
created = self.api.create_subscriber(subscriber)
|
|
942
|
+
except ValueError as exc:
|
|
943
|
+
return self._reply(message, f"Subscriber creation failed: {exc}")
|
|
944
|
+
payload = json.dumps(created.to_dict(), sort_keys=True)
|
|
945
|
+
self._record_event(
|
|
946
|
+
"subscriber_created",
|
|
947
|
+
f"Subscriber created: {created.subscriber_id}",
|
|
948
|
+
)
|
|
949
|
+
return self._reply(message, f"Subscriber created: {payload}")
|
|
950
|
+
|
|
951
|
+
def _handle_retrieve_subscriber(
|
|
952
|
+
self, command: dict, message: LXMF.LXMessage
|
|
953
|
+
) -> LXMF.LXMessage:
|
|
954
|
+
subscriber_id = self._extract_subscriber_id(command)
|
|
955
|
+
if not subscriber_id:
|
|
956
|
+
return self._prompt_for_fields(
|
|
957
|
+
self.CMD_RETRIEVE_SUBSCRIBER, ["SubscriberID"], message, command
|
|
958
|
+
)
|
|
959
|
+
try:
|
|
960
|
+
subscriber = self.api.retrieve_subscriber(subscriber_id)
|
|
961
|
+
except KeyError as exc:
|
|
962
|
+
return self._reply(message, str(exc))
|
|
963
|
+
payload = json.dumps(subscriber.to_dict(), sort_keys=True)
|
|
964
|
+
return self._reply(message, payload)
|
|
965
|
+
|
|
966
|
+
def _handle_delete_subscriber(
|
|
967
|
+
self, command: dict, message: LXMF.LXMessage
|
|
968
|
+
) -> LXMF.LXMessage:
|
|
969
|
+
subscriber_id = self._extract_subscriber_id(command)
|
|
970
|
+
if not subscriber_id:
|
|
971
|
+
return self._prompt_for_fields(
|
|
972
|
+
self.CMD_DELETE_SUBSCRIBER, ["SubscriberID"], message, command
|
|
973
|
+
)
|
|
974
|
+
try:
|
|
975
|
+
subscriber = self.api.delete_subscriber(subscriber_id)
|
|
976
|
+
except KeyError as exc:
|
|
977
|
+
return self._reply(message, str(exc))
|
|
978
|
+
payload = json.dumps(subscriber.to_dict(), sort_keys=True)
|
|
979
|
+
self._record_event(
|
|
980
|
+
"subscriber_deleted",
|
|
981
|
+
f"Subscriber deleted: {subscriber.subscriber_id}",
|
|
982
|
+
)
|
|
983
|
+
return self._reply(message, f"Subscriber deleted: {payload}")
|
|
984
|
+
|
|
985
|
+
def _handle_patch_subscriber(
|
|
986
|
+
self, command: dict, message: LXMF.LXMessage
|
|
987
|
+
) -> LXMF.LXMessage:
|
|
988
|
+
subscriber_id = self._extract_subscriber_id(command)
|
|
989
|
+
if not subscriber_id:
|
|
990
|
+
return self._prompt_for_fields(
|
|
991
|
+
self.CMD_PATCH_SUBSCRIBER, ["SubscriberID"], message, command
|
|
992
|
+
)
|
|
993
|
+
updates = {k: v for k, v in command.items() if k != PLUGIN_COMMAND}
|
|
994
|
+
try:
|
|
995
|
+
subscriber = self.api.patch_subscriber(subscriber_id, **updates)
|
|
996
|
+
except KeyError as exc:
|
|
997
|
+
return self._reply(message, str(exc))
|
|
998
|
+
payload = json.dumps(subscriber.to_dict(), sort_keys=True)
|
|
999
|
+
self._record_event(
|
|
1000
|
+
"subscriber_updated",
|
|
1001
|
+
f"Subscriber updated: {subscriber.subscriber_id}",
|
|
1002
|
+
)
|
|
1003
|
+
return self._reply(message, f"Subscriber updated: {payload}")
|
|
1004
|
+
|
|
1005
|
+
def _handle_status(self, message: LXMF.LXMessage) -> LXMF.LXMessage:
|
|
1006
|
+
"""Return the dashboard status snapshot."""
|
|
1007
|
+
|
|
1008
|
+
uptime_seconds = int(time.time() - self._start_time)
|
|
1009
|
+
status = {
|
|
1010
|
+
"uptime_seconds": uptime_seconds,
|
|
1011
|
+
"clients": len(self.connections),
|
|
1012
|
+
"topics": len(self.api.list_topics()),
|
|
1013
|
+
"subscribers": len(self.api.list_subscribers()),
|
|
1014
|
+
"files": len(self.api.list_files()),
|
|
1015
|
+
"images": len(self.api.list_images()),
|
|
1016
|
+
"telemetry": self.tel_controller.telemetry_stats(),
|
|
1017
|
+
}
|
|
1018
|
+
payload = json.dumps(status, sort_keys=True)
|
|
1019
|
+
return self._reply(message, payload)
|
|
1020
|
+
|
|
1021
|
+
def _handle_list_events(self, message: LXMF.LXMessage) -> LXMF.LXMessage:
|
|
1022
|
+
"""Return recent event entries for the dashboard."""
|
|
1023
|
+
|
|
1024
|
+
events = []
|
|
1025
|
+
if self.event_log is not None:
|
|
1026
|
+
events = self.event_log.list_events(limit=50)
|
|
1027
|
+
payload = json.dumps(events, sort_keys=True)
|
|
1028
|
+
return self._reply(message, payload)
|
|
1029
|
+
|
|
1030
|
+
def _handle_ban_identity(
|
|
1031
|
+
self, command: dict, message: LXMF.LXMessage
|
|
1032
|
+
) -> LXMF.LXMessage:
|
|
1033
|
+
"""Mark an identity as banned."""
|
|
1034
|
+
|
|
1035
|
+
identity = self._extract_identity(command)
|
|
1036
|
+
if not identity:
|
|
1037
|
+
return self._prompt_for_fields(
|
|
1038
|
+
self.CMD_BAN_IDENTITY, ["Identity"], message, command
|
|
1039
|
+
)
|
|
1040
|
+
status = self.api.ban_identity(identity)
|
|
1041
|
+
payload = json.dumps(status.to_dict(), sort_keys=True)
|
|
1042
|
+
self._record_event("identity_banned", f"Identity banned: {identity}")
|
|
1043
|
+
return self._reply(message, payload)
|
|
1044
|
+
|
|
1045
|
+
def _handle_unban_identity(
|
|
1046
|
+
self, command: dict, message: LXMF.LXMessage
|
|
1047
|
+
) -> LXMF.LXMessage:
|
|
1048
|
+
"""Remove a ban/blackhole from an identity."""
|
|
1049
|
+
|
|
1050
|
+
identity = self._extract_identity(command)
|
|
1051
|
+
if not identity:
|
|
1052
|
+
return self._prompt_for_fields(
|
|
1053
|
+
self.CMD_UNBAN_IDENTITY, ["Identity"], message, command
|
|
1054
|
+
)
|
|
1055
|
+
status = self.api.unban_identity(identity)
|
|
1056
|
+
payload = json.dumps(status.to_dict(), sort_keys=True)
|
|
1057
|
+
self._record_event("identity_unbanned", f"Identity unbanned: {identity}")
|
|
1058
|
+
return self._reply(message, payload)
|
|
1059
|
+
|
|
1060
|
+
def _handle_blackhole_identity(
|
|
1061
|
+
self, command: dict, message: LXMF.LXMessage
|
|
1062
|
+
) -> LXMF.LXMessage:
|
|
1063
|
+
"""Mark an identity as blackholed."""
|
|
1064
|
+
|
|
1065
|
+
identity = self._extract_identity(command)
|
|
1066
|
+
if not identity:
|
|
1067
|
+
return self._prompt_for_fields(
|
|
1068
|
+
self.CMD_BLACKHOLE_IDENTITY, ["Identity"], message, command
|
|
1069
|
+
)
|
|
1070
|
+
status = self.api.blackhole_identity(identity)
|
|
1071
|
+
payload = json.dumps(status.to_dict(), sort_keys=True)
|
|
1072
|
+
self._record_event("identity_blackholed", f"Identity blackholed: {identity}")
|
|
1073
|
+
return self._reply(message, payload)
|
|
1074
|
+
|
|
1075
|
+
def _handle_list_identities(self, message: LXMF.LXMessage) -> LXMF.LXMessage:
|
|
1076
|
+
"""Return identity status entries for admin tools."""
|
|
1077
|
+
|
|
1078
|
+
identities = self.api.list_identity_statuses()
|
|
1079
|
+
payload = json.dumps([entry.to_dict() for entry in identities], sort_keys=True)
|
|
1080
|
+
return self._reply(message, payload)
|
|
1081
|
+
|
|
1082
|
+
def _handle_get_config(self, message: LXMF.LXMessage) -> LXMF.LXMessage:
|
|
1083
|
+
"""Return the current config.ini content."""
|
|
1084
|
+
|
|
1085
|
+
config_text = self.api.get_config_text()
|
|
1086
|
+
return self._reply(message, config_text)
|
|
1087
|
+
|
|
1088
|
+
def _handle_validate_config(
|
|
1089
|
+
self, command: dict, message: LXMF.LXMessage
|
|
1090
|
+
) -> LXMF.LXMessage:
|
|
1091
|
+
"""Validate config content without applying changes."""
|
|
1092
|
+
|
|
1093
|
+
config_text = command.get("ConfigText") or command.get("config_text")
|
|
1094
|
+
if not config_text:
|
|
1095
|
+
return self._prompt_for_fields(
|
|
1096
|
+
self.CMD_VALIDATE_CONFIG, ["ConfigText"], message, command
|
|
1097
|
+
)
|
|
1098
|
+
result = self.api.validate_config_text(str(config_text))
|
|
1099
|
+
payload = json.dumps(result, sort_keys=True)
|
|
1100
|
+
return self._reply(message, payload)
|
|
1101
|
+
|
|
1102
|
+
def _handle_apply_config(
|
|
1103
|
+
self, command: dict, message: LXMF.LXMessage
|
|
1104
|
+
) -> LXMF.LXMessage:
|
|
1105
|
+
"""Apply a new config.ini payload."""
|
|
1106
|
+
|
|
1107
|
+
config_text = command.get("ConfigText") or command.get("config_text")
|
|
1108
|
+
if not config_text:
|
|
1109
|
+
return self._prompt_for_fields(
|
|
1110
|
+
self.CMD_APPLY_CONFIG, ["ConfigText"], message, command
|
|
1111
|
+
)
|
|
1112
|
+
try:
|
|
1113
|
+
result = self.api.apply_config_text(str(config_text))
|
|
1114
|
+
except ValueError as exc:
|
|
1115
|
+
return self._reply(message, f"Config apply failed: {exc}")
|
|
1116
|
+
payload = json.dumps(result, sort_keys=True)
|
|
1117
|
+
self._record_event("config_applied", "Configuration updated")
|
|
1118
|
+
return self._reply(message, payload)
|
|
1119
|
+
|
|
1120
|
+
def _handle_rollback_config(
|
|
1121
|
+
self, command: dict, message: LXMF.LXMessage
|
|
1122
|
+
) -> LXMF.LXMessage:
|
|
1123
|
+
"""Rollback configuration using the latest backup."""
|
|
1124
|
+
|
|
1125
|
+
backup_path = command.get("BackupPath") or command.get("backup_path")
|
|
1126
|
+
backup_value = str(backup_path) if backup_path else None
|
|
1127
|
+
result = self.api.rollback_config_text(backup_path=backup_value)
|
|
1128
|
+
payload = json.dumps(result, sort_keys=True)
|
|
1129
|
+
self._record_event("config_rollback", "Configuration rollback applied")
|
|
1130
|
+
return self._reply(message, payload)
|
|
1131
|
+
|
|
1132
|
+
def _handle_flush_telemetry(self, message: LXMF.LXMessage) -> LXMF.LXMessage:
|
|
1133
|
+
"""Clear stored telemetry records."""
|
|
1134
|
+
|
|
1135
|
+
deleted = self.tel_controller.clear_telemetry()
|
|
1136
|
+
payload = json.dumps({"deleted": deleted}, sort_keys=True)
|
|
1137
|
+
self._record_event("telemetry_flushed", f"Telemetry flushed ({deleted} rows)")
|
|
1138
|
+
return self._reply(message, payload)
|
|
1139
|
+
|
|
1140
|
+
def _handle_reload_config(self, message: LXMF.LXMessage) -> LXMF.LXMessage:
|
|
1141
|
+
"""Reload configuration from disk."""
|
|
1142
|
+
|
|
1143
|
+
config = self.api.reload_config()
|
|
1144
|
+
payload = json.dumps(config.to_dict(), sort_keys=True)
|
|
1145
|
+
self._record_event("config_reloaded", "Configuration reloaded")
|
|
1146
|
+
return self._reply(message, payload)
|
|
1147
|
+
|
|
1148
|
+
def _handle_dump_routing(self, message: LXMF.LXMessage) -> LXMF.LXMessage:
|
|
1149
|
+
"""Return a summary of connected destinations."""
|
|
1150
|
+
|
|
1151
|
+
destinations = [
|
|
1152
|
+
self._identity_hex(dest.identity) for dest in self.connections.values()
|
|
1153
|
+
]
|
|
1154
|
+
payload = json.dumps({"destinations": destinations}, sort_keys=True)
|
|
1155
|
+
return self._reply(message, payload)
|
|
1156
|
+
|
|
1157
|
+
@staticmethod
|
|
1158
|
+
def _identity_hex(identity: RNS.Identity) -> str:
|
|
1159
|
+
hash_bytes = getattr(identity, "hash", b"") or b""
|
|
1160
|
+
return hash_bytes.hex()
|
|
1161
|
+
|
|
1162
|
+
def _resolve_identity_label(self, identity: str) -> tuple[str | None, str]:
|
|
1163
|
+
display_name = None
|
|
1164
|
+
if hasattr(self.api, "resolve_identity_display_name"):
|
|
1165
|
+
try:
|
|
1166
|
+
display_name = self.api.resolve_identity_display_name(identity)
|
|
1167
|
+
except Exception: # pragma: no cover - defensive
|
|
1168
|
+
display_name = None
|
|
1169
|
+
if display_name:
|
|
1170
|
+
return display_name, f"{display_name} ({identity})"
|
|
1171
|
+
return None, identity
|
|
1172
|
+
|
|
1173
|
+
@staticmethod
|
|
1174
|
+
def _format_client_entry(client: Client) -> str:
|
|
1175
|
+
metadata = client.metadata or {}
|
|
1176
|
+
metadata_str = json.dumps(metadata, sort_keys=True)
|
|
1177
|
+
try:
|
|
1178
|
+
identity_bytes = bytes.fromhex(client.identity)
|
|
1179
|
+
identity_value = RNS.prettyhexrep(identity_bytes)
|
|
1180
|
+
except (ValueError, TypeError):
|
|
1181
|
+
identity_value = client.identity
|
|
1182
|
+
return f"{identity_value}|{metadata_str}"
|
|
1183
|
+
|
|
1184
|
+
def _reply(
|
|
1185
|
+
self, message: LXMF.LXMessage, content: str, *, fields: Optional[dict] = None
|
|
1186
|
+
) -> LXMF.LXMessage:
|
|
1187
|
+
dest = self._create_dest(message.source.identity)
|
|
1188
|
+
return LXMF.LXMessage(
|
|
1189
|
+
dest,
|
|
1190
|
+
self.my_lxmf_dest,
|
|
1191
|
+
content,
|
|
1192
|
+
fields=fields,
|
|
1193
|
+
desired_method=LXMF.LXMessage.DIRECT,
|
|
1194
|
+
)
|
|
1195
|
+
|
|
1196
|
+
@staticmethod
|
|
1197
|
+
def _extract_topic_id(command: dict) -> Optional[str]:
|
|
1198
|
+
return (
|
|
1199
|
+
command.get("TopicID")
|
|
1200
|
+
or command.get("topic_id")
|
|
1201
|
+
or command.get("id")
|
|
1202
|
+
or command.get("ID")
|
|
1203
|
+
)
|
|
1204
|
+
|
|
1205
|
+
@staticmethod
|
|
1206
|
+
def _extract_subscriber_id(command: dict) -> Optional[str]:
|
|
1207
|
+
return (
|
|
1208
|
+
command.get("SubscriberID")
|
|
1209
|
+
or command.get("subscriber_id")
|
|
1210
|
+
or command.get("id")
|
|
1211
|
+
or command.get("ID")
|
|
1212
|
+
)
|
|
1213
|
+
|
|
1214
|
+
@staticmethod
|
|
1215
|
+
def _extract_file_id(command: dict) -> Optional[Any]:
|
|
1216
|
+
for field in ("FileID", "ImageID", "ID"):
|
|
1217
|
+
value = CommandManager._field_value(command, field)
|
|
1218
|
+
if value is not None:
|
|
1219
|
+
return value
|
|
1220
|
+
return None
|
|
1221
|
+
|
|
1222
|
+
@staticmethod
|
|
1223
|
+
def _extract_identity(command: dict) -> Optional[str]:
|
|
1224
|
+
"""Return identity hash from a command payload."""
|
|
1225
|
+
|
|
1226
|
+
return (
|
|
1227
|
+
command.get("Identity")
|
|
1228
|
+
or command.get("identity")
|
|
1229
|
+
or command.get("Destination")
|
|
1230
|
+
or command.get("destination")
|
|
1231
|
+
)
|
|
1232
|
+
|
|
1233
|
+
@staticmethod
|
|
1234
|
+
def _coerce_int_id(value: Any) -> Optional[int]:
|
|
1235
|
+
try:
|
|
1236
|
+
return int(value)
|
|
1237
|
+
except (TypeError, ValueError):
|
|
1238
|
+
return None
|
|
1239
|
+
|
|
1240
|
+
def _attachment_payload(self, attachment: FileAttachment) -> list:
|
|
1241
|
+
"""Build a list payload compatible with Sideband/MeshChat clients."""
|
|
1242
|
+
|
|
1243
|
+
file_path = Path(attachment.path)
|
|
1244
|
+
data = file_path.read_bytes()
|
|
1245
|
+
if attachment.media_type:
|
|
1246
|
+
return [attachment.name, data, attachment.media_type]
|
|
1247
|
+
return [attachment.name, data]
|
|
1248
|
+
|
|
1249
|
+
def _build_attachment_fields(self, attachment: FileAttachment) -> dict:
|
|
1250
|
+
"""Return LXMF fields carrying attachment content."""
|
|
1251
|
+
|
|
1252
|
+
payload = self._attachment_payload(attachment)
|
|
1253
|
+
category = (attachment.category or "").lower()
|
|
1254
|
+
if category == "image":
|
|
1255
|
+
return {
|
|
1256
|
+
LXMF.FIELD_IMAGE: payload,
|
|
1257
|
+
LXMF.FIELD_FILE_ATTACHMENTS: [payload],
|
|
1258
|
+
}
|
|
1259
|
+
return {LXMF.FIELD_FILE_ATTACHMENTS: [payload]}
|
|
1260
|
+
|
|
1261
|
+
def _record_event(
|
|
1262
|
+
self, event_type: str, message: str, *, metadata: Optional[dict] = None
|
|
1263
|
+
) -> None:
|
|
1264
|
+
"""Emit an event log entry when a log sink is configured."""
|
|
1265
|
+
|
|
1266
|
+
if self.event_log is None:
|
|
1267
|
+
return
|
|
1268
|
+
self.event_log.add_event(event_type, message, metadata=metadata)
|