Add resend button for 30s

This commit is contained in:
Jack Kingsman
2026-02-14 17:37:51 -08:00
parent 7b2d5b817e
commit 5a82d469b4
23 changed files with 570 additions and 171 deletions

View File

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

View File

@@ -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`

View File

@@ -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()

View File

@@ -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"
)

View File

@@ -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])

View File

@@ -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}

View File

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

View File

@@ -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`

View File

@@ -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}
/>

View File

@@ -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'),

View File

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

View File

@@ -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')}

View File

@@ -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,

View File

@@ -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',

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);
});
});

View File

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

View File

@@ -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")

View File

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

View File

@@ -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"

View File

@@ -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):