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:
+359
-1
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user