feat: Phase 2 — user experience & advanced filtering

- Add price_min/price_max filters per keyword (stored in cents)
- Add allowed_postcodes filter per keyword (text[] array)
- Add mute hours per user with cross-midnight support
- Add digest mode with configurable interval and buffered summaries
- 10 new bot commands: /price_min, /price_max, /clear_price, /postcode,
  /clear_postcode, /mute_hours, /mute_off, /digest_on, /digest_off, /status
- Updated keyword card display to show price range and postcodes
- New migrations: 05-keyword-filters.sql, 06-user-settings.sql
This commit is contained in:
2026-07-10 21:19:34 +02:00
parent 3e7e5d0b32
commit 5d792e8ae7
5 changed files with 574 additions and 4 deletions
+359 -1
View File
@@ -189,10 +189,23 @@ def _format_kw_card(kw: dict) -> str:
status_icon = "🟢 Active" if kw["is_active"] else "🔴 Stopped"
subs_line = f"\nSubscribers: <code>{kw['subs']}</code>" if kw.get("subs", 1) > 1 else ""
price_line = ""
if kw.get("price_min") is not None or kw.get("price_max") is not None:
parts = []
if kw.get("price_min") is not None:
parts.append(f"{kw['price_min'] / 100:.0f}")
if kw.get("price_max") is not None:
parts.append(f"{kw['price_max'] / 100:.0f}")
price_line = f"\nPrice: <code>{''.join(parts)}</code>"
postcode_line = ""
if kw.get("allowed_postcodes"):
postcode_line = f"\nPostcodes: <code>{', '.join(kw['allowed_postcodes'])}</code>"
return (
f"<b>🔍 {kw['keyword']}</b>\n"
f"{status_icon} | Interval: <code>{kw['interval_minutes']} min</code>\n"
f"Last scrape: {_vienna_time(kw.get('last_scraped_at'))}{subs_line}"
f"Last scrape: {_vienna_time(kw.get('last_scraped_at'))}{price_line}{postcode_line}{subs_line}"
)
@@ -226,12 +239,37 @@ async def setup_global_commands(app: Application) -> None:
await app.bot.set_my_commands([
("start", "Open main menu"),
("admin", "Admin panel (admins only)"),
("price_min", "Set min price: /price_min <kw> <€>"),
("price_max", "Set max price: /price_max <kw> <€>"),
("clear_price", "Remove price filter: /clear_price <kw>"),
("postcode", "Set postcodes: /postcode <kw> p1,p2"),
("clear_postcode", "Remove postcode filter: /clear_postcode <kw>"),
("mute_hours", "Set mute window: /mute_hours HH:MM-HH:MM"),
("mute_off", "Disable mute hours"),
("digest_on", "Enable digest: /digest_on [minutes]"),
("digest_off", "Disable digest mode"),
("status", "Show your settings"),
])
def register_handlers(app: Application) -> None:
app.add_handler(CommandHandler("start", start_handler))
app.add_handler(CommandHandler("admin", admin_handler))
# Phase 2: Price filters
app.add_handler(CommandHandler("price_min", price_min_handler))
app.add_handler(CommandHandler("price_max", price_max_handler))
app.add_handler(CommandHandler("clear_price", clear_price_handler))
# Phase 2: Postcode filters
app.add_handler(CommandHandler("postcode", postcode_handler))
app.add_handler(CommandHandler("clear_postcode", clear_postcode_handler))
# Phase 2: Mute hours
app.add_handler(CommandHandler("mute_hours", mute_hours_handler))
app.add_handler(CommandHandler("mute_off", mute_off_handler))
# Phase 2: Digest mode
app.add_handler(CommandHandler("digest_on", digest_on_handler))
app.add_handler(CommandHandler("digest_off", digest_off_handler))
# Phase 2: Status
app.add_handler(CommandHandler("status", status_handler))
app.add_handler(CallbackQueryHandler(callback_router))
# Catch all non-command text messages for conversation flows
app.add_handler(MessageHandler(TEXT_FILTER, text_input_handler))
@@ -268,6 +306,326 @@ async def admin_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
)
# ── Phase 2: Price filter commands ────────────────────────────────────────
async def _find_keyword_for_user(pool, user_id: str, keyword_text: str) -> dict | None:
"""Find a keyword matching the text that the user subscribes to (or any if admin)."""
# First try exact match for the user's keywords
row = await pool.fetchrow(
"""SELECT kw.* FROM keywords kw
JOIN keyword_subscriptions ks ON ks.keyword_id = kw.id
WHERE LOWER(kw.keyword) = LOWER($1) AND ks.user_id = $2""",
keyword_text.lower(), user_id,
)
if row:
return dict(row)
# Admin can access any keyword
row = await pool.fetchrow(
"SELECT * FROM keywords WHERE LOWER(keyword) = LOWER($1)",
keyword_text.lower(),
)
return dict(row) if row else None
async def price_min_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not update.message or not update.message.text: # type: ignore[union-attr]
return
parts = update.message.text.split(maxsplit=2) # type: ignore[union-attr]
if len(parts) < 3:
await update.message.reply_text("Usage: /price_min <keyword> <amount in €>") # type: ignore[union-attr]
return
keyword_text = parts[1]
try:
amount = float(parts[2])
if amount < 0:
raise ValueError
except ValueError:
await update.message.reply_text("Enter a valid positive amount.") # type: ignore[union-attr]
return
user = await _require_user(update)
if not user:
return
pool = await get_pool()
kw = await _find_keyword_for_user(pool, user["id"], keyword_text)
if not kw:
await update.message.reply_text(f"Keyword '{keyword_text}' not found.") # type: ignore[union-attr]
return
price_cents = int(round(amount * 100))
await pool.execute("UPDATE keywords SET price_min = $1 WHERE id = $2", price_cents, kw["id"])
await update.message.reply_text( # type: ignore[union-attr]
f"✅ <b>{kw['keyword']}</b>: min price set to €{amount:.2f}", parse_mode="HTML")
async def price_max_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not update.message or not update.message.text: # type: ignore[union-attr]
return
parts = update.message.text.split(maxsplit=2) # type: ignore[union-attr]
if len(parts) < 3:
await update.message.reply_text("Usage: /price_max <keyword> <amount in €>") # type: ignore[union-attr]
return
keyword_text = parts[1]
try:
amount = float(parts[2])
if amount < 0:
raise ValueError
except ValueError:
await update.message.reply_text("Enter a valid positive amount.") # type: ignore[union-attr]
return
user = await _require_user(update)
if not user:
return
pool = await get_pool()
kw = await _find_keyword_for_user(pool, user["id"], keyword_text)
if not kw:
await update.message.reply_text(f"Keyword '{keyword_text}' not found.") # type: ignore[union-attr]
return
price_cents = int(round(amount * 100))
await pool.execute("UPDATE keywords SET price_max = $1 WHERE id = $2", price_cents, kw["id"])
await update.message.reply_text( # type: ignore[union-attr]
f"✅ <b>{kw['keyword']}</b>: max price set to €{amount:.2f}", parse_mode="HTML")
async def clear_price_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not update.message or not update.message.text: # type: ignore[union-attr]
return
parts = update.message.text.split(maxsplit=1) # type: ignore[union-attr]
if len(parts) < 2:
await update.message.reply_text("Usage: /clear_price <keyword>") # type: ignore[union-attr]
return
user = await _require_user(update)
if not user:
return
pool = await get_pool()
kw = await _find_keyword_for_user(pool, user["id"], parts[1])
if not kw:
await update.message.reply_text(f"Keyword '{parts[1]}' not found.") # type: ignore[union-attr]
return
await pool.execute("UPDATE keywords SET price_min = NULL, price_max = NULL WHERE id = $1", kw["id"])
await update.message.reply_text( # type: ignore[union-attr]
f"✅ <b>{kw['keyword']}</b>: price filters cleared", parse_mode="HTML")
# ── Phase 2: Postcode filter commands ─────────────────────────────────────
async def postcode_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not update.message or not update.message.text: # type: ignore[union-attr]
return
parts = update.message.text.split(maxsplit=1) # type: ignore[union-attr]
if len(parts) < 2:
await update.message.reply_text("Usage: /postcode <keyword> p1,p2,p3") # type: ignore[union-attr]
return
user = await _require_user(update)
if not user:
return
postcodes = [p.strip() for p in parts[1].split(",")]
for p in postcodes:
if not p.isdigit() or len(p) != 4:
await update.message.reply_text(f"Invalid postcode '{p}'. Use 4-digit codes (e.g., 1010,1020).") # type: ignore[union-attr]
return
pool = await get_pool()
kw = await _find_keyword_for_user(pool, user["id"], parts[0])
if not kw:
await update.message.reply_text(f"Keyword '{parts[0]}' not found.") # type: ignore[union-attr]
return
await pool.execute("UPDATE keywords SET allowed_postcodes = $1 WHERE id = $2", postcodes, kw["id"])
await update.message.reply_text( # type: ignore[union-attr]
f"✅ <b>{kw['keyword']}</b>: postcodes set to {', '.join(postcodes)}", parse_mode="HTML")
async def clear_postcode_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not update.message or not update.message.text: # type: ignore[union-attr]
return
parts = update.message.text.split(maxsplit=1) # type: ignore[union-attr]
if len(parts) < 2:
await update.message.reply_text("Usage: /clear_postcode <keyword>") # type: ignore[union-attr]
return
user = await _require_user(update)
if not user:
return
pool = await get_pool()
kw = await _find_keyword_for_user(pool, user["id"], parts[1])
if not kw:
await update.message.reply_text(f"Keyword '{parts[1]}' not found.") # type: ignore[union-attr]
return
await pool.execute("UPDATE keywords SET allowed_postcodes = NULL WHERE id = $1", kw["id"])
await update.message.reply_text( # type: ignore[union-attr]
f"✅ <b>{kw['keyword']}</b>: postcode filter cleared", parse_mode="HTML")
# ── Phase 2: Mute hours commands ──────────────────────────────────────────
async def mute_hours_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not update.message or not update.message.text: # type: ignore[union-attr]
return
parts = update.message.text.split(maxsplit=1) # type: ignore[union-attr]
if len(parts) < 2:
await update.message.reply_text("Usage: /mute_hours HH:MM-HH:MM (UTC)") # type: ignore[union-attr]
return
try:
start_str, end_str = parts[1].split("-", 1)
# Validate time format
datetime.strptime(start_str, "%H:%M")
datetime.strptime(end_str, "%H:%M")
except (ValueError, AttributeError):
await update.message.reply_text("Usage: /mute_hours HH:MM-HH:MM (UTC), e.g. /mute_hours 22:00-07:00") # type: ignore[union-attr]
return
user = await _require_user(update)
if not user:
return
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 = $2, mute_end = $3""",
str(user["telegram_id"]), start_str, end_str,
)
await update.message.reply_text( # type: ignore[union-attr]
f"✅ Mute hours set: {start_str}{end_str} UTC", parse_mode="HTML")
async def mute_off_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
user = await _require_user(update)
if not user:
return
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 = NULL, mute_end = NULL""",
str(user["telegram_id"]),
)
msg = update.message or update.callback_query # type: ignore[union-attr]
await msg.reply_text("✅ Mute hours disabled.", parse_mode="HTML")
# ── Phase 2: Digest mode commands ─────────────────────────────────────────
async def digest_on_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not update.message or not update.message.text: # type: ignore[union-attr]
return
parts = update.message.text.split(maxsplit=1) # type: ignore[union-attr]
interval = 60 # default
if len(parts) > 1:
try:
interval = int(parts[1])
if interval < 5 or interval > 1440:
raise ValueError
except ValueError:
await update.message.reply_text("Enter a number between 5 and 1440 minutes.") # type: ignore[union-attr]
return
user = await _require_user(update)
if not user:
return
pool = await get_pool()
await pool.execute(
"""INSERT INTO user_settings (telegram_id, digest_mode, digest_interval) VALUES ($1, true, $2)
ON CONFLICT (telegram_id) DO UPDATE SET digest_mode = true, digest_interval = $2""",
str(user["telegram_id"]), interval,
)
await update.message.reply_text( # type: ignore[union-attr]
f"✅ Digest mode enabled (every {interval} min)", parse_mode="HTML")
async def digest_off_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
user = await _require_user(update)
if not user:
return
pool = await get_pool()
# Flush any pending digest items before disabling
buffered = await pool.fetch(
"SELECT db.id, db.keyword, db.title, db.price, db.url FROM digest_buffer db WHERE db.telegram_id = $1",
str(user["telegram_id"]),
)
if buffered:
# Send immediate summary of pending items
by_keyword = {}
for item in buffered:
by_keyword.setdefault(item["keyword"], []).append(item)
lines = ["📦 <b>Pending Digest Summary</b>"]
for keyword, items in by_keyword.items():
lines.append(f"\n<b>🔍 {keyword}</b> ({len(items)} ads)")
for item in items:
price_str = f"{item['price'] / 100:.0f}" if item["price"] else "N/A"
lines.append(f"{item['title']}{price_str}")
try:
await update.message.reply_text("\n".join(lines), parse_mode="HTML") # type: ignore[union-attr]
except Exception:
pass
await pool.execute("DELETE FROM digest_buffer WHERE telegram_id = $1", str(user["telegram_id"]))
await pool.execute(
"""INSERT INTO user_settings (telegram_id, digest_mode) VALUES ($1, false)
ON CONFLICT (telegram_id) DO UPDATE SET digest_mode = false""",
str(user["telegram_id"]),
)
msg = update.message or update.callback_query # type: ignore[union-attr]
await msg.reply_text("✅ Digest mode disabled.", parse_mode="HTML")
# ── Phase 2: Status command ───────────────────────────────────────────────
async def status_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
user = await _require_user(update)
if not user:
return
pool = await get_pool()
settings = await pool.fetchrow(
"SELECT mute_start, mute_end, digest_mode, digest_interval FROM user_settings WHERE telegram_id = $1",
str(user["telegram_id"]),
)
lines = ["<b>⚙️ Your Settings</b>"]
if settings and settings["mute_start"]:
lines.append(f"\n🔇 Mute hours: {settings['mute_start']}{settings['mute_end']} UTC")
else:
lines.append("\n🔇 Mute hours: off")
if settings and settings["digest_mode"]:
lines.append(f"📦 Digest: on (every {settings['digest_interval']} min)")
else:
lines.append("📦 Digest: off")
msg = update.message or update.callback_query # type: ignore[union-attr]
await msg.reply_text("\n".join(lines), parse_mode="HTML")
# ── text input handler (keyword name, custom interval, admin flows) ───────
async def text_input_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: