diff --git a/app/packet_processor.py b/app/packet_processor.py index 79210a5..e778aaf 100644 --- a/app/packet_processor.py +++ b/app/packet_processor.py @@ -115,6 +115,8 @@ async def create_dm_message_from_decrypted( outgoing: bool = False, realtime: bool = True, packet_hash: str | None = None, + transport_code: int | None = None, + region: str | None = None, ) -> int | None: """Store a decrypted direct message via the shared message service.""" return await _create_dm_message_from_decrypted( @@ -131,6 +133,8 @@ async def create_dm_message_from_decrypted( realtime=realtime, broadcast_fn=broadcast_event, packet_hash=packet_hash, + transport_code=transport_code, + region=region, ) @@ -391,7 +395,15 @@ async def process_raw_packet( elif payload_type == PayloadType.TEXT_MESSAGE: # Try to decrypt direct messages using stored private key and known contacts decrypt_result = await _process_direct_message( - 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) @@ -656,6 +668,8 @@ async def _process_direct_message( 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 TEXT_MESSAGE (direct message) packet. @@ -780,6 +794,8 @@ async def _process_direct_message( snr=snr, outgoing=effective_outgoing, packet_hash=packet_hash, + transport_code=transport_code, + region=region, ) return { diff --git a/app/services/dm_ingest.py b/app/services/dm_ingest.py index 318aafc..b7a5133 100644 --- a/app/services/dm_ingest.py +++ b/app/services/dm_ingest.py @@ -155,6 +155,8 @@ async def _store_direct_message( best_effort_content_dedup: bool, linked_packet_dedup: bool, packet_hash: str | None = None, + transport_code: int | None = None, + region: str | None = None, message_repository=MessageRepository, contact_repository=ContactRepository, raw_packet_repository=RawPacketRepository, @@ -213,6 +215,8 @@ async def _store_direct_message( outgoing=outgoing, sender_key=sender_key, sender_name=sender_name, + transport_code=transport_code, + region=region, ) if msg_id is None: await handle_duplicate_message( @@ -248,6 +252,8 @@ async def _store_direct_message( outgoing=outgoing, sender_name=sender_name, packet_id=packet_id, + transport_code=transport_code, + region=region, ) broadcast_message( message=message, broadcast_fn=broadcast_fn, realtime=realtime, packet_hash=packet_hash @@ -283,6 +289,8 @@ async def ingest_decrypted_direct_message( realtime: bool = True, broadcast_fn: BroadcastFn, packet_hash: str | None = None, + transport_code: int | None = None, + region: str | None = None, contact_repository=ContactRepository, ) -> Message | None: conversation_key = their_public_key.lower() @@ -343,6 +351,8 @@ async def ingest_decrypted_direct_message( best_effort_content_dedup=outgoing, linked_packet_dedup=True, packet_hash=packet_hash, + transport_code=transport_code, + region=region, ) if message is None: return None diff --git a/app/services/message_send.py b/app/services/message_send.py index 17a0775..1e6ad4b 100644 --- a/app/services/message_send.py +++ b/app/services/message_send.py @@ -191,30 +191,6 @@ async def send_channel_message_with_effective_scope( # (channel-default) path leaves the radio untouched when no override is set. apply_scope = desired_scope != baseline_scope and (bool(desired_scope) or scope_explicit) - if apply_scope: - logger.info( - "Temporarily applying flood_scope %s for %s", - desired_scope or "(unscoped)", - channel.name, - ) - override_result = await set_radio_flood_scope( - mc, desired_scope, fw_ver=radio_manager.firmware_ver_code - ) - if override_result is not None and override_result.type == EventType.ERROR: - logger.warning( - "Failed to apply flood_scope %r for %s: %s", - desired_scope, - channel.name, - override_result.payload, - ) - raise HTTPException( - status_code=422, - detail=( - f"Failed to apply regional override {desired_scope!r} before {action_label}: " - f"{override_result.payload}" - ), - ) - # Path hash mode per-channel override override_phm = channel.path_hash_mode_override baseline_phm = radio_manager.path_hash_mode @@ -224,28 +200,59 @@ async def send_channel_message_with_effective_scope( and override_phm != baseline_phm ) - if apply_phm: - logger.info( - "Temporarily applying channel path_hash_mode override for %s: %d", - channel.name, - override_phm, - ) - phm_result = await mc.commands.set_path_hash_mode(override_phm) - if phm_result is not None and phm_result.type == EventType.ERROR: - logger.warning( - "Failed to apply channel path_hash_mode override for %s: %s", - channel.name, - phm_result.payload, - ) - raise HTTPException( - status_code=422, - detail=( - f"Failed to apply path hash mode override before {action_label}: " - f"{phm_result.payload}" - ), - ) - + # Apply the temporary overrides and send inside the try so the finally below + # always restores the radio's baseline scope/hash-mode -- even when applying an + # override raises (e.g. the radio rejects the scope, or the path-hash-mode apply + # fails) or the send itself throws. ``apply_scope``/``apply_phm`` are computed + # above so the finally can reference them regardless of where we fail, and + # restoring to baseline is idempotent, so forcing baseline back after an apply + # whose effect on the radio is unknown is safe. try: + if apply_scope: + logger.info( + "Temporarily applying flood_scope %s for %s", + desired_scope or "(unscoped)", + channel.name, + ) + override_result = await set_radio_flood_scope( + mc, desired_scope, fw_ver=radio_manager.firmware_ver_code + ) + if override_result is not None and override_result.type == EventType.ERROR: + logger.warning( + "Failed to apply flood_scope %r for %s: %s", + desired_scope, + channel.name, + override_result.payload, + ) + raise HTTPException( + status_code=422, + detail=( + f"Failed to apply regional override {desired_scope!r} before {action_label}: " + f"{override_result.payload}" + ), + ) + + if apply_phm: + logger.info( + "Temporarily applying channel path_hash_mode override for %s: %d", + channel.name, + override_phm, + ) + phm_result = await mc.commands.set_path_hash_mode(override_phm) + if phm_result is not None and phm_result.type == EventType.ERROR: + logger.warning( + "Failed to apply channel path_hash_mode override for %s: %s", + channel.name, + phm_result.payload, + ) + raise HTTPException( + status_code=422, + detail=( + f"Failed to apply path hash mode override before {action_label}: " + f"{phm_result.payload}" + ), + ) + channel_slot, needs_configure, evicted_channel_key = radio_manager.plan_channel_send_slot( channel_key, preferred_slot=temp_radio_slot, diff --git a/app/services/messages.py b/app/services/messages.py index 9e37881..643eaaf 100644 --- a/app/services/messages.py +++ b/app/services/messages.py @@ -408,6 +408,8 @@ async def create_dm_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 direct message.""" from app.services.dm_ingest import ingest_decrypted_direct_message @@ -425,6 +427,8 @@ async def create_dm_message_from_decrypted( realtime=realtime, broadcast_fn=broadcast_fn, packet_hash=packet_hash, + transport_code=transport_code, + region=region, ) return message.id if message is not None else None diff --git a/frontend/src/components/ChatHeader.tsx b/frontend/src/components/ChatHeader.tsx index 00f24a9..478a26d 100644 --- a/frontend/src/components/ChatHeader.tsx +++ b/frontend/src/components/ChatHeader.tsx @@ -7,7 +7,7 @@ import { ChannelFloodScopeOverrideModal } from './ChannelFloodScopeOverrideModal import { ChannelPathHashModeOverrideModal } from './ChannelPathHashModeOverrideModal'; import { handleKeyboardActivate } from '../utils/a11y'; import { isPublicChannelKey } from '../utils/publicChannel'; -import { stripRegionScopePrefix } from '../utils/regionScope'; +import { stripRegionScopePrefix, floodScopeOverrideLabel } from '../utils/regionScope'; import { isPrefixOnlyContact } from '../utils/pubkey'; import { cn } from '../lib/utils'; import { ContactAvatar } from './ContactAvatar'; @@ -102,7 +102,9 @@ export function ChatHeader({ const activeFloodScopeLabel = activeFloodScopeOverride ? stripRegionScopePrefix(activeFloodScopeOverride) : null; - const activeFloodScopeDisplay = activeFloodScopeOverride ? activeFloodScopeOverride : null; + // Badge text: maps the raw override ("*", "#Region", null) to a friendly label + // so the unscoped marker renders as "unscoped" instead of a bare "*". + const activeFloodScopeBadge = floodScopeOverrideLabel(activeFloodScopeOverride); const activePathHashModeOverride = conversation.type === 'channel' ? (activeChannel?.path_hash_mode_override ?? null) : null; const showPathHashModeOverride = @@ -256,7 +258,7 @@ export function ChatHeader({ )} - {conversation.type === 'channel' && activeFloodScopeDisplay && ( + {conversation.type === 'channel' && activeFloodScopeBadge && ( )} @@ -445,9 +447,9 @@ export function ChatHeader({ className={`h-4 w-4 ${activeFloodScopeLabel ? 'text-[hsl(var(--region-override))]' : 'text-muted-foreground'}`} aria-hidden="true" /> - {activeFloodScopeDisplay && ( + {activeFloodScopeBadge && ( - {activeFloodScopeDisplay} + {activeFloodScopeBadge} )} @@ -513,7 +515,7 @@ export function ChatHeader({ open={channelOverrideOpen} onClose={() => setChannelOverrideOpen(false)} roomName={conversation.name} - currentOverride={activeFloodScopeDisplay} + currentOverride={activeFloodScopeOverride} onSetOverride={(value) => onSetChannelFloodScopeOverride(conversation.id, value)} /> )} diff --git a/frontend/src/test/regionScope.test.ts b/frontend/src/test/regionScope.test.ts new file mode 100644 index 0000000..c56959a --- /dev/null +++ b/frontend/src/test/regionScope.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { + UNSCOPED_OVERRIDE_MARKER, + isUnscopedMarker, + stripRegionScopePrefix, + floodScopeOverrideLabel, +} from '../utils/regionScope'; + +describe('regionScope helpers', () => { + describe('isUnscopedMarker', () => { + it('recognizes the unscoped marker', () => { + expect(isUnscopedMarker(UNSCOPED_OVERRIDE_MARKER)).toBe(true); + expect(isUnscopedMarker('*')).toBe(true); + }); + + it('rejects regions, null, undefined, and empty', () => { + expect(isUnscopedMarker('#Esperance')).toBe(false); + expect(isUnscopedMarker('Esperance')).toBe(false); + expect(isUnscopedMarker(null)).toBe(false); + expect(isUnscopedMarker(undefined)).toBe(false); + expect(isUnscopedMarker('')).toBe(false); + }); + }); + + describe('stripRegionScopePrefix', () => { + it('strips a leading hash', () => { + expect(stripRegionScopePrefix('#Esperance')).toBe('Esperance'); + }); + + it('leaves unprefixed values (incl. the marker) unchanged', () => { + expect(stripRegionScopePrefix('Esperance')).toBe('Esperance'); + expect(stripRegionScopePrefix('*')).toBe('*'); + }); + + it('returns empty string for nullish input', () => { + expect(stripRegionScopePrefix(null)).toBe(''); + expect(stripRegionScopePrefix(undefined)).toBe(''); + expect(stripRegionScopePrefix('')).toBe(''); + }); + }); + + describe('floodScopeOverrideLabel', () => { + it('returns null when there is no override (inherit)', () => { + expect(floodScopeOverrideLabel(null)).toBeNull(); + expect(floodScopeOverrideLabel(undefined)).toBeNull(); + expect(floodScopeOverrideLabel('')).toBeNull(); + }); + + it('maps the unscoped marker to a friendly label, not a bare "*"', () => { + expect(floodScopeOverrideLabel(UNSCOPED_OVERRIDE_MARKER)).toBe('unscoped'); + expect(floodScopeOverrideLabel('*')).toBe('unscoped'); + }); + + it('passes a region name through unchanged', () => { + expect(floodScopeOverrideLabel('#Esperance')).toBe('#Esperance'); + expect(floodScopeOverrideLabel('Esperance')).toBe('Esperance'); + }); + }); +}); diff --git a/frontend/src/utils/regionScope.ts b/frontend/src/utils/regionScope.ts index 64219ff..b9d6537 100644 --- a/frontend/src/utils/regionScope.ts +++ b/frontend/src/utils/regionScope.ts @@ -11,3 +11,12 @@ export function stripRegionScopePrefix(scope: string | null | undefined): string if (!scope) return ''; return scope.startsWith('#') ? scope.slice(1) : scope; } + +// Compact, human-readable label for a channel's persisted flood-scope override, +// suitable for a header badge. Returns null when there is no override (inherit), +// "unscoped" for the unscoped marker, and the region name as-is otherwise. +export function floodScopeOverrideLabel(scope: string | null | undefined): string | null { + if (!scope) return null; + if (isUnscopedMarker(scope)) return 'unscoped'; + return scope; +} diff --git a/tests/test_packet_pipeline.py b/tests/test_packet_pipeline.py index 2aff82c..51642f6 100644 --- a/tests/test_packet_pipeline.py +++ b/tests/test_packet_pipeline.py @@ -1398,6 +1398,53 @@ class TestCreateDMMessageFromDecrypted: assert broadcast["paths"][0]["path"] == "aabbcc" assert broadcast["paths"][0]["received_at"] == 1700000001 + @pytest.mark.asyncio + async def test_dm_includes_region_scope_in_broadcast(self, test_db, captured_broadcasts): + """A region-scoped (transport-routed) flood DM threads transport_code/region + into the stored row and the broadcast, so bots see `scoped`/`region` for DMs + (issue #300 DM half) and the UI can badge the scope.""" + from app.decoder import DecryptedDirectMessage + from app.packet_processor import create_dm_message_from_decrypted + + packet_id, _ = await RawPacketRepository.create(b"region_test_dm", 1700000000) + + decrypted = DecryptedDirectMessage( + timestamp=1700000000, + flags=0, + message="Scoped DM", + dest_hash="fa", + src_hash="a1", + ) + + broadcasts, mock_broadcast = captured_broadcasts + + with patch("app.packet_processor.broadcast_event", mock_broadcast): + msg_id = await create_dm_message_from_decrypted( + packet_id=packet_id, + decrypted=decrypted, + their_public_key=self.A1B2C3_PUB, + our_public_key=self.FACE12_PUB, + received_at=1700000001, + outgoing=False, + transport_code=0x1234, + region="#Esperance", + ) + + assert msg_id is not None + + message_broadcasts = [b for b in broadcasts if b["type"] == "message"] + assert len(message_broadcasts) == 1 + broadcast = message_broadcasts[0]["data"] + # Bots derive `scoped = transport_code is not None` and read `region`. + assert broadcast["transport_code"] == 0x1234 + assert broadcast["region"] == "#Esperance" + + # Persisted so the scope survives a raw-packet purge, mirroring channel msgs. + stored = await MessageRepository.get_by_id(msg_id) + assert stored is not None + assert stored.transport_code == 0x1234 + assert stored.region == "#Esperance" + class TestDMDecryptionFunction: """Test the DM decryption function with real crypto.""" diff --git a/tests/test_send_messages.py b/tests/test_send_messages.py index 76064f9..63173b5 100644 --- a/tests/test_send_messages.py +++ b/tests/test_send_messages.py @@ -1426,6 +1426,50 @@ class TestPathHashModeOverride: assert "path hash mode" in exc_info.value.detail.lower() mc.commands.send_chan_msg.assert_not_awaited() + @pytest.mark.asyncio + async def test_send_channel_msg_restores_scope_when_phm_apply_fails(self, test_db): + """A flood-scope override that was applied must still be restored when the + subsequent path-hash-mode apply fails. Regression: the scope apply and the + PHM apply used to sit outside the try/finally, so a PHM error left the radio + stuck on the temporary region scope for all later traffic.""" + mc = _make_mc(name="MyNode") + # PHM apply (first call) errors -> raises; PHM restore (second call, in the + # finally) succeeds so this test isolates the scope-restore behavior. + mc.commands.set_path_hash_mode = AsyncMock( + side_effect=[ + MagicMock(type=EventType.ERROR, payload="unsupported mode"), + _make_radio_result(), + ] + ) + chan_key = "f6" * 16 + await ChannelRepository.upsert(key=chan_key, name="#both") + await ChannelRepository.update_flood_scope_override(chan_key, "Esperance") + await ChannelRepository.update_path_hash_mode_override(chan_key, 2) + await AppSettingsRepository.update(flood_scope="Baseline") + + radio_manager.path_hash_mode = 0 + radio_manager.path_hash_mode_supported = True + + with ( + patch("app.routers.messages.radio_manager.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + patch("app.routers.messages.broadcast_event"), + pytest.raises(HTTPException) as exc_info, + ): + await send_channel_message( + SendChannelMessageRequest(channel_key=chan_key, text="hello") + ) + + assert exc_info.value.status_code == 422 + assert "path hash mode" in exc_info.value.detail.lower() + # The send never happens... + mc.commands.send_chan_msg.assert_not_awaited() + # ...but the applied region scope is restored to the global baseline. + assert mc.commands.set_flood_scope.await_args_list == [ + call("#Esperance"), + call("#Baseline"), + ] + @pytest.mark.asyncio async def test_send_channel_msg_phm_restore_failure_broadcasts_error(self, test_db): """Message sends OK but restore failure after 3 attempts broadcasts an error."""