diff --git a/app/decoder.py b/app/decoder.py index c34f5d4..e17b8a2 100644 --- a/app/decoder.py +++ b/app/decoder.py @@ -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, ) diff --git a/app/event_handlers.py b/app/event_handlers.py index 269de32..7a96f80 100644 --- a/app/event_handlers.py +++ b/app/event_handlers.py @@ -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, ) diff --git a/app/main.py b/app/main.py index fa509fc..911635b 100644 --- a/app/main.py +++ b/app/main.py @@ -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") diff --git a/app/models.py b/app/models.py index 3d3790c..650ec18 100644 --- a/app/models.py +++ b/app/models.py @@ -231,6 +231,7 @@ class ContactRoutingOverrideRequest(BaseModel): # Contact type constants CONTACT_TYPE_REPEATER = 2 +CONTACT_TYPE_ROOM = 3 class ContactAdvertPath(BaseModel): diff --git a/app/routers/repeaters.py b/app/routers/repeaters.py index 22057a1..fe9744f 100644 --- a/app/routers/repeaters.py +++ b/app/routers/repeaters.py @@ -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 - - get tx, set tx - - get radio, set radio - - tempradio - - setperm (0=guest, 1=read-only, 2=read-write, 3=admin) - - clock, clock sync, time - - 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", + ) diff --git a/app/routers/rooms.py b/app/routers/rooms.py new file mode 100644 index 0000000..fa1afae --- /dev/null +++ b/app/routers/rooms.py @@ -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) diff --git a/app/routers/server_control.py b/app/routers/server_control.py new file mode 100644 index 0000000..7919775 --- /dev/null +++ b/app/routers/server_control.py @@ -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, + ) diff --git a/app/services/dm_ingest.py b/app/services/dm_ingest.py index c1bf42e..f5a39d7 100644 --- a/app/services/dm_ingest.py +++ b/app/services/dm_ingest.py @@ -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, diff --git a/frontend/src/api.ts b/frontend/src/api.ts index bd98c49..56a610c 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -383,4 +383,21 @@ export const api = { fetchJson(`/contacts/${publicKey}/repeater/lpp-telemetry`, { method: 'POST', }), + roomLogin: (publicKey: string, password: string) => + fetchJson(`/contacts/${publicKey}/room/login`, { + method: 'POST', + body: JSON.stringify({ password }), + }), + roomStatus: (publicKey: string) => + fetchJson(`/contacts/${publicKey}/room/status`, { + method: 'POST', + }), + roomAcl: (publicKey: string) => + fetchJson(`/contacts/${publicKey}/room/acl`, { + method: 'POST', + }), + roomLppTelemetry: (publicKey: string) => + fetchJson(`/contacts/${publicKey}/room/lpp-telemetry`, { + method: 'POST', + }), }; diff --git a/frontend/src/components/ChatHeader.tsx b/frontend/src/components/ChatHeader.tsx index 378785e..629644a 100644 --- a/frontend/src/components/ChatHeader.tsx +++ b/frontend/src/components/ChatHeader.tsx @@ -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({
- {conversation.type === 'contact' && ( + {conversation.type === 'contact' && !activeContactIsRoomServer && ( )} - {conversation.type === 'contact' && ( + {conversation.type === 'contact' && !activeContactIsRoomServer && ( )} - {notificationsSupported && ( + {notificationsSupported && !activeContactIsRoomServer && (
diff --git a/frontend/src/components/RoomServerPanel.tsx b/frontend/src/components/RoomServerPanel.tsx new file mode 100644 index 0000000..80a07ff --- /dev/null +++ b/frontend/src/components/RoomServerPanel.tsx @@ -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; + +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(null); + const [loginMessage, setLoginMessage] = useState(null); + const [authenticated, setAuthenticated] = useState(false); + const [advancedOpen, setAdvancedOpen] = useState(false); + const [paneData, setPaneData] = useState({ + status: null, + acl: null, + lppTelemetry: null, + }); + const [paneStates, setPaneStates] = useState(createInitialPaneStates); + const [consoleHistory, setConsoleHistory] = useState([]); + 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 (pane: K, loader: () => Promise) => { + 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 ( +
+ +
+ ); + } + + return ( +
+
+
+
+
Room Server Controls
+

+ Room access is active. Use the chat history and message box below to participate, and + open admin tools when needed. +

+ {loginMessage &&

{loginMessage}

} +
+
+ + +
+
+ + {advancedOpen && ( +
+ refreshPane('status', () => api.roomStatus(contact.public_key))} + /> + refreshPane('acl', () => api.roomAcl(contact.public_key))} + /> + + refreshPane('lppTelemetry', () => api.roomLppTelemetry(contact.public_key)) + } + /> + +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 5c73d49..ad98404 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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(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 && ( diff --git a/frontend/src/test/chatHeaderKeyVisibility.test.tsx b/frontend/src/test/chatHeaderKeyVisibility.test.tsx index 29be711..322411b 100644 --- a/frontend/src/test/chatHeaderKeyVisibility.test.tsx +++ b/frontend/src/test/chatHeaderKeyVisibility.test.tsx @@ -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( + + ); + + 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' }; diff --git a/frontend/src/test/contactAvatar.test.ts b/frontend/src/test/contactAvatar.test.ts index 42a70ae..58cc7c9 100644 --- a/frontend/src/test/contactAvatar.test.ts +++ b/frontend/src/test/contactAvatar.test.ts @@ -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); diff --git a/frontend/src/test/conversationPane.test.tsx b/frontend/src/test/conversationPane.test.tsx index 44d3782..1fcc9f8 100644 --- a/frontend/src/test/conversationPane.test.tsx +++ b/frontend/src/test/conversationPane.test.tsx @@ -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: () =>
, })); +vi.mock('../components/RoomServerPanel', () => ({ + RoomServerPanel: ({ + onAuthenticatedChange, + }: { + onAuthenticatedChange?: (value: boolean) => void; + }) => ( +
+
+ +
+ ), +})); + vi.mock('../components/MapView', () => ({ MapView: () =>
, })); @@ -216,6 +231,54 @@ describe('ConversationPane', () => { }); }); + it('gates room chat behind room login controls until authenticated', async () => { + render( + + ); + + 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( { 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( + + ); + + 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( 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( ); - 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(); 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( + + ); + + 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 }); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index dceed39..a894ad5 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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; diff --git a/frontend/src/utils/contactAvatar.ts b/frontend/src/utils/contactAvatar.ts index 440fbf9..f7b7274 100644 --- a/frontend/src/utils/contactAvatar.ts +++ b/frontend/src/utils/contactAvatar.ts @@ -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); diff --git a/frontend/src/utils/conversationState.ts b/frontend/src/utils/conversationState.ts index df6d8f0..937ec6f 100644 --- a/frontend/src/utils/conversationState.ts +++ b/frontend/src/utils/conversationState.ts @@ -15,7 +15,7 @@ const SIDEBAR_SECTION_SORT_ORDERS_KEY = 'remoteterm-sidebar-section-sort-orders' export type ConversationTimes = Record; export type SortOrder = 'recent' | 'alpha'; -export type SidebarSortableSection = 'favorites' | 'channels' | 'contacts' | 'repeaters'; +export type SidebarSortableSection = 'favorites' | 'channels' | 'contacts' | 'rooms' | 'repeaters'; export type SidebarSectionSortOrders = Record; // 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 { diff --git a/tests/test_decoder.py b/tests/test_decoder.py index 40fcb23..cca31d9 100644 --- a/tests/test_decoder.py +++ b/tests/test_decoder.py @@ -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.""" diff --git a/tests/test_event_handlers.py b/tests/test_event_handlers.py index d1ab348..f0bacf8 100644 --- a/tests/test_event_handlers.py +++ b/tests/test_event_handlers.py @@ -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).""" diff --git a/tests/test_packet_pipeline.py b/tests/test_packet_pipeline.py index eaf3c31..4588481 100644 --- a/tests/test_packet_pipeline.py +++ b/tests/test_packet_pipeline.py @@ -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.""" diff --git a/tests/test_room_routes.py b/tests/test_room_routes.py new file mode 100644 index 0000000..461a1ce --- /dev/null +++ b/tests/test_room_routes.py @@ -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"