Update main.py
Browse files
main.py
CHANGED
@@ -64,6 +64,8 @@ CACHE_UPDATE_INTERVAL_SECONDS = int(os.getenv('CACHE_UPDATE_INTERVAL_SECONDS', 6
|
|
64 |
# Webhook URLs
|
65 |
SCAMMER_WEBHOOK_URL = os.getenv("SCAMMER_WEBHOOK_URL")
|
66 |
VALUE_WEBHOOK_URL = os.getenv("VALUE_WEBHOOK_URL")
|
|
|
|
|
67 |
|
68 |
|
69 |
# --- Global Cache ---
|
@@ -175,18 +177,19 @@ def format_currency(value: Any) -> Optional[str]:
|
|
175 |
num = float(num_str)
|
176 |
return f"${num:,.0f}"
|
177 |
except (ValueError, TypeError):
|
178 |
-
|
|
|
179 |
return value.strip() # Return original text if non-numeric-like
|
180 |
-
return 'N/A'
|
181 |
|
182 |
def parse_cached_currency(value_str: Optional[str]) -> Optional[float]:
|
183 |
-
if value_str is None or
|
184 |
return None
|
185 |
try:
|
186 |
num_str = str(value_str).replace('$', '').replace(',', '').strip()
|
187 |
return float(num_str)
|
188 |
except (ValueError, TypeError):
|
189 |
-
return None
|
190 |
|
191 |
def clean_string(value, default='N/A'):
|
192 |
if value is None: return default
|
@@ -205,19 +208,20 @@ def parse_alt_accounts(value):
|
|
205 |
return [acc.strip() for acc in raw_string.split(',') if acc.strip()]
|
206 |
|
207 |
|
208 |
-
# --- Roblox API Helpers
|
209 |
async def get_roblox_user_id(session: aiohttp.ClientSession, username: str):
|
210 |
if not username: return None
|
211 |
url = "https://users.roblox.com/v1/usernames/users"
|
212 |
payload = {"usernames": [username], "excludeBannedUsers": False}
|
213 |
try:
|
214 |
-
|
|
|
215 |
if response.status == 200:
|
216 |
data = await response.json()
|
217 |
if data and data.get("data") and len(data["data"]) > 0:
|
218 |
return data["data"][0].get("id")
|
219 |
else:
|
220 |
-
logger.warning(f"Roblox API returned status {response.status} for username '{username}'")
|
221 |
return None
|
222 |
except asyncio.TimeoutError:
|
223 |
logger.warning(f"Timeout fetching Roblox User ID for {username}")
|
@@ -233,7 +237,8 @@ async def get_roblox_avatar_url(session: aiohttp.ClientSession, user_id: int):
|
|
233 |
if not user_id: return None
|
234 |
url = f"https://thumbnails.roblox.com/v1/users/avatar-headshot?userIds={user_id}&size=150x150&format=Png&isCircular=false"
|
235 |
try:
|
236 |
-
|
|
|
237 |
if response.status == 200:
|
238 |
data = await response.json()
|
239 |
if data and data.get("data") and len(data["data"]) > 0:
|
@@ -253,29 +258,32 @@ async def get_roblox_avatar_url(session: aiohttp.ClientSession, user_id: int):
|
|
253 |
|
254 |
|
255 |
# --- Data Processing Functions ---
|
256 |
-
# These functions take raw rows from the sheet and process them.
|
257 |
|
258 |
def process_sheet_data(values): # For Value Categories
|
259 |
if not values: return []
|
260 |
processed_data = []
|
261 |
-
for row in values: #
|
262 |
if not row or not any(str(cell).strip() for cell in row if cell is not None): continue
|
263 |
# Skip header-like rows (e.g., "LEVEL 1 | HYPERCHROMES" in column F/index 4)
|
264 |
-
|
|
|
|
|
265 |
continue
|
266 |
|
267 |
# Indices based on B6:P (0-indexed from B)
|
268 |
icon_formula = row[0] if len(row) > 0 else ''
|
269 |
-
name = row[2] if len(row) > 2 else 'N/A'
|
270 |
-
value_raw = row[4] if len(row) > 4 else 'N/A'
|
271 |
-
duped_value_raw = row[6] if len(row) > 6 else 'N/A'
|
272 |
-
market_value_raw = row[8] if len(row) > 8 else 'N/A'
|
273 |
-
demand = row[10] if len(row) > 10 else 'N/A'
|
274 |
-
notes = row[12] if len(row) > 12 else ''
|
275 |
drive_url = row[14] if len(row) > 14 else None # Column P
|
276 |
|
277 |
cleaned_name = clean_string(name)
|
278 |
-
|
|
|
|
|
279 |
continue
|
280 |
|
281 |
processed_item = {
|
@@ -298,7 +306,11 @@ def process_user_scammer_data(values): # For User Scammer Sheet
|
|
298 |
# Indices based on B6:G (0-indexed from B)
|
299 |
discord_id = clean_string_optional(row[0]) if len(row) > 0 else None # Col B
|
300 |
roblox_username = clean_string_optional(row[1]) if len(row) > 1 else None # Col C
|
|
|
301 |
if not discord_id and not roblox_username: continue
|
|
|
|
|
|
|
302 |
processed_item = {
|
303 |
'discord_id': discord_id,
|
304 |
'roblox_username': roblox_username,
|
@@ -319,7 +331,11 @@ def process_server_scammer_data(values): # For Server Scammer Sheet
|
|
319 |
# Indices based on B6:F (0-indexed from B)
|
320 |
server_id = clean_string_optional(row[0]) if len(row) > 0 else None # Col B
|
321 |
server_name = clean_string_optional(row[1]) if len(row) > 1 else None # Col C
|
|
|
322 |
if not server_id and not server_name: continue
|
|
|
|
|
|
|
323 |
processed_item = {
|
324 |
'server_id': server_id,
|
325 |
'server_name': server_name,
|
@@ -334,12 +350,16 @@ def process_dwc_data(values): # For DWC Sheet
|
|
334 |
if not values: return []
|
335 |
processed_data = []
|
336 |
for row in values: # Expected range like B6:G
|
337 |
-
if not row or len(row) <
|
338 |
# Indices based on B6:G (0-indexed from B)
|
339 |
user_id = clean_string_optional(row[0]) if len(row) > 0 else None # Col B
|
340 |
server_id = clean_string_optional(row[1]) if len(row) > 1 else None # Col C
|
341 |
roblox_user = clean_string_optional(row[2]) if len(row) > 2 else None # Col D
|
|
|
342 |
if not user_id and not server_id and not roblox_user: continue
|
|
|
|
|
|
|
343 |
processed_item = {
|
344 |
'status': 'DWC',
|
345 |
'discord_user_id': user_id,
|
@@ -356,14 +376,21 @@ def process_dwc_data(values): # For DWC Sheet
|
|
356 |
def process_dupe_list_data(values): # For Dupe List Sheet
|
357 |
if not values: return []
|
358 |
# Expected range like B2:B
|
359 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
360 |
|
361 |
|
362 |
# --- Async Fetching Functions ---
|
363 |
|
364 |
async def fetch_batch_ranges_async(spreadsheet_id: str, ranges: List[str], value_render_option: str = 'FORMATTED_VALUE') -> List[Dict]:
|
365 |
"""Async wrapper to fetch multiple ranges using batchGet and return raw valueRanges."""
|
366 |
-
global sheets_service
|
367 |
if not sheets_service:
|
368 |
logger.warning(f"Attempted batch fetch from {spreadsheet_id} but Sheets service is unavailable.")
|
369 |
raise Exception("Google Sheets service not initialized")
|
@@ -388,27 +415,41 @@ async def fetch_batch_ranges_async(spreadsheet_id: str, ranges: List[str], value
|
|
388 |
return value_ranges # Return the raw list of valueRange objects
|
389 |
|
390 |
except HttpError as e:
|
391 |
-
|
392 |
-
|
393 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
394 |
logger.error(f"Google API HTTP Error during batch fetch for {spreadsheet_id}: Status={status}, Message={message}")
|
395 |
-
|
396 |
-
if status
|
397 |
-
logger.critical(f"Authentication/Permission Error accessing {spreadsheet_id}.
|
398 |
cache["service_available"] = False # Mark service as down
|
399 |
-
sheets_service = None # Reset service
|
400 |
-
elif status == 'NOT_FOUND':
|
401 |
logger.error(f"Spreadsheet or Range not found error for {spreadsheet_id}. Ranges: {ranges}. Check IDs and Sheet Names.")
|
|
|
|
|
|
|
|
|
|
|
402 |
raise e # Re-raise after logging
|
403 |
except Exception as e:
|
404 |
logger.error(f"Error during batch fetching from {spreadsheet_id} for ranges {ranges}: {e}", exc_info=True)
|
|
|
|
|
|
|
405 |
raise e
|
406 |
|
407 |
# --- Webhook Sending ---
|
408 |
async def send_webhook_notification(session: aiohttp.ClientSession, webhook_url: str, embed: Dict):
|
409 |
"""Sends a Discord webhook notification with the provided embed."""
|
410 |
if not webhook_url:
|
411 |
-
# logger.debug("Webhook URL not configured. Skipping notification.")
|
412 |
return
|
413 |
if not embed:
|
414 |
logger.warning("Attempted to send webhook with empty embed.")
|
@@ -416,11 +457,14 @@ async def send_webhook_notification(session: aiohttp.ClientSession, webhook_url:
|
|
416 |
|
417 |
webhook_data = {"embeds": [embed]}
|
418 |
try:
|
|
|
419 |
async with session.post(webhook_url, json=webhook_data, timeout=aiohttp.ClientTimeout(total=10)) as response:
|
420 |
if response.status not in [200, 204]:
|
421 |
-
|
422 |
-
|
423 |
-
|
|
|
|
|
424 |
except asyncio.TimeoutError:
|
425 |
logger.warning(f"Timeout sending webhook to {webhook_url[:30]}...")
|
426 |
except aiohttp.ClientError as e:
|
@@ -428,12 +472,13 @@ async def send_webhook_notification(session: aiohttp.ClientSession, webhook_url:
|
|
428 |
except Exception as e:
|
429 |
logger.error(f"Unexpected error sending webhook: {e}", exc_info=True)
|
430 |
|
431 |
-
# --- Background Cache Update Task
|
432 |
|
433 |
async def update_cache_periodically():
|
434 |
-
"""Fetches data, processes, detects changes/new entries, sends webhooks, and updates cache."""
|
435 |
global cache
|
436 |
-
|
|
|
437 |
while True:
|
438 |
if not cache["service_available"]:
|
439 |
logger.info("Attempting to re-initialize Google Sheets service...")
|
@@ -445,7 +490,7 @@ async def update_cache_periodically():
|
|
445 |
else:
|
446 |
logger.info("Google Sheets service re-initialized. Proceeding with cache update.")
|
447 |
|
448 |
-
logger.info("Starting cache update cycle...")
|
449 |
start_time = datetime.now(timezone.utc)
|
450 |
webhook_tasks = [] # Store webhook sending tasks
|
451 |
|
@@ -507,6 +552,8 @@ async def update_cache_periodically():
|
|
507 |
if isinstance(result, Exception):
|
508 |
logger.error(f"Failed to fetch batch data for {key}: {result}")
|
509 |
current_errors[key] = str(result)
|
|
|
|
|
510 |
else:
|
511 |
if key == "scammer_dwc_batch":
|
512 |
raw_scammer_dwc_results = result
|
@@ -537,6 +584,9 @@ async def update_cache_periodically():
|
|
537 |
current_errors[f"process_{target_key}"] = str(e)
|
538 |
else:
|
539 |
logger.warning(f"No processor found for sheet name '{sheet_name}' derived from range '{range_str}' in Scammer/DWC sheet.")
|
|
|
|
|
|
|
540 |
|
541 |
# --- Process Values/Dupes Results ---
|
542 |
if raw_values_dupes_results is not None:
|
@@ -565,39 +615,51 @@ async def update_cache_periodically():
|
|
565 |
target_key = "dupes" if sheet_name == DUPE_LIST_SHEET else f"values_{sheet_name}"
|
566 |
logger.error(f"Error processing data for {sheet_name}: {e}", exc_info=True)
|
567 |
current_errors[f"process_{target_key}"] = str(e)
|
|
|
|
|
568 |
|
569 |
# --- Fetch Roblox Avatars (for new data before comparison/webhook) ---
|
570 |
-
|
571 |
-
|
572 |
-
|
573 |
-
|
574 |
-
|
575 |
-
|
576 |
-
|
577 |
-
|
578 |
-
|
579 |
-
|
580 |
-
|
581 |
-
|
582 |
-
|
|
|
|
|
|
|
|
|
583 |
current_time = datetime.now(timezone.utc)
|
584 |
timestamp_iso = current_time.isoformat()
|
|
|
|
|
|
|
|
|
585 |
|
586 |
-
# 1. Value Changes
|
587 |
-
|
588 |
-
if "
|
589 |
-
if VALUE_WEBHOOK_URL and not any(k.startswith("process_values_") for k in current_errors) and "values_dupes_batch" not in current_errors:
|
590 |
-
logger.info("Detecting value changes for webhooks...")
|
591 |
fields_to_compare = ['value', 'dupedValue', 'marketValue']
|
592 |
for category, new_items in fetched_values_categories.items():
|
593 |
-
old_items_dict = {item['name']: item for item in cache["values"].get(category, [])}
|
594 |
category_changes_for_api = []
|
595 |
|
596 |
for new_item in new_items:
|
597 |
item_name = new_item.get('name')
|
598 |
if not item_name or item_name == 'N/A': continue
|
|
|
|
|
|
|
|
|
|
|
599 |
|
600 |
-
old_item = old_items_dict.get(item_name)
|
601 |
if old_item: # Check existing item for changes
|
602 |
for field in fields_to_compare:
|
603 |
old_val_str = old_item.get(field, 'N/A')
|
@@ -612,113 +674,133 @@ async def update_cache_periodically():
|
|
612 |
"timestamp": timestamp_iso
|
613 |
}
|
614 |
category_changes_for_api.append(change_info)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
615 |
|
616 |
-
# Prepare webhook embed
|
617 |
-
embed = {
|
618 |
-
"title": f"Value Update: {item_name} ({category})",
|
619 |
-
"color": 3447003, # Blue
|
620 |
-
"fields": [
|
621 |
-
{"name": "Field Changed", "value": field, "inline": True},
|
622 |
-
{"name": "Old Value", "value": f"`{change_info['old_value']}`", "inline": True},
|
623 |
-
{"name": "New Value", "value": f"`{change_info['new_value']}`", "inline": True},
|
624 |
-
{"name": "Item Notes", "value": new_item.get('notes', 'N/A')[:1020] or 'N/A', "inline": False}, # Limit notes length
|
625 |
-
],
|
626 |
-
"timestamp": timestamp_iso
|
627 |
-
}
|
628 |
-
if new_item.get('icon'):
|
629 |
-
embed["thumbnail"] = {"url": new_item['icon']}
|
630 |
-
|
631 |
-
webhook_tasks.append(send_webhook_notification(session, VALUE_WEBHOOK_URL, embed))
|
632 |
if category_changes_for_api:
|
633 |
detected_value_changes_for_api[category] = category_changes_for_api
|
634 |
-
|
635 |
-
|
636 |
-
|
|
|
|
|
|
|
637 |
else:
|
638 |
-
|
639 |
|
640 |
|
641 |
-
# 2. New Scammers / DWC (
|
642 |
-
if
|
643 |
-
|
|
|
|
|
|
|
644 |
initial_webhook_task_count = len(webhook_tasks)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
645 |
|
646 |
-
# User Scammers
|
647 |
-
old_user_keys = set((item.get('discord_id'), item.get('roblox_username')) for item in cache.get("user_scammers", []))
|
648 |
for item in new_cache_data.get("user_scammers", []):
|
649 |
-
key = (item
|
650 |
if key not in old_user_keys:
|
651 |
logger.info(f"New User Scammer detected: Discord={item.get('discord_id')}, Roblox={item.get('roblox_username')}")
|
652 |
embed = {
|
653 |
-
"title": "🚨 New User Scammer Added",
|
654 |
-
"color": 15158332, # Red
|
655 |
"fields": [
|
656 |
{"name": "Discord ID", "value": f"`{item.get('discord_id', 'N/A')}`", "inline": True},
|
657 |
{"name": "Roblox User", "value": f"`{item.get('roblox_username', 'N/A')}`", "inline": True},
|
658 |
-
{"name": "Scam Type", "value": item.get('scam_type', 'N/A'), "inline": False},
|
659 |
{"name": "Explanation", "value": item.get('explanation', 'N/A')[:1020] or 'N/A', "inline": False},
|
660 |
-
],
|
661 |
-
"timestamp": timestamp_iso
|
662 |
}
|
663 |
-
if item.get('evidence_link'):
|
664 |
-
|
665 |
-
if item.get('
|
666 |
-
embed["fields"].append({"name": "Alt Accounts", "value": ", ".join([f"`{a}`" for a in item['alt_accounts']]), "inline": False})
|
667 |
-
if item.get('roblox_avatar_url'):
|
668 |
-
embed["thumbnail"] = {"url": item['roblox_avatar_url']}
|
669 |
webhook_tasks.append(send_webhook_notification(session, SCAMMER_WEBHOOK_URL, embed))
|
|
|
|
|
|
|
|
|
|
|
|
|
670 |
|
671 |
-
# Server Scammers
|
672 |
-
old_server_keys = set((item.get('server_id'), item.get('server_name')) for item in cache.get("server_scammers", []))
|
673 |
for item in new_cache_data.get("server_scammers", []):
|
674 |
-
key = (item
|
675 |
if key not in old_server_keys:
|
676 |
logger.info(f"New Server Scammer detected: ID={item.get('server_id')}, Name={item.get('server_name')}")
|
677 |
embed = {
|
678 |
-
"title": "🚨 New Server Scammer Added",
|
679 |
-
"color": 15158332, # Red
|
680 |
"fields": [
|
681 |
{"name": "Server ID", "value": f"`{item.get('server_id', 'N/A')}`", "inline": True},
|
682 |
{"name": "Server Name", "value": f"`{item.get('server_name', 'N/A')}`", "inline": True},
|
683 |
-
{"name": "Scam Type", "value": item.get('scam_type', 'N/A'), "inline": False},
|
684 |
{"name": "Explanation", "value": item.get('explanation', 'N/A')[:1020] or 'N/A', "inline": False},
|
685 |
-
],
|
686 |
-
"timestamp": timestamp_iso
|
687 |
}
|
688 |
-
if item.get('evidence_link'):
|
689 |
-
embed["fields"].append({"name": "Evidence", "value": item['evidence_link'], "inline": False})
|
690 |
webhook_tasks.append(send_webhook_notification(session, SCAMMER_WEBHOOK_URL, embed))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
691 |
|
692 |
-
# DWC Entries
|
693 |
-
old_dwc_keys = set((item.get('discord_user_id'), item.get('discord_server_id'), item.get('roblox_username')) for item in cache.get("dwc", []))
|
694 |
for item in new_cache_data.get("dwc", []):
|
695 |
-
key = (item
|
696 |
if key not in old_dwc_keys:
|
697 |
logger.info(f"New DWC Entry detected: User={item.get('discord_user_id')}, Server={item.get('discord_server_id')}, Roblox={item.get('roblox_username')}")
|
698 |
embed = {
|
699 |
-
"title": "⚠️ New DWC Entry Added",
|
700 |
-
"color": 15105570, # Orange/Dark Yellow
|
701 |
"fields": [
|
702 |
{"name": "Discord User ID", "value": f"`{item.get('discord_user_id', 'N/A')}`", "inline": True},
|
703 |
{"name": "Discord Server ID", "value": f"`{item.get('discord_server_id', 'N/A')}`", "inline": True},
|
704 |
{"name": "Roblox User", "value": f"`{item.get('roblox_username', 'N/A')}`", "inline": True},
|
705 |
{"name": "Explanation", "value": item.get('explanation', 'N/A')[:1020] or 'N/A', "inline": False},
|
706 |
-
],
|
707 |
-
"timestamp": timestamp_iso
|
708 |
}
|
709 |
-
if item.get('evidence_link'):
|
710 |
-
|
711 |
-
if item.get('
|
712 |
-
embed["fields"].append({"name": "Alt Accounts", "value": ", ".join([f"`{a}`" for a in item['alt_accounts']]), "inline": False})
|
713 |
-
if item.get('roblox_avatar_url'):
|
714 |
-
embed["thumbnail"] = {"url": item['roblox_avatar_url']}
|
715 |
webhook_tasks.append(send_webhook_notification(session, SCAMMER_WEBHOOK_URL, embed))
|
|
|
716 |
|
717 |
-
|
|
|
|
|
|
|
|
|
718 |
elif not SCAMMER_WEBHOOK_URL:
|
719 |
-
|
720 |
-
else:
|
721 |
-
|
|
|
722 |
|
723 |
# --- Send Webhooks Concurrently ---
|
724 |
if webhook_tasks:
|
@@ -726,11 +808,14 @@ async def update_cache_periodically():
|
|
726 |
await asyncio.gather(*webhook_tasks)
|
727 |
logger.info("Finished sending webhook notifications.")
|
728 |
else:
|
729 |
-
logger.info("No webhooks to send for this cycle.")
|
730 |
|
731 |
|
732 |
# --- Final Cache Update ---
|
733 |
update_occurred = False
|
|
|
|
|
|
|
734 |
if not current_errors: # Perfect cycle
|
735 |
logger.info("Updating full cache (no errors during fetch or processing).")
|
736 |
cache["values"] = fetched_values_categories
|
@@ -740,7 +825,9 @@ async def update_cache_periodically():
|
|
740 |
cache["dupes"] = new_cache_data["dupes"]
|
741 |
cache["value_changes"] = detected_value_changes_for_api # Store the detected changes
|
742 |
cache["last_updated"] = current_time
|
743 |
-
|
|
|
|
|
744 |
cache["service_available"] = True # Mark as available on success
|
745 |
update_occurred = True
|
746 |
logger.info(f"Cache update cycle completed successfully.")
|
@@ -750,7 +837,6 @@ async def update_cache_periodically():
|
|
750 |
|
751 |
# Update values only if the values/dupes batch succeeded AND processing succeeded
|
752 |
if "values_dupes_batch" not in current_errors and not any(k.startswith("process_values_") for k in current_errors):
|
753 |
-
# Check if fetched data is different from cache before updating
|
754 |
if cache.get("values") != fetched_values_categories:
|
755 |
cache["values"] = fetched_values_categories
|
756 |
cache["value_changes"] = detected_value_changes_for_api # Update changes along with values
|
@@ -759,7 +845,6 @@ async def update_cache_periodically():
|
|
759 |
else:
|
760 |
logger.warning("Skipping update for 'values' due to errors.")
|
761 |
|
762 |
-
|
763 |
# Update dupes only if the values/dupes batch succeeded AND processing succeeded
|
764 |
if "values_dupes_batch" not in current_errors and "process_dupes" not in current_errors:
|
765 |
if cache.get("dupes") != new_cache_data["dupes"]:
|
@@ -783,15 +868,19 @@ async def update_cache_periodically():
|
|
783 |
else:
|
784 |
logger.warning("Skipping update for 'user_scammers', 'server_scammers', 'dwc' due to batch fetch error.")
|
785 |
|
786 |
-
|
787 |
if update_occurred:
|
788 |
cache["last_updated"] = current_time # Mark partial update time
|
789 |
-
cache
|
790 |
-
#
|
791 |
-
|
|
|
|
|
|
|
|
|
|
|
792 |
else:
|
793 |
logger.error(f"Cache update cycle failed, and no parts could be updated based on errors. Errors: {current_errors}")
|
794 |
-
#
|
795 |
|
796 |
except Exception as e:
|
797 |
logger.exception(f"Critical error during cache update cycle: {e}")
|
@@ -818,6 +907,8 @@ async def fetch_avatar_for_entry_update(session: aiohttp.ClientSession, entry: d
|
|
818 |
user_id = await get_roblox_user_id(session, roblox_username)
|
819 |
if user_id:
|
820 |
new_avatar = await get_roblox_avatar_url(session, user_id)
|
|
|
|
|
821 |
|
822 |
except Exception as e:
|
823 |
# Log errors but don't stop the main update loop
|
@@ -825,7 +916,7 @@ async def fetch_avatar_for_entry_update(session: aiohttp.ClientSession, entry: d
|
|
825 |
# Keep new_avatar as None on error
|
826 |
|
827 |
finally:
|
828 |
-
# Update the entry dict directly
|
829 |
entry['roblox_avatar_url'] = new_avatar
|
830 |
|
831 |
|
@@ -841,6 +932,8 @@ async def startup_event():
|
|
841 |
logger.warning("SCAMMER_WEBHOOK_URL environment variable not set. New scammer notifications disabled.")
|
842 |
if not VALUE_WEBHOOK_URL:
|
843 |
logger.warning("VALUE_WEBHOOK_URL environment variable not set. Value change notifications disabled.")
|
|
|
|
|
844 |
asyncio.create_task(update_cache_periodically())
|
845 |
|
846 |
|
@@ -854,9 +947,7 @@ def check_cache_readiness():
|
|
854 |
raise HTTPException(status_code=503, detail="Service temporarily unavailable due to backend connection issues. Please try again later.")
|
855 |
else:
|
856 |
raise HTTPException(status_code=503, detail="Cache is initializing or data is currently unavailable. Please try again shortly.")
|
857 |
-
|
858 |
-
# if cache["last_updated"] and (datetime.now(timezone.utc) - cache["last_updated"]).total_seconds() > CACHE_UPDATE_INTERVAL_SECONDS * 3:
|
859 |
-
# raise HTTPException(status_code=503, detail="Data may be stale. Update in progress or backend issue.")
|
860 |
|
861 |
@app.get("/")
|
862 |
async def root():
|
@@ -894,8 +985,9 @@ async def get_category_values(category: str):
|
|
894 |
matched_category = next((c for c in cache.get("values", {}).keys() if c.lower() == category.lower()), None)
|
895 |
if not matched_category:
|
896 |
# Check if the category *exists* conceptually even if empty
|
897 |
-
|
898 |
-
|
|
|
899 |
else:
|
900 |
raise HTTPException(status_code=404, detail=f"Category '{category}' not found.")
|
901 |
return {matched_category: cache.get("values", {}).get(matched_category, [])}
|
@@ -909,7 +1001,8 @@ async def get_category_value_changes(category: str):
|
|
909 |
matched_category = next((c for c in cache.get("value_changes", {}).keys() if c.lower() == category.lower()), None)
|
910 |
if not matched_category:
|
911 |
# Check if the category *exists* conceptually even if empty
|
912 |
-
|
|
|
913 |
return {category: []} # Return empty list if category is valid but had no changes
|
914 |
else:
|
915 |
raise HTTPException(status_code=404, detail=f"Category '{category}' not found.")
|
@@ -943,7 +1036,7 @@ class UsernameCheck(BaseModel):
|
|
943 |
|
944 |
@app.post("/api/check")
|
945 |
async def check_username(data: UsernameCheck):
|
946 |
-
"""Check if a username is duped using cached data and send webhook"""
|
947 |
check_cache_readiness() # Use the standard readiness check
|
948 |
|
949 |
username_to_check = data.username.strip().lower()
|
@@ -951,25 +1044,22 @@ async def check_username(data: UsernameCheck):
|
|
951 |
dupes_list = cache.get("dupes", [])
|
952 |
is_duped = username_to_check in dupes_list
|
953 |
|
954 |
-
# Webhook notification for checks resulting in "Not Found"
|
955 |
-
# Note: This uses the *value* webhook URL per original code, not the scammer one.
|
956 |
-
# If this needs to go to a *different* webhook, adjust the env var name.
|
957 |
if not is_duped:
|
958 |
-
|
959 |
-
if webhook_url:
|
960 |
-
# Use a new session for this one-off task or pass the main one if safe
|
961 |
async def send_check_webhook():
|
962 |
try:
|
963 |
-
|
|
|
964 |
embed = {
|
965 |
"title": "User Dupe Check - Not Found",
|
966 |
"description": f"Username `{data.username}` was checked against the dupe list but was **not** found.",
|
967 |
"color": 16776960, # Yellow
|
968 |
"timestamp": datetime.now(timezone.utc).isoformat()
|
969 |
}
|
970 |
-
await send_webhook_notification(session,
|
971 |
except Exception as e:
|
972 |
-
logger.error(f"Error sending dupe check webhook: {e}")
|
973 |
|
974 |
asyncio.create_task(send_check_webhook()) # Fire and forget
|
975 |
else:
|
|
|
64 |
# Webhook URLs
|
65 |
SCAMMER_WEBHOOK_URL = os.getenv("SCAMMER_WEBHOOK_URL")
|
66 |
VALUE_WEBHOOK_URL = os.getenv("VALUE_WEBHOOK_URL")
|
67 |
+
# Optional: Separate webhook for dupe checks? Keep using the general one for now.
|
68 |
+
DUPE_CHECK_WEBHOOK_URL = os.getenv("WEBHOOK_URL")
|
69 |
|
70 |
|
71 |
# --- Global Cache ---
|
|
|
177 |
num = float(num_str)
|
178 |
return f"${num:,.0f}"
|
179 |
except (ValueError, TypeError):
|
180 |
+
# Allow text like "Event", "Unobtainable" etc. to pass through
|
181 |
+
if isinstance(value, str) and value.strip() and not re.match(r'^-?[\d,.$]+\$?$', value.strip()):
|
182 |
return value.strip() # Return original text if non-numeric-like
|
183 |
+
return 'N/A' # Return N/A for things that look like bad numbers
|
184 |
|
185 |
def parse_cached_currency(value_str: Optional[str]) -> Optional[float]:
|
186 |
+
if value_str is None or str(value_str).strip().lower() == 'n/a':
|
187 |
return None
|
188 |
try:
|
189 |
num_str = str(value_str).replace('$', '').replace(',', '').strip()
|
190 |
return float(num_str)
|
191 |
except (ValueError, TypeError):
|
192 |
+
return None # Return None if it's not a parsable number (e.g., "Event")
|
193 |
|
194 |
def clean_string(value, default='N/A'):
|
195 |
if value is None: return default
|
|
|
208 |
return [acc.strip() for acc in raw_string.split(',') if acc.strip()]
|
209 |
|
210 |
|
211 |
+
# --- Roblox API Helpers ---
|
212 |
async def get_roblox_user_id(session: aiohttp.ClientSession, username: str):
|
213 |
if not username: return None
|
214 |
url = "https://users.roblox.com/v1/usernames/users"
|
215 |
payload = {"usernames": [username], "excludeBannedUsers": False}
|
216 |
try:
|
217 |
+
# Increased timeout specifically for Roblox API calls which can be slow
|
218 |
+
async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=10)) as response:
|
219 |
if response.status == 200:
|
220 |
data = await response.json()
|
221 |
if data and data.get("data") and len(data["data"]) > 0:
|
222 |
return data["data"][0].get("id")
|
223 |
else:
|
224 |
+
logger.warning(f"Roblox User API returned status {response.status} for username '{username}'")
|
225 |
return None
|
226 |
except asyncio.TimeoutError:
|
227 |
logger.warning(f"Timeout fetching Roblox User ID for {username}")
|
|
|
237 |
if not user_id: return None
|
238 |
url = f"https://thumbnails.roblox.com/v1/users/avatar-headshot?userIds={user_id}&size=150x150&format=Png&isCircular=false"
|
239 |
try:
|
240 |
+
# Increased timeout specifically for Roblox API calls
|
241 |
+
async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as response:
|
242 |
if response.status == 200:
|
243 |
data = await response.json()
|
244 |
if data and data.get("data") and len(data["data"]) > 0:
|
|
|
258 |
|
259 |
|
260 |
# --- Data Processing Functions ---
|
|
|
261 |
|
262 |
def process_sheet_data(values): # For Value Categories
|
263 |
if not values: return []
|
264 |
processed_data = []
|
265 |
+
for row_idx, row in enumerate(values): # Start counting from sheet row 6 (index 0 here)
|
266 |
if not row or not any(str(cell).strip() for cell in row if cell is not None): continue
|
267 |
# Skip header-like rows (e.g., "LEVEL 1 | HYPERCHROMES" in column F/index 4)
|
268 |
+
# Use index 4 for Value column (F)
|
269 |
+
if len(row) > 4 and isinstance(row[4], str) and re.search(r'(LEVEL \d+ \|)|(VALUE)', row[4], re.IGNORECASE):
|
270 |
+
#logger.debug(f"Skipping potential header row {row_idx+6}: {row}")
|
271 |
continue
|
272 |
|
273 |
# Indices based on B6:P (0-indexed from B)
|
274 |
icon_formula = row[0] if len(row) > 0 else ''
|
275 |
+
name = row[2] if len(row) > 2 else 'N/A' # Column D
|
276 |
+
value_raw = row[4] if len(row) > 4 else 'N/A' # Column F
|
277 |
+
duped_value_raw = row[6] if len(row) > 6 else 'N/A' # Column H
|
278 |
+
market_value_raw = row[8] if len(row) > 8 else 'N/A' # Column J
|
279 |
+
demand = row[10] if len(row) > 10 else 'N/A' # Column L
|
280 |
+
notes = row[12] if len(row) > 12 else '' # Column N
|
281 |
drive_url = row[14] if len(row) > 14 else None # Column P
|
282 |
|
283 |
cleaned_name = clean_string(name)
|
284 |
+
# Also skip if name is clearly a header like "Name"
|
285 |
+
if cleaned_name == 'N/A' or cleaned_name.lower() == 'name':
|
286 |
+
#logger.debug(f"Skipping row {row_idx+6} due to missing/header name: {row}")
|
287 |
continue
|
288 |
|
289 |
processed_item = {
|
|
|
306 |
# Indices based on B6:G (0-indexed from B)
|
307 |
discord_id = clean_string_optional(row[0]) if len(row) > 0 else None # Col B
|
308 |
roblox_username = clean_string_optional(row[1]) if len(row) > 1 else None # Col C
|
309 |
+
# Skip if both identifiers are missing
|
310 |
if not discord_id and not roblox_username: continue
|
311 |
+
# Skip if it looks like a header row
|
312 |
+
if str(discord_id).lower() == 'discord id' or str(roblox_username).lower() == 'roblox username':
|
313 |
+
continue
|
314 |
processed_item = {
|
315 |
'discord_id': discord_id,
|
316 |
'roblox_username': roblox_username,
|
|
|
331 |
# Indices based on B6:F (0-indexed from B)
|
332 |
server_id = clean_string_optional(row[0]) if len(row) > 0 else None # Col B
|
333 |
server_name = clean_string_optional(row[1]) if len(row) > 1 else None # Col C
|
334 |
+
# Skip if both identifiers are missing
|
335 |
if not server_id and not server_name: continue
|
336 |
+
# Skip if it looks like a header row
|
337 |
+
if str(server_id).lower() == 'server id' or str(server_name).lower() == 'server name':
|
338 |
+
continue
|
339 |
processed_item = {
|
340 |
'server_id': server_id,
|
341 |
'server_name': server_name,
|
|
|
350 |
if not values: return []
|
351 |
processed_data = []
|
352 |
for row in values: # Expected range like B6:G
|
353 |
+
if not row or len(row) < 1: continue # Need at least one ID
|
354 |
# Indices based on B6:G (0-indexed from B)
|
355 |
user_id = clean_string_optional(row[0]) if len(row) > 0 else None # Col B
|
356 |
server_id = clean_string_optional(row[1]) if len(row) > 1 else None # Col C
|
357 |
roblox_user = clean_string_optional(row[2]) if len(row) > 2 else None # Col D
|
358 |
+
# Skip if all identifiers are missing
|
359 |
if not user_id and not server_id and not roblox_user: continue
|
360 |
+
# Skip if it looks like a header row
|
361 |
+
if str(user_id).lower() == 'user id' or str(server_id).lower() == 'server id' or str(roblox_user).lower() == 'roblox user':
|
362 |
+
continue
|
363 |
processed_item = {
|
364 |
'status': 'DWC',
|
365 |
'discord_user_id': user_id,
|
|
|
376 |
def process_dupe_list_data(values): # For Dupe List Sheet
|
377 |
if not values: return []
|
378 |
# Expected range like B2:B
|
379 |
+
processed_dupes = []
|
380 |
+
for row in values:
|
381 |
+
if row and len(row)>0 and row[0] and isinstance(row[0], str):
|
382 |
+
username = row[0].strip().lower()
|
383 |
+
# Skip header or empty strings
|
384 |
+
if username and username not in ('username', 'usernames'):
|
385 |
+
processed_dupes.append(username)
|
386 |
+
return processed_dupes
|
387 |
|
388 |
|
389 |
# --- Async Fetching Functions ---
|
390 |
|
391 |
async def fetch_batch_ranges_async(spreadsheet_id: str, ranges: List[str], value_render_option: str = 'FORMATTED_VALUE') -> List[Dict]:
|
392 |
"""Async wrapper to fetch multiple ranges using batchGet and return raw valueRanges."""
|
393 |
+
global sheets_service, cache
|
394 |
if not sheets_service:
|
395 |
logger.warning(f"Attempted batch fetch from {spreadsheet_id} but Sheets service is unavailable.")
|
396 |
raise Exception("Google Sheets service not initialized")
|
|
|
415 |
return value_ranges # Return the raw list of valueRange objects
|
416 |
|
417 |
except HttpError as e:
|
418 |
+
status_code = e.resp.status
|
419 |
+
error_details = {}
|
420 |
+
try:
|
421 |
+
error_details = json.loads(e.content).get('error', {})
|
422 |
+
except json.JSONDecodeError:
|
423 |
+
logger.error(f"Failed to parse JSON error content from Google API: {e.content}")
|
424 |
+
|
425 |
+
status = error_details.get('status', f'HTTP_{status_code}') # Use HTTP status if details missing
|
426 |
+
message = error_details.get('message', e._get_reason()) # Fallback message
|
427 |
logger.error(f"Google API HTTP Error during batch fetch for {spreadsheet_id}: Status={status}, Message={message}")
|
428 |
+
|
429 |
+
if status in ('PERMISSION_DENIED', 'UNAUTHENTICATED') or status_code == 403 or status_code == 401:
|
430 |
+
logger.critical(f"Authentication/Permission Error accessing {spreadsheet_id}. Disabling service checks.")
|
431 |
cache["service_available"] = False # Mark service as down
|
432 |
+
sheets_service = None # Reset service to force re-init attempt
|
433 |
+
elif status == 'NOT_FOUND' or status_code == 404:
|
434 |
logger.error(f"Spreadsheet or Range not found error for {spreadsheet_id}. Ranges: {ranges}. Check IDs and Sheet Names.")
|
435 |
+
elif status_code >= 500: # Server-side errors on Google's end
|
436 |
+
logger.warning(f"Google API server error ({status_code}) for {spreadsheet_id}. May be temporary.")
|
437 |
+
# Keep service_available as True, retry might work
|
438 |
+
# else: # Other client errors (e.g., 400 Bad Request for invalid range format)
|
439 |
+
|
440 |
raise e # Re-raise after logging
|
441 |
except Exception as e:
|
442 |
logger.error(f"Error during batch fetching from {spreadsheet_id} for ranges {ranges}: {e}", exc_info=True)
|
443 |
+
# Could be network issues, timeouts handled by aiohttp session typically
|
444 |
+
# Consider marking service unavailable for persistent non-HTTP errors too?
|
445 |
+
# cache["service_available"] = False # Optional: Be more aggressive
|
446 |
raise e
|
447 |
|
448 |
# --- Webhook Sending ---
|
449 |
async def send_webhook_notification(session: aiohttp.ClientSession, webhook_url: str, embed: Dict):
|
450 |
"""Sends a Discord webhook notification with the provided embed."""
|
451 |
if not webhook_url:
|
452 |
+
# logger.debug("Webhook URL not configured. Skipping notification.")
|
453 |
return
|
454 |
if not embed:
|
455 |
logger.warning("Attempted to send webhook with empty embed.")
|
|
|
457 |
|
458 |
webhook_data = {"embeds": [embed]}
|
459 |
try:
|
460 |
+
# Use a reasonable timeout for webhook posts
|
461 |
async with session.post(webhook_url, json=webhook_data, timeout=aiohttp.ClientTimeout(total=10)) as response:
|
462 |
if response.status not in [200, 204]:
|
463 |
+
# Log more details on failure
|
464 |
+
response_text = await response.text()
|
465 |
+
logger.warning(f"Failed to send webhook to {webhook_url[:30]}... (Status: {response.status}): {response_text[:500]}") # Limit response text length
|
466 |
+
# else:
|
467 |
+
# logger.debug(f"Webhook notification sent successfully to {webhook_url[:30]}...")
|
468 |
except asyncio.TimeoutError:
|
469 |
logger.warning(f"Timeout sending webhook to {webhook_url[:30]}...")
|
470 |
except aiohttp.ClientError as e:
|
|
|
472 |
except Exception as e:
|
473 |
logger.error(f"Unexpected error sending webhook: {e}", exc_info=True)
|
474 |
|
475 |
+
# --- Background Cache Update Task ---
|
476 |
|
477 |
async def update_cache_periodically():
|
478 |
+
"""Fetches data, processes, detects changes/new entries (if not first run), sends webhooks, and updates cache."""
|
479 |
global cache
|
480 |
+
# Increase overall session timeout slightly for robustness
|
481 |
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=45)) as session:
|
482 |
while True:
|
483 |
if not cache["service_available"]:
|
484 |
logger.info("Attempting to re-initialize Google Sheets service...")
|
|
|
490 |
else:
|
491 |
logger.info("Google Sheets service re-initialized. Proceeding with cache update.")
|
492 |
|
493 |
+
logger.info(f"Starting cache update cycle... (Cache Ready: {cache['is_ready']})")
|
494 |
start_time = datetime.now(timezone.utc)
|
495 |
webhook_tasks = [] # Store webhook sending tasks
|
496 |
|
|
|
552 |
if isinstance(result, Exception):
|
553 |
logger.error(f"Failed to fetch batch data for {key}: {result}")
|
554 |
current_errors[key] = str(result)
|
555 |
+
# If fetch failed, likely service unavailable (handled by fetch_batch_ranges_async)
|
556 |
+
# No need to explicitly set cache["service_available"] = False here again
|
557 |
else:
|
558 |
if key == "scammer_dwc_batch":
|
559 |
raw_scammer_dwc_results = result
|
|
|
584 |
current_errors[f"process_{target_key}"] = str(e)
|
585 |
else:
|
586 |
logger.warning(f"No processor found for sheet name '{sheet_name}' derived from range '{range_str}' in Scammer/DWC sheet.")
|
587 |
+
else:
|
588 |
+
logger.warning("Skipping Scammer/DWC processing due to fetch error.")
|
589 |
+
|
590 |
|
591 |
# --- Process Values/Dupes Results ---
|
592 |
if raw_values_dupes_results is not None:
|
|
|
615 |
target_key = "dupes" if sheet_name == DUPE_LIST_SHEET else f"values_{sheet_name}"
|
616 |
logger.error(f"Error processing data for {sheet_name}: {e}", exc_info=True)
|
617 |
current_errors[f"process_{target_key}"] = str(e)
|
618 |
+
else:
|
619 |
+
logger.warning("Skipping Values/Dupes processing due to fetch error.")
|
620 |
|
621 |
# --- Fetch Roblox Avatars (for new data before comparison/webhook) ---
|
622 |
+
if not current_errors.get("scammer_dwc_batch") and \
|
623 |
+
not current_errors.get("process_user_scammers") and \
|
624 |
+
not current_errors.get("process_dwc"):
|
625 |
+
logger.info("Fetching Roblox avatars for newly processed data...")
|
626 |
+
avatar_tasks = []
|
627 |
+
entries_needing_avatars = new_cache_data.get("user_scammers", []) + new_cache_data.get("dwc", [])
|
628 |
+
for entry in entries_needing_avatars:
|
629 |
+
if entry.get('roblox_username'):
|
630 |
+
avatar_tasks.append(fetch_avatar_for_entry_update(session, entry))
|
631 |
+
if avatar_tasks:
|
632 |
+
await asyncio.gather(*avatar_tasks) # Exceptions logged within helper
|
633 |
+
logger.info(f"Finished fetching avatars for {len(avatar_tasks)} potential new entries.")
|
634 |
+
else:
|
635 |
+
logger.warning("Skipping avatar fetching due to errors in fetching/processing scammer/dwc data.")
|
636 |
+
|
637 |
+
|
638 |
+
# --- Change Detection & Webhook Preparation (ONLY if cache is ready) ---
|
639 |
current_time = datetime.now(timezone.utc)
|
640 |
timestamp_iso = current_time.isoformat()
|
641 |
+
detected_value_changes_for_api = {} # Always calculate for API, but only send webhooks if ready
|
642 |
+
|
643 |
+
# Perform comparisons regardless of cache readiness to populate detected_value_changes_for_api
|
644 |
+
# But only queue webhooks if cache["is_ready"] is True
|
645 |
|
646 |
+
# 1. Value Changes Calculation
|
647 |
+
if "values" not in cache: cache["values"] = {} # Ensure exists for comparison logic
|
648 |
+
if "values_dupes_batch" not in current_errors and not any(k.startswith("process_values_") for k in current_errors):
|
|
|
|
|
649 |
fields_to_compare = ['value', 'dupedValue', 'marketValue']
|
650 |
for category, new_items in fetched_values_categories.items():
|
651 |
+
old_items_dict = {item['name'].lower(): item for item in cache["values"].get(category, [])} # Use lower case for comparison robustness
|
652 |
category_changes_for_api = []
|
653 |
|
654 |
for new_item in new_items:
|
655 |
item_name = new_item.get('name')
|
656 |
if not item_name or item_name == 'N/A': continue
|
657 |
+
item_name_lower = item_name.lower()
|
658 |
+
|
659 |
+
old_item = old_items_dict.get(item_name_lower)
|
660 |
+
change_detected_for_webhook = False
|
661 |
+
change_info_webhook = {}
|
662 |
|
|
|
663 |
if old_item: # Check existing item for changes
|
664 |
for field in fields_to_compare:
|
665 |
old_val_str = old_item.get(field, 'N/A')
|
|
|
674 |
"timestamp": timestamp_iso
|
675 |
}
|
676 |
category_changes_for_api.append(change_info)
|
677 |
+
change_detected_for_webhook = True
|
678 |
+
change_info_webhook = change_info # Store last change for potential webhook
|
679 |
+
|
680 |
+
# Prepare webhook only if a change was found AND cache was ready
|
681 |
+
if change_detected_for_webhook and cache["is_ready"] and VALUE_WEBHOOK_URL:
|
682 |
+
embed = {
|
683 |
+
"title": f"Value Update: {item_name} ({category})",
|
684 |
+
"color": 3447003, # Blue
|
685 |
+
"fields": [
|
686 |
+
{"name": "Field Changed", "value": change_info_webhook['field'], "inline": True},
|
687 |
+
{"name": "Old Value", "value": f"`{change_info_webhook['old_value']}`", "inline": True},
|
688 |
+
{"name": "New Value", "value": f"`{change_info_webhook['new_value']}`", "inline": True},
|
689 |
+
{"name": "Item Notes", "value": new_item.get('notes', 'N/A')[:1020] or 'N/A', "inline": False}, # Limit notes length
|
690 |
+
],
|
691 |
+
"timestamp": timestamp_iso
|
692 |
+
}
|
693 |
+
if new_item.get('icon'):
|
694 |
+
embed["thumbnail"] = {"url": new_item['icon']}
|
695 |
+
webhook_tasks.append(send_webhook_notification(session, VALUE_WEBHOOK_URL, embed))
|
696 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
697 |
if category_changes_for_api:
|
698 |
detected_value_changes_for_api[category] = category_changes_for_api
|
699 |
+
if cache["is_ready"] and VALUE_WEBHOOK_URL:
|
700 |
+
logger.info(f"Prepared {len(webhook_tasks)} value change webhooks.")
|
701 |
+
elif not VALUE_WEBHOOK_URL:
|
702 |
+
logger.info("VALUE_WEBHOOK_URL not set, skipping value change webhook sending.")
|
703 |
+
elif not cache["is_ready"]:
|
704 |
+
logger.info("Skipping value change webhook sending during initial cache population.")
|
705 |
else:
|
706 |
+
logger.warning("Skipping value change detection and webhooks due to fetch/processing errors.")
|
707 |
|
708 |
|
709 |
+
# 2. New Scammers / DWC (Only if cache is ready)
|
710 |
+
if cache["is_ready"] and SCAMMER_WEBHOOK_URL and \
|
711 |
+
"scammer_dwc_batch" not in current_errors and \
|
712 |
+
not any(k.startswith("process_") and k in ["process_user_scammers", "process_server_scammers", "process_dwc"] for k in current_errors):
|
713 |
+
|
714 |
+
logger.info("Detecting new scammer/DWC entries for webhooks (cache is ready)...")
|
715 |
initial_webhook_task_count = len(webhook_tasks)
|
716 |
+
added_new_scammer_webhook = False
|
717 |
+
|
718 |
+
# --- User Scammers ---
|
719 |
+
# Create keys robust to None values
|
720 |
+
def get_user_scammer_key(item):
|
721 |
+
return (item.get('discord_id') or 'none', item.get('roblox_username') or 'none')
|
722 |
+
old_user_keys = set(get_user_scammer_key(item) for item in cache.get("user_scammers", []))
|
723 |
|
|
|
|
|
724 |
for item in new_cache_data.get("user_scammers", []):
|
725 |
+
key = get_user_scammer_key(item)
|
726 |
if key not in old_user_keys:
|
727 |
logger.info(f"New User Scammer detected: Discord={item.get('discord_id')}, Roblox={item.get('roblox_username')}")
|
728 |
embed = {
|
729 |
+
"title": "🚨 New User Scammer Added", "color": 15158332, # Red
|
|
|
730 |
"fields": [
|
731 |
{"name": "Discord ID", "value": f"`{item.get('discord_id', 'N/A')}`", "inline": True},
|
732 |
{"name": "Roblox User", "value": f"`{item.get('roblox_username', 'N/A')}`", "inline": True},
|
733 |
+
{"name": "Scam Type", "value": item.get('scam_type', 'N/A')[:1020] or 'N/A', "inline": False},
|
734 |
{"name": "Explanation", "value": item.get('explanation', 'N/A')[:1020] or 'N/A', "inline": False},
|
735 |
+
], "timestamp": timestamp_iso
|
|
|
736 |
}
|
737 |
+
if item.get('evidence_link'): embed["fields"].append({"name": "Evidence", "value": item['evidence_link'], "inline": False})
|
738 |
+
if item.get('alt_accounts'): embed["fields"].append({"name": "Alt Accounts", "value": ", ".join([f"`{a}`" for a in item['alt_accounts']])[:1020] or 'N/A', "inline": False}) # Limit length
|
739 |
+
if item.get('roblox_avatar_url'): embed["thumbnail"] = {"url": item['roblox_avatar_url']}
|
|
|
|
|
|
|
740 |
webhook_tasks.append(send_webhook_notification(session, SCAMMER_WEBHOOK_URL, embed))
|
741 |
+
added_new_scammer_webhook = True
|
742 |
+
|
743 |
+
# --- Server Scammers ---
|
744 |
+
def get_server_scammer_key(item):
|
745 |
+
return (item.get('server_id') or 'none', item.get('server_name') or 'none')
|
746 |
+
old_server_keys = set(get_server_scammer_key(item) for item in cache.get("server_scammers", []))
|
747 |
|
|
|
|
|
748 |
for item in new_cache_data.get("server_scammers", []):
|
749 |
+
key = get_server_scammer_key(item)
|
750 |
if key not in old_server_keys:
|
751 |
logger.info(f"New Server Scammer detected: ID={item.get('server_id')}, Name={item.get('server_name')}")
|
752 |
embed = {
|
753 |
+
"title": "🚨 New Server Scammer Added", "color": 15158332, # Red
|
|
|
754 |
"fields": [
|
755 |
{"name": "Server ID", "value": f"`{item.get('server_id', 'N/A')}`", "inline": True},
|
756 |
{"name": "Server Name", "value": f"`{item.get('server_name', 'N/A')}`", "inline": True},
|
757 |
+
{"name": "Scam Type", "value": item.get('scam_type', 'N/A')[:1020] or 'N/A', "inline": False},
|
758 |
{"name": "Explanation", "value": item.get('explanation', 'N/A')[:1020] or 'N/A', "inline": False},
|
759 |
+
], "timestamp": timestamp_iso
|
|
|
760 |
}
|
761 |
+
if item.get('evidence_link'): embed["fields"].append({"name": "Evidence", "value": item['evidence_link'], "inline": False})
|
|
|
762 |
webhook_tasks.append(send_webhook_notification(session, SCAMMER_WEBHOOK_URL, embed))
|
763 |
+
added_new_scammer_webhook = True
|
764 |
+
|
765 |
+
# --- DWC Entries ---
|
766 |
+
def get_dwc_key(item):
|
767 |
+
# Use a combination of available identifiers as the key
|
768 |
+
return (
|
769 |
+
item.get('discord_user_id') or 'none',
|
770 |
+
item.get('discord_server_id') or 'none',
|
771 |
+
item.get('roblox_username') or 'none'
|
772 |
+
)
|
773 |
+
old_dwc_keys = set(get_dwc_key(item) for item in cache.get("dwc", []))
|
774 |
|
|
|
|
|
775 |
for item in new_cache_data.get("dwc", []):
|
776 |
+
key = get_dwc_key(item)
|
777 |
if key not in old_dwc_keys:
|
778 |
logger.info(f"New DWC Entry detected: User={item.get('discord_user_id')}, Server={item.get('discord_server_id')}, Roblox={item.get('roblox_username')}")
|
779 |
embed = {
|
780 |
+
"title": "⚠️ New DWC Entry Added", "color": 15105570, # Orange/Dark Yellow
|
|
|
781 |
"fields": [
|
782 |
{"name": "Discord User ID", "value": f"`{item.get('discord_user_id', 'N/A')}`", "inline": True},
|
783 |
{"name": "Discord Server ID", "value": f"`{item.get('discord_server_id', 'N/A')}`", "inline": True},
|
784 |
{"name": "Roblox User", "value": f"`{item.get('roblox_username', 'N/A')}`", "inline": True},
|
785 |
{"name": "Explanation", "value": item.get('explanation', 'N/A')[:1020] or 'N/A', "inline": False},
|
786 |
+
], "timestamp": timestamp_iso
|
|
|
787 |
}
|
788 |
+
if item.get('evidence_link'): embed["fields"].append({"name": "Evidence", "value": item['evidence_link'], "inline": False})
|
789 |
+
if item.get('alt_accounts'): embed["fields"].append({"name": "Alt Accounts", "value": ", ".join([f"`{a}`" for a in item['alt_accounts']])[:1020] or 'N/A', "inline": False})
|
790 |
+
if item.get('roblox_avatar_url'): embed["thumbnail"] = {"url": item['roblox_avatar_url']}
|
|
|
|
|
|
|
791 |
webhook_tasks.append(send_webhook_notification(session, SCAMMER_WEBHOOK_URL, embed))
|
792 |
+
added_new_scammer_webhook = True
|
793 |
|
794 |
+
if added_new_scammer_webhook:
|
795 |
+
logger.info(f"Prepared {len(webhook_tasks) - initial_webhook_task_count} new scammer/DWC webhooks.")
|
796 |
+
|
797 |
+
elif not cache["is_ready"]:
|
798 |
+
logger.info("Skipping new scammer webhook detection during initial cache population.")
|
799 |
elif not SCAMMER_WEBHOOK_URL:
|
800 |
+
logger.info("SCAMMER_WEBHOOK_URL not set, skipping new scammer webhook detection.")
|
801 |
+
else: # Errors occurred
|
802 |
+
logger.warning("Skipping new scammer webhook detection due to fetch/processing errors.")
|
803 |
+
|
804 |
|
805 |
# --- Send Webhooks Concurrently ---
|
806 |
if webhook_tasks:
|
|
|
808 |
await asyncio.gather(*webhook_tasks)
|
809 |
logger.info("Finished sending webhook notifications.")
|
810 |
else:
|
811 |
+
logger.info("No webhooks prepared to send for this cycle.")
|
812 |
|
813 |
|
814 |
# --- Final Cache Update ---
|
815 |
update_occurred = False
|
816 |
+
# Determine if this cycle *should* mark the cache as ready
|
817 |
+
can_set_ready = not cache["is_ready"] and not current_errors # Only set ready on first *fully successful* run
|
818 |
+
|
819 |
if not current_errors: # Perfect cycle
|
820 |
logger.info("Updating full cache (no errors during fetch or processing).")
|
821 |
cache["values"] = fetched_values_categories
|
|
|
825 |
cache["dupes"] = new_cache_data["dupes"]
|
826 |
cache["value_changes"] = detected_value_changes_for_api # Store the detected changes
|
827 |
cache["last_updated"] = current_time
|
828 |
+
if can_set_ready:
|
829 |
+
logger.info("Marking cache as ready after initial successful population.")
|
830 |
+
cache["is_ready"] = True
|
831 |
cache["service_available"] = True # Mark as available on success
|
832 |
update_occurred = True
|
833 |
logger.info(f"Cache update cycle completed successfully.")
|
|
|
837 |
|
838 |
# Update values only if the values/dupes batch succeeded AND processing succeeded
|
839 |
if "values_dupes_batch" not in current_errors and not any(k.startswith("process_values_") for k in current_errors):
|
|
|
840 |
if cache.get("values") != fetched_values_categories:
|
841 |
cache["values"] = fetched_values_categories
|
842 |
cache["value_changes"] = detected_value_changes_for_api # Update changes along with values
|
|
|
845 |
else:
|
846 |
logger.warning("Skipping update for 'values' due to errors.")
|
847 |
|
|
|
848 |
# Update dupes only if the values/dupes batch succeeded AND processing succeeded
|
849 |
if "values_dupes_batch" not in current_errors and "process_dupes" not in current_errors:
|
850 |
if cache.get("dupes") != new_cache_data["dupes"]:
|
|
|
868 |
else:
|
869 |
logger.warning("Skipping update for 'user_scammers', 'server_scammers', 'dwc' due to batch fetch error.")
|
870 |
|
|
|
871 |
if update_occurred:
|
872 |
cache["last_updated"] = current_time # Mark partial update time
|
873 |
+
# Mark cache ready only if it was *already* ready and we managed a partial update
|
874 |
+
# Or if this was the first run AND it was partially successful (maybe relax this?)
|
875 |
+
# Let's stick to: only mark ready on first FULL success.
|
876 |
+
if cache["is_ready"]: # If it was already ready, keep it ready
|
877 |
+
logger.info(f"Partially updated cache sections: {', '.join(partial_update_details)}. Cache remains ready.")
|
878 |
+
else:
|
879 |
+
logger.info(f"Partially updated cache sections: {', '.join(partial_update_details)}. Cache remains NOT ready (requires full success on first run).")
|
880 |
+
# Keep service_available based on whether fetch errors occurred
|
881 |
else:
|
882 |
logger.error(f"Cache update cycle failed, and no parts could be updated based on errors. Errors: {current_errors}")
|
883 |
+
# Cache readiness and service availability remain unchanged
|
884 |
|
885 |
except Exception as e:
|
886 |
logger.exception(f"Critical error during cache update cycle: {e}")
|
|
|
907 |
user_id = await get_roblox_user_id(session, roblox_username)
|
908 |
if user_id:
|
909 |
new_avatar = await get_roblox_avatar_url(session, user_id)
|
910 |
+
# else: # User ID not found, keep avatar as None
|
911 |
+
# logger.debug(f"Roblox user ID not found for username: {roblox_username}")
|
912 |
|
913 |
except Exception as e:
|
914 |
# Log errors but don't stop the main update loop
|
|
|
916 |
# Keep new_avatar as None on error
|
917 |
|
918 |
finally:
|
919 |
+
# Update the entry dict directly
|
920 |
entry['roblox_avatar_url'] = new_avatar
|
921 |
|
922 |
|
|
|
932 |
logger.warning("SCAMMER_WEBHOOK_URL environment variable not set. New scammer notifications disabled.")
|
933 |
if not VALUE_WEBHOOK_URL:
|
934 |
logger.warning("VALUE_WEBHOOK_URL environment variable not set. Value change notifications disabled.")
|
935 |
+
if not DUPE_CHECK_WEBHOOK_URL:
|
936 |
+
logger.warning("WEBHOOK_URL (for dupe checks) environment variable not set. Dupe check notifications disabled.")
|
937 |
asyncio.create_task(update_cache_periodically())
|
938 |
|
939 |
|
|
|
947 |
raise HTTPException(status_code=503, detail="Service temporarily unavailable due to backend connection issues. Please try again later.")
|
948 |
else:
|
949 |
raise HTTPException(status_code=503, detail="Cache is initializing or data is currently unavailable. Please try again shortly.")
|
950 |
+
|
|
|
|
|
951 |
|
952 |
@app.get("/")
|
953 |
async def root():
|
|
|
985 |
matched_category = next((c for c in cache.get("values", {}).keys() if c.lower() == category.lower()), None)
|
986 |
if not matched_category:
|
987 |
# Check if the category *exists* conceptually even if empty
|
988 |
+
valid_categories_lower = [c.lower() for c in CATEGORIES]
|
989 |
+
if category.lower() in valid_categories_lower:
|
990 |
+
return {category: []} # Return empty list if category is valid but has no items yet
|
991 |
else:
|
992 |
raise HTTPException(status_code=404, detail=f"Category '{category}' not found.")
|
993 |
return {matched_category: cache.get("values", {}).get(matched_category, [])}
|
|
|
1001 |
matched_category = next((c for c in cache.get("value_changes", {}).keys() if c.lower() == category.lower()), None)
|
1002 |
if not matched_category:
|
1003 |
# Check if the category *exists* conceptually even if empty
|
1004 |
+
valid_categories_lower = [c.lower() for c in CATEGORIES]
|
1005 |
+
if category.lower() in valid_categories_lower:
|
1006 |
return {category: []} # Return empty list if category is valid but had no changes
|
1007 |
else:
|
1008 |
raise HTTPException(status_code=404, detail=f"Category '{category}' not found.")
|
|
|
1036 |
|
1037 |
@app.post("/api/check")
|
1038 |
async def check_username(data: UsernameCheck):
|
1039 |
+
"""Check if a username is duped using cached data and optionally send webhook"""
|
1040 |
check_cache_readiness() # Use the standard readiness check
|
1041 |
|
1042 |
username_to_check = data.username.strip().lower()
|
|
|
1044 |
dupes_list = cache.get("dupes", [])
|
1045 |
is_duped = username_to_check in dupes_list
|
1046 |
|
1047 |
+
# Webhook notification for checks resulting in "Not Found"
|
|
|
|
|
1048 |
if not is_duped:
|
1049 |
+
if DUPE_CHECK_WEBHOOK_URL:
|
|
|
|
|
1050 |
async def send_check_webhook():
|
1051 |
try:
|
1052 |
+
# Use a short-lived session for this potentially frequent task
|
1053 |
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
1054 |
embed = {
|
1055 |
"title": "User Dupe Check - Not Found",
|
1056 |
"description": f"Username `{data.username}` was checked against the dupe list but was **not** found.",
|
1057 |
"color": 16776960, # Yellow
|
1058 |
"timestamp": datetime.now(timezone.utc).isoformat()
|
1059 |
}
|
1060 |
+
await send_webhook_notification(session, DUPE_CHECK_WEBHOOK_URL, embed)
|
1061 |
except Exception as e:
|
1062 |
+
logger.error(f"Error sending dupe check webhook: {e}")
|
1063 |
|
1064 |
asyncio.create_task(send_check_webhook()) # Fire and forget
|
1065 |
else:
|