diff --git a/AGENTS.md b/AGENTS.md index fb4343a..02d75f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/app/AGENTS.md b/app/AGENTS.md index 321290a..7e375ad 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -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) diff --git a/app/database.py b/app/database.py index 3855b4e..f0cb53d 100644 --- a/app/database.py +++ b/app/database.py @@ -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 ( diff --git a/app/migrations.py b/app/migrations.py index ae23cd6..e3787a4 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -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() diff --git a/app/models.py b/app/models.py index ba8bad1..8c56f45 100644 --- a/app/models.py +++ b/app/models.py @@ -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 diff --git a/app/repository/channels.py b/app/repository/channels.py index c83bac5..f35a6dd 100644 --- a/app/repository/channels.py +++ b/app/repository/channels.py @@ -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.""" diff --git a/app/routers/channels.py b/app/routers/channels.py index 47d8d25..8f3831a 100644 --- a/app/routers/channels.py +++ b/app/routers/channels.py @@ -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"} diff --git a/app/routers/messages.py b/app/routers/messages.py index 9aabb6f..26f8ee2 100644 --- a/app/routers/messages.py +++ b/app/routers/messages.py @@ -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( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e9931ca..d50cdae 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index af195bf..3b32be1 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -157,6 +157,11 @@ export const api = { fetchJson<{ status: string; key: string }>(`/channels/${key}/mark-read`, { method: 'POST', }), + setChannelFloodScopeOverride: (key: string, floodScopeOverride: string) => + fetchJson(`/channels/${key}/flood-scope-override`, { + method: 'POST', + body: JSON.stringify({ flood_scope_override: floodScopeOverride }), + }), // Messages getMessages: ( diff --git a/frontend/src/components/ChatHeader.tsx b/frontend/src/components/ChatHeader.tsx index 414264b..6eea00b 100644 --- a/frontend/src/components/ChatHeader.tsx +++ b/frontend/src/components/ChatHeader.tsx @@ -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 (
@@ -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} )} + {conversation.type === 'channel' && activeChannel?.flood_scope_override && ( + + Regional override active: {activeChannel.flood_scope_override} + + )} {conversation.type === 'contact' && (() => { const contact = contacts.find((c) => c.public_key === conversation.id); @@ -136,7 +157,6 @@ export function ChatHeader({ })()}
- {/* Direct trace button (contacts only) */} {conversation.type === 'contact' && ( )} - {/* Favorite button */} + {conversation.type === 'channel' && onSetChannelFloodScopeOverride && ( + + )} {(conversation.type === 'channel' || conversation.type === 'contact') && (