diff --git a/AGENTS.md b/AGENTS.md index 01c7ab4..bc351a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/app/AGENTS.md b/app/AGENTS.md index 8b78a77..7d052ee 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -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` diff --git a/app/migrations.py b/app/migrations.py index b866b32..0782fd7 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -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() diff --git a/app/models.py b/app/models.py index a3613bb..e31442f 100644 --- a/app/models.py +++ b/app/models.py @@ -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" ) diff --git a/app/repository.py b/app/repository.py index 69974fe..b4859be 100644 --- a/app/repository.py +++ b/app/repository.py @@ -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]) diff --git a/app/routers/messages.py b/app/routers/messages.py index fab95cc..5ce7f10 100644 --- a/app/routers/messages.py +++ b/app/routers/messages.py @@ -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} diff --git a/app/routers/settings.py b/app/routers/settings.py index 4c5e5c4..ce02db6 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -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 diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 59ef669..cb153a2 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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` diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 200a232..6389498 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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} /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index b947c5a..11d964d 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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'), diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index 21f421d..6978545 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -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>(new Set()); + const resendTimersRef = useRef>>(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(); + 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) && ( + + )} {msg.outgoing && (msg.acked > 0 ? ( msg.paths && msg.paths.length > 0 ? ( { e.stopPropagation(); setSelectedPath({ @@ -483,10 +538,13 @@ export function MessageList({ title="View echo paths" >{` ✓${msg.acked > 1 ? msg.acked : ''}`} ) : ( - ` ✓${msg.acked > 1 ? msg.acked : ''}` + {` ✓${msg.acked > 1 ? msg.acked : ''}`} ) ) : ( - ? + + {' '} + ? + ))} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 942218a..f6b3a7c 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -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(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) {

-
-

- Experimental: Adds a duplicate channel send after a 3-second - delay, using the exact same timestamp bytes. -

- -

- 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. -

-
-