Files
willhaben-tracker/docs/phase-2/task-mute-hours.md
T

7.9 KiB
Raw Blame History

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:0007:00). The check handles this correctly.

Implementation Details

1. Add migration

In worker/src/migrations/04-user-settings.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

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:0007: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

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:

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)