Add draft reactions + gifs; region resolution

This commit is contained in:
Jack Kingsman
2026-06-20 21:12:47 -07:00
parent cb4d4ca584
commit 06556a853d
35 changed files with 1111 additions and 44 deletions
+1 -1
View File
@@ -513,7 +513,7 @@ mc.subscribe(EventType.ACK, handler)
| `MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT` | `false` | Enable `GET /api/radio/private-key` to return the in-memory private key as hex. Disabled by default; only enable on a trusted network where you need to retrieve the key (e.g. for backup or migration). |
| `MESHCORE_VAPID_SUBJECT` | `mailto:noreply@meshcore.local` | Subject (`sub`) claim for Web Push VAPID tokens; must be a `mailto:` or `https:` contact. Apple's push service (APNs) rejects the default `.local` domain with `403 BadJwtToken`, so iOS/Safari operators must set this to a real address. Google FCM (Chrome/Android) accepts the default. |
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `advert_interval`, `last_advert_time`, `last_message_times`, `flood_scope`, `blocked_keys`, `blocked_names`, `discovery_blocked_types`, `tracked_telemetry_repeaters`, `tracked_telemetry_contacts`, `auto_resend_channel`, and `telemetry_interval_hours`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`. If the radio's channel slots appear unstable or another client is mutating them underneath this app, operators can force the old always-reconfigure send path with `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true`.
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `advert_interval`, `last_advert_time`, `last_message_times`, `flood_scope`, `known_regions`, `blocked_keys`, `blocked_names`, `discovery_blocked_types`, `tracked_telemetry_repeaters`, `tracked_telemetry_contacts`, `auto_resend_channel`, and `telemetry_interval_hours`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`. If the radio's channel slots appear unstable or another client is mutating them underneath this app, operators can force the old always-reconfigure send path with `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true`.
Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send.
+8
View File
@@ -145,6 +145,13 @@ app/
- Incoming PRIV message uniqueness (`idx_messages_incoming_priv_dedup`): `(type, conversation_key, text, COALESCE(sender_timestamp, 0), COALESCE(sender_key, ''))` where `type = 'PRIV' AND outgoing = 0``sender_key` was added in migration 056 to distinguish room-server posts from different senders in the same second.
- Duplicate insert is treated as an echo/repeat: the new path (if any) is appended, and the ACK count is incremented only for outgoing channel messages. Incoming direct messages with the same dedup identity also collapse onto one stored row, with later observations merging path data instead of creating a second DM.
### Region scope decoding (transport codes)
- `ROUTE_TYPE_TRANSPORT_FLOOD`/`ROUTE_TYPE_TRANSPORT_DIRECT` packets carry a 4-byte transport-code block; `parse_packet_envelope` exposes it as `transport_codes = (code_1, code_2)` (little-endian uint16s; `code_2` is reserved/0).
- `code_1` is a keyed MAC over the payload, not a stable per-region id: `code = HMAC-SHA256(SHA256("#" + region_name)[:16], payload_type || payload)[:2]` (firmware `TransportKeyStore.cpp`; reserved values `0x0000`/`0xFFFF` are nudged to `0x0001`/`0xFFFE`). There is **no** reverse lookup table — to name a packet's region you recompute the code per candidate region and check for a match (`app/region_resolver.py`).
- Candidate region names come from `app_settings.known_regions` (user-editable, seeded by migration 064 from `flood_scope` + channel `flood_scope_override`).
- Channel messages persist `messages.transport_code` (uint16, NULL = unscoped plain flood) and `messages.region` (resolved name, NULL = scoped but no list match) at ingest, so the chat region badge survives raw-packet purge. The packet inspector (`GET /packets/{id}` and the `raw_packet` WS broadcast) resolves region on the fly against the current list since it still holds the raw payload.
### Raw packet dedup policy
- Raw packet storage deduplicates by payload hash (`RawPacketRepository.create`), excluding routing/path bytes.
@@ -350,6 +357,7 @@ Repository writes should prefer typed models such as `ContactUpsert` over ad hoc
- `advert_interval`
- `last_advert_time`
- `flood_scope`
- `known_regions`
- `blocked_keys`, `blocked_names`, `discovery_blocked_types`
- `tracked_telemetry_repeaters`, `tracked_telemetry_contacts`
- `auto_resend_channel`
+2
View File
@@ -105,6 +105,7 @@ class PacketInfo:
path: bytes # The routing path bytes (empty if path_length is 0)
payload: bytes
path_hash_size: int = 1 # Bytes per hop: 1, 2, or 3
transport_codes: tuple[int, int] | None = None # (code_1, code_2) for TRANSPORT_* routes
def _is_valid_advert_location(lat: float, lon: float) -> bool:
@@ -146,6 +147,7 @@ def parse_packet(raw_packet: bytes) -> PacketInfo | None:
path_hash_size=envelope.hash_size,
path=envelope.path,
payload=envelope.payload,
transport_codes=envelope.transport_codes,
)
except ValueError:
return None
@@ -0,0 +1,86 @@
import json
import logging
import aiosqlite
logger = logging.getLogger(__name__)
async def migrate(conn: aiosqlite.Connection) -> None:
"""Add region-scope decoding support.
1. Add ``transport_code`` (uint16) and ``region`` columns to ``messages`` so
transport-routed channel messages persist their resolved region even after
the source raw packet is purged by maintenance.
2. Add ``known_regions`` (JSON list) to ``app_settings`` and seed it from any
region names we already know about: the global ``flood_scope`` and per-channel
``flood_scope_override`` values. Names are stored without the ``#`` prefix.
"""
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
existing_tables = {row[0] for row in await tables_cursor.fetchall()}
# --- messages columns ---
if "messages" in existing_tables:
col_cursor = await conn.execute("PRAGMA table_info(messages)")
message_columns = {row[1] for row in await col_cursor.fetchall()}
if "transport_code" not in message_columns:
await conn.execute("ALTER TABLE messages ADD COLUMN transport_code INTEGER")
if "region" not in message_columns:
await conn.execute("ALTER TABLE messages ADD COLUMN region TEXT")
await conn.commit()
# --- app_settings.known_regions column + seed ---
if "app_settings" not in existing_tables:
await conn.commit()
return
col_cursor = await conn.execute("PRAGMA table_info(app_settings)")
settings_columns = {row[1] for row in await col_cursor.fetchall()}
if "known_regions" not in settings_columns:
await conn.execute("ALTER TABLE app_settings ADD COLUMN known_regions TEXT")
await conn.commit()
def _clean(name: str | None) -> str | None:
stripped = (name or "").strip()
if stripped.startswith("#"):
stripped = stripped[1:].strip()
return stripped or None
seeded: list[str] = []
seen: set[str] = set()
def _add(name: str | None) -> None:
cleaned = _clean(name)
if cleaned and cleaned.lower() not in seen:
seen.add(cleaned.lower())
seeded.append(cleaned)
# Global flood scope
try:
cursor = await conn.execute("SELECT flood_scope FROM app_settings WHERE id = 1")
row = await cursor.fetchone()
if row:
_add(row[0])
except aiosqlite.Error:
pass
# Per-channel flood scope overrides
if "channels" in existing_tables:
chan_cols_cursor = await conn.execute("PRAGMA table_info(channels)")
chan_cols = {row[1] for row in await chan_cols_cursor.fetchall()}
if "flood_scope_override" in chan_cols:
cursor = await conn.execute(
"SELECT DISTINCT flood_scope_override FROM channels "
"WHERE flood_scope_override IS NOT NULL AND flood_scope_override != ''"
)
for row in await cursor.fetchall():
_add(row[0])
await conn.execute(
"UPDATE app_settings SET known_regions = ? WHERE id = 1",
(json.dumps(seeded),),
)
await conn.commit()
if seeded:
logger.info("Seeded %d known region(s) for scope decoding: %s", len(seeded), seeded)
+34
View File
@@ -429,6 +429,17 @@ class Message(BaseModel):
default=None,
description="Representative raw packet row ID when archival raw bytes exist",
)
transport_code: int | None = Field(
default=None,
description=(
"Region scope transport code (uint16) when the message arrived via a "
"TransportFlood/TransportDirect packet; None for unscoped (plain flood) messages"
),
)
region: str | None = Field(
default=None,
description="Resolved region name for the transport code, if it matched a known region",
)
class MessagesAroundResponse(BaseModel):
@@ -474,6 +485,14 @@ class RawPacketBroadcast(BaseModel):
rssi: int | None = Field(default=None, description="Received signal strength in dBm")
decrypted: bool = False
decrypted_info: RawPacketDecryptedInfo | None = None
transport_code: int | None = Field(
default=None,
description="Region scope transport code (uint16) for TransportFlood/TransportDirect packets",
)
region: str | None = Field(
default=None,
description="Resolved region name for the transport code, if it matched a known region",
)
class RawPacketDetail(BaseModel):
@@ -489,6 +508,14 @@ class RawPacketDetail(BaseModel):
)
decrypted: bool = False
decrypted_info: RawPacketDecryptedInfo | None = None
transport_code: int | None = Field(
default=None,
description="Region scope transport code (uint16) for TransportFlood/TransportDirect packets",
)
region: str | None = Field(
default=None,
description="Resolved region name for the transport code, if it matched a known region",
)
class SendMessageRequest(BaseModel):
@@ -842,6 +869,13 @@ class AppSettings(BaseModel):
default="",
description="Outbound flood scope / region name (empty = disabled, no tagging)",
)
known_regions: list[str] = Field(
default_factory=list,
description=(
"Region scope names used to resolve incoming TransportFlood/TransportDirect "
"packets back to a readable region label (packet inspector + channel decoration)"
),
)
blocked_keys: list[str] = Field(
default_factory=list,
description="Public keys whose messages are hidden from the UI",
+40 -1
View File
@@ -36,7 +36,9 @@ from app.models import (
RawPacketDecryptedInfo,
)
from app.path_utils import calculate_packet_hash
from app.region_resolver import resolve_region
from app.repository import (
AppSettingsRepository,
ChannelRepository,
ContactAdvertPathRepository,
ContactRepository,
@@ -75,6 +77,8 @@ async def create_message_from_decrypted(
channel_name: str | None = None,
realtime: bool = True,
packet_hash: str | None = None,
transport_code: int | None = None,
region: str | None = None,
) -> int | None:
"""Store a decrypted channel message via the shared message service."""
return await _create_message_from_decrypted(
@@ -92,6 +96,8 @@ async def create_message_from_decrypted(
realtime=realtime,
broadcast_fn=broadcast_event,
packet_hash=packet_hash,
transport_code=transport_code,
region=region,
)
@@ -338,13 +344,40 @@ async def process_raw_packet(
# Compute packet hash once for threading into message broadcasts (used by bot fanout).
pkt_hash = calculate_packet_hash(raw_bytes)
# Resolve regional flood-scope for transport-routed packets. The transport code
# is a keyed MAC over the payload, so we recompute it for each known region name
# and keep the first match. Only transport-routed packets carry codes, so this is
# skipped for the common (unscoped) flood/direct case.
transport_code: int | None = None
region: str | None = None
if packet_info is not None and packet_info.transport_codes is not None:
transport_code = packet_info.transport_codes[0]
try:
settings = await AppSettingsRepository.get()
region = resolve_region(
int(packet_info.payload_type),
packet_info.payload,
transport_code,
settings.known_regions,
)
except Exception:
logger.debug("Region resolution failed for packet %d", packet_id, exc_info=True)
# Process packets based on payload type
# For GROUP_TEXT, we always try to decrypt even for duplicate packets - the message
# deduplication in create_message_from_decrypted handles adding paths to existing messages.
# This is more reliable than trying to look up the message via raw packet linking.
if payload_type == PayloadType.GROUP_TEXT:
decrypt_result = await _process_group_text(
raw_bytes, packet_id, ts, packet_info, rssi=rssi, snr=snr, packet_hash=pkt_hash
raw_bytes,
packet_id,
ts,
packet_info,
rssi=rssi,
snr=snr,
packet_hash=pkt_hash,
transport_code=transport_code,
region=region,
)
if decrypt_result:
result.update(decrypt_result)
@@ -403,6 +436,8 @@ async def process_raw_packet(
)
if result["decrypted"]
else None,
transport_code=transport_code,
region=region,
)
broadcast_event("raw_packet", broadcast_payload.model_dump())
@@ -417,6 +452,8 @@ async def _process_group_text(
rssi: int | None = None,
snr: float | None = None,
packet_hash: str | None = None,
transport_code: int | None = None,
region: str | None = None,
) -> dict | None:
"""
Process a GroupText (channel message) packet.
@@ -456,6 +493,8 @@ async def _process_group_text(
rssi=rssi,
snr=snr,
packet_hash=packet_hash,
transport_code=transport_code,
region=region,
)
return {
+12
View File
@@ -31,6 +31,12 @@ class ParsedPacketEnvelope:
path: bytes
payload: bytes
payload_offset: int
transport_codes: tuple[int, int] | None = None
"""Region transport codes (code_1, code_2) for TRANSPORT_* routes, else None.
Each is a little-endian uint16 read from the 4-byte transport-code block.
``code_1`` is the region-scope code; ``code_2`` is currently reserved (0).
"""
def decode_path_byte(path_byte: int) -> tuple[int, int]:
@@ -91,9 +97,14 @@ def parse_packet_envelope(raw_packet: bytes) -> ParsedPacketEnvelope | None:
payload_version = (header >> 6) & 0x03
offset = 1
transport_codes: tuple[int, int] | None = None
if route_type in (0x00, 0x03):
if len(raw_packet) < offset + 4:
return None
transport_codes = (
int.from_bytes(raw_packet[offset : offset + 2], "little"),
int.from_bytes(raw_packet[offset + 2 : offset + 4], "little"),
)
offset += 4
if len(raw_packet) < offset + 1:
@@ -123,6 +134,7 @@ def parse_packet_envelope(raw_packet: bytes) -> ParsedPacketEnvelope | None:
path=path,
payload=raw_packet[offset:],
payload_offset=offset,
transport_codes=transport_codes,
)
except (IndexError, ValueError):
return None
+79
View File
@@ -0,0 +1,79 @@
"""Resolve a packet's regional flood-scope (transport code) back to a region name.
MeshCore's TransportFlood/TransportDirect packets carry a 16-bit "transport code"
derived from the region name *and the packet payload* it is a keyed MAC, not a
stable per-region identifier:
key = SHA256("#" + region_name)[:16] # firmware TransportKey
code = HMAC-SHA256(key, payload_type || payload)[:2] # little-endian uint16
(see ``references/MeshCore/src/helpers/TransportKeyStore.cpp``). Codes ``0x0000``
and ``0xFFFF`` are reserved, so the firmware nudges them to ``0x0001`` / ``0xFFFE``.
Because the code depends on the payload, there is no reverse lookup table: to
name a packet's region we recompute the code for each known region name and check
for a match. The candidate region names come from the server-side
``app_settings.known_regions`` list.
"""
import hashlib
import hmac
from app.region_scope import normalize_region_scope
# SHA256("#name")[:16] is deterministic and cheap, but region lists are tiny and
# packets are frequent, so cache the derived 16-byte keys by normalized name.
_key_cache: dict[str, bytes] = {}
def _region_key(region_name: str) -> bytes | None:
"""Return the 16-byte TransportKey for a region name, or None if blank.
Region names are hashed in their hashtag form (``#name``), matching the
firmware (``getAutoKeyFor(id, "#" + name, ...)``).
"""
normalized = normalize_region_scope(region_name)
if not normalized:
return None
key = _key_cache.get(normalized)
if key is None:
key = hashlib.sha256(normalized.encode("utf-8")).digest()[:16]
_key_cache[normalized] = key
return key
def compute_transport_code(region_name: str, payload_type: int, payload: bytes) -> int | None:
"""Compute the transport code a region would produce for this payload.
Returns the little-endian uint16 code (with the firmware's reserved-value
adjustment applied), or None if the region name is blank.
"""
key = _region_key(region_name)
if key is None:
return None
digest = hmac.new(key, bytes([payload_type & 0xFF]) + payload, hashlib.sha256).digest()
code = int.from_bytes(digest[:2], "little")
if code == 0:
code = 1
elif code == 0xFFFF:
code = 0xFFFE
return code
def resolve_region(
payload_type: int,
payload: bytes,
transport_code: int,
region_names: list[str],
) -> str | None:
"""Return the first region name whose code matches ``transport_code``, else None.
The returned name is the user-facing form as stored in the candidate list
(no ``#`` prefix is added or stripped here).
"""
for name in region_names:
if not name:
continue
if compute_transport_code(name, payload_type, payload) == transport_code:
return name
return None
+14 -2
View File
@@ -64,6 +64,8 @@ class MessageRepository:
outgoing: bool = False,
sender_name: str | None = None,
sender_key: str | None = None,
transport_code: int | None = None,
region: str | None = None,
) -> int | None:
"""Create a message, returning the ID or None if duplicate.
@@ -94,8 +96,8 @@ class MessageRepository:
"""
INSERT OR IGNORE INTO messages (type, conversation_key, text, sender_timestamp,
received_at, paths, txt_type, signature, outgoing,
sender_name, sender_key)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
sender_name, sender_key, transport_code, region)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
msg_type,
@@ -109,6 +111,8 @@ class MessageRepository:
outgoing,
sender_name,
normalized_sender_key,
transport_code,
region,
),
) as cursor:
rowcount = cursor.rowcount
@@ -357,10 +361,16 @@ class MessageRepository:
def _row_to_message(row: Any) -> Message:
"""Convert a database row to a Message model."""
packet_id = None
transport_code = None
region = None
if hasattr(row, "keys"):
row_keys = row.keys()
if "packet_id" in row_keys:
packet_id = row["packet_id"]
if "transport_code" in row_keys:
transport_code = row["transport_code"]
if "region" in row_keys:
region = row["region"]
return Message(
id=row["id"],
@@ -377,6 +387,8 @@ class MessageRepository:
acked=row["acked"],
sender_name=row["sender_name"],
packet_id=packet_id,
transport_code=transport_code,
region=region,
)
@staticmethod
+18 -1
View File
@@ -39,7 +39,7 @@ class AppSettingsRepository:
"""
SELECT max_radio_contacts, auto_decrypt_dm_on_advert,
last_message_times,
advert_interval, last_advert_time, flood_scope,
advert_interval, last_advert_time, flood_scope, known_regions,
blocked_keys, blocked_names, discovery_blocked_types,
tracked_telemetry_repeaters, tracked_telemetry_contacts,
auto_resend_channel,
@@ -81,6 +81,15 @@ class AppSettingsRepository:
except (json.JSONDecodeError, TypeError):
blocked_names = []
# Parse known_regions JSON
known_regions: list[str] = []
try:
raw_regions = row["known_regions"]
if raw_regions:
known_regions = json.loads(raw_regions)
except (json.JSONDecodeError, TypeError, KeyError):
known_regions = []
# Parse discovery_blocked_types JSON
discovery_blocked_types: list[int] = []
if row["discovery_blocked_types"]:
@@ -136,6 +145,7 @@ class AppSettingsRepository:
advert_interval=row["advert_interval"] or 0,
last_advert_time=row["last_advert_time"] or 0,
flood_scope=row["flood_scope"] or "",
known_regions=known_regions,
blocked_keys=blocked_keys,
blocked_names=blocked_names,
discovery_blocked_types=discovery_blocked_types,
@@ -156,6 +166,7 @@ class AppSettingsRepository:
advert_interval: int | None = None,
last_advert_time: int | None = None,
flood_scope: str | None = None,
known_regions: list[str] | None = None,
blocked_keys: list[str] | None = None,
blocked_names: list[str] | None = None,
discovery_blocked_types: list[int] | None = None,
@@ -197,6 +208,10 @@ class AppSettingsRepository:
updates.append("flood_scope = ?")
params.append(flood_scope)
if known_regions is not None:
updates.append("known_regions = ?")
params.append(json.dumps(known_regions))
if blocked_keys is not None:
updates.append("blocked_keys = ?")
params.append(json.dumps(blocked_keys))
@@ -251,6 +266,7 @@ class AppSettingsRepository:
advert_interval: int | None = None,
last_advert_time: int | None = None,
flood_scope: str | None = None,
known_regions: list[str] | None = None,
blocked_keys: list[str] | None = None,
blocked_names: list[str] | None = None,
discovery_blocked_types: list[int] | None = None,
@@ -270,6 +286,7 @@ class AppSettingsRepository:
advert_interval=advert_interval,
last_advert_time=last_advert_time,
flood_scope=flood_scope,
known_regions=known_regions,
blocked_keys=blocked_keys,
blocked_names=blocked_names,
discovery_blocked_types=discovery_blocked_types,
+40 -1
View File
@@ -10,7 +10,13 @@ from app.database import db
from app.decoder import parse_packet, try_decrypt_packet_with_channel_key
from app.models import RawPacketDecryptedInfo, RawPacketDetail
from app.packet_processor import create_message_from_decrypted, run_historical_dm_decryption
from app.repository import ChannelRepository, MessageRepository, RawPacketRepository
from app.region_resolver import resolve_region
from app.repository import (
AppSettingsRepository,
ChannelRepository,
MessageRepository,
RawPacketRepository,
)
from app.websocket import broadcast_success
logger = logging.getLogger(__name__)
@@ -58,6 +64,8 @@ async def _run_historical_channel_decryption(
logger.info("Starting historical channel decryption of %d packets", total)
known_regions = (await AppSettingsRepository.get()).known_regions
async for (
packet_id,
packet_data,
@@ -70,6 +78,18 @@ async def _run_historical_channel_decryption(
packet_info = parse_packet(packet_data)
path_hex = packet_info.path.hex() if packet_info else None
# Resolve regional flood-scope if this is a transport-routed packet.
transport_code: int | None = None
region: str | None = None
if packet_info is not None and packet_info.transport_codes is not None:
transport_code = packet_info.transport_codes[0]
region = resolve_region(
int(packet_info.payload_type),
packet_info.payload,
transport_code,
known_regions,
)
msg_id = await create_message_from_decrypted(
packet_id=packet_id,
channel_key=channel_key_hex,
@@ -81,6 +101,8 @@ async def _run_historical_channel_decryption(
path=path_hex,
path_len=packet_info.path_length if packet_info else None,
realtime=False, # Historical decryption should not trigger fanout
transport_code=transport_code,
region=region,
)
if msg_id is not None:
@@ -117,6 +139,21 @@ async def get_raw_packet(packet_id: int) -> RawPacketDetail:
packet_info = parse_packet(packet_data)
payload_type_name = packet_info.payload_type.name if packet_info else "Unknown"
# Resolve regional flood-scope for transport-routed packets against the
# current known-region list (we have the raw payload here, so this stays
# accurate even if the stored message predates a region-list change).
transport_code: int | None = None
region: str | None = None
if packet_info is not None and packet_info.transport_codes is not None:
transport_code = packet_info.transport_codes[0]
settings = await AppSettingsRepository.get()
region = resolve_region(
int(packet_info.payload_type),
packet_info.payload,
transport_code,
settings.known_regions,
)
decrypted_info: RawPacketDecryptedInfo | None = None
if message_id is not None:
message = await MessageRepository.get_by_id(message_id)
@@ -146,6 +183,8 @@ async def get_raw_packet(packet_id: int) -> RawPacketDetail:
payload_type=payload_type_name,
decrypted=message_id is not None,
decrypted_info=decrypted_info,
transport_code=transport_code,
region=region,
)
+21
View File
@@ -46,6 +46,13 @@ class AppSettingsUpdate(BaseModel):
default=None,
description="Outbound flood scope / region name (empty = disabled)",
)
known_regions: list[str] | None = Field(
default=None,
description=(
"Region scope names used to decode incoming transport-scoped packets "
"(stored without a leading '#')"
),
)
blocked_keys: list[str] | None = Field(
default=None,
description="Public keys whose messages are hidden from the UI",
@@ -212,6 +219,20 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
logger.info("Updating advert_interval to %d", interval)
kwargs["advert_interval"] = interval
# Known regions for scope decoding. Normalize to user-facing form (no leading
# '#'), trim blanks, and dedupe case-insensitively while preserving order.
if update.known_regions is not None:
cleaned_regions: list[str] = []
seen_regions: set[str] = set()
for raw_name in update.known_regions:
name = (raw_name or "").strip()
if name.startswith("#"):
name = name[1:].strip()
if name and name.lower() not in seen_regions:
seen_regions.add(name.lower())
cleaned_regions.append(name)
kwargs["known_regions"] = cleaned_regions
# Block lists
if update.blocked_keys is not None:
kwargs["blocked_keys"] = [k.lower() for k in update.blocked_keys]
+10
View File
@@ -69,6 +69,8 @@ def build_message_model(
sender_name: str | None = None,
channel_name: str | None = None,
packet_id: int | None = None,
transport_code: int | None = None,
region: str | None = None,
) -> Message:
"""Build a Message model with the canonical backend payload shape."""
return Message(
@@ -87,6 +89,8 @@ def build_message_model(
sender_name=sender_name,
channel_name=channel_name,
packet_id=packet_id,
transport_code=transport_code,
region=region,
)
@@ -276,6 +280,8 @@ async def create_message_from_decrypted(
realtime: bool = True,
broadcast_fn: BroadcastFn,
packet_hash: str | None = None,
transport_code: int | None = None,
region: str | None = None,
) -> int | None:
"""Store and broadcast a decrypted channel message."""
received = received_at or int(time.time())
@@ -300,6 +306,8 @@ async def create_message_from_decrypted(
snr=snr,
sender_name=sender,
sender_key=resolved_sender_key,
transport_code=transport_code,
region=region,
)
if msg_id is None:
@@ -341,6 +349,8 @@ async def create_message_from_decrypted(
sender_key=resolved_sender_key,
channel_name=channel_name,
packet_id=packet_id,
transport_code=transport_code,
region=region,
),
broadcast_fn=broadcast_fn,
realtime=realtime,
+1
View File
@@ -361,6 +361,7 @@ Distance/validation helpers used by path + map UI.
- `advert_interval`
- `last_advert_time`
- `flood_scope`
- `known_regions`
- `blocked_keys`, `blocked_names`, `discovery_blocked_types`
- `tracked_telemetry_repeaters`, `tracked_telemetry_contacts`
- `auto_resend_channel`
+36 -28
View File
@@ -22,6 +22,7 @@ import { toast } from './components/ui/sonner';
import { AppShell } from './components/AppShell';
import type { MessageInputHandle } from './components/MessageInput';
import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
import { RichPayloadProvider } from './contexts/RichPayloadContext';
import { usePush } from './contexts/PushSubscriptionContext';
import { messageContainsMention } from './utils/messageParser';
import { getStateKey } from './utils/conversationState';
@@ -117,11 +118,13 @@ export function App() {
crackerRunning,
localLabel,
distanceUnit,
renderRichPayloads,
setSettingsSection,
setSidebarOpen,
setCrackerRunning,
setLocalLabel,
setDistanceUnit,
setRenderRichPayloads,
handleCloseSettingsView,
handleToggleSettingsView,
handleOpenNewMessage: openNewMessageModal,
@@ -794,34 +797,39 @@ export function App() {
]);
return (
<DistanceUnitProvider distanceUnit={distanceUnit} setDistanceUnit={setDistanceUnit}>
<AppShell
localLabel={localLabel}
showNewMessage={showNewMessage}
showBulkAddResults={bulkAddResult !== null}
showSettings={showSettings}
settingsSection={settingsSection}
sidebarOpen={sidebarOpen}
showCracker={showCracker}
onSettingsSectionChange={setSettingsSection}
onSidebarOpenChange={setSidebarOpen}
onCrackerRunningChange={setCrackerRunning}
onToggleSettingsView={handleToggleSettingsView}
onCloseSettingsView={handleCloseSettingsView}
onCloseNewMessage={handleCloseNewMessage}
onCloseBulkAddResults={handleCloseBulkAddResults}
onLocalLabelChange={setLocalLabel}
statusProps={statusProps}
sidebarProps={sidebarProps}
conversationPaneProps={conversationPaneProps}
searchProps={searchProps}
settingsProps={settingsProps}
crackerProps={crackerProps}
newMessageModalProps={newMessageModalProps}
bulkAddChannelResultModalProps={bulkAddChannelResultModalProps}
contactInfoPaneProps={contactInfoPaneProps}
channelInfoPaneProps={channelInfoPaneProps}
onRepeaterAutoLogin={handleRepeaterAutoLogin}
/>
<RichPayloadProvider
renderRichPayloads={renderRichPayloads}
setRenderRichPayloads={setRenderRichPayloads}
>
<AppShell
localLabel={localLabel}
showNewMessage={showNewMessage}
showBulkAddResults={bulkAddResult !== null}
showSettings={showSettings}
settingsSection={settingsSection}
sidebarOpen={sidebarOpen}
showCracker={showCracker}
onSettingsSectionChange={setSettingsSection}
onSidebarOpenChange={setSidebarOpen}
onCrackerRunningChange={setCrackerRunning}
onToggleSettingsView={handleToggleSettingsView}
onCloseSettingsView={handleCloseSettingsView}
onCloseNewMessage={handleCloseNewMessage}
onCloseBulkAddResults={handleCloseBulkAddResults}
onLocalLabelChange={setLocalLabel}
statusProps={statusProps}
sidebarProps={sidebarProps}
conversationPaneProps={conversationPaneProps}
searchProps={searchProps}
settingsProps={settingsProps}
crackerProps={crackerProps}
newMessageModalProps={newMessageModalProps}
bulkAddChannelResultModalProps={bulkAddChannelResultModalProps}
contactInfoPaneProps={contactInfoPaneProps}
channelInfoPaneProps={channelInfoPaneProps}
onRepeaterAutoLogin={handleRepeaterAutoLogin}
/>
</RichPayloadProvider>
</DistanceUnitProvider>
);
}
+75 -6
View File
@@ -16,6 +16,8 @@ import {
formatTime,
parseSenderFromText,
} from '../utils/messageParser';
import { giphyUrlForId, parseGif, parseReaction } from '../utils/meshcoreOpenPayloads';
import { useRichPayloads } from '../contexts/RichPayloadContext';
import { formatHopCounts, type SenderInfo } from '../utils/pathUtils';
import { getDirectContactRoute } from '../utils/pathUtils';
import { ContactAvatar } from './ContactAvatar';
@@ -50,6 +52,57 @@ interface MessageListProps {
preSorted?: boolean;
}
// Renders a MeshCore Open GIF payload, falling back to the raw text on load error.
function GifPayload({ gifId, rawText }: { gifId: string; rawText: string }) {
const [failed, setFailed] = useState(false);
if (failed) {
return <>{rawText}</>;
}
const url = giphyUrlForId(gifId);
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="inline-block"
title="Open GIF on Giphy"
>
<img
src={url}
alt="GIF"
loading="lazy"
onError={() => setFailed(true)}
className="max-w-[240px] max-h-[240px] rounded-md"
/>
</a>
);
}
// Renders a MeshCore Open reaction generically (emoji + "reacted"); the target
// message is not resolved (see issue #291).
function ReactionPayload({ emoji }: { emoji: string }) {
return (
<span className="inline-flex items-center gap-1.5">
<span className="text-xl leading-none">{emoji}</span>
<span className="text-xs text-muted-foreground italic">reacted</span>
</span>
);
}
// Recognize a whole-message MeshCore Open payload and render it. Returns null
// when the content is not a recognized payload, so the caller renders normally.
function renderMeshcoreOpenPayload(content: string): ReactNode | null {
const gifId = parseGif(content);
if (gifId) {
return <GifPayload gifId={gifId} rawText={content} />;
}
const reaction = parseReaction(content);
if (reaction) {
return <ReactionPayload emoji={reaction.emoji} />;
}
return null;
}
// URL regex for linkifying plain text
const URL_PATTERN =
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/g;
@@ -241,6 +294,18 @@ function HopCountBadge({ paths, onClick, variant }: HopCountBadgeProps) {
);
}
// Region scope badge for messages that arrived via a transport-routed (region-scoped) packet.
function RegionBadge({ region }: { region: string }) {
return (
<span
className="ml-1.5 align-middle text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground"
title={`Regional scope: ${region}`}
>
{region}
</span>
);
}
const RESEND_WINDOW_SECONDS = 30;
const CORRUPT_SENDER_LABEL = '<No name -- corrupt packet?>';
const ANALYZE_PACKET_NOTICE =
@@ -286,6 +351,7 @@ export function MessageList({
onJumpToBottom,
preSorted = false,
}: MessageListProps) {
const { renderRichPayloads } = useRichPayloads();
const listRef = useRef<HTMLDivElement>(null);
const prevMessagesLengthRef = useRef<number>(0);
const isInitialLoadRef = useRef<boolean>(true);
@@ -1009,15 +1075,17 @@ export function MessageList({
}
/>
)}
{msg.region && <RegionBadge region={msg.region} />}
</div>
)}
<div className="break-words whitespace-pre-wrap">
{content.split('\n').map((line, i, arr) => (
<span key={i}>
{renderTextWithMentions(line, radioName, onChannelReferenceClick)}
{i < arr.length - 1 && <br />}
</span>
))}
{(renderRichPayloads && renderMeshcoreOpenPayload(content)) ||
content.split('\n').map((line, i, arr) => (
<span key={i}>
{renderTextWithMentions(line, radioName, onChannelReferenceClick)}
{i < arr.length - 1 && <br />}
</span>
))}
{!showAvatar && (
<>
<span className="text-[0.625rem] text-muted-foreground ml-2">
@@ -1037,6 +1105,7 @@ export function MessageList({
}
/>
)}
{msg.region && <RegionBadge region={msg.region} />}
</>
)}
{msg.outgoing &&
@@ -672,8 +672,12 @@ export function RawPacketInspectionPanel({
{inspection.decoded?.transportCodes ? (
<CompactMetaCard
label="Scope"
primary="Regional"
secondary={formatTransportCodes(inspection.decoded.transportCodes)}
primary={packet.region ? packet.region : 'Regional'}
secondary={
packet.region
? formatTransportCodes(inspection.decoded.transportCodes)
: `${formatTransportCodes(inspection.decoded.transportCodes)} · unknown region`
}
/>
) : null}
{(() => {
@@ -24,6 +24,8 @@ import {
setSavedDistanceUnit,
} from '../../utils/distanceUnits';
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
import { useRichPayloads } from '../../contexts/RichPayloadContext';
import { setSavedRenderRichPayloads } from '../../utils/richPayloadPreference';
import {
DEFAULT_FONT_SCALE,
FONT_SCALE_SLIDER_STEP,
@@ -230,6 +232,7 @@ export function SettingsLocalSection({
className?: string;
}) {
const { distanceUnit, setDistanceUnit } = useDistanceUnit();
const { renderRichPayloads, setRenderRichPayloads } = useRichPayloads();
const [reopenLastConversation, setReopenLastConversation] = useState(
getReopenLastConversationEnabled
);
@@ -450,6 +453,33 @@ export function SettingsLocalSection({
</div>
</div>
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
<Checkbox
id="render-rich-payloads"
checked={renderRichPayloads}
onCheckedChange={(checked) => {
const v = checked === true;
setRenderRichPayloads(v);
setSavedRenderRichPayloads(v);
}}
className="mt-0.5"
/>
<div className="space-y-1">
<Label htmlFor="render-rich-payloads">
Render MeshCore Open GIFs &amp; Reactions
</Label>
<p className="text-[0.8125rem] text-muted-foreground">
MeshCore Open clients send GIFs and emoji reactions as encoded text (e.g.{' '}
<code className="text-[0.75rem]">g:abc123</code> or{' '}
<code className="text-[0.75rem]">r:1a2b:05</code>). When enabled, these render as
the GIF image or reaction emoji instead of the raw text. Reactions show generically
(the emoji is not tied to a specific message). GIFs load from media.giphy.com, which
reaches outside your local network and exposes your IP to Giphy so this is off by
default.
</p>
</div>
</div>
<div className="rounded-md border border-border/60 p-3 space-y-2">
<div className="flex items-start gap-3">
<Checkbox
@@ -200,6 +200,7 @@ export function SettingsRadioSection({
// Flood & advert control state
const [advertIntervalHours, setAdvertIntervalHours] = useState('0');
const [floodScope, setFloodScope] = useState('');
const [knownRegions, setKnownRegions] = useState('');
const [maxRadioContacts, setMaxRadioContacts] = useState('');
const [floodBusy, setFloodBusy] = useState(false);
const [floodError, setFloodError] = useState<string | null>(null);
@@ -229,6 +230,7 @@ export function SettingsRadioSection({
useEffect(() => {
setAdvertIntervalHours(String(Math.round(appSettings.advert_interval / 3600)));
setFloodScope(stripRegionScopePrefix(appSettings.flood_scope));
setKnownRegions((appSettings.known_regions ?? []).join('\n'));
setMaxRadioContacts(String(appSettings.max_radio_contacts));
}, [appSettings]);
@@ -414,6 +416,14 @@ export function SettingsRadioSection({
if (floodScope !== stripRegionScopePrefix(appSettings.flood_scope)) {
update.flood_scope = floodScope;
}
// Known regions: one per line (commas also accepted), trimmed, blanks dropped.
const parsedRegions = knownRegions
.split(/[\n,]/)
.map((r) => r.trim())
.filter((r) => r.length > 0);
if (JSON.stringify(parsedRegions) !== JSON.stringify(appSettings.known_regions ?? [])) {
update.known_regions = parsedRegions;
}
const newMaxRadioContacts = parseInt(maxRadioContacts, 10);
if (!isNaN(newMaxRadioContacts) && newMaxRadioContacts !== appSettings.max_radio_contacts) {
update.max_radio_contacts = newMaxRadioContacts;
@@ -1157,6 +1167,25 @@ export function SettingsRadioSection({
</p>
</div>
<div className="space-y-2">
<Label htmlFor="known-regions">Known Regions (for decoding)</Label>
<textarea
id="known-regions"
value={knownRegions}
onChange={(e) => setKnownRegions(e.target.value)}
rows={4}
placeholder={'nl-gr\nde-by\nMyRegion'}
spellCheck={false}
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
<p className="text-[0.8125rem] text-muted-foreground">
One region name per line. Incoming region-scoped (TransportFlood/TransportDirect) packets
are matched against this list so messages and the packet inspector show a readable region
label instead of a raw transport code. The list is seeded from your channels' regions and
can be edited freely.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="max-contacts">Max Contacts on Radio</Label>
<Input
@@ -0,0 +1,29 @@
import { createContext, useContext, type ReactNode } from 'react';
interface RichPayloadContextValue {
renderRichPayloads: boolean;
setRenderRichPayloads: (enabled: boolean) => void;
}
const noop = () => {};
const RichPayloadContext = createContext<RichPayloadContextValue>({
renderRichPayloads: false,
setRenderRichPayloads: noop,
});
export function RichPayloadProvider({
renderRichPayloads,
setRenderRichPayloads,
children,
}: RichPayloadContextValue & { children: ReactNode }) {
return (
<RichPayloadContext.Provider value={{ renderRichPayloads, setRenderRichPayloads }}>
{children}
</RichPayloadContext.Provider>
);
}
export function useRichPayloads() {
return useContext(RichPayloadContext);
}
+6
View File
@@ -2,6 +2,7 @@ import { startTransition, useCallback, useEffect, useRef, useState } from 'react
import { getLocalLabel, type LocalLabel } from '../utils/localLabel';
import { getSavedDistanceUnit, type DistanceUnit } from '../utils/distanceUnits';
import { getSavedRenderRichPayloads } from '../utils/richPayloadPreference';
import type { SettingsSection } from '../components/settings/settingsConstants';
import { parseHashSettingsSection, updateSettingsHash, pushSettingsHash } from '../utils/urlHash';
@@ -14,11 +15,13 @@ interface UseAppShellResult {
crackerRunning: boolean;
localLabel: LocalLabel;
distanceUnit: DistanceUnit;
renderRichPayloads: boolean;
setSettingsSection: (section: SettingsSection) => void;
setSidebarOpen: (open: boolean) => void;
setCrackerRunning: (running: boolean) => void;
setLocalLabel: (label: LocalLabel) => void;
setDistanceUnit: (unit: DistanceUnit) => void;
setRenderRichPayloads: (enabled: boolean) => void;
handleCloseSettingsView: () => void;
handleToggleSettingsView: () => void;
handleOpenNewMessage: () => void;
@@ -38,6 +41,7 @@ export function useAppShell(): UseAppShellResult {
const [crackerRunning, setCrackerRunning] = useState(false);
const [localLabel, setLocalLabel] = useState(getLocalLabel);
const [distanceUnit, setDistanceUnit] = useState(getSavedDistanceUnit);
const [renderRichPayloads, setRenderRichPayloads] = useState(getSavedRenderRichPayloads);
const previousHashRef = useRef('');
const isOpeningSettingsRef = useRef(false);
const pushedSettingsEntryRef = useRef(false);
@@ -127,11 +131,13 @@ export function useAppShell(): UseAppShellResult {
crackerRunning,
localLabel,
distanceUnit,
renderRichPayloads,
setSettingsSection,
setSidebarOpen,
setCrackerRunning,
setLocalLabel,
setDistanceUnit,
setRenderRichPayloads,
handleCloseSettingsView,
handleToggleSettingsView,
handleOpenNewMessage,
+1
View File
@@ -222,6 +222,7 @@ const baseSettings = {
advert_interval: 0,
last_advert_time: 0,
flood_scope: '',
known_regions: [],
blocked_keys: [],
blocked_names: [],
};
+2
View File
@@ -105,6 +105,7 @@ beforeEach(() => {
advert_interval: 0,
last_advert_time: 0,
flood_scope: '',
known_regions: [],
blocked_keys: [],
blocked_names: [],
discovery_blocked_types: [],
@@ -1147,6 +1148,7 @@ describe('SettingsFanoutSection', () => {
advert_interval: 0,
last_advert_time: 0,
flood_scope: '',
known_regions: [],
blocked_keys: [],
blocked_names: [],
discovery_blocked_types: [],
@@ -0,0 +1,81 @@
/**
* Tests for MeshCore Open rich-chat payload parsing (GIFs and reactions).
*
* Formats are ported from meshcore-open; see meshcoreOpenPayloads.ts.
*/
import { describe, it, expect } from 'vitest';
import {
REACTION_EMOJIS,
giphyUrlForId,
parseGif,
parseReaction,
} from '../utils/meshcoreOpenPayloads';
describe('parseGif', () => {
it('parses a g:<id> payload', () => {
expect(parseGif('g:abc123')).toBe('abc123');
});
it('accepts ids with underscores and dashes', () => {
expect(parseGif('g:aB3_-xY')).toBe('aB3_-xY');
});
it('trims surrounding whitespace', () => {
expect(parseGif(' g:abc123 ')).toBe('abc123');
});
it('returns null for non-gif text', () => {
expect(parseGif('hello world')).toBeNull();
expect(parseGif('g:')).toBeNull();
expect(parseGif('g:abc 123')).toBeNull();
expect(parseGif('prefix g:abc')).toBeNull();
expect(parseGif('g:abc!')).toBeNull();
});
it('builds the Giphy media URL', () => {
expect(giphyUrlForId('abc123')).toBe('https://media.giphy.com/media/abc123/giphy.gif');
});
});
describe('parseReaction', () => {
it('decodes the first emoji (index 00)', () => {
const result = parseReaction('r:1a2b:00');
expect(result).toEqual({ emoji: REACTION_EMOJIS[0], targetHash: '1a2b' });
expect(result?.emoji).toBe('👍');
});
it('decodes a non-zero index', () => {
// index 0x06 -> first smiley (after the 6 quick emojis)
const result = parseReaction('r:ffff:06');
expect(result?.emoji).toBe(REACTION_EMOJIS[6]);
expect(result?.targetHash).toBe('ffff');
});
it('trims surrounding whitespace', () => {
expect(parseReaction(' r:1a2b:00 ')?.emoji).toBe('👍');
});
it('returns null for an out-of-range index', () => {
// 0xff (255) is beyond the emoji list length
expect(parseReaction('r:1a2b:ff')).toBeNull();
});
it('returns null for malformed reactions', () => {
expect(parseReaction('r:1a2b')).toBeNull();
expect(parseReaction('r:1a2:00')).toBeNull(); // hash too short
expect(parseReaction('r:1A2B:00')).toBeNull(); // uppercase hex not accepted
expect(parseReaction('r:1a2b:0')).toBeNull(); // index too short
expect(parseReaction('hello')).toBeNull();
});
it('exposes a stable, deduplication-free emoji index range', () => {
// 6 quick + 64 smileys + 33 gestures + 32 hearts + 49 objects
expect(REACTION_EMOJIS.length).toBe(184);
// every defined index decodes to a string
for (let i = 0; i < REACTION_EMOJIS.length; i++) {
const hex = i.toString(16).padStart(2, '0');
expect(parseReaction(`r:0000:${hex}`)?.emoji).toBe(REACTION_EMOJIS[i]);
}
});
});
+25
View File
@@ -62,6 +62,31 @@ describe('MessageList channel sender rendering', () => {
expect(screen.getByTestId('corrupt-avatar')).toBeInTheDocument();
});
it('renders a region badge for region-scoped channel messages', () => {
render(
<MessageList
messages={[createMessage({ sender_name: 'Alice', region: 'nl-gr' })]}
contacts={[]}
loading={false}
/>
);
expect(screen.getByText('nl-gr')).toBeInTheDocument();
expect(screen.getByTitle('Regional scope: nl-gr')).toBeInTheDocument();
});
it('does not render a region badge for unscoped messages', () => {
render(
<MessageList
messages={[createMessage({ sender_name: 'Alice', region: null })]}
contacts={[]}
loading={false}
/>
);
expect(screen.queryByText('nl-gr')).not.toBeInTheDocument();
});
it('prefers stored sender_name for channel messages even when text is not sender-prefixed', () => {
render(
<MessageList
@@ -91,11 +91,26 @@ describe('RawPacketDetailModal', () => {
expect(pathRun.className).toBe(idleClassName);
});
it('shows scope card with transport codes for scoped packets', () => {
it('shows scope card with transport codes for scoped packets without a resolved region', () => {
render(<RawPacketDetailModal packet={SCOPED_PACKET} channels={[]} onClose={vi.fn()} />);
expect(screen.getByText('Scope')).toBeInTheDocument();
expect(screen.getByText('Regional')).toBeInTheDocument();
expect(screen.getByText('0x1234, 0x5678 · unknown region')).toBeInTheDocument();
});
it('shows the resolved region name in the scope card when the backend matched one', () => {
render(
<RawPacketDetailModal
packet={{ ...SCOPED_PACKET, region: 'nl-gr', transport_code: 0x1234 }}
channels={[]}
onClose={vi.fn()}
/>
);
expect(screen.getByText('Scope')).toBeInTheDocument();
expect(screen.getByText('nl-gr')).toBeInTheDocument();
// Raw codes remain visible as the secondary detail.
expect(screen.getByText('0x1234, 0x5678')).toBeInTheDocument();
});
@@ -0,0 +1,35 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
RENDER_RICH_PAYLOADS_KEY,
getSavedRenderRichPayloads,
setSavedRenderRichPayloads,
} from '../utils/richPayloadPreference';
describe('richPayloadPreference utilities', () => {
beforeEach(() => {
localStorage.clear();
});
it('defaults to off when unset', () => {
expect(getSavedRenderRichPayloads()).toBe(false);
});
it('returns true when enabled', () => {
localStorage.setItem(RENDER_RICH_PAYLOADS_KEY, 'true');
expect(getSavedRenderRichPayloads()).toBe(true);
});
it('treats any non-"true" value as off', () => {
localStorage.setItem(RENDER_RICH_PAYLOADS_KEY, 'yes');
expect(getSavedRenderRichPayloads()).toBe(false);
});
it('persists when enabled and clears the key when disabled', () => {
setSavedRenderRichPayloads(true);
expect(localStorage.getItem(RENDER_RICH_PAYLOADS_KEY)).toBe('true');
setSavedRenderRichPayloads(false);
expect(localStorage.getItem(RENDER_RICH_PAYLOADS_KEY)).toBeNull();
});
});
+1
View File
@@ -66,6 +66,7 @@ const baseSettings: AppSettings = {
advert_interval: 0,
last_advert_time: 0,
flood_scope: '',
known_regions: [],
blocked_keys: [],
blocked_names: [],
discovery_blocked_types: [],
+10
View File
@@ -309,6 +309,10 @@ export interface Message {
sender_name: string | null;
channel_name?: string | null;
packet_id?: number | null;
/** Region scope transport code (uint16) when this arrived via a transport-routed packet. */
transport_code?: number | null;
/** Resolved region name for the transport code, if it matched a known region. */
region?: string | null;
}
export interface MessagesAroundResponse {
@@ -352,6 +356,10 @@ export interface RawPacket {
sender_timestamp: number | null;
message: string | null;
} | null;
/** Region scope transport code (uint16) for TransportFlood/TransportDirect packets. */
transport_code?: number | null;
/** Resolved region name for the transport code, if it matched a known region. */
region?: string | null;
}
export interface AppSettings {
@@ -361,6 +369,7 @@ export interface AppSettings {
advert_interval: number;
last_advert_time: number;
flood_scope: string;
known_regions: string[];
blocked_keys: string[];
blocked_names: string[];
discovery_blocked_types: number[];
@@ -377,6 +386,7 @@ export interface AppSettingsUpdate {
advert_interval?: number;
auto_resend_channel?: boolean;
flood_scope?: string;
known_regions?: string[];
blocked_keys?: string[];
blocked_names?: string[];
discovery_blocked_types?: number[];
+113
View File
@@ -0,0 +1,113 @@
/**
* Parsing for rich-chat payloads sent by MeshCore Open clients as ordinary
* plaintext mesh messages.
*
* MeshCore Open encodes some rich features into the message body with a short
* prefix. RemoteTerm recognizes two of them for display:
*
* g:<gifId> Giphy GIF -> https://media.giphy.com/media/<id>/giphy.gif
* r:<hash>:<index> Emoji reaction -> <index> picks an emoji from a fixed list
*
* Formats and the emoji table are ported verbatim from meshcore-open:
* lib/helpers/gif_helper.dart
* lib/helpers/reaction_helper.dart
* lib/widgets/emoji_picker.dart
* (github.com/zjs81/meshcore-open, dev branch).
*
* Reaction support here is intentionally "generic display only": we decode the
* emoji from <index> and show it, but we do NOT resolve <hash> back to the
* target message (that requires porting Dart's String.hashCode). See issue #291.
*/
// --- Emoji table (order must match meshcore-open exactly for index compat) ---
const QUICK_EMOJIS = ['👍', '❤️', '😂', '🎉', '👏', '🔥'];
// prettier-ignore
const SMILEYS = [
'😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂',
'🙃', '😉', '😌', '😍', '🥰', '😘', '😗', '😙', '😚', '😋',
'😛', '😝', '😜', '🤪', '🤨', '🧐', '🤓', '😎', '🥸', '🤩',
'🥳', '😏', '😒', '😞', '😔', '😟', '😕', '🙁', '😣', '😖',
'😫', '😩', '🥺', '😢', '😭', '😤', '😠', '😡', '🤬', '🤯',
'😳', '🥵', '🥶', '😱', '😨', '😰', '😥', '😓', '🤗', '🤔',
'🤭', '🤫', '🤥', '😶',
];
// prettier-ignore
const GESTURES = [
'👍', '👎', '👊', '✊', '🤛', '🤜', '🤞', '✌️', '🤟', '🤘',
'👌', '🤌', '🤏', '👈', '👉', '👆', '👇', '☝️', '👋', '🤚',
'🖐️', '✋', '🖖', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '✍️',
'💅', '🤳', '💪',
];
// prettier-ignore
const HEARTS = [
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔',
'❤️‍🔥', '❤️‍🩹', '💕', '💞', '💓', '💗', '💖', '💘', '💝', '💟',
'💌', '💢', '💥', '💫', '💦', '💨', '🕳️', '💬', '👁️‍🗨️', '🗨️',
'🗯️', '💭',
];
// prettier-ignore
const OBJECTS = [
'🎉', '🎊', '🎈', '🎁', '🎀', '🪅', '🪆', '🏆', '🥇', '🥈',
'🥉', '⚽', '⚾', '🥎', '🏀', '🏐', '🏈', '🏉', '🎾', '🥏',
'🎳', '🏏', '🏑', '🏒', '🥍', '🏓', '🏸', '🥊', '🥋', '🥅',
'⛳', '🔥', '⭐', '🌟', '✨', '⚡', '💡', '🔦', '🏮', '🪔',
'📱', '💻', '⌚', '📷', '📺', '📻', '🎵', '🎶', '🚀',
];
/** Combined reaction emoji list, in the fixed index order used on the wire. */
export const REACTION_EMOJIS: readonly string[] = [
...QUICK_EMOJIS,
...SMILEYS,
...GESTURES,
...HEARTS,
...OBJECTS,
];
// --- GIF (g:<gifId>) ---
const GIF_PATTERN = /^g:([A-Za-z0-9_-]+)$/;
/**
* Parse a MeshCore Open GIF payload. Returns the Giphy GIF id, or null if the
* (trimmed) text is not a `g:<id>` payload.
*/
export function parseGif(text: string): string | null {
const match = GIF_PATTERN.exec(text.trim());
return match ? match[1] : null;
}
/** Build the Giphy media URL for a GIF id. */
export function giphyUrlForId(gifId: string): string {
return `https://media.giphy.com/media/${gifId}/giphy.gif`;
}
// --- Reaction (r:<hash>:<index>) ---
const REACTION_PATTERN = /^r:([0-9a-f]{4}):([0-9a-f]{2})$/;
export interface ParsedReaction {
/** The decoded reaction emoji. */
emoji: string;
/** 4-hex hash identifying the target message (not resolved here). */
targetHash: string;
}
/**
* Parse a MeshCore Open reaction payload. Returns the decoded emoji and the
* (unresolved) target-message hash, or null if the (trimmed) text is not a
* valid `r:<hash>:<index>` payload or the index is out of range.
*/
export function parseReaction(text: string): ParsedReaction | null {
const match = REACTION_PATTERN.exec(text.trim());
if (!match) return null;
const index = parseInt(match[2], 16);
if (!Number.isInteger(index) || index < 0 || index >= REACTION_EMOJIS.length) {
return null;
}
return { emoji: REACTION_EMOJIS[index], targetHash: match[1] };
}
@@ -0,0 +1,26 @@
// Browser-local preference for rendering MeshCore Open rich-chat payloads
// (Giphy GIFs and emoji reactions) instead of their raw encoded text. This is
// a pure display tweak, stored per-browser in localStorage. GIF rendering
// fetches images from media.giphy.com, so it is off by default.
export const RENDER_RICH_PAYLOADS_KEY = 'remoteterm-render-rich-payloads';
export function getSavedRenderRichPayloads(): boolean {
try {
return localStorage.getItem(RENDER_RICH_PAYLOADS_KEY) === 'true';
} catch {
return false;
}
}
export function setSavedRenderRichPayloads(enabled: boolean): void {
try {
if (enabled) {
localStorage.setItem(RENDER_RICH_PAYLOADS_KEY, 'true');
} else {
localStorage.removeItem(RENDER_RICH_PAYLOADS_KEY);
}
} catch {
// localStorage may be unavailable
}
}
+2
View File
@@ -384,6 +384,8 @@ class TestContactMessageCLIFiltering:
"sender_name",
"channel_name",
"packet_id",
"transport_code",
"region",
}
with patch("app.event_handlers.broadcast_event") as mock_broadcast:
+1 -1
View File
@@ -2,4 +2,4 @@
# run ``run_migrations`` to completion assert ``get_version == LATEST`` and
# ``applied == LATEST - starting_version`` so only this constant needs to
# change, not every individual assertion.
LATEST_SCHEMA_VERSION = 62
LATEST_SCHEMA_VERSION = 63
+198
View File
@@ -0,0 +1,198 @@
"""Tests for regional flood-scope (transport code) resolution."""
import hashlib
import hmac
import json
from pathlib import Path
from unittest.mock import patch
import pytest
from app.path_utils import parse_packet_envelope
from app.region_resolver import compute_transport_code, resolve_region
FIXTURES_PATH = Path(__file__).parent / "fixtures" / "websocket_events.json"
with open(FIXTURES_PATH) as f:
FIXTURES = json.load(f)
def _reference_code(region_name: str, payload_type: int, payload: bytes) -> int:
"""Independent reimplementation of the firmware algorithm for cross-checking."""
key = hashlib.sha256(("#" + region_name).encode()).digest()[:16]
digest = hmac.new(key, bytes([payload_type]) + payload, hashlib.sha256).digest()
code = int.from_bytes(digest[:2], "little")
if code == 0:
return 1
if code == 0xFFFF:
return 0xFFFE
return code
class TestComputeTransportCode:
def test_matches_reference_algorithm(self):
payload = bytes.fromhex("0badcafe1234")
assert compute_transport_code("nl-gr", 0x05, payload) == _reference_code(
"nl-gr", 0x05, payload
)
def test_hashtag_prefix_is_equivalent(self):
payload = b"hello"
assert compute_transport_code("nl-gr", 0x05, payload) == compute_transport_code(
"#nl-gr", 0x05, payload
)
def test_blank_region_returns_none(self):
assert compute_transport_code("", 0x05, b"x") is None
def test_code_depends_on_payload(self):
# The code is a keyed MAC over the payload, so different payloads under the
# same region produce different codes (this is why there is no static map).
assert compute_transport_code("nl-gr", 0x05, b"a") != compute_transport_code(
"nl-gr", 0x05, b"b"
)
def test_never_returns_reserved_values(self):
for i in range(3000):
code = compute_transport_code(f"region-{i}", 0x05, bytes([i & 0xFF, (i >> 8) & 0xFF]))
assert code not in (0x0000, 0xFFFF)
class TestResolveRegion:
def test_resolves_first_matching_region(self):
payload = bytes.fromhex("c0ffee")
code = compute_transport_code("nl-gr", 0x05, payload)
assert resolve_region(0x05, payload, code, ["de-by", "nl-gr", "fr"]) == "nl-gr"
def test_no_match_returns_none(self):
payload = bytes.fromhex("c0ffee")
code = compute_transport_code("nl-gr", 0x05, payload)
assert resolve_region(0x05, payload, code, ["de-by", "fr"]) is None
def test_empty_candidates_returns_none(self):
assert resolve_region(0x05, b"x", 0x1234, []) is None
def test_blank_candidate_names_skipped(self):
payload = b"x"
code = compute_transport_code("nl-gr", 0x05, payload)
assert resolve_region(0x05, payload, code, ["", "nl-gr"]) == "nl-gr"
class TestEnvelopeTransportCodes:
def test_flood_packet_has_no_transport_codes(self):
raw = bytes.fromhex(FIXTURES["channel_message"]["raw_packet_hex"])
env = parse_packet_envelope(raw)
assert env is not None
assert env.transport_codes is None
def test_transport_routed_packet_exposes_codes(self):
# Build a TRANSPORT_FLOOD packet: header | code_1 | code_2 | path_byte | payload
code_1, code_2 = 0x9164, 0x0000
header = bytes([0x05 << 2]) # payload_type=GROUP_TEXT, route_type=TRANSPORT_FLOOD(0)
raw = (
header
+ code_1.to_bytes(2, "little")
+ code_2.to_bytes(2, "little")
+ bytes([0x00]) # path byte: 0 hops, 1-byte hash
+ b"payloadbytes"
)
env = parse_packet_envelope(raw)
assert env is not None
assert env.transport_codes == (code_1, code_2)
assert env.payload == b"payloadbytes"
def _build_transport_channel_packet(region_name: str | None, code_override: int | None = None):
"""Rebuild the channel fixture as a TRANSPORT_FLOOD packet, scoped to a region."""
raw = bytes.fromhex(FIXTURES["channel_message"]["raw_packet_hex"])
env = parse_packet_envelope(raw)
assert env is not None and env.hop_count == 0
payload = env.payload
if code_override is not None:
code_1 = code_override
else:
code_1 = compute_transport_code(region_name, 0x05, payload)
header = bytes([0x05 << 2]) # GROUP_TEXT + TRANSPORT_FLOOD
return (
header + code_1.to_bytes(2, "little") + (0).to_bytes(2, "little") + bytes([0x00]) + payload
), code_1
class TestRegionPersistedOnChannelMessage:
@pytest.mark.asyncio
async def test_known_region_stored_on_message_and_broadcast(self, test_db, captured_broadcasts):
from app.packet_processor import process_raw_packet
from app.repository import AppSettingsRepository, ChannelRepository, MessageRepository
fixture = FIXTURES["channel_message"]
await ChannelRepository.upsert(
key=fixture["channel_key_hex"].upper(), name=fixture["channel_name"], is_hashtag=True
)
await AppSettingsRepository.update(known_regions=["nl-gr"])
packet_bytes, code = _build_transport_channel_packet("nl-gr")
broadcasts, mock_broadcast = captured_broadcasts
with patch("app.packet_processor.broadcast_event", mock_broadcast):
await process_raw_packet(packet_bytes, timestamp=1700000000)
messages = await MessageRepository.get_all(
msg_type="CHAN", conversation_key=fixture["channel_key_hex"].upper(), limit=10
)
assert len(messages) == 1
assert messages[0].region == "nl-gr"
assert messages[0].transport_code == code
# WS message + raw_packet broadcasts both carry the region
msg_b = [b for b in broadcasts if b["type"] == "message"][0]
assert msg_b["data"]["region"] == "nl-gr"
assert msg_b["data"]["transport_code"] == code
raw_b = [b for b in broadcasts if b["type"] == "raw_packet"][0]
assert raw_b["data"]["region"] == "nl-gr"
assert raw_b["data"]["transport_code"] == code
@pytest.mark.asyncio
async def test_unlisted_region_keeps_code_but_no_name(self, test_db, captured_broadcasts):
from app.packet_processor import process_raw_packet
from app.repository import AppSettingsRepository, ChannelRepository, MessageRepository
fixture = FIXTURES["channel_message"]
await ChannelRepository.upsert(
key=fixture["channel_key_hex"].upper(), name=fixture["channel_name"], is_hashtag=True
)
# Region list does NOT include the scope this packet is tagged with.
await AppSettingsRepository.update(known_regions=["somewhere-else"])
packet_bytes, code = _build_transport_channel_packet("nl-gr")
_, mock_broadcast = captured_broadcasts
with patch("app.packet_processor.broadcast_event", mock_broadcast):
await process_raw_packet(packet_bytes, timestamp=1700000000)
messages = await MessageRepository.get_all(
msg_type="CHAN", conversation_key=fixture["channel_key_hex"].upper(), limit=10
)
assert len(messages) == 1
# Scoped (transport_code set) but region unknown → distinguishable from unscoped.
assert messages[0].transport_code == code
assert messages[0].region is None
@pytest.mark.asyncio
async def test_plain_flood_message_is_unscoped(self, test_db, captured_broadcasts):
from app.packet_processor import process_raw_packet
from app.repository import AppSettingsRepository, ChannelRepository, MessageRepository
fixture = FIXTURES["channel_message"]
await ChannelRepository.upsert(
key=fixture["channel_key_hex"].upper(), name=fixture["channel_name"], is_hashtag=True
)
await AppSettingsRepository.update(known_regions=["nl-gr"])
packet_bytes = bytes.fromhex(fixture["raw_packet_hex"]) # original FLOOD packet
_, mock_broadcast = captured_broadcasts
with patch("app.packet_processor.broadcast_event", mock_broadcast):
await process_raw_packet(packet_bytes, timestamp=1700000000)
messages = await MessageRepository.get_all(
msg_type="CHAN", conversation_key=fixture["channel_key_hex"].upper(), limit=10
)
assert len(messages) == 1
assert messages[0].transport_code is None
assert messages[0].region is None
+23
View File
@@ -81,6 +81,29 @@ class TestUpdateSettings:
result = await update_settings(AppSettingsUpdate(flood_scope="#MyRegion"))
assert result.flood_scope == "#MyRegion"
@pytest.mark.asyncio
async def test_known_regions_round_trip(self, test_db):
"""Known regions should be saved and retrieved as a clean list."""
result = await update_settings(AppSettingsUpdate(known_regions=["nl-gr", "de-by"]))
assert result.known_regions == ["nl-gr", "de-by"]
fresh = await AppSettingsRepository.get()
assert fresh.known_regions == ["nl-gr", "de-by"]
@pytest.mark.asyncio
async def test_known_regions_default_empty(self, test_db):
"""Fresh DB should have known_regions as an empty list."""
settings = await AppSettingsRepository.get()
assert settings.known_regions == []
@pytest.mark.asyncio
async def test_known_regions_cleaned_and_deduped(self, test_db):
"""Leading hashes, whitespace, blanks, and case-insensitive dupes are normalized."""
result = await update_settings(
AppSettingsUpdate(known_regions=[" #nl-gr ", "", "nl-gr", "DE-BY", "de-by"])
)
assert result.known_regions == ["nl-gr", "DE-BY"]
@pytest.mark.asyncio
async def test_flood_scope_applies_to_radio(self, test_db):
"""When radio is connected, setting flood_scope calls set_flood_scope on radio."""