mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Add background-hash-mark addition for region routing
Per https://buymeacoffee.com/ripplebiz/region-filtering: > After some discussions, and that there is some confusion around #channels and #regions, it's been decided to drop the requirement to have the '#' prefix. So, region names will just be plain alphanumeric (and '-'), with no # prefix. > For backwards compatibility, the names will internally have a '#' prepended, but for all client GUI's and command lines, you generally won't see mention of '#' prefixes. The next firmware release (v1.12.0) and subsequent Ripple firmware and Liam's app will have modified UI to remove the '#' requirement. So, silently add, but don't duplicate, for users who have already added hashmarks.
This commit is contained in:
@@ -262,10 +262,11 @@ class RadioManager:
|
||||
|
||||
# Apply flood scope from settings (best-effort; older firmware
|
||||
# may not support set_flood_scope)
|
||||
from app.region_scope import normalize_region_scope
|
||||
from app.repository import AppSettingsRepository
|
||||
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
scope = app_settings.flood_scope
|
||||
scope = normalize_region_scope(app_settings.flood_scope)
|
||||
try:
|
||||
await mc.commands.set_flood_scope(scope if scope else "")
|
||||
logger.info("Applied flood_scope=%r", scope or "(disabled)")
|
||||
|
||||
20
app/region_scope.py
Normal file
20
app/region_scope.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Helpers for normalizing MeshCore flood-scope / region names."""
|
||||
|
||||
|
||||
def normalize_region_scope(scope: str | None) -> str:
|
||||
"""Normalize a user-facing region scope into MeshCore's internal form.
|
||||
|
||||
Region names are now user-facing plain strings like ``Esperance``.
|
||||
Internally, MeshCore still expects hashtag-style names like ``#Esperance``.
|
||||
|
||||
Backward compatibility:
|
||||
- blank/None stays disabled (`""`)
|
||||
- existing leading ``#`` is preserved
|
||||
"""
|
||||
|
||||
stripped = (scope or "").strip()
|
||||
if not stripped:
|
||||
return ""
|
||||
if stripped.startswith("#"):
|
||||
return stripped
|
||||
return f"#{stripped}"
|
||||
@@ -9,6 +9,7 @@ from app.dependencies import require_connected
|
||||
from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender
|
||||
from app.radio import radio_manager
|
||||
from app.radio_sync import upsert_channel_from_radio_slot
|
||||
from app.region_scope import normalize_region_scope
|
||||
from app.repository import ChannelRepository, MessageRepository
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
@@ -153,7 +154,7 @@ async def set_channel_flood_scope_override(
|
||||
if not channel:
|
||||
raise HTTPException(status_code=404, detail="Channel not found")
|
||||
|
||||
override = request.flood_scope_override.strip() or None
|
||||
override = normalize_region_scope(request.flood_scope_override) or None
|
||||
updated = await ChannelRepository.update_flood_scope_override(channel.key, override)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=500, detail="Failed to update flood-scope override")
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.models import (
|
||||
SendDirectMessageRequest,
|
||||
)
|
||||
from app.radio import radio_manager
|
||||
from app.region_scope import normalize_region_scope
|
||||
from app.repository import AmbiguousPublicKeyPrefixError, AppSettingsRepository, MessageRepository
|
||||
from app.websocket import broadcast_error, broadcast_event
|
||||
|
||||
@@ -31,12 +32,12 @@ async def _send_channel_message_with_effective_scope(
|
||||
action_label: str,
|
||||
) -> Any:
|
||||
"""Send a channel message, temporarily overriding flood scope when configured."""
|
||||
override_scope = (channel.flood_scope_override or "").strip()
|
||||
override_scope = normalize_region_scope(channel.flood_scope_override)
|
||||
baseline_scope = ""
|
||||
|
||||
if override_scope:
|
||||
settings = await AppSettingsRepository.get()
|
||||
baseline_scope = settings.flood_scope
|
||||
baseline_scope = normalize_region_scope(settings.flood_scope)
|
||||
|
||||
if override_scope and override_scope != baseline_scope:
|
||||
logger.info(
|
||||
|
||||
@@ -6,6 +6,7 @@ from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.models import AppSettings
|
||||
from app.region_scope import normalize_region_scope
|
||||
from app.repository import AppSettingsRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -123,8 +124,7 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
|
||||
# Flood scope
|
||||
flood_scope_changed = False
|
||||
if update.flood_scope is not None:
|
||||
stripped = update.flood_scope.strip()
|
||||
kwargs["flood_scope"] = stripped
|
||||
kwargs["flood_scope"] = normalize_region_scope(update.flood_scope)
|
||||
flood_scope_changed = True
|
||||
|
||||
if kwargs:
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { toast } from './ui/sonner';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { stripRegionScopePrefix } from '../utils/regionScope';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
import { ContactStatusInfo } from './ContactStatusInfo';
|
||||
import type { Channel, Contact, Conversation, Favorite, RadioConfig } from '../types';
|
||||
@@ -54,8 +55,8 @@ export function ChatHeader({
|
||||
const handleEditFloodScopeOverride = () => {
|
||||
if (conversation.type !== 'channel' || !onSetChannelFloodScopeOverride) return;
|
||||
const nextValue = window.prompt(
|
||||
'Enter regional override flood scope for this room. This temporarily changes the radio flood scope before send and restores it after, which significantly slows room sends. Leave blank to clear.\n\nNote: some radio clients (including the official ones) implicitly add a "#" character to the beginning of a region specifier. That needs to be manually added here, so if you use the official app with region "Anytown", you should enter it here as "#Anytown".',
|
||||
activeChannel?.flood_scope_override ?? ''
|
||||
'Enter regional override flood scope for this room. This temporarily changes the radio flood scope before send and restores it after, which significantly slows room sends. Leave blank to clear.',
|
||||
stripRegionScopePrefix(activeChannel?.flood_scope_override)
|
||||
);
|
||||
if (nextValue === null) return;
|
||||
onSetChannelFloodScopeOverride(conversation.id, nextValue);
|
||||
@@ -140,7 +141,7 @@ export function ChatHeader({
|
||||
)}
|
||||
{conversation.type === 'channel' && activeChannel?.flood_scope_override && (
|
||||
<span className="basis-full sm:basis-auto text-[11px] text-amber-700 dark:text-amber-300 truncate">
|
||||
Regional override active: {activeChannel.flood_scope_override}
|
||||
Regional override active: {stripRegionScopePrefix(activeChannel.flood_scope_override)}
|
||||
</span>
|
||||
)}
|
||||
{conversation.type === 'contact' &&
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from '../ui/button';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { toast } from '../ui/sonner';
|
||||
import { RADIO_PRESETS } from '../../utils/radioPresets';
|
||||
import { stripRegionScopePrefix } from '../../utils/regionScope';
|
||||
import type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
@@ -83,7 +84,7 @@ export function SettingsRadioSection({
|
||||
|
||||
useEffect(() => {
|
||||
setAdvertIntervalHours(String(Math.round(appSettings.advert_interval / 3600)));
|
||||
setFloodScope(appSettings.flood_scope);
|
||||
setFloodScope(stripRegionScopePrefix(appSettings.flood_scope));
|
||||
setMaxRadioContacts(String(appSettings.max_radio_contacts));
|
||||
}, [appSettings]);
|
||||
|
||||
@@ -256,7 +257,7 @@ export function SettingsRadioSection({
|
||||
if (newAdvertInterval !== appSettings.advert_interval) {
|
||||
update.advert_interval = newAdvertInterval;
|
||||
}
|
||||
if (floodScope !== appSettings.flood_scope) {
|
||||
if (floodScope !== stripRegionScopePrefix(appSettings.flood_scope)) {
|
||||
update.flood_scope = floodScope;
|
||||
}
|
||||
const newMaxRadioContacts = parseInt(maxRadioContacts, 10);
|
||||
@@ -554,10 +555,10 @@ export function SettingsRadioSection({
|
||||
id="flood-scope"
|
||||
value={floodScope}
|
||||
onChange={(e) => setFloodScope(e.target.value)}
|
||||
placeholder="#MyRegion"
|
||||
placeholder="MyRegion"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Tag outgoing flood messages with a region name (e.g. #MyRegion). Repeaters configured for
|
||||
Tag outgoing flood messages with a region name (e.g. MyRegion). Repeaters configured for
|
||||
that region can forward the traffic, while repeaters configured to deny other regions may
|
||||
drop it. Leave empty to disable.
|
||||
</p>
|
||||
|
||||
@@ -117,7 +117,7 @@ describe('ChatHeader key visibility', () => {
|
||||
|
||||
render(<ChatHeader {...baseProps} conversation={conversation} channels={[channel]} />);
|
||||
|
||||
expect(screen.getByText('Regional override active: #Esperance')).toBeInTheDocument();
|
||||
expect(screen.getByText('Regional override active: Esperance')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prompts for regional override when globe button is clicked', () => {
|
||||
@@ -125,7 +125,7 @@ describe('ChatHeader key visibility', () => {
|
||||
const channel = makeChannel(key, '#flightless', true);
|
||||
const conversation: Conversation = { type: 'channel', id: key, name: '#flightless' };
|
||||
const onSetChannelFloodScopeOverride = vi.fn();
|
||||
const promptSpy = vi.spyOn(window, 'prompt').mockReturnValue('#Esperance');
|
||||
const promptSpy = vi.spyOn(window, 'prompt').mockReturnValue('Esperance');
|
||||
|
||||
render(
|
||||
<ChatHeader
|
||||
@@ -139,7 +139,7 @@ describe('ChatHeader key visibility', () => {
|
||||
fireEvent.click(screen.getByTitle('Set regional override'));
|
||||
|
||||
expect(promptSpy).toHaveBeenCalled();
|
||||
expect(onSetChannelFloodScopeOverride).toHaveBeenCalledWith(key, '#Esperance');
|
||||
expect(onSetChannelFloodScopeOverride).toHaveBeenCalledWith(key, 'Esperance');
|
||||
promptSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
4
frontend/src/utils/regionScope.ts
Normal file
4
frontend/src/utils/regionScope.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function stripRegionScopePrefix(scope: string | null | undefined): string {
|
||||
if (!scope) return '';
|
||||
return scope.startsWith('#') ? scope.slice(1) : scope;
|
||||
}
|
||||
@@ -270,7 +270,7 @@ class TestChannelFloodScopeOverride:
|
||||
with patch("app.routers.channels.broadcast_event") as mock_broadcast:
|
||||
response = await client.post(
|
||||
f"/api/channels/{key}/flood-scope-override",
|
||||
json={"flood_scope_override": "#Esperance"},
|
||||
json={"flood_scope_override": "Esperance"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -283,6 +283,20 @@ class TestChannelFloodScopeOverride:
|
||||
mock_broadcast.assert_called_once()
|
||||
assert mock_broadcast.call_args.args[0] == "channel"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_existing_hash_is_not_doubled(self, test_db, client):
|
||||
key = "CC" * 16
|
||||
await ChannelRepository.upsert(key=key, name="#flightless", is_hashtag=True)
|
||||
|
||||
response = await client.post(
|
||||
f"/api/channels/{key}/flood-scope-override",
|
||||
json={"flood_scope_override": "#Esperance"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["flood_scope_override"] == "#Esperance"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blank_override_clears_channel_flood_scope_override(self, test_db, client):
|
||||
key = "BB" * 16
|
||||
|
||||
@@ -655,6 +655,40 @@ class TestPostConnectSetupOrdering:
|
||||
|
||||
mock_mc.commands.set_flood_scope.assert_awaited_once_with("#TestRegion")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plain_flood_scope_is_normalized_during_setup(self):
|
||||
"""Legacy/plain stored flood_scope is normalized before applying to radio."""
|
||||
from app.models import AppSettings
|
||||
from app.radio import RadioManager
|
||||
|
||||
rm = RadioManager()
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.start_auto_message_fetching = AsyncMock()
|
||||
mock_mc.commands.set_flood_scope = AsyncMock()
|
||||
rm._meshcore = mock_mc
|
||||
|
||||
mock_settings = AppSettings(flood_scope="TestRegion")
|
||||
|
||||
with (
|
||||
patch("app.event_handlers.register_event_handlers"),
|
||||
patch("app.keystore.export_and_store_private_key", new_callable=AsyncMock),
|
||||
patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock),
|
||||
patch(
|
||||
"app.repository.AppSettingsRepository.get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_settings,
|
||||
),
|
||||
patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock, return_value={}),
|
||||
patch("app.radio_sync.start_periodic_sync"),
|
||||
patch("app.radio_sync.send_advertisement", new_callable=AsyncMock, return_value=False),
|
||||
patch("app.radio_sync.start_periodic_advert"),
|
||||
patch("app.radio_sync.drain_pending_messages", new_callable=AsyncMock, return_value=0),
|
||||
patch("app.radio_sync.start_message_polling"),
|
||||
):
|
||||
await rm.post_connect_setup()
|
||||
|
||||
mock_mc.commands.set_flood_scope.assert_awaited_once_with("#TestRegion")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flood_scope_empty_resets_during_setup(self):
|
||||
"""Empty flood_scope calls set_flood_scope("") during post_connect_setup."""
|
||||
|
||||
@@ -279,8 +279,8 @@ class TestOutgoingChannelBroadcast:
|
||||
mc = _make_mc(name="MyNode")
|
||||
chan_key = "de" * 16
|
||||
await ChannelRepository.upsert(key=chan_key, name="#flightless")
|
||||
await ChannelRepository.update_flood_scope_override(chan_key, "#Esperance")
|
||||
await AppSettingsRepository.update(flood_scope="#Baseline")
|
||||
await ChannelRepository.update_flood_scope_override(chan_key, "Esperance")
|
||||
await AppSettingsRepository.update(flood_scope="Baseline")
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
@@ -303,8 +303,8 @@ class TestOutgoingChannelBroadcast:
|
||||
mc = _make_mc(name="MyNode")
|
||||
chan_key = "df" * 16
|
||||
await ChannelRepository.upsert(key=chan_key, name="#matching")
|
||||
await ChannelRepository.update_flood_scope_override(chan_key, "#Esperance")
|
||||
await AppSettingsRepository.update(flood_scope="#Esperance")
|
||||
await ChannelRepository.update_flood_scope_override(chan_key, "Esperance")
|
||||
await AppSettingsRepository.update(flood_scope="Esperance")
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
|
||||
@@ -55,7 +55,7 @@ class TestUpdateSettings:
|
||||
@pytest.mark.asyncio
|
||||
async def test_flood_scope_round_trip(self, test_db):
|
||||
"""Flood scope should be saved and retrieved correctly."""
|
||||
result = await update_settings(AppSettingsUpdate(flood_scope="#MyRegion"))
|
||||
result = await update_settings(AppSettingsUpdate(flood_scope="MyRegion"))
|
||||
assert result.flood_scope == "#MyRegion"
|
||||
|
||||
fresh = await AppSettingsRepository.get()
|
||||
@@ -70,7 +70,13 @@ class TestUpdateSettings:
|
||||
@pytest.mark.asyncio
|
||||
async def test_flood_scope_whitespace_stripped(self, test_db):
|
||||
"""Flood scope should be stripped of whitespace."""
|
||||
result = await update_settings(AppSettingsUpdate(flood_scope=" #MyRegion "))
|
||||
result = await update_settings(AppSettingsUpdate(flood_scope=" MyRegion "))
|
||||
assert result.flood_scope == "#MyRegion"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flood_scope_existing_hash_is_not_doubled(self, test_db):
|
||||
"""Existing leading hash should be preserved for backward compatibility."""
|
||||
result = await update_settings(AppSettingsUpdate(flood_scope="#MyRegion"))
|
||||
assert result.flood_scope == "#MyRegion"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -92,7 +98,7 @@ class TestUpdateSettings:
|
||||
mock_rm.radio_operation = mock_radio_op
|
||||
|
||||
with patch("app.radio.radio_manager", mock_rm):
|
||||
await update_settings(AppSettingsUpdate(flood_scope="#TestRegion"))
|
||||
await update_settings(AppSettingsUpdate(flood_scope="TestRegion"))
|
||||
|
||||
mock_mc.commands.set_flood_scope.assert_awaited_once_with("#TestRegion")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user