mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-12 03:33:03 +02:00
Add resend button for 30s
This commit is contained in:
+1
-1
@@ -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"
|
||||
)
|
||||
|
||||
+31
-8
@@ -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])
|
||||
|
||||
+82
-27
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user