saq1b commited on
Commit
f5739d8
·
verified ·
1 Parent(s): bf0acaf

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +237 -147
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
- if isinstance(value, str) and not re.match(r'^-?[\d,.$]+\$?$', value.strip()):
 
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 value_str is None or str(value_str).strip().lower() == 'n/a':
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 (Unchanged) ---
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
- async with session.post(url, json=payload) as response:
 
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
- async with session.get(url) as response:
 
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: # Expected range like B6:P
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
- if len(row) > 4 and isinstance(row[4], str) and re.search(r'LEVEL \d+ \|', row[4]):
 
 
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
- if cleaned_name == 'N/A': # Skip rows without a valid name
 
 
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) < 3: continue
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
- return [row[0].strip().lower() for row in values if row and len(row)>0 and row[0] and isinstance(row[0], str) and row[0].strip()]
 
 
 
 
 
 
 
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
- error_details = json.loads(e.content).get('error', {})
392
- status = error_details.get('status')
393
- message = error_details.get('message')
 
 
 
 
 
 
394
  logger.error(f"Google API HTTP Error during batch fetch for {spreadsheet_id}: Status={status}, Message={message}")
395
- # Handle potential API key/permission issues explicitly
396
- if status == 'PERMISSION_DENIED' or status == 'UNAUTHENTICATED':
397
- logger.critical(f"Authentication/Permission Error accessing {spreadsheet_id}. Please check credentials/API access.")
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.") # Optional: Log less verbosely
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
- logger.warning(f"Failed to send webhook (Status: {response.status}): {await response.text()}")
422
- # else: # Optional: Log success, can be verbose
423
- # logger.info(f"Webhook notification sent successfully to {webhook_url[:30]}...")
 
 
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 (Refactored for Batching & Webhooks) ---
432
 
433
  async def update_cache_periodically():
434
- """Fetches data, processes, detects changes/new entries, sends webhooks, and updates cache."""
435
  global cache
436
- async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session: # Overall session timeout
 
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
- logger.info("Fetching Roblox avatars for newly processed data...")
571
- avatar_tasks = []
572
- # Combine lists needing avatars from the *newly fetched* data
573
- entries_needing_avatars = new_cache_data.get("user_scammers", []) + new_cache_data.get("dwc", [])
574
- for entry in entries_needing_avatars:
575
- if entry.get('roblox_username'):
576
- # Pass the specific entry dict to the update function
577
- avatar_tasks.append(fetch_avatar_for_entry_update(session, entry))
578
- if avatar_tasks:
579
- await asyncio.gather(*avatar_tasks) # Exceptions logged within helper
580
- logger.info(f"Finished fetching avatars for {len(avatar_tasks)} potential new entries.")
581
-
582
- # --- Change Detection & Webhook Preparation (BEFORE Cache Update) ---
 
 
 
 
583
  current_time = datetime.now(timezone.utc)
584
  timestamp_iso = current_time.isoformat()
 
 
 
 
585
 
586
- # 1. Value Changes (Existing Logic + Webhook Prep)
587
- detected_value_changes_for_api = {} # For the /api/value-changes endpoint
588
- if "values" not in cache: cache["values"] = {} # Ensure exists for comparison
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
- logger.info(f"Prepared {len(webhook_tasks)} value change webhooks.")
635
- elif not VALUE_WEBHOOK_URL:
636
- logger.info("VALUE_WEBHOOK_URL not set, skipping value change webhook detection.")
 
 
 
637
  else:
638
- logger.warning("Skipping value change webhook detection due to fetch/processing errors.")
639
 
640
 
641
- # 2. New Scammers / DWC (New Logic + Webhook Prep)
642
- if SCAMMER_WEBHOOK_URL and "scammer_dwc_batch" not in current_errors and not any(k.startswith("process_") and k in ["process_user_scammers", "process_server_scammers", "process_dwc"] for k in current_errors):
643
- logger.info("Detecting new scammer/DWC entries for webhooks...")
 
 
 
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.get('discord_id'), item.get('roblox_username'))
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
- embed["fields"].append({"name": "Evidence", "value": item['evidence_link'], "inline": False})
665
- if item.get('alt_accounts'):
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.get('server_id'), item.get('server_name'))
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.get('discord_user_id'), item.get('discord_server_id'), item.get('roblox_username'))
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
- embed["fields"].append({"name": "Evidence", "value": item['evidence_link'], "inline": False})
711
- if item.get('alt_accounts'):
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
- logger.info(f"Prepared {len(webhook_tasks) - initial_webhook_task_count} new scammer/DWC webhooks.")
 
 
 
 
718
  elif not SCAMMER_WEBHOOK_URL:
719
- logger.info("SCAMMER_WEBHOOK_URL not set, skipping new scammer webhook detection.")
720
- else:
721
- logger.warning("Skipping new scammer webhook detection due to fetch/processing errors.")
 
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
- cache["is_ready"] = True
 
 
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["is_ready"] = True # Allow access even if partial
790
- # Keep service_available as potentially false if there were fetch errors
791
- logger.info(f"Partially updated cache sections: {', '.join(partial_update_details)}")
 
 
 
 
 
792
  else:
793
  logger.error(f"Cache update cycle failed, and no parts could be updated based on errors. Errors: {current_errors}")
794
- # Keep cache["is_ready"] as it was. Don't update timestamp.
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 (no need to check if changed, just set it)
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
- # Optional: Add check for staleness?
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
- if category.lower() in [c.lower() for c in CATEGORIES]:
898
- return {category: []} # Return empty list if category is valid but has no items
 
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
- if category.lower() in [c.lower() for c in CATEGORIES]:
 
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" (runs in background)
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
- webhook_url = os.getenv("WEBHOOK_URL") # Keep original env var name for this specific check
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
- async with aiohttp.ClientSession() as session: # Create a short-lived session
 
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, webhook_url, embed)
971
  except Exception as e:
972
- logger.error(f"Error sending dupe check webhook: {e}") # Log errors from the task
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: