From f25ff56babfece2db1e6d7d590a7c9392d3a58f2 Mon Sep 17 00:00:00 2001 From: Jose Lago Date: Wed, 17 Jun 2026 11:46:57 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20full=20bot=20redesign=20with=20inline?= =?UTF-8?q?=20keyboards,=20callback=20routing,=20Vienna=20timezone\n\n-=20?= =?UTF-8?q?Two=20global=20commands=20only:=20/start=20(main=20menu),=20/ad?= =?UTF-8?q?min=20(admin=20panel)\n-=20Inline=20keyboard=20UI=20with=20acti?= =?UTF-8?q?on=20buttons=20per=20keyword=20card\n-=20Add=20flow:=20keyword?= =?UTF-8?q?=20text=20=E2=86=92=20confirmation=20Yes/No=20=E2=86=92=20edits?= =?UTF-8?q?=20message\n-=20Edit=20submenu:=20change=20name=20(in-place)=20?= =?UTF-8?q?or=20interval=20(presets=20+=20custom)\n-=20Toggle=20active/ina?= =?UTF-8?q?ctive,=20remove=20with=20confirmation\n-=20Stats=20formatted=20?= =?UTF-8?q?inline\n-=20All=20times=20shown=20in=20Vienna=20timezone=20(Eur?= =?UTF-8?q?ope/Vienna)\n-=20Message=20editing=20with=20fallback=20to=20new?= =?UTF-8?q?=20message=20on=2024h=20limit\n-=20Removed=20all=20old=20text?= =?UTF-8?q?=20commands:=20/add,=20/list,=20/delete,=20/interval,=20/stats\?= =?UTF-8?q?n-=20Updated=20README=20with=20new=20UI=20documentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 42 +- worker/src/bot.py | 1020 +++++++++++++++++++++++++++++---------------- 2 files changed, 694 insertions(+), 368 deletions(-) diff --git a/README.md b/README.md index d650008..2f989d6 100644 --- a/README.md +++ b/README.md @@ -95,22 +95,36 @@ Edit `.env` before first startup. All values are read by the worker and database | meta | supabase/postgres-meta:v0.84.2 | — | Database metadata service | | worker | (built from ./worker) | — | Scraper + Telegram bot | -## Telegram Commands +## Telegram Bot UI -All users must be whitelisted before use. Run `/start` to activate your account. +All users must be whitelisted before use. The bot uses **inline keyboards** (buttons) instead of text commands for a clean experience. Times are shown in **Vienna timezone**. -| Command | Access | Description | -|------------------------------------|-------------|--------------------------------------------------| -| `/start` | Anyone | Activate account and show help message | -| `/add ` | Active user | Subscribe to keyword (shared across users) | -| `/list` | Active user | List your subscriptions with subscriber counts | -| `/delete ` | Active user | Unsubscribe from a keyword | -| `/interval ` | Active user | Change scrape interval (1-1440 min) | -| `/stats` | Active user | Show keywords, ads tracked, notifications | -| `/adduser [admin]` | Admin only | Add or promote a user by Telegram ID | -| `/removeuser ` | Admin only | Remove a user from the bot | -| `/users` | Admin only | List all registered users and their roles | +### Commands (global menu, appears when you type `/`) + +| Command | Access | Description | +|---------|--------|-------------| +| `/start` | Anyone | Open main menu | +| `/admin` | Admin only | Open admin panel | + +### Main Menu (`/start`) + +Tap one of the buttons: + +- **➕ Add Keyword** → Bot asks for keyword text → Shows confirmation with Yes/No +- **📋 My Keywords** → Sends a formatted card per keyword with status, interval (Vienna tz), last scrape, subscriber count. Each card has action buttons: + - `⏸ Stop` / `▶ Start` — Toggle active state + - `✏️ Edit` — Submenu to change name or interval + - Change Name: type new name → confirm Yes/No → in-place update + - Change Interval: preset buttons (1m, 3m, 5m, 10m, 30m, 60m) or ⌨️ Custom + - `🗑 Remove` — Confirm Yes/No → deletes subscription +- **📊 Stats** — Keywords count, ads indexed, notifications sent + +### Admin Panel (`/admin`) + +- **➕ Add User** — Enter Telegram ID → adds user +- **👥 List Users** — Shows all users with status icons and admin badges +- **🗑 Remove User** — Enter Telegram ID → removes user ## Default Admin -On first boot, Telegram ID `298181113` is seeded as an admin user. Add additional admins via `/adduser admin`. +On first boot, Telegram ID `298181113` is seeded as an admin user. Add additional admins via the `/admin` panel. diff --git a/worker/src/bot.py b/worker/src/bot.py index 553a43e..f644519 100644 --- a/worker/src/bot.py +++ b/worker/src/bot.py @@ -1,28 +1,80 @@ import logging import uuid +from base64 import b64encode, b64decode +from datetime import datetime, timezone from typing import Any +from zoneinfo import ZoneInfo -import asyncpg from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update -from telegram.ext import Application, CommandHandler, ContextTypes +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: str) -> str: + """Return a short, safe version of the UUID for Telegram callback_data.""" + return kw_id[:28] # well within 64-char limit with prefixes + + +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. Auto-register on first /start.""" + """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, + "SELECT id, is_active FROM users WHERE telegram_id = $1", telegram_id, ) if not row or not row["is_active"]: - await update.message.reply_text("Access denied. This bot requires an invitation.") # type: ignore[union-attr] + 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 row + return dict(row) async def _require_admin(update: Update) -> dict[str, Any] | None: @@ -34,433 +86,693 @@ async def _require_admin(update: Update) -> dict[str, Any] | None: telegram_id, ) if not row: - await update.message.reply_text("Unauthorized — admin only.") # type: ignore[union-attr] + 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 row + return dict(row) async def _auto_register(update: Update) -> dict[str, Any] | None: - """Create user row on first contact if not present. Returns nothing if un-whitelisted.""" + """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, + "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, + username, first_name, telegram_id, ) if not existing["is_active"]: - await update.message.reply_text("Account deactivated. Contact an admin.") # type: ignore[union-attr] + msg = update.message or update.callback_query # type: ignore[union-attr] + await msg.reply_text("Account deactivated. Contact an admin.") return None - return existing + 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, + 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: {kw['subs']}" if kw.get("subs", 1) > 1 else "" + + return ( + f"🔍 {kw['keyword']}\n" + f"━━━━━━━━━━━━━━━━━━━━━\n" + f"{status_icon} | Interval: {kw['interval_minutes']} min\n" + f"Last scrape: {_vienna_time(kw.get('last_scraped_at'))}{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 ──────────────────────────────── + def register_handlers(app: Application) -> None: + # Set the bot's command menu (shown when user types /) + app.bot.set_my_commands([ + {"command": "start", "description": "Open main menu"}, + {"command": "admin", "description": "Admin panel (admins only)"}, + ]) + app.add_handler(CommandHandler("start", start_handler)) - app.add_handler(CommandHandler("add", add_handler)) - app.add_handler(CommandHandler("list", list_handler)) - app.add_handler(CommandHandler("delete", delete_handler)) - app.add_handler(CommandHandler("interval", interval_handler)) - app.add_handler(CommandHandler("stats", stats_handler)) - app.add_handler(CommandHandler("adduser", adduser_handler)) - app.add_handler(CommandHandler("removeuser", removeuser_handler)) - app.add_handler(CommandHandler("users", users_handler)) + app.add_handler(CommandHandler("admin", admin_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 --------------------------------------------------------------- +# ── /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 f"user {update.effective_user.id}" - await update.message.reply_text( # type: ignore[union-attr] - f"Hello {name}! I'll notify you about new willhaben listings.\n\n" - "Available commands:\n" - "/add — Subscribe to a keyword (shared across users)\n" - "/list — List your subscriptions\n" - "/delete — Unsubscribe from a keyword\n" - "/interval — Change scrape interval\n" - "/stats — View your tracking statistics", + name = update.effective_user.first_name or "there" + msg = (update.message or update.callback_query).answer # type: ignore[union-attr] + chat_id = update.effective_chat.id # type: ignore[union-attr] + await context.bot.send_message( + chat_id=chat_id, + text=f"Hello {name}! I'll notify you about new willhaben listings.", + parse_mode="HTML", + reply_markup=_main_menu_keyboard(), ) -# -- /add ----------------------------------------------------------------- +# ── /admin ──────────────────────────────────────────────────────────────── -async def add_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - row = await _require_user(update) +async def admin_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + row = await _require_admin(update) if not row: return - text = (update.message or update.callback_query).text or "" # type: ignore[union-attr] - parts = text.split(" ", 1) - if len(parts) < 2 or not parts[1].strip().strip("'\""): - await update.message.reply_text("Usage: /add ") # type: ignore[union-attr] - return - - keyword = parts[1].strip().strip("'\"") - pool = await get_pool() - - existing = await pool.fetchrow( - "SELECT id, is_active FROM keywords WHERE LOWER(keyword) = LOWER($1)", - keyword, + chat_id = update.effective_chat.id # type: ignore[union-attr] + await context.bot.send_message( + chat_id=chat_id, text="⚙️ Admin Panel", + parse_mode="HTML", reply_markup=_admin_menu_keyboard(), ) - if not existing: + +# ── 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'
"{text}"
\n\n' + f"Interval: 5 min (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
"{text}"
\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( - "INSERT INTO keywords (id, keyword, interval_minutes, is_active) VALUES ($1, $2, 5, true)", - kw_id, - keyword, + "UPDATE keywords SET interval_minutes = $1 WHERE id::text LIKE $2 || '%'", + minutes, kw_id + "%", ) - logger.info("Created new keyword '%s' (%s)", keyword, kw_id) - else: - kw_id = existing["id"] - await pool.execute( - "INSERT INTO keyword_subscriptions (keyword_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", - kw_id, - row["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 - sub_count_row = await pool.fetchrow( - "SELECT COUNT(*) - 1 AS others FROM keyword_subscriptions WHERE keyword_id = $1", - kw_id, - ) - other_count = sub_count_row["others"] - - if other_count > 0: await update.message.reply_text( # type: ignore[union-attr] - f"Tracking \"{keyword}\"\n" - f"Keyword ID: {kw_id}\n" - f"({other_count} other subscriber(s))", - ) - else: - await update.message.reply_text( # type: ignore[union-attr] - f"Tracking \"{keyword}\"\n" - f"Keyword ID: {kw_id}", - ) + f"Interval set to {minutes} min.", parse_mode="HTML") + context.user_data.clear() - logger.info("User %s subscribed to keyword '%s' (%s)", update.effective_user.id, keyword, kw_id) + # ── 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 -# -- /list ---------------------------------------------------------------- - -async def list_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - row = await _require_user(update) - if not row: - return - - pool = await get_pool() - subscriptions = 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", - row["id"], - ) - - if not subscriptions: - await update.message.reply_text("No active subscriptions. Use /add to start.") # type: ignore[union-attr] - return - - lines = [] - for i, s in enumerate(subscriptions, 1): - status = "active" if s["is_active"] else "inactive" - last = s["last_scraped_at"].strftime("%d.%m.%Y %H:%M") if s["last_scraped_at"] else "never" - subs = f" | {s['subs']} subscriber(s)" if s["subs"] > 1 else "" - lines.append( - f"{i}. \"{s['keyword']}\"\n" - f" ID: {s['id']}\n" - f" Status: {status}{subs}\n" - f" Interval: {s['interval_minutes']} min\n" - f" Last scraped: {last}" - ) - - await update.message.reply_text("\n\n".join(lines)) # type: ignore[union-attr] - - -# -- /interval ------------------------------------------------------------ - -async def interval_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - row = await _require_user(update) - if not row: - return - - text = (update.message or update.callback_query).text or "" # type: ignore[union-attr] - parts = text.split() - if len(parts) < 3: - await update.message.reply_text("Usage: /interval ") # type: ignore[union-attr] - return - - keyword_id = parts[1].strip() - try: - uuid.UUID(keyword_id) - except ValueError: - await update.message.reply_text("Invalid keyword ID format.") # type: ignore[union-attr] - return - - try: - minutes = int(parts[2]) - except ValueError: - await update.message.reply_text("Interval must be a number (minutes).") # type: ignore[union-attr] - return - - if minutes < 1 or minutes > 1440: - await update.message.reply_text("Interval must be between 1 and 1440 minutes.") # type: ignore[union-attr] - return - - pool = await get_pool() - - sub_check = await pool.fetchrow( - "SELECT 1 FROM keyword_subscriptions WHERE keyword_id = $1 AND user_id = $2", - keyword_id, - row["id"], - ) - if not sub_check: - await update.message.reply_text("You are not subscribed to this keyword.") # type: ignore[union-attr] - return - - kw_row = await pool.fetchrow( - "SELECT id, interval_minutes FROM keywords WHERE id = $1", - keyword_id, - ) - if not kw_row: - await update.message.reply_text("Keyword not found.") # type: ignore[union-attr] - return - - old_interval = kw_row["interval_minutes"] - await pool.execute( - "UPDATE keywords SET interval_minutes = $1 WHERE id = $2", - minutes, - keyword_id, - ) - - sub_count = await pool.fetchval( - "SELECT COUNT(*) FROM keyword_subscriptions WHERE keyword_id = $1", - keyword_id, - ) - - if sub_count > 1: - await update.message.reply_text( # type: ignore[union-attr] - f"Interval updated: {old_interval} min → {minutes} min\n" - f"(affects all {sub_count} subscriber(s))", - ) - else: - await update.message.reply_text( # type: ignore[union-attr] - f"Interval updated: {old_interval} min → {minutes} min", - ) - - logger.info("User %s changed interval for keyword '%s' to %d min", update.effective_user.id, keyword_id, minutes) - - -# -- /delete -------------------------------------------------------------- - -async def delete_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - row = await _require_user(update) - if not row: - return - - text = (update.message or update.callback_query).text or "" # type: ignore[union-attr] - parts = text.split() - if len(parts) < 2: - await update.message.reply_text("Usage: /delete ") # type: ignore[union-attr] - return - - keyword_id = parts[1].strip() - try: - uuid.UUID(keyword_id) - except ValueError: - await update.message.reply_text("Invalid keyword ID format.") # type: ignore[union-attr] - return - - pool = await get_pool() - - kw_row = await pool.fetchrow( - "SELECT keyword FROM keywords WHERE id = $1", - keyword_id, - ) - if not kw_row: - await update.message.reply_text("Keyword not found.") # type: ignore[union-attr] - return - - result = await pool.execute( - "DELETE FROM keyword_subscriptions WHERE keyword_id = $1 AND user_id = $2", - keyword_id, - row["id"], - ) - - if "0" in result: - await update.message.reply_text("Not subscribed to this keyword.") # type: ignore[union-attr] - return - - remaining = await pool.fetchval( - "SELECT COUNT(*) FROM keyword_subscriptions WHERE keyword_id = $1", - keyword_id, - ) - - if remaining == 0: + user_uuid_val = str(uuid.uuid4()) + pool = await get_pool() await pool.execute( - "UPDATE keywords SET is_active = false WHERE id = $1", - keyword_id, + "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, ) - await update.message.reply_text(f"Unsubscribed from \"{kw_row['keyword']}\"") # type: ignore[union-attr] - logger.info("User %s unsubscribed from keyword '%s' (%s)", update.effective_user.id, kw_row["keyword"], keyword_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 {tg_id} as user.", + ) + except Exception: + pass + + context.user_data.clear() + logger.info("Admin added user %d", tg_id) + + # ── 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 {tg_id}.", + ) + 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 {tg_id}.", + ) + except Exception: + pass + + context.user_data.clear() + logger.info("Admin removed user %d", tg_id) -# -- /stats --------------------------------------------------------------- +# ── callback router (handles ALL inline button clicks) ──────────────────── -async def stats_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - row = await _require_user(update) - if not row: +async def callback_router(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Route all callback queries by prefix pattern.""" + query = update.callback_query + if not query or not query.data: return + await query.answer() # dismiss loading indicator + user = await _require_user(update) + if not user: + 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() - total_keywords = await pool.fetchval( - "SELECT COUNT(DISTINCT ks.keyword_id) " - "FROM keyword_subscriptions ks " - "WHERE ks.user_id = $1", - row["id"], - ) + # ════════════════════════════════════════════════════════════════════ + # MAIN MENU BUTTONS + # ════════════════════════════════════════════════════════════════════ - total_ads = await pool.fetchval( - "SELECT COUNT(*) FROM ads" - ) + if action == "menu": + sub = payload - total_notifications = await pool.fetchval( - "SELECT COUNT(*) FROM notifications WHERE user_id = $1", - row["id"], - ) + if sub == "add": + context.user_data["state"] = "awaiting_keyword" + await query.edit_message_text( + "Send me a search keyword (e.g. rtx 3090)", parse_mode="HTML") - text = ( - f"Your stats:\n\n" - f"Keywords subscribed: {total_keywords}\n" - f"Ads tracked: {total_ads}\n" - f"Notifications sent: {total_notifications}" - ) - await update.message.reply_text(text) # type: ignore[union-attr] + 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 ➕ Add Keyword to start.", parse_mode="HTML") + return -# -- /adduser (admin only) ------------------------------------------------ + 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, + ) -async def adduser_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - admin_row = await _require_admin(update) - if not admin_row: - return + 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 = (update.message or update.callback_query).text or "" # type: ignore[union-attr] - parts = text.split() - if len(parts) < 2: - await update.message.reply_text("Usage: /adduser [admin]") # type: ignore[union-attr] - return + text = ( + "📊 Tracking Statistics\n" + "━━━━━━━━━━━━━━━━━━━━━\n\n" + f"Keywords: {total_kw}\n" + f"Ads indexed: {total_ads}\n" + f"Notifications sent: {total_notifs}" + ) + await query.edit_message_text(text=text, parse_mode="HTML") - try: - telegram_id = int(parts[1]) - except ValueError: - await update.message.reply_text("Invalid Telegram ID (must be numeric).") # type: ignore[union-attr] - return + # ════════════════════════════════════════════════════════════════════ + # ADD KEYWORD FLOW + # ════════════════════════════════════════════════════════════════════ - is_admin = parts[-1].lower() == "admin" if len(parts) >= 3 else False - pool = await get_pool() + 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 ➕ Add Keyword again.") + return - user_uuid = str(uuid.uuid4()) - await pool.execute( - "INSERT INTO users (id, telegram_id, is_admin) VALUES ($1, $2, $3) " - "ON CONFLICT (telegram_id) DO UPDATE SET is_admin = EXCLUDED.is_admin", - user_uuid, - telegram_id, - is_admin, - ) + # 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"] - role = "admin" if is_admin else "user" - await update.message.reply_text( # type: ignore[union-attr] - f"Added Telegram ID {telegram_id} as {role}.", - ) - logger.info("Admin %s added user %d (is_admin=%s)", update.effective_user.id, telegram_id, is_admin) - - -# -- /removeuser (admin only) --------------------------------------------- - -async def removeuser_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - admin_row = await _require_admin(update) - if not admin_row: - return - - text = (update.message or update.callback_query).text or "" # type: ignore[union-attr] - parts = text.split() - if len(parts) < 2: - await update.message.reply_text("Usage: /removeuser ") # type: ignore[union-attr] - return - - try: - telegram_id = int(parts[1]) - except ValueError: - await update.message.reply_text("Invalid Telegram ID (must be numeric).") # type: ignore[union-attr] - return - - pool = await get_pool() - result = await pool.execute( - "DELETE FROM users WHERE telegram_id = $1", - telegram_id, - ) - - if "0" in result: - await update.message.reply_text(f"No user found with Telegram ID {telegram_id}.") # type: ignore[union-attr] - else: - await update.message.reply_text( # type: ignore[union-attr] - f"Removed Telegram ID {telegram_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 "" -# -- /users (admin only) -------------------------------------------------- + await _edit_or_send( + context.bot, chat_id, msg_id, # type: ignore[arg-type] + f'✅ Keyword added!\n\n
"{keyword_text}"
{extra}', + ) + logger.info("User %s subscribed to '%s'", update.effective_user.id, keyword_text) -async def users_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - admin_row = await _require_admin(update) - if not admin_row: - return + elif action == "cancel_add": + await _edit_or_send(context.bot, chat_id, msg_id, # type: ignore[arg-type] + "❌ Keyword not added.") - pool = await get_pool() - users_list = await pool.fetch( - "SELECT telegram_id, username, first_name, is_admin, is_active, created_at " - "FROM users ORDER BY created_at DESC" - ) + # ════════════════════════════════════════════════════════════════════ + # KEYWORD ACTIONS (toggle, edit, remove) + # ════════════════════════════════════════════════════════════════════ - if not users_list: - await update.message.reply_text("No users registered.") # type: ignore[union-attr] - return + 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] - lines = [] - for u in users_list: - role = "[admin]" if u["is_admin"] else "" - status = "active" if u["is_active"] else "inactive" - name = u["username"] or u["first_name"] or str(u["telegram_id"]) - lines.append(f"{u['telegram_id']} | {name} | {status} {role}") + 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 "{kw_row["keyword"]}"', parse_mode="HTML", + reply_markup=_edit_menu_keyboard(_safe_id(sid_full)), + ) - await update.message.reply_text( # type: ignore[union-attr] - "Registered users:\n\n" + "\n".join(lines), - ) + 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( + "Change Interval\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 (1–1440):", parse_mode="HTML") + + elif action == "set_interval": + sub_parts = payload.split(":", 2) + if len(sub_parts) < 3: + return + sid, minutes_str = sub_parts[0], sub_parts[2] + 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 "{kw_row["keyword"]}"?', + 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] + "❌ Keyword removed.") + + 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 == "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") + + 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} {d['telegram_id']} — {name}{role}") + + text = f"Registered users ({len(users_list)})\n\n" + "\n".join(lines) + kb = InlineKeyboardMarkup([ + [InlineKeyboardButton("➕ Add User", callback_data="admin_add"), + InlineKeyboardButton("🗑 Remove User", callback_data="admin_remove")], + ]) + 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") \ No newline at end of file