diff --git a/app/migrations.py b/app/migrations.py index 4588003..d7b41ac 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -177,6 +177,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int: await set_version(conn, 20) applied += 1 + # Migration 21: Enforce minimum 1-hour advert interval + if version < 21: + logger.info("Applying migration 21: enforce minimum 1-hour advert interval") + await _migrate_021_enforce_min_advert_interval(conn) + await set_version(conn, 21) + applied += 1 + if applied > 0: logger.info( "Applied %d migration(s), schema now at version %d", applied, await get_version(conn) @@ -1252,3 +1259,27 @@ async def _migrate_020_enable_wal_and_auto_vacuum(conn: aiosqlite.Connection) -> logger.info("Journal mode set to %s", mode) await conn.commit() + + +async def _migrate_021_enforce_min_advert_interval(conn: aiosqlite.Connection) -> None: + """ + Enforce minimum 1-hour advert interval. + + Any advert_interval between 1 and 3599 is clamped up to 3600 (1 hour). + Zero (disabled) is left unchanged. + """ + # Guard: app_settings table may not exist if running against a very old schema + # (it's created in migration 9). The UPDATE is harmless if the table exists + # but has no rows, but will error if the table itself is missing. + cursor = await conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'" + ) + if await cursor.fetchone() is None: + logger.debug("app_settings table does not exist yet, skipping advert_interval clamp") + return + + await conn.execute( + "UPDATE app_settings SET advert_interval = 3600 WHERE advert_interval > 0 AND advert_interval < 3600" + ) + await conn.commit() + logger.debug("Clamped advert_interval to minimum 3600 seconds") diff --git a/app/radio_sync.py b/app/radio_sync.py index 467be8f..86252ae 100644 --- a/app/radio_sync.py +++ b/app/radio_sync.py @@ -41,6 +41,11 @@ _advert_task: asyncio.Task | None = None # We still need to periodically check if it's been enabled ADVERT_CHECK_INTERVAL = 60 +# Minimum allowed advertisement interval (1 hour). +# Even if the database has a shorter value, we silently refuse to advertise +# more frequently than this. +MIN_ADVERT_INTERVAL = 3600 + # Counter to pause polling during repeater operations (supports nested pauses) _polling_pause_count: int = 0 @@ -384,6 +389,9 @@ async def send_advertisement(force: bool = False) -> bool: logger.debug("Advertisement skipped: periodic advertising is disabled") return False + # Enforce minimum interval floor + interval = max(interval, MIN_ADVERT_INTERVAL) + # Check if enough time has passed elapsed = now - last_time if elapsed < interval: diff --git a/app/routers/settings.py b/app/routers/settings.py index ce02db6..086cdd2 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -52,7 +52,7 @@ class AppSettingsUpdate(BaseModel): advert_interval: int | None = Field( default=None, ge=0, - description="Periodic advertisement interval in seconds (0 = disabled)", + description="Periodic advertisement interval in seconds (0 = disabled, minimum 3600)", ) bots: list[BotConfig] | None = Field( default=None, @@ -111,8 +111,12 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings: kwargs["sidebar_sort_order"] = update.sidebar_sort_order if update.advert_interval is not None: - logger.info("Updating advert_interval to %d", update.advert_interval) - kwargs["advert_interval"] = update.advert_interval + # Enforce minimum 1-hour interval; 0 means disabled + interval = update.advert_interval + if 0 < interval < 3600: + interval = 3600 + logger.info("Updating advert_interval to %d", interval) + kwargs["advert_interval"] = interval if update.bots is not None: validate_all_bots(update.bots) diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 769a29f..c765595 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -151,8 +151,8 @@ export function SettingsModal(props: SettingsModalProps) { getReopenLastConversationEnabled ); - // Advertisement interval state - const [advertInterval, setAdvertInterval] = useState('0'); + // Advertisement interval state (displayed in hours, stored as seconds in DB) + const [advertIntervalHours, setAdvertIntervalHours] = useState('0'); // Bot state const DEFAULT_BOT_CODE = `def bot( @@ -218,7 +218,7 @@ export function SettingsModal(props: SettingsModalProps) { if (appSettings) { setMaxRadioContacts(String(appSettings.max_radio_contacts)); setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert); - setAdvertInterval(String(appSettings.advert_interval)); + setAdvertIntervalHours(String(Math.round(appSettings.advert_interval / 3600))); setBots(appSettings.bots || []); } }, [appSettings]); @@ -394,9 +394,10 @@ export function SettingsModal(props: SettingsModalProps) { const update: RadioConfigUpdate = { name }; await onSave(update); - // Save advert interval to app settings - const newAdvertInterval = parseInt(advertInterval, 10); - if (!isNaN(newAdvertInterval) && newAdvertInterval !== appSettings?.advert_interval) { + // Save advert interval to app settings (convert hours to seconds) + const hours = parseInt(advertIntervalHours, 10); + const newAdvertInterval = isNaN(hours) ? 0 : hours * 3600; + if (newAdvertInterval !== appSettings?.advert_interval) { await onSaveAppSettings({ advert_interval: newAdvertInterval }); } @@ -865,15 +866,15 @@ export function SettingsModal(props: SettingsModalProps) { id="advert-interval" type="number" min="0" - value={advertInterval} - onChange={(e) => setAdvertInterval(e.target.value)} + value={advertIntervalHours} + onChange={(e) => setAdvertIntervalHours(e.target.value)} className="w-28" /> - seconds (0 = off) + hours (0 = off)

- How often to automatically advertise presence. Set to 0 to disable. Recommended: - 86400 (24 hours) or higher. + How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 + hour. Recommended: 24 hours or higher.

diff --git a/tests/test_migrations.py b/tests/test_migrations.py index cb324f6..2b1c0c9 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -100,8 +100,8 @@ class TestMigration001: # Run migrations applied = await run_migrations(conn) - assert applied == 20 # All 17 migrations run - assert await get_version(conn) == 20 + assert applied == 21 # All migrations run + assert await get_version(conn) == 21 # 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 == 20 # All 20 migrations run + assert applied1 == 21 # All 21 migrations run assert applied2 == 0 # No migrations on second run - assert await get_version(conn) == 20 + assert await get_version(conn) == 21 finally: await conn.close() @@ -245,9 +245,9 @@ class TestMigration001: # Run migrations - should not fail applied = await run_migrations(conn) - # All 17 migrations applied (version incremented) but no error - assert applied == 20 - assert await get_version(conn) == 20 + # All migrations applied (version incremented) but no error + assert applied == 21 + assert await get_version(conn) == 21 finally: await conn.close() @@ -374,10 +374,10 @@ class TestMigration013: ) await conn.commit() - # Run migration 13 (plus 14-20 which also run) + # Run migration 13 (plus 14-21 which also run) applied = await run_migrations(conn) - assert applied == 8 - assert await get_version(conn) == 20 + assert applied == 9 + assert await get_version(conn) == 21 # Verify bots array was created with migrated data cursor = await conn.execute("SELECT bots FROM app_settings WHERE id = 1") @@ -497,7 +497,7 @@ class TestMigration018: assert await cursor.fetchone() is not None await run_migrations(conn) - assert await get_version(conn) == 20 + assert await get_version(conn) == 21 # Verify autoindex is gone cursor = await conn.execute( @@ -571,8 +571,8 @@ class TestMigration018: await conn.commit() applied = await run_migrations(conn) - assert applied == 3 # Migrations 18+19+20 run (18+19 skip internally) - assert await get_version(conn) == 20 + assert applied == 4 # Migrations 18+19+20+21 run (18+19 skip internally) + assert await get_version(conn) == 21 finally: await conn.close() @@ -644,7 +644,7 @@ class TestMigration019: assert await cursor.fetchone() is not None await run_migrations(conn) - assert await get_version(conn) == 20 + assert await get_version(conn) == 21 # Verify autoindex is gone cursor = await conn.execute( @@ -710,8 +710,8 @@ class TestMigration020: assert (await cursor.fetchone())[0] == "delete" applied = await run_migrations(conn) - assert applied == 1 - assert await get_version(conn) == 20 + assert applied == 2 # Migrations 20+21 + assert await get_version(conn) == 21 # Verify WAL mode cursor = await conn.execute("PRAGMA journal_mode") @@ -741,7 +741,7 @@ class TestMigration020: await set_version(conn, 20) applied = await run_migrations(conn) - assert applied == 0 # Already at version 20 + assert applied == 1 # Migration 21 still runs # Still WAL + INCREMENTAL cursor = await conn.execute("PRAGMA journal_mode") diff --git a/tests/test_settings_router.py b/tests/test_settings_router.py index a6375a7..b932c89 100644 --- a/tests/test_settings_router.py +++ b/tests/test_settings_router.py @@ -47,6 +47,21 @@ class TestUpdateSettings: assert result.max_radio_contacts == 321 assert result.advert_interval == 3600 + @pytest.mark.asyncio + async def test_advert_interval_below_minimum_is_clamped_to_one_hour(self, test_db): + result = await update_settings(AppSettingsUpdate(advert_interval=600)) + assert result.advert_interval == 3600 + + @pytest.mark.asyncio + async def test_advert_interval_zero_stays_disabled(self, test_db): + result = await update_settings(AppSettingsUpdate(advert_interval=0)) + assert result.advert_interval == 0 + + @pytest.mark.asyncio + async def test_advert_interval_above_minimum_is_preserved(self, test_db): + result = await update_settings(AppSettingsUpdate(advert_interval=86400)) + assert result.advert_interval == 86400 + @pytest.mark.asyncio async def test_empty_patch_returns_current_settings(self, test_db): result = await update_settings(AppSettingsUpdate())