- 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
1227 lines
52 KiB
Python
1227 lines
52 KiB
Python
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 (1–1440):", 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")],
|
||
]),
|
||
) |