mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-06 08:43:36 +02:00
Add regional channel routing (closes #42)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -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">🛎</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">🌎</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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user