feat: python worker (bot, scraper, notifier, scheduler)
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
import os
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import asyncpg
|
||||
from telegram import Update
|
||||
from telegram.ext import ContextTypes, CommandHandler, ExtBot
|
||||
|
||||
from db import get_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _admin_ids() -> set[int]:
|
||||
raw = os.getenv("ADMIN_TELEGRAM_IDS", "")
|
||||
return {int(x.strip()) for x in raw.split(",") if x.strip()}
|
||||
|
||||
|
||||
async def _require_user(update: Update, context: ContextTypes.DEFAULT_TYPE) -> asyncpg.Row | None: # type: ignore[name-defined]
|
||||
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"]:
|
||||
await update.message.reply_text("Access denied. This bot requires an invitation.") # type: ignore[union-attr]
|
||||
return None
|
||||
return row
|
||||
|
||||
|
||||
def register_handlers(bot: ExtBot) -> None:
|
||||
bot.add_handler(CommandHandler("start", start_handler))
|
||||
bot.add_handler(CommandHandler("add", add_handler))
|
||||
bot.add_handler(CommandHandler("list", list_handler))
|
||||
bot.add_handler(CommandHandler("pause", pause_handler))
|
||||
bot.add_handler(CommandHandler("resume", resume_handler))
|
||||
bot.add_handler(CommandHandler("delete", delete_handler))
|
||||
bot.add_handler(CommandHandler("stats", stats_handler))
|
||||
bot.add_handler(CommandHandler("users", users_handler))
|
||||
|
||||
|
||||
# -- /start ---------------------------------------------------------------
|
||||
|
||||
|
||||
async def start_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
row = await _require_user(update, context)
|
||||
if not row:
|
||||
return
|
||||
|
||||
user = update.effective_user
|
||||
name = user.first_name or f"user {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 <keyword> — Start tracking a keyword\n"
|
||||
"/list — List your active searches\n"
|
||||
"/pause <query_id> — Pause a search\n"
|
||||
"/resume <query_id> — Resume a paused search\n"
|
||||
"/delete <query_id> — Delete a search\n"
|
||||
"/stats — View your tracking statistics",
|
||||
)
|
||||
|
||||
|
||||
# -- /add -----------------------------------------------------------------
|
||||
|
||||
|
||||
async def add_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
row = await _require_user(update, context)
|
||||
if not row:
|
||||
return
|
||||
|
||||
text = (update.message or update.callback_query).text or "" # type: ignore[union-attr]
|
||||
keyword = text.split(" ", 1)
|
||||
if len(keyword) < 2 or not keyword[1].strip().strip("'\""):
|
||||
await update.message.reply_text("Usage: /add <keyword>") # type: ignore[union-attr]
|
||||
return
|
||||
|
||||
keyword = keyword[1].strip().strip("'\"")
|
||||
if not keyword:
|
||||
await update.message.reply_text("Please provide a non-empty keyword.") # type: ignore[union-attr]
|
||||
return
|
||||
|
||||
pool = await get_pool()
|
||||
existing = await pool.fetchrow(
|
||||
"SELECT id FROM search_queries WHERE user_id = $1 AND keyword = $2",
|
||||
row["id"],
|
||||
keyword,
|
||||
)
|
||||
if existing:
|
||||
await update.message.reply_text(f"Query already exists: {keyword}") # type: ignore[union-attr]
|
||||
return
|
||||
|
||||
interval = int(os.getenv("DEFAULT_INTERVAL_MINUTES", "60"))
|
||||
query_id = str(uuid.uuid4())
|
||||
await pool.execute(
|
||||
"INSERT INTO search_queries (id, user_id, keyword, interval_minutes) VALUES ($1, $2, $3, $4)",
|
||||
query_id,
|
||||
row["id"],
|
||||
keyword,
|
||||
interval,
|
||||
)
|
||||
|
||||
await update.message.reply_text( # type: ignore[union-attr]
|
||||
f"✓ Tracking \"{keyword}\"\n"
|
||||
f"Query ID: {query_id}\n"
|
||||
f"Check interval: every {interval} minutes",
|
||||
)
|
||||
logger.info("User %s added query '%s' (%s)", update.effective_user.id, keyword, query_id)
|
||||
|
||||
|
||||
# -- /list ----------------------------------------------------------------
|
||||
|
||||
|
||||
async def list_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
row = await _require_user(update, context)
|
||||
if not row:
|
||||
return
|
||||
|
||||
pool = await get_pool()
|
||||
queries = await pool.fetch(
|
||||
"SELECT id, keyword, interval_minutes, is_active, last_scraped_at "
|
||||
"FROM search_queries WHERE user_id = $1 ORDER BY created_at DESC",
|
||||
row["id"],
|
||||
)
|
||||
|
||||
if not queries:
|
||||
await update.message.reply_text("No active searches. Use /add <keyword> to start.") # type: ignore[union-attr]
|
||||
return
|
||||
|
||||
lines = []
|
||||
for i, q in enumerate(queries, 1):
|
||||
status = "active" if q["is_active"] else "paused"
|
||||
last = q["last_scraped_at"].strftime("%d.%m.%Y %H:%M") if q["last_scraped_at"] else "never"
|
||||
lines.append(
|
||||
f"{i}. \"{q['keyword']}\"\n"
|
||||
f" ID: {q['id']}\n"
|
||||
f" Interval: {q['interval_minutes']} min | Status: {status}\n"
|
||||
f" Last scraped: {last}"
|
||||
)
|
||||
|
||||
await update.message.reply_text("\n\n".join(lines)) # type: ignore[union-attr]
|
||||
|
||||
|
||||
# -- /pause ---------------------------------------------------------------
|
||||
|
||||
|
||||
async def pause_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
row = await _require_user(update, context)
|
||||
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: /pause <query_id>") # type: ignore[union-attr]
|
||||
return
|
||||
|
||||
query_id = parts[1].strip()
|
||||
try:
|
||||
uuid.UUID(query_id)
|
||||
except ValueError:
|
||||
await update.message.reply_text("Invalid query ID format.") # type: ignore[union-attr]
|
||||
return
|
||||
|
||||
pool = await get_pool()
|
||||
result = await pool.execute(
|
||||
"UPDATE search_queries SET is_active = false WHERE id = $1 AND user_id = $2",
|
||||
query_id,
|
||||
row["id"],
|
||||
)
|
||||
|
||||
if "1" not in result:
|
||||
await update.message.reply_text("Query not found or access denied.") # type: ignore[union-attr]
|
||||
return
|
||||
|
||||
await update.message.reply_text(f"✓ Query paused: {query_id}") # type: ignore[union-attr]
|
||||
logger.info("User %s paused query %s", update.effective_user.id, query_id)
|
||||
|
||||
|
||||
# -- /resume --------------------------------------------------------------
|
||||
|
||||
|
||||
async def resume_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
row = await _require_user(update, context)
|
||||
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: /resume <query_id>") # type: ignore[union-attr]
|
||||
return
|
||||
|
||||
query_id = parts[1].strip()
|
||||
try:
|
||||
uuid.UUID(query_id)
|
||||
except ValueError:
|
||||
await update.message.reply_text("Invalid query ID format.") # type: ignore[union-attr]
|
||||
return
|
||||
|
||||
pool = await get_pool()
|
||||
result = await pool.execute(
|
||||
"UPDATE search_queries SET is_active = true WHERE id = $1 AND user_id = $2",
|
||||
query_id,
|
||||
row["id"],
|
||||
)
|
||||
|
||||
if "1" not in result:
|
||||
await update.message.reply_text("Query not found or access denied.") # type: ignore[union-attr]
|
||||
return
|
||||
|
||||
await update.message.reply_text(f"✓ Query resumed: {query_id}") # type: ignore[union-attr]
|
||||
logger.info("User %s resumed query %s", update.effective_user.id, query_id)
|
||||
|
||||
|
||||
# -- /delete --------------------------------------------------------------
|
||||
|
||||
|
||||
async def delete_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
row = await _require_user(update, context)
|
||||
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 <query_id>") # type: ignore[union-attr]
|
||||
return
|
||||
|
||||
query_id = parts[1].strip()
|
||||
try:
|
||||
uuid.UUID(query_id)
|
||||
except ValueError:
|
||||
await update.message.reply_text("Invalid query ID format.") # type: ignore[union-attr]
|
||||
return
|
||||
|
||||
pool = await get_pool()
|
||||
result = await pool.execute(
|
||||
"DELETE FROM search_queries WHERE id = $1 AND user_id = $2",
|
||||
query_id,
|
||||
row["id"],
|
||||
)
|
||||
|
||||
if "1" not in result:
|
||||
await update.message.reply_text("Query not found or access denied.") # type: ignore[union-attr]
|
||||
return
|
||||
|
||||
await update.message.reply_text(f"✓ Query deleted: {query_id}") # type: ignore[union-attr]
|
||||
logger.info("User %s deleted query %s", update.effective_user.id, query_id)
|
||||
|
||||
|
||||
# -- /stats ---------------------------------------------------------------
|
||||
|
||||
|
||||
async def stats_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
row = await _require_user(update, context)
|
||||
if not row:
|
||||
return
|
||||
|
||||
pool = await get_pool()
|
||||
|
||||
total_queries = await pool.fetchval(
|
||||
"SELECT COUNT(*) FROM search_queries WHERE user_id = $1",
|
||||
row["id"],
|
||||
)
|
||||
|
||||
total_ads = await pool.fetchval(
|
||||
"SELECT COUNT(DISTINCT qa.ad_id) "
|
||||
"FROM query_ads qa JOIN search_queries sq ON qa.search_query_id = sq.id "
|
||||
"WHERE sq.user_id = $1",
|
||||
row["id"],
|
||||
)
|
||||
|
||||
total_notifications = await pool.fetchval(
|
||||
"SELECT COUNT(*) FROM notifications WHERE user_id = $1",
|
||||
row["id"],
|
||||
)
|
||||
|
||||
text = (
|
||||
f"📊 Your stats:\n\n"
|
||||
f"Queries: {total_queries}\n"
|
||||
f"Ads tracked: {total_ads}\n"
|
||||
f"Notifications sent: {total_notifications}"
|
||||
)
|
||||
await update.message.reply_text(text) # type: ignore[union-attr]
|
||||
|
||||
|
||||
# -- /users (admin only) --------------------------------------------------
|
||||
|
||||
|
||||
async def users_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
if update.effective_user.id not in _admin_ids():
|
||||
await update.message.reply_text("Unauthorized") # type: ignore[union-attr]
|
||||
return
|
||||
|
||||
pool = await get_pool()
|
||||
users = await pool.fetch(
|
||||
"SELECT telegram_id, username, first_name, is_active, created_at FROM users ORDER BY created_at DESC"
|
||||
)
|
||||
|
||||
if not users:
|
||||
await update.message.reply_text("No users registered.") # type: ignore[union-attr]
|
||||
return
|
||||
|
||||
lines = []
|
||||
for u in users:
|
||||
status = "active" if u["is_active"] else "inactive"
|
||||
name = u["username"] or u["first_name"] or "unknown"
|
||||
lines.append(f"• {u['telegram_id']} | @{name} | {status}")
|
||||
|
||||
await update.message.reply_text("👥 Registered users:\n\n" + "\n".join(lines)) # type: ignore[union-attr]
|
||||
Reference in New Issue
Block a user