Add regional channel routing (closes #42)

This commit is contained in:
Jack Kingsman
2026-03-09 11:09:31 -07:00
parent 0c5b37c07c
commit 811c7e7349
18 changed files with 604 additions and 61 deletions
+4
View File
@@ -107,6 +107,8 @@ Raw packet handling uses two identities by design:
Frontend packet-feed consumers should treat `observation_id` as the dedup/render key, while `id` remains the storage reference.
Channel metadata updates may also fan out as `channel` WebSocket events (full `Channel` payload) so clients can reflect local-only channel state such as regional flood-scope overrides without a full refetch.
## Contact Advert Path Memory
To improve repeater disambiguation in the network visualizer, the backend stores recent unique advertisement paths per contact in a dedicated table (`contact_advert_paths`).
@@ -307,6 +309,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| POST | `/api/channels` | Create channel |
| DELETE | `/api/channels/{key}` | Delete channel |
| POST | `/api/channels/sync` | Pull from radio |
| POST | `/api/channels/{key}/flood-scope-override` | Set or clear a per-channel regional flood-scope override |
| POST | `/api/channels/{key}/mark-read` | Mark channel as read |
| GET | `/api/messages` | List with filters (`q`, `after`/`after_id` for forward pagination) |
| GET | `/api/messages/around/{id}` | Get messages around a specific message (for jump-to-message) |
@@ -352,6 +355,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
- Stored as 32-character hex string (TEXT PRIMARY KEY)
- Hashtag channels: `SHA256("#name")[:16]` converted to hex
- Custom channels: User-provided or generated
- Channels may also persist `flood_scope_override`; when set, channel sends temporarily switch the radio flood scope to that value for the duration of the send, then restore the global app setting.
### Message Types
+3
View File
@@ -163,6 +163,7 @@ app/
- `POST /channels`
- `DELETE /channels/{key}`
- `POST /channels/sync`
- `POST /channels/{key}/flood-scope-override`
- `POST /channels/{key}/mark-read`
### Messages
@@ -209,6 +210,7 @@ app/
- `message_acked` — ACK/echo update for existing message (ack count + paths)
- `raw_packet` — every incoming RF packet (for real-time packet feed UI)
- `contact_deleted` — contact removed from database (payload: `{ public_key }`)
- `channel` — single channel upsert/update (payload: full `Channel`)
- `channel_deleted` — channel removed from database (payload: `{ key }`)
- `error` — toast notification (reconnect failure, missing private key, etc.)
- `success` — toast notification (historical decrypt complete, etc.)
@@ -221,6 +223,7 @@ Client sends `"ping"` text; server replies `{"type":"pong"}`.
Main tables:
- `contacts` (includes `first_seen` for contact age tracking and `out_path_hash_mode` for route round-tripping)
- `channels`
Includes optional `flood_scope_override` for channel-specific regional sends.
- `messages` (includes `sender_name`, `sender_key` for per-contact channel message attribution)
- `raw_packets`
- `contact_advert_paths` (recent unique advertisement paths per contact, keyed by contact + path bytes + hop count)
+2 -1
View File
@@ -32,7 +32,8 @@ CREATE TABLE IF NOT EXISTS channels (
key TEXT PRIMARY KEY,
name TEXT NOT NULL,
is_hashtag INTEGER DEFAULT 0,
on_radio INTEGER DEFAULT 0
on_radio INTEGER DEFAULT 0,
flood_scope_override TEXT
);
CREATE TABLE IF NOT EXISTS messages (
+28
View File
@@ -324,6 +324,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 41)
applied += 1
# Migration 42: Persist optional per-channel flood-scope overrides
if version < 42:
logger.info("Applying migration 42: add channels flood_scope_override column")
await _migrate_042_add_channel_flood_scope_override(conn)
await set_version(conn, 42)
applied += 1
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -2415,3 +2422,24 @@ async def _migrate_041_add_contact_routing_override_columns(conn: aiosqlite.Conn
raise
await conn.commit()
async def _migrate_042_add_channel_flood_scope_override(conn: aiosqlite.Connection) -> None:
"""Add nullable per-channel flood-scope override column."""
cursor = await conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='channels'"
)
if await cursor.fetchone() is None:
await conn.commit()
return
try:
await conn.execute("ALTER TABLE channels ADD COLUMN flood_scope_override TEXT")
logger.debug("Added flood_scope_override to channels table")
except aiosqlite.OperationalError as e:
if "duplicate column name" in str(e).lower():
logger.debug("channels.flood_scope_override already exists, skipping")
else:
raise
await conn.commit()
+4
View File
@@ -188,6 +188,10 @@ class Channel(BaseModel):
name: str
is_hashtag: bool = False
on_radio: bool = False
flood_scope_override: str | None = Field(
default=None,
description="Per-channel outbound flood scope override (null = use global app setting)",
)
last_read_at: int | None = None # Server-side read state tracking
+24 -4
View File
@@ -10,8 +10,8 @@ class ChannelRepository:
"""Upsert a channel. Key is 32-char hex string."""
await db.conn.execute(
"""
INSERT INTO channels (key, name, is_hashtag, on_radio)
VALUES (?, ?, ?, ?)
INSERT INTO channels (key, name, is_hashtag, on_radio, flood_scope_override)
VALUES (?, ?, ?, ?, NULL)
ON CONFLICT(key) DO UPDATE SET
name = excluded.name,
is_hashtag = excluded.is_hashtag,
@@ -25,7 +25,11 @@ class ChannelRepository:
async def get_by_key(key: str) -> Channel | None:
"""Get a channel by its key (32-char hex string)."""
cursor = await db.conn.execute(
"SELECT key, name, is_hashtag, on_radio, last_read_at FROM channels WHERE key = ?",
"""
SELECT key, name, is_hashtag, on_radio, flood_scope_override, last_read_at
FROM channels
WHERE key = ?
""",
(key.upper(),),
)
row = await cursor.fetchone()
@@ -35,6 +39,7 @@ class ChannelRepository:
name=row["name"],
is_hashtag=bool(row["is_hashtag"]),
on_radio=bool(row["on_radio"]),
flood_scope_override=row["flood_scope_override"],
last_read_at=row["last_read_at"],
)
return None
@@ -42,7 +47,11 @@ class ChannelRepository:
@staticmethod
async def get_all() -> list[Channel]:
cursor = await db.conn.execute(
"SELECT key, name, is_hashtag, on_radio, last_read_at FROM channels ORDER BY name"
"""
SELECT key, name, is_hashtag, on_radio, flood_scope_override, last_read_at
FROM channels
ORDER BY name
"""
)
rows = await cursor.fetchall()
return [
@@ -51,6 +60,7 @@ class ChannelRepository:
name=row["name"],
is_hashtag=bool(row["is_hashtag"]),
on_radio=bool(row["on_radio"]),
flood_scope_override=row["flood_scope_override"],
last_read_at=row["last_read_at"],
)
for row in rows
@@ -79,6 +89,16 @@ class ChannelRepository:
await db.conn.commit()
return cursor.rowcount > 0
@staticmethod
async def update_flood_scope_override(key: str, flood_scope_override: str | None) -> bool:
"""Set or clear a channel's flood-scope override."""
cursor = await db.conn.execute(
"UPDATE channels SET flood_scope_override = ? WHERE key = ?",
(flood_scope_override, key.upper()),
)
await db.conn.commit()
return cursor.rowcount > 0
@staticmethod
async def mark_all_read(timestamp: int) -> None:
"""Mark all channels as read at the given timestamp."""
+30 -2
View File
@@ -10,6 +10,7 @@ from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopS
from app.radio import radio_manager
from app.radio_sync import upsert_channel_from_radio_slot
from app.repository import ChannelRepository, MessageRepository
from app.websocket import broadcast_event
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/channels", tags=["channels"])
@@ -23,6 +24,12 @@ class CreateChannelRequest(BaseModel):
)
class ChannelFloodScopeOverrideRequest(BaseModel):
flood_scope_override: str = Field(
description="Blank clears the override; non-empty values temporarily override flood scope"
)
@router.get("", response_model=list[Channel])
async def list_channels() -> list[Channel]:
"""List all channels from the database."""
@@ -95,6 +102,7 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
name=request.name,
is_hashtag=is_hashtag,
on_radio=False,
flood_scope_override=None,
)
@@ -136,6 +144,28 @@ async def mark_channel_read(key: str) -> dict:
return {"status": "ok", "key": channel.key}
@router.post("/{key}/flood-scope-override", response_model=Channel)
async def set_channel_flood_scope_override(
key: str, request: ChannelFloodScopeOverrideRequest
) -> Channel:
"""Set or clear a per-channel flood-scope override."""
channel = await ChannelRepository.get_by_key(key)
if not channel:
raise HTTPException(status_code=404, detail="Channel not found")
override = request.flood_scope_override.strip() 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")
refreshed = await ChannelRepository.get_by_key(channel.key)
if refreshed is None:
raise HTTPException(status_code=500, detail="Channel disappeared after update")
broadcast_event("channel", refreshed.model_dump())
return refreshed
@router.delete("/{key}")
async def delete_channel(key: str) -> dict:
"""Delete a channel from the database by key.
@@ -146,8 +176,6 @@ async def delete_channel(key: str) -> dict:
logger.info("Deleting channel %s from database", key)
await ChannelRepository.delete(key)
from app.websocket import broadcast_event
broadcast_event("channel_deleted", {"key": key})
return {"status": "ok"}
+115 -37
View File
@@ -1,5 +1,6 @@
import logging
import time
from typing import Any
from fastapi import APIRouter, HTTPException, Query
from meshcore import EventType
@@ -14,12 +15,111 @@ from app.models import (
)
from app.radio import radio_manager
from app.repository import AmbiguousPublicKeyPrefixError, AppSettingsRepository, MessageRepository
from app.websocket import broadcast_event
from app.websocket import broadcast_error, broadcast_event
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/messages", tags=["messages"])
async def _send_channel_message_with_effective_scope(
*,
mc,
channel,
key_bytes: bytes,
text: str,
timestamp_bytes: bytes,
action_label: str,
) -> Any:
"""Send a channel message, temporarily overriding flood scope when configured."""
override_scope = (channel.flood_scope_override or "").strip()
baseline_scope = ""
if override_scope:
settings = await AppSettingsRepository.get()
baseline_scope = settings.flood_scope
if override_scope and override_scope != baseline_scope:
logger.info(
"Temporarily applying channel flood_scope override for %s: %r",
channel.name,
override_scope,
)
override_result = await mc.commands.set_flood_scope(override_scope)
if override_result is not None and override_result.type == EventType.ERROR:
logger.warning(
"Failed to apply channel flood_scope override for %s: %s",
channel.name,
override_result.payload,
)
raise HTTPException(
status_code=500,
detail=(
f"Failed to apply regional override {override_scope!r} before {action_label}: "
f"{override_result.payload}"
),
)
try:
set_result = await mc.commands.set_channel(
channel_idx=TEMP_RADIO_SLOT,
channel_name=channel.name,
channel_secret=key_bytes,
)
if set_result.type == EventType.ERROR:
logger.warning(
"Failed to set channel on radio slot %d before %s: %s",
TEMP_RADIO_SLOT,
action_label,
set_result.payload,
)
raise HTTPException(
status_code=500,
detail=f"Failed to configure channel on radio before {action_label}",
)
return await mc.commands.send_chan_msg(
chan=TEMP_RADIO_SLOT,
msg=text,
timestamp=timestamp_bytes,
)
finally:
if override_scope and override_scope != baseline_scope:
try:
restore_result = await mc.commands.set_flood_scope(
baseline_scope if baseline_scope else ""
)
if restore_result is not None and restore_result.type == EventType.ERROR:
logger.error(
"Failed to restore baseline flood_scope after sending to %s: %s",
channel.name,
restore_result.payload,
)
broadcast_error(
"Regional override restore failed",
(
f"Sent to {channel.name}, but restoring flood scope failed. "
"The radio may still be region-scoped. Consider rebooting the radio."
),
)
else:
logger.debug(
"Restored baseline flood_scope after channel send: %r",
baseline_scope or "(disabled)",
)
except Exception:
logger.exception(
"Failed to restore baseline flood_scope after sending to %s",
channel.name,
)
broadcast_error(
"Regional override restore failed",
(
f"Sent to {channel.name}, but restoring flood scope failed. "
"The radio may still be region-scoped. Consider rebooting the radio."
),
)
@router.get("/around/{message_id}", response_model=MessagesAroundResponse)
async def get_messages_around(
message_id: int,
@@ -228,23 +328,6 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
radio_name = mc.self_info.get("name", "") if mc.self_info else ""
our_public_key = (mc.self_info.get("public_key") or None) if mc.self_info else None
text_with_sender = f"{radio_name}: {request.text}" if radio_name else request.text
# Load the channel to a temporary radio slot before sending
set_result = await mc.commands.set_channel(
channel_idx=TEMP_RADIO_SLOT,
channel_name=db_channel.name,
channel_secret=key_bytes,
)
if set_result.type == EventType.ERROR:
logger.warning(
"Failed to set channel on radio slot %d before sending: %s",
TEMP_RADIO_SLOT,
set_result.payload,
)
raise HTTPException(
status_code=500,
detail="Failed to configure channel on radio before sending message",
)
logger.info("Sending channel message to %s: %s", db_channel.name, request.text[:50])
# Capture timestamp BEFORE sending so we can pass the same value to both the radio
@@ -253,10 +336,13 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
now = int(time.time())
timestamp_bytes = now.to_bytes(4, "little")
result = await mc.commands.send_chan_msg(
chan=TEMP_RADIO_SLOT,
msg=request.text,
timestamp=timestamp_bytes,
result = await _send_channel_message_with_effective_scope(
mc=mc,
channel=db_channel,
key_bytes=key_bytes,
text=request.text,
timestamp_bytes=timestamp_bytes,
action_label="sending message",
)
if result.type == EventType.ERROR:
@@ -390,21 +476,13 @@ async def resend_channel_message(
if radio_name and text_to_send.startswith(f"{radio_name}: "):
text_to_send = text_to_send[len(f"{radio_name}: ") :]
set_result = await mc.commands.set_channel(
channel_idx=TEMP_RADIO_SLOT,
channel_name=db_channel.name,
channel_secret=key_bytes,
)
if set_result.type == EventType.ERROR:
raise HTTPException(
status_code=500,
detail="Failed to configure channel on radio before resending",
)
result = await mc.commands.send_chan_msg(
chan=TEMP_RADIO_SLOT,
msg=text_to_send,
timestamp=timestamp_bytes,
result = await _send_channel_message_with_effective_scope(
mc=mc,
channel=db_channel,
key_bytes=key_bytes,
text=text_to_send,
timestamp_bytes=timestamp_bytes,
action_label="resending message",
)
if result.type == EventType.ERROR:
raise HTTPException(
+46 -1
View File
@@ -69,7 +69,15 @@ import { mergeContactIntoList } from './utils/contactMerge';
import { getLocalLabel, getContrastTextColor } from './utils/localLabel';
import { cn } from '@/lib/utils';
import type { SearchNavigateTarget } from './components/SearchView';
import type { Contact, Conversation, HealthStatus, Message, MessagePath, RawPacket } from './types';
import type {
Channel,
Contact,
Conversation,
HealthStatus,
Message,
MessagePath,
RawPacket,
} from './types';
const MAX_RAW_PACKETS = 500;
@@ -225,6 +233,21 @@ export function App() {
return contact?.type === CONTACT_TYPE_REPEATER;
}, [activeConversation, contacts]);
const mergeChannelIntoList = useCallback(
(updated: Channel) => {
setChannels((prev) => {
const existingIndex = prev.findIndex((channel) => channel.key === updated.key);
if (existingIndex === -1) {
return [...prev, updated].sort((a, b) => a.name.localeCompare(b.name));
}
const next = [...prev];
next[existingIndex] = updated;
return next;
});
},
[setChannels]
);
// WebSocket handlers - memoized to prevent reconnection loops
const wsHandlers = useMemo(
() => ({
@@ -353,6 +376,9 @@ export function App() {
onContact: (contact: Contact) => {
setContacts((prev) => mergeContactIntoList(prev, contact));
},
onChannel: (channel: Channel) => {
mergeChannelIntoList(channel);
},
onContactDeleted: (publicKey: string) => {
setContacts((prev) => prev.filter((c) => c.public_key !== publicKey));
messageCache.remove(publicKey);
@@ -386,6 +412,7 @@ export function App() {
updateMessageAck,
checkMention,
fetchConfig,
mergeChannelIntoList,
prevHealthRef,
setHealth,
activeConversationRef,
@@ -467,6 +494,23 @@ export function App() {
[]
);
const handleSetChannelFloodScopeOverride = useCallback(
async (channelKey: string, floodScopeOverride: string) => {
try {
const updated = await api.setChannelFloodScopeOverride(channelKey, floodScopeOverride);
mergeChannelIntoList(updated);
toast.success(
updated.flood_scope_override ? 'Regional override saved' : 'Regional override cleared'
);
} catch (err) {
toast.error('Failed to update regional override', {
description: err instanceof Error ? err.message : 'Unknown error',
});
}
},
[mergeChannelIntoList]
);
// Handle sender click to add mention
const handleSenderClick = useCallback((sender: string) => {
messageInputRef.current?.appendText(`@[${sender}] `);
@@ -769,6 +813,7 @@ export function App() {
favorites={favorites}
onTrace={handleTrace}
onToggleFavorite={handleToggleFavorite}
onSetChannelFloodScopeOverride={handleSetChannelFloodScopeOverride}
onDeleteChannel={handleDeleteChannel}
onDeleteContact={handleDeleteContact}
onOpenContactInfo={handleOpenContactInfo}
+5
View File
@@ -157,6 +157,11 @@ export const api = {
fetchJson<{ status: string; key: string }>(`/channels/${key}/mark-read`, {
method: 'POST',
}),
setChannelFloodScopeOverride: (key: string, floodScopeOverride: string) =>
fetchJson<Channel>(`/channels/${key}/flood-scope-override`, {
method: 'POST',
body: JSON.stringify({ flood_scope_override: floodScopeOverride }),
}),
// Messages
getMessages: (
+34 -6
View File
@@ -14,6 +14,7 @@ interface ChatHeaderProps {
favorites: Favorite[];
onTrace: () => void;
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void;
onDeleteChannel: (key: string) => void;
onDeleteContact: (publicKey: string) => void;
onOpenContactInfo?: (publicKey: string) => void;
@@ -28,6 +29,7 @@ export function ChatHeader({
favorites,
onTrace,
onToggleFavorite,
onSetChannelFloodScopeOverride,
onDeleteChannel,
onDeleteContact,
onOpenContactInfo,
@@ -39,12 +41,26 @@ export function ChatHeader({
setShowKey(false);
}, [conversation.id]);
const isPrivateChannel =
conversation.type === 'channel' && !channels.find((c) => c.key === conversation.id)?.is_hashtag;
const activeChannel =
conversation.type === 'channel'
? channels.find((channel) => channel.key === conversation.id)
: undefined;
const isPrivateChannel = conversation.type === 'channel' && !activeChannel?.is_hashtag;
const titleClickable =
(conversation.type === 'contact' && onOpenContactInfo) ||
(conversation.type === 'channel' && onOpenChannelInfo);
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.',
activeChannel?.flood_scope_override ?? ''
);
if (nextValue === null) return;
onSetChannelFloodScopeOverride(conversation.id, nextValue);
};
return (
<header className="flex justify-between items-center px-4 py-2.5 border-b border-border gap-2">
<span className="flex flex-wrap items-baseline gap-x-2 min-w-0 flex-1">
@@ -87,7 +103,7 @@ export function ChatHeader({
>
{conversation.type === 'channel' &&
!conversation.name.startsWith('#') &&
channels.find((c) => c.key === conversation.id)?.is_hashtag
activeChannel?.is_hashtag
? '#'
: ''}
{conversation.name}
@@ -122,6 +138,11 @@ export function ChatHeader({
{conversation.type === 'channel' ? conversation.id.toLowerCase() : conversation.id}
</span>
)}
{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}
</span>
)}
{conversation.type === 'contact' &&
(() => {
const contact = contacts.find((c) => c.public_key === conversation.id);
@@ -136,7 +157,6 @@ export function ChatHeader({
})()}
</span>
<div className="flex items-center gap-0.5 flex-shrink-0">
{/* Direct trace button (contacts only) */}
{conversation.type === 'contact' && (
<button
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@@ -147,7 +167,16 @@ export function ChatHeader({
<span aria-hidden="true">&#x1F6CE;</span>
</button>
)}
{/* Favorite button */}
{conversation.type === 'channel' && onSetChannelFloodScopeOverride && (
<button
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={handleEditFloodScopeOverride}
title="Set regional override"
aria-label="Set regional override"
>
<span aria-hidden="true">&#127758;</span>
</button>
)}
{(conversation.type === 'channel' || conversation.type === 'contact') && (
<button
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@@ -172,7 +201,6 @@ export function ChatHeader({
)}
</button>
)}
{/* Delete button */}
{!(conversation.type === 'channel' && conversation.name === 'Public') && (
<button
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@@ -16,6 +16,7 @@ const baseProps = {
favorites: [] as Favorite[],
onTrace: noop,
onToggleFavorite: noop,
onSetChannelFloodScopeOverride: noop,
onDeleteChannel: noop,
onDeleteContact: noop,
};
@@ -105,4 +106,40 @@ describe('ChatHeader key visibility', () => {
expect(writeText).toHaveBeenCalledWith(key);
});
it('shows active regional override banner for channels', () => {
const key = 'AB'.repeat(16);
const channel = {
...makeChannel(key, '#flightless', true),
flood_scope_override: '#Esperance',
};
const conversation: Conversation = { type: 'channel', id: key, name: '#flightless' };
render(<ChatHeader {...baseProps} conversation={conversation} channels={[channel]} />);
expect(screen.getByText('Regional override active: #Esperance')).toBeInTheDocument();
});
it('prompts for regional override when globe button is clicked', () => {
const key = 'CD'.repeat(16);
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');
render(
<ChatHeader
{...baseProps}
conversation={conversation}
channels={[channel]}
onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride}
/>
);
fireEvent.click(screen.getByTitle('Set regional override'));
expect(promptSpy).toHaveBeenCalled();
expect(onSetChannelFloodScopeOverride).toHaveBeenCalledWith(key, '#Esperance');
promptSpy.mockRestore();
});
});
+1
View File
@@ -130,6 +130,7 @@ export interface Channel {
name: string;
is_hashtag: boolean;
on_radio: boolean;
flood_scope_override?: string | null;
last_read_at: number | null;
}
+5 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useCallback } from 'react';
import type { HealthStatus, Contact, Message, MessagePath, RawPacket } from './types';
import type { Channel, HealthStatus, Contact, Message, MessagePath, RawPacket } from './types';
interface WebSocketMessage {
type: string;
@@ -21,6 +21,7 @@ interface UseWebSocketOptions {
onMessage?: (message: Message) => void;
onContact?: (contact: Contact) => void;
onContactDeleted?: (publicKey: string) => void;
onChannel?: (channel: Channel) => void;
onChannelDeleted?: (key: string) => void;
onRawPacket?: (packet: RawPacket) => void;
onMessageAcked?: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
@@ -105,6 +106,9 @@ export function useWebSocket(options: UseWebSocketOptions) {
case 'contact':
handlers.onContact?.(msg.data as Contact);
break;
case 'channel':
handlers.onChannel?.(msg.data as Channel);
break;
case 'contact_deleted':
handlers.onContactDeleted?.((msg.data as { public_key: string }).public_key);
break;
+1
View File
@@ -64,6 +64,7 @@ export interface Channel {
name: string;
is_hashtag: boolean;
on_radio: boolean;
flood_scope_override?: string | null;
}
export function getChannels(): Promise<Channel[]> {
+74
View File
@@ -228,6 +228,80 @@ class TestSyncChannelsFromRadio:
channel = await ChannelRepository.get_by_key("AABBCCDDAABBCCDDAABBCCDDAABBCCDD")
assert channel is not None
@pytest.mark.asyncio
async def test_sync_preserves_existing_flood_scope_override(self, test_db, client):
secret = bytes.fromhex("cafebabecafebabecafebabecafebabe")
key = secret.hex().upper()
await ChannelRepository.upsert(key=key, name="#flightless", is_hashtag=True, on_radio=False)
await ChannelRepository.update_flood_scope_override(key, "#Esperance")
mock_mc = MagicMock()
async def mock_get_channel(idx):
if idx == 0:
return _make_channel_info("#flightless", secret)
return _make_empty_channel()
mock_mc.commands.get_channel = AsyncMock(side_effect=mock_get_channel)
radio_manager._meshcore = mock_mc
with (
patch("app.dependencies.radio_manager") as mock_dep_rm,
patch("app.routers.channels.radio_manager") as mock_ch_rm,
):
mock_dep_rm.is_connected = True
mock_dep_rm.meshcore = mock_mc
mock_ch_rm.radio_operation = lambda desc: _noop_radio_operation(mock_mc)
response = await client.post("/api/channels/sync?max_channels=3")
assert response.status_code == 200
channel = await ChannelRepository.get_by_key(key)
assert channel is not None
assert channel.flood_scope_override == "#Esperance"
class TestChannelFloodScopeOverride:
@pytest.mark.asyncio
async def test_sets_channel_flood_scope_override(self, test_db, client):
key = "AA" * 16
await ChannelRepository.upsert(key=key, name="#flightless", is_hashtag=True)
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"},
)
assert response.status_code == 200
data = response.json()
assert data["flood_scope_override"] == "#Esperance"
channel = await ChannelRepository.get_by_key(key)
assert channel is not None
assert channel.flood_scope_override == "#Esperance"
mock_broadcast.assert_called_once()
assert mock_broadcast.call_args.args[0] == "channel"
@pytest.mark.asyncio
async def test_blank_override_clears_channel_flood_scope_override(self, test_db, client):
key = "BB" * 16
await ChannelRepository.upsert(key=key, name="#flightless", is_hashtag=True)
await ChannelRepository.update_flood_scope_override(key, "#Esperance")
response = await client.post(
f"/api/channels/{key}/flood-scope-override",
json={"flood_scope_override": " "},
)
assert response.status_code == 200
data = response.json()
assert data["flood_scope_override"] is None
channel = await ChannelRepository.get_by_key(key)
assert channel is not None
assert channel.flood_scope_override is None
class TestChannelDetail:
"""Test GET /api/channels/{key}/detail."""
+52 -8
View File
@@ -1116,8 +1116,8 @@ class TestMigration039:
applied = await run_migrations(conn)
assert applied == 3
assert await get_version(conn) == 41
assert applied == 4
assert await get_version(conn) == 42
cursor = await conn.execute(
"""
@@ -1186,8 +1186,8 @@ class TestMigration039:
applied = await run_migrations(conn)
assert applied == 3
assert await get_version(conn) == 41
assert applied == 4
assert await get_version(conn) == 42
cursor = await conn.execute(
"""
@@ -1240,8 +1240,8 @@ class TestMigration040:
applied = await run_migrations(conn)
assert applied == 2
assert await get_version(conn) == 41
assert applied == 3
assert await get_version(conn) == 42
await conn.execute(
"""
@@ -1302,8 +1302,8 @@ class TestMigration041:
applied = await run_migrations(conn)
assert applied == 1
assert await get_version(conn) == 41
assert applied == 2
assert await get_version(conn) == 42
await conn.execute(
"""
@@ -1334,6 +1334,50 @@ class TestMigration041:
await conn.close()
class TestMigration042:
"""Test migration 042: add channels.flood_scope_override."""
@pytest.mark.asyncio
async def test_adds_channel_flood_scope_override_column(self):
conn = await aiosqlite.connect(":memory:")
conn.row_factory = aiosqlite.Row
try:
await set_version(conn, 41)
await conn.execute("""
CREATE TABLE channels (
key TEXT PRIMARY KEY,
name TEXT NOT NULL,
is_hashtag INTEGER DEFAULT 0,
on_radio INTEGER DEFAULT 0
)
""")
await conn.commit()
applied = await run_migrations(conn)
assert applied == 1
assert await get_version(conn) == 42
await conn.execute(
"""
INSERT INTO channels (
key, name, is_hashtag, on_radio, flood_scope_override
) VALUES (?, ?, ?, ?, ?)
""",
("AA" * 16, "#flightless", 1, 0, "#Esperance"),
)
await conn.commit()
cursor = await conn.execute(
"SELECT flood_scope_override FROM channels WHERE key = ?",
("AA" * 16,),
)
row = await cursor.fetchone()
assert row["flood_scope_override"] == "#Esperance"
finally:
await conn.close()
class TestMigrationPacketHelpers:
"""Test migration-local packet helpers against canonical path validation."""
+139 -1
View File
@@ -2,7 +2,7 @@
import asyncio
import time
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, call, patch
import pytest
from fastapi import HTTPException
@@ -14,6 +14,7 @@ from app.models import (
)
from app.radio import radio_manager
from app.repository import (
AppSettingsRepository,
ChannelRepository,
ContactRepository,
MessageRepository,
@@ -48,6 +49,7 @@ def _make_mc(name="TestNode"):
mc = MagicMock()
mc.self_info = {"name": name}
mc.commands = MagicMock()
mc.commands.set_flood_scope = AsyncMock(return_value=_make_radio_result())
mc.commands.send_msg = AsyncMock(return_value=_make_radio_result())
mc.commands.send_chan_msg = AsyncMock(return_value=_make_radio_result())
mc.commands.add_contact = AsyncMock(return_value=_make_radio_result())
@@ -272,6 +274,75 @@ class TestOutgoingChannelBroadcast:
assert db_msg is not None
assert db_msg.sender_key == our_pubkey
@pytest.mark.asyncio
async def test_send_channel_msg_uses_channel_flood_scope_override(self, test_db):
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")
with (
patch("app.routers.messages.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
patch("app.routers.messages.broadcast_event"),
):
request = SendChannelMessageRequest(channel_key=chan_key, text="hello")
await send_channel_message(request)
assert mc.commands.set_flood_scope.await_args_list == [
call("#Esperance"),
call("#Baseline"),
]
@pytest.mark.asyncio
async def test_send_channel_msg_skips_temporary_scope_when_override_matches_global(
self, test_db
):
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")
with (
patch("app.routers.messages.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
patch("app.routers.messages.broadcast_event"),
):
request = SendChannelMessageRequest(channel_key=chan_key, text="hello")
await send_channel_message(request)
mc.commands.set_flood_scope.assert_not_awaited()
@pytest.mark.asyncio
async def test_send_channel_msg_aborts_when_override_apply_fails(self, test_db):
mc = _make_mc(name="MyNode")
chan_key = "a1" * 16
await ChannelRepository.upsert(key=chan_key, name="#flightless")
await ChannelRepository.update_flood_scope_override(chan_key, "#Esperance")
await AppSettingsRepository.update(flood_scope="#Baseline")
mc.commands.set_flood_scope = AsyncMock(
return_value=MagicMock(type=EventType.ERROR, payload="unsupported")
)
with (
patch("app.routers.messages.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
patch("app.routers.messages.broadcast_event"),
pytest.raises(HTTPException) as exc_info,
):
request = SendChannelMessageRequest(channel_key=chan_key, text="hello")
await send_channel_message(request)
assert exc_info.value.status_code == 500
assert "regional override" in exc_info.value.detail.lower()
mc.commands.set_channel.assert_not_awaited()
mc.commands.send_chan_msg.assert_not_awaited()
class TestResendChannelMessage:
"""Test the user-triggered resend endpoint."""
@@ -336,6 +407,73 @@ class TestResendChannelMessage:
assert exc_info.value.status_code == 400
assert "expired" in exc_info.value.detail.lower()
@pytest.mark.asyncio
async def test_resend_uses_current_channel_flood_scope_override(self, test_db):
mc = _make_mc(name="MyNode")
chan_key = "be" * 16
await ChannelRepository.upsert(key=chan_key, name="#flightless")
await ChannelRepository.update_flood_scope_override(chan_key, "#CurrentRegion")
await AppSettingsRepository.update(flood_scope="#Baseline")
now = int(time.time()) - 10
msg_id = await MessageRepository.create(
msg_type="CHAN",
text="MyNode: hello",
conversation_key=chan_key.upper(),
sender_timestamp=now,
received_at=now,
outgoing=True,
)
assert msg_id is not None
with (
patch("app.routers.messages.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
):
await resend_channel_message(msg_id, new_timestamp=False)
assert mc.commands.set_flood_scope.await_args_list == [
call("#CurrentRegion"),
call("#Baseline"),
]
@pytest.mark.asyncio
async def test_resend_restore_failure_broadcasts_warning(self, test_db):
mc = _make_mc(name="MyNode")
chan_key = "b1" * 16
await ChannelRepository.upsert(key=chan_key, name="#flightless")
await ChannelRepository.update_flood_scope_override(chan_key, "#CurrentRegion")
await AppSettingsRepository.update(flood_scope="#Baseline")
now = int(time.time()) - 10
msg_id = await MessageRepository.create(
msg_type="CHAN",
text="MyNode: hello",
conversation_key=chan_key.upper(),
sender_timestamp=now,
received_at=now,
outgoing=True,
)
assert msg_id is not None
mc.commands.set_flood_scope = AsyncMock(
side_effect=[
_make_radio_result(),
MagicMock(type=EventType.ERROR, payload="restore failed"),
]
)
with (
patch("app.routers.messages.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch("app.routers.messages.broadcast_error") as mock_broadcast_error,
):
result = await resend_channel_message(msg_id, new_timestamp=False)
assert result["status"] == "ok"
mock_broadcast_error.assert_called_once()
assert "restore failed" in mock_broadcast_error.call_args.args[0].lower()
@pytest.mark.asyncio
async def test_resend_new_timestamp_collision_returns_original_id(self, test_db):
"""When new-timestamp resend collides (same second), return original ID gracefully."""