-
Notifications
You must be signed in to change notification settings - Fork 0
Security Blocklist + Rate Limiting Features #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
6735b9b
d208f85
c7baf8d
791331d
fd0f334
c8cc213
d7efb95
25875f9
1b9d931
64de0bc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| import firebase_admin | ||
| from firebase_admin import credentials, firestore | ||
| import logging | ||
| import os, json | ||
|
|
||
| FIREBASE_CREDENTIALS_JSON = os.environ.get("FIREBASE_CREDENTIALS_JSON") | ||
|
|
||
| if not FIREBASE_CREDENTIALS_JSON: | ||
| raise RuntimeError("Missing FIREBASE_CREDENTIALS_JSON environment variable") | ||
|
|
||
| cred = credentials.Certificate(json.loads(FIREBASE_CREDENTIALS_JSON)) | ||
| firebase_admin.initialize_app(cred) | ||
| db = firestore.client() | ||
| logging.basicConfig(level=logging.INFO) | ||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| def initialize_blacklist_config(default_ttl_seconds=3600, initial_blocked_numbers=None): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. blacklist/blocklist |
||
| """ | ||
| Creates or updates everything under the same 'blocked_numbers' collection. | ||
| - blocked_numbers/_config : stores cache TTL and other metadata | ||
| - blocked_numbers/<phone_number> : empty doc or metadata for each blocked user | ||
| """ | ||
| blk_ref = db.collection('blocked_numbers') | ||
|
|
||
| # 1. Save the cache TTL under a special _config document | ||
| config_ref = blk_ref.document('_config') | ||
| config_ref.set({ | ||
| "cache_ttl_seconds": default_ttl_seconds | ||
| }, merge=True) | ||
|
|
||
| logger.info(f"[initialize_blacklist_config] Set cache_ttl_seconds={default_ttl_seconds}") | ||
|
|
||
| # 2. Optionally preload blocked numbers | ||
| if initial_blocked_numbers: | ||
| for num in initial_blocked_numbers: | ||
| blk_ref.document(num).set({}) # Empty doc = blocked | ||
| logger.info(f"[initialize_blacklist_config] Added blocked number: {num}") | ||
|
|
||
| if __name__ == "__main__": | ||
| initialize_blacklist_config( | ||
| default_ttl_seconds=3600, # 1-hour cache TTL | ||
| initial_blocked_numbers=[ | ||
| "+whatsapp:131xxx", | ||
| "+whatsapp:131xxx", | ||
| "whatsapp:" # add more as needed | ||
| ] | ||
| ) | ||
|
|
||
| # Optional verification | ||
| doc = db.collection("blocked_numbers").document("_config").get() | ||
| print("Config exists:", doc.exists) | ||
| if doc.exists: | ||
| print("Data:", doc.to_dict()) | ||
| else: | ||
| print("⚠️ Still missing – check credentials or Firestore project ID.") | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
|
|
||
| import time | ||
| import pytest | ||
|
|
||
| from app.utils import blocklist_helpers | ||
|
|
||
|
|
||
| def test_default_ttl_is_int(): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure how much value this test is adding |
||
| ttl = blocklist_helpers._get_cache_ttl() | ||
| assert isinstance(ttl, int) | ||
| assert ttl > 0 | ||
|
|
||
|
|
||
| def test_cache_update_logic(monkeypatch): | ||
| blocklist_helpers._last_ttl_fetch = 0 | ||
|
|
||
| class FakeDoc: | ||
| def __init__(self, exists=True): | ||
| self.exists = exists | ||
| def to_dict(self): | ||
| return {"cache_ttl_seconds": 42} | ||
|
|
||
| class FakeDB: | ||
| def collection(self, name): | ||
| assert name == "system_settings" or name == "blocked_numbers" | ||
| return self | ||
| def document(self, name): | ||
| return self | ||
| def get(self): | ||
| return FakeDoc() | ||
|
|
||
| monkeypatch.setattr(blocklist_helpers, "db", FakeDB()) | ||
|
|
||
| ttl = blocklist_helpers._get_cache_ttl() | ||
| assert ttl == 42, "TTL should update from Firestore mock" | ||
|
|
||
|
|
||
| def test_cache_reuse(monkeypatch): | ||
| """Ensures cached TTL is reused within refresh interval.""" | ||
| blocklist_helpers._ttl_value = 99 | ||
| blocklist_helpers._last_ttl_fetch = time.time() | ||
|
|
||
| class FakeDB: | ||
| def collection(self, *_): | ||
| raise AssertionError("Firestore should not be called") | ||
| monkeypatch.setattr(blocklist_helpers, "db", FakeDB()) | ||
|
|
||
| ttl = blocklist_helpers._get_cache_ttl() | ||
| assert ttl == 99 | ||
|
|
||
|
|
||
| def test_is_blocked_number_caching(monkeypatch): | ||
| """Tests that cached phone results are reused correctly.""" | ||
| fake_phone = "12345" | ||
| now = time.time() | ||
| blocklist_helpers._cache[fake_phone] = {"value": True, "time": now} | ||
|
|
||
| class FakeDB: | ||
| def collection(self, *_): | ||
| raise AssertionError("Firestore should not be called") | ||
| monkeypatch.setattr(blocklist_helpers, "db", FakeDB()) | ||
|
|
||
| result = blocklist_helpers.is_blocked_number(fake_phone) | ||
| assert result is True | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -29,6 +29,8 @@ | |
| from app.utils.validators import _norm | ||
| from app.utils.validators import normalize_event_path | ||
|
|
||
| from app.utils.blocklist_helpers import is_blocked_number, get_interaction_limit | ||
|
|
||
|
|
||
|
|
||
| def is_second_round_enabled(event_id: str) -> bool: | ||
|
|
@@ -69,9 +71,13 @@ async def reply_followup(Body: str, From: str, MediaUrl0: str = None): | |
|
|
||
| logger.info(f"Received message from {From} with body '{Body}' and media URL {MediaUrl0}") | ||
|
|
||
| # Normalize phone number | ||
| normalized_phone = From.replace("+", "").replace("-", "").replace(" ", "") | ||
|
|
||
| if is_blocked_number(normalized_phone): | ||
| logger.warning(f"[Blacklist] Ignoring message from blocked number: {normalized_phone}") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. blacklist/blocklist (and in several other places) |
||
| return Response(status_code=200) | ||
|
|
||
|
|
||
| # Step 1: Retrieve or initialize user tracking document | ||
| user_tracking_ref = db.collection('user_event_tracking').document(normalized_phone) | ||
| user_tracking_doc = user_tracking_ref.get() | ||
|
|
@@ -746,8 +752,20 @@ def process_second_round(transaction, ref, user_msg, sr_reply=None): | |
|
|
||
| data = event_doc.to_dict() | ||
| interactions = data.get('interactions', []) | ||
| if len(interactions) >= 450: | ||
| send_message(From, "You have reached your interaction limit with AOI. Please contact AOI for further assistance.") | ||
| interaction_limit = get_interaction_limit(current_event_id) | ||
| if len(interactions) >= interaction_limit: | ||
| logger.info(f"[FollowUpMode] {normalized_phone} reached interaction limit " | ||
| f"({len(interactions)} / {interaction_limit}) for {current_event_id}") | ||
| # Log event for moderation | ||
explomind1 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| db.collection("users_exceeding_limit").document(normalized_phone).set({ | ||
| "phone": normalized_phone, | ||
| "event_id": current_event_id, | ||
| "timestamp": datetime.utcnow().isoformat(), | ||
| "total_interactions": len(interactions), | ||
| "limit_used": interaction_limit | ||
| }, merge=True) | ||
|
|
||
| send_message(From, f"You have reached your interaction limit ({interaction_limit}) for this event. Please contact AOI for assistance.") | ||
| return Response(status_code=200) | ||
|
|
||
| # Send user prompt to LLM | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,7 +11,8 @@ | |
| from pydub import AudioSegment | ||
| from fastapi import Response | ||
| from app.deliberation.second_round_agent import run_second_round_for_user | ||
|
|
||
| from app.utils.blocklist_helpers import get_interaction_limit, is_blocked_number | ||
| import random | ||
|
|
||
| from config.config import ( | ||
| db, logger, client, twilio_client, | ||
|
|
@@ -35,7 +36,8 @@ | |
| from app.utils.validators import _norm | ||
| from app.utils.validators import normalize_event_path | ||
|
|
||
|
|
||
| DEFAULT_MODEL = os.getenv("DEFAULT_MODEL", "gpt-4o-mini") | ||
| FALLBACK_MODEL = os.getenv("FALLBACK_MODEL", "gpt-4.1-mini") | ||
|
|
||
| def is_second_round_enabled(event_id: str) -> bool: | ||
| """Return True iff info.second_round_claims_source.enabled is truthy.""" | ||
|
|
@@ -65,13 +67,14 @@ def is_second_round_enabled(event_id: str) -> bool: | |
|
|
||
|
|
||
| async def reply_listener(Body: str, From: str, MediaUrl0: str = None): | ||
|
|
||
|
|
||
| logger.info(f"Received message from {From} with body '{Body}' and media URL {MediaUrl0}") | ||
|
|
||
| # Normalize phone number | ||
| normalized_phone = From.replace("+", "").replace("-", "").replace(" ", "") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Feels like this snippet should probably be a utility function since it's repeated a few times. |
||
|
|
||
| if is_blocked_number(normalized_phone): | ||
| logger.warning(f"[Blacklist] Ignoring message from blocked number: {normalized_phone}") | ||
| return Response(status_code=200) | ||
|
|
||
| # Step 1: Retrieve or initialize user tracking document | ||
| user_tracking_ref = db.collection('user_event_tracking').document(normalized_phone) | ||
| user_tracking_doc = user_tracking_ref.get() | ||
|
|
@@ -742,8 +745,22 @@ def process_second_round(transaction, ref, user_msg, sr_reply=None): | |
|
|
||
| data = event_doc.to_dict() | ||
| interactions = data.get('interactions', []) | ||
| if len(interactions) >= 450: | ||
| send_message(From, "You have reached your interaction limit with AOI. Please contact AOI for further assistance.") | ||
|
|
||
|
|
||
| interaction_limit = get_interaction_limit(current_event_id) | ||
| if len(interactions) >= interaction_limit: | ||
explomind1 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| logger.info(f"[Listener Mode] {normalized_phone} reached interaction limit " | ||
| f"({len(interactions)} / {interaction_limit}) for {current_event_id}") | ||
| # Log event for moderation | ||
| db.collection("users_exceeding_limit").document(normalized_phone).set({ | ||
| "phone": normalized_phone, | ||
| "event_id": current_event_id, | ||
| "timestamp": datetime.utcnow().isoformat(), | ||
| "total_interactions": len(interactions), | ||
| "limit_used": interaction_limit | ||
| }, merge=True) | ||
|
|
||
| send_message(From, f"You have reached your interaction limit ({interaction_limit}) for this event. Please contact AOI for assistance.") | ||
| return Response(status_code=200) | ||
|
|
||
| # Send user prompt to LLM | ||
|
|
@@ -758,20 +775,97 @@ def process_second_round(transaction, ref, user_msg, sr_reply=None): | |
| 'interactions': firestore.ArrayUnion([{'message': Body}]) | ||
| }) | ||
|
|
||
| run = client.beta.threads.runs.create_and_poll( | ||
| thread_id=thread.id, | ||
| assistant_id=assistant_id, | ||
| instructions=event_instructions | ||
| ) | ||
| try: | ||
| # Attempt to fetch model configuration from Firestore | ||
| event_info_ref = db.collection(normalize_event_path(current_event_id)).document("info") | ||
| event_info_doc = event_info_ref.get() | ||
|
|
||
| # Pre-initialize with the environment or constant default | ||
| default_model = DEFAULT_MODEL | ||
| if event_info_doc.exists: | ||
| event_info_data = event_info_doc.to_dict() | ||
| default_model = event_info_data.get("default_model", default_model) | ||
|
|
||
| logger.info(f"[LLM Config] Using model from Firestore: {default_model}") | ||
|
|
||
| except Exception as e: | ||
| logger.error(f"[LLM Config] Failed to fetch model from Firestore, defaulting to {DEFAULT_MODEL}: {e}") | ||
| default_model = DEFAULT_MODEL | ||
|
|
||
|
|
||
| try: | ||
| # Primary model attempt | ||
| logger.info(f"[LLM Run] Starting primary run with model: {default_model}") | ||
|
|
||
| run = client.beta.threads.runs.create_and_poll( | ||
| thread_id=thread.id, | ||
| assistant_id=assistant_id, | ||
| instructions=event_instructions, | ||
| model=default_model | ||
| ) | ||
|
|
||
| logger.info(f"[LLM Debug] Primary run status: {getattr(run, 'status', 'N/A')}") | ||
|
|
||
| # Fallback if the primary model failed or didn’t complete | ||
| if run.status != "completed": | ||
| logger.warning(f"[LLM Fallback] Model {default_model} failed, retrying with {FALLBACK_MODEL}") | ||
|
|
||
| if hasattr(run, 'last_error'): | ||
| logger.error(f"[LLM Debug] last_error (primary): {run.last_error}") | ||
| if hasattr(run, 'incomplete_details'): | ||
| logger.error(f"[LLM Debug] incomplete_details (primary): {run.incomplete_details}") | ||
|
|
||
| run = client.beta.threads.runs.create_and_poll( | ||
| thread_id=thread.id, | ||
| assistant_id=assistant_id, | ||
| instructions=event_instructions, | ||
| model=FALLBACK_MODEL | ||
| ) | ||
|
|
||
| logger.info(f"[LLM Debug] Fallback run status: {getattr(run, 'status', 'N/A')}") | ||
|
|
||
| except Exception as e: | ||
| logger.exception(f"[LLM Exception] Error while creating run: {e}") | ||
| run = None | ||
|
|
||
|
|
||
| # --- RESPONSE HANDLING --- | ||
| if run and run.status == "completed": | ||
| final_model = getattr(run, 'model', default_model) | ||
| logger.info(f"[LLM Success] Final model used: {final_model}") | ||
|
|
||
| if run.status == 'completed': | ||
| messages = client.beta.threads.messages.list(thread_id=thread.id) | ||
| assistant_response = extract_text_from_messages(messages) | ||
|
|
||
| send_message(From, assistant_response) | ||
| event_doc_ref.update({ | ||
| 'interactions': firestore.ArrayUnion([{'response': assistant_response}]) | ||
| 'interactions': firestore.ArrayUnion([ | ||
| {'response': assistant_response, 'model': final_model, 'fallback': False} | ||
| ]) | ||
| }) | ||
|
|
||
| else: | ||
| send_message(From, "There was an issue processing your request.") | ||
| logger.warning("[LLM Fallback] Both models failed or returned incomplete response.") | ||
|
|
||
| if run and hasattr(run, 'last_error'): | ||
| logger.error(f"[LLM Debug] last_error (final): {run.last_error}") | ||
| if run and hasattr(run, 'incomplete_details'): | ||
| logger.error(f"[LLM Debug] incomplete_details (final): {run.incomplete_details}") | ||
|
|
||
| fallback_responses = [ | ||
| "Agreed.", | ||
| "Please continue.", | ||
| "That’s an interesting point, tell me more.", | ||
| "I understand.", | ||
| "Go on, I’m listening." | ||
| ] | ||
| fallback_message = random.choice(fallback_responses) | ||
|
|
||
| send_message(From, fallback_message) | ||
| event_doc_ref.update({ | ||
| 'interactions': firestore.ArrayUnion([ | ||
| {'response': fallback_message, 'model': None, 'fallback': True} | ||
| ]) | ||
| }) | ||
|
|
||
| return Response(status_code=200) | ||
Uh oh!
There was an error while loading. Please reload this page.