docs(plan): add Phase 1, 2, 3 implementation specs
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user