mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Add room server
This commit is contained in:
@@ -58,6 +58,15 @@ class DecryptedDirectMessage:
|
||||
message: str
|
||||
dest_hash: str # First byte of destination pubkey as hex
|
||||
src_hash: str # First byte of sender pubkey as hex
|
||||
signed_sender_prefix: str | None = None
|
||||
|
||||
@property
|
||||
def txt_type(self) -> int:
|
||||
return self.flags >> 2
|
||||
|
||||
@property
|
||||
def attempt(self) -> int:
|
||||
return self.flags & 0x03
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -498,6 +507,13 @@ def decrypt_direct_message(payload: bytes, shared_secret: bytes) -> DecryptedDir
|
||||
|
||||
# Extract message text (UTF-8, null-padded)
|
||||
message_bytes = decrypted[5:]
|
||||
signed_sender_prefix: str | None = None
|
||||
txt_type = flags >> 2
|
||||
if txt_type == 2:
|
||||
if len(message_bytes) < 4:
|
||||
return None
|
||||
signed_sender_prefix = message_bytes[:4].hex()
|
||||
message_bytes = message_bytes[4:]
|
||||
try:
|
||||
message_text = message_bytes.decode("utf-8")
|
||||
# Truncate at first null terminator (consistent with channel message handling)
|
||||
@@ -513,6 +529,7 @@ def decrypt_direct_message(payload: bytes, shared_secret: bytes) -> DecryptedDir
|
||||
message=message_text,
|
||||
dest_hash=dest_hash,
|
||||
src_hash=src_hash,
|
||||
signed_sender_prefix=signed_sender_prefix,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from meshcore import EventType
|
||||
|
||||
from app.models import Contact, ContactUpsert
|
||||
from app.models import CONTACT_TYPE_ROOM, Contact, ContactUpsert
|
||||
from app.packet_processor import process_raw_packet
|
||||
from app.repository import (
|
||||
ContactRepository,
|
||||
@@ -17,6 +17,7 @@ from app.services.contact_reconciliation import (
|
||||
from app.services.dm_ack_apply import apply_dm_ack_code
|
||||
from app.services.dm_ingest import (
|
||||
ingest_fallback_direct_message,
|
||||
resolve_direct_message_sender_metadata,
|
||||
resolve_fallback_direct_message_context,
|
||||
)
|
||||
from app.websocket import broadcast_event
|
||||
@@ -87,6 +88,23 @@ async def on_contact_message(event: "Event") -> None:
|
||||
sender_timestamp = ts if ts is not None else received_at
|
||||
path = payload.get("path")
|
||||
path_len = payload.get("path_len")
|
||||
sender_name = context.sender_name
|
||||
sender_key = context.sender_key
|
||||
signature = payload.get("signature")
|
||||
if (
|
||||
context.contact is not None
|
||||
and context.contact.type == CONTACT_TYPE_ROOM
|
||||
and txt_type == 2
|
||||
and isinstance(signature, str)
|
||||
and signature
|
||||
):
|
||||
sender_name, sender_key = await resolve_direct_message_sender_metadata(
|
||||
sender_public_key=signature,
|
||||
received_at=received_at,
|
||||
broadcast_fn=broadcast_event,
|
||||
contact_repository=ContactRepository,
|
||||
log=logger,
|
||||
)
|
||||
message = await ingest_fallback_direct_message(
|
||||
conversation_key=context.conversation_key,
|
||||
text=payload.get("text", ""),
|
||||
@@ -95,9 +113,9 @@ async def on_contact_message(event: "Event") -> None:
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
txt_type=txt_type,
|
||||
signature=payload.get("signature"),
|
||||
sender_name=context.sender_name,
|
||||
sender_key=context.sender_key,
|
||||
signature=signature,
|
||||
sender_name=sender_name,
|
||||
sender_key=sender_key,
|
||||
broadcast_fn=broadcast_event,
|
||||
update_last_contacted_key=context.contact.public_key.lower() if context.contact else None,
|
||||
)
|
||||
|
||||
@@ -32,6 +32,7 @@ from app.routers import (
|
||||
radio,
|
||||
read_state,
|
||||
repeaters,
|
||||
rooms,
|
||||
settings,
|
||||
statistics,
|
||||
ws,
|
||||
@@ -134,6 +135,7 @@ app.include_router(fanout.router, prefix="/api")
|
||||
app.include_router(radio.router, prefix="/api")
|
||||
app.include_router(contacts.router, prefix="/api")
|
||||
app.include_router(repeaters.router, prefix="/api")
|
||||
app.include_router(rooms.router, prefix="/api")
|
||||
app.include_router(channels.router, prefix="/api")
|
||||
app.include_router(messages.router, prefix="/api")
|
||||
app.include_router(packets.router, prefix="/api")
|
||||
|
||||
@@ -231,6 +231,7 @@ class ContactRoutingOverrideRequest(BaseModel):
|
||||
|
||||
# Contact type constants
|
||||
CONTACT_TYPE_REPEATER = 2
|
||||
CONTACT_TYPE_ROOM = 3
|
||||
|
||||
|
||||
class ContactAdvertPath(BaseModel):
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
@@ -28,6 +27,14 @@ from app.models import (
|
||||
)
|
||||
from app.repository import ContactRepository
|
||||
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
|
||||
from app.routers.server_control import (
|
||||
_monotonic,
|
||||
batch_cli_fetch,
|
||||
extract_response_text,
|
||||
prepare_authenticated_contact_connection,
|
||||
require_server_capable_contact,
|
||||
send_contact_cli_command,
|
||||
)
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -43,39 +50,11 @@ ACL_PERMISSION_NAMES = {
|
||||
3: "Admin",
|
||||
}
|
||||
router = APIRouter(prefix="/contacts", tags=["repeaters"])
|
||||
|
||||
REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS = 5.0
|
||||
REPEATER_LOGIN_REJECTED_MESSAGE = (
|
||||
"The repeater replied but did not confirm this login. "
|
||||
"Existing access may still allow some repeater operations, but admin actions may fail."
|
||||
)
|
||||
REPEATER_LOGIN_SEND_FAILED_MESSAGE = (
|
||||
"The login request could not be sent to the repeater. "
|
||||
"The dashboard is still available, but repeater operations may fail until a login succeeds."
|
||||
)
|
||||
REPEATER_LOGIN_TIMEOUT_MESSAGE = (
|
||||
"No login confirmation was heard from the repeater. "
|
||||
"On current repeater firmware, that can mean the password was wrong, "
|
||||
"blank-password login was not allowed by the ACL, or the reply was missed in transit. "
|
||||
"The dashboard is still available; try logging in again if admin actions fail."
|
||||
)
|
||||
|
||||
|
||||
def _monotonic() -> float:
|
||||
"""Wrapper around time.monotonic() for testability.
|
||||
|
||||
Patching time.monotonic directly breaks the asyncio event loop which also
|
||||
uses it. This indirection allows tests to control the clock safely.
|
||||
"""
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
def _extract_response_text(event) -> str:
|
||||
"""Extract text from a CLI response event, stripping the firmware '> ' prefix."""
|
||||
text = event.payload.get("text", str(event.payload))
|
||||
if text.startswith("> "):
|
||||
text = text[2:]
|
||||
return text
|
||||
return extract_response_text(event)
|
||||
|
||||
|
||||
async def _fetch_repeater_response(
|
||||
@@ -83,21 +62,6 @@ async def _fetch_repeater_response(
|
||||
target_pubkey_prefix: str,
|
||||
timeout: float = 20.0,
|
||||
) -> "Event | None":
|
||||
"""Fetch a CLI response from a specific repeater via a validated get_msg() loop.
|
||||
|
||||
Calls get_msg() repeatedly until a matching CLI response (txt_type=1) from the
|
||||
target repeater arrives or the wall-clock deadline expires. Unrelated messages
|
||||
are safe to skip — meshcore's event dispatcher already delivers them to the
|
||||
normal subscription handlers (on_contact_message, etc.) when get_msg() returns.
|
||||
|
||||
Args:
|
||||
mc: MeshCore instance
|
||||
target_pubkey_prefix: 12-char hex prefix of the repeater's public key
|
||||
timeout: Wall-clock seconds before giving up
|
||||
|
||||
Returns:
|
||||
The matching Event, or None if no response arrived before the deadline.
|
||||
"""
|
||||
deadline = _monotonic() + timeout
|
||||
|
||||
while _monotonic() < deadline:
|
||||
@@ -105,13 +69,12 @@ async def _fetch_repeater_response(
|
||||
result = await mc.commands.get_msg(timeout=2.0)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug("get_msg() exception: %s", e)
|
||||
except Exception as exc:
|
||||
logger.debug("get_msg() exception: %s", exc)
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
|
||||
if result.type == EventType.NO_MORE_MSGS:
|
||||
# No messages queued yet — wait and retry
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
|
||||
@@ -125,8 +88,6 @@ async def _fetch_repeater_response(
|
||||
txt_type = result.payload.get("txt_type", 0)
|
||||
if msg_prefix == target_pubkey_prefix and txt_type == 1:
|
||||
return result
|
||||
# Not our target — already dispatched to subscribers by meshcore,
|
||||
# so just continue draining the queue.
|
||||
logger.debug(
|
||||
"Skipping non-target message (from=%s, txt_type=%d) while waiting for %s",
|
||||
msg_prefix,
|
||||
@@ -136,7 +97,6 @@ async def _fetch_repeater_response(
|
||||
continue
|
||||
|
||||
if result.type == EventType.CHANNEL_MSG_RECV:
|
||||
# Already dispatched to subscribers by meshcore; skip.
|
||||
logger.debug(
|
||||
"Skipping channel message (channel_idx=%s) during repeater fetch",
|
||||
result.payload.get("channel_idx"),
|
||||
@@ -150,87 +110,13 @@ async def _fetch_repeater_response(
|
||||
|
||||
|
||||
async def prepare_repeater_connection(mc, contact: Contact, password: str) -> RepeaterLoginResponse:
|
||||
"""Prepare connection to a repeater by adding to radio and attempting login.
|
||||
|
||||
Args:
|
||||
mc: MeshCore instance
|
||||
contact: The repeater contact
|
||||
password: Password for login (empty string for no password)
|
||||
"""
|
||||
pubkey_prefix = contact.public_key[:12].lower()
|
||||
loop = asyncio.get_running_loop()
|
||||
login_future = loop.create_future()
|
||||
|
||||
def _resolve_login(event_type: EventType, message: str | None = None) -> None:
|
||||
if login_future.done():
|
||||
return
|
||||
login_future.set_result(
|
||||
RepeaterLoginResponse(
|
||||
status="ok" if event_type == EventType.LOGIN_SUCCESS else "error",
|
||||
authenticated=event_type == EventType.LOGIN_SUCCESS,
|
||||
message=message,
|
||||
)
|
||||
)
|
||||
|
||||
success_subscription = mc.subscribe(
|
||||
EventType.LOGIN_SUCCESS,
|
||||
lambda _event: _resolve_login(EventType.LOGIN_SUCCESS),
|
||||
attribute_filters={"pubkey_prefix": pubkey_prefix},
|
||||
return await prepare_authenticated_contact_connection(
|
||||
mc,
|
||||
contact,
|
||||
password,
|
||||
label="repeater",
|
||||
response_timeout=REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS,
|
||||
)
|
||||
failed_subscription = mc.subscribe(
|
||||
EventType.LOGIN_FAILED,
|
||||
lambda _event: _resolve_login(
|
||||
EventType.LOGIN_FAILED,
|
||||
REPEATER_LOGIN_REJECTED_MESSAGE,
|
||||
),
|
||||
attribute_filters={"pubkey_prefix": pubkey_prefix},
|
||||
)
|
||||
|
||||
# Add contact to radio with path from DB (non-fatal — contact may already be loaded)
|
||||
try:
|
||||
logger.info("Adding repeater %s to radio", contact.public_key[:12])
|
||||
await _ensure_on_radio(mc, contact)
|
||||
|
||||
logger.info("Sending login to repeater %s", contact.public_key[:12])
|
||||
login_result = await mc.commands.send_login(contact.public_key, password)
|
||||
|
||||
if login_result.type == EventType.ERROR:
|
||||
return RepeaterLoginResponse(
|
||||
status="error",
|
||||
authenticated=False,
|
||||
message=f"{REPEATER_LOGIN_SEND_FAILED_MESSAGE} ({login_result.payload})",
|
||||
)
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
login_future,
|
||||
timeout=REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"No login response from repeater %s within %.1fs",
|
||||
contact.public_key[:12],
|
||||
REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS,
|
||||
)
|
||||
return RepeaterLoginResponse(
|
||||
status="timeout",
|
||||
authenticated=False,
|
||||
message=REPEATER_LOGIN_TIMEOUT_MESSAGE,
|
||||
)
|
||||
except HTTPException as exc:
|
||||
logger.warning(
|
||||
"Repeater login setup failed for %s: %s",
|
||||
contact.public_key[:12],
|
||||
exc.detail,
|
||||
)
|
||||
return RepeaterLoginResponse(
|
||||
status="error",
|
||||
authenticated=False,
|
||||
message=f"{REPEATER_LOGIN_SEND_FAILED_MESSAGE} ({exc.detail})",
|
||||
)
|
||||
finally:
|
||||
success_subscription.unsubscribe()
|
||||
failed_subscription.unsubscribe()
|
||||
|
||||
|
||||
def _require_repeater(contact: Contact) -> None:
|
||||
@@ -403,43 +289,7 @@ async def _batch_cli_fetch(
|
||||
operation_name: str,
|
||||
commands: list[tuple[str, str]],
|
||||
) -> dict[str, str | None]:
|
||||
"""Send a batch of CLI commands to a repeater and collect responses.
|
||||
|
||||
Opens a radio operation with polling paused and auto-fetch suspended (since
|
||||
we call get_msg() directly via _fetch_repeater_response), adds the contact
|
||||
to the radio for routing, then sends each command sequentially with a 1-second
|
||||
gap between them.
|
||||
|
||||
Returns a dict mapping field names to response strings (or None on timeout).
|
||||
"""
|
||||
results: dict[str, str | None] = {field: None for _, field in commands}
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
operation_name,
|
||||
pause_polling=True,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
await _ensure_on_radio(mc, contact)
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
for i, (cmd, field) in enumerate(commands):
|
||||
if i > 0:
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
send_result = await mc.commands.send_cmd(contact.public_key, cmd)
|
||||
if send_result.type == EventType.ERROR:
|
||||
logger.debug("Command '%s' send error: %s", cmd, send_result.payload)
|
||||
continue
|
||||
|
||||
response_event = await _fetch_repeater_response(
|
||||
mc, contact.public_key[:12], timeout=10.0
|
||||
)
|
||||
if response_event is not None:
|
||||
results[field] = _extract_response_text(response_event)
|
||||
else:
|
||||
logger.warning("No response for command '%s' (%s)", cmd, field)
|
||||
|
||||
return results
|
||||
return await batch_cli_fetch(contact, operation_name, commands)
|
||||
|
||||
|
||||
@router.post("/{public_key}/repeater/node-info", response_model=RepeaterNodeInfoResponse)
|
||||
@@ -524,72 +374,13 @@ async def repeater_owner_info(public_key: str) -> RepeaterOwnerInfoResponse:
|
||||
|
||||
@router.post("/{public_key}/command", response_model=CommandResponse)
|
||||
async def send_repeater_command(public_key: str, request: CommandRequest) -> CommandResponse:
|
||||
"""Send a CLI command to a repeater.
|
||||
|
||||
The contact must be a repeater (type=2). The user must have already logged in
|
||||
via the repeater/login endpoint. This endpoint ensures the contact is on the
|
||||
radio before sending commands (the repeater remembers ACL permissions after login).
|
||||
|
||||
Common commands:
|
||||
- get name, set name <value>
|
||||
- get tx, set tx <dbm>
|
||||
- get radio, set radio <freq,bw,sf,cr>
|
||||
- tempradio <freq,bw,sf,cr,minutes>
|
||||
- setperm <pubkey> <permission> (0=guest, 1=read-only, 2=read-write, 3=admin)
|
||||
- clock, clock sync, time <epoch_seconds>
|
||||
- reboot
|
||||
- ver
|
||||
"""
|
||||
"""Send a CLI command to a repeater or room server."""
|
||||
require_connected()
|
||||
|
||||
# Get contact from database
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"send_repeater_command",
|
||||
pause_polling=True,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
# Add contact to radio with path from DB (non-fatal — contact may already be loaded)
|
||||
logger.info("Adding repeater %s to radio", contact.public_key[:12])
|
||||
await _ensure_on_radio(mc, contact)
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
# Send the command
|
||||
logger.info("Sending command to repeater %s: %s", contact.public_key[:12], request.command)
|
||||
|
||||
send_result = await mc.commands.send_cmd(contact.public_key, request.command)
|
||||
|
||||
if send_result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to send command: {send_result.payload}"
|
||||
)
|
||||
|
||||
# Wait for response using validated fetch loop
|
||||
response_event = await _fetch_repeater_response(mc, contact.public_key[:12])
|
||||
|
||||
if response_event is None:
|
||||
logger.warning(
|
||||
"No response from repeater %s for command: %s",
|
||||
contact.public_key[:12],
|
||||
request.command,
|
||||
)
|
||||
return CommandResponse(
|
||||
command=request.command,
|
||||
response="(no response - command may have been processed)",
|
||||
)
|
||||
|
||||
# CONTACT_MSG_RECV payloads use sender_timestamp in meshcore.
|
||||
response_text = _extract_response_text(response_event)
|
||||
sender_timestamp = response_event.payload.get(
|
||||
"sender_timestamp",
|
||||
response_event.payload.get("timestamp"),
|
||||
)
|
||||
logger.info("Received response from %s: %s", contact.public_key[:12], response_text)
|
||||
|
||||
return CommandResponse(
|
||||
command=request.command,
|
||||
response=response_text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
)
|
||||
require_server_capable_contact(contact)
|
||||
return await send_contact_cli_command(
|
||||
contact,
|
||||
request.command,
|
||||
operation_name="send_repeater_command",
|
||||
)
|
||||
|
||||
145
app/routers/rooms.py
Normal file
145
app/routers/rooms.py
Normal file
@@ -0,0 +1,145 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.models import (
|
||||
CONTACT_TYPE_ROOM,
|
||||
AclEntry,
|
||||
LppSensor,
|
||||
RepeaterAclResponse,
|
||||
RepeaterLoginRequest,
|
||||
RepeaterLoginResponse,
|
||||
RepeaterLppTelemetryResponse,
|
||||
RepeaterStatusResponse,
|
||||
)
|
||||
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
|
||||
from app.routers.server_control import (
|
||||
prepare_authenticated_contact_connection,
|
||||
require_server_capable_contact,
|
||||
)
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
router = APIRouter(prefix="/contacts", tags=["rooms"])
|
||||
|
||||
|
||||
def _require_room(contact) -> None:
|
||||
require_server_capable_contact(contact, allowed_types=(CONTACT_TYPE_ROOM,))
|
||||
|
||||
|
||||
@router.post("/{public_key}/room/login", response_model=RepeaterLoginResponse)
|
||||
async def room_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse:
|
||||
"""Attempt room-server login and report whether auth was confirmed."""
|
||||
require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_room(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"room_login",
|
||||
pause_polling=True,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
return await prepare_authenticated_contact_connection(
|
||||
mc,
|
||||
contact,
|
||||
request.password,
|
||||
label="room server",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{public_key}/room/status", response_model=RepeaterStatusResponse)
|
||||
async def room_status(public_key: str) -> RepeaterStatusResponse:
|
||||
"""Fetch status telemetry from a room server."""
|
||||
require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_room(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"room_status", pause_polling=True, suspend_auto_fetch=True
|
||||
) as mc:
|
||||
await _ensure_on_radio(mc, contact)
|
||||
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")
|
||||
|
||||
return RepeaterStatusResponse(
|
||||
battery_volts=status.get("bat", 0) / 1000.0,
|
||||
tx_queue_len=status.get("tx_queue_len", 0),
|
||||
noise_floor_dbm=status.get("noise_floor", 0),
|
||||
last_rssi_dbm=status.get("last_rssi", 0),
|
||||
last_snr_db=status.get("last_snr", 0.0),
|
||||
packets_received=status.get("nb_recv", 0),
|
||||
packets_sent=status.get("nb_sent", 0),
|
||||
airtime_seconds=status.get("airtime", 0),
|
||||
rx_airtime_seconds=status.get("rx_airtime", 0),
|
||||
uptime_seconds=status.get("uptime", 0),
|
||||
sent_flood=status.get("sent_flood", 0),
|
||||
sent_direct=status.get("sent_direct", 0),
|
||||
recv_flood=status.get("recv_flood", 0),
|
||||
recv_direct=status.get("recv_direct", 0),
|
||||
flood_dups=status.get("flood_dups", 0),
|
||||
direct_dups=status.get("direct_dups", 0),
|
||||
full_events=status.get("full_evts", 0),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{public_key}/room/lpp-telemetry", response_model=RepeaterLppTelemetryResponse)
|
||||
async def room_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
|
||||
"""Fetch CayenneLPP telemetry from a room server."""
|
||||
require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_room(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"room_lpp_telemetry", pause_polling=True, suspend_auto_fetch=True
|
||||
) as mc:
|
||||
await _ensure_on_radio(mc, contact)
|
||||
telemetry = await mc.commands.req_telemetry_sync(
|
||||
contact.public_key, timeout=10, min_timeout=5
|
||||
)
|
||||
|
||||
if telemetry is None:
|
||||
raise HTTPException(status_code=504, detail="No telemetry response from room server")
|
||||
|
||||
sensors = [
|
||||
LppSensor(
|
||||
channel=entry.get("channel", 0),
|
||||
type_name=str(entry.get("type", "unknown")),
|
||||
value=entry.get("value", 0),
|
||||
)
|
||||
for entry in telemetry
|
||||
]
|
||||
return RepeaterLppTelemetryResponse(sensors=sensors)
|
||||
|
||||
|
||||
@router.post("/{public_key}/room/acl", response_model=RepeaterAclResponse)
|
||||
async def room_acl(public_key: str) -> RepeaterAclResponse:
|
||||
"""Fetch ACL entries from a room server."""
|
||||
require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_room(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"room_acl", pause_polling=True, suspend_auto_fetch=True
|
||||
) as mc:
|
||||
await _ensure_on_radio(mc, contact)
|
||||
acl_data = await mc.commands.req_acl_sync(contact.public_key, timeout=10, min_timeout=5)
|
||||
|
||||
acl_entries = []
|
||||
if acl_data and isinstance(acl_data, list):
|
||||
from app.repository import ContactRepository
|
||||
from app.routers.repeaters import ACL_PERMISSION_NAMES
|
||||
|
||||
for entry in acl_data:
|
||||
pubkey_prefix = entry.get("key", "")
|
||||
perm = entry.get("perm", 0)
|
||||
resolved_contact = await ContactRepository.get_by_key_prefix(pubkey_prefix)
|
||||
acl_entries.append(
|
||||
AclEntry(
|
||||
pubkey_prefix=pubkey_prefix,
|
||||
name=resolved_contact.name if resolved_contact else None,
|
||||
permission=perm,
|
||||
permission_name=ACL_PERMISSION_NAMES.get(perm, f"Unknown({perm})"),
|
||||
)
|
||||
)
|
||||
|
||||
return RepeaterAclResponse(acl=acl_entries)
|
||||
317
app/routers/server_control.py
Normal file
317
app/routers/server_control.py
Normal file
@@ -0,0 +1,317 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import HTTPException
|
||||
from meshcore import EventType
|
||||
|
||||
from app.models import (
|
||||
CONTACT_TYPE_REPEATER,
|
||||
CONTACT_TYPE_ROOM,
|
||||
CommandResponse,
|
||||
Contact,
|
||||
RepeaterLoginResponse,
|
||||
)
|
||||
from app.routers.contacts import _ensure_on_radio
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from meshcore.events import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SERVER_LOGIN_RESPONSE_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
|
||||
def _monotonic() -> float:
|
||||
"""Wrapper around time.monotonic() for testability."""
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
def get_server_contact_label(contact: Contact) -> str:
|
||||
"""Return a user-facing label for server-capable contacts."""
|
||||
if contact.type == CONTACT_TYPE_REPEATER:
|
||||
return "repeater"
|
||||
if contact.type == CONTACT_TYPE_ROOM:
|
||||
return "room server"
|
||||
return "server"
|
||||
|
||||
|
||||
def require_server_capable_contact(
|
||||
contact: Contact,
|
||||
*,
|
||||
allowed_types: tuple[int, ...] = (CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM),
|
||||
) -> None:
|
||||
"""Raise 400 if the contact does not support server control/login features."""
|
||||
if contact.type not in allowed_types:
|
||||
expected = ", ".join(str(value) for value in allowed_types)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Contact is not a supported server contact (type={contact.type}, expected one of {expected})",
|
||||
)
|
||||
|
||||
|
||||
def _login_rejected_message(label: str) -> str:
|
||||
return (
|
||||
f"The {label} replied but did not confirm this login. "
|
||||
f"Existing access may still allow some {label} operations, but privileged actions may fail."
|
||||
)
|
||||
|
||||
|
||||
def _login_send_failed_message(label: str) -> str:
|
||||
return (
|
||||
f"The login request could not be sent to the {label}. "
|
||||
f"The control panel is still available, but authenticated actions may fail until a login succeeds."
|
||||
)
|
||||
|
||||
|
||||
def _login_timeout_message(label: str) -> str:
|
||||
return (
|
||||
f"No login confirmation was heard from the {label}. "
|
||||
"That can mean the password was wrong or the reply was missed in transit. "
|
||||
"The control panel is still available; try logging in again if authenticated actions fail."
|
||||
)
|
||||
|
||||
|
||||
def extract_response_text(event) -> str:
|
||||
"""Extract text from a CLI response event, stripping the firmware '> ' prefix."""
|
||||
text = event.payload.get("text", str(event.payload))
|
||||
if text.startswith("> "):
|
||||
text = text[2:]
|
||||
return text
|
||||
|
||||
|
||||
async def fetch_contact_cli_response(
|
||||
mc,
|
||||
target_pubkey_prefix: str,
|
||||
timeout: float = 20.0,
|
||||
) -> "Event | None":
|
||||
"""Fetch a CLI response from a specific contact via a validated get_msg() loop."""
|
||||
deadline = _monotonic() + timeout
|
||||
|
||||
while _monotonic() < deadline:
|
||||
try:
|
||||
result = await mc.commands.get_msg(timeout=2.0)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
except Exception as exc:
|
||||
logger.debug("get_msg() exception: %s", exc)
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
|
||||
if result.type == EventType.NO_MORE_MSGS:
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
logger.debug("get_msg() error: %s", result.payload)
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
|
||||
if result.type == EventType.CONTACT_MSG_RECV:
|
||||
msg_prefix = result.payload.get("pubkey_prefix", "")
|
||||
txt_type = result.payload.get("txt_type", 0)
|
||||
if msg_prefix == target_pubkey_prefix and txt_type == 1:
|
||||
return result
|
||||
logger.debug(
|
||||
"Skipping non-target message (from=%s, txt_type=%d) while waiting for %s",
|
||||
msg_prefix,
|
||||
txt_type,
|
||||
target_pubkey_prefix,
|
||||
)
|
||||
continue
|
||||
|
||||
if result.type == EventType.CHANNEL_MSG_RECV:
|
||||
logger.debug(
|
||||
"Skipping channel message (channel_idx=%s) during CLI fetch",
|
||||
result.payload.get("channel_idx"),
|
||||
)
|
||||
continue
|
||||
|
||||
logger.debug("Unexpected event type %s during CLI fetch, skipping", result.type)
|
||||
|
||||
logger.warning("No CLI response from contact %s within %.1fs", target_pubkey_prefix, timeout)
|
||||
return None
|
||||
|
||||
|
||||
async def prepare_authenticated_contact_connection(
|
||||
mc,
|
||||
contact: Contact,
|
||||
password: str,
|
||||
*,
|
||||
label: str | None = None,
|
||||
response_timeout: float = SERVER_LOGIN_RESPONSE_TIMEOUT_SECONDS,
|
||||
) -> RepeaterLoginResponse:
|
||||
"""Prepare connection to a server-capable contact by adding it to the radio and logging in."""
|
||||
pubkey_prefix = contact.public_key[:12].lower()
|
||||
contact_label = label or get_server_contact_label(contact)
|
||||
loop = asyncio.get_running_loop()
|
||||
login_future = loop.create_future()
|
||||
|
||||
def _resolve_login(event_type: EventType, message: str | None = None) -> None:
|
||||
if login_future.done():
|
||||
return
|
||||
login_future.set_result(
|
||||
RepeaterLoginResponse(
|
||||
status="ok" if event_type == EventType.LOGIN_SUCCESS else "error",
|
||||
authenticated=event_type == EventType.LOGIN_SUCCESS,
|
||||
message=message,
|
||||
)
|
||||
)
|
||||
|
||||
success_subscription = mc.subscribe(
|
||||
EventType.LOGIN_SUCCESS,
|
||||
lambda _event: _resolve_login(EventType.LOGIN_SUCCESS),
|
||||
attribute_filters={"pubkey_prefix": pubkey_prefix},
|
||||
)
|
||||
failed_subscription = mc.subscribe(
|
||||
EventType.LOGIN_FAILED,
|
||||
lambda _event: _resolve_login(
|
||||
EventType.LOGIN_FAILED,
|
||||
_login_rejected_message(contact_label),
|
||||
),
|
||||
attribute_filters={"pubkey_prefix": pubkey_prefix},
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info("Adding %s %s to radio", contact_label, contact.public_key[:12])
|
||||
await _ensure_on_radio(mc, contact)
|
||||
|
||||
logger.info("Sending login to %s %s", contact_label, contact.public_key[:12])
|
||||
login_result = await mc.commands.send_login(contact.public_key, password)
|
||||
|
||||
if login_result.type == EventType.ERROR:
|
||||
return RepeaterLoginResponse(
|
||||
status="error",
|
||||
authenticated=False,
|
||||
message=f"{_login_send_failed_message(contact_label)} ({login_result.payload})",
|
||||
)
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
login_future,
|
||||
timeout=response_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"No login response from %s %s within %.1fs",
|
||||
contact_label,
|
||||
contact.public_key[:12],
|
||||
response_timeout,
|
||||
)
|
||||
return RepeaterLoginResponse(
|
||||
status="timeout",
|
||||
authenticated=False,
|
||||
message=_login_timeout_message(contact_label),
|
||||
)
|
||||
except HTTPException as exc:
|
||||
logger.warning(
|
||||
"%s login setup failed for %s: %s",
|
||||
contact_label.capitalize(),
|
||||
contact.public_key[:12],
|
||||
exc.detail,
|
||||
)
|
||||
return RepeaterLoginResponse(
|
||||
status="error",
|
||||
authenticated=False,
|
||||
message=f"{_login_send_failed_message(contact_label)} ({exc.detail})",
|
||||
)
|
||||
finally:
|
||||
success_subscription.unsubscribe()
|
||||
failed_subscription.unsubscribe()
|
||||
|
||||
|
||||
async def batch_cli_fetch(
|
||||
contact: Contact,
|
||||
operation_name: str,
|
||||
commands: list[tuple[str, str]],
|
||||
) -> dict[str, str | None]:
|
||||
"""Send a batch of CLI commands to a server-capable contact and collect responses."""
|
||||
results: dict[str, str | None] = {field: None for _, field in commands}
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
operation_name,
|
||||
pause_polling=True,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
await _ensure_on_radio(mc, contact)
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
for index, (cmd, field) in enumerate(commands):
|
||||
if index > 0:
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
send_result = await mc.commands.send_cmd(contact.public_key, cmd)
|
||||
if send_result.type == EventType.ERROR:
|
||||
logger.debug("Command '%s' send error: %s", cmd, send_result.payload)
|
||||
continue
|
||||
|
||||
response_event = await fetch_contact_cli_response(
|
||||
mc, contact.public_key[:12], timeout=10.0
|
||||
)
|
||||
if response_event is not None:
|
||||
results[field] = extract_response_text(response_event)
|
||||
else:
|
||||
logger.warning("No response for command '%s' (%s)", cmd, field)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def send_contact_cli_command(
|
||||
contact: Contact,
|
||||
command: str,
|
||||
*,
|
||||
operation_name: str,
|
||||
) -> CommandResponse:
|
||||
"""Send a CLI command to a server-capable contact and return the text response."""
|
||||
label = get_server_contact_label(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
operation_name,
|
||||
pause_polling=True,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
logger.info("Adding %s %s to radio", label, contact.public_key[:12])
|
||||
await _ensure_on_radio(mc, contact)
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
logger.info("Sending command to %s %s: %s", label, contact.public_key[:12], command)
|
||||
send_result = await mc.commands.send_cmd(contact.public_key, command)
|
||||
|
||||
if send_result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to send command: {send_result.payload}"
|
||||
)
|
||||
|
||||
response_event = await fetch_contact_cli_response(mc, contact.public_key[:12])
|
||||
|
||||
if response_event is None:
|
||||
logger.warning(
|
||||
"No response from %s %s for command: %s",
|
||||
label,
|
||||
contact.public_key[:12],
|
||||
command,
|
||||
)
|
||||
return CommandResponse(
|
||||
command=command,
|
||||
response="(no response - command may have been processed)",
|
||||
)
|
||||
|
||||
response_text = extract_response_text(response_event)
|
||||
sender_timestamp = response_event.payload.get(
|
||||
"sender_timestamp",
|
||||
response_event.payload.get("timestamp"),
|
||||
)
|
||||
logger.info(
|
||||
"Received response from %s %s: %s",
|
||||
label,
|
||||
contact.public_key[:12],
|
||||
response_text,
|
||||
)
|
||||
|
||||
return CommandResponse(
|
||||
command=command,
|
||||
response=response_text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
)
|
||||
@@ -5,7 +5,7 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.models import CONTACT_TYPE_REPEATER, Contact, ContactUpsert, Message
|
||||
from app.models import CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM, Contact, ContactUpsert, Message
|
||||
from app.repository import (
|
||||
AmbiguousPublicKeyPrefixError,
|
||||
ContactRepository,
|
||||
@@ -106,6 +106,49 @@ async def resolve_fallback_direct_message_context(
|
||||
)
|
||||
|
||||
|
||||
async def resolve_direct_message_sender_metadata(
|
||||
*,
|
||||
sender_public_key: str,
|
||||
received_at: int,
|
||||
broadcast_fn: BroadcastFn,
|
||||
contact_repository=ContactRepository,
|
||||
log: logging.Logger | None = None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Resolve sender attribution for direct-message variants such as room-server posts."""
|
||||
normalized_sender = sender_public_key.lower()
|
||||
|
||||
try:
|
||||
contact = await contact_repository.get_by_key_or_prefix(normalized_sender)
|
||||
except AmbiguousPublicKeyPrefixError:
|
||||
(log or logger).warning(
|
||||
"Sender prefix '%s' is ambiguous; preserving prefix-only attribution",
|
||||
sender_public_key,
|
||||
)
|
||||
contact = None
|
||||
|
||||
if contact is not None:
|
||||
await claim_prefix_messages_for_contact(
|
||||
public_key=contact.public_key.lower(), log=log or logger
|
||||
)
|
||||
return contact.name, contact.public_key.lower()
|
||||
|
||||
if normalized_sender:
|
||||
placeholder_upsert = ContactUpsert(
|
||||
public_key=normalized_sender,
|
||||
type=0,
|
||||
last_seen=received_at,
|
||||
last_contacted=received_at,
|
||||
first_seen=received_at,
|
||||
on_radio=False,
|
||||
)
|
||||
await contact_repository.upsert(placeholder_upsert)
|
||||
placeholder = await contact_repository.get_by_key(normalized_sender)
|
||||
if placeholder is not None:
|
||||
broadcast_fn("contact", placeholder.model_dump())
|
||||
|
||||
return None, normalized_sender or None
|
||||
|
||||
|
||||
async def _store_direct_message(
|
||||
*,
|
||||
packet_id: int | None,
|
||||
@@ -237,8 +280,19 @@ async def ingest_decrypted_direct_message(
|
||||
contact_repository=ContactRepository,
|
||||
) -> Message | None:
|
||||
conversation_key = their_public_key.lower()
|
||||
|
||||
if not outgoing and decrypted.txt_type == 1:
|
||||
logger.debug(
|
||||
"Skipping CLI response from %s (txt_type=1): %s",
|
||||
conversation_key[:12],
|
||||
(decrypted.message or "")[:50],
|
||||
)
|
||||
return None
|
||||
|
||||
contact = await contact_repository.get_by_key(conversation_key)
|
||||
sender_name: str | None = None
|
||||
sender_key: str | None = conversation_key if not outgoing else None
|
||||
signature: str | None = None
|
||||
if contact is not None:
|
||||
conversation_key, skip_storage = await _prepare_resolved_contact(contact, log=logger)
|
||||
if skip_storage:
|
||||
@@ -249,7 +303,17 @@ async def ingest_decrypted_direct_message(
|
||||
)
|
||||
return None
|
||||
if not outgoing:
|
||||
sender_name = contact.name
|
||||
if contact.type == CONTACT_TYPE_ROOM and decrypted.signed_sender_prefix:
|
||||
sender_name, sender_key = await resolve_direct_message_sender_metadata(
|
||||
sender_public_key=decrypted.signed_sender_prefix,
|
||||
received_at=received_at or int(time.time()),
|
||||
broadcast_fn=broadcast_fn,
|
||||
contact_repository=contact_repository,
|
||||
log=logger,
|
||||
)
|
||||
signature = decrypted.signed_sender_prefix
|
||||
else:
|
||||
sender_name = contact.name
|
||||
|
||||
received = received_at or int(time.time())
|
||||
message = await _store_direct_message(
|
||||
@@ -261,10 +325,10 @@ async def ingest_decrypted_direct_message(
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
outgoing=outgoing,
|
||||
txt_type=0,
|
||||
signature=None,
|
||||
txt_type=decrypted.txt_type,
|
||||
signature=signature,
|
||||
sender_name=sender_name,
|
||||
sender_key=conversation_key if not outgoing else None,
|
||||
sender_key=sender_key,
|
||||
realtime=realtime,
|
||||
broadcast_fn=broadcast_fn,
|
||||
update_last_contacted_key=conversation_key,
|
||||
|
||||
@@ -383,4 +383,21 @@ export const api = {
|
||||
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/repeater/lpp-telemetry`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
roomLogin: (publicKey: string, password: string) =>
|
||||
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/room/login`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
}),
|
||||
roomStatus: (publicKey: string) =>
|
||||
fetchJson<RepeaterStatusResponse>(`/contacts/${publicKey}/room/status`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
roomAcl: (publicKey: string) =>
|
||||
fetchJson<RepeaterAclResponse>(`/contacts/${publicKey}/room/acl`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
roomLppTelemetry: (publicKey: string) =>
|
||||
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/room/lpp-telemetry`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
PathDiscoveryResponse,
|
||||
RadioConfig,
|
||||
} from '../types';
|
||||
import { CONTACT_TYPE_ROOM } from '../types';
|
||||
|
||||
interface ChatHeaderProps {
|
||||
conversation: Conversation;
|
||||
@@ -84,6 +85,7 @@ export function ChatHeader({
|
||||
conversation.type === 'contact'
|
||||
? contacts.find((contact) => contact.public_key === conversation.id)
|
||||
: null;
|
||||
const activeContactIsRoomServer = activeContact?.type === CONTACT_TYPE_ROOM;
|
||||
const activeContactIsPrefixOnly = activeContact
|
||||
? isPrefixOnlyContact(activeContact.public_key)
|
||||
: false;
|
||||
@@ -230,7 +232,7 @@ export function ChatHeader({
|
||||
</span>
|
||||
</span>
|
||||
<div className="flex items-center justify-end gap-0.5 flex-shrink-0">
|
||||
{conversation.type === 'contact' && (
|
||||
{conversation.type === 'contact' && !activeContactIsRoomServer && (
|
||||
<button
|
||||
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => setPathDiscoveryOpen(true)}
|
||||
@@ -245,7 +247,7 @@ export function ChatHeader({
|
||||
<Route className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
{conversation.type === 'contact' && (
|
||||
{conversation.type === 'contact' && !activeContactIsRoomServer && (
|
||||
<button
|
||||
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onTrace}
|
||||
@@ -260,7 +262,7 @@ export function ChatHeader({
|
||||
<DirectTraceIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
{notificationsSupported && (
|
||||
{notificationsSupported && !activeContactIsRoomServer && (
|
||||
<button
|
||||
className="flex items-center gap-1 rounded px-1 py-1 hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onToggleNotifications}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { lazy, Suspense, useMemo, type Ref } from 'react';
|
||||
import { lazy, Suspense, useEffect, useMemo, useState, type Ref } from 'react';
|
||||
|
||||
import { ChatHeader } from './ChatHeader';
|
||||
import { MessageInput, type MessageInputHandle } from './MessageInput';
|
||||
import { MessageList } from './MessageList';
|
||||
import { RawPacketFeedView } from './RawPacketFeedView';
|
||||
import { RoomServerPanel } from './RoomServerPanel';
|
||||
import type {
|
||||
Channel,
|
||||
Contact,
|
||||
@@ -16,7 +17,7 @@ import type {
|
||||
RadioConfig,
|
||||
} from '../types';
|
||||
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
||||
import { isPrefixOnlyContact, isUnknownFullKeyContact } from '../utils/pubkey';
|
||||
|
||||
const RepeaterDashboard = lazy(() =>
|
||||
@@ -131,6 +132,7 @@ export function ConversationPane({
|
||||
onSendMessage,
|
||||
onToggleNotifications,
|
||||
}: ConversationPaneProps) {
|
||||
const [roomAuthenticated, setRoomAuthenticated] = useState(false);
|
||||
const activeContactIsRepeater = useMemo(() => {
|
||||
if (!activeConversation || activeConversation.type !== 'contact') return false;
|
||||
const contact = contacts.find((candidate) => candidate.public_key === activeConversation.id);
|
||||
@@ -140,6 +142,10 @@ export function ConversationPane({
|
||||
if (!activeConversation || activeConversation.type !== 'contact') return null;
|
||||
return contacts.find((candidate) => candidate.public_key === activeConversation.id) ?? null;
|
||||
}, [activeConversation, contacts]);
|
||||
const activeContactIsRoom = activeContact?.type === CONTACT_TYPE_ROOM;
|
||||
useEffect(() => {
|
||||
setRoomAuthenticated(false);
|
||||
}, [activeConversation?.id]);
|
||||
const isPrefixOnlyActiveContact = activeContact
|
||||
? isPrefixOnlyContact(activeContact.public_key)
|
||||
: false;
|
||||
@@ -218,6 +224,8 @@ export function ConversationPane({
|
||||
);
|
||||
}
|
||||
|
||||
const showRoomChat = !activeContactIsRoom || roomAuthenticated;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChatHeader
|
||||
@@ -245,35 +253,40 @@ export function ConversationPane({
|
||||
{activeConversation.type === 'contact' && isUnknownFullKeyActiveContact && (
|
||||
<ContactResolutionBanner variant="unknown-full-key" />
|
||||
)}
|
||||
<MessageList
|
||||
key={activeConversation.id}
|
||||
messages={messages}
|
||||
contacts={contacts}
|
||||
loading={messagesLoading}
|
||||
loadingOlder={loadingOlder}
|
||||
hasOlderMessages={hasOlderMessages}
|
||||
unreadMarkerLastReadAt={
|
||||
activeConversation.type === 'channel' ? unreadMarkerLastReadAt : undefined
|
||||
}
|
||||
onDismissUnreadMarker={
|
||||
activeConversation.type === 'channel' ? onDismissUnreadMarker : undefined
|
||||
}
|
||||
onSenderClick={activeConversation.type === 'channel' ? onSenderClick : undefined}
|
||||
onLoadOlder={onLoadOlder}
|
||||
onResendChannelMessage={
|
||||
activeConversation.type === 'channel' ? onResendChannelMessage : undefined
|
||||
}
|
||||
radioName={config?.name}
|
||||
config={config}
|
||||
onOpenContactInfo={onOpenContactInfo}
|
||||
targetMessageId={targetMessageId}
|
||||
onTargetReached={onTargetReached}
|
||||
hasNewerMessages={hasNewerMessages}
|
||||
loadingNewer={loadingNewer}
|
||||
onLoadNewer={onLoadNewer}
|
||||
onJumpToBottom={onJumpToBottom}
|
||||
/>
|
||||
{activeConversation.type === 'contact' && isPrefixOnlyActiveContact ? null : (
|
||||
{activeContactIsRoom && activeContact && (
|
||||
<RoomServerPanel contact={activeContact} onAuthenticatedChange={setRoomAuthenticated} />
|
||||
)}
|
||||
{showRoomChat && (
|
||||
<MessageList
|
||||
key={activeConversation.id}
|
||||
messages={messages}
|
||||
contacts={contacts}
|
||||
loading={messagesLoading}
|
||||
loadingOlder={loadingOlder}
|
||||
hasOlderMessages={hasOlderMessages}
|
||||
unreadMarkerLastReadAt={
|
||||
activeConversation.type === 'channel' ? unreadMarkerLastReadAt : undefined
|
||||
}
|
||||
onDismissUnreadMarker={
|
||||
activeConversation.type === 'channel' ? onDismissUnreadMarker : undefined
|
||||
}
|
||||
onSenderClick={activeConversation.type === 'channel' ? onSenderClick : undefined}
|
||||
onLoadOlder={onLoadOlder}
|
||||
onResendChannelMessage={
|
||||
activeConversation.type === 'channel' ? onResendChannelMessage : undefined
|
||||
}
|
||||
radioName={config?.name}
|
||||
config={config}
|
||||
onOpenContactInfo={onOpenContactInfo}
|
||||
targetMessageId={targetMessageId}
|
||||
onTargetReached={onTargetReached}
|
||||
hasNewerMessages={hasNewerMessages}
|
||||
loadingNewer={loadingNewer}
|
||||
onLoadNewer={onLoadNewer}
|
||||
onJumpToBottom={onJumpToBottom}
|
||||
/>
|
||||
)}
|
||||
{showRoomChat && !(activeConversation.type === 'contact' && isPrefixOnlyActiveContact) ? (
|
||||
<MessageInput
|
||||
ref={messageInputRef}
|
||||
onSend={onSendMessage}
|
||||
@@ -286,7 +299,7 @@ export function ConversationPane({
|
||||
: `Message ${activeConversation.name}...`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import type { Contact, Message, MessagePath, RadioConfig } from '../types';
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
||||
import { formatTime, parseSenderFromText } from '../utils/messageParser';
|
||||
import { formatHopCounts, type SenderInfo } from '../utils/pathUtils';
|
||||
import { getDirectContactRoute } from '../utils/pathUtils';
|
||||
@@ -500,6 +500,33 @@ export function MessageList({
|
||||
contact: Contact | null,
|
||||
parsedSender: string | null
|
||||
): SenderInfo => {
|
||||
if (
|
||||
msg.type === 'PRIV' &&
|
||||
contact?.type === CONTACT_TYPE_ROOM &&
|
||||
(msg.sender_key || msg.sender_name)
|
||||
) {
|
||||
const authorContact =
|
||||
(msg.sender_key
|
||||
? contacts.find((candidate) => candidate.public_key === msg.sender_key)
|
||||
: null) || (msg.sender_name ? getContactByName(msg.sender_name) : null);
|
||||
if (authorContact) {
|
||||
const directRoute = getDirectContactRoute(authorContact);
|
||||
return {
|
||||
name: authorContact.name || msg.sender_name || authorContact.public_key.slice(0, 12),
|
||||
publicKeyOrPrefix: authorContact.public_key,
|
||||
lat: authorContact.lat,
|
||||
lon: authorContact.lon,
|
||||
pathHashMode: directRoute?.path_hash_mode ?? null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: msg.sender_name || msg.sender_key || 'Unknown',
|
||||
publicKeyOrPrefix: msg.sender_key || '',
|
||||
lat: null,
|
||||
lon: null,
|
||||
pathHashMode: null,
|
||||
};
|
||||
}
|
||||
if (msg.type === 'PRIV' && contact) {
|
||||
const directRoute = getDirectContactRoute(contact);
|
||||
return {
|
||||
@@ -584,6 +611,8 @@ export function MessageList({
|
||||
isCorruptChannelMessage: boolean
|
||||
): string => {
|
||||
if (msg.outgoing) return '__outgoing__';
|
||||
if (msg.type === 'PRIV' && msg.sender_key) return `key:${msg.sender_key}`;
|
||||
if (msg.type === 'PRIV' && senderName) return `name:${senderName}`;
|
||||
if (msg.type === 'PRIV' && msg.conversation_key) return msg.conversation_key;
|
||||
if (msg.sender_key) return `key:${msg.sender_key}`;
|
||||
if (senderName) return `name:${senderName}`;
|
||||
@@ -612,18 +641,24 @@ export function MessageList({
|
||||
// For DMs, look up contact; for channel messages, use parsed sender
|
||||
const contact = msg.type === 'PRIV' ? getContact(msg.conversation_key) : null;
|
||||
const isRepeater = contact?.type === CONTACT_TYPE_REPEATER;
|
||||
const isRoomServer = contact?.type === CONTACT_TYPE_ROOM;
|
||||
|
||||
// Skip sender parsing for repeater messages (CLI responses often have colons)
|
||||
const { sender, content } = isRepeater
|
||||
? { sender: null, content: msg.text }
|
||||
: parseSenderFromText(msg.text);
|
||||
const { sender, content } =
|
||||
isRepeater || (isRoomServer && msg.type === 'PRIV')
|
||||
? { sender: null, content: msg.text }
|
||||
: parseSenderFromText(msg.text);
|
||||
const directSenderName =
|
||||
msg.type === 'PRIV' && isRoomServer ? msg.sender_name || null : null;
|
||||
const channelSenderName = msg.type === 'CHAN' ? msg.sender_name || sender : null;
|
||||
const channelSenderContact =
|
||||
msg.type === 'CHAN' && channelSenderName ? getContactByName(channelSenderName) : null;
|
||||
const isCorruptChannelMessage = isCorruptUnnamedChannelMessage(msg, sender);
|
||||
const displaySender = msg.outgoing
|
||||
? 'You'
|
||||
: contact?.name ||
|
||||
: directSenderName ||
|
||||
(isRoomServer && msg.sender_key ? msg.sender_key.slice(0, 8) : null) ||
|
||||
contact?.name ||
|
||||
channelSenderName ||
|
||||
(isCorruptChannelMessage
|
||||
? CORRUPT_SENDER_LABEL
|
||||
@@ -636,15 +671,22 @@ export function MessageList({
|
||||
displaySender !== CORRUPT_SENDER_LABEL;
|
||||
|
||||
// Determine if we should show avatar (first message in a chunk from same sender)
|
||||
const currentSenderKey = getSenderKey(msg, channelSenderName, isCorruptChannelMessage);
|
||||
const currentSenderKey = getSenderKey(
|
||||
msg,
|
||||
directSenderName || channelSenderName,
|
||||
isCorruptChannelMessage
|
||||
);
|
||||
const prevMsg = sortedMessages[index - 1];
|
||||
const prevParsedSender = prevMsg ? parseSenderFromText(prevMsg.text).sender : null;
|
||||
const prevSenderKey = prevMsg
|
||||
? getSenderKey(
|
||||
prevMsg,
|
||||
prevMsg.type === 'CHAN'
|
||||
? prevMsg.sender_name || prevParsedSender
|
||||
: prevParsedSender,
|
||||
prevMsg.type === 'PRIV' &&
|
||||
getContact(prevMsg.conversation_key)?.type === CONTACT_TYPE_ROOM
|
||||
? prevMsg.sender_name
|
||||
: prevMsg.type === 'CHAN'
|
||||
? prevMsg.sender_name || prevParsedSender
|
||||
: prevParsedSender,
|
||||
isCorruptUnnamedChannelMessage(prevMsg, prevParsedSender)
|
||||
)
|
||||
: null;
|
||||
@@ -658,9 +700,14 @@ export function MessageList({
|
||||
let avatarVariant: 'default' | 'corrupt' = 'default';
|
||||
if (!msg.outgoing) {
|
||||
if (msg.type === 'PRIV' && msg.conversation_key) {
|
||||
// DM: use conversation_key (sender's public key)
|
||||
avatarName = contact?.name || null;
|
||||
avatarKey = msg.conversation_key;
|
||||
if (isRoomServer) {
|
||||
avatarName = directSenderName;
|
||||
avatarKey =
|
||||
msg.sender_key || (avatarName ? `name:${avatarName}` : msg.conversation_key);
|
||||
} else {
|
||||
avatarName = contact?.name || null;
|
||||
avatarKey = msg.conversation_key;
|
||||
}
|
||||
} else if (isCorruptChannelMessage) {
|
||||
avatarName = CORRUPT_SENDER_LABEL;
|
||||
avatarKey = `corrupt:${msg.id}`;
|
||||
@@ -725,7 +772,12 @@ export function MessageList({
|
||||
type="button"
|
||||
className="avatar-action-button rounded-full border-none bg-transparent p-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={avatarActionLabel}
|
||||
onClick={() => onOpenContactInfo(avatarKey, msg.type === 'CHAN')}
|
||||
onClick={() =>
|
||||
onOpenContactInfo(
|
||||
avatarKey,
|
||||
msg.type === 'CHAN' || (msg.type === 'PRIV' && isRoomServer)
|
||||
)
|
||||
}
|
||||
>
|
||||
<ContactAvatar
|
||||
name={avatarName}
|
||||
@@ -780,7 +832,7 @@ export function MessageList({
|
||||
onClick={() =>
|
||||
setSelectedPath({
|
||||
paths: msg.paths!,
|
||||
senderInfo: getSenderInfo(msg, contact, sender),
|
||||
senderInfo: getSenderInfo(msg, contact, directSenderName || sender),
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -806,7 +858,7 @@ export function MessageList({
|
||||
onClick={() =>
|
||||
setSelectedPath({
|
||||
paths: msg.paths!,
|
||||
senderInfo: getSenderInfo(msg, contact, sender),
|
||||
senderInfo: getSenderInfo(msg, contact, directSenderName || sender),
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -8,6 +8,10 @@ interface RepeaterLoginProps {
|
||||
error: string | null;
|
||||
onLogin: (password: string) => Promise<void>;
|
||||
onLoginAsGuest: () => Promise<void>;
|
||||
description?: string;
|
||||
passwordPlaceholder?: string;
|
||||
loginLabel?: string;
|
||||
guestLabel?: string;
|
||||
}
|
||||
|
||||
export function RepeaterLogin({
|
||||
@@ -16,6 +20,10 @@ export function RepeaterLogin({
|
||||
error,
|
||||
onLogin,
|
||||
onLoginAsGuest,
|
||||
description = 'Log in to access repeater dashboard',
|
||||
passwordPlaceholder = 'Repeater password...',
|
||||
loginLabel = 'Login with Password',
|
||||
guestLabel = 'Login as Guest / ACLs',
|
||||
}: RepeaterLoginProps) {
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
@@ -33,7 +41,7 @@ export function RepeaterLogin({
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
<div className="text-center space-y-1">
|
||||
<h2 className="text-lg font-semibold">{repeaterName}</h2>
|
||||
<p className="text-sm text-muted-foreground">Log in to access repeater dashboard</p>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4" autoComplete="off">
|
||||
@@ -46,7 +54,7 @@ export function RepeaterLogin({
|
||||
data-bwignore="true"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Repeater password..."
|
||||
placeholder={passwordPlaceholder}
|
||||
aria-label="Repeater password"
|
||||
disabled={loading}
|
||||
autoFocus
|
||||
@@ -60,7 +68,7 @@ export function RepeaterLogin({
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? 'Logging in...' : 'Login with Password'}
|
||||
{loading ? 'Logging in...' : loginLabel}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -69,7 +77,7 @@ export function RepeaterLogin({
|
||||
className="w-full"
|
||||
onClick={onLoginAsGuest}
|
||||
>
|
||||
Login as Guest / ACLs
|
||||
{guestLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
289
frontend/src/components/RoomServerPanel.tsx
Normal file
289
frontend/src/components/RoomServerPanel.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { api } from '../api';
|
||||
import { toast } from './ui/sonner';
|
||||
import { Button } from './ui/button';
|
||||
import type {
|
||||
Contact,
|
||||
PaneState,
|
||||
RepeaterAclResponse,
|
||||
RepeaterLppTelemetryResponse,
|
||||
RepeaterStatusResponse,
|
||||
} from '../types';
|
||||
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
|
||||
import { AclPane } from './repeater/RepeaterAclPane';
|
||||
import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
|
||||
import { ConsolePane } from './repeater/RepeaterConsolePane';
|
||||
import { RepeaterLogin } from './RepeaterLogin';
|
||||
|
||||
interface RoomServerPanelProps {
|
||||
contact: Contact;
|
||||
onAuthenticatedChange?: (authenticated: boolean) => void;
|
||||
}
|
||||
|
||||
type RoomPaneKey = 'status' | 'acl' | 'lppTelemetry';
|
||||
|
||||
type RoomPaneData = {
|
||||
status: RepeaterStatusResponse | null;
|
||||
acl: RepeaterAclResponse | null;
|
||||
lppTelemetry: RepeaterLppTelemetryResponse | null;
|
||||
};
|
||||
|
||||
type RoomPaneStates = Record<RoomPaneKey, PaneState>;
|
||||
|
||||
type ConsoleEntry = {
|
||||
command: string;
|
||||
response: string;
|
||||
timestamp: number;
|
||||
outgoing: boolean;
|
||||
};
|
||||
|
||||
const INITIAL_PANE_STATE: PaneState = {
|
||||
loading: false,
|
||||
attempt: 0,
|
||||
error: null,
|
||||
fetched_at: null,
|
||||
};
|
||||
|
||||
function createInitialPaneStates(): RoomPaneStates {
|
||||
return {
|
||||
status: { ...INITIAL_PANE_STATE },
|
||||
acl: { ...INITIAL_PANE_STATE },
|
||||
lppTelemetry: { ...INITIAL_PANE_STATE },
|
||||
};
|
||||
}
|
||||
|
||||
export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPanelProps) {
|
||||
const [loginLoading, setLoginLoading] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const [loginMessage, setLoginMessage] = useState<string | null>(null);
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [paneData, setPaneData] = useState<RoomPaneData>({
|
||||
status: null,
|
||||
acl: null,
|
||||
lppTelemetry: null,
|
||||
});
|
||||
const [paneStates, setPaneStates] = useState<RoomPaneStates>(createInitialPaneStates);
|
||||
const [consoleHistory, setConsoleHistory] = useState<ConsoleEntry[]>([]);
|
||||
const [consoleLoading, setConsoleLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLoginLoading(false);
|
||||
setLoginError(null);
|
||||
setLoginMessage(null);
|
||||
setAuthenticated(false);
|
||||
setAdvancedOpen(false);
|
||||
setPaneData({
|
||||
status: null,
|
||||
acl: null,
|
||||
lppTelemetry: null,
|
||||
});
|
||||
setPaneStates(createInitialPaneStates());
|
||||
setConsoleHistory([]);
|
||||
setConsoleLoading(false);
|
||||
}, [contact.public_key]);
|
||||
|
||||
useEffect(() => {
|
||||
onAuthenticatedChange?.(authenticated);
|
||||
}, [authenticated, onAuthenticatedChange]);
|
||||
|
||||
const refreshPane = useCallback(
|
||||
async <K extends RoomPaneKey>(pane: K, loader: () => Promise<RoomPaneData[K]>) => {
|
||||
setPaneStates((prev) => ({
|
||||
...prev,
|
||||
[pane]: {
|
||||
...prev[pane],
|
||||
loading: true,
|
||||
attempt: prev[pane].attempt + 1,
|
||||
error: null,
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
const data = await loader();
|
||||
setPaneData((prev) => ({ ...prev, [pane]: data }));
|
||||
setPaneStates((prev) => ({
|
||||
...prev,
|
||||
[pane]: {
|
||||
loading: false,
|
||||
attempt: prev[pane].attempt,
|
||||
error: null,
|
||||
fetched_at: Date.now(),
|
||||
},
|
||||
}));
|
||||
} catch (err) {
|
||||
setPaneStates((prev) => ({
|
||||
...prev,
|
||||
[pane]: {
|
||||
...prev[pane],
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
},
|
||||
}));
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const performLogin = useCallback(
|
||||
async (password: string) => {
|
||||
if (loginLoading) return;
|
||||
|
||||
setLoginLoading(true);
|
||||
setLoginError(null);
|
||||
setLoginMessage(null);
|
||||
try {
|
||||
const result = await api.roomLogin(contact.public_key, password);
|
||||
setAuthenticated(result.authenticated);
|
||||
setLoginMessage(
|
||||
result.message ??
|
||||
(result.authenticated
|
||||
? 'Login confirmed. You can now send room messages and open admin tools.'
|
||||
: 'Login request sent, but authentication was not confirmed.')
|
||||
);
|
||||
if (result.authenticated) {
|
||||
toast.success('Room login confirmed');
|
||||
} else {
|
||||
toast(result.message ?? 'Room login was not confirmed');
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setAuthenticated(false);
|
||||
setLoginError(message);
|
||||
toast.error('Room login failed', { description: message });
|
||||
} finally {
|
||||
setLoginLoading(false);
|
||||
}
|
||||
},
|
||||
[contact.public_key, loginLoading]
|
||||
);
|
||||
|
||||
const handleLogin = useCallback(
|
||||
async (password: string) => {
|
||||
await performLogin(password);
|
||||
},
|
||||
[performLogin]
|
||||
);
|
||||
|
||||
const handleLoginAsGuest = useCallback(async () => {
|
||||
await performLogin('');
|
||||
}, [performLogin]);
|
||||
|
||||
const handleConsoleCommand = useCallback(
|
||||
async (command: string) => {
|
||||
setConsoleLoading(true);
|
||||
const timestamp = Date.now();
|
||||
setConsoleHistory((prev) => [
|
||||
...prev,
|
||||
{ command, response: command, timestamp, outgoing: true },
|
||||
]);
|
||||
try {
|
||||
const response = await api.sendRepeaterCommand(contact.public_key, command);
|
||||
setConsoleHistory((prev) => [
|
||||
...prev,
|
||||
{
|
||||
command,
|
||||
response: response.response,
|
||||
timestamp: Date.now(),
|
||||
outgoing: false,
|
||||
},
|
||||
]);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setConsoleHistory((prev) => [
|
||||
...prev,
|
||||
{
|
||||
command,
|
||||
response: `(error) ${message}`,
|
||||
timestamp: Date.now(),
|
||||
outgoing: false,
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
setConsoleLoading(false);
|
||||
}
|
||||
},
|
||||
[contact.public_key]
|
||||
);
|
||||
|
||||
const panelTitle = useMemo(() => contact.name || contact.public_key.slice(0, 12), [contact]);
|
||||
|
||||
if (!authenticated) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<RepeaterLogin
|
||||
repeaterName={panelTitle}
|
||||
loading={loginLoading}
|
||||
error={loginError}
|
||||
onLogin={handleLogin}
|
||||
onLoginAsGuest={handleLoginAsGuest}
|
||||
description="Log in with the room password or use ACL/guest access to enter this room server"
|
||||
passwordPlaceholder="Room server password..."
|
||||
guestLabel="Login with ACL / Guest"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="border-b border-border bg-muted/20 px-4 py-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">Room Server Controls</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Room access is active. Use the chat history and message box below to participate, and
|
||||
open admin tools when needed.
|
||||
</p>
|
||||
{loginMessage && <p className="text-xs text-muted-foreground">{loginMessage}</p>}
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-2 sm:flex-row lg:w-auto">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleLoginAsGuest}
|
||||
disabled={loginLoading}
|
||||
>
|
||||
Refresh ACL Login
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setAdvancedOpen((prev) => !prev)}
|
||||
>
|
||||
{advancedOpen ? 'Hide Tools' : 'Show Tools'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{advancedOpen && (
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
<TelemetryPane
|
||||
data={paneData.status}
|
||||
state={paneStates.status}
|
||||
onRefresh={() => refreshPane('status', () => api.roomStatus(contact.public_key))}
|
||||
/>
|
||||
<AclPane
|
||||
data={paneData.acl}
|
||||
state={paneStates.acl}
|
||||
onRefresh={() => refreshPane('acl', () => api.roomAcl(contact.public_key))}
|
||||
/>
|
||||
<LppTelemetryPane
|
||||
data={paneData.lppTelemetry}
|
||||
state={paneStates.lppTelemetry}
|
||||
onRefresh={() =>
|
||||
refreshPane('lppTelemetry', () => api.roomLppTelemetry(contact.public_key))
|
||||
}
|
||||
/>
|
||||
<ConsolePane
|
||||
history={consoleHistory}
|
||||
loading={consoleLoading}
|
||||
onSend={handleConsoleCommand}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
CONTACT_TYPE_ROOM,
|
||||
CONTACT_TYPE_REPEATER,
|
||||
type Contact,
|
||||
type Channel,
|
||||
@@ -57,6 +58,7 @@ type CollapseState = {
|
||||
favorites: boolean;
|
||||
channels: boolean;
|
||||
contacts: boolean;
|
||||
rooms: boolean;
|
||||
repeaters: boolean;
|
||||
};
|
||||
|
||||
@@ -67,6 +69,7 @@ const DEFAULT_COLLAPSE_STATE: CollapseState = {
|
||||
favorites: false,
|
||||
channels: false,
|
||||
contacts: false,
|
||||
rooms: false,
|
||||
repeaters: false,
|
||||
};
|
||||
|
||||
@@ -80,6 +83,7 @@ function loadCollapsedState(): CollapseState {
|
||||
favorites: parsed.favorites ?? DEFAULT_COLLAPSE_STATE.favorites,
|
||||
channels: parsed.channels ?? DEFAULT_COLLAPSE_STATE.channels,
|
||||
contacts: parsed.contacts ?? DEFAULT_COLLAPSE_STATE.contacts,
|
||||
rooms: parsed.rooms ?? DEFAULT_COLLAPSE_STATE.rooms,
|
||||
repeaters: parsed.repeaters ?? DEFAULT_COLLAPSE_STATE.repeaters,
|
||||
};
|
||||
} catch {
|
||||
@@ -157,6 +161,7 @@ export function Sidebar({
|
||||
const [favoritesCollapsed, setFavoritesCollapsed] = useState(initialCollapsedState.favorites);
|
||||
const [channelsCollapsed, setChannelsCollapsed] = useState(initialCollapsedState.channels);
|
||||
const [contactsCollapsed, setContactsCollapsed] = useState(initialCollapsedState.contacts);
|
||||
const [roomsCollapsed, setRoomsCollapsed] = useState(initialCollapsedState.rooms);
|
||||
const [repeatersCollapsed, setRepeatersCollapsed] = useState(initialCollapsedState.repeaters);
|
||||
const collapseSnapshotRef = useRef<CollapseState | null>(null);
|
||||
const sectionSortSourceRef = useRef(initialSectionSortState.source);
|
||||
@@ -352,12 +357,23 @@ export function Sidebar({
|
||||
const sortedNonRepeaterContacts = useMemo(
|
||||
() =>
|
||||
sortContactsByOrder(
|
||||
uniqueContacts.filter((c) => c.type !== CONTACT_TYPE_REPEATER),
|
||||
uniqueContacts.filter(
|
||||
(c) => c.type !== CONTACT_TYPE_REPEATER && c.type !== CONTACT_TYPE_ROOM
|
||||
),
|
||||
sectionSortOrders.contacts
|
||||
),
|
||||
[uniqueContacts, sectionSortOrders.contacts, sortContactsByOrder]
|
||||
);
|
||||
|
||||
const sortedRooms = useMemo(
|
||||
() =>
|
||||
sortContactsByOrder(
|
||||
uniqueContacts.filter((c) => c.type === CONTACT_TYPE_ROOM),
|
||||
sectionSortOrders.rooms
|
||||
),
|
||||
[uniqueContacts, sectionSortOrders.rooms, sortContactsByOrder]
|
||||
);
|
||||
|
||||
const sortedRepeaters = useMemo(
|
||||
() =>
|
||||
sortRepeatersByOrder(
|
||||
@@ -392,6 +408,17 @@ export function Sidebar({
|
||||
[sortedNonRepeaterContacts, query]
|
||||
);
|
||||
|
||||
const filteredRooms = useMemo(
|
||||
() =>
|
||||
query
|
||||
? sortedRooms.filter(
|
||||
(c) =>
|
||||
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
||||
)
|
||||
: sortedRooms,
|
||||
[sortedRooms, query]
|
||||
);
|
||||
|
||||
const filteredRepeaters = useMemo(
|
||||
() =>
|
||||
query
|
||||
@@ -412,6 +439,7 @@ export function Sidebar({
|
||||
favorites: favoritesCollapsed,
|
||||
channels: channelsCollapsed,
|
||||
contacts: contactsCollapsed,
|
||||
rooms: roomsCollapsed,
|
||||
repeaters: repeatersCollapsed,
|
||||
};
|
||||
}
|
||||
@@ -421,12 +449,14 @@ export function Sidebar({
|
||||
favoritesCollapsed ||
|
||||
channelsCollapsed ||
|
||||
contactsCollapsed ||
|
||||
roomsCollapsed ||
|
||||
repeatersCollapsed
|
||||
) {
|
||||
setToolsCollapsed(false);
|
||||
setFavoritesCollapsed(false);
|
||||
setChannelsCollapsed(false);
|
||||
setContactsCollapsed(false);
|
||||
setRoomsCollapsed(false);
|
||||
setRepeatersCollapsed(false);
|
||||
}
|
||||
return;
|
||||
@@ -439,6 +469,7 @@ export function Sidebar({
|
||||
setFavoritesCollapsed(prev.favorites);
|
||||
setChannelsCollapsed(prev.channels);
|
||||
setContactsCollapsed(prev.contacts);
|
||||
setRoomsCollapsed(prev.rooms);
|
||||
setRepeatersCollapsed(prev.repeaters);
|
||||
}
|
||||
}, [
|
||||
@@ -447,6 +478,7 @@ export function Sidebar({
|
||||
favoritesCollapsed,
|
||||
channelsCollapsed,
|
||||
contactsCollapsed,
|
||||
roomsCollapsed,
|
||||
repeatersCollapsed,
|
||||
]);
|
||||
|
||||
@@ -458,6 +490,7 @@ export function Sidebar({
|
||||
favorites: favoritesCollapsed,
|
||||
channels: channelsCollapsed,
|
||||
contacts: contactsCollapsed,
|
||||
rooms: roomsCollapsed,
|
||||
repeaters: repeatersCollapsed,
|
||||
};
|
||||
|
||||
@@ -472,45 +505,56 @@ export function Sidebar({
|
||||
favoritesCollapsed,
|
||||
channelsCollapsed,
|
||||
contactsCollapsed,
|
||||
roomsCollapsed,
|
||||
repeatersCollapsed,
|
||||
]);
|
||||
|
||||
// Separate favorites from regular items, and build combined favorites list
|
||||
const { favoriteItems, nonFavoriteChannels, nonFavoriteContacts, nonFavoriteRepeaters } =
|
||||
useMemo(() => {
|
||||
const favChannels = filteredChannels.filter((c) => isFavorite(favorites, 'channel', c.key));
|
||||
const favContacts = [...filteredNonRepeaterContacts, ...filteredRepeaters].filter((c) =>
|
||||
isFavorite(favorites, 'contact', c.public_key)
|
||||
);
|
||||
const nonFavChannels = filteredChannels.filter(
|
||||
(c) => !isFavorite(favorites, 'channel', c.key)
|
||||
);
|
||||
const nonFavContacts = filteredNonRepeaterContacts.filter(
|
||||
(c) => !isFavorite(favorites, 'contact', c.public_key)
|
||||
);
|
||||
const nonFavRepeaters = filteredRepeaters.filter(
|
||||
(c) => !isFavorite(favorites, 'contact', c.public_key)
|
||||
);
|
||||
const {
|
||||
favoriteItems,
|
||||
nonFavoriteChannels,
|
||||
nonFavoriteContacts,
|
||||
nonFavoriteRooms,
|
||||
nonFavoriteRepeaters,
|
||||
} = useMemo(() => {
|
||||
const favChannels = filteredChannels.filter((c) => isFavorite(favorites, 'channel', c.key));
|
||||
const favContacts = [
|
||||
...filteredNonRepeaterContacts,
|
||||
...filteredRooms,
|
||||
...filteredRepeaters,
|
||||
].filter((c) => isFavorite(favorites, 'contact', c.public_key));
|
||||
const nonFavChannels = filteredChannels.filter((c) => !isFavorite(favorites, 'channel', c.key));
|
||||
const nonFavContacts = filteredNonRepeaterContacts.filter(
|
||||
(c) => !isFavorite(favorites, 'contact', c.public_key)
|
||||
);
|
||||
const nonFavRooms = filteredRooms.filter(
|
||||
(c) => !isFavorite(favorites, 'contact', c.public_key)
|
||||
);
|
||||
const nonFavRepeaters = filteredRepeaters.filter(
|
||||
(c) => !isFavorite(favorites, 'contact', c.public_key)
|
||||
);
|
||||
|
||||
const items: FavoriteItem[] = [
|
||||
...favChannels.map((channel) => ({ type: 'channel' as const, channel })),
|
||||
...favContacts.map((contact) => ({ type: 'contact' as const, contact })),
|
||||
];
|
||||
const items: FavoriteItem[] = [
|
||||
...favChannels.map((channel) => ({ type: 'channel' as const, channel })),
|
||||
...favContacts.map((contact) => ({ type: 'contact' as const, contact })),
|
||||
];
|
||||
|
||||
return {
|
||||
favoriteItems: sortFavoriteItemsByOrder(items, sectionSortOrders.favorites),
|
||||
nonFavoriteChannels: nonFavChannels,
|
||||
nonFavoriteContacts: nonFavContacts,
|
||||
nonFavoriteRepeaters: nonFavRepeaters,
|
||||
};
|
||||
}, [
|
||||
filteredChannels,
|
||||
filteredNonRepeaterContacts,
|
||||
filteredRepeaters,
|
||||
favorites,
|
||||
sectionSortOrders.favorites,
|
||||
sortFavoriteItemsByOrder,
|
||||
]);
|
||||
return {
|
||||
favoriteItems: sortFavoriteItemsByOrder(items, sectionSortOrders.favorites),
|
||||
nonFavoriteChannels: nonFavChannels,
|
||||
nonFavoriteContacts: nonFavContacts,
|
||||
nonFavoriteRooms: nonFavRooms,
|
||||
nonFavoriteRepeaters: nonFavRepeaters,
|
||||
};
|
||||
}, [
|
||||
filteredChannels,
|
||||
filteredNonRepeaterContacts,
|
||||
filteredRooms,
|
||||
filteredRepeaters,
|
||||
favorites,
|
||||
sectionSortOrders.favorites,
|
||||
sortFavoriteItemsByOrder,
|
||||
]);
|
||||
|
||||
const buildChannelRow = (channel: Channel, keyPrefix: string): ConversationRow => ({
|
||||
key: `${keyPrefix}-${channel.key}`,
|
||||
@@ -638,11 +682,13 @@ export function Sidebar({
|
||||
);
|
||||
const channelRows = nonFavoriteChannels.map((channel) => buildChannelRow(channel, 'chan'));
|
||||
const contactRows = nonFavoriteContacts.map((contact) => buildContactRow(contact, 'contact'));
|
||||
const roomRows = nonFavoriteRooms.map((contact) => buildContactRow(contact, 'room'));
|
||||
const repeaterRows = nonFavoriteRepeaters.map((contact) => buildContactRow(contact, 'repeater'));
|
||||
|
||||
const favoritesUnreadCount = getSectionUnreadCount(favoriteRows);
|
||||
const channelsUnreadCount = getSectionUnreadCount(channelRows);
|
||||
const contactsUnreadCount = getSectionUnreadCount(contactRows);
|
||||
const roomsUnreadCount = getSectionUnreadCount(roomRows);
|
||||
const repeatersUnreadCount = getSectionUnreadCount(repeaterRows);
|
||||
const favoritesHasMention = sectionHasMention(favoriteRows);
|
||||
const channelsHasMention = sectionHasMention(channelRows);
|
||||
@@ -899,6 +945,21 @@ export function Sidebar({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Room Servers */}
|
||||
{nonFavoriteRooms.length > 0 && (
|
||||
<>
|
||||
{renderSectionHeader(
|
||||
'Room Servers',
|
||||
roomsCollapsed,
|
||||
() => setRoomsCollapsed((prev) => !prev),
|
||||
'rooms',
|
||||
roomsUnreadCount,
|
||||
roomsUnreadCount > 0
|
||||
)}
|
||||
{(isSearching || !roomsCollapsed) && roomRows.map((row) => renderConversationRow(row))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Repeaters */}
|
||||
{nonFavoriteRepeaters.length > 0 && (
|
||||
<>
|
||||
@@ -916,6 +977,7 @@ export function Sidebar({
|
||||
|
||||
{/* Empty state */}
|
||||
{nonFavoriteContacts.length === 0 &&
|
||||
nonFavoriteRooms.length === 0 &&
|
||||
nonFavoriteChannels.length === 0 &&
|
||||
nonFavoriteRepeaters.length === 0 &&
|
||||
favoriteItems.length === 0 && (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ChatHeader } from '../components/ChatHeader';
|
||||
import type { Channel, Contact, Conversation, Favorite, PathDiscoveryResponse } from '../types';
|
||||
import { CONTACT_TYPE_ROOM } from '../types';
|
||||
import { PUBLIC_CHANNEL_KEY } from '../utils/publicChannel';
|
||||
|
||||
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
|
||||
@@ -170,6 +171,38 @@ describe('ChatHeader key visibility', () => {
|
||||
expect(onToggleNotifications).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hides trace and notification controls for room-server contacts', () => {
|
||||
const pubKey = '41'.repeat(32);
|
||||
const contact: Contact = {
|
||||
public_key: pubKey,
|
||||
name: 'Ops Board',
|
||||
type: CONTACT_TYPE_ROOM,
|
||||
flags: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
};
|
||||
const conversation: Conversation = { type: 'contact', id: pubKey, name: 'Ops Board' };
|
||||
|
||||
render(
|
||||
<ChatHeader {...baseProps} conversation={conversation} channels={[]} contacts={[contact]} />
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Path Discovery' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Direct Trace' })).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Enable notifications for this conversation' })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the delete button for the canonical Public channel', () => {
|
||||
const channel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public', false);
|
||||
const conversation: Conversation = { type: 'channel', id: PUBLIC_CHANNEL_KEY, name: 'Public' };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getContactAvatar } from '../utils/contactAvatar';
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
||||
|
||||
describe('getContactAvatar', () => {
|
||||
it('returns complete avatar info', () => {
|
||||
@@ -30,6 +30,13 @@ describe('getContactAvatar', () => {
|
||||
expect(avatar1.background).toBe(avatar2.background);
|
||||
});
|
||||
|
||||
it('returns room avatar for type=3', () => {
|
||||
const avatar = getContactAvatar('Ops Board', 'abc123def456', CONTACT_TYPE_ROOM);
|
||||
expect(avatar.text).toBe('🛖');
|
||||
expect(avatar.background).toBe('#6b4f2a');
|
||||
expect(avatar.textColor).toBe('#ffffff');
|
||||
});
|
||||
|
||||
it('non-repeater types use normal avatar', () => {
|
||||
const avatar0 = getContactAvatar('John', 'abc123', 0);
|
||||
const avatar1 = getContactAvatar('John', 'abc123', 1);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ConversationPane } from '../components/ConversationPane';
|
||||
@@ -41,6 +41,21 @@ vi.mock('../components/RepeaterDashboard', () => ({
|
||||
RepeaterDashboard: () => <div data-testid="repeater-dashboard" />,
|
||||
}));
|
||||
|
||||
vi.mock('../components/RoomServerPanel', () => ({
|
||||
RoomServerPanel: ({
|
||||
onAuthenticatedChange,
|
||||
}: {
|
||||
onAuthenticatedChange?: (value: boolean) => void;
|
||||
}) => (
|
||||
<div>
|
||||
<div data-testid="room-server-panel" />
|
||||
<button type="button" onClick={() => onAuthenticatedChange?.(true)}>
|
||||
Authenticate room
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../components/MapView', () => ({
|
||||
MapView: () => <div data-testid="map-view" />,
|
||||
}));
|
||||
@@ -216,6 +231,54 @@ describe('ConversationPane', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('gates room chat behind room login controls until authenticated', async () => {
|
||||
render(
|
||||
<ConversationPane
|
||||
{...createProps({
|
||||
activeConversation: {
|
||||
type: 'contact',
|
||||
id: 'cc'.repeat(32),
|
||||
name: 'Ops Board',
|
||||
},
|
||||
contacts: [
|
||||
{
|
||||
public_key: 'cc'.repeat(32),
|
||||
name: 'Ops Board',
|
||||
type: 3,
|
||||
flags: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
},
|
||||
],
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('room-server-panel')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('chat-header')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('message-list')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('message-input')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Authenticate room' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('message-list')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('message-input')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('passes unread marker props to MessageList only for channel conversations', async () => {
|
||||
render(
|
||||
<ConversationPane
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MessageList } from '../components/MessageList';
|
||||
import type { Message } from '../types';
|
||||
import { CONTACT_TYPE_ROOM, type Contact, type Message } from '../types';
|
||||
|
||||
const scrollIntoViewMock = vi.fn();
|
||||
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
|
||||
@@ -81,6 +81,46 @@ describe('MessageList channel sender rendering', () => {
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders room-server DM messages using stored sender attribution instead of the room contact', () => {
|
||||
const roomContact: Contact = {
|
||||
public_key: 'ab'.repeat(32),
|
||||
name: 'Ops Board',
|
||||
type: CONTACT_TYPE_ROOM,
|
||||
flags: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
};
|
||||
|
||||
render(
|
||||
<MessageList
|
||||
messages={[
|
||||
createMessage({
|
||||
type: 'PRIV',
|
||||
conversation_key: roomContact.public_key,
|
||||
text: 'status update: ready',
|
||||
sender_name: 'Alice',
|
||||
sender_key: '12'.repeat(32),
|
||||
}),
|
||||
]}
|
||||
contacts={[roomContact]}
|
||||
loading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Alice')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Ops Board')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('status update: ready')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('gives clickable sender avatars an accessible label', () => {
|
||||
render(
|
||||
<MessageList
|
||||
|
||||
@@ -2,7 +2,13 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Sidebar } from '../components/Sidebar';
|
||||
import { CONTACT_TYPE_REPEATER, type Channel, type Contact, type Favorite } from '../types';
|
||||
import {
|
||||
CONTACT_TYPE_REPEATER,
|
||||
CONTACT_TYPE_ROOM,
|
||||
type Channel,
|
||||
type Contact,
|
||||
type Favorite,
|
||||
} from '../types';
|
||||
import { getStateKey, type ConversationTimes } from '../utils/conversationState';
|
||||
import { PUBLIC_CHANNEL_KEY } from '../utils/publicChannel';
|
||||
|
||||
@@ -51,16 +57,19 @@ function renderSidebar(overrides?: {
|
||||
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
|
||||
}) {
|
||||
const aliceName = 'Alice';
|
||||
const roomName = 'Ops Board';
|
||||
const publicChannel = makeChannel('AA'.repeat(16), 'Public');
|
||||
const flightChannel = makeChannel('BB'.repeat(16), '#flight');
|
||||
const opsChannel = makeChannel('CC'.repeat(16), '#ops');
|
||||
const alice = makeContact('11'.repeat(32), aliceName);
|
||||
const board = makeContact('33'.repeat(32), roomName, CONTACT_TYPE_ROOM);
|
||||
const relay = makeContact('22'.repeat(32), 'Relay', CONTACT_TYPE_REPEATER);
|
||||
|
||||
const unreadCounts = overrides?.unreadCounts ?? {
|
||||
[getStateKey('channel', flightChannel.key)]: 2,
|
||||
[getStateKey('channel', opsChannel.key)]: 1,
|
||||
[getStateKey('contact', alice.public_key)]: 3,
|
||||
[getStateKey('contact', board.public_key)]: 5,
|
||||
[getStateKey('contact', relay.public_key)]: 4,
|
||||
};
|
||||
|
||||
@@ -69,7 +78,7 @@ function renderSidebar(overrides?: {
|
||||
|
||||
const view = render(
|
||||
<Sidebar
|
||||
contacts={[alice, relay]}
|
||||
contacts={[alice, board, relay]}
|
||||
channels={channels}
|
||||
activeConversation={null}
|
||||
onSelectConversation={vi.fn()}
|
||||
@@ -87,7 +96,7 @@ function renderSidebar(overrides?: {
|
||||
/>
|
||||
);
|
||||
|
||||
return { ...view, flightChannel, opsChannel, aliceName };
|
||||
return { ...view, flightChannel, opsChannel, aliceName, roomName };
|
||||
}
|
||||
|
||||
function getSectionHeaderContainer(title: string): HTMLElement {
|
||||
@@ -108,6 +117,7 @@ describe('Sidebar section summaries', () => {
|
||||
expect(within(getSectionHeaderContainer('Favorites')).getByText('2')).toBeInTheDocument();
|
||||
expect(within(getSectionHeaderContainer('Channels')).getByText('1')).toBeInTheDocument();
|
||||
expect(within(getSectionHeaderContainer('Contacts')).getByText('3')).toBeInTheDocument();
|
||||
expect(within(getSectionHeaderContainer('Room Servers')).getByText('5')).toBeInTheDocument();
|
||||
expect(within(getSectionHeaderContainer('Repeaters')).getByText('4')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -169,16 +179,25 @@ describe('Sidebar section summaries', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('renders room servers in their own section', () => {
|
||||
const { roomName } = renderSidebar();
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Room Servers' })).toBeInTheDocument();
|
||||
expect(screen.getByText(roomName)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('expands collapsed sections during search and restores collapse state after clearing search', async () => {
|
||||
const { opsChannel, aliceName } = renderSidebar();
|
||||
const { opsChannel, aliceName, roomName } = renderSidebar();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Tools' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Channels' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Contacts' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Room Servers' }));
|
||||
|
||||
expect(screen.queryByText('Packet Feed')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(aliceName)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(roomName)).not.toBeInTheDocument();
|
||||
|
||||
const search = screen.getByLabelText('Search conversations');
|
||||
fireEvent.change(search, { target: { value: 'alice' } });
|
||||
@@ -193,19 +212,22 @@ describe('Sidebar section summaries', () => {
|
||||
expect(screen.queryByText('Packet Feed')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(aliceName)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(roomName)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('persists collapsed section state across unmount and remount', () => {
|
||||
const { opsChannel, aliceName, unmount } = renderSidebar();
|
||||
const { opsChannel, aliceName, roomName, unmount } = renderSidebar();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Tools' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Channels' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Contacts' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Room Servers' }));
|
||||
|
||||
expect(screen.queryByText('Packet Feed')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(aliceName)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(roomName)).not.toBeInTheDocument();
|
||||
|
||||
unmount();
|
||||
renderSidebar();
|
||||
@@ -213,6 +235,7 @@ describe('Sidebar section summaries', () => {
|
||||
expect(screen.queryByText('Packet Feed')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(aliceName)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(roomName)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders same-name channels when keys differ and allows selecting both', () => {
|
||||
@@ -289,6 +312,12 @@ describe('Sidebar section summaries', () => {
|
||||
const alphaChannel = makeChannel('CC'.repeat(16), '#alpha');
|
||||
const zed = makeContact('11'.repeat(32), 'Zed', 1, { last_advert: 150 });
|
||||
const amy = makeContact('22'.repeat(32), 'Amy');
|
||||
const zebraRoom = makeContact('55'.repeat(32), 'Zebra Room', CONTACT_TYPE_ROOM, {
|
||||
last_seen: 100,
|
||||
});
|
||||
const alphaRoom = makeContact('66'.repeat(32), 'Alpha Room', CONTACT_TYPE_ROOM, {
|
||||
last_advert: 300,
|
||||
});
|
||||
const relayZulu = makeContact('33'.repeat(32), 'Zulu Relay', CONTACT_TYPE_REPEATER, {
|
||||
last_seen: 100,
|
||||
});
|
||||
@@ -297,7 +326,7 @@ describe('Sidebar section summaries', () => {
|
||||
});
|
||||
|
||||
const props = {
|
||||
contacts: [zed, amy, relayZulu, relayAlpha],
|
||||
contacts: [zed, amy, zebraRoom, alphaRoom, relayZulu, relayAlpha],
|
||||
channels: [publicChannel, zebraChannel, alphaChannel],
|
||||
activeConversation: null,
|
||||
onSelectConversation: vi.fn(),
|
||||
@@ -306,6 +335,7 @@ describe('Sidebar section summaries', () => {
|
||||
[getStateKey('channel', zebraChannel.key)]: 300,
|
||||
[getStateKey('channel', alphaChannel.key)]: 100,
|
||||
[getStateKey('contact', zed.public_key)]: 200,
|
||||
[getStateKey('contact', zebraRoom.public_key)]: 350,
|
||||
},
|
||||
unreadCounts: {},
|
||||
mentions: {},
|
||||
@@ -328,18 +358,26 @@ describe('Sidebar section summaries', () => {
|
||||
.getAllByText(/Relay$/)
|
||||
.map((node) => node.textContent)
|
||||
.filter((text): text is string => Boolean(text));
|
||||
const getRoomsOrder = () =>
|
||||
screen
|
||||
.getAllByText(/Room$/)
|
||||
.map((node) => node.textContent)
|
||||
.filter((text): text is string => Boolean(text));
|
||||
|
||||
const { unmount } = render(<Sidebar {...props} />);
|
||||
|
||||
expect(getChannelsOrder()).toEqual(['#zebra', '#alpha']);
|
||||
expect(getContactsOrder()).toEqual(['Zed', 'Amy']);
|
||||
expect(getRoomsOrder()).toEqual(['Zebra Room', 'Alpha Room']);
|
||||
expect(getRepeatersOrder()).toEqual(['Alpha Relay', 'Zulu Relay']);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sort Channels alphabetically' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sort Contacts alphabetically' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sort Room Servers alphabetically' }));
|
||||
|
||||
expect(getChannelsOrder()).toEqual(['#alpha', '#zebra']);
|
||||
expect(getContactsOrder()).toEqual(['Amy', 'Zed']);
|
||||
expect(getRoomsOrder()).toEqual(['Alpha Room', 'Zebra Room']);
|
||||
expect(getRepeatersOrder()).toEqual(['Alpha Relay', 'Zulu Relay']);
|
||||
|
||||
unmount();
|
||||
@@ -347,9 +385,49 @@ describe('Sidebar section summaries', () => {
|
||||
|
||||
expect(getChannelsOrder()).toEqual(['#alpha', '#zebra']);
|
||||
expect(getContactsOrder()).toEqual(['Amy', 'Zed']);
|
||||
expect(getRoomsOrder()).toEqual(['Alpha Room', 'Zebra Room']);
|
||||
expect(getRepeatersOrder()).toEqual(['Alpha Relay', 'Zulu Relay']);
|
||||
});
|
||||
|
||||
it('sorts room servers like contacts by DM recency first, then advert recency', () => {
|
||||
const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
|
||||
const dmRecentRoom = makeContact('77'.repeat(32), 'DM Recent Room', CONTACT_TYPE_ROOM, {
|
||||
last_advert: 100,
|
||||
});
|
||||
const advertOnlyRoom = makeContact('88'.repeat(32), 'Advert Only Room', CONTACT_TYPE_ROOM, {
|
||||
last_seen: 300,
|
||||
});
|
||||
const noRecencyRoom = makeContact('99'.repeat(32), 'No Recency Room', CONTACT_TYPE_ROOM);
|
||||
|
||||
render(
|
||||
<Sidebar
|
||||
contacts={[noRecencyRoom, advertOnlyRoom, dmRecentRoom]}
|
||||
channels={[publicChannel]}
|
||||
activeConversation={null}
|
||||
onSelectConversation={vi.fn()}
|
||||
onNewMessage={vi.fn()}
|
||||
lastMessageTimes={{
|
||||
[getStateKey('contact', dmRecentRoom.public_key)]: 400,
|
||||
}}
|
||||
unreadCounts={{}}
|
||||
mentions={{}}
|
||||
showCracker={false}
|
||||
crackerRunning={false}
|
||||
onToggleCracker={vi.fn()}
|
||||
onMarkAllRead={vi.fn()}
|
||||
favorites={[]}
|
||||
legacySortOrder="recent"
|
||||
/>
|
||||
);
|
||||
|
||||
const roomRows = screen
|
||||
.getAllByText(/Room$/)
|
||||
.map((node) => node.textContent)
|
||||
.filter((text): text is string => Boolean(text));
|
||||
|
||||
expect(roomRows).toEqual(['DM Recent Room', 'Advert Only Room', 'No Recency Room']);
|
||||
});
|
||||
|
||||
it('sorts contacts by DM recency first, then advert recency, then no-recency at the bottom', () => {
|
||||
const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
|
||||
const dmRecent = makeContact('11'.repeat(32), 'DM Recent', 1, { last_advert: 100 });
|
||||
|
||||
@@ -352,6 +352,7 @@ export interface MigratePreferencesResponse {
|
||||
|
||||
/** Contact type constants */
|
||||
export const CONTACT_TYPE_REPEATER = 2;
|
||||
export const CONTACT_TYPE_ROOM = 3;
|
||||
|
||||
export interface NeighborInfo {
|
||||
pubkey_prefix: string;
|
||||
|
||||
@@ -3,18 +3,24 @@
|
||||
*
|
||||
* Uses the contact's public key to generate a consistent background color,
|
||||
* and extracts initials or emoji from the name for display.
|
||||
* Repeaters (type=2) always show 🛜 with a gray background.
|
||||
* Repeaters (type=2) and room servers (type=3) always show a fixed glyph.
|
||||
*/
|
||||
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
||||
|
||||
// Repeater avatar styling
|
||||
// Fixed contact-type avatar styling
|
||||
const REPEATER_AVATAR = {
|
||||
text: '🛜',
|
||||
background: '#444444',
|
||||
textColor: '#ffffff',
|
||||
};
|
||||
|
||||
const ROOM_AVATAR = {
|
||||
text: '🛖',
|
||||
background: '#6b4f2a',
|
||||
textColor: '#ffffff',
|
||||
};
|
||||
|
||||
// DJB2 hash function for strings
|
||||
export function hashString(str: string): number {
|
||||
let hash = 0;
|
||||
@@ -103,7 +109,7 @@ function getAvatarColor(publicKey: string): {
|
||||
|
||||
/**
|
||||
* Get all avatar properties for a contact.
|
||||
* Repeaters (type=2) always get a special gray avatar with 🛜.
|
||||
* Repeaters and room servers always get a special fixed avatar.
|
||||
*/
|
||||
export function getContactAvatar(
|
||||
name: string | null,
|
||||
@@ -114,10 +120,12 @@ export function getContactAvatar(
|
||||
background: string;
|
||||
textColor: string;
|
||||
} {
|
||||
// Repeaters always get the repeater avatar
|
||||
if (contactType === CONTACT_TYPE_REPEATER) {
|
||||
return REPEATER_AVATAR;
|
||||
}
|
||||
if (contactType === CONTACT_TYPE_ROOM) {
|
||||
return ROOM_AVATAR;
|
||||
}
|
||||
|
||||
const text = getAvatarText(name, publicKey);
|
||||
const colors = getAvatarColor(publicKey);
|
||||
|
||||
@@ -15,7 +15,7 @@ const SIDEBAR_SECTION_SORT_ORDERS_KEY = 'remoteterm-sidebar-section-sort-orders'
|
||||
|
||||
export type ConversationTimes = Record<string, number>;
|
||||
export type SortOrder = 'recent' | 'alpha';
|
||||
export type SidebarSortableSection = 'favorites' | 'channels' | 'contacts' | 'repeaters';
|
||||
export type SidebarSortableSection = 'favorites' | 'channels' | 'contacts' | 'rooms' | 'repeaters';
|
||||
export type SidebarSectionSortOrders = Record<SidebarSortableSection, SortOrder>;
|
||||
|
||||
// In-memory cache of last message times (loaded from server on init)
|
||||
@@ -116,6 +116,7 @@ export function buildSidebarSectionSortOrders(
|
||||
favorites: defaultOrder,
|
||||
channels: defaultOrder,
|
||||
contacts: defaultOrder,
|
||||
rooms: defaultOrder,
|
||||
repeaters: defaultOrder,
|
||||
};
|
||||
}
|
||||
@@ -133,6 +134,7 @@ export function loadLocalStorageSidebarSectionSortOrders(): SidebarSectionSortOr
|
||||
favorites: parsed.favorites === 'alpha' ? 'alpha' : 'recent',
|
||||
channels: parsed.channels === 'alpha' ? 'alpha' : 'recent',
|
||||
contacts: parsed.contacts === 'alpha' ? 'alpha' : 'recent',
|
||||
rooms: parsed.rooms === 'alpha' ? 'alpha' : 'recent',
|
||||
repeaters: parsed.repeaters === 'alpha' ? 'alpha' : 'recent',
|
||||
};
|
||||
} catch {
|
||||
|
||||
@@ -898,6 +898,33 @@ class TestDirectMessageDecryption:
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_decrypt_signed_room_post_extracts_author_prefix(self):
|
||||
"""TXT_TYPE_SIGNED_PLAIN room posts expose the 4-byte author prefix separately."""
|
||||
shared_secret = bytes(range(32))
|
||||
timestamp = 1_700_000_000
|
||||
flags = (2 << 2) | 1
|
||||
author_prefix = bytes.fromhex("aabbccdd")
|
||||
plaintext = (
|
||||
timestamp.to_bytes(4, "little")
|
||||
+ bytes([flags])
|
||||
+ author_prefix
|
||||
+ b"hello room"
|
||||
+ b"\x00"
|
||||
)
|
||||
padded = plaintext + (b"\x00" * ((16 - (len(plaintext) % 16)) % 16))
|
||||
cipher = AES.new(shared_secret[:16], AES.MODE_ECB)
|
||||
ciphertext = cipher.encrypt(padded)
|
||||
mac = hmac.new(shared_secret, ciphertext, hashlib.sha256).digest()[:2]
|
||||
payload = bytes.fromhex("1020") + mac + ciphertext
|
||||
|
||||
result = decrypt_direct_message(payload, shared_secret)
|
||||
|
||||
assert result is not None
|
||||
assert result.txt_type == 2
|
||||
assert result.attempt == 1
|
||||
assert result.signed_sender_prefix == "aabbccdd"
|
||||
assert result.message == "hello room"
|
||||
|
||||
|
||||
class TestTryDecryptDM:
|
||||
"""Test full packet decryption for direct messages."""
|
||||
|
||||
@@ -429,6 +429,69 @@ class TestContactMessageCLIFiltering:
|
||||
assert event_type == "message_acked"
|
||||
assert set(payload.keys()) == EXPECTED_ACK_KEYS
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_room_server_message_uses_author_prefix_for_sender_metadata(self, test_db):
|
||||
from app.event_handlers import on_contact_message
|
||||
|
||||
room_key = "ab" * 32
|
||||
author_key = "12345678" + ("cd" * 28)
|
||||
await ContactRepository.upsert(
|
||||
{
|
||||
"public_key": room_key,
|
||||
"name": "Ops Board",
|
||||
"type": 3,
|
||||
"flags": 0,
|
||||
"direct_path": None,
|
||||
"direct_path_len": -1,
|
||||
"direct_path_hash_mode": -1,
|
||||
"last_advert": None,
|
||||
"lat": None,
|
||||
"lon": None,
|
||||
"last_seen": None,
|
||||
"on_radio": False,
|
||||
"last_contacted": None,
|
||||
"first_seen": None,
|
||||
}
|
||||
)
|
||||
await ContactRepository.upsert(
|
||||
{
|
||||
"public_key": author_key,
|
||||
"name": "Alice",
|
||||
"type": 1,
|
||||
"flags": 0,
|
||||
"direct_path": None,
|
||||
"direct_path_len": -1,
|
||||
"direct_path_hash_mode": -1,
|
||||
"last_advert": None,
|
||||
"lat": None,
|
||||
"lon": None,
|
||||
"last_seen": None,
|
||||
"on_radio": False,
|
||||
"last_contacted": None,
|
||||
"first_seen": None,
|
||||
}
|
||||
)
|
||||
|
||||
with patch("app.event_handlers.broadcast_event") as mock_broadcast:
|
||||
|
||||
class MockEvent:
|
||||
payload = {
|
||||
"pubkey_prefix": room_key[:12],
|
||||
"text": "hello room",
|
||||
"txt_type": 2,
|
||||
"signature": author_key[:8],
|
||||
"sender_timestamp": 1700000000,
|
||||
}
|
||||
|
||||
await on_contact_message(MockEvent())
|
||||
|
||||
event_type, payload = mock_broadcast.call_args_list[-1][0]
|
||||
assert event_type == "message"
|
||||
assert payload["conversation_key"] == room_key
|
||||
assert payload["sender_name"] == "Alice"
|
||||
assert payload["sender_key"] == author_key
|
||||
assert payload["signature"] == author_key[:8]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_txt_type_defaults_to_normal(self, test_db):
|
||||
"""Messages without txt_type field are treated as normal (not filtered)."""
|
||||
|
||||
@@ -1388,6 +1388,56 @@ class TestRepeaterMessageFiltering:
|
||||
)
|
||||
assert len(messages) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_room_cli_response_not_stored(self, test_db, captured_broadcasts):
|
||||
"""Room-server CLI responses should not be stored in chat history."""
|
||||
from app.decoder import DecryptedDirectMessage
|
||||
from app.models import CONTACT_TYPE_ROOM
|
||||
from app.packet_processor import create_dm_message_from_decrypted
|
||||
from app.repository import ContactRepository, MessageRepository, RawPacketRepository
|
||||
|
||||
room_pub = "c3d4e5f6cb0a6fb9816ca956ff22dd7f12e2e5adbbf5e233bd8232774d6cffee"
|
||||
|
||||
await ContactRepository.upsert(
|
||||
{
|
||||
"public_key": room_pub,
|
||||
"name": "Test Room",
|
||||
"type": CONTACT_TYPE_ROOM,
|
||||
"flags": 0,
|
||||
"on_radio": False,
|
||||
}
|
||||
)
|
||||
|
||||
packet_id, _ = await RawPacketRepository.create(b"\x09\x00test", 1700000000)
|
||||
|
||||
decrypted = DecryptedDirectMessage(
|
||||
timestamp=1700000000,
|
||||
flags=(1 << 2),
|
||||
message="> status ok",
|
||||
dest_hash="fa",
|
||||
src_hash="c3",
|
||||
)
|
||||
|
||||
broadcasts, mock_broadcast = captured_broadcasts
|
||||
|
||||
with patch("app.packet_processor.broadcast_event", mock_broadcast):
|
||||
msg_id = await create_dm_message_from_decrypted(
|
||||
packet_id=packet_id,
|
||||
decrypted=decrypted,
|
||||
their_public_key=room_pub,
|
||||
our_public_key=self.OUR_PUB,
|
||||
received_at=1700000001,
|
||||
outgoing=False,
|
||||
)
|
||||
|
||||
assert msg_id is None
|
||||
assert len(broadcasts) == 0
|
||||
|
||||
messages = await MessageRepository.get_all(
|
||||
msg_type="PRIV", conversation_key=room_pub.lower(), limit=10
|
||||
)
|
||||
assert len(messages) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_message_still_stored(self, test_db, captured_broadcasts):
|
||||
"""Messages from normal clients should still be stored."""
|
||||
|
||||
187
tests/test_room_routes.py
Normal file
187
tests/test_room_routes.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""Tests for room-server contact routes."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from meshcore import EventType
|
||||
|
||||
from app.models import CommandRequest, RepeaterLoginRequest
|
||||
from app.radio import radio_manager
|
||||
from app.repository import ContactRepository
|
||||
from app.routers.repeaters import send_repeater_command
|
||||
from app.routers.rooms import room_acl, room_login, room_status
|
||||
|
||||
ROOM_KEY = "cc" * 32
|
||||
AUTHOR_KEY = "12345678" + ("dd" * 28)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_radio_state():
|
||||
prev = radio_manager._meshcore
|
||||
prev_lock = radio_manager._operation_lock
|
||||
yield
|
||||
radio_manager._meshcore = prev
|
||||
radio_manager._operation_lock = prev_lock
|
||||
|
||||
|
||||
def _radio_result(event_type=EventType.OK, payload=None):
|
||||
result = MagicMock()
|
||||
result.type = event_type
|
||||
result.payload = payload or {}
|
||||
return result
|
||||
|
||||
|
||||
def _mock_mc():
|
||||
mc = MagicMock()
|
||||
mc.commands = MagicMock()
|
||||
mc.commands.send_login = AsyncMock(return_value=_radio_result(EventType.MSG_SENT))
|
||||
mc.commands.req_status_sync = AsyncMock()
|
||||
mc.commands.req_acl_sync = AsyncMock()
|
||||
mc.commands.send_cmd = AsyncMock(return_value=_radio_result(EventType.OK))
|
||||
mc.commands.get_msg = AsyncMock()
|
||||
mc.commands.add_contact = AsyncMock(return_value=_radio_result(EventType.OK))
|
||||
mc.subscribe = MagicMock(return_value=MagicMock(unsubscribe=MagicMock()))
|
||||
mc.stop_auto_message_fetching = AsyncMock()
|
||||
mc.start_auto_message_fetching = AsyncMock()
|
||||
return mc
|
||||
|
||||
|
||||
async def _insert_contact(public_key: str, name: str, contact_type: int):
|
||||
await ContactRepository.upsert(
|
||||
{
|
||||
"public_key": public_key,
|
||||
"name": name,
|
||||
"type": contact_type,
|
||||
"flags": 0,
|
||||
"direct_path": None,
|
||||
"direct_path_len": -1,
|
||||
"direct_path_hash_mode": -1,
|
||||
"last_advert": None,
|
||||
"lat": None,
|
||||
"lon": None,
|
||||
"last_seen": None,
|
||||
"on_radio": False,
|
||||
"last_contacted": None,
|
||||
"first_seen": None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TestRoomLogin:
|
||||
@pytest.mark.asyncio
|
||||
async def test_room_login_success(self, test_db):
|
||||
mc = _mock_mc()
|
||||
await _insert_contact(ROOM_KEY, name="Room Server", contact_type=3)
|
||||
subscriptions: dict[EventType, tuple[object, object]] = {}
|
||||
|
||||
def _subscribe(event_type, callback, attribute_filters=None):
|
||||
subscriptions[event_type] = (callback, attribute_filters)
|
||||
return MagicMock(unsubscribe=MagicMock())
|
||||
|
||||
async def _send_login(*args, **kwargs):
|
||||
callback, _filters = subscriptions[EventType.LOGIN_SUCCESS]
|
||||
callback(_radio_result(EventType.LOGIN_SUCCESS, {"pubkey_prefix": ROOM_KEY[:12]}))
|
||||
return _radio_result(EventType.MSG_SENT)
|
||||
|
||||
mc.subscribe = MagicMock(side_effect=_subscribe)
|
||||
mc.commands.send_login = AsyncMock(side_effect=_send_login)
|
||||
|
||||
with (
|
||||
patch("app.routers.rooms.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
response = await room_login(ROOM_KEY, RepeaterLoginRequest(password="hello"))
|
||||
|
||||
assert response.status == "ok"
|
||||
assert response.authenticated is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_room_login_rejects_non_room(self, test_db):
|
||||
mc = _mock_mc()
|
||||
await _insert_contact(ROOM_KEY, name="Client", contact_type=1)
|
||||
|
||||
with (
|
||||
patch("app.routers.rooms.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await room_login(ROOM_KEY, RepeaterLoginRequest(password="hello"))
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
class TestRoomStatus:
|
||||
@pytest.mark.asyncio
|
||||
async def test_room_status_maps_fields(self, test_db):
|
||||
mc = _mock_mc()
|
||||
await _insert_contact(ROOM_KEY, name="Room Server", contact_type=3)
|
||||
mc.commands.req_status_sync = AsyncMock(
|
||||
return_value={
|
||||
"bat": 4025,
|
||||
"tx_queue_len": 1,
|
||||
"noise_floor": -118,
|
||||
"last_rssi": -82,
|
||||
"last_snr": 6.0,
|
||||
"nb_recv": 80,
|
||||
"nb_sent": 40,
|
||||
"airtime": 120,
|
||||
"rx_airtime": 240,
|
||||
"uptime": 600,
|
||||
"sent_flood": 5,
|
||||
"sent_direct": 35,
|
||||
"recv_flood": 7,
|
||||
"recv_direct": 73,
|
||||
"flood_dups": 2,
|
||||
"direct_dups": 1,
|
||||
"full_evts": 0,
|
||||
}
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.rooms.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
response = await room_status(ROOM_KEY)
|
||||
|
||||
assert response.battery_volts == 4.025
|
||||
assert response.packets_received == 80
|
||||
assert response.recv_direct == 73
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_room_acl_maps_entries(self, test_db):
|
||||
mc = _mock_mc()
|
||||
await _insert_contact(ROOM_KEY, name="Room Server", contact_type=3)
|
||||
await _insert_contact(AUTHOR_KEY, name="Author", contact_type=1)
|
||||
mc.commands.req_acl_sync = AsyncMock(return_value=[{"key": AUTHOR_KEY[:12], "perm": 3}])
|
||||
|
||||
with (
|
||||
patch("app.routers.rooms.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
response = await room_acl(ROOM_KEY)
|
||||
|
||||
assert len(response.acl) == 1
|
||||
assert response.acl[0].name == "Author"
|
||||
assert response.acl[0].permission_name == "Admin"
|
||||
|
||||
|
||||
class TestRoomCommandReuse:
|
||||
@pytest.mark.asyncio
|
||||
async def test_generic_command_route_accepts_room_servers(self, test_db):
|
||||
mc = _mock_mc()
|
||||
await _insert_contact(ROOM_KEY, name="Room Server", contact_type=3)
|
||||
mc.commands.get_msg = AsyncMock(
|
||||
return_value=_radio_result(
|
||||
EventType.CONTACT_MSG_RECV,
|
||||
{"pubkey_prefix": ROOM_KEY[:12], "text": "> ok", "txt_type": 1},
|
||||
)
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.repeaters.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
response = await send_repeater_command(ROOM_KEY, CommandRequest(command="ver"))
|
||||
|
||||
assert response.response == "ok"
|
||||
Reference in New Issue
Block a user