Files
willhaben-tracker/worker/src/bot.py
T
Lago 5d792e8ae7 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
2026-07-10 21:19:34 +02:00

1227 lines
52 KiB
Python
Raw 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.
import logging
import uuid
from base64 import b64encode, b64decode
from datetime import datetime, timezone
from typing import Any
from zoneinfo import ZoneInfo
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.ext import (
Application,
CallbackQueryHandler,
CommandHandler,
ContextTypes,
MessageHandler,
)
from telegram.ext.filters import TEXT as TEXT_FILTER
from db import get_pool
logger = logging.getLogger(__name__)
VIENNA_TZ = ZoneInfo("Europe/Vienna")
# ── helpers ────────────────────────────────────────────────────────────────
def _vienna_time(dt: datetime | None, fmt: str = "%d.%m.%Y %H:%M") -> str:
"""Convert any datetime to Vienna timezone string."""
if dt is None:
return "never"
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(VIENNA_TZ).strftime(fmt)
def _safe_id(kw_id: Any) -> str:
"""Return a short, safe version of the UUID for Telegram callback_data."""
return str(kw_id)[:28] # handles asyncpg.UUID objects
def _b64(text: str) -> str:
return b64encode(text.encode()).decode()
def _ub64(s: str) -> str:
return b64decode(s.encode()).decode()
async def _edit_or_send(bot, chat_id: int, message_id: int, text: str, reply_markup=None):
"""Try to edit the original message; if it fails (24h limit), send new."""
try:
await bot.edit_message_text(
chat_id=chat_id, message_id=message_id,
text=text, parse_mode="HTML", reply_markup=reply_markup,
)
except Exception:
await bot.send_message(
chat_id=chat_id, text=text,
parse_mode="HTML", reply_markup=reply_markup,
)
async def _require_user(update: Update) -> dict[str, Any] | None:
"""Look up user by telegram_id. Returns row or None if blocked."""
telegram_id = update.effective_user.id
pool = await get_pool()
row = await pool.fetchrow(
"SELECT id, is_active FROM users WHERE telegram_id = $1", telegram_id,
)
if not row or not row["is_active"]:
msg = update.message or update.callback_query # type: ignore[union-attr]
if update.callback_query:
await update.callback_query.answer("Access denied.")
else:
await msg.reply_text("Access denied. This bot requires an invitation.")
return None
return dict(row)
async def _require_admin(update: Update) -> dict[str, Any] | None:
"""Require the sender to be a whitelisted admin."""
telegram_id = update.effective_user.id
pool = await get_pool()
row = await pool.fetchrow(
"SELECT id, is_admin FROM users WHERE telegram_id = $1 AND is_active = true AND is_admin = true",
telegram_id,
)
if not row:
msg = update.message or update.callback_query # type: ignore[union-attr]
if update.callback_query:
await update.callback_query.answer("Admin only.")
else:
await msg.reply_text("Unauthorized — admin only.")
return None
return dict(row)
async def _auto_register(update: Update) -> dict[str, Any] | None:
"""Create user row on first contact if not present."""
telegram_id = update.effective_user.id
username = update.effective_user.username or None
first_name = update.effective_user.first_name or None
pool = await get_pool()
existing = await pool.fetchrow(
"SELECT id, is_active FROM users WHERE telegram_id = $1", telegram_id,
)
if existing:
await pool.execute(
"UPDATE users SET username = $1, first_name = $2 WHERE telegram_id = $3",
username, first_name, telegram_id,
)
if not existing["is_active"]:
msg = update.message or update.callback_query # type: ignore[union-attr]
await msg.reply_text("Account deactivated. Contact an admin.")
return None
return dict(existing)
user_uuid = str(uuid.uuid4())
await pool.execute(
"INSERT INTO users (id, telegram_id, username, first_name) VALUES ($1, $2, $3, $4)",
user_uuid, telegram_id, username, first_name,
)
logger.info("Auto-registered user %s (%s)", telegram_id, first_name)
return {"id": user_uuid, "is_active": True}
# ── keyboard builders ──────────────────────────────────────────────────────
def _main_menu_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup([
[InlineKeyboardButton(" Add Keyword", callback_data="menu:add")],
[InlineKeyboardButton("📋 My Keywords", callback_data="menu:list"),
InlineKeyboardButton("📊 Stats", callback_data="menu:stats")],
])
def _confirm_add_keyboard(kw_id: str) -> InlineKeyboardMarkup:
sid = _safe_id(kw_id)
return InlineKeyboardMarkup([
[InlineKeyboardButton("✅ Yes", callback_data=f"confirm_add:{sid}"),
InlineKeyboardButton("❌ Cancel", callback_data="cancel_add")],
])
def _kw_action_keyboard(kw_id: str, is_active: bool) -> InlineKeyboardMarkup:
sid = _safe_id(kw_id)
toggle_btn = "⏸ Stop" if is_active else "▶ Start"
return InlineKeyboardMarkup([
[InlineKeyboardButton(toggle_btn, callback_data=f"toggle:{sid}"),
InlineKeyboardButton("✏️ Edit", callback_data=f"edit_menu:{sid}")],
[InlineKeyboardButton("🗑 Remove", callback_data=f"remove_confirm:{sid}")],
])
def _edit_menu_keyboard(kw_id: str) -> InlineKeyboardMarkup:
sid = _safe_id(kw_id)
return InlineKeyboardMarkup([
[InlineKeyboardButton("🔄 Change Name", callback_data=f"edit_name_prompt:{sid}"),
InlineKeyboardButton("⏱ Change Interval", callback_data=f"edit_interval_preset:{sid}")],
[InlineKeyboardButton("↩ Back", callback_data=f"show_kw:{sid}")],
])
def _interval_preset_keyboard(kw_id: str) -> InlineKeyboardMarkup:
sid = _safe_id(kw_id)
btn_row = [
InlineKeyboardButton(f"{m}m", callback_data=f"set_interval:{sid}:{m}")
for m in ("1", "3", "5", "10", "30", "60")
]
return InlineKeyboardMarkup([
btn_row,
[InlineKeyboardButton("⌨️ Custom", callback_data=f"edit_interval_custom:{sid}")],
[InlineKeyboardButton("↩ Back", callback_data=f"show_kw:{sid}")],
])
def _admin_menu_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup([
[InlineKeyboardButton(" Add User", callback_data="admin_add"),
InlineKeyboardButton("👥 List Users", callback_data="admin_list")],
[InlineKeyboardButton("🗑 Remove User", callback_data="admin_remove")],
])
# ── format keyword card ───────────────────────────────────────────────────
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'))}{price_line}{postcode_line}{subs_line}"
)
async def _refresh_kw_card(bot, chat_id: int, message_id: int, kw_id_full: str):
"""Refresh a keyword card message with latest DB state."""
pool = await get_pool()
# Accept either full UUID or truncated prefix — both work as LIKE match
kw = await pool.fetchrow(
"SELECT kw.id, kw.keyword, kw.is_active, kw.interval_minutes, kw.last_scraped_at, COUNT(ks.user_id) AS subs "
"FROM keywords kw LEFT JOIN keyword_subscriptions ks ON ks.keyword_id = kw.id "
"WHERE kw.id::text LIKE $1 || '%' GROUP BY kw.id",
kw_id_full + "%",
)
if not kw:
return
text = _format_kw_card(dict(kw))
kb = _kw_action_keyboard(_safe_id(kw["id"]), kw["is_active"])
try:
await bot.edit_message_text(
chat_id=chat_id, message_id=message_id,
text=text, parse_mode="HTML", reply_markup=kb,
)
except Exception:
pass
# ── handler registration + global commands ────────────────────────────────
async def setup_global_commands(app: Application) -> None:
"""Call this from main() after building the app to register bot commands."""
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))
# ── /start ────────────────────────────────────────────────────────────────
async def start_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
row = await _auto_register(update)
if not row:
return
name = update.effective_user.first_name or "there"
chat_id = update.effective_chat.id # type: ignore[union-attr]
await context.bot.send_message(
chat_id=chat_id,
text=f"Hello <b>{name}</b>! I'll notify you about new willhaben listings.",
parse_mode="HTML",
reply_markup=_main_menu_keyboard(),
)
# ── /admin ────────────────────────────────────────────────────────────────
async def admin_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
row = await _require_admin(update)
if not row:
return
chat_id = update.effective_chat.id # type: ignore[union-attr]
await context.bot.send_message(
chat_id=chat_id, text="<b>⚙️ Admin Panel</b>",
parse_mode="HTML", reply_markup=_admin_menu_keyboard(),
)
# ── 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:
"""Catch free-text input during conversation flows."""
user = await _require_user(update)
if not user or not update.message:
return
# Ignore command messages — they're handled by CommandHandler
if update.message.text and update.message.text.startswith("/"): # type: ignore[union-attr]
return
text = update.message.text.strip() # type: ignore[union-attr]
state = context.user_data.get("state")
# ── awaiting keyword name (from Add flow) ─────────────────────────
if state == "awaiting_keyword":
kw_id = str(uuid.uuid4())
await update.message.reply_text( # type: ignore[union-attr]
f'I will add this keyword to your watchlist:\n\n'
f'Keyword: <b>{text}</b>\n'
f'Interval: <code>5 min</code> (default)\n\nLooks good?',
parse_mode="HTML",
reply_markup=_confirm_add_keyboard(kw_id),
)
context.user_data["state"] = "pending_confirm_add"
context.user_data["add_keyword_text"] = text
context.user_data["add_kw_id"] = kw_id
# ── awaiting new name (from Edit → Change Name) ───────────────────
elif state == "awaiting_name":
kw_id = context.user_data.get("edit_kw_id")
msg_id = context.user_data.get("edit_msg_id")
chat_id_target = context.user_data.get("edit_chat_id", update.effective_chat.id) # type: ignore[union-attr]
encoded_name = _b64(text)
await update.message.reply_text( # type: ignore[union-attr]
f'New keyword name:\n\n<b>{text}</b>\n\nLooks good?',
parse_mode="HTML",
reply_markup=InlineKeyboardMarkup([
[InlineKeyboardButton("✅ Yes", callback_data=f"confirm_name:{kw_id}:{encoded_name}"),
InlineKeyboardButton("❌ No", callback_data=f"cancel_edit:{kw_id}")],
]),
)
# ── awaiting custom interval number ───────────────────────────────
elif state == "awaiting_interval":
kw_id = context.user_data.get("edit_kw_id")
try:
minutes = int(text)
if minutes < 1 or minutes > 1440:
raise ValueError
except (ValueError, TypeError):
await update.message.reply_text( # type: ignore[union-attr]
"Enter a number between 1 and 1440 minutes.")
return
pool = await get_pool()
sub_check = await pool.fetchrow(
"SELECT 1 FROM keyword_subscriptions WHERE keyword_id::text LIKE $1 || '%' AND user_id = $2",
kw_id + "%", user["id"],
)
if not sub_check:
await update.message.reply_text("You are not subscribed to this keyword.") # type: ignore[union-attr]
return
await pool.execute(
"UPDATE keywords SET interval_minutes = $1 WHERE id::text LIKE $2 || '%'",
minutes, kw_id + "%",
)
msg_id = context.user_data.get("edit_msg_id")
chat_id_target = context.user_data.get("edit_chat_id", update.effective_chat.id) # type: ignore[union-attr]
if msg_id:
try:
await _refresh_kw_card(context.bot, int(chat_id_target), int(msg_id), kw_id)
except Exception:
pass
await update.message.reply_text( # type: ignore[union-attr]
f"Interval set to <code>{minutes} min</code>.", parse_mode="HTML")
context.user_data.clear()
# ── admin: awaiting TG ID to add user ─────────────────────────────
elif state == "admin_awaiting_tg_id_add":
try:
tg_id = int(text)
except ValueError:
await update.message.reply_text("Enter a valid Telegram ID (numeric).") # type: ignore[union-attr]
return
admin_row = await _require_admin(update)
if not admin_row:
return
user_uuid_val = str(uuid.uuid4())
pool = await get_pool()
await pool.execute(
"INSERT INTO users (id, telegram_id, is_admin) VALUES ($1, $2, false) "
"ON CONFLICT (telegram_id) DO UPDATE SET is_active = true",
user_uuid_val, tg_id,
)
existing_msg_id = context.user_data.get("admin_action_msg_id")
if existing_msg_id:
try:
await _edit_or_send(
context.bot, update.effective_chat.id, int(existing_msg_id), # type: ignore[union-attr]
f"✅ Added Telegram ID <code>{tg_id}</code> as user.",
)
except Exception:
pass
context.user_data.clear()
logger.info("Admin added user %d", tg_id)
await update.message.reply_text( # type: ignore[union-attr]
"<b>⚙️ Admin Panel</b>", parse_mode="HTML", reply_markup=_admin_menu_keyboard())
# ── admin: awaiting TG ID to remove user ──────────────────────────
elif state == "admin_awaiting_tg_id_remove":
try:
tg_id = int(text)
except ValueError:
await update.message.reply_text("Enter a valid Telegram ID (numeric).") # type: ignore[union-attr]
return
admin_row = await _require_admin(update)
if not admin_row:
return
pool = await get_pool()
row = await pool.fetchrow(
"SELECT telegram_id FROM users WHERE telegram_id = $1", tg_id,
)
existing_msg_id = context.user_data.get("admin_action_msg_id")
if not row and existing_msg_id:
try:
await _edit_or_send(
context.bot, update.effective_chat.id, int(existing_msg_id), # type: ignore[union-attr]
f"❌ No user found with Telegram ID <code>{tg_id}</code>.",
)
except Exception:
pass
context.user_data.clear()
return
if row:
await pool.execute("DELETE FROM users WHERE telegram_id = $1", tg_id)
if existing_msg_id:
try:
await _edit_or_send(
context.bot, update.effective_chat.id, int(existing_msg_id), # type: ignore[union-attr]
f"✅ Removed Telegram ID <code>{tg_id}</code>.",
)
except Exception:
pass
context.user_data.clear()
logger.info("Admin removed user %d", tg_id)
await update.message.reply_text( # type: ignore[union-attr]
"<b>⚙️ Admin Panel</b>", parse_mode="HTML", reply_markup=_admin_menu_keyboard())
# ── callback router (handles ALL inline button clicks) ────────────────────
async def callback_router(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Route all callback queries by prefix pattern."""
try:
await _handle_callback(update, context)
except Exception:
logger.exception("Callback handler error for %s", update.callback_query.data if update.callback_query else "unknown")
async def _handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query
if not query or not query.data:
return
await query.answer() # dismiss loading indicator
logger.info("Callback received: %s", query.data)
user = await _require_user(update)
if not user:
logger.warning("Callback rejected: user not found or inactive")
return
data = query.data
parts = data.split(":", 1)
action = parts[0]
payload = parts[1] if len(parts) > 1 else ""
chat_id = query.message.chat.id if query.message else None # type: ignore[union-attr]
msg_id = query.message.message_id if query.message else None # type: ignore[union-attr]
pool = await get_pool()
# ════════════════════════════════════════════════════════════════════
# MAIN MENU BUTTONS
# ════════════════════════════════════════════════════════════════════
if action == "menu":
sub = payload
if sub == "add":
context.user_data["state"] = "awaiting_keyword"
await query.edit_message_text(
"Send me a search keyword (e.g. <code>rtx 3090</code>)",
parse_mode="HTML",
reply_markup=InlineKeyboardMarkup([
[InlineKeyboardButton("❌ Cancel", callback_data="cancel:menu")],
]),
)
elif sub == "list":
subs = await pool.fetch(
"SELECT kw.id, kw.keyword, kw.is_active, kw.interval_minutes, "
"kw.last_scraped_at, COUNT(ks2.user_id) AS subs "
"FROM keyword_subscriptions ks "
"JOIN keywords kw ON kw.id = ks.keyword_id "
"LEFT JOIN keyword_subscriptions ks2 ON ks2.keyword_id = ks.keyword_id "
"WHERE ks.user_id = $1 GROUP BY kw.id ORDER BY kw.created_at DESC",
user["id"],
)
if not subs:
await query.edit_message_text(
"No keywords yet. Tap <b> Add Keyword</b> to start.", parse_mode="HTML")
return
for i, s in enumerate(subs):
sd = dict(s)
text = _format_kw_card(sd)
kb = _kw_action_keyboard(_safe_id(sd["id"]), sd["is_active"])
if i == 0:
await query.edit_message_text(text=text, parse_mode="HTML", reply_markup=kb)
else:
await context.bot.send_message(
chat_id=chat_id, text=text, parse_mode="HTML", reply_markup=kb,
)
name = update.effective_user.first_name or "there" # type: ignore[union-attr]
summary_chat_id = update.effective_chat.id # type: ignore[union-attr]
try:
await context.bot.send_message(
chat_id=summary_chat_id,
text=f"<b>That was all your listings, {name}!</b>\n\nAnything else I can help with?",
parse_mode="HTML",
reply_markup=_main_menu_keyboard(),
)
except Exception:
logger.exception("Failed to send summary message for user %s", user["id"])
elif sub == "stats":
total_kw = await pool.fetchval(
"SELECT COUNT(DISTINCT ks.keyword_id) FROM keyword_subscriptions ks WHERE ks.user_id = $1",
user["id"],
) or 0
total_ads = await pool.fetchval("SELECT COUNT(*) FROM ads") or 0
total_notifs = await pool.fetchval(
"SELECT COUNT(*) FROM notifications WHERE user_id = $1", user["id"]) or 0
text = (
"<b>📊 Tracking Statistics</b>\n"
f"Keywords: <code>{total_kw}</code>\n"
f"Ads indexed: <code>{total_ads}</code>\n"
f"Notifications sent: <code>{total_notifs}</code>"
)
await query.edit_message_text(
text=text, parse_mode="HTML",
reply_markup=InlineKeyboardMarkup([
[InlineKeyboardButton("↩ Back", callback_data="menu:")],
]),
)
else:
# Empty payload — "Back" button from sub-menus, show main menu
name = update.effective_user.first_name or "there"
kb = _main_menu_keyboard()
try:
await query.edit_message_text(
f"Hello <b>{name}</b>! I'll notify you about new willhaben listings.",
parse_mode="HTML", reply_markup=kb)
except Exception:
await context.bot.send_message(
chat_id=chat_id or user["telegram_id"],
text=f"Hello <b>{name}</b>! I'll notify you about new willhaben listings.",
parse_mode="HTML", reply_markup=kb)
# ════════════════════════════════════════════════════════════════════
# CANCEL / BACK
# ════════════════════════════════════════════════════════════════════
elif action == "cancel":
context.user_data.clear()
kb = _main_menu_keyboard()
name = update.effective_user.first_name or "there" # type: ignore[union-attr]
try:
await query.edit_message_text(
f"Hello <b>{name}</b>! I'll notify you about new willhaben listings.",
parse_mode="HTML", reply_markup=kb)
except Exception:
await context.bot.send_message(
chat_id=chat_id or user["telegram_id"],
text=f"Hello <b>{name}</b>! I'll notify you about new willhaben listings.",
parse_mode="HTML", reply_markup=kb)
# ════════════════════════════════════════════════════════════════════
# ADD KEYWORD FLOW
# ════════════════════════════════════════════════════════════════════
elif action == "confirm_add":
sid = payload # truncated ID stored in context.user_data["add_kw_id"] as full UUID
keyword_text = context.user_data.get("add_keyword_text", "")
if not keyword_text:
await query.edit_message_text("Session expired. Tap <b> Add Keyword</b> again.")
return
# Insert into DB using the actual keyword text + real UUID from context
full_kw_id = context.user_data.get("add_kw_id", str(uuid.uuid4()))
existing = await pool.fetchrow(
"SELECT id FROM keywords WHERE LOWER(keyword) = LOWER($1)", keyword_text,
)
if not existing:
await pool.execute(
"INSERT INTO keywords (id, keyword, interval_minutes, is_active) VALUES ($1, $2, 5, true)",
full_kw_id, keyword_text,
)
else:
full_kw_id = existing["id"]
await pool.execute(
"INSERT INTO keyword_subscriptions (keyword_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
full_kw_id, user["id"],
)
sub_count_row = await pool.fetchrow(
"SELECT COUNT(*) - 1 AS others FROM keyword_subscriptions WHERE keyword_id = $1",
full_kw_id,
)
other_count = sub_count_row["others"]
extra = f"\n({other_count} other subscriber(s))" if other_count > 0 else ""
await _edit_or_send(
context.bot, chat_id, msg_id, # type: ignore[arg-type]
f'<b>✅ Keyword added!</b>\n\n"{keyword_text}"{extra}',
reply_markup=InlineKeyboardMarkup([
[InlineKeyboardButton("↩ Back", callback_data="menu:")],
]),
)
logger.info("User %s subscribed to '%s'", update.effective_user.id, keyword_text)
elif action == "cancel_add":
await _edit_or_send(context.bot, chat_id, msg_id, # type: ignore[arg-type]
"<b>❌ Keyword not added.</b>")
# ════════════════════════════════════════════════════════════════════
# KEYWORD ACTIONS (toggle, edit, remove)
# ════════════════════════════════════════════════════════════════════
elif action == "toggle":
sid = payload
await pool.execute(
"UPDATE keywords SET is_active = NOT is_active WHERE id::text LIKE $1",
sid + "%",
)
await _refresh_kw_card(context.bot, chat_id, msg_id, sid) # type: ignore[arg-type]
elif action == "edit_menu":
kw_row = await pool.fetchrow(
"SELECT id, keyword FROM keywords WHERE id::text LIKE $1", payload + "%",
)
if not kw_row:
return
sid_full = str(kw_row["id"])
context.user_data["edit_kw_id"] = _safe_id(sid_full)
await query.edit_message_text(
f'Edit <b>"{kw_row["keyword"]}"</b>', parse_mode="HTML",
reply_markup=_edit_menu_keyboard(_safe_id(sid_full)),
)
elif action == "edit_name_prompt":
kw_row = await pool.fetchrow(
"SELECT id, keyword FROM keywords WHERE id::text LIKE $1", payload + "%",
)
if not kw_row:
return
sid_full = str(kw_row["id"])
context.user_data["state"] = "awaiting_name"
context.user_data["edit_kw_id"] = _safe_id(sid_full)
context.user_data["edit_msg_id"] = msg_id
context.user_data["edit_chat_id"] = chat_id
await query.edit_message_text("Send the new keyword name:", parse_mode="HTML")
elif action == "confirm_name":
sub_parts = payload.split(":", 2)
if len(sub_parts) < 3:
return
sid, encoded_name = sub_parts[0], sub_parts[2]
new_name = _ub64(encoded_name)
kw_row = await pool.fetchrow(
"SELECT id, keyword FROM keywords WHERE id::text LIKE $1", sid + "%",
)
if not kw_row:
return
# Only update if the LOWERcased name actually changed AND no collision
old_kw_lower = kw_row["keyword"].lower()
new_kw_lower = new_name.lower()
full_id = str(kw_row["id"])
if old_kw_lower != new_kw_lower:
existing = await pool.fetchrow(
"SELECT id FROM keywords WHERE LOWER(keyword) = $1 AND id::text NOT LIKE $2",
new_kw_lower, sid + "%",
)
if not existing:
await pool.execute("UPDATE keywords SET keyword = $1 WHERE id = $2", new_name, full_id)
edit_msg_id = context.user_data.get("edit_msg_id") or msg_id
edit_chat_id_val = context.user_data.get("edit_chat_id") or chat_id
await _refresh_kw_card(context.bot, int(edit_chat_id_val), int(edit_msg_id), sid) # type: ignore[arg-type]
elif action == "cancel_edit":
context.user_data.clear()
await _refresh_kw_card(context.bot, chat_id, msg_id, payload) # type: ignore[arg-type]
# ════════════════════════════════════════════════════════════════════
# INTERVAL PICKER
# ════════════════════════════════════════════════════════════════════
elif action == "edit_interval_preset":
await query.edit_message_text(
"<b>Change Interval</b>\n\nTap a preset below:", parse_mode="HTML",
reply_markup=_interval_preset_keyboard(payload),
)
elif action == "edit_interval_custom":
kw_row = await pool.fetchrow(
"SELECT id FROM keywords WHERE id::text LIKE $1", payload + "%",
)
if not kw_row:
return
sid_full = str(kw_row["id"])
context.user_data["state"] = "awaiting_interval"
context.user_data["edit_kw_id"] = _safe_id(sid_full)
context.user_data["edit_msg_id"] = msg_id
context.user_data["edit_chat_id"] = chat_id
await query.edit_message_text(
"Enter interval in minutes (11440):", parse_mode="HTML")
elif action == "set_interval":
sub_parts = payload.split(":", 1)
if len(sub_parts) < 2:
return
sid, minutes_str = sub_parts[0], sub_parts[1]
try:
minutes = int(minutes_str)
except ValueError:
await query.answer("Invalid interval.")
return
await pool.execute(
"UPDATE keywords SET interval_minutes = $1 WHERE id::text LIKE $2",
minutes, sid + "%",
)
edit_msg_id = context.user_data.get("edit_msg_id") or msg_id
edit_chat_id_val = context.user_data.get("edit_chat_id") or chat_id
await _refresh_kw_card(context.bot, int(edit_chat_id_val), int(edit_msg_id), sid) # type: ignore[arg-type]
# ════════════════════════════════════════════════════════════════════
# REMOVE FLOW
# ════════════════════════════════════════════════════════════════════
elif action == "remove_confirm":
kw_row = await pool.fetchrow(
"SELECT id, keyword FROM keywords WHERE id::text LIKE $1", payload + "%",
)
if not kw_row:
return
full_id = str(kw_row["id"])
context.user_data["remove_kw_id"] = _safe_id(full_id)
context.user_data["remove_msg_id"] = msg_id
context.user_data["remove_chat_id"] = chat_id
await query.edit_message_text(
f'Are you sure you want to remove <b>"{kw_row["keyword"]}"</b>?',
parse_mode="HTML",
reply_markup=InlineKeyboardMarkup([
[InlineKeyboardButton("✅ Yes, remove", callback_data=f"remove_exec:{payload}"),
InlineKeyboardButton("❌ No", callback_data=f"cancel_remove:{payload}")],
]),
)
elif action == "remove_exec":
sid = payload
await pool.execute(
"DELETE FROM keyword_subscriptions WHERE keyword_id::text LIKE $1 AND user_id = $2",
sid + "%", user["id"],
)
remaining = await pool.fetchval(
"SELECT COUNT(*) FROM keyword_subscriptions WHERE keyword_id::text LIKE $1",
sid + "%",
)
if remaining == 0:
await pool.execute("UPDATE keywords SET is_active = false WHERE id::text LIKE $1", sid + "%")
rm_msg_id = context.user_data.get("remove_msg_id") or msg_id
rm_chat_id = context.user_data.get("remove_chat_id") or chat_id
await _edit_or_send(context.bot, int(rm_chat_id), int(rm_msg_id), # type: ignore[arg-type]
"<b>❌ Keyword removed.</b>")
elif action == "cancel_remove":
context.user_data.clear()
sid = payload
kw_row = await pool.fetchrow(
"SELECT id FROM keywords WHERE id::text LIKE $1", sid + "%",
)
if kw_row:
full_id = str(kw_row["id"])
await _refresh_kw_card(context.bot, chat_id, msg_id, _safe_id(full_id)) # type: ignore[arg-type]
# ════════════════════════════════════════════════════════════════════
# BACK TO KEYWORD CARD
# ════════════════════════════════════════════════════════════════════
elif action == "show_kw":
context.user_data.clear()
await _refresh_kw_card(context.bot, chat_id, msg_id, payload) # type: ignore[arg-type]
# ════════════════════════════════════════════════════════════════════
# ADMIN FLOWS
# ════════════════════════════════════════════════════════════════════
elif action == "back":
if payload == "admin":
context.user_data.clear()
try:
await query.edit_message_text(
"<b>⚙️ Admin Panel</b>", parse_mode="HTML",
reply_markup=_admin_menu_keyboard())
except Exception:
await query.answer(show_alert=True)
elif action == "admin_add":
admin_row = await _require_admin(update)
if not admin_row:
return
context.user_data["state"] = "admin_awaiting_tg_id_add"
context.user_data["admin_action_msg_id"] = msg_id
await query.edit_message_text(
"Enter the Telegram user ID to add:", parse_mode="HTML",
reply_markup=InlineKeyboardMarkup([
[InlineKeyboardButton("❌ Cancel", callback_data="back:admin")],
]),
)
elif action == "admin_list":
admin_row = await _require_admin(update)
if not admin_row:
return
users_list = await pool.fetch(
"SELECT telegram_id, username, first_name, is_admin, is_active FROM users ORDER BY created_at DESC"
)
if not users_list:
await query.edit_message_text("No users registered.")
return
lines = []
for u in users_list:
d = dict(u)
role = " 👑" if d["is_admin"] else ""
status = "🟢" if d["is_active"] else "🔴"
name = d["username"] or d["first_name"] or str(d["telegram_id"])
lines.append(f"{status} <code>{d['telegram_id']}</code> — {name}{role}")
text = f"<b>Registered users ({len(users_list)})</b>\n\n" + "\n".join(lines)
kb = InlineKeyboardMarkup([
[InlineKeyboardButton(" Add User", callback_data="admin_add"),
InlineKeyboardButton("🗑 Remove User", callback_data="admin_remove")],
[InlineKeyboardButton("↩ Back", callback_data="back:admin")],
])
await query.edit_message_text(text=text, parse_mode="HTML", reply_markup=kb)
elif action == "admin_remove":
admin_row = await _require_admin(update)
if not admin_row:
return
context.user_data["state"] = "admin_awaiting_tg_id_remove"
context.user_data["admin_action_msg_id"] = msg_id
await query.edit_message_text(
"Enter the Telegram user ID to remove:", parse_mode="HTML",
reply_markup=InlineKeyboardMarkup([
[InlineKeyboardButton("❌ Cancel", callback_data="back:admin")],
]),
)