diff --git a/README.md b/README.md index ed0d550..f66016d 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ POCKETBASE_ADMIN_PASSWORD=secret - Notes: - `ALLOWED_USERS` should be a comma-separated list of Telegram user IDs (no brackets). + - `ALLOWED_USERS` acts as the bootstrap allowlist; the bot also checks the PocketBase `Telegram` collection for persisted access. - The bot reads `TG_TOKEN` and `ALLOWED_USERS` from the environment. 6. Ollama (local LLM) setup @@ -99,6 +100,7 @@ python main.py ``` The bot listens for commands: +- `/add ` — grant a Telegram user ID access through the `Telegram` collection - `/op ` — parse an opportunity - `/ev ` — parse an event - If you send a URL directly in chat, the bot will ask whether to process it as an event or an opportunity using buttons. diff --git a/__pycache__/agent.cpython-312.pyc b/__pycache__/agent.cpython-312.pyc index 59a6e04..77f0723 100644 Binary files a/__pycache__/agent.cpython-312.pyc and b/__pycache__/agent.cpython-312.pyc differ diff --git a/__pycache__/database.cpython-312.pyc b/__pycache__/database.cpython-312.pyc index f9c3d3c..457c575 100644 Binary files a/__pycache__/database.cpython-312.pyc and b/__pycache__/database.cpython-312.pyc differ diff --git a/__pycache__/prompts.cpython-312.pyc b/__pycache__/prompts.cpython-312.pyc index 0e1a3b3..60d77e3 100644 Binary files a/__pycache__/prompts.cpython-312.pyc and b/__pycache__/prompts.cpython-312.pyc differ diff --git a/__pycache__/schemas.cpython-312.pyc b/__pycache__/schemas.cpython-312.pyc index 58410ba..dc9d56b 100644 Binary files a/__pycache__/schemas.cpython-312.pyc and b/__pycache__/schemas.cpython-312.pyc differ diff --git a/__pycache__/scraper.cpython-312.pyc b/__pycache__/scraper.cpython-312.pyc index f99504b..2c9475f 100644 Binary files a/__pycache__/scraper.cpython-312.pyc and b/__pycache__/scraper.cpython-312.pyc differ diff --git a/bot.py b/bot.py index 10dd58b..38a7c1d 100644 --- a/bot.py +++ b/bot.py @@ -13,7 +13,7 @@ from telegram.ext import ApplicationBuilder, CommandHandler, MessageHandler, fil # Import your existing logic from agent import parse_page -from database import upload_entry +from database import add_telegram_user_id, get_telegram_user_ids, upload_entry from scraper import get_clean_content as _get_clean_content @@ -45,12 +45,12 @@ TOKEN = os.getenv("TG_TOKEN") _allowed_env = os.getenv("ALLOWED_USERS", "") if _allowed_env: try: - ALLOWED_IDS = [int(x.strip()) for x in _allowed_env.split(',') if x.strip()] + ALLOWED_IDS = {int(x.strip()) for x in _allowed_env.split(',') if x.strip()} except Exception: logging.warning("Failed to parse ALLOWED_USERS from .env; defaulting to empty list") - ALLOWED_IDS = [] + ALLOWED_IDS = set() else: - ALLOWED_IDS = [] + ALLOWED_IDS = set() if not TOKEN: logging.warning("TG_TOKEN not set in .env; bot will not start without a token") @@ -95,6 +95,17 @@ def retry(max_attempts=3, backoff_factor=2, initial_delay=1): task_queue = asyncio.Queue() +def is_authorized_user(user_id: int) -> bool: + if user_id in ALLOWED_IDS: + return True + + try: + return user_id in set(get_telegram_user_ids()) + except Exception as exc: + logging.warning("Failed to fetch Telegram access list: %s", exc) + return False + + def is_http_url(text: str) -> bool: return bool(re.match(r'^https?://\S+$', text.strip())) @@ -188,19 +199,56 @@ async def process_link(update, context, source_value, entry_type="opportunity", # --- Handlers --- async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): - if update.effective_user.id not in ALLOWED_IDS: + if not is_authorized_user(update.effective_user.id): return await update.message.reply_text( "Welcome! I can extract arts opportunities and events.\n\n" "📋 **Commands:**\n" + "/add - Allow a Telegram user ID access\n" "/op - Extract an opportunity\n" "/ev - Extract an event\n\n" "You can also send a URL directly and I will ask whether to process it as an event or opportunity." ) + +async def handle_add_user(update: Update, context: ContextTypes.DEFAULT_TYPE): + requester_id = update.effective_user.id + if not is_authorized_user(requester_id): + await update.message.reply_text("Unauthorized. User ID needs to be added!") + return + + if not context.args: + await update.message.reply_text("Usage: /add ") + return + + raw_user_id = context.args[0].strip() + try: + user_id = int(raw_user_id) + except ValueError: + await update.message.reply_text("Please provide a valid numeric Telegram user ID. Usage: /add ") + return + + if user_id in ALLOWED_IDS: + await update.message.reply_text(f"{user_id} already has access.") + return + + try: + existing_ids = set(get_telegram_user_ids()) + if user_id in existing_ids: + ALLOWED_IDS.add(user_id) + await update.message.reply_text(f"{user_id} already has access.") + return + + add_telegram_user_id(user_id) + ALLOWED_IDS.add(user_id) + await update.message.reply_text(f"Added {user_id} to Telegram access.") + except Exception as exc: + logging.exception("Failed to add Telegram user ID %s", user_id) + await update.message.reply_text(f"Failed to add {user_id}: {exc}") + async def handle_opportunity(update: Update, context: ContextTypes.DEFAULT_TYPE): user_id = update.effective_user.id - if user_id not in ALLOWED_IDS: + if not is_authorized_user(user_id): await update.message.reply_text("Unauthorized. User ID needs to be added!") return @@ -220,7 +268,7 @@ async def handle_opportunity(update: Update, context: ContextTypes.DEFAULT_TYPE) async def handle_event(update: Update, context: ContextTypes.DEFAULT_TYPE): user_id = update.effective_user.id - if user_id not in ALLOWED_IDS: + if not is_authorized_user(user_id): await update.message.reply_text("Unauthorized. User ID needs to be added!") return @@ -239,7 +287,7 @@ async def handle_event(update: Update, context: ContextTypes.DEFAULT_TYPE): await task_queue.put((update, context, input_text, "event", source_kind)) async def handle_followup_text(update: Update, context: ContextTypes.DEFAULT_TYPE): - if update.effective_user.id not in ALLOWED_IDS: + if not is_authorized_user(update.effective_user.id): return if not context.user_data.get('awaiting_save_url'): @@ -279,6 +327,10 @@ async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): query = update.callback_query await query.answer() + if not is_authorized_user(query.from_user.id): + await query.edit_message_text("Unauthorized. User ID needs to be added!") + return + if query.data.startswith('choose_type:'): pending_url = context.user_data.get('pending_url_to_process') if not pending_url: @@ -325,6 +377,7 @@ async def _main(): application = ApplicationBuilder().token(TOKEN).build() application.add_handler(CommandHandler("start", start)) + application.add_handler(CommandHandler("add", handle_add_user)) application.add_handler(CommandHandler("op", handle_opportunity)) application.add_handler(CommandHandler("ev", handle_event)) application.add_handler(MessageHandler(filters.TEXT & (~filters.COMMAND), handle_followup_text)) diff --git a/database.py b/database.py index 558d56c..c275385 100644 --- a/database.py +++ b/database.py @@ -9,6 +9,112 @@ load_dotenv() pb = PocketBase(os.getenv('POCKETBASE_URL')) admin_data = pb.admins.auth_with_password(os.getenv('POCKETBASE_ADMIN_EMAIL'), os.getenv('POCKETBASE_ADMIN_PASSWORD')) show_debug_msg = False +TELEGRAM_COLLECTION_NAME = 'Telegram' + + +def _record_value(record, key, default=None): + def _lookup(mapping): + if not isinstance(mapping, dict): + return default + + if key in mapping: + return mapping.get(key, default) + + lower_key = key.lower() + for existing_key, existing_value in mapping.items(): + if str(existing_key).lower() == lower_key: + return existing_value + + return default + + if isinstance(record, dict): + return _lookup(record) + + if hasattr(record, 'get'): + try: + value = record.get(key, default) + if value is not default: + return value + except Exception: + pass + + if hasattr(record, 'data'): + try: + value = _lookup(record.data) + if value is not default: + return value + except Exception: + pass + + if hasattr(record, 'model_dump'): + try: + value = _lookup(record.model_dump()) + if value is not default: + return value + except Exception: + pass + + if hasattr(record, 'to_dict'): + try: + value = _lookup(record.to_dict()) + if value is not default: + return value + except Exception: + pass + + if hasattr(record, '__dict__'): + value = _lookup(record.__dict__) + if value is not default: + return value + + if hasattr(record, key): + return getattr(record, key) + + return default + + +def _normalize_telegram_id(value): + if value is None: + return None + + try: + return int(str(value).strip()) + except (TypeError, ValueError): + return None + + +def get_telegram_user_ids(): + records = pb.collection(TELEGRAM_COLLECTION_NAME).get_full_list() + telegram_ids = set() + + for record in records: + raw_telegram_id = _record_value(record, 'TGID') + telegram_id = _normalize_telegram_id(raw_telegram_id) + if telegram_id is not None: + telegram_ids.add(telegram_id) + + record_id = _normalize_telegram_id(_record_value(record, 'id')) + if record_id is not None: + telegram_ids.add(record_id) + + return sorted(telegram_ids) + + +def add_telegram_user_id(user_id: int): + collection = pb.collection(TELEGRAM_COLLECTION_NAME) + payload_candidates = [ + {'TGID': user_id}, + {'id': str(user_id)}, + ] + + last_error = None + for payload in payload_candidates: + try: + return collection.create(payload) + except Exception as exc: + last_error = exc + + raise last_error def convert_datetime_to_pocketbase(date_time_str): """ @@ -52,7 +158,7 @@ def upload_entry(data, entry_type='opportunity', url=None): entry_type: 'opportunity' or 'event' url: The source URL of the entry """ - print(f"[DEBUG] Uploading {entry_type} entry. Data: {data["title"]}") + print(f"[DEBUG] Uploading {entry_type} entry. Data: {data['title']}") data = dict(data) # Add URL to data if provided