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

212 lines
7.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`:
```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: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`
```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)