# Task: Mute hours per user ## Description Users may add keywords that are popular enough to trigger notifications at any hour. Currently, there's no way to suppress alerts during sleeping hours — the bot sends notifications 24/7. This task creates a `user_settings` table with configurable mute windows (start/end time). During the mute window, notifications for that user are suppressed entirely. The notification is not lost — it's still logged in `log_notifications`, but the Telegram message is not sent. ## Architecture ``` ┌───────────────────────────────────────┐ │ user_settings table (new) │ │ │ │ telegram_id text PRIMARY KEY │ │ mute_start time │ │ mute_end time │ │ digest_mode bool DEFAULT false │ │ digest_interval int DEFAULT 60 │ │ │ │ Example: │ │ telegram_id = '298181113' │ │ mute_start = '22:00:00' │ │ mute_end = '07:00:00' │ │ → no alerts between 10PM-7AM UTC │ └──────────┬────────────────────────────┘ │ ▼ ┌───────────────────────────────────────┐ │ Notification pipeline (in main.py) │ │ │ │ For each new ad that passes filters: │ │ user_settings = get from DB │ │ if in_mute_hours(user_settings): │ │ log_notify() │ │ → skip Telegram send │ │ else: │ │ notify_new() / notify_drop() │ └───────────────────────────────────────┘ ``` ### Key design decisions - **Time stored as `time` type in PostgreSQL** — native, efficient for range checks. Default is NULL (no mute window). - *Alternative*: Could store as integer hours (e.g., 22, 7), but `time` type gives flexibility for minute-level precision and clearer UI. - **UTC timezone**: The bot operates in UTC internally. Users should be informed that mute times are in UTC. Adding timezone support per-user is a Phase 3 consideration. - **Mute window can cross midnight** — start > end means the window wraps around midnight (e.g., 22:00–07:00). The check handles this correctly. ## Implementation Details ### 1. Add migration In `worker/src/migrations/04-user-settings.sql`: ```sql CREATE TABLE IF NOT EXISTS user_settings ( telegram_id text PRIMARY KEY, mute_start time, mute_end time, digest_mode bool NOT NULL DEFAULT false, digest_interval int NOT NULL DEFAULT 60, -- minutes CONSTRAINT chk_mute_hours CHECK ( mute_start IS NULL AND mute_end IS NULL OR mute_start IS NOT NULL AND mute_end IS NOT NULL ) ); COMMENT ON TABLE user_settings IS 'User-specific settings for notification behavior'; ``` ### 2. Add mute hours check in `notifier.py` or `main.py` ```python async def _is_in_mute_hours( telegram_id: str, pool: asyncpg.Pool ) -> bool: """Check if the current time is within the user's mute window.""" settings = await pool.fetchrow( "SELECT mute_start, mute_end FROM user_settings WHERE telegram_id = $1", telegram_id, ) if not settings or not settings["mute_start"] or not settings["mute_end"]: return False # no mute configured now_utc = datetime.now(tz=timezone.utc).time() start = settings["mute_start"] end = settings["mute_end"] if start < end: # Normal window (e.g., 22:00–07:00 → actually wraps, so this is rare) return start <= now_utc <= end else: # Window crosses midnight (e.g., 22:00 to 07:00 next day) return now_utc >= start or now_utc <= end # In main.py scheduler loop, before calling notify_new(): telegram_id_str = str(telegram_id) in_mute = await _is_in_mute_hours(telegram_id_str, pool) if in_mute: logger.debug("Muted notification for user %s (mute window active)", telegram_id) # Still log it but don't send Telegram message await log_notify(pool, ad_id, telegram_id, "new") return # Proceed with normal notification... ``` ### 3. Add bot commands in `bot.py` ```python async def cmd_set_mute(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Set mute hours window (HH:MM-HH:MM format).""" if len(context.args) < 1: await update.message.reply_text( "Usage: /mute_hours HH:MM-HH:MM\n" "Example: /mute_hours 22:00-07:00 (mutes from 10PM to 7AM UTC)\n" "Use /mute_off to disable." ) return try: start_str, end_str = context.args[0].split("-") mute_start = datetime.strptime(start_str.strip(), "%H:%M").time() mute_end = datetime.strptime(end_str.strip(), "%H:%M").time() except (ValueError, TypeError) as e: await update.message.reply_text( f"Invalid format. Use HH:MM-HH:MM.\nExample: /mute_hours 22:00-07:00" ) return telegram_id = str(update.effective_user.id) from db import get_pool pool = await get_pool() await pool.execute( """INSERT INTO user_settings (telegram_id, mute_start, mute_end) VALUES ($1, $2, $3) ON CONFLICT (telegram_id) DO UPDATE SET mute_start = EXCLUDED.mute_start, mute_end = EXCLUDED.mute_end""", telegram_id, mute_start, mute_end, ) await update.message.reply_text( f"✅ Mute hours set: {mute_start} — {mute_end} UTC\n" "No notifications will be sent during this window.\n" "Use /mute_off to disable or change." ) async def cmd_mute_off(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Disable mute hours.""" telegram_id = str(update.effective_user.id) from db import get_pool pool = await get_pool() await pool.execute( """INSERT INTO user_settings (telegram_id, mute_start, mute_end) VALUES ($1, NULL, NULL) ON CONFLICT (telegram_id) DO UPDATE SET mute_start = EXCLUDED.mute_start, mute_end = EXCLUDED.mute_end""", telegram_id, ) await update.message.reply_text("✅ Mute hours disabled.") async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Show current user settings.""" telegram_id = str(update.effective_user.id) from db import get_pool pool = await get_pool() settings = await pool.fetchrow( "SELECT mute_start, mute_end FROM user_settings WHERE telegram_id = $1", telegram_id, ) if not settings or not settings["mute_start"]: reply = "🔕 Mute hours: OFF (notifications sent 24/7)" else: reply = f"🔕 Mute hours: {settings['mute_start']} — {settings['mute_end']} UTC" await update.message.reply_text(reply) ``` Register handlers: ```python dp.add_handler(MessageHandler(REGEX(r"^/mute_hours"), cmd_set_mute)) dp.add_handler(CommandHandler("mute_off", cmd_mute_off)) dp.add_handler(CommandHandler("status", cmd_status)) ``` ## Acceptance Criteria - [ ] `/mute_hours 22:00-07:00` sets mute window from 10 PM to 7 AM UTC - [ ] Notifications during the mute window are logged but NOT sent via Telegram - [ ] Notifications outside the mute window work normally (no regression) - [ ] Mute windows that cross midnight (start > end) are handled correctly - [ ] `/mute_off` clears both start and end times, restoring 24/7 notifications - [ ] `/status` shows current mute settings clearly - [ ] Users without any settings in `user_settings` table receive all notifications (default behavior unchanged)