mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-11 20:06:13 +02:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 70cb133b24 | |||
| f95745cb05 | |||
| 39ba88bc4b | |||
| e814653300 | |||
| e76d922752 | |||
| d0e02a42f8 | |||
| dbf14259dc | |||
| a9ac87e668 | |||
| f710a1f2d9 | |||
| 9f6c0f12c5 | |||
| 466f693c21 | |||
| 16f87e640f | |||
| 761fd82da6 |
@@ -25,6 +25,7 @@ references/
|
||||
|
||||
# ancillary LLM files
|
||||
.claude/
|
||||
.codex
|
||||
|
||||
# local Docker compose files
|
||||
docker-compose.yml
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
## [3.13.0] - 2026-04-30
|
||||
|
||||
* Feature: Error counts included in repeater telemetry
|
||||
* Feature: RX error rate + percentage surfaced and tracked for repeaters
|
||||
* Feature: Dynamic as-you-type text replacement for Cyrillic byte optimization
|
||||
* Feature: Permit hourly checks for direct/routed repeaters
|
||||
* Feature: Allow newlines in input
|
||||
* Feature: Packet-send radio time added to packet analyzer
|
||||
* Feature: Enable forced plaintext for Apprise
|
||||
* Bugfix: Less annoying MQTT failure notifications with backoff
|
||||
* Bugfis: Don't obscure input; use dvh everywhere
|
||||
* Bugfix: Clearer save button for advert interval
|
||||
* Misc: Library updates
|
||||
* Misc: Rewrite 5xx to 4xx to avoid issues with proxies that don't react well to 503/504
|
||||
|
||||
## [3.12.3] - 2026-04-24
|
||||
|
||||
* Feature: Customizable Apprise strings
|
||||
|
||||
+1
-1
@@ -330,7 +330,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
|
||||
</details>
|
||||
|
||||
### meshcore (2.3.2) — MIT
|
||||
### meshcore (2.3.7) — MIT
|
||||
|
||||
<details>
|
||||
<summary>Full license text</summary>
|
||||
|
||||
+93
-24
@@ -11,6 +11,9 @@ from app.path_utils import split_path_hex
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MAX_SEND_ATTEMPTS = 3
|
||||
_RETRY_DELAY_S = 2
|
||||
|
||||
DEFAULT_BODY_FORMAT_DM = "**DM:** {sender_name}: {text} **via:** [{hops_backticked}]"
|
||||
DEFAULT_BODY_FORMAT_CHANNEL = (
|
||||
"**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]"
|
||||
@@ -18,6 +21,12 @@ DEFAULT_BODY_FORMAT_CHANNEL = (
|
||||
_DEFAULT_BODY_FORMAT_DM_NO_PATH = "**DM:** {sender_name}: {text}"
|
||||
_DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH = "**{channel_name}:** {sender_name}: {text}"
|
||||
|
||||
# Plain-text variants (no markdown formatting)
|
||||
DEFAULT_BODY_FORMAT_DM_PLAIN = "DM: {sender_name}: {text} via: [{hops}]"
|
||||
DEFAULT_BODY_FORMAT_CHANNEL_PLAIN = "{channel_name}: {sender_name}: {text} via: [{hops}]"
|
||||
_DEFAULT_BODY_FORMAT_DM_NO_PATH_PLAIN = "DM: {sender_name}: {text}"
|
||||
_DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH_PLAIN = "{channel_name}: {sender_name}: {text}"
|
||||
|
||||
# Variables available for user format strings
|
||||
FORMAT_VARIABLES = (
|
||||
"type",
|
||||
@@ -130,10 +139,17 @@ def _apply_format(fmt: str, variables: dict[str, str]) -> str:
|
||||
def _format_body(
|
||||
data: dict,
|
||||
*,
|
||||
body_format_dm: str = DEFAULT_BODY_FORMAT_DM,
|
||||
body_format_channel: str = DEFAULT_BODY_FORMAT_CHANNEL,
|
||||
body_format_dm: str | None = None,
|
||||
body_format_channel: str | None = None,
|
||||
markdown: bool = True,
|
||||
) -> str:
|
||||
"""Build a notification body from message data using format strings."""
|
||||
if body_format_dm is None:
|
||||
body_format_dm = DEFAULT_BODY_FORMAT_DM if markdown else DEFAULT_BODY_FORMAT_DM_PLAIN
|
||||
if body_format_channel is None:
|
||||
body_format_channel = (
|
||||
DEFAULT_BODY_FORMAT_CHANNEL if markdown else DEFAULT_BODY_FORMAT_CHANNEL_PLAIN
|
||||
)
|
||||
variables = _build_template_vars(data)
|
||||
msg_type = data.get("type", "")
|
||||
fmt = body_format_dm if msg_type == "PRIV" else body_format_channel
|
||||
@@ -141,13 +157,21 @@ def _format_body(
|
||||
return _apply_format(fmt, variables)
|
||||
except Exception:
|
||||
logger.warning("Apprise format string error, falling back to default")
|
||||
default = DEFAULT_BODY_FORMAT_DM if msg_type == "PRIV" else DEFAULT_BODY_FORMAT_CHANNEL
|
||||
if markdown:
|
||||
default = DEFAULT_BODY_FORMAT_DM if msg_type == "PRIV" else DEFAULT_BODY_FORMAT_CHANNEL
|
||||
else:
|
||||
default = (
|
||||
DEFAULT_BODY_FORMAT_DM_PLAIN
|
||||
if msg_type == "PRIV"
|
||||
else DEFAULT_BODY_FORMAT_CHANNEL_PLAIN
|
||||
)
|
||||
return _apply_format(default, variables)
|
||||
|
||||
|
||||
def _send_sync(urls_raw: str, body: str, *, preserve_identity: bool) -> bool:
|
||||
def _send_sync(urls_raw: str, body: str, *, preserve_identity: bool, markdown: bool = True) -> bool:
|
||||
"""Send notification synchronously via Apprise. Returns True on success."""
|
||||
import apprise as apprise_lib
|
||||
from apprise import NotifyFormat
|
||||
|
||||
urls = _parse_urls(urls_raw)
|
||||
if not urls:
|
||||
@@ -159,7 +183,8 @@ def _send_sync(urls_raw: str, body: str, *, preserve_identity: bool) -> bool:
|
||||
url = _normalize_discord_url(url)
|
||||
notifier.add(url)
|
||||
|
||||
return bool(notifier.notify(title="", body=body))
|
||||
body_fmt = NotifyFormat.MARKDOWN if markdown else NotifyFormat.TEXT
|
||||
return bool(notifier.notify(title="", body=body, body_format=body_fmt))
|
||||
|
||||
|
||||
class AppriseModule(FanoutModule):
|
||||
@@ -178,6 +203,7 @@ class AppriseModule(FanoutModule):
|
||||
return
|
||||
|
||||
preserve_identity = self.config.get("preserve_identity", True)
|
||||
markdown = self.config.get("markdown_format", True)
|
||||
|
||||
# Read format strings; treat empty/whitespace as unset (use default).
|
||||
# Fall back to legacy include_path for pre-migration configs.
|
||||
@@ -186,30 +212,73 @@ class AppriseModule(FanoutModule):
|
||||
if body_format_dm is None or body_format_channel is None:
|
||||
include_path = self.config.get("include_path", True)
|
||||
if body_format_dm is None:
|
||||
body_format_dm = (
|
||||
DEFAULT_BODY_FORMAT_DM if include_path else _DEFAULT_BODY_FORMAT_DM_NO_PATH
|
||||
)
|
||||
if markdown:
|
||||
body_format_dm = (
|
||||
DEFAULT_BODY_FORMAT_DM if include_path else _DEFAULT_BODY_FORMAT_DM_NO_PATH
|
||||
)
|
||||
else:
|
||||
body_format_dm = (
|
||||
DEFAULT_BODY_FORMAT_DM_PLAIN
|
||||
if include_path
|
||||
else _DEFAULT_BODY_FORMAT_DM_NO_PATH_PLAIN
|
||||
)
|
||||
if body_format_channel is None:
|
||||
body_format_channel = (
|
||||
DEFAULT_BODY_FORMAT_CHANNEL
|
||||
if include_path
|
||||
else _DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH
|
||||
)
|
||||
if markdown:
|
||||
body_format_channel = (
|
||||
DEFAULT_BODY_FORMAT_CHANNEL
|
||||
if include_path
|
||||
else _DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH
|
||||
)
|
||||
else:
|
||||
body_format_channel = (
|
||||
DEFAULT_BODY_FORMAT_CHANNEL_PLAIN
|
||||
if include_path
|
||||
else _DEFAULT_BODY_FORMAT_CHANNEL_NO_PATH_PLAIN
|
||||
)
|
||||
|
||||
body = _format_body(
|
||||
data, body_format_dm=body_format_dm, body_format_channel=body_format_channel
|
||||
data,
|
||||
body_format_dm=body_format_dm,
|
||||
body_format_channel=body_format_channel,
|
||||
markdown=markdown,
|
||||
)
|
||||
|
||||
try:
|
||||
success = await asyncio.to_thread(
|
||||
_send_sync, urls, body, preserve_identity=preserve_identity
|
||||
)
|
||||
self._set_last_error(None if success else "Apprise notify returned failure")
|
||||
if not success:
|
||||
logger.warning("Apprise notification failed for module %s", self.config_id)
|
||||
except Exception as exc:
|
||||
self._set_last_error(str(exc))
|
||||
logger.exception("Apprise send error for module %s", self.config_id)
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(_MAX_SEND_ATTEMPTS):
|
||||
try:
|
||||
success = await asyncio.to_thread(
|
||||
_send_sync,
|
||||
urls,
|
||||
body,
|
||||
preserve_identity=preserve_identity,
|
||||
markdown=markdown,
|
||||
)
|
||||
if success:
|
||||
self._set_last_error(None)
|
||||
return
|
||||
logger.warning(
|
||||
"Apprise notification failed for module %s (attempt %d/%d)",
|
||||
self.config_id,
|
||||
attempt + 1,
|
||||
_MAX_SEND_ATTEMPTS,
|
||||
)
|
||||
except Exception as exc:
|
||||
last_exc = exc
|
||||
logger.warning(
|
||||
"Apprise send error for module %s (attempt %d/%d): %s",
|
||||
self.config_id,
|
||||
attempt + 1,
|
||||
_MAX_SEND_ATTEMPTS,
|
||||
exc,
|
||||
)
|
||||
if attempt < _MAX_SEND_ATTEMPTS - 1:
|
||||
await asyncio.sleep(_RETRY_DELAY_S)
|
||||
|
||||
# All attempts exhausted
|
||||
if last_exc is not None:
|
||||
self._set_last_error(str(last_exc))
|
||||
else:
|
||||
self._set_last_error("Apprise notify returned failure")
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
|
||||
@@ -245,7 +245,7 @@ def _get_client_version() -> str:
|
||||
class CommunityMqttPublisher(BaseMqttPublisher):
|
||||
"""Manages the community MQTT connection and publishes raw packets."""
|
||||
|
||||
_backoff_max = 60
|
||||
_backoff_max = 3600
|
||||
_log_prefix = "Community MQTT"
|
||||
_not_configured_timeout: float | None = 30
|
||||
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ class PrivateMqttSettings(Protocol):
|
||||
class MqttPublisher(BaseMqttPublisher):
|
||||
"""Manages an MQTT connection and publishes mesh network events."""
|
||||
|
||||
_backoff_max = 30
|
||||
_backoff_max = 3600
|
||||
_log_prefix = "MQTT"
|
||||
|
||||
def _is_configured(self) -> bool:
|
||||
|
||||
@@ -65,6 +65,7 @@ class BaseMqttPublisher(ABC):
|
||||
self.connected: bool = False
|
||||
self.integration_name: str = ""
|
||||
self._last_error: str | None = None
|
||||
self._error_notified: bool = False
|
||||
|
||||
def set_integration_name(self, name: str) -> None:
|
||||
"""Attach the configured fanout-module name for operator-facing logs."""
|
||||
@@ -104,6 +105,7 @@ class BaseMqttPublisher(ABC):
|
||||
self._client = None
|
||||
self.connected = False
|
||||
self._last_error = None
|
||||
self._error_notified = False
|
||||
|
||||
async def restart(self, settings: object) -> None:
|
||||
"""Called when settings change — stop + start."""
|
||||
@@ -217,6 +219,7 @@ class BaseMqttPublisher(ABC):
|
||||
self._client = client
|
||||
self.connected = True
|
||||
self._last_error = None
|
||||
self._error_notified = False
|
||||
backoff = _BACKOFF_MIN
|
||||
|
||||
title, detail = self._on_connected(settings)
|
||||
@@ -281,9 +284,11 @@ class BaseMqttPublisher(ABC):
|
||||
)
|
||||
return
|
||||
|
||||
title, detail = self._on_error()
|
||||
broadcast_error(title, detail)
|
||||
_broadcast_health()
|
||||
if not self._error_notified:
|
||||
title, detail = self._on_error()
|
||||
broadcast_error(title, detail)
|
||||
_broadcast_health()
|
||||
self._error_notified = True
|
||||
logger.warning(
|
||||
"%s connection error. This is usually transient network noise; "
|
||||
"if it self-resolves, it is generally not a concern: %s "
|
||||
|
||||
@@ -316,7 +316,7 @@ def _device_payload(
|
||||
class _HaMqttPublisher(BaseMqttPublisher):
|
||||
"""Thin MQTT lifecycle wrapper for the HA discovery module."""
|
||||
|
||||
_backoff_max = 30
|
||||
_backoff_max = 3600
|
||||
_log_prefix = "HA-MQTT"
|
||||
|
||||
def __init__(self) -> None:
|
||||
|
||||
+2
-2
@@ -176,8 +176,8 @@ app.add_middleware(
|
||||
|
||||
@app.exception_handler(RadioDisconnectedError)
|
||||
async def radio_disconnected_handler(request: Request, exc: RadioDisconnectedError):
|
||||
"""Return 503 when a radio disconnect race occurs during an operation."""
|
||||
return JSONResponse(status_code=503, content={"detail": "Radio not connected"})
|
||||
"""Return 423 when a radio disconnect race occurs during an operation."""
|
||||
return JSONResponse(status_code=423, content={"detail": "Radio not connected"})
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
|
||||
@@ -448,6 +448,8 @@ class RawPacketDecryptedInfo(BaseModel):
|
||||
sender: str | None = None
|
||||
channel_key: str | None = None
|
||||
contact_key: str | None = None
|
||||
sender_timestamp: int | None = None
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class RawPacketBroadcast(BaseModel):
|
||||
|
||||
@@ -366,6 +366,8 @@ async def process_raw_packet(
|
||||
sender=result["sender"],
|
||||
channel_key=result.get("channel_key"),
|
||||
contact_key=result.get("contact_key"),
|
||||
sender_timestamp=result.get("sender_timestamp"),
|
||||
message=result.get("message"),
|
||||
)
|
||||
if result["decrypted"]
|
||||
else None,
|
||||
@@ -428,6 +430,8 @@ async def _process_group_text(
|
||||
"sender": decrypted.sender,
|
||||
"message_id": msg_id, # None if duplicate, msg_id if new
|
||||
"channel_key": channel.key,
|
||||
"sender_timestamp": decrypted.timestamp,
|
||||
"message": decrypted.message,
|
||||
}
|
||||
|
||||
# Couldn't decrypt with any known key
|
||||
@@ -694,6 +698,8 @@ async def _process_direct_message(
|
||||
"sender": contact.name or contact.public_key[:12],
|
||||
"message_id": msg_id,
|
||||
"contact_key": contact.public_key,
|
||||
"sender_timestamp": result.timestamp,
|
||||
"message": result.message,
|
||||
}
|
||||
|
||||
# Couldn't decrypt with any known contact
|
||||
|
||||
@@ -66,11 +66,11 @@ async def _resolve_contact_or_404(
|
||||
|
||||
|
||||
async def _ensure_on_radio(mc, contact: Contact) -> None:
|
||||
"""Add a contact to the radio for routing, raising 500 on failure."""
|
||||
"""Add a contact to the radio for routing, raising 422 on failure."""
|
||||
add_result = await mc.commands.add_contact(contact.to_radio_dict())
|
||||
if add_result is not None and add_result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to add contact to radio: {add_result.payload}"
|
||||
status_code=422, detail=f"Failed to add contact to radio: {add_result.payload}"
|
||||
)
|
||||
|
||||
|
||||
@@ -452,7 +452,7 @@ async def request_trace(public_key: str) -> TraceResponse:
|
||||
)
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to send trace: {result.payload}")
|
||||
raise HTTPException(status_code=422, detail=f"Failed to send trace: {result.payload}")
|
||||
|
||||
# Wait for the matching TRACE_DATA event
|
||||
event = await mc.wait_for_event(
|
||||
@@ -462,7 +462,7 @@ async def request_trace(public_key: str) -> TraceResponse:
|
||||
)
|
||||
|
||||
if event is None:
|
||||
raise HTTPException(status_code=504, detail="No trace response heard")
|
||||
raise HTTPException(status_code=408, detail="No trace response heard")
|
||||
|
||||
trace = event.payload
|
||||
path = trace.get("path", [])
|
||||
@@ -506,7 +506,7 @@ async def request_path_discovery(public_key: str) -> PathDiscoveryResponse:
|
||||
result = await mc.commands.send_path_discovery(contact.public_key)
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
status_code=422,
|
||||
detail=f"Failed to send path discovery: {result.payload}",
|
||||
)
|
||||
|
||||
@@ -518,7 +518,7 @@ async def request_path_discovery(public_key: str) -> PathDiscoveryResponse:
|
||||
await response_task
|
||||
|
||||
if event is None:
|
||||
raise HTTPException(status_code=504, detail="No path discovery response heard")
|
||||
raise HTTPException(status_code=408, detail="No path discovery response heard")
|
||||
|
||||
payload = event.payload
|
||||
forward_path = str(payload.get("out_path") or "")
|
||||
|
||||
@@ -274,6 +274,10 @@ def _validate_apprise_config(config: dict) -> None:
|
||||
status_code=400, detail=f"Invalid format string in {field}"
|
||||
) from None
|
||||
|
||||
markdown_format = config.get("markdown_format")
|
||||
if markdown_format is not None:
|
||||
config["markdown_format"] = bool(markdown_format)
|
||||
|
||||
|
||||
def _validate_webhook_config(config: dict) -> None:
|
||||
"""Validate webhook config blob."""
|
||||
|
||||
@@ -128,11 +128,15 @@ async def get_raw_packet(packet_id: int) -> RawPacketDetail:
|
||||
sender=message.sender_name,
|
||||
channel_key=message.conversation_key,
|
||||
contact_key=message.sender_key,
|
||||
sender_timestamp=message.sender_timestamp,
|
||||
message=message.text,
|
||||
)
|
||||
else:
|
||||
decrypted_info = RawPacketDecryptedInfo(
|
||||
sender=message.sender_name,
|
||||
contact_key=message.conversation_key,
|
||||
sender_timestamp=message.sender_timestamp,
|
||||
message=message.text,
|
||||
)
|
||||
|
||||
return RawPacketDetail(
|
||||
|
||||
+5
-5
@@ -48,7 +48,7 @@ async def vapid_public_key() -> VapidPublicKeyResponse:
|
||||
"""Return the VAPID public key for browser PushManager.subscribe()."""
|
||||
key = get_vapid_public_key()
|
||||
if not key:
|
||||
raise HTTPException(status_code=503, detail="VAPID keys not initialized")
|
||||
raise HTTPException(status_code=423, detail="VAPID keys not initialized")
|
||||
return VapidPublicKeyResponse(public_key=key)
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ async def test_push(subscription_id: str) -> dict:
|
||||
|
||||
vapid_key = get_vapid_private_key()
|
||||
if not vapid_key:
|
||||
raise HTTPException(status_code=503, detail="VAPID keys not initialized")
|
||||
raise HTTPException(status_code=423, detail="VAPID keys not initialized")
|
||||
|
||||
payload = json.dumps(
|
||||
{
|
||||
@@ -127,7 +127,7 @@ async def test_push(subscription_id: str) -> dict:
|
||||
)
|
||||
return {"status": "sent"}
|
||||
except TimeoutError:
|
||||
raise HTTPException(status_code=504, detail="Push delivery timed out") from None
|
||||
raise HTTPException(status_code=408, detail="Push delivery timed out") from None
|
||||
except WebPushException as e:
|
||||
status_code = getattr(getattr(e, "response", None), "status_code", 0)
|
||||
if status_code in (403, 404, 410):
|
||||
@@ -143,10 +143,10 @@ async def test_push(subscription_id: str) -> dict:
|
||||
"Re-enable push from a conversation header.",
|
||||
) from None
|
||||
logger.warning("Test push failed: %s", e)
|
||||
raise HTTPException(status_code=502, detail=f"Push delivery failed: {e}") from None
|
||||
raise HTTPException(status_code=422, detail=f"Push delivery failed: {e}") from None
|
||||
except Exception as e:
|
||||
logger.warning("Test push failed: %s", e)
|
||||
raise HTTPException(status_code=502, detail=f"Push delivery failed: {e}") from None
|
||||
raise HTTPException(status_code=422, detail=f"Push delivery failed: {e}") from None
|
||||
|
||||
|
||||
# ── Global push conversation management ──────────────────────────────────
|
||||
|
||||
+15
-15
@@ -338,7 +338,7 @@ async def get_radio_config() -> RadioConfigResponse:
|
||||
|
||||
info = mc.self_info
|
||||
if not info:
|
||||
raise HTTPException(status_code=503, detail="Radio info not available")
|
||||
raise HTTPException(status_code=423, detail="Radio info not available")
|
||||
|
||||
adv_loc_policy = info.get("adv_loc_policy", 1)
|
||||
advert_location_source: AdvertLocationSource = "off" if adv_loc_policy == 0 else "current"
|
||||
@@ -380,7 +380,7 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
|
||||
except PathHashModeUnsupportedError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except RadioCommandRejectedError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
return await get_radio_config()
|
||||
|
||||
@@ -430,7 +430,7 @@ async def set_private_key(update: PrivateKeyUpdate) -> dict:
|
||||
export_and_store_private_key_fn=export_and_store_private_key,
|
||||
)
|
||||
except (RadioCommandRejectedError, KeystoreRefreshError) as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -454,7 +454,7 @@ async def send_advertisement(request: RadioAdvertiseRequest | None = None) -> di
|
||||
success = await do_send_advertisement(mc, force=True, mode=mode)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to send {mode} advertisement")
|
||||
raise HTTPException(status_code=422, detail=f"Failed to send {mode} advertisement")
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -486,7 +486,7 @@ async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryRespons
|
||||
tag=tag,
|
||||
)
|
||||
if send_result is None or send_result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail="Failed to start mesh discovery")
|
||||
raise HTTPException(status_code=422, detail="Failed to start mesh discovery")
|
||||
|
||||
deadline = _monotonic() + DISCOVERY_WINDOW_SECONDS
|
||||
results_by_key: dict[str, RadioDiscoveryResult] = {}
|
||||
@@ -538,7 +538,7 @@ async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse:
|
||||
async with radio_manager.radio_operation("radio_trace", pause_polling=True) as mc:
|
||||
local_public_key = str((mc.self_info or {}).get("public_key") or "").lower()
|
||||
if len(local_public_key) != 64:
|
||||
raise HTTPException(status_code=503, detail="Local radio public key is unavailable")
|
||||
raise HTTPException(status_code=423, detail="Local radio public key is unavailable")
|
||||
local_name = (mc.self_info or {}).get("name")
|
||||
|
||||
response_task = asyncio.create_task(
|
||||
@@ -555,13 +555,13 @@ async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse:
|
||||
flags=trace_flags,
|
||||
)
|
||||
if send_result is None or send_result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail="Failed to send trace")
|
||||
raise HTTPException(status_code=422, detail="Failed to send trace")
|
||||
|
||||
timeout_seconds = _trace_timeout_seconds(send_result)
|
||||
try:
|
||||
event = await asyncio.wait_for(response_task, timeout=timeout_seconds)
|
||||
except TimeoutError as exc:
|
||||
raise HTTPException(status_code=504, detail="No trace response heard") from exc
|
||||
raise HTTPException(status_code=408, detail="No trace response heard") from exc
|
||||
finally:
|
||||
if not response_task.done():
|
||||
response_task.cancel()
|
||||
@@ -569,12 +569,12 @@ async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse:
|
||||
await response_task
|
||||
|
||||
if event is None:
|
||||
raise HTTPException(status_code=504, detail="No trace response heard")
|
||||
raise HTTPException(status_code=408, detail="No trace response heard")
|
||||
|
||||
payload = event.payload if isinstance(event.payload, dict) else {}
|
||||
path_len = payload.get("path_len")
|
||||
if not isinstance(path_len, int):
|
||||
raise HTTPException(status_code=500, detail="Trace response was malformed")
|
||||
raise HTTPException(status_code=422, detail="Trace response was malformed")
|
||||
|
||||
raw_path = payload.get("path")
|
||||
path_nodes = raw_path if isinstance(raw_path, list) else []
|
||||
@@ -588,7 +588,7 @@ async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse:
|
||||
hashed_nodes = path_nodes[:-1] if final_local_node is not None else path_nodes
|
||||
|
||||
if len(hashed_nodes) < len(trace_nodes):
|
||||
raise HTTPException(status_code=500, detail="Trace response was incomplete")
|
||||
raise HTTPException(status_code=422, detail="Trace response was incomplete")
|
||||
|
||||
nodes: list[RadioTraceNode] = []
|
||||
for index, trace_node in enumerate(trace_nodes):
|
||||
@@ -641,13 +641,13 @@ async def _attempt_reconnect() -> dict:
|
||||
except Exception as e:
|
||||
logger.exception("Post-connect setup failed after reconnect")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
status_code=423,
|
||||
detail=f"Radio connected but setup failed: {e}",
|
||||
) from e
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Failed to reconnect. Check radio connection and power."
|
||||
status_code=423, detail="Failed to reconnect. Check radio connection and power."
|
||||
)
|
||||
|
||||
return {"status": "ok", "message": "Reconnected successfully", "connected": True}
|
||||
@@ -702,14 +702,14 @@ async def reconnect_radio() -> dict:
|
||||
logger.info("Radio connected but setup incomplete, retrying setup")
|
||||
try:
|
||||
if not await _prepare_connected(broadcast_on_success=True):
|
||||
raise HTTPException(status_code=503, detail="Radio connection is paused")
|
||||
raise HTTPException(status_code=423, detail="Radio connection is paused")
|
||||
return {"status": "ok", "message": "Setup completed", "connected": True}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("Post-connect setup failed")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
status_code=423,
|
||||
detail=f"Radio connected but setup failed: {e}",
|
||||
) from e
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
|
||||
logger.debug("LPP sensor fetch failed for %s (non-fatal): %s", public_key[:12], e)
|
||||
|
||||
if status is None:
|
||||
raise HTTPException(status_code=504, detail="No status response from repeater")
|
||||
raise HTTPException(status_code=408, detail="No status response from repeater")
|
||||
|
||||
response = RepeaterStatusResponse(
|
||||
battery_volts=status.get("bat", 0) / 1000.0,
|
||||
@@ -222,7 +222,7 @@ async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryRespons
|
||||
)
|
||||
|
||||
if telemetry is None:
|
||||
raise HTTPException(status_code=504, detail="No telemetry response from repeater")
|
||||
raise HTTPException(status_code=408, detail="No telemetry response from repeater")
|
||||
|
||||
sensors: list[LppSensor] = []
|
||||
for entry in telemetry:
|
||||
|
||||
@@ -58,7 +58,7 @@ async def room_status(public_key: str) -> RepeaterStatusResponse:
|
||||
status = await mc.commands.req_status_sync(contact.public_key, timeout=10, min_timeout=5)
|
||||
|
||||
if status is None:
|
||||
raise HTTPException(status_code=504, detail="No status response from room server")
|
||||
raise HTTPException(status_code=408, detail="No status response from room server")
|
||||
|
||||
return RepeaterStatusResponse(
|
||||
battery_volts=status.get("bat", 0) / 1000.0,
|
||||
@@ -98,7 +98,7 @@ async def room_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
|
||||
)
|
||||
|
||||
if telemetry is None:
|
||||
raise HTTPException(status_code=504, detail="No telemetry response from room server")
|
||||
raise HTTPException(status_code=408, detail="No telemetry response from room server")
|
||||
|
||||
sensors = [
|
||||
LppSensor(
|
||||
|
||||
@@ -291,7 +291,7 @@ async def send_contact_cli_command(
|
||||
|
||||
if send_result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to send command: {send_result.payload}"
|
||||
status_code=422, detail=f"Failed to send command: {send_result.payload}"
|
||||
)
|
||||
|
||||
response_event = await fetch_contact_cli_response(mc, contact.public_key[:12])
|
||||
|
||||
@@ -159,7 +159,7 @@ async def send_channel_message_with_effective_scope(
|
||||
override_result.payload,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
status_code=422,
|
||||
detail=(
|
||||
f"Failed to apply regional override {override_scope!r} before {action_label}: "
|
||||
f"{override_result.payload}"
|
||||
@@ -189,7 +189,7 @@ async def send_channel_message_with_effective_scope(
|
||||
phm_result.payload,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
status_code=422,
|
||||
detail=(
|
||||
f"Failed to apply path hash mode override before {action_label}: "
|
||||
f"{phm_result.payload}"
|
||||
@@ -233,7 +233,7 @@ async def send_channel_message_with_effective_scope(
|
||||
set_result.payload,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
status_code=422,
|
||||
detail=f"Failed to configure channel on radio before {action_label}",
|
||||
)
|
||||
radio_manager.note_channel_slot_loaded(channel_key, channel_slot)
|
||||
@@ -256,7 +256,7 @@ async def send_channel_message_with_effective_scope(
|
||||
action_label,
|
||||
channel.name,
|
||||
)
|
||||
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
|
||||
raise HTTPException(status_code=408, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
|
||||
if send_result.type == EventType.ERROR:
|
||||
logger.error(
|
||||
"Radio returned error during %s for channel %s: %s",
|
||||
@@ -598,10 +598,10 @@ async def send_direct_message_to_contact(
|
||||
"No response from radio after direct send to %s; send outcome is unknown",
|
||||
contact.public_key[:12],
|
||||
)
|
||||
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
|
||||
raise HTTPException(status_code=408, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}")
|
||||
raise HTTPException(status_code=422, detail=f"Failed to send message: {result.payload}")
|
||||
|
||||
message = await create_outgoing_direct_message(
|
||||
conversation_key=contact.public_key.lower(),
|
||||
@@ -613,7 +613,7 @@ async def send_direct_message_to_contact(
|
||||
)
|
||||
if message is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
status_code=422,
|
||||
detail="Failed to store outgoing message - unexpected duplicate",
|
||||
)
|
||||
finally:
|
||||
@@ -626,7 +626,7 @@ async def send_direct_message_to_contact(
|
||||
)
|
||||
|
||||
if sent_at is None or sender_timestamp is None or message is None or result is None:
|
||||
raise HTTPException(status_code=500, detail="Failed to store outgoing message")
|
||||
raise HTTPException(status_code=422, detail="Failed to store outgoing message")
|
||||
|
||||
await contact_repository.update_last_contacted(contact.public_key.lower(), sent_at)
|
||||
|
||||
@@ -791,7 +791,7 @@ async def send_channel_message_to_channel(
|
||||
)
|
||||
if outgoing_message is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
status_code=422,
|
||||
detail="Failed to store outgoing message - unexpected duplicate",
|
||||
)
|
||||
|
||||
@@ -813,11 +813,11 @@ async def send_channel_message_to_channel(
|
||||
"No response from radio after channel send to %s; send outcome is unknown",
|
||||
channel.name,
|
||||
)
|
||||
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
|
||||
raise HTTPException(status_code=408, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to send message: {result.payload}"
|
||||
status_code=422, detail=f"Failed to send message: {result.payload}"
|
||||
)
|
||||
except Exception:
|
||||
if outgoing_message is not None:
|
||||
@@ -834,7 +834,7 @@ async def send_channel_message_to_channel(
|
||||
)
|
||||
|
||||
if sent_at is None or sender_timestamp is None or outgoing_message is None:
|
||||
raise HTTPException(status_code=500, detail="Failed to store outgoing message")
|
||||
raise HTTPException(status_code=422, detail="Failed to store outgoing message")
|
||||
|
||||
outgoing_message = await build_stored_outgoing_channel_message(
|
||||
message_id=outgoing_message.id,
|
||||
@@ -928,7 +928,7 @@ async def resend_channel_message_record(
|
||||
)
|
||||
if new_message is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
status_code=422,
|
||||
detail="Failed to store resent message - unexpected duplicate",
|
||||
)
|
||||
|
||||
@@ -949,10 +949,10 @@ async def resend_channel_message_record(
|
||||
"No response from radio after channel resend to %s; send outcome is unknown",
|
||||
channel.name,
|
||||
)
|
||||
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
|
||||
raise HTTPException(status_code=408, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
status_code=422,
|
||||
detail=f"Failed to resend message: {result.payload}",
|
||||
)
|
||||
except Exception:
|
||||
@@ -971,7 +971,7 @@ async def resend_channel_message_record(
|
||||
|
||||
if new_timestamp:
|
||||
if sent_at is None or new_message is None:
|
||||
raise HTTPException(status_code=500, detail="Failed to assign resend timestamp")
|
||||
raise HTTPException(status_code=422, detail="Failed to assign resend timestamp")
|
||||
|
||||
new_message = await build_stored_outgoing_channel_message(
|
||||
message_id=new_message.id,
|
||||
|
||||
@@ -52,12 +52,12 @@ class RadioRuntime:
|
||||
def require_connected(self):
|
||||
"""Return MeshCore when available, mirroring existing HTTP semantics."""
|
||||
if self.is_setup_in_progress:
|
||||
raise HTTPException(status_code=503, detail="Radio is initializing")
|
||||
raise HTTPException(status_code=423, detail="Radio is initializing")
|
||||
if not self.is_connected:
|
||||
raise HTTPException(status_code=503, detail="Radio not connected")
|
||||
raise HTTPException(status_code=423, detail="Radio not connected")
|
||||
mc = self.meshcore
|
||||
if mc is None:
|
||||
raise HTTPException(status_code=503, detail="Radio not connected")
|
||||
raise HTTPException(status_code=423, detail="Radio not connected")
|
||||
return mc
|
||||
|
||||
@asynccontextmanager
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"private": true,
|
||||
"version": "3.12.3",
|
||||
"version": "3.13.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -4,12 +4,12 @@ import {
|
||||
useImperativeHandle,
|
||||
forwardRef,
|
||||
useRef,
|
||||
useEffect,
|
||||
useMemo,
|
||||
type ChangeEvent,
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
} from 'react';
|
||||
import { Input } from './ui/input';
|
||||
import { Button } from './ui/button';
|
||||
import { toast } from './ui/sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -59,19 +59,32 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
||||
) {
|
||||
const [text, setText] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
/** Resize textarea to fit content, clamped between 1 row and ~6 rows. */
|
||||
const autoResize = useCallback(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
// Clamp: min 40px (≈1 row), max 160px (≈6 rows)
|
||||
el.style.height = `${Math.min(el.scrollHeight, 160)}px`;
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
appendText: (appendedText: string) => {
|
||||
setText((prev) => prev + appendedText);
|
||||
// Focus the input after appending
|
||||
inputRef.current?.focus();
|
||||
textareaRef.current?.focus();
|
||||
},
|
||||
focus: () => {
|
||||
inputRef.current?.focus();
|
||||
textareaRef.current?.focus();
|
||||
},
|
||||
}));
|
||||
|
||||
// Re-measure height whenever text changes (covers programmatic updates like appendText)
|
||||
useEffect(() => {
|
||||
autoResize();
|
||||
}, [text, autoResize]);
|
||||
|
||||
// Calculate character limits based on conversation type
|
||||
const limits = useMemo(() => {
|
||||
if (conversationType === 'contact') {
|
||||
@@ -139,13 +152,13 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
// Refocus after React re-enables the input
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
// Refocus after React re-enables the textarea
|
||||
setTimeout(() => textareaRef.current?.focus(), 0);
|
||||
},
|
||||
[text, sending, disabled, onSend]
|
||||
);
|
||||
|
||||
const handleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||
const handleChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const input = e.target;
|
||||
const raw = input.value;
|
||||
// Skip replacement during IME / dead-key composition to avoid garbling interim input
|
||||
@@ -171,11 +184,12 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e as unknown as FormEvent);
|
||||
}
|
||||
// Shift+Enter falls through naturally and inserts a newline
|
||||
},
|
||||
[handleSubmit]
|
||||
);
|
||||
@@ -193,22 +207,28 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
||||
onSubmit={handleSubmit}
|
||||
autoComplete="off"
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
<div className="flex gap-2 items-end">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
autoComplete="off"
|
||||
name="chat-message-input"
|
||||
aria-label={placeholder || 'Type a message'}
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
data-bwignore="true"
|
||||
rows={1}
|
||||
value={text}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder || 'Type a message...'}
|
||||
disabled={disabled || sending}
|
||||
className="flex-1 min-w-0"
|
||||
className={cn(
|
||||
'flex-1 min-w-0 resize-none overflow-y-auto',
|
||||
'rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background',
|
||||
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50 md:text-sm'
|
||||
)}
|
||||
style={{ minHeight: '40px', maxHeight: '160px' }}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
|
||||
@@ -287,6 +287,7 @@ const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [
|
||||
config: {
|
||||
urls: '',
|
||||
preserve_identity: true,
|
||||
markdown_format: true,
|
||||
body_format_dm: '**DM:** {sender_name}: {text} **via:** [{hops_backticked}]',
|
||||
body_format_channel:
|
||||
'**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]',
|
||||
@@ -2390,6 +2391,8 @@ function ScopeSelector({
|
||||
const APPRISE_DEFAULT_DM = '**DM:** {sender_name}: {text} **via:** [{hops_backticked}]';
|
||||
const APPRISE_DEFAULT_CHANNEL =
|
||||
'**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]';
|
||||
const APPRISE_DEFAULT_DM_PLAIN = 'DM: {sender_name}: {text} via: [{hops}]';
|
||||
const APPRISE_DEFAULT_CHANNEL_PLAIN = '{channel_name}: {sender_name}: {text} via: [{hops}]';
|
||||
|
||||
const APPRISE_SAMPLE_VARS: Record<string, string> = {
|
||||
type: 'CHAN',
|
||||
@@ -2420,19 +2423,32 @@ function appriseApplyFormat(fmt: string, vars: Record<string, string>): string {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Render a markdown-ish string into inline React elements (bold + code spans). */
|
||||
/** Render a markdown-ish string into inline React elements (bold, italic, code). */
|
||||
function appriseRenderMarkdown(s: string): ReactNode[] {
|
||||
const nodes: ReactNode[] = [];
|
||||
let key = 0;
|
||||
// Split on **bold** and `code` spans
|
||||
const parts = s.split(/(\*\*[^*]+\*\*|`[^`]+`)/g);
|
||||
// Split on **bold**, __bold__, *italic*, _italic_, and `code` spans.
|
||||
// Longer delimiters first so ** and __ match before * and _.
|
||||
const parts = s.split(/(\*\*[^*]+\*\*|__[^_]+__|`[^`]+`|\*[^*]+\*|_[^_]+_)/g);
|
||||
for (const part of parts) {
|
||||
if (part.startsWith('**') && part.endsWith('**')) {
|
||||
if (
|
||||
(part.startsWith('**') && part.endsWith('**')) ||
|
||||
(part.startsWith('__') && part.endsWith('__'))
|
||||
) {
|
||||
nodes.push(
|
||||
<strong key={key++} className="font-bold">
|
||||
{part.slice(2, -2)}
|
||||
</strong>
|
||||
);
|
||||
} else if (
|
||||
(part.startsWith('*') && part.endsWith('*')) ||
|
||||
(part.startsWith('_') && part.endsWith('_'))
|
||||
) {
|
||||
nodes.push(
|
||||
<em key={key++} className="italic">
|
||||
{part.slice(1, -1)}
|
||||
</em>
|
||||
);
|
||||
} else if (part.startsWith('`') && part.endsWith('`')) {
|
||||
nodes.push(
|
||||
<code key={key++} className="rounded bg-muted px-1 py-0.5 text-[0.6875rem] font-mono">
|
||||
@@ -2446,19 +2462,29 @@ function appriseRenderMarkdown(s: string): ReactNode[] {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function AppriseFormatPreview({ format, vars }: { format: string; vars: Record<string, string> }) {
|
||||
function AppriseFormatPreview({
|
||||
format,
|
||||
vars,
|
||||
markdown = true,
|
||||
}: {
|
||||
format: string;
|
||||
vars: Record<string, string>;
|
||||
markdown?: boolean;
|
||||
}) {
|
||||
const raw = appriseApplyFormat(format, vars);
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-muted/30 p-2 space-y-1.5">
|
||||
{markdown && (
|
||||
<div>
|
||||
<span className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Rendered (Discord, Slack, Telegram)
|
||||
</span>
|
||||
<p className="text-xs break-all">{appriseRenderMarkdown(raw)}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Rendered (Discord, Slack)
|
||||
</span>
|
||||
<p className="text-xs break-all">{appriseRenderMarkdown(raw)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Raw (Telegram, email)
|
||||
{markdown ? 'Raw (email, SMS)' : 'Preview'}
|
||||
</span>
|
||||
<p className="text-xs font-mono break-all text-muted-foreground">{raw}</p>
|
||||
</div>
|
||||
@@ -2483,9 +2509,11 @@ function AppriseConfigEditor({
|
||||
onChange: (config: Record<string, unknown>) => void;
|
||||
onScopeChange: (scope: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const dmFormat = ((config.body_format_dm as string) || '').trim() || APPRISE_DEFAULT_DM;
|
||||
const chanFormat =
|
||||
((config.body_format_channel as string) || '').trim() || APPRISE_DEFAULT_CHANNEL;
|
||||
const markdown = config.markdown_format !== false;
|
||||
const defaultDm = markdown ? APPRISE_DEFAULT_DM : APPRISE_DEFAULT_DM_PLAIN;
|
||||
const defaultChan = markdown ? APPRISE_DEFAULT_CHANNEL : APPRISE_DEFAULT_CHANNEL_PLAIN;
|
||||
const dmFormat = ((config.body_format_dm as string) || '').trim() || defaultDm;
|
||||
const chanFormat = ((config.body_format_channel as string) || '').trim() || defaultChan;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -2549,6 +2577,39 @@ function AppriseConfigEditor({
|
||||
|
||||
<h3 className="text-base font-semibold tracking-tight">Message Format</h3>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={markdown}
|
||||
onChange={(e) => {
|
||||
const md = e.target.checked;
|
||||
const updates: Record<string, unknown> = { ...config, markdown_format: md };
|
||||
const curDm = ((config.body_format_dm as string) || '').trim();
|
||||
const curChan = ((config.body_format_channel as string) || '').trim();
|
||||
if (md) {
|
||||
if (!curDm || curDm === APPRISE_DEFAULT_DM_PLAIN)
|
||||
updates.body_format_dm = APPRISE_DEFAULT_DM;
|
||||
if (!curChan || curChan === APPRISE_DEFAULT_CHANNEL_PLAIN)
|
||||
updates.body_format_channel = APPRISE_DEFAULT_CHANNEL;
|
||||
} else {
|
||||
if (!curDm || curDm === APPRISE_DEFAULT_DM)
|
||||
updates.body_format_dm = APPRISE_DEFAULT_DM_PLAIN;
|
||||
if (!curChan || curChan === APPRISE_DEFAULT_CHANNEL)
|
||||
updates.body_format_channel = APPRISE_DEFAULT_CHANNEL_PLAIN;
|
||||
}
|
||||
onChange(updates);
|
||||
}}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm">Markdown formatting</span>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
If notifications fail on services like Telegram due to special characters in sender
|
||||
names, disable this option.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<details className="group">
|
||||
<summary className="text-sm font-medium text-foreground cursor-pointer select-none flex items-center gap-1">
|
||||
<ChevronDown className="h-3 w-3 transition-transform group-open:rotate-0 -rotate-90" />
|
||||
@@ -2604,12 +2665,12 @@ function AppriseConfigEditor({
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="fanout-apprise-fmt-dm">DM format</Label>
|
||||
{!appriseIsDefault(config.body_format_dm, APPRISE_DEFAULT_DM) && (
|
||||
{!appriseIsDefault(config.body_format_dm, defaultDm) && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Reset DM format to default"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => onChange({ ...config, body_format_dm: APPRISE_DEFAULT_DM })}
|
||||
onClick={() => onChange({ ...config, body_format_dm: defaultDm })}
|
||||
>
|
||||
Reset to default
|
||||
</button>
|
||||
@@ -2618,23 +2679,23 @@ function AppriseConfigEditor({
|
||||
<textarea
|
||||
id="fanout-apprise-fmt-dm"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono min-h-[56px]"
|
||||
placeholder={APPRISE_DEFAULT_DM}
|
||||
placeholder={defaultDm}
|
||||
value={(config.body_format_dm as string) ?? ''}
|
||||
onChange={(e) => onChange({ ...config, body_format_dm: e.target.value })}
|
||||
rows={2}
|
||||
/>
|
||||
<AppriseFormatPreview format={dmFormat} vars={APPRISE_SAMPLE_VARS_DM} />
|
||||
<AppriseFormatPreview format={dmFormat} vars={APPRISE_SAMPLE_VARS_DM} markdown={markdown} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="fanout-apprise-fmt-chan">Channel format</Label>
|
||||
{!appriseIsDefault(config.body_format_channel, APPRISE_DEFAULT_CHANNEL) && (
|
||||
{!appriseIsDefault(config.body_format_channel, defaultChan) && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Reset channel format to default"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => onChange({ ...config, body_format_channel: APPRISE_DEFAULT_CHANNEL })}
|
||||
onClick={() => onChange({ ...config, body_format_channel: defaultChan })}
|
||||
>
|
||||
Reset to default
|
||||
</button>
|
||||
@@ -2643,12 +2704,12 @@ function AppriseConfigEditor({
|
||||
<textarea
|
||||
id="fanout-apprise-fmt-chan"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono min-h-[56px]"
|
||||
placeholder={APPRISE_DEFAULT_CHANNEL}
|
||||
placeholder={defaultChan}
|
||||
value={(config.body_format_channel as string) ?? ''}
|
||||
onChange={(e) => onChange({ ...config, body_format_channel: e.target.value })}
|
||||
rows={2}
|
||||
/>
|
||||
<AppriseFormatPreview format={chanFormat} vars={APPRISE_SAMPLE_VARS} />
|
||||
<AppriseFormatPreview format={chanFormat} vars={APPRISE_SAMPLE_VARS} markdown={markdown} />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
html {
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('MessageInput', () => {
|
||||
}
|
||||
|
||||
function getInput() {
|
||||
return screen.getByPlaceholderText('Type a message...') as HTMLInputElement;
|
||||
return screen.getByPlaceholderText('Type a message...') as HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
function getSendButton() {
|
||||
|
||||
@@ -94,6 +94,8 @@ describe('buildRawPacketStatsSnapshot', () => {
|
||||
sender: 'Alpha',
|
||||
channel_key: null,
|
||||
contact_key: '0a'.repeat(32),
|
||||
sender_timestamp: null,
|
||||
message: null,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -145,7 +147,9 @@ describe('buildRawPacketStatsSnapshot', () => {
|
||||
'2-5',
|
||||
'6-10',
|
||||
'11-15',
|
||||
'16+',
|
||||
'16-20',
|
||||
'21-31',
|
||||
'32+',
|
||||
]);
|
||||
expect(stats.hopProfile).toEqual(
|
||||
expect.arrayContaining([
|
||||
@@ -154,7 +158,9 @@ describe('buildRawPacketStatsSnapshot', () => {
|
||||
expect.objectContaining({ label: '2-5', count: 1 }),
|
||||
expect.objectContaining({ label: '6-10', count: 0 }),
|
||||
expect.objectContaining({ label: '11-15', count: 0 }),
|
||||
expect.objectContaining({ label: '16+', count: 0 }),
|
||||
expect.objectContaining({ label: '16-20', count: 0 }),
|
||||
expect.objectContaining({ label: '21-31', count: 0 }),
|
||||
expect.objectContaining({ label: '32+', count: 0 }),
|
||||
])
|
||||
);
|
||||
expect(stats.hopByteWidthProfile).toEqual(
|
||||
|
||||
@@ -343,6 +343,8 @@ export interface RawPacket {
|
||||
sender: string | null;
|
||||
channel_key: string | null;
|
||||
contact_key: string | null;
|
||||
sender_timestamp: number | null;
|
||||
message: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -324,51 +324,56 @@ export function inspectRawPacketWithOptions(
|
||||
createPacketField('payload', `payload-${index}`, segment, structure.payload.startByte)
|
||||
);
|
||||
|
||||
const enrichedPayloadFields =
|
||||
decoded?.isValid && decoded.payloadType === PayloadType.GroupText && decoded.payload.decoded
|
||||
? payloadFields.map((field) => {
|
||||
if (field.name !== 'Ciphertext') {
|
||||
return field;
|
||||
}
|
||||
const payload = decoded.payload.decoded as {
|
||||
decrypted?: { timestamp?: number; flags?: number; sender?: string; message?: string };
|
||||
};
|
||||
if (!payload.decrypted?.message) {
|
||||
return field;
|
||||
}
|
||||
const detailLines = [
|
||||
payload.decrypted.timestamp != null
|
||||
? `Timestamp: ${formatUnixTimestamp(payload.decrypted.timestamp)}`
|
||||
: null,
|
||||
payload.decrypted.flags != null
|
||||
? `Flags: 0x${payload.decrypted.flags.toString(16).padStart(2, '0')}`
|
||||
: null,
|
||||
payload.decrypted.sender ? `Sender: ${payload.decrypted.sender}` : null,
|
||||
`Message: ${payload.decrypted.message}`,
|
||||
].filter((line): line is string => line !== null);
|
||||
return {
|
||||
...field,
|
||||
description: describeCiphertextStructure(
|
||||
decoded.payloadType,
|
||||
field.endByte - field.startByte + 1,
|
||||
field.description
|
||||
),
|
||||
decryptedMessage: detailLines.join('\n'),
|
||||
};
|
||||
})
|
||||
: payloadFields.map((field) => {
|
||||
if (!decoded?.isValid || field.name !== 'Ciphertext') {
|
||||
return field;
|
||||
}
|
||||
return {
|
||||
...field,
|
||||
description: describeCiphertextStructure(
|
||||
decoded.payloadType,
|
||||
field.endByte - field.startByte + 1,
|
||||
field.description
|
||||
),
|
||||
};
|
||||
});
|
||||
const enrichedPayloadFields = payloadFields.map((field) => {
|
||||
if (!decoded?.isValid || field.name !== 'Ciphertext') {
|
||||
return field;
|
||||
}
|
||||
|
||||
const withStructure = {
|
||||
...field,
|
||||
description: describeCiphertextStructure(
|
||||
decoded.payloadType,
|
||||
field.endByte - field.startByte + 1,
|
||||
field.description
|
||||
),
|
||||
};
|
||||
|
||||
// GroupText: client-side decoder has the decrypted content
|
||||
if (decoded.payloadType === PayloadType.GroupText && decoded.payload.decoded) {
|
||||
const payload = decoded.payload.decoded as {
|
||||
decrypted?: { timestamp?: number; flags?: number; sender?: string; message?: string };
|
||||
};
|
||||
if (!payload.decrypted?.message) {
|
||||
return withStructure;
|
||||
}
|
||||
const detailLines = [
|
||||
payload.decrypted.timestamp != null
|
||||
? `Sent (packet): ${formatUnixTimestamp(payload.decrypted.timestamp)}`
|
||||
: null,
|
||||
payload.decrypted.flags != null
|
||||
? `Flags: 0x${payload.decrypted.flags.toString(16).padStart(2, '0')}`
|
||||
: null,
|
||||
payload.decrypted.sender ? `Sender: ${payload.decrypted.sender}` : null,
|
||||
`Message: ${payload.decrypted.message}`,
|
||||
].filter((line): line is string => line !== null);
|
||||
return { ...withStructure, decryptedMessage: detailLines.join('\n') };
|
||||
}
|
||||
|
||||
// TextMessage (DM): server-side decryption via decrypted_info
|
||||
if (decoded.payloadType === PayloadType.TextMessage && packet.decrypted_info?.message) {
|
||||
const info = packet.decrypted_info;
|
||||
const detailLines = [
|
||||
info.sender_timestamp != null
|
||||
? `Sent (packet): ${formatUnixTimestamp(info.sender_timestamp)}`
|
||||
: null,
|
||||
info.sender ? `Sender: ${info.sender}` : null,
|
||||
`Message: ${info.message}`,
|
||||
].filter((line): line is string => line !== null);
|
||||
return { ...withStructure, decryptedMessage: detailLines.join('\n') };
|
||||
}
|
||||
|
||||
return withStructure;
|
||||
});
|
||||
|
||||
return {
|
||||
decoded,
|
||||
|
||||
@@ -322,7 +322,13 @@ function getHopProfileBucket(pathTokenCount: number): string {
|
||||
if (pathTokenCount <= 15) {
|
||||
return '11-15';
|
||||
}
|
||||
return '16+';
|
||||
if (pathTokenCount <= 20) {
|
||||
return '16-20';
|
||||
}
|
||||
if (pathTokenCount <= 31) {
|
||||
return '21-31';
|
||||
}
|
||||
return '32+';
|
||||
}
|
||||
|
||||
export function buildRawPacketStatsSnapshot(
|
||||
@@ -354,7 +360,9 @@ export function buildRawPacketStatsSnapshot(
|
||||
['2-5', 0],
|
||||
['6-10', 0],
|
||||
['11-15', 0],
|
||||
['16+', 0],
|
||||
['16-20', 0],
|
||||
['21-31', 0],
|
||||
['32+', 0],
|
||||
]);
|
||||
const hopByteWidthCounts = new Map<string, number>([
|
||||
['No path', 0],
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "remoteterm-meshcore"
|
||||
version = "3.12.3"
|
||||
version = "3.13.0"
|
||||
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -23,8 +23,9 @@ test.describe('Channel messaging in #flightless', () => {
|
||||
// Send it
|
||||
await page.getByRole('button', { name: 'Send', exact: true }).click();
|
||||
|
||||
// Verify message appears in the message list
|
||||
await expect(page.getByText(testMessage)).toBeVisible({ timeout: 15_000 });
|
||||
// Verify message appears in the message list (use locator('span') to avoid
|
||||
// matching the textarea which may briefly retain the sent text)
|
||||
await expect(page.locator('span', { hasText: testMessage })).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('outgoing message shows ack indicator', async ({ page }) => {
|
||||
@@ -37,8 +38,8 @@ test.describe('Channel messaging in #flightless', () => {
|
||||
await input.fill(testMessage);
|
||||
await page.getByRole('button', { name: 'Send', exact: true }).click();
|
||||
|
||||
// Wait for the message to appear
|
||||
const messageEl = page.getByText(testMessage);
|
||||
// Wait for the message to appear in the message list
|
||||
const messageEl = page.locator('span', { hasText: testMessage });
|
||||
await expect(messageEl).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Outgoing messages show either "?" (pending) or "✓" (acked)
|
||||
@@ -58,7 +59,7 @@ test.describe('Channel messaging in #flightless', () => {
|
||||
await input.fill(testMessage);
|
||||
await page.getByRole('button', { name: 'Send', exact: true }).click();
|
||||
|
||||
const messageEl = page.getByText(testMessage).first();
|
||||
const messageEl = page.locator('span', { hasText: testMessage }).first();
|
||||
await expect(messageEl).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const messageContainer = messageEl.locator(
|
||||
@@ -94,6 +95,6 @@ test.describe('Channel messaging in #flightless', () => {
|
||||
await expect(page.getByText('Message resent')).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Byte-perfect resend should not create a second visible row in this conversation.
|
||||
await expect(page.getByText(testMessage)).toHaveCount(1);
|
||||
await expect(page.locator('span', { hasText: testMessage })).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
|
||||
+17
-17
@@ -50,7 +50,7 @@ def _patch_require_connected(mc=None, *, detail="Radio not connected"):
|
||||
if mc is None:
|
||||
return patch(
|
||||
"app.services.radio_runtime.radio_runtime.require_connected",
|
||||
side_effect=HTTPException(status_code=503, detail=detail),
|
||||
side_effect=HTTPException(status_code=423, detail=detail),
|
||||
)
|
||||
return patch("app.services.radio_runtime.radio_runtime.require_connected", return_value=mc)
|
||||
|
||||
@@ -422,11 +422,11 @@ class TestDebugEndpoint:
|
||||
|
||||
|
||||
class TestRadioDisconnectedHandler:
|
||||
"""Test that RadioDisconnectedError maps to 503."""
|
||||
"""Test that RadioDisconnectedError maps to 423."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_race_returns_503(self, test_db, client):
|
||||
"""If radio disconnects between require_connected() and lock acquisition, return 503."""
|
||||
async def test_disconnect_race_returns_423(self, test_db, client):
|
||||
"""If radio disconnects between require_connected() and lock acquisition, return 423."""
|
||||
pub_key = "ab" * 32
|
||||
await _insert_contact(pub_key, "Alice")
|
||||
|
||||
@@ -437,7 +437,7 @@ class TestRadioDisconnectedHandler:
|
||||
"/api/messages/direct", json={"destination": pub_key, "text": "Hi"}
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.status_code == 423
|
||||
assert "not connected" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
@@ -500,25 +500,25 @@ class TestMessagesEndpoint:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_direct_message_requires_connection(self, test_db, client):
|
||||
"""Sending message when disconnected returns 503."""
|
||||
"""Sending message when disconnected returns 423."""
|
||||
with _patch_require_connected():
|
||||
response = await client.post(
|
||||
"/api/messages/direct", json={"destination": "abc123", "text": "Hello"}
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.status_code == 423
|
||||
assert "not connected" in response.json()["detail"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_channel_message_requires_connection(self, test_db, client):
|
||||
"""Sending channel message when disconnected returns 503."""
|
||||
"""Sending channel message when disconnected returns 423."""
|
||||
with _patch_require_connected():
|
||||
response = await client.post(
|
||||
"/api/messages/channel",
|
||||
json={"channel_key": "0123456789ABCDEF0123456789ABCDEF", "text": "Hello"},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.status_code == 423
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_direct_message_emits_websocket_message_event(self, test_db, client):
|
||||
@@ -603,8 +603,8 @@ class TestMessagesEndpoint:
|
||||
assert "not found" in response.json()["detail"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_direct_message_duplicate_returns_500(self, test_db):
|
||||
"""If MessageRepository.create returns None (duplicate), returns 500."""
|
||||
async def test_send_direct_message_duplicate_returns_422(self, test_db):
|
||||
"""If MessageRepository.create returns None (duplicate), returns 422."""
|
||||
from app.models import SendDirectMessageRequest
|
||||
from app.routers.messages import send_direct_message
|
||||
|
||||
@@ -636,12 +636,12 @@ class TestMessagesEndpoint:
|
||||
SendDirectMessageRequest(destination=pub_key, text="Hello")
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert exc_info.value.status_code == 422
|
||||
assert "unexpected duplicate" in exc_info.value.detail.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_channel_message_duplicate_returns_500(self, test_db):
|
||||
"""If MessageRepository.create returns None (duplicate), returns 500."""
|
||||
async def test_send_channel_message_duplicate_returns_422(self, test_db):
|
||||
"""If MessageRepository.create returns None (duplicate), returns 422."""
|
||||
from app.models import SendChannelMessageRequest
|
||||
from app.routers.messages import send_channel_message
|
||||
|
||||
@@ -672,16 +672,16 @@ class TestMessagesEndpoint:
|
||||
SendChannelMessageRequest(channel_key=chan_key, text="Hello")
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert exc_info.value.status_code == 422
|
||||
assert "unexpected duplicate" in exc_info.value.detail.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resend_channel_message_requires_connection(self, test_db, client):
|
||||
"""Resend endpoint returns 503 when radio is disconnected."""
|
||||
"""Resend endpoint returns 423 when radio is disconnected."""
|
||||
with _patch_require_connected():
|
||||
response = await client.post("/api/messages/channel/1/resend")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.status_code == 423
|
||||
assert "not connected" in response.json()["detail"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
+1
-1
@@ -709,7 +709,7 @@ class TestBotMessageRateLimiting:
|
||||
patch(
|
||||
"app.routers.messages.send_direct_message",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=HTTPException(status_code=500, detail="Send failed"),
|
||||
side_effect=HTTPException(status_code=422, detail="Send failed"),
|
||||
),
|
||||
):
|
||||
await process_bot_response(
|
||||
|
||||
@@ -317,7 +317,7 @@ class TestPathDiscovery:
|
||||
mock_broadcast.assert_called_once_with("contact", updated.model_dump())
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_504_when_no_response_is_heard(self, test_db, client):
|
||||
async def test_returns_408_when_no_response_is_heard(self, test_db, client):
|
||||
await _insert_contact(KEY_A, "Alice", type=1)
|
||||
mc = MagicMock()
|
||||
mc.commands = MagicMock()
|
||||
@@ -332,7 +332,7 @@ class TestPathDiscovery:
|
||||
mock_rm.radio_operation = _noop_radio_operation(mc)
|
||||
response = await client.post(f"/api/contacts/{KEY_A}/path-discovery")
|
||||
|
||||
assert response.status_code == 504
|
||||
assert response.status_code == 408
|
||||
assert "No path discovery response heard" in response.json()["detail"]
|
||||
|
||||
|
||||
|
||||
@@ -1367,6 +1367,134 @@ class TestAppriseValidation:
|
||||
assert scope["raw_packets"] == "none"
|
||||
assert scope["messages"] == "all"
|
||||
|
||||
def test_validate_apprise_config_accepts_markdown_format_bool(self):
|
||||
from app.routers.fanout import _validate_apprise_config
|
||||
|
||||
_validate_apprise_config({"urls": "discord://123/abc", "markdown_format": False})
|
||||
|
||||
def test_validate_apprise_config_normalizes_markdown_format(self):
|
||||
from app.routers.fanout import _validate_apprise_config
|
||||
|
||||
config: dict = {"urls": "discord://123/abc", "markdown_format": 0}
|
||||
_validate_apprise_config(config)
|
||||
assert config["markdown_format"] is False
|
||||
|
||||
def test_validate_apprise_config_works_without_markdown_format(self):
|
||||
from app.routers.fanout import _validate_apprise_config
|
||||
|
||||
_validate_apprise_config({"urls": "discord://123/abc"})
|
||||
|
||||
|
||||
class TestAppriseMarkdownFormat:
|
||||
def test_format_body_markdown_true_uses_markdown_fallback(self):
|
||||
from app.fanout.apprise_mod import _format_body
|
||||
|
||||
body = _format_body(
|
||||
{"type": "PRIV", "text": "hi", "sender_name": "Alice"},
|
||||
markdown=True,
|
||||
)
|
||||
assert "**DM:**" in body
|
||||
|
||||
def test_format_body_markdown_false_uses_plain_fallback(self):
|
||||
from app.fanout.apprise_mod import _format_body
|
||||
|
||||
body = _format_body(
|
||||
{"type": "PRIV", "text": "hi", "sender_name": "Alice"},
|
||||
markdown=False,
|
||||
)
|
||||
assert "**" not in body
|
||||
assert "DM:" in body
|
||||
assert "Alice" in body
|
||||
|
||||
def test_format_body_markdown_false_channel(self):
|
||||
from app.fanout.apprise_mod import _format_body
|
||||
|
||||
body = _format_body(
|
||||
{"type": "CHAN", "text": "hi", "sender_name": "Bob", "channel_name": "#gen"},
|
||||
markdown=False,
|
||||
)
|
||||
assert "**" not in body
|
||||
assert "#gen:" in body
|
||||
|
||||
def test_send_sync_passes_markdown_body_format(self):
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
with patch("app.fanout.apprise_mod.apprise_lib", create=True) as mock_lib:
|
||||
mock_notifier = MagicMock()
|
||||
mock_notifier.notify.return_value = True
|
||||
mock_lib.Apprise.return_value = mock_notifier
|
||||
|
||||
with patch.dict("sys.modules", {"apprise": mock_lib}):
|
||||
from app.fanout.apprise_mod import _send_sync
|
||||
|
||||
_send_sync("json://localhost", "test", preserve_identity=False, markdown=True)
|
||||
call_kwargs = mock_notifier.notify.call_args
|
||||
assert call_kwargs.kwargs.get("body_format") or call_kwargs[1].get("body_format")
|
||||
|
||||
def test_send_sync_passes_text_body_format_when_markdown_false(self):
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
with patch("app.fanout.apprise_mod.apprise_lib", create=True) as mock_lib:
|
||||
mock_notifier = MagicMock()
|
||||
mock_notifier.notify.return_value = True
|
||||
mock_lib.Apprise.return_value = mock_notifier
|
||||
|
||||
with patch.dict("sys.modules", {"apprise": mock_lib}):
|
||||
from app.fanout.apprise_mod import _send_sync
|
||||
|
||||
_send_sync("json://localhost", "test", preserve_identity=False, markdown=False)
|
||||
call_kwargs = mock_notifier.notify.call_args
|
||||
assert call_kwargs.kwargs.get("body_format") or call_kwargs[1].get("body_format")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_reads_markdown_format_config(self):
|
||||
from unittest.mock import patch as _patch
|
||||
|
||||
from app.fanout.apprise_mod import AppriseModule
|
||||
|
||||
mod = AppriseModule("test", {"urls": "json://localhost", "markdown_format": False})
|
||||
with _patch("app.fanout.apprise_mod._send_sync", return_value=True) as mock_send:
|
||||
await mod.on_message(
|
||||
{"type": "PRIV", "text": "hello", "outgoing": False, "sender_name": "S_Borkin"}
|
||||
)
|
||||
mock_send.assert_called_once()
|
||||
assert mock_send.call_args.kwargs.get("markdown") is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_defaults_markdown_true(self):
|
||||
from unittest.mock import patch as _patch
|
||||
|
||||
from app.fanout.apprise_mod import AppriseModule
|
||||
|
||||
mod = AppriseModule("test", {"urls": "json://localhost"})
|
||||
with _patch("app.fanout.apprise_mod._send_sync", return_value=True) as mock_send:
|
||||
await mod.on_message(
|
||||
{"type": "PRIV", "text": "hello", "outgoing": False, "sender_name": "Alice"}
|
||||
)
|
||||
mock_send.assert_called_once()
|
||||
assert mock_send.call_args.kwargs.get("markdown") is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_markdown_false_uses_plain_default_format(self):
|
||||
from unittest.mock import patch as _patch
|
||||
|
||||
from app.fanout.apprise_mod import AppriseModule
|
||||
|
||||
mod = AppriseModule("test", {"urls": "json://localhost", "markdown_format": False})
|
||||
with _patch("app.fanout.apprise_mod._send_sync", return_value=True) as mock_send:
|
||||
await mod.on_message(
|
||||
{
|
||||
"type": "CHAN",
|
||||
"text": "hi",
|
||||
"outgoing": False,
|
||||
"sender_name": "Bob",
|
||||
"channel_name": "#general",
|
||||
}
|
||||
)
|
||||
body = mock_send.call_args[0][1]
|
||||
assert "**" not in body
|
||||
assert "#general:" in body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Comprehensive scope/filter selection logic tests
|
||||
|
||||
@@ -1580,6 +1580,46 @@ class TestFanoutAppriseIntegration:
|
||||
assert "Eve" in body_text
|
||||
assert "routed msg" in body_text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apprise_markdown_false_delivers_plain_text(
|
||||
self, apprise_capture_server, integration_db
|
||||
):
|
||||
"""Apprise with markdown_format=False delivers without markdown formatting."""
|
||||
cfg = await FanoutConfigRepository.create(
|
||||
config_type="apprise",
|
||||
name="Plain Apprise",
|
||||
config={
|
||||
"urls": f"json://127.0.0.1:{apprise_capture_server.port}",
|
||||
"markdown_format": False,
|
||||
},
|
||||
scope={"messages": "all", "raw_packets": "none"},
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
manager = FanoutManager()
|
||||
try:
|
||||
await manager.load_from_db()
|
||||
assert cfg["id"] in manager._modules
|
||||
|
||||
await manager.broadcast_message(
|
||||
{
|
||||
"type": "PRIV",
|
||||
"conversation_key": "pk1",
|
||||
"text": "hello",
|
||||
"sender_name": "S_Borkin",
|
||||
}
|
||||
)
|
||||
|
||||
results = await apprise_capture_server.wait_for(1)
|
||||
finally:
|
||||
await manager.stop_all()
|
||||
|
||||
assert len(results) >= 1
|
||||
body_text = str(results[0])
|
||||
assert "S_Borkin" in body_text
|
||||
assert "hello" in body_text
|
||||
assert "**" not in body_text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bot lifecycle tests
|
||||
|
||||
+2
-2
@@ -342,8 +342,8 @@ class TestConnectionLoop:
|
||||
assert sleep_args[0] == _BACKOFF_MIN
|
||||
assert sleep_args[1] == _BACKOFF_MIN * 2
|
||||
assert sleep_args[2] == _BACKOFF_MIN * 4
|
||||
# Fourth should be capped at _backoff_max (5*8=40 > 30)
|
||||
assert sleep_args[3] == MqttPublisher._backoff_max
|
||||
# Fourth is still doubling (5*8=40), not yet at _backoff_max
|
||||
assert sleep_args[3] == _BACKOFF_MIN * 8
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_waits_for_settings_when_unconfigured(self):
|
||||
|
||||
@@ -95,6 +95,8 @@ class TestGetRawPacket:
|
||||
"sender": "Alice",
|
||||
"channel_key": channel_key,
|
||||
"contact_key": None,
|
||||
"sender_timestamp": 1700000000,
|
||||
"message": "Alice: hello",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -174,8 +174,8 @@ class TestRadioOperationYield:
|
||||
class TestRequireConnected:
|
||||
"""Test the require_connected() FastAPI dependency."""
|
||||
|
||||
def test_raises_503_when_setup_in_progress(self):
|
||||
"""HTTPException 503 is raised when radio is connected but setup is still in progress."""
|
||||
def test_raises_423_when_setup_in_progress(self):
|
||||
"""HTTPException 423 is raised when radio is connected but setup is still in progress."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.services.radio_runtime import radio_runtime
|
||||
@@ -188,11 +188,11 @@ class TestRequireConnected:
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
radio_runtime.require_connected()
|
||||
|
||||
assert exc_info.value.status_code == 503
|
||||
assert exc_info.value.status_code == 423
|
||||
assert "initializing" in exc_info.value.detail.lower()
|
||||
|
||||
def test_raises_503_when_not_connected(self):
|
||||
"""HTTPException 503 is raised when radio is not connected."""
|
||||
def test_raises_423_when_not_connected(self):
|
||||
"""HTTPException 423 is raised when radio is not connected."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.services.radio_runtime import radio_runtime
|
||||
@@ -205,7 +205,7 @@ class TestRequireConnected:
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
radio_runtime.require_connected()
|
||||
|
||||
assert exc_info.value.status_code == 503
|
||||
assert exc_info.value.status_code == 423
|
||||
|
||||
def test_returns_meshcore_when_connected_and_setup_complete(self):
|
||||
"""Returns meshcore instance when radio is connected and setup is complete."""
|
||||
|
||||
+11
-11
@@ -131,14 +131,14 @@ class TestGetRadioConfig:
|
||||
assert response.advert_location_source == "current"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_503_when_self_info_missing(self):
|
||||
async def test_returns_423_when_self_info_missing(self):
|
||||
mc = MagicMock()
|
||||
mc.self_info = None
|
||||
with patch("app.routers.radio.radio_manager.require_connected", return_value=mc):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await get_radio_config()
|
||||
|
||||
assert exc.value.status_code == 503
|
||||
assert exc.value.status_code == 423
|
||||
|
||||
|
||||
class TestUpdateRadioConfig:
|
||||
@@ -278,7 +278,7 @@ class TestUpdateRadioConfig:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await update_radio_config(RadioConfigUpdate(path_hash_mode=1))
|
||||
|
||||
assert exc.value.status_code == 500
|
||||
assert exc.value.status_code == 422
|
||||
assert "Failed to set path hash mode" in str(exc.value.detail)
|
||||
assert radio_manager.path_hash_mode == 0
|
||||
mc.commands.send_appstart.assert_not_awaited()
|
||||
@@ -339,7 +339,7 @@ class TestPrivateKeyImport:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await set_private_key(PrivateKeyUpdate(private_key="aa" * 64))
|
||||
|
||||
assert exc.value.status_code == 500
|
||||
assert exc.value.status_code == 422
|
||||
|
||||
|
||||
class TestDiscoverMesh:
|
||||
@@ -699,7 +699,7 @@ class TestTracePath:
|
||||
assert "not a repeater" in exc.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_504_when_no_trace_response_is_heard(self):
|
||||
async def test_returns_408_when_no_trace_response_is_heard(self):
|
||||
mc = _mock_meshcore_with_info()
|
||||
repeater = Contact(
|
||||
public_key="44" * 32,
|
||||
@@ -741,7 +741,7 @@ class TestTracePath:
|
||||
)
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 504
|
||||
assert exc.value.status_code == 408
|
||||
assert "No trace response heard" in exc.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -850,7 +850,7 @@ class TestTracePath:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await discover_mesh(RadioDiscoveryRequest(target="sensors"))
|
||||
|
||||
assert exc.value.status_code == 500
|
||||
assert exc.value.status_code == 422
|
||||
assert exc.value.detail == "Failed to start mesh discovery"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -887,7 +887,7 @@ class TestTracePath:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await set_private_key(PrivateKeyUpdate(private_key="aa" * 64))
|
||||
|
||||
assert exc.value.status_code == 500
|
||||
assert exc.value.status_code == 422
|
||||
assert "keystore" in exc.value.detail.lower()
|
||||
# Called twice: initial attempt + one retry
|
||||
assert mock_export.await_count == 2
|
||||
@@ -926,7 +926,7 @@ class TestAdvertise:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await send_advertisement()
|
||||
|
||||
assert exc.value.status_code == 500
|
||||
assert exc.value.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_defaults_to_flood_mode(self):
|
||||
@@ -1059,7 +1059,7 @@ class TestRebootAndReconnect:
|
||||
assert result["connected"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconnect_raises_503_on_failure(self):
|
||||
async def test_reconnect_raises_423_on_failure(self):
|
||||
mock_rm = MagicMock()
|
||||
mock_rm.is_connected = False
|
||||
mock_rm.is_reconnecting = False
|
||||
@@ -1070,7 +1070,7 @@ class TestRebootAndReconnect:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await reconnect_radio()
|
||||
|
||||
assert exc.value.status_code == 503
|
||||
assert exc.value.status_code == 423
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_pauses_connection_attempts_and_broadcasts_health(self):
|
||||
|
||||
@@ -57,12 +57,12 @@ def test_require_connected_preserves_http_semantics():
|
||||
)
|
||||
with pytest.raises(HTTPException, match="Radio is initializing") as exc:
|
||||
runtime.require_connected()
|
||||
assert exc.value.status_code == 503
|
||||
assert exc.value.status_code == 423
|
||||
|
||||
runtime = RadioRuntime(_Manager(meshcore=None, is_connected=False, is_setup_in_progress=False))
|
||||
with pytest.raises(HTTPException, match="Radio not connected") as exc:
|
||||
runtime.require_connected()
|
||||
assert exc.value.status_code == 503
|
||||
assert exc.value.status_code == 423
|
||||
|
||||
|
||||
def test_require_connected_returns_fresh_meshcore_after_connectivity_check():
|
||||
|
||||
@@ -302,7 +302,7 @@ class TestRepeaterCommandRoute:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await send_repeater_command(KEY_A, CommandRequest(command="ver"))
|
||||
|
||||
assert exc.value.status_code == 500
|
||||
assert exc.value.status_code == 422
|
||||
mc.start_auto_message_fetching.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -502,7 +502,7 @@ class TestTraceRoute:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await request_trace(KEY_A)
|
||||
|
||||
assert exc.value.status_code == 500
|
||||
assert exc.value.status_code == 422
|
||||
mc.commands.send_trace.assert_awaited_once_with(
|
||||
path=KEY_A[:8],
|
||||
tag=1234,
|
||||
@@ -510,7 +510,7 @@ class TestTraceRoute:
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_timeout_returns_504(self, test_db):
|
||||
async def test_wait_timeout_returns_408(self, test_db):
|
||||
mc = _mock_mc()
|
||||
await _insert_contact(KEY_A, name="Client", contact_type=1)
|
||||
mc.commands.send_trace = AsyncMock(return_value=_radio_result(EventType.OK))
|
||||
@@ -524,7 +524,7 @@ class TestTraceRoute:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await request_trace(KEY_A)
|
||||
|
||||
assert exc.value.status_code == 504
|
||||
assert exc.value.status_code == 408
|
||||
mc.commands.send_trace.assert_awaited_once_with(
|
||||
path=KEY_A[:8],
|
||||
tag=1234,
|
||||
@@ -745,7 +745,7 @@ class TestRepeaterStatus:
|
||||
assert response.recv_errors == 42
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_504_on_timeout(self, test_db):
|
||||
async def test_408_on_timeout(self, test_db):
|
||||
mc = _mock_mc()
|
||||
await _insert_contact(KEY_A, name="Repeater", contact_type=2)
|
||||
mc.commands.req_status_sync = AsyncMock(return_value=None)
|
||||
@@ -756,7 +756,7 @@ class TestRepeaterStatus:
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await repeater_status(KEY_A)
|
||||
assert exc.value.status_code == 504
|
||||
assert exc.value.status_code == 408
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_400_not_repeater(self, test_db):
|
||||
@@ -819,7 +819,7 @@ class TestRepeaterLppTelemetry:
|
||||
assert response.sensors == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_504_on_timeout(self, test_db):
|
||||
async def test_408_on_timeout(self, test_db):
|
||||
mc = _mock_mc()
|
||||
await _insert_contact(KEY_A, name="Repeater", contact_type=2)
|
||||
mc.commands.req_telemetry_sync = AsyncMock(return_value=None)
|
||||
@@ -830,7 +830,7 @@ class TestRepeaterLppTelemetry:
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await repeater_lpp_telemetry(KEY_A)
|
||||
assert exc.value.status_code == 504
|
||||
assert exc.value.status_code == 408
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_400_not_repeater(self, test_db):
|
||||
@@ -1234,7 +1234,7 @@ class TestBatchCliFetch:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _batch_cli_fetch(contact, "test_op", [("ver", "firmware_version")])
|
||||
|
||||
assert exc.value.status_code == 500
|
||||
assert exc.value.status_code == 422
|
||||
assert "Failed to add contact to radio" in exc.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1307,7 +1307,7 @@ class TestRepeaterAddContactError:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await repeater_status(KEY_A)
|
||||
|
||||
assert exc.value.status_code == 500
|
||||
assert exc.value.status_code == 422
|
||||
assert "Failed to add contact to radio" in exc.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1325,7 +1325,7 @@ class TestRepeaterAddContactError:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await repeater_lpp_telemetry(KEY_A)
|
||||
|
||||
assert exc.value.status_code == 500
|
||||
assert exc.value.status_code == 422
|
||||
assert "Failed to add contact to radio" in exc.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1343,7 +1343,7 @@ class TestRepeaterAddContactError:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await repeater_neighbors(KEY_A)
|
||||
|
||||
assert exc.value.status_code == 500
|
||||
assert exc.value.status_code == 422
|
||||
assert "Failed to add contact to radio" in exc.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1361,5 +1361,5 @@ class TestRepeaterAddContactError:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await repeater_acl(KEY_A)
|
||||
|
||||
assert exc.value.status_code == 500
|
||||
assert exc.value.status_code == 422
|
||||
assert "Failed to add contact to radio" in exc.value.detail
|
||||
|
||||
+10
-10
@@ -646,7 +646,7 @@ class TestOutgoingChannelBroadcast:
|
||||
request = SendChannelMessageRequest(channel_key=chan_key, text="hello")
|
||||
await send_channel_message(request)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert exc_info.value.status_code == 422
|
||||
assert "regional override" in exc_info.value.detail.lower()
|
||||
mc.commands.set_channel.assert_not_awaited()
|
||||
mc.commands.send_chan_msg.assert_not_awaited()
|
||||
@@ -790,7 +790,7 @@ class TestOutgoingChannelBroadcast:
|
||||
SendChannelMessageRequest(channel_key=chan_key, text="this will fail")
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert exc_info.value.status_code == 422
|
||||
assert radio_manager.get_cached_channel_slot(chan_key) is None
|
||||
|
||||
|
||||
@@ -969,7 +969,7 @@ class TestResendChannelMessage:
|
||||
assert sent_timestamp == now + 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resend_no_radio_response_returns_504_and_creates_no_new_row(self, test_db):
|
||||
async def test_resend_no_radio_response_returns_408_and_creates_no_new_row(self, test_db):
|
||||
"""When resend returns None, report unknown outcome and create no new message row."""
|
||||
mc = _make_mc(name="MyNode")
|
||||
chan_key = "c1" * 16
|
||||
@@ -995,7 +995,7 @@ class TestResendChannelMessage:
|
||||
):
|
||||
await resend_channel_message(msg_id, new_timestamp=True)
|
||||
|
||||
assert exc_info.value.status_code == 504
|
||||
assert exc_info.value.status_code == 408
|
||||
assert exc_info.value.detail == NO_RADIO_RESPONSE_AFTER_SEND_DETAIL
|
||||
|
||||
messages = await MessageRepository.get_all(
|
||||
@@ -1317,7 +1317,7 @@ class TestPathHashModeOverride:
|
||||
SendChannelMessageRequest(channel_key=chan_key, text="hello")
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert exc_info.value.status_code == 422
|
||||
assert "path hash mode" in exc_info.value.detail.lower()
|
||||
mc.commands.send_chan_msg.assert_not_awaited()
|
||||
|
||||
@@ -1567,7 +1567,7 @@ class TestRadioExceptionMidSend:
|
||||
assert len(messages) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_send_no_radio_response_returns_504_without_storing_message(self, test_db):
|
||||
async def test_dm_send_no_radio_response_returns_408_without_storing_message(self, test_db):
|
||||
"""When mc.commands.send_msg() returns None, report unknown outcome and store nothing."""
|
||||
mc = _make_mc()
|
||||
pub_key = "ac" * 32
|
||||
@@ -1584,7 +1584,7 @@ class TestRadioExceptionMidSend:
|
||||
SendDirectMessageRequest(destination=pub_key, text="Did this send?")
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 504
|
||||
assert exc_info.value.status_code == 408
|
||||
assert exc_info.value.detail == NO_RADIO_RESPONSE_AFTER_SEND_DETAIL
|
||||
|
||||
messages = await MessageRepository.get_all(
|
||||
@@ -1593,7 +1593,7 @@ class TestRadioExceptionMidSend:
|
||||
assert len(messages) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_send_no_radio_response_returns_504_without_storing_message(
|
||||
async def test_channel_send_no_radio_response_returns_408_without_storing_message(
|
||||
self, test_db
|
||||
):
|
||||
"""When mc.commands.send_chan_msg() returns None, report unknown outcome and store nothing."""
|
||||
@@ -1612,7 +1612,7 @@ class TestRadioExceptionMidSend:
|
||||
SendChannelMessageRequest(channel_key=chan_key, text="Did this send?")
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 504
|
||||
assert exc_info.value.status_code == 408
|
||||
assert exc_info.value.detail == NO_RADIO_RESPONSE_AFTER_SEND_DETAIL
|
||||
|
||||
messages = await MessageRepository.get_all(
|
||||
@@ -1733,7 +1733,7 @@ class TestRadioExceptionMidSend:
|
||||
SendChannelMessageRequest(channel_key=chan_key_b, text="Never sent")
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert exc_info.value.status_code == 422
|
||||
assert radio_manager.get_cached_channel_slot(chan_key_a) is None
|
||||
assert radio_manager.get_cached_channel_slot(chan_key_b) is None
|
||||
mc.commands.send_chan_msg.assert_not_called()
|
||||
|
||||
Reference in New Issue
Block a user