mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Add resend button for 30s
This commit is contained in:
@@ -268,6 +268,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| GET | `/api/messages` | List with filters |
|
||||
| POST | `/api/messages/direct` | Send direct message |
|
||||
| POST | `/api/messages/channel` | Send channel message |
|
||||
| POST | `/api/messages/channel/{message_id}/resend` | Resend an outgoing channel message (within 30 seconds) |
|
||||
| GET | `/api/packets/undecrypted/count` | Count of undecrypted packets |
|
||||
| POST | `/api/packets/decrypt/historical` | Decrypt stored packets |
|
||||
| POST | `/api/packets/maintenance` | Delete old packets and vacuum |
|
||||
@@ -359,8 +360,8 @@ mc.subscribe(EventType.ACK, handler)
|
||||
| `MESHCORE_BLE_PIN` | *(required with BLE)* | BLE PIN code |
|
||||
| `MESHCORE_DATABASE_PATH` | `data/meshcore.db` | SQLite database location |
|
||||
|
||||
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `experimental_channel_double_send`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, and `bots`. They are configured via `GET/PATCH /api/settings` (and related settings endpoints).
|
||||
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, and `bots`. They are configured via `GET/PATCH /api/settings` (and related settings endpoints).
|
||||
|
||||
`experimental_channel_double_send` is an opt-in experimental setting: when enabled, channel sends perform a second byte-perfect resend after a 3-second delay.
|
||||
Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send.
|
||||
|
||||
**Transport mutual exclusivity:** Only one of `MESHCORE_SERIAL_PORT`, `MESHCORE_TCP_HOST`, or `MESHCORE_BLE_ADDRESS` may be set. If none are set, serial auto-detection is used.
|
||||
|
||||
@@ -121,6 +121,7 @@ app/
|
||||
- `GET /messages`
|
||||
- `POST /messages/direct`
|
||||
- `POST /messages/channel`
|
||||
- `POST /messages/channel/{message_id}/resend`
|
||||
|
||||
### Packets
|
||||
- `GET /packets/undecrypted/count`
|
||||
@@ -164,7 +165,6 @@ Main tables:
|
||||
|
||||
`app_settings` fields in active model:
|
||||
- `max_radio_contacts`
|
||||
- `experimental_channel_double_send`
|
||||
- `favorites`
|
||||
- `auto_decrypt_dm_on_advert`
|
||||
- `sidebar_sort_order`
|
||||
|
||||
@@ -149,6 +149,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
|
||||
await set_version(conn, 16)
|
||||
applied += 1
|
||||
|
||||
# Migration 17: Drop experimental_channel_double_send column (replaced by user-triggered resend)
|
||||
if version < 17:
|
||||
logger.info("Applying migration 17: drop experimental_channel_double_send column")
|
||||
await _migrate_017_drop_experimental_channel_double_send(conn)
|
||||
await set_version(conn, 17)
|
||||
applied += 1
|
||||
|
||||
if applied > 0:
|
||||
logger.info(
|
||||
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
||||
@@ -1021,3 +1028,29 @@ async def _migrate_016_add_experimental_channel_double_send(conn: aiosqlite.Conn
|
||||
raise
|
||||
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_017_drop_experimental_channel_double_send(conn: aiosqlite.Connection) -> None:
|
||||
"""
|
||||
Drop experimental_channel_double_send column from app_settings.
|
||||
|
||||
This feature is replaced by a user-triggered resend button.
|
||||
SQLite 3.35.0+ supports ALTER TABLE DROP COLUMN. For older versions,
|
||||
we silently skip (the column will remain but is unused).
|
||||
"""
|
||||
try:
|
||||
await conn.execute("ALTER TABLE app_settings DROP COLUMN experimental_channel_double_send")
|
||||
logger.debug("Dropped experimental_channel_double_send from app_settings")
|
||||
except aiosqlite.OperationalError as e:
|
||||
error_msg = str(e).lower()
|
||||
if "no such column" in error_msg:
|
||||
logger.debug("app_settings.experimental_channel_double_send already dropped, skipping")
|
||||
elif "syntax error" in error_msg or "drop column" in error_msg:
|
||||
logger.debug(
|
||||
"SQLite doesn't support DROP COLUMN, "
|
||||
"experimental_channel_double_send column will remain"
|
||||
)
|
||||
else:
|
||||
raise
|
||||
|
||||
await conn.commit()
|
||||
|
||||
@@ -265,13 +265,6 @@ class AppSettings(BaseModel):
|
||||
"(favorite contacts first, then recent non-repeaters)"
|
||||
),
|
||||
)
|
||||
experimental_channel_double_send: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Experimental: when enabled, channel messages are sent twice with a 3-second delay, "
|
||||
"reusing the same timestamp bytes"
|
||||
),
|
||||
)
|
||||
favorites: list[Favorite] = Field(
|
||||
default_factory=list, description="List of favorited conversations"
|
||||
)
|
||||
|
||||
@@ -522,6 +522,36 @@ class MessageRepository:
|
||||
return 0, None
|
||||
return row["acked"], MessageRepository._parse_paths(row["paths"])
|
||||
|
||||
@staticmethod
|
||||
async def get_by_id(message_id: int) -> "Message | None":
|
||||
"""Look up a message by its ID."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT id, type, conversation_key, text, sender_timestamp, received_at,
|
||||
paths, txt_type, signature, outgoing, acked
|
||||
FROM messages
|
||||
WHERE id = ?
|
||||
""",
|
||||
(message_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
|
||||
return Message(
|
||||
id=row["id"],
|
||||
type=row["type"],
|
||||
conversation_key=row["conversation_key"],
|
||||
text=row["text"],
|
||||
sender_timestamp=row["sender_timestamp"],
|
||||
received_at=row["received_at"],
|
||||
paths=MessageRepository._parse_paths(row["paths"]),
|
||||
txt_type=row["txt_type"],
|
||||
signature=row["signature"],
|
||||
outgoing=bool(row["outgoing"]),
|
||||
acked=row["acked"],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get_by_content(
|
||||
msg_type: str,
|
||||
@@ -800,8 +830,7 @@ class AppSettingsRepository:
|
||||
"""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT max_radio_contacts, experimental_channel_double_send,
|
||||
favorites, auto_decrypt_dm_on_advert,
|
||||
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
|
||||
sidebar_sort_order, last_message_times, preferences_migrated,
|
||||
advert_interval, last_advert_time, bots
|
||||
FROM app_settings WHERE id = 1
|
||||
@@ -860,7 +889,6 @@ class AppSettingsRepository:
|
||||
|
||||
return AppSettings(
|
||||
max_radio_contacts=row["max_radio_contacts"],
|
||||
experimental_channel_double_send=bool(row["experimental_channel_double_send"]),
|
||||
favorites=favorites,
|
||||
auto_decrypt_dm_on_advert=bool(row["auto_decrypt_dm_on_advert"]),
|
||||
sidebar_sort_order=sort_order,
|
||||
@@ -874,7 +902,6 @@ class AppSettingsRepository:
|
||||
@staticmethod
|
||||
async def update(
|
||||
max_radio_contacts: int | None = None,
|
||||
experimental_channel_double_send: bool | None = None,
|
||||
favorites: list[Favorite] | None = None,
|
||||
auto_decrypt_dm_on_advert: bool | None = None,
|
||||
sidebar_sort_order: str | None = None,
|
||||
@@ -892,10 +919,6 @@ class AppSettingsRepository:
|
||||
updates.append("max_radio_contacts = ?")
|
||||
params.append(max_radio_contacts)
|
||||
|
||||
if experimental_channel_double_send is not None:
|
||||
updates.append("experimental_channel_double_send = ?")
|
||||
params.append(1 if experimental_channel_double_send else 0)
|
||||
|
||||
if favorites is not None:
|
||||
updates.append("favorites = ?")
|
||||
favorites_json = json.dumps([f.model_dump() for f in favorites])
|
||||
|
||||
@@ -158,7 +158,6 @@ async def send_direct_message(request: SendDirectMessageRequest) -> Message:
|
||||
|
||||
# Temporary radio slot used for sending channel messages
|
||||
TEMP_RADIO_SLOT = 0
|
||||
EXPERIMENTAL_CHANNEL_DOUBLE_SEND_DELAY_SECONDS = 3
|
||||
|
||||
|
||||
@router.post("/channel", response_model=Message)
|
||||
@@ -168,14 +167,13 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
|
||||
|
||||
# Get channel info from our database
|
||||
from app.decoder import calculate_channel_hash
|
||||
from app.repository import AppSettingsRepository, ChannelRepository
|
||||
from app.repository import ChannelRepository
|
||||
|
||||
db_channel = await ChannelRepository.get_by_key(request.channel_key)
|
||||
if not db_channel:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Channel {request.channel_key} not found in database"
|
||||
)
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
|
||||
# Convert channel key hex to bytes
|
||||
try:
|
||||
@@ -234,8 +232,8 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}")
|
||||
|
||||
# Store outgoing immediately after the first successful send to avoid a race where
|
||||
# our own echo lands before persistence (especially with delayed duplicate sends).
|
||||
# Store outgoing immediately after send to avoid a race where
|
||||
# our own echo lands before persistence.
|
||||
message_id = await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text=text_with_sender,
|
||||
@@ -250,9 +248,9 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
|
||||
detail="Failed to store outgoing message - unexpected duplicate",
|
||||
)
|
||||
|
||||
# Broadcast immediately so all connected clients see the message before any
|
||||
# double-send delay. This also ensures the message is in the frontend's state
|
||||
# when echo-driven `message_acked` events arrive during the sleep below.
|
||||
# Broadcast immediately so all connected clients see the message promptly.
|
||||
# This ensures the message exists in frontend state when echo-driven
|
||||
# `message_acked` events arrive.
|
||||
broadcast_event(
|
||||
"message",
|
||||
Message(
|
||||
@@ -267,25 +265,6 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
# Experimental: byte-perfect resend after a delay to improve delivery reliability.
|
||||
# This intentionally holds the radio operation lock for the full delay — it is an
|
||||
# opt-in experimental feature where blocking other radio operations is acceptable.
|
||||
if app_settings.experimental_channel_double_send:
|
||||
logger.debug(
|
||||
"Experimental channel double-send enabled; waiting %ds before byte-perfect duplicate",
|
||||
EXPERIMENTAL_CHANNEL_DOUBLE_SEND_DELAY_SECONDS,
|
||||
)
|
||||
await asyncio.sleep(EXPERIMENTAL_CHANNEL_DOUBLE_SEND_DELAY_SECONDS)
|
||||
duplicate_result = await mc.commands.send_chan_msg(
|
||||
chan=TEMP_RADIO_SLOT,
|
||||
msg=request.text,
|
||||
timestamp=timestamp_bytes,
|
||||
)
|
||||
if duplicate_result.type == EventType.ERROR:
|
||||
logger.warning(
|
||||
"Experimental duplicate channel send failed: %s", duplicate_result.payload
|
||||
)
|
||||
|
||||
if message_id is None or now is None:
|
||||
raise HTTPException(status_code=500, detail="Failed to store outgoing message")
|
||||
|
||||
@@ -321,3 +300,79 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
|
||||
)
|
||||
|
||||
return message
|
||||
|
||||
|
||||
RESEND_WINDOW_SECONDS = 30
|
||||
|
||||
|
||||
@router.post("/channel/{message_id}/resend")
|
||||
async def resend_channel_message(message_id: int) -> dict:
|
||||
"""Resend a channel message within 30 seconds of original send.
|
||||
|
||||
Performs a byte-perfect resend using the same timestamp bytes as the original.
|
||||
"""
|
||||
mc = require_connected()
|
||||
|
||||
from app.repository import ChannelRepository
|
||||
|
||||
msg = await MessageRepository.get_by_id(message_id)
|
||||
if not msg:
|
||||
raise HTTPException(status_code=404, detail="Message not found")
|
||||
|
||||
if not msg.outgoing:
|
||||
raise HTTPException(status_code=400, detail="Can only resend outgoing messages")
|
||||
|
||||
if msg.type != "CHAN":
|
||||
raise HTTPException(status_code=400, detail="Can only resend channel messages")
|
||||
|
||||
if msg.sender_timestamp is None:
|
||||
raise HTTPException(status_code=400, detail="Message has no timestamp")
|
||||
|
||||
elapsed = int(time.time()) - msg.sender_timestamp
|
||||
if elapsed > RESEND_WINDOW_SECONDS:
|
||||
raise HTTPException(status_code=400, detail="Resend window has expired (30 seconds)")
|
||||
|
||||
db_channel = await ChannelRepository.get_by_key(msg.conversation_key)
|
||||
if not db_channel:
|
||||
raise HTTPException(status_code=404, detail=f"Channel {msg.conversation_key} not found")
|
||||
|
||||
# Reconstruct timestamp bytes
|
||||
timestamp_bytes = msg.sender_timestamp.to_bytes(4, "little")
|
||||
|
||||
# Strip sender prefix: DB stores "RadioName: message" but radio needs "message"
|
||||
radio_name = mc.self_info.get("name", "") if mc.self_info else ""
|
||||
text_to_send = msg.text
|
||||
if radio_name and text_to_send.startswith(f"{radio_name}: "):
|
||||
text_to_send = text_to_send[len(f"{radio_name}: ") :]
|
||||
|
||||
try:
|
||||
key_bytes = bytes.fromhex(msg.conversation_key)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Invalid channel key format: {msg.conversation_key}"
|
||||
) from None
|
||||
|
||||
async with radio_manager.radio_operation("resend_channel_message"):
|
||||
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,
|
||||
)
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to resend message: {result.payload}"
|
||||
)
|
||||
|
||||
logger.info("Resent channel message %d to %s", message_id, db_channel.name)
|
||||
return {"status": "ok", "message_id": message_id}
|
||||
|
||||
@@ -41,13 +41,6 @@ class AppSettingsUpdate(BaseModel):
|
||||
"Maximum contacts to keep on radio (favorites first, then recent non-repeaters)"
|
||||
),
|
||||
)
|
||||
experimental_channel_double_send: bool | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Experimental: always send channel messages twice with a 3-second delay using "
|
||||
"identical timestamp bytes"
|
||||
),
|
||||
)
|
||||
auto_decrypt_dm_on_advert: bool | None = Field(
|
||||
default=None,
|
||||
description="Whether to attempt historical DM decryption on new contact advertisement",
|
||||
@@ -109,13 +102,6 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
|
||||
logger.info("Updating max_radio_contacts to %d", update.max_radio_contacts)
|
||||
kwargs["max_radio_contacts"] = update.max_radio_contacts
|
||||
|
||||
if update.experimental_channel_double_send is not None:
|
||||
logger.info(
|
||||
"Updating experimental_channel_double_send to %s",
|
||||
update.experimental_channel_double_send,
|
||||
)
|
||||
kwargs["experimental_channel_double_send"] = update.experimental_channel_double_send
|
||||
|
||||
if update.auto_decrypt_dm_on_advert is not None:
|
||||
logger.info("Updating auto_decrypt_dm_on_advert to %s", update.auto_decrypt_dm_on_advert)
|
||||
kwargs["auto_decrypt_dm_on_advert"] = update.auto_decrypt_dm_on_advert
|
||||
|
||||
@@ -96,6 +96,7 @@ Specialized logic is delegated to hooks:
|
||||
- Outgoing sends are optimistic in UI and persisted server-side.
|
||||
- Backend also emits WS `message` for outgoing sends so other clients stay in sync.
|
||||
- ACK/repeat updates arrive as `message_acked` events.
|
||||
- Outgoing channel messages show a 30-second resend control; resend calls `POST /api/messages/channel/{message_id}/resend`.
|
||||
|
||||
## WebSocket (`useWebSocket.ts`)
|
||||
|
||||
@@ -149,7 +150,6 @@ LocalStorage migration helpers for favorites; canonical favorites are server-sid
|
||||
|
||||
`AppSettings` currently includes:
|
||||
- `max_radio_contacts`
|
||||
- `experimental_channel_double_send`
|
||||
- `favorites`
|
||||
- `auto_decrypt_dm_on_advert`
|
||||
- `sidebar_sort_order`
|
||||
|
||||
@@ -666,6 +666,18 @@ export function App() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle resend channel message
|
||||
const handleResendChannelMessage = useCallback(async (messageId: number) => {
|
||||
try {
|
||||
await api.resendChannelMessage(messageId);
|
||||
toast.success('Message resent');
|
||||
} catch (err) {
|
||||
toast.error('Failed to resend', {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle sender click to add mention
|
||||
const handleSenderClick = useCallback((sender: string) => {
|
||||
messageInputRef.current?.appendText(`@[${sender}] `);
|
||||
@@ -1182,6 +1194,9 @@ export function App() {
|
||||
activeConversation.type === 'channel' ? handleSenderClick : undefined
|
||||
}
|
||||
onLoadOlder={fetchOlderMessages}
|
||||
onResendChannelMessage={
|
||||
activeConversation.type === 'channel' ? handleResendChannelMessage : undefined
|
||||
}
|
||||
radioName={config?.name}
|
||||
config={config}
|
||||
/>
|
||||
|
||||
@@ -166,6 +166,10 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ channel_key: channelKey, text }),
|
||||
}),
|
||||
resendChannelMessage: (messageId: number) =>
|
||||
fetchJson<{ status: string; message_id: number }>(`/messages/channel/${messageId}/resend`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
// Packets
|
||||
getUndecryptedPacketCount: () => fetchJson<{ count: number }>('/packets/undecrypted/count'),
|
||||
|
||||
@@ -23,6 +23,7 @@ interface MessageListProps {
|
||||
hasOlderMessages?: boolean;
|
||||
onSenderClick?: (sender: string) => void;
|
||||
onLoadOlder?: () => void;
|
||||
onResendChannelMessage?: (messageId: number) => void;
|
||||
radioName?: string;
|
||||
config?: RadioConfig | null;
|
||||
}
|
||||
@@ -134,6 +135,8 @@ function HopCountBadge({ paths, onClick, variant }: HopCountBadgeProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const RESEND_WINDOW_SECONDS = 30;
|
||||
|
||||
export function MessageList({
|
||||
messages,
|
||||
contacts,
|
||||
@@ -142,6 +145,7 @@ export function MessageList({
|
||||
hasOlderMessages = false,
|
||||
onSenderClick,
|
||||
onLoadOlder,
|
||||
onResendChannelMessage,
|
||||
radioName,
|
||||
config,
|
||||
}: MessageListProps) {
|
||||
@@ -153,6 +157,8 @@ export function MessageList({
|
||||
paths: MessagePath[];
|
||||
senderInfo: SenderInfo;
|
||||
} | null>(null);
|
||||
const [resendableIds, setResendableIds] = useState<Set<number>>(new Set());
|
||||
const resendTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
|
||||
|
||||
// Capture scroll state in the scroll handler BEFORE any state updates
|
||||
const scrollStateRef = useRef({
|
||||
@@ -216,6 +222,43 @@ export function MessageList({
|
||||
}
|
||||
}, [messages.length]);
|
||||
|
||||
// Track resendable outgoing CHAN messages (within 30s window)
|
||||
useEffect(() => {
|
||||
if (!onResendChannelMessage) return;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const newResendable = new Set<number>();
|
||||
const timers = resendTimersRef.current;
|
||||
|
||||
for (const msg of messages) {
|
||||
if (!msg.outgoing || msg.type !== 'CHAN' || msg.sender_timestamp === null) continue;
|
||||
const remaining = RESEND_WINDOW_SECONDS - (now - msg.sender_timestamp);
|
||||
if (remaining <= 0) continue;
|
||||
|
||||
newResendable.add(msg.id);
|
||||
|
||||
// Schedule removal if not already tracked
|
||||
if (!timers.has(msg.id)) {
|
||||
const timer = setTimeout(() => {
|
||||
setResendableIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(msg.id);
|
||||
return next;
|
||||
});
|
||||
timers.delete(msg.id);
|
||||
}, remaining * 1000);
|
||||
timers.set(msg.id, timer);
|
||||
}
|
||||
}
|
||||
|
||||
setResendableIds(newResendable);
|
||||
|
||||
return () => {
|
||||
for (const timer of timers.values()) clearTimeout(timer);
|
||||
timers.clear();
|
||||
};
|
||||
}, [messages, onResendChannelMessage]);
|
||||
|
||||
// Handle scroll - capture state and detect when user is near top/bottom
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!listRef.current) return;
|
||||
@@ -463,11 +506,23 @@ export function MessageList({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{msg.outgoing && onResendChannelMessage && resendableIds.has(msg.id) && (
|
||||
<button
|
||||
className="text-muted-foreground hover:text-primary ml-1 text-xs cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onResendChannelMessage(msg.id);
|
||||
}}
|
||||
title="Resend message"
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
)}
|
||||
{msg.outgoing &&
|
||||
(msg.acked > 0 ? (
|
||||
msg.paths && msg.paths.length > 0 ? (
|
||||
<span
|
||||
className="cursor-pointer hover:text-primary"
|
||||
className="text-muted-foreground cursor-pointer hover:text-primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedPath({
|
||||
@@ -483,10 +538,13 @@ export function MessageList({
|
||||
title="View echo paths"
|
||||
>{` ✓${msg.acked > 1 ? msg.acked : ''}`}</span>
|
||||
) : (
|
||||
` ✓${msg.acked > 1 ? msg.acked : ''}`
|
||||
<span className="text-muted-foreground">{` ✓${msg.acked > 1 ? msg.acked : ''}`}</span>
|
||||
)
|
||||
) : (
|
||||
<span title="No repeats heard yet"> ?</span>
|
||||
<span className="text-muted-foreground" title="No repeats heard yet">
|
||||
{' '}
|
||||
?
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -123,7 +123,6 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
const [cr, setCr] = useState('');
|
||||
const [privateKey, setPrivateKey] = useState('');
|
||||
const [maxRadioContacts, setMaxRadioContacts] = useState('');
|
||||
const [experimentalChannelDoubleSend, setExperimentalChannelDoubleSend] = useState(false);
|
||||
|
||||
// Loading states
|
||||
const [busySection, setBusySection] = useState<SettingsSection | null>(null);
|
||||
@@ -202,7 +201,6 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
useEffect(() => {
|
||||
if (appSettings) {
|
||||
setMaxRadioContacts(String(appSettings.max_radio_contacts));
|
||||
setExperimentalChannelDoubleSend(appSettings.experimental_channel_double_send);
|
||||
setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert);
|
||||
setAdvertInterval(String(appSettings.advert_interval));
|
||||
setBots(appSettings.bots || []);
|
||||
@@ -368,9 +366,6 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
if (!isNaN(newMaxRadioContacts) && newMaxRadioContacts !== appSettings?.max_radio_contacts) {
|
||||
update.max_radio_contacts = newMaxRadioContacts;
|
||||
}
|
||||
if (experimentalChannelDoubleSend !== appSettings?.experimental_channel_double_send) {
|
||||
update.experimental_channel_double_send = experimentalChannelDoubleSend;
|
||||
}
|
||||
if (Object.keys(update).length > 0) {
|
||||
await onSaveAppSettings(update);
|
||||
}
|
||||
@@ -900,27 +895,6 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-md space-y-3">
|
||||
<p className="text-sm text-yellow-500">
|
||||
<strong>Experimental:</strong> Adds a duplicate channel send after a 3-second
|
||||
delay, using the exact same timestamp bytes.
|
||||
</p>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={experimentalChannelDoubleSend}
|
||||
onChange={(e) => setExperimentalChannelDoubleSend(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Always send channel messages twice</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This increases channel airtime and adds a 3-second second-attempt delay. Most
|
||||
clients deduplicate repeats by payload and timestamp, but behavior can vary by
|
||||
firmware/client.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleSaveConnectivity}
|
||||
disabled={isSectionBusy('connectivity')}
|
||||
|
||||
@@ -182,7 +182,6 @@ const baseConfig = {
|
||||
|
||||
const baseSettings = {
|
||||
max_radio_contacts: 200,
|
||||
experimental_channel_double_send: false,
|
||||
favorites: [] as Array<{ type: 'channel' | 'contact'; id: string }>,
|
||||
auto_decrypt_dm_on_advert: false,
|
||||
sidebar_sort_order: 'recent' as const,
|
||||
|
||||
@@ -158,7 +158,6 @@ describe('App startup hash resolution', () => {
|
||||
});
|
||||
mocks.api.getSettings.mockResolvedValue({
|
||||
max_radio_contacts: 200,
|
||||
experimental_channel_double_send: false,
|
||||
favorites: [],
|
||||
auto_decrypt_dm_on_advert: false,
|
||||
sidebar_sort_order: 'recent',
|
||||
|
||||
@@ -36,7 +36,6 @@ const baseHealth: HealthStatus = {
|
||||
|
||||
const baseSettings: AppSettings = {
|
||||
max_radio_contacts: 200,
|
||||
experimental_channel_double_send: false,
|
||||
favorites: [],
|
||||
auto_decrypt_dm_on_advert: false,
|
||||
sidebar_sort_order: 'recent',
|
||||
@@ -194,24 +193,6 @@ describe('SettingsModal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('saves experimental channel double-send toggle through onSaveAppSettings', async () => {
|
||||
const { onSaveAppSettings } = renderModal({
|
||||
appSettings: { ...baseSettings, experimental_channel_double_send: false },
|
||||
});
|
||||
|
||||
openConnectivitySection();
|
||||
|
||||
const toggle = screen.getByLabelText('Always send channel messages twice');
|
||||
fireEvent.click(toggle);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save Settings' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSaveAppSettings).toHaveBeenCalledWith({
|
||||
experimental_channel_double_send: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('renders selected section from external sidebar nav on desktop mode', async () => {
|
||||
renderModal({
|
||||
externalSidebarNav: true,
|
||||
|
||||
@@ -124,7 +124,6 @@ export interface BotConfig {
|
||||
|
||||
export interface AppSettings {
|
||||
max_radio_contacts: number;
|
||||
experimental_channel_double_send: boolean;
|
||||
favorites: Favorite[];
|
||||
auto_decrypt_dm_on_advert: boolean;
|
||||
sidebar_sort_order: 'recent' | 'alpha';
|
||||
@@ -137,7 +136,6 @@ export interface AppSettings {
|
||||
|
||||
export interface AppSettingsUpdate {
|
||||
max_radio_contacts?: number;
|
||||
experimental_channel_double_send?: boolean;
|
||||
auto_decrypt_dm_on_advert?: boolean;
|
||||
sidebar_sort_order?: 'recent' | 'alpha';
|
||||
advert_interval?: number;
|
||||
|
||||
@@ -174,7 +174,6 @@ export interface BotConfig {
|
||||
|
||||
export interface AppSettings {
|
||||
max_radio_contacts: number;
|
||||
experimental_channel_double_send: boolean;
|
||||
favorites: { type: string; id: string }[];
|
||||
auto_decrypt_dm_on_advert: boolean;
|
||||
sidebar_sort_order: string;
|
||||
|
||||
@@ -46,4 +46,40 @@ test.describe('Channel messaging in #flightless', () => {
|
||||
const messageContainer = messageEl.locator('..');
|
||||
await expect(messageContainer.getByText(/[?✓]/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('resend outgoing channel message from message row', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await page.getByText('#flightless', { exact: true }).first().click();
|
||||
await expect(page.getByPlaceholder(/message #flightless/i)).toBeVisible();
|
||||
|
||||
const testMessage = `resend-test-${Date.now()}`;
|
||||
const input = page.getByPlaceholder(/type a message|message #flightless/i);
|
||||
await input.fill(testMessage);
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
const messageEl = page.getByText(testMessage).first();
|
||||
await expect(messageEl).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const messageContainer = messageEl.locator(
|
||||
'xpath=ancestor::div[contains(@class,"break-words")][1]'
|
||||
);
|
||||
const resendButton = messageContainer.getByTitle('Resend message');
|
||||
await expect(resendButton).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const resendResponsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === 'POST' &&
|
||||
/\/api\/messages\/channel\/\d+\/resend$/.test(response.url())
|
||||
);
|
||||
|
||||
await resendButton.click();
|
||||
|
||||
const resendResponse = await resendResponsePromise;
|
||||
expect(resendResponse.ok()).toBeTruthy();
|
||||
await expect(page.getByText('Message resent')).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Byte-perfect resend should not create a second visible row in this conversation.
|
||||
await expect(page.getByText(testMessage)).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -315,6 +315,118 @@ class TestMessagesEndpoint:
|
||||
assert exc_info.value.status_code == 500
|
||||
assert "unexpected duplicate" in exc_info.value.detail.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resend_channel_message_requires_connection(self, test_db, client):
|
||||
"""Resend endpoint returns 503 when radio is disconnected."""
|
||||
with patch("app.dependencies.radio_manager") as mock_rm:
|
||||
mock_rm.is_connected = False
|
||||
mock_rm.meshcore = None
|
||||
|
||||
response = await client.post("/api/messages/channel/1/resend")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert "not connected" in response.json()["detail"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resend_channel_message_success(self, test_db, client):
|
||||
"""Resend endpoint reuses timestamp bytes and strips sender prefix."""
|
||||
from meshcore import EventType
|
||||
|
||||
chan_key = "AB" * 16
|
||||
await ChannelRepository.upsert(key=chan_key, name="#resend")
|
||||
sent_at = int(time.time()) - 5
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="TestNode: hello world",
|
||||
conversation_key=chan_key,
|
||||
sender_timestamp=sent_at,
|
||||
received_at=sent_at,
|
||||
outgoing=True,
|
||||
)
|
||||
assert msg_id is not None
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.self_info = {"name": "TestNode"}
|
||||
mock_mc.commands = MagicMock()
|
||||
mock_mc.commands.set_channel = AsyncMock(
|
||||
return_value=MagicMock(type=EventType.OK, payload={})
|
||||
)
|
||||
mock_mc.commands.send_chan_msg = AsyncMock(
|
||||
return_value=MagicMock(type=EventType.MSG_SENT, payload={})
|
||||
)
|
||||
|
||||
with patch("app.dependencies.radio_manager") as mock_rm:
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.meshcore = mock_mc
|
||||
|
||||
response = await client.post(f"/api/messages/channel/{msg_id}/resend")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok", "message_id": msg_id}
|
||||
|
||||
set_kwargs = mock_mc.commands.set_channel.await_args.kwargs
|
||||
assert set_kwargs["channel_idx"] == 0
|
||||
assert set_kwargs["channel_name"] == "#resend"
|
||||
assert set_kwargs["channel_secret"] == bytes.fromhex(chan_key)
|
||||
|
||||
send_kwargs = mock_mc.commands.send_chan_msg.await_args.kwargs
|
||||
assert send_kwargs["chan"] == 0
|
||||
assert send_kwargs["msg"] == "hello world"
|
||||
assert send_kwargs["timestamp"] == sent_at.to_bytes(4, "little")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resend_channel_message_window_expired(self, test_db, client):
|
||||
"""Resend endpoint rejects channel messages older than 30 seconds."""
|
||||
chan_key = "CD" * 16
|
||||
await ChannelRepository.upsert(key=chan_key, name="#old")
|
||||
sent_at = int(time.time()) - 60
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="TestNode: too old",
|
||||
conversation_key=chan_key,
|
||||
sender_timestamp=sent_at,
|
||||
received_at=sent_at,
|
||||
outgoing=True,
|
||||
)
|
||||
assert msg_id is not None
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.self_info = {"name": "TestNode"}
|
||||
mock_mc.commands = MagicMock()
|
||||
mock_mc.commands.set_channel = AsyncMock()
|
||||
mock_mc.commands.send_chan_msg = AsyncMock()
|
||||
|
||||
with patch("app.dependencies.radio_manager") as mock_rm:
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.meshcore = mock_mc
|
||||
|
||||
response = await client.post(f"/api/messages/channel/{msg_id}/resend")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "expired" in response.json()["detail"].lower()
|
||||
assert mock_mc.commands.set_channel.await_count == 0
|
||||
assert mock_mc.commands.send_chan_msg.await_count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resend_channel_message_returns_404_for_missing(self, test_db, client):
|
||||
"""Resend endpoint returns 404 for nonexistent message ID."""
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.self_info = {"name": "TestNode"}
|
||||
mock_mc.commands = MagicMock()
|
||||
mock_mc.commands.set_channel = AsyncMock()
|
||||
mock_mc.commands.send_chan_msg = AsyncMock()
|
||||
|
||||
with patch("app.dependencies.radio_manager") as mock_rm:
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.meshcore = mock_mc
|
||||
|
||||
response = await client.post("/api/messages/channel/999999/resend")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert "not found" in response.json()["detail"].lower()
|
||||
assert mock_mc.commands.set_channel.await_count == 0
|
||||
assert mock_mc.commands.send_chan_msg.await_count == 0
|
||||
|
||||
|
||||
class TestChannelsEndpoint:
|
||||
"""Test channel-related endpoints."""
|
||||
|
||||
@@ -100,8 +100,8 @@ class TestMigration001:
|
||||
# Run migrations
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 16 # All 16 migrations run
|
||||
assert await get_version(conn) == 16
|
||||
assert applied == 17 # All 17 migrations run
|
||||
assert await get_version(conn) == 17
|
||||
|
||||
# Verify columns exist by inserting and selecting
|
||||
await conn.execute(
|
||||
@@ -183,9 +183,9 @@ class TestMigration001:
|
||||
applied1 = await run_migrations(conn)
|
||||
applied2 = await run_migrations(conn)
|
||||
|
||||
assert applied1 == 16 # All 16 migrations run
|
||||
assert applied1 == 17 # All 17 migrations run
|
||||
assert applied2 == 0 # No migrations on second run
|
||||
assert await get_version(conn) == 16
|
||||
assert await get_version(conn) == 17
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@@ -245,9 +245,9 @@ class TestMigration001:
|
||||
# Run migrations - should not fail
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
# All 16 migrations applied (version incremented) but no error
|
||||
assert applied == 16
|
||||
assert await get_version(conn) == 16
|
||||
# All 17 migrations applied (version incremented) but no error
|
||||
assert applied == 17
|
||||
assert await get_version(conn) == 17
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@@ -374,10 +374,10 @@ class TestMigration013:
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
# Run migration 13 (plus 14+15+16 which also run)
|
||||
# Run migration 13 (plus 14+15+16+17 which also run)
|
||||
applied = await run_migrations(conn)
|
||||
assert applied == 4
|
||||
assert await get_version(conn) == 16
|
||||
assert applied == 5
|
||||
assert await get_version(conn) == 17
|
||||
|
||||
# Verify bots array was created with migrated data
|
||||
cursor = await conn.execute("SELECT bots FROM app_settings WHERE id = 1")
|
||||
|
||||
@@ -385,7 +385,6 @@ class TestAppSettingsRepository:
|
||||
mock_cursor.fetchone = AsyncMock(
|
||||
return_value={
|
||||
"max_radio_contacts": 250,
|
||||
"experimental_channel_double_send": 1,
|
||||
"favorites": "{not-json",
|
||||
"auto_decrypt_dm_on_advert": 1,
|
||||
"sidebar_sort_order": "invalid",
|
||||
@@ -406,7 +405,6 @@ class TestAppSettingsRepository:
|
||||
settings = await AppSettingsRepository.get()
|
||||
|
||||
assert settings.max_radio_contacts == 250
|
||||
assert settings.experimental_channel_double_send is True
|
||||
assert settings.favorites == []
|
||||
assert settings.last_message_times == {}
|
||||
assert settings.sidebar_sort_order == "recent"
|
||||
@@ -471,3 +469,26 @@ class TestAppSettingsRepository:
|
||||
assert result.preferences_migrated is True
|
||||
assert mock_update.call_args.kwargs["sidebar_sort_order"] == "recent"
|
||||
assert mock_update.call_args.kwargs["preferences_migrated"] is True
|
||||
|
||||
|
||||
class TestMessageRepositoryGetById:
|
||||
"""Test MessageRepository.get_by_id method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_message_when_exists(self, test_db):
|
||||
"""Returns message for valid ID."""
|
||||
msg_id = await _create_message(test_db, text="Find me", outgoing=True)
|
||||
|
||||
result = await MessageRepository.get_by_id(msg_id)
|
||||
|
||||
assert result is not None
|
||||
assert result.id == msg_id
|
||||
assert result.text == "Find me"
|
||||
assert result.outgoing is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_when_not_found(self, test_db):
|
||||
"""Returns None for nonexistent ID."""
|
||||
result = await MessageRepository.get_by_id(999999)
|
||||
|
||||
assert result is None
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests for bot triggering on outgoing messages sent via the messages router."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -13,11 +14,15 @@ from app.models import (
|
||||
SendDirectMessageRequest,
|
||||
)
|
||||
from app.repository import (
|
||||
AppSettingsRepository,
|
||||
ChannelRepository,
|
||||
ContactRepository,
|
||||
MessageRepository,
|
||||
)
|
||||
from app.routers.messages import (
|
||||
resend_channel_message,
|
||||
send_channel_message,
|
||||
send_direct_message,
|
||||
)
|
||||
from app.routers.messages import send_channel_message, send_direct_message
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -236,48 +241,6 @@ class TestOutgoingChannelBotTrigger:
|
||||
message = await send_channel_message(request)
|
||||
assert message.outgoing is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_channel_msg_double_send_when_experimental_enabled(self, test_db):
|
||||
"""Experimental setting triggers an immediate byte-perfect duplicate send."""
|
||||
mc = _make_mc(name="MyNode")
|
||||
chan_key = "dd" * 16
|
||||
await ChannelRepository.upsert(key=chan_key, name="#double")
|
||||
await AppSettingsRepository.update(experimental_channel_double_send=True)
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
|
||||
patch("app.bot.run_bot_for_message", new=AsyncMock()),
|
||||
patch("app.routers.messages.asyncio.sleep", new=AsyncMock()) as mock_sleep,
|
||||
):
|
||||
request = SendChannelMessageRequest(channel_key=chan_key, text="same bytes")
|
||||
await send_channel_message(request)
|
||||
|
||||
assert mc.commands.send_chan_msg.await_count == 2
|
||||
mock_sleep.assert_awaited_once_with(3)
|
||||
first_call = mc.commands.send_chan_msg.await_args_list[0].kwargs
|
||||
second_call = mc.commands.send_chan_msg.await_args_list[1].kwargs
|
||||
assert first_call["chan"] == second_call["chan"]
|
||||
assert first_call["msg"] == second_call["msg"]
|
||||
assert first_call["timestamp"] == second_call["timestamp"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_channel_msg_single_send_when_experimental_disabled(self, test_db):
|
||||
"""Default setting keeps channel sends to a single radio command."""
|
||||
mc = _make_mc(name="MyNode")
|
||||
chan_key = "ee" * 16
|
||||
await ChannelRepository.upsert(key=chan_key, name="#single")
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
|
||||
patch("app.bot.run_bot_for_message", new=AsyncMock()),
|
||||
):
|
||||
request = SendChannelMessageRequest(channel_key=chan_key, text="single send")
|
||||
await send_channel_message(request)
|
||||
|
||||
assert mc.commands.send_chan_msg.await_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_channel_msg_response_includes_current_ack_count(self, test_db):
|
||||
"""Send response reflects latest DB ack count at response time."""
|
||||
@@ -296,3 +259,154 @@ class TestOutgoingChannelBotTrigger:
|
||||
# Fresh message has acked=0
|
||||
assert message.id is not None
|
||||
assert message.acked == 0
|
||||
|
||||
|
||||
class TestResendChannelMessage:
|
||||
"""Test the user-triggered resend endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resend_within_window_succeeds(self, test_db):
|
||||
"""Resend within 30-second window sends with same timestamp bytes."""
|
||||
mc = _make_mc(name="MyNode")
|
||||
chan_key = "aa" * 16
|
||||
await ChannelRepository.upsert(key=chan_key, name="#resend")
|
||||
|
||||
now = int(time.time()) - 10 # 10 seconds ago
|
||||
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):
|
||||
result = await resend_channel_message(msg_id)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["message_id"] == msg_id
|
||||
|
||||
# Verify radio was called with correct timestamp bytes
|
||||
mc.commands.send_chan_msg.assert_awaited_once()
|
||||
call_kwargs = mc.commands.send_chan_msg.await_args.kwargs
|
||||
assert call_kwargs["timestamp"] == now.to_bytes(4, "little")
|
||||
assert call_kwargs["msg"] == "hello" # Sender prefix stripped
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resend_outside_window_returns_400(self, test_db):
|
||||
"""Resend after 30-second window fails."""
|
||||
mc = _make_mc(name="MyNode")
|
||||
chan_key = "bb" * 16
|
||||
await ChannelRepository.upsert(key=chan_key, name="#old")
|
||||
|
||||
old_ts = int(time.time()) - 60 # 60 seconds ago
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="MyNode: old message",
|
||||
conversation_key=chan_key.upper(),
|
||||
sender_timestamp=old_ts,
|
||||
received_at=old_ts,
|
||||
outgoing=True,
|
||||
)
|
||||
assert msg_id is not None
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
pytest.raises(HTTPException) as exc_info,
|
||||
):
|
||||
await resend_channel_message(msg_id)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "expired" in exc_info.value.detail.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resend_non_outgoing_returns_400(self, test_db):
|
||||
"""Resend of incoming message fails."""
|
||||
mc = _make_mc(name="MyNode")
|
||||
chan_key = "cc" * 16
|
||||
await ChannelRepository.upsert(key=chan_key, name="#incoming")
|
||||
|
||||
now = int(time.time())
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="SomeUser: incoming",
|
||||
conversation_key=chan_key.upper(),
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
outgoing=False,
|
||||
)
|
||||
assert msg_id is not None
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
pytest.raises(HTTPException) as exc_info,
|
||||
):
|
||||
await resend_channel_message(msg_id)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "outgoing" in exc_info.value.detail.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resend_dm_returns_400(self, test_db):
|
||||
"""Resend of DM message fails."""
|
||||
mc = _make_mc(name="MyNode")
|
||||
pub_key = "dd" * 32
|
||||
|
||||
now = int(time.time())
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="PRIV",
|
||||
text="hello dm",
|
||||
conversation_key=pub_key,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
outgoing=True,
|
||||
)
|
||||
assert msg_id is not None
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
pytest.raises(HTTPException) as exc_info,
|
||||
):
|
||||
await resend_channel_message(msg_id)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "channel" in exc_info.value.detail.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resend_nonexistent_returns_404(self, test_db):
|
||||
"""Resend of nonexistent message fails."""
|
||||
mc = _make_mc(name="MyNode")
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
pytest.raises(HTTPException) as exc_info,
|
||||
):
|
||||
await resend_channel_message(999999)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resend_strips_sender_prefix(self, test_db):
|
||||
"""Resend strips the sender prefix before sending to radio."""
|
||||
mc = _make_mc(name="MyNode")
|
||||
chan_key = "ee" * 16
|
||||
await ChannelRepository.upsert(key=chan_key, name="#strip")
|
||||
|
||||
now = int(time.time()) - 5
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="MyNode: hello world",
|
||||
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):
|
||||
await resend_channel_message(msg_id)
|
||||
|
||||
call_kwargs = mc.commands.send_chan_msg.await_args.kwargs
|
||||
assert call_kwargs["msg"] == "hello world"
|
||||
|
||||
@@ -41,13 +41,11 @@ class TestUpdateSettings:
|
||||
AppSettingsUpdate(
|
||||
max_radio_contacts=321,
|
||||
advert_interval=3600,
|
||||
experimental_channel_double_send=True,
|
||||
)
|
||||
)
|
||||
|
||||
assert result.max_radio_contacts == 321
|
||||
assert result.advert_interval == 3600
|
||||
assert result.experimental_channel_double_send is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_patch_returns_current_settings(self, test_db):
|
||||
|
||||
Reference in New Issue
Block a user