mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-06-22 02:55:53 +02:00
Add draft reactions + gifs; region resolution
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 & 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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -222,6 +222,7 @@ const baseSettings = {
|
||||
advert_interval: 0,
|
||||
last_advert_time: 0,
|
||||
flood_scope: '',
|
||||
known_regions: [],
|
||||
blocked_keys: [],
|
||||
blocked_names: [],
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user