From 7a2db354950f67220b9d3a26ec4ab6028e79ff53 Mon Sep 17 00:00:00 2001 From: cailean Date: Mon, 11 May 2026 11:34:38 +0100 Subject: [PATCH] allow user to be added via id --- README.md | 2 + __pycache__/agent.cpython-312.pyc | Bin 2152 -> 2143 bytes __pycache__/database.cpython-312.pyc | Bin 4289 -> 7948 bytes __pycache__/prompts.cpython-312.pyc | Bin 2372 -> 2363 bytes __pycache__/schemas.cpython-312.pyc | Bin 1659 -> 1650 bytes __pycache__/scraper.cpython-312.pyc | Bin 1484 -> 1475 bytes bot.py | 69 +++++++++++++++-- database.py | 108 ++++++++++++++++++++++++++- 8 files changed, 170 insertions(+), 9 deletions(-) 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 59a6e04037bd166fe79b5fafcdfd26af38b3a634..77f0723ce844e250362af14f9bb2bd817897e574 100644 GIT binary patch delta 48 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 jEH+{HBX=YAul71{^Uco32)al94(wk00p5=ts=? delta 431 zcmXv}ze~eF6wcK~YumKZ3R1h zfs8_5#`~6;Jk&bGO1^8W%{132VXC;--VwEtVXl42yeOa@*g}rr5tm7{K+U)8BANNv LCYz)LZKQtzI-PsE diff --git a/__pycache__/prompts.cpython-312.pyc b/__pycache__/prompts.cpython-312.pyc index 0e1a3b31d8d5cb8ec407d8e3d928c0593dfbea74..60d77e3b3929bbffb5371dcc0d97c313661a80b5 100644 GIT binary patch delta 47 zcmX>iv|EVlG%qg~0}wRL-pF-J!=k diff --git a/__pycache__/schemas.cpython-312.pyc b/__pycache__/schemas.cpython-312.pyc index 58410ba66eceadebb453c3787c755186fa7fd132..dc9d56b0b47158d7eba1bfd267dffd90f3a0fc60 100644 GIT binary patch delta 48 zcmey(^NEM+G%qg~0}wRL-pD1xB(CLb6%$&VT2vg9oS2!D8sm~$oL!P%u-T2tlm!4@ Cq7LN% delta 57 zcmeyw^P7k3G%qg~0}vd_-^eAyq+sl96%$&VT2vg9oS2!D8sm~$oL!P%5ED?8pOu{E1J4qu diff --git a/__pycache__/scraper.cpython-312.pyc b/__pycache__/scraper.cpython-312.pyc index f99504b171f73b47048f367c7d7330c6875a48a9..2c9475f652ed54e81a4aa8c1e65152a80cd633c5 100644 GIT binary patch delta 48 zcmX@ZeVCi;G%qg~0}wRL-pIwlBCh3Z6%$&VT2vg9oS2!D8sm~$oL!P%u-S-Z79#*l CQ4YfZ delta 57 zcmX@ieTJLsG%qg~0}xnOZ{*@&Q80G4iU}=FEh>&lPRz_njd4jW&MwI>hzTgl&q_@$ LDc-EfGK&!a*F6#o 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