saq1b commited on
Commit
2765d51
·
verified ·
1 Parent(s): eb1ed4d

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +671 -671
main.py CHANGED
@@ -1,672 +1,672 @@
1
- # main.py
2
- from fastapi import FastAPI, HTTPException
3
- from fastapi.middleware.cors import CORSMiddleware
4
- from pydantic import BaseModel
5
- from typing import Optional, Any
6
- import aiohttp
7
- import os
8
- from datetime import datetime, timezone # <-- Add timezone
9
- import json
10
- import re
11
- from google.oauth2.service_account import Credentials as ServiceAccountCredentials
12
- from googleapiclient.discovery import build
13
- from dotenv import load_dotenv
14
- import asyncio
15
- import logging # <-- Add logging
16
-
17
- # --- Logging Setup ---
18
- logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
19
- logger = logging.getLogger(__name__)
20
-
21
- app = FastAPI()
22
-
23
- # --- Configuration ---
24
- load_dotenv()
25
-
26
- # CORS
27
- app.add_middleware(
28
- CORSMiddleware,
29
- allow_origins=["*"], # Consider restricting in production
30
- allow_credentials=True,
31
- allow_methods=["*"],
32
- allow_headers=["*"],
33
- )
34
-
35
- # Google Sheets Config
36
- SPREADSHEET_ID = '1sgkhBNGw_r6tBIxvdeXaI0bVmWBeACN4jiw_oDEeXLw'
37
- VALUES_SPREADSHEET_ID = '1Toe07o3P517q8sm9Qb1e5xyFWCuwgskj71IKJwJNfNU'
38
- SCOPES = ['https://www.googleapis.com/auth/spreadsheets.readonly']
39
- USER_SCAMMER_SHEET = "User Scammer Files"
40
- SERVER_SCAMMER_SHEET = "Server Scammer Files"
41
- DWC_SHEET = "DWC Servers / Users"
42
- DUPE_LIST_SHEET = "Dupe List"
43
- CATEGORIES = [
44
- "Vehicles", "Textures", "Colours", "Spoilers",
45
- "Rims", "Furnitures", "Gun Skins", "Hyperchromes"
46
- ]
47
-
48
- # Cache Update Interval
49
- CACHE_UPDATE_INTERVAL_SECONDS = 60 # 1 minute
50
-
51
- # --- Global Cache ---
52
- cache = {
53
- "values": {},
54
- "value_changes": {},
55
- "user_scammers": [],
56
- "server_scammers": [],
57
- "dwc": [],
58
- "dupes": [],
59
- "last_updated": None,
60
- "is_ready": False,
61
- "service_available": True
62
- }
63
- # --- Google Sheets Initialization ---
64
- sheets_service = None # Initialize as None
65
-
66
- def init_google_sheets(scopes=SCOPES):
67
- """Initialize Google Sheets credentials from environment variable"""
68
- global sheets_service, cache # Allow modifying global vars
69
- try:
70
- creds_json_str = os.getenv('CREDENTIALS_JSON')
71
- if not creds_json_str:
72
- logger.error("CREDENTIALS_JSON environment variable not found")
73
- raise ValueError("CREDENTIALS_JSON environment variable not found")
74
- creds_json = json.loads(creds_json_str)
75
- creds = ServiceAccountCredentials.from_service_account_info(
76
- creds_json,
77
- scopes=scopes
78
- )
79
- sheets_service = build('sheets', 'v4', credentials=creds)
80
- logger.info("Google Sheets service initialized successfully from ENV VAR.")
81
- cache["service_available"] = True
82
- return sheets_service
83
- except Exception as e:
84
- logger.error(f"Error initializing Google Sheets from ENV VAR: {e}")
85
- # Fallback attempt (optional)
86
- try:
87
- logger.info("Falling back to loading credentials from file 'credentials.json'")
88
- creds = ServiceAccountCredentials.from_service_account_file(
89
- 'credentials.json',
90
- scopes=scopes
91
- )
92
- sheets_service = build('sheets', 'v4', credentials=creds)
93
- logger.info("Google Sheets service initialized successfully from file.")
94
- cache["service_available"] = True
95
- return sheets_service
96
- except Exception as file_e:
97
- logger.error(f"Error loading credentials from file: {file_e}")
98
- logger.critical("Google Sheets service could not be initialized. API will be limited.")
99
- cache["service_available"] = False
100
- sheets_service = None # Ensure it's None if failed
101
- return None
102
-
103
- # Initialize on module load
104
- init_google_sheets()
105
-
106
-
107
- # --- Helper Functions (Mostly unchanged) ---
108
-
109
- def extract_drive_id(url):
110
- if not url or not isinstance(url, str): return None
111
- match = re.search(r'https://drive\.google\.com/file/d/([^/]+)', url)
112
- return match.group(1) if match else None
113
-
114
- def convert_to_thumbnail_url(drive_url):
115
- drive_id = extract_drive_id(drive_url)
116
- return f"https://drive.google.com/thumbnail?id={drive_id}&sz=w1000" if drive_id else drive_url
117
-
118
- def extract_image_url(formula, drive_url=None):
119
- if drive_url and isinstance(drive_url, str) and 'drive.google.com' in drive_url:
120
- return convert_to_thumbnail_url(drive_url)
121
- if not formula or not isinstance(formula, str): return ''
122
- if formula.startswith('=IMAGE('):
123
- match = re.search(r'=IMAGE\("([^"]+)"', formula)
124
- if match: return match.group(1)
125
- return formula
126
-
127
- def format_currency(value: Any) -> Optional[str]:
128
- if value is None or str(value).strip() == '': return 'N/A'
129
- try:
130
- num_str = str(value).replace('$', '').replace(',', '').strip()
131
- if not num_str or num_str.lower() == 'n/a': return 'N/A'
132
- num = float(num_str)
133
- return f"${num:,.0f}"
134
- except (ValueError, TypeError):
135
- # Check if it's non-numeric text before returning N/A
136
- if isinstance(value, str) and not re.match(r'^-?[\d,.]+\$?$', value.strip()):
137
- return value.strip() # Return original text if it doesn't look like a number/currency
138
- return 'N/A' # Default to N/A if conversion fails
139
-
140
- def parse_cached_currency(value_str: Optional[str]) -> Optional[float]:
141
- if value_str is None or value_str.lower() == 'n/a':
142
- return None
143
- try:
144
- num_str = value_str.replace('$', '').replace(',', '').strip()
145
- return float(num_str)
146
- except (ValueError, TypeError):
147
- return None # Cannot parse
148
-
149
- def clean_string(value, default='N/A'):
150
- if value is None: return default
151
- cleaned = str(value).strip()
152
- return cleaned if cleaned else default
153
-
154
- def clean_string_optional(value):
155
- if value is None: return None
156
- cleaned = str(value).strip()
157
- return cleaned if cleaned and cleaned != '-' else None
158
-
159
- def parse_alt_accounts(value):
160
- if value is None: return []
161
- raw_string = str(value).strip()
162
- if not raw_string or raw_string == '-': return []
163
- return [acc.strip() for acc in raw_string.split(',') if acc.strip()]
164
-
165
-
166
- # --- Roblox API Helpers (Unchanged) ---
167
- async def get_roblox_user_id(session: aiohttp.ClientSession, username: str):
168
- if not username: return None
169
- url = "https://users.roblox.com/v1/usernames/users"
170
- payload = {"usernames": [username], "excludeBannedUsers": False}
171
- try:
172
- async with session.post(url, json=payload) as response:
173
- if response.status == 200:
174
- data = await response.json()
175
- if data and data.get("data") and len(data["data"]) > 0:
176
- return data["data"][0].get("id")
177
- # else: logger.warning(f"Roblox User ID API non-200 status for {username}: {response.status}") # Maybe too noisy
178
- return None
179
- except asyncio.TimeoutError:
180
- logger.warning(f"Timeout fetching Roblox User ID for {username}")
181
- return None
182
- except aiohttp.ClientError as e:
183
- logger.warning(f"Network error fetching Roblox User ID for {username}: {e}")
184
- return None
185
- except Exception as e:
186
- logger.error(f"Unexpected exception fetching Roblox User ID for {username}: {e}")
187
- return None
188
-
189
- async def get_roblox_avatar_url(session: aiohttp.ClientSession, user_id: int):
190
- if not user_id: return None
191
- url = f"https://thumbnails.roblox.com/v1/users/avatar-headshot?userIds={user_id}&size=150x150&format=Png&isCircular=false"
192
- try:
193
- async with session.get(url) as response:
194
- if response.status == 200:
195
- data = await response.json()
196
- if data and data.get("data") and len(data["data"]) > 0:
197
- return data["data"][0].get("imageUrl")
198
- # else: logger.warning(f"Roblox Avatar API non-200 status for User ID {user_id}: {response.status}") # Maybe too noisy
199
- return None
200
- except asyncio.TimeoutError:
201
- logger.warning(f"Timeout fetching Roblox avatar for User ID {user_id}")
202
- return None
203
- except aiohttp.ClientError as e:
204
- logger.warning(f"Network error fetching Roblox avatar for User ID {user_id}: {e}")
205
- return None
206
- except Exception as e:
207
- logger.error(f"Unexpected exception fetching Roblox avatar for User ID {user_id}: {e}")
208
- return None
209
-
210
-
211
- # --- Data Processing Functions (Unchanged) ---
212
- def process_sheet_data(values):
213
- """Process raw sheet data into structured format for values"""
214
- if not values: return []
215
- processed_data = []
216
- for row in values:
217
- if not row or not any(cell.strip() for cell in row if cell): continue
218
- drive_url = row[14] if len(row) > 14 else None
219
- filtered_row = [cell for i, cell in enumerate(row) if i % 2 == 0]
220
- if len(filtered_row) >= 4 and isinstance(filtered_row[3], str) and re.search(r'LEVEL \d+ \| HYPERCHROMES', filtered_row[3]): continue
221
- if len(filtered_row) >= 6:
222
- processed_item = {
223
- 'icon': extract_image_url(filtered_row[0], drive_url),
224
- 'name': clean_string(filtered_row[1], 'N/A') if len(filtered_row) > 1 else 'N/A',
225
- 'value': format_currency(filtered_row[2]) if len(filtered_row) > 2 else 'N/A',
226
- 'dupedValue': format_currency(filtered_row[3]) if len(filtered_row) > 3 else 'N/A',
227
- 'marketValue': format_currency(filtered_row[4]) if len(filtered_row) > 4 else 'N/A',
228
- 'demand': clean_string(filtered_row[5], 'N/A') if len(filtered_row) > 5 else 'N/A',
229
- 'notes': clean_string(filtered_row[6], '') if len(filtered_row) > 6 else ''
230
- }
231
- processed_data.append(processed_item)
232
- return processed_data
233
-
234
- def process_user_scammer_data(values):
235
- """Process raw user scammer data"""
236
- if not values: return []
237
- processed_data = []
238
- for row in values:
239
- if not row or len(row) < 2 or not any(clean_string_optional(cell) for cell in row[:2]): continue
240
- discord_id = clean_string_optional(row[0]) if len(row) > 0 else None
241
- roblox_username = clean_string_optional(row[1]) if len(row) > 1 else None
242
- if not discord_id and not roblox_username: continue
243
- processed_item = {
244
- 'discord_id': discord_id,
245
- 'roblox_username': roblox_username,
246
- 'scam_type': clean_string(row[2]) if len(row) > 2 else 'N/A',
247
- 'explanation': clean_string(row[3]) if len(row) > 3 else 'N/A',
248
- 'evidence_link': clean_string_optional(row[4]) if len(row) > 4 else None,
249
- 'alt_accounts': parse_alt_accounts(row[5]) if len(row) > 5 else [],
250
- 'roblox_avatar_url': None # Placeholder
251
- }
252
- processed_data.append(processed_item)
253
- return processed_data
254
-
255
- def process_server_scammer_data(values):
256
- """Process raw server scammer data"""
257
- if not values: return []
258
- processed_data = []
259
- for row in values:
260
- if not row or len(row) < 2 or not any(clean_string_optional(cell) for cell in row[:2]): continue
261
- server_id = clean_string_optional(row[0]) if len(row) > 0 else None
262
- server_name = clean_string_optional(row[1]) if len(row) > 1 else None
263
- if not server_id and not server_name: continue
264
- processed_item = {
265
- 'server_id': server_id,
266
- 'server_name': server_name,
267
- 'scam_type': clean_string(row[2]) if len(row) > 2 else 'N/A',
268
- 'explanation': clean_string(row[3]) if len(row) > 3 else 'N/A',
269
- 'evidence_link': clean_string_optional(row[4]) if len(row) > 4 else None
270
- }
271
- processed_data.append(processed_item)
272
- return processed_data
273
-
274
- def process_dwc_data(values):
275
- """Process raw DWC data"""
276
- if not values: return []
277
- processed_data = []
278
- for row in values:
279
- if not row or len(row) < 3 or not any(clean_string_optional(cell) for cell in row[:3]): continue
280
- user_id = clean_string_optional(row[0]) if len(row) > 0 else None
281
- server_id = clean_string_optional(row[1]) if len(row) > 1 else None
282
- roblox_user = clean_string_optional(row[2]) if len(row) > 2 else None
283
- if not user_id and not server_id and not roblox_user: continue
284
- processed_item = {
285
- 'status': 'DWC',
286
- 'discord_user_id': user_id,
287
- 'discord_server_id': server_id,
288
- 'roblox_username': roblox_user,
289
- 'explanation': clean_string(row[3]) if len(row) > 3 else 'N/A',
290
- 'evidence_link': clean_string_optional(row[4]) if len(row) > 4 else None,
291
- 'alt_accounts': parse_alt_accounts(row[5]) if len(row) > 5 else [],
292
- 'roblox_avatar_url': None # Placeholder
293
- }
294
- processed_data.append(processed_item)
295
- return processed_data
296
-
297
- def process_dupe_list_data(values):
298
- """Process raw dupe list data"""
299
- if not values: return []
300
- return [row[0].strip().lower() for row in values if row and row[0] and isinstance(row[0], str) and row[0].strip()]
301
-
302
-
303
- # --- Async Fetching Functions (Used by background task) ---
304
- async def fetch_sheet_data_async(sheet_name, range_name, processor, value_render_option='FORMATTED_VALUE', spreadsheet_id=SPREADSHEET_ID):
305
- """Async wrapper to fetch and process sheet data"""
306
- global sheets_service # Access the initialized service
307
- if not sheets_service:
308
- logger.warning(f"Attempted to fetch {sheet_name} but Sheets service is unavailable.")
309
- raise Exception("Google Sheets service not initialized") # Raise to signal failure in update task
310
-
311
- try:
312
- quoted_sheet_name = f"'{sheet_name}'" if not sheet_name.isalnum() else sheet_name
313
- full_range = f"{quoted_sheet_name}!{range_name}"
314
-
315
- loop = asyncio.get_event_loop()
316
- result = await loop.run_in_executor(
317
- None, # Default executor
318
- lambda: sheets_service.spreadsheets().values().get(
319
- spreadsheetId=spreadsheet_id,
320
- range=full_range,
321
- valueRenderOption=value_render_option
322
- ).execute()
323
- )
324
- values = result.get('values', [])
325
- return processor(values)
326
- except Exception as e:
327
- logger.error(f"Error fetching/processing {sheet_name} from {spreadsheet_id}: {e}")
328
- # Re-raise the exception so the update loop knows this part failed
329
- raise e
330
-
331
-
332
- # --- Background Cache Update Task ---
333
-
334
- async def update_cache_periodically():
335
- """Fetches data from sheets, detects value changes, and updates the cache periodically."""
336
- global cache
337
- async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=15)) as session:
338
- while True:
339
- if not cache["service_available"]:
340
- logger.warning("Google Sheets service unavailable, skipping cache update cycle.")
341
- await asyncio.sleep(CACHE_UPDATE_INTERVAL_SECONDS)
342
- continue
343
-
344
- logger.info("Starting cache update cycle...")
345
- start_time = datetime.now(timezone.utc)
346
- success = True
347
-
348
- new_cache_data = {
349
- "values": {},
350
- "user_scammers": [],
351
- "server_scammers": [],
352
- "dwc": [],
353
- "dupes": [],
354
- }
355
- detected_value_changes = {} # Store changes detected *in this cycle*
356
-
357
- try:
358
- # --- Fetch all data concurrently ---
359
- fetch_tasks = {
360
- "user_scammers": fetch_sheet_data_async(USER_SCAMMER_SHEET, 'B6:G', process_user_scammer_data, spreadsheet_id=SPREADSHEET_ID),
361
- "server_scammers": fetch_sheet_data_async(SERVER_SCAMMER_SHEET, 'B6:F', process_server_scammer_data, spreadsheet_id=SPREADSHEET_ID),
362
- "dwc": fetch_sheet_data_async(DWC_SHEET, 'B6:G', process_dwc_data, spreadsheet_id=SPREADSHEET_ID),
363
- "dupes": fetch_sheet_data_async(DUPE_LIST_SHEET, 'B2:B', process_dupe_list_data, spreadsheet_id=VALUES_SPREADSHEET_ID),
364
- # Add tasks for each value category
365
- **{f"values_{cat}": fetch_sheet_data_async(
366
- cat, 'B6:P', process_sheet_data,
367
- value_render_option='FORMULA',
368
- spreadsheet_id=VALUES_SPREADSHEET_ID
369
- ) for cat in CATEGORIES}
370
- }
371
-
372
- results = await asyncio.gather(*fetch_tasks.values(), return_exceptions=True)
373
- task_keys = list(fetch_tasks.keys())
374
-
375
- # --- Process results and update temporary cache ---
376
- fetched_values = {}
377
- current_errors = {} # Track errors for specific keys
378
- for i, result in enumerate(results):
379
- key = task_keys[i]
380
- if isinstance(result, Exception):
381
- logger.error(f"Failed to fetch data for {key}: {result}")
382
- success = False
383
- current_errors[key] = str(result) # Log the error
384
- # Decide: keep old data or clear? We'll keep old cache data by not updating below
385
- else:
386
- if key.startswith("values_"):
387
- category_name = key.split("_", 1)[1]
388
- fetched_values[category_name] = result
389
- elif key in new_cache_data: # Only update if key exists
390
- new_cache_data[key] = result
391
- else:
392
- logger.warning(f"Fetched data for unknown key: {key}")
393
-
394
- # --- Detect Value Changes ---
395
- logger.info("Comparing fetched values with cached values to detect changes...")
396
- current_time = datetime.now(timezone.utc)
397
- fields_to_compare = ['value', 'dupedValue', 'marketValue']
398
-
399
- for category, new_items in fetched_values.items():
400
- if category not in cache["values"]: # Category is new or wasn't cached before
401
- cache["values"][category] = [] # Ensure old category exists for comparison logic below
402
-
403
- # Create a lookup for old items by name for efficient comparison
404
- old_items_dict = {item['name']: item for item in cache["values"].get(category, [])}
405
- category_changes = []
406
-
407
- for new_item in new_items:
408
- item_name = new_item['name']
409
- if item_name in old_items_dict:
410
- old_item = old_items_dict[item_name]
411
- for field in fields_to_compare:
412
- old_val_str = old_item.get(field)
413
- new_val_str = new_item.get(field)
414
-
415
- # Normalize "N/A" and potential whitespace before comparison
416
- old_norm = old_val_str.strip().lower() if isinstance(old_val_str, str) else old_val_str
417
- new_norm = new_val_str.strip().lower() if isinstance(new_val_str, str) else new_val_str
418
-
419
- if old_norm == 'n/a': old_norm = None
420
- if new_norm == 'n/a': new_norm = None
421
-
422
- # Only record change if values are meaningfully different
423
- if old_norm != new_norm:
424
- # Try parsing for better comparison (optional but recommended)
425
- old_numeric = parse_cached_currency(old_val_str)
426
- new_numeric = parse_cached_currency(new_val_str)
427
-
428
- # Compare numeric if possible, otherwise string representations
429
- # (Handles cases like $10 vs $10.00 becoming same numeric 10.0)
430
- values_differ = False
431
- if old_numeric is not None and new_numeric is not None:
432
- if old_numeric != new_numeric:
433
- values_differ = True
434
- elif old_val_str != new_val_str: # Fallback to string comparison if parsing fails or types differ
435
- values_differ = True
436
-
437
- if values_differ:
438
- logger.info(f"Change detected in {category}: {item_name} - {field}: '{old_val_str}' -> '{new_val_str}'")
439
- category_changes.append({
440
- "item_name": item_name,
441
- "field": field,
442
- "old_value": old_val_str if old_val_str is not None else "N/A",
443
- "new_value": new_val_str if new_val_str is not None else "N/A",
444
- "timestamp": current_time.isoformat()
445
- })
446
- if category_changes:
447
- detected_value_changes[category] = category_changes
448
-
449
- # --- Fetch Roblox Avatars (no changes needed here) ---
450
- # ... (avatar fetching logic remains the same)
451
-
452
- # --- Final Cache Update ---
453
- # Only update the main cache if the fetch cycle didn't have critical errors
454
- # We allow partial updates if only some fetches failed.
455
- if not current_errors: # If no errors at all
456
- logger.info("Updating full cache.")
457
- cache["values"] = fetched_values
458
- cache["user_scammers"] = new_cache_data["user_scammers"]
459
- cache["server_scammers"] = new_cache_data["server_scammers"]
460
- cache["dwc"] = new_cache_data["dwc"]
461
- cache["dupes"] = new_cache_data["dupes"]
462
- cache["value_changes"] = detected_value_changes # Store the detected changes
463
- cache["last_updated"] = current_time
464
- cache["is_ready"] = True
465
- logger.info(f"Cache update cycle completed successfully.")
466
- else:
467
- # Update parts that *did* succeed, if any
468
- partial_update_occurred = False
469
- if fetched_values: # Only update values if *all* value fetches succeeded
470
- all_values_fetched = True
471
- for cat in CATEGORIES:
472
- if f"values_{cat}" in current_errors:
473
- all_values_fetched = False
474
- break
475
- if all_values_fetched:
476
- cache["values"] = fetched_values
477
- cache["value_changes"] = detected_value_changes # Update changes if values updated
478
- partial_update_occurred = True
479
- logger.info("Partially updated cache: Values updated.")
480
- else:
481
- logger.warning("Values cache not updated due to fetch errors in some categories.")
482
-
483
-
484
- # Update other sections if they succeeded
485
- for key in ["user_scammers", "server_scammers", "dwc", "dupes"]:
486
- if key not in current_errors and new_cache_data.get(key) is not None:
487
- cache[key] = new_cache_data[key]
488
- partial_update_occurred = True
489
- logger.info(f"Partially updated cache: {key} updated.")
490
-
491
- if partial_update_occurred:
492
- cache["last_updated"] = current_time # Mark partial update time
493
- cache["is_ready"] = True # Allow access even if partial
494
- logger.warning(f"Cache update cycle completed with errors: {current_errors}. Some data might be stale.")
495
- else:
496
- logger.error(f"Cache update cycle failed completely. No parts updated. Errors: {current_errors}")
497
- # Keep cache["is_ready"] as it was.
498
-
499
-
500
- except Exception as e:
501
- logger.exception(f"Critical error during cache update cycle: {e}")
502
- success = False # Should already be false if exception bubbled up
503
-
504
- # --- Wait for the next cycle ---
505
- end_time = datetime.now(timezone.utc)
506
- duration = (end_time - start_time).total_seconds()
507
- wait_time = max(0, CACHE_UPDATE_INTERVAL_SECONDS - duration)
508
- logger.info(f"Cache update duration: {duration:.2f}s. Waiting {wait_time:.2f}s for next cycle.")
509
- await asyncio.sleep(wait_time)
510
-
511
- # Helper specifically for the background task to update the dict in place
512
- async def fetch_avatar_for_entry_update(session: aiohttp.ClientSession, entry: dict):
513
- """Fetches avatar and updates the provided entry dictionary."""
514
- roblox_username = entry.get('roblox_username')
515
- if not roblox_username: return
516
-
517
- try:
518
- user_id = await get_roblox_user_id(session, roblox_username)
519
- if user_id:
520
- avatar_url = await get_roblox_avatar_url(session, user_id)
521
- entry['roblox_avatar_url'] = avatar_url # Update the dict directly
522
- # logger.debug(f"Avatar found for {roblox_username}") # Debug level
523
- # else: logger.debug(f"No Roblox user ID found for {roblox_username}") # Debug level
524
- except Exception as e:
525
- # Log errors but don't stop the main update loop
526
- logger.warning(f"Failed to fetch avatar for {roblox_username}: {e}")
527
- entry['roblox_avatar_url'] = None # Ensure it's None on error
528
-
529
-
530
- # --- FastAPI Startup Event ---
531
- @app.on_event("startup")
532
- async def startup_event():
533
- """Starts the background cache update task."""
534
- if cache["service_available"]:
535
- logger.info("Starting background cache update task...")
536
- asyncio.create_task(update_cache_periodically())
537
- else:
538
- logger.warning("Google Sheets service not available. Cache update task will not start.")
539
-
540
-
541
- # --- API Endpoints (Modified to use Cache) ---
542
-
543
- def check_service_availability():
544
- """Reusable check for API endpoints"""
545
- if not cache["service_available"]:
546
- raise HTTPException(status_code=503, detail="Google Sheets service unavailable. Cannot fetch data.")
547
- if not cache["is_ready"]:
548
- raise HTTPException(status_code=503, detail="Cache is not ready yet. Please try again shortly.")
549
-
550
- @app.get("/")
551
- async def root():
552
- return {"message": "JB Vanta API - Running"}
553
-
554
- @app.get("/api/status")
555
- async def get_status():
556
- """Returns the current status of the cache"""
557
- return {
558
- "cache_ready": cache["is_ready"],
559
- "sheets_service_available": cache["service_available"],
560
- "last_updated": cache["last_updated"].isoformat() if cache["last_updated"] else None,
561
- "cached_items": {
562
- "value_categories": len(cache["values"]),
563
- "user_scammers": len(cache["user_scammers"]),
564
- "server_scammers": len(cache["server_scammers"]),
565
- "dwc_entries": len(cache["dwc"]),
566
- "duped_usernames": len(cache["dupes"]),
567
- }
568
- }
569
-
570
-
571
- @app.get("/api/values")
572
- async def get_values():
573
- """Get all values data from cache"""
574
- check_service_availability()
575
- return cache["values"]
576
-
577
- @app.get("/api/values/{category}")
578
- async def get_category_values(category: str):
579
- """Get values data for a specific category from cache"""
580
- check_service_availability()
581
- category = category.capitalize()
582
-
583
- if category not in CATEGORIES:
584
- raise HTTPException(status_code=404, detail=f"Category '{category}' not found or configured.")
585
-
586
- return {category: cache["values"].get(category, [])}
587
-
588
- @app.get("/api/value-changes/{category}")
589
- async def get_category_value_changes(category: str):
590
- """Get detected value changes for a specific category from the last cache update cycle."""
591
- check_service_availability()
592
- category = category.capitalize()
593
-
594
- if category not in CATEGORIES:
595
- raise HTTPException(status_code=404, detail=f"Category '{category}' not found or configured.")
596
-
597
- return {category: cache.get("value_changes", {}).get(category, [])}
598
-
599
- @app.get("/api/value-changes")
600
- async def get_all_value_changes():
601
- """Get all detected value changes from the last cache update cycle."""
602
- check_service_availability()
603
- return cache.get("value_changes", {})
604
-
605
- @app.get("/api/scammers")
606
- async def get_scammers():
607
- """Get all scammer and DWC data (users, servers, dwc) from cache"""
608
- check_service_availability()
609
- # Data is already fetched and processed (including avatars) by the background task
610
- return {
611
- "users": cache["user_scammers"],
612
- "servers": cache["server_scammers"],
613
- "dwc": cache["dwc"]
614
- }
615
-
616
- @app.get("/api/dupes")
617
- async def get_dupes():
618
- """Get all duped usernames from cache"""
619
- check_service_availability()
620
- return {"usernames": cache["dupes"]}
621
-
622
-
623
- class UsernameCheck(BaseModel):
624
- username: str
625
-
626
- @app.post("/api/check")
627
- async def check_username(data: UsernameCheck):
628
- """Check if a username is duped using cached data and send webhook"""
629
- check_service_availability() # Ensure cache is ready before checking
630
-
631
- username_to_check = data.username.strip().lower()
632
- is_duped = username_to_check in cache["dupes"]
633
-
634
- # Webhook notification logic (remains the same, consider making it non-blocking)
635
- if not is_duped:
636
- webhook_url = os.getenv("WEBHOOK_URL")
637
- if webhook_url:
638
- async def send_webhook_notification(): # Wrap in async func
639
- try:
640
- async with aiohttp.ClientSession() as session:
641
- webhook_data = {
642
- "content": None,
643
- "embeds": [{
644
- "title": "New Dupe Check - Not Found",
645
- "description": f"Username `{data.username}` was checked but not found in the dupe database.",
646
- "color": 16776960, # Yellow
647
- "timestamp": datetime.now(timezone.utc).isoformat() # Use timezone aware
648
- }]
649
- }
650
- async with session.post(webhook_url, json=webhook_data) as response:
651
- if response.status not in [200, 204]:
652
- logger.warning(f"Failed to send webhook (Status: {response.status}): {await response.text()}")
653
- except Exception as e:
654
- logger.error(f"Error sending webhook: {e}")
655
-
656
- # Run the webhook sending in the background so it doesn't delay the API response
657
- asyncio.create_task(send_webhook_notification())
658
- else:
659
- logger.info("Webhook URL not configured. Skipping notification.")
660
-
661
- return {"username": data.username, "is_duped": is_duped}
662
-
663
-
664
- # Optional: Add a health check endpoint (simple version)
665
- @app.get("/health")
666
- def health_check():
667
- # Basic check: is the app running?
668
- # More advanced: could check cache['is_ready'] or cache['last_updated']
669
- return {"status": "ok"}
670
-
671
- # Run with: uvicorn main:app --reload (for development)
672
  # For production: uvicorn main:app --host 0.0.0.0 --port 8000 (or your preferred port)
 
1
+ # main.py
2
+ from fastapi import FastAPI, HTTPException
3
+ from fastapi.middleware.cors import CORSMiddleware
4
+ from pydantic import BaseModel
5
+ from typing import Optional, Any
6
+ import aiohttp
7
+ import os
8
+ from datetime import datetime, timezone # <-- Add timezone
9
+ import json
10
+ import re
11
+ from google.oauth2.service_account import Credentials as ServiceAccountCredentials
12
+ from googleapiclient.discovery import build
13
+ from dotenv import load_dotenv
14
+ import asyncio
15
+ import logging # <-- Add logging
16
+
17
+ # --- Logging Setup ---
18
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
19
+ logger = logging.getLogger(__name__)
20
+
21
+ app = FastAPI()
22
+
23
+ # --- Configuration ---
24
+ load_dotenv()
25
+
26
+ # CORS
27
+ app.add_middleware(
28
+ CORSMiddleware,
29
+ allow_origins=["*"], # Consider restricting in production
30
+ allow_credentials=True,
31
+ allow_methods=["*"],
32
+ allow_headers=["*"],
33
+ )
34
+
35
+ # Google Sheets Config
36
+ SPREADSHEET_ID = '1sgkhBNGw_r6tBIxvdeXaI0bVmWBeACN4jiw_oDEeXLw'
37
+ VALUES_SPREADSHEET_ID = '1Toe07o3P517q8sm9Qb1e5xyFWCuwgskj71IKJwJNfNU'
38
+ SCOPES = ['https://www.googleapis.com/auth/spreadsheets.readonly']
39
+ USER_SCAMMER_SHEET = "User Scammer Files"
40
+ SERVER_SCAMMER_SHEET = "Server Scammer Files"
41
+ DWC_SHEET = "DWC Servers / Users"
42
+ DUPE_LIST_SHEET = "Dupe List"
43
+ CATEGORIES = [
44
+ "Vehicles", "Textures", "Colours", "Spoilers",
45
+ "Rims", "Furnitures", "Gun Skins", "Hyperchromes"
46
+ ]
47
+
48
+ # Cache Update Interval
49
+ CACHE_UPDATE_INTERVAL_SECONDS = 60 # 1 minute
50
+
51
+ # --- Global Cache ---
52
+ cache = {
53
+ "values": {},
54
+ "value_changes": {},
55
+ "user_scammers": [],
56
+ "server_scammers": [],
57
+ "dwc": [],
58
+ "dupes": [],
59
+ "last_updated": None,
60
+ "is_ready": False,
61
+ "service_available": True
62
+ }
63
+ # --- Google Sheets Initialization ---
64
+ sheets_service = None # Initialize as None
65
+
66
+ def init_google_sheets(scopes=SCOPES):
67
+ """Initialize Google Sheets credentials from environment variable"""
68
+ global sheets_service, cache # Allow modifying global vars
69
+ try:
70
+ creds_json_str = os.getenv('CREDENTIALS_JSON')
71
+ if not creds_json_str:
72
+ logger.error("CREDENTIALS_JSON environment variable not found")
73
+ raise ValueError("CREDENTIALS_JSON environment variable not found")
74
+ creds_json = json.loads(creds_json_str)
75
+ creds = ServiceAccountCredentials.from_service_account_info(
76
+ creds_json,
77
+ scopes=scopes
78
+ )
79
+ sheets_service = build('sheets', 'v4', credentials=creds)
80
+ logger.info("Google Sheets service initialized successfully from ENV VAR.")
81
+ cache["service_available"] = True
82
+ return sheets_service
83
+ except Exception as e:
84
+ logger.error(f"Error initializing Google Sheets from ENV VAR: {e}")
85
+ # Fallback attempt (optional)
86
+ try:
87
+ logger.info("Falling back to loading credentials from file 'credentials.json'")
88
+ creds = ServiceAccountCredentials.from_service_account_file(
89
+ 'credentials.json',
90
+ scopes=scopes
91
+ )
92
+ sheets_service = build('sheets', 'v4', credentials=creds)
93
+ logger.info("Google Sheets service initialized successfully from file.")
94
+ cache["service_available"] = True
95
+ return sheets_service
96
+ except Exception as file_e:
97
+ logger.error(f"Error loading credentials from file: {file_e}")
98
+ logger.critical("Google Sheets service could not be initialized. API will be limited.")
99
+ cache["service_available"] = False
100
+ sheets_service = None # Ensure it's None if failed
101
+ return None
102
+
103
+ # Initialize on module load
104
+ init_google_sheets()
105
+
106
+
107
+ # --- Helper Functions (Mostly unchanged) ---
108
+
109
+ def extract_drive_id(url):
110
+ if not url or not isinstance(url, str): return None
111
+ match = re.search(r'https://drive\.google\.com/file/d/([^/]+)', url)
112
+ return match.group(1) if match else None
113
+
114
+ def convert_to_thumbnail_url(drive_url):
115
+ drive_id = extract_drive_id(drive_url)
116
+ return f"https://drive.google.com/thumbnail?id={drive_id}&sz=w1000" if drive_id else drive_url
117
+
118
+ def extract_image_url(formula, drive_url=None):
119
+ if drive_url and isinstance(drive_url, str) and 'drive.google.com' in drive_url:
120
+ return convert_to_thumbnail_url(drive_url)
121
+ if not formula or not isinstance(formula, str): return ''
122
+ if formula.startswith('=IMAGE('):
123
+ match = re.search(r'=IMAGE\("([^"]+)"', formula)
124
+ if match: return match.group(1)
125
+ return formula
126
+
127
+ def format_currency(value: Any) -> Optional[str]:
128
+ if value is None or str(value).strip() == '': return 'N/A'
129
+ try:
130
+ num_str = str(value).replace('$', '').replace(',', '').strip()
131
+ if not num_str or num_str.lower() == 'n/a': return 'N/A'
132
+ num = float(num_str)
133
+ return f"${num:,.0f}"
134
+ except (ValueError, TypeError):
135
+ # Check if it's non-numeric text before returning N/A
136
+ if isinstance(value, str) and not re.match(r'^-?[\d,.]+\$?$', value.strip()):
137
+ return value.strip() # Return original text if it doesn't look like a number/currency
138
+ return 'N/A' # Default to N/A if conversion fails
139
+
140
+ def parse_cached_currency(value_str: Optional[str]) -> Optional[float]:
141
+ if value_str is None or value_str.lower() == 'n/a':
142
+ return None
143
+ try:
144
+ num_str = value_str.replace('$', '').replace(',', '').strip()
145
+ return float(num_str)
146
+ except (ValueError, TypeError):
147
+ return None # Cannot parse
148
+
149
+ def clean_string(value, default='N/A'):
150
+ if value is None: return default
151
+ cleaned = str(value).strip()
152
+ return cleaned if cleaned else default
153
+
154
+ def clean_string_optional(value):
155
+ if value is None: return None
156
+ cleaned = str(value).strip()
157
+ return cleaned if cleaned and cleaned != '-' else None
158
+
159
+ def parse_alt_accounts(value):
160
+ if value is None: return []
161
+ raw_string = str(value).strip()
162
+ if not raw_string or raw_string == '-': return []
163
+ return [acc.strip() for acc in raw_string.split(',') if acc.strip()]
164
+
165
+
166
+ # --- Roblox API Helpers (Unchanged) ---
167
+ async def get_roblox_user_id(session: aiohttp.ClientSession, username: str):
168
+ if not username: return None
169
+ url = "https://users.roblox.com/v1/usernames/users"
170
+ payload = {"usernames": [username], "excludeBannedUsers": False}
171
+ try:
172
+ async with session.post(url, json=payload) as response:
173
+ if response.status == 200:
174
+ data = await response.json()
175
+ if data and data.get("data") and len(data["data"]) > 0:
176
+ return data["data"][0].get("id")
177
+ # else: logger.warning(f"Roblox User ID API non-200 status for {username}: {response.status}") # Maybe too noisy
178
+ return None
179
+ except asyncio.TimeoutError:
180
+ logger.warning(f"Timeout fetching Roblox User ID for {username}")
181
+ return None
182
+ except aiohttp.ClientError as e:
183
+ logger.warning(f"Network error fetching Roblox User ID for {username}: {e}")
184
+ return None
185
+ except Exception as e:
186
+ logger.error(f"Unexpected exception fetching Roblox User ID for {username}: {e}")
187
+ return None
188
+
189
+ async def get_roblox_avatar_url(session: aiohttp.ClientSession, user_id: int):
190
+ if not user_id: return None
191
+ url = f"https://thumbnails.roblox.com/v1/users/avatar-headshot?userIds={user_id}&size=150x150&format=Png&isCircular=false"
192
+ try:
193
+ async with session.get(url) as response:
194
+ if response.status == 200:
195
+ data = await response.json()
196
+ if data and data.get("data") and len(data["data"]) > 0:
197
+ return data["data"][0].get("imageUrl")
198
+ # else: logger.warning(f"Roblox Avatar API non-200 status for User ID {user_id}: {response.status}") # Maybe too noisy
199
+ return None
200
+ except asyncio.TimeoutError:
201
+ logger.warning(f"Timeout fetching Roblox avatar for User ID {user_id}")
202
+ return None
203
+ except aiohttp.ClientError as e:
204
+ logger.warning(f"Network error fetching Roblox avatar for User ID {user_id}: {e}")
205
+ return None
206
+ except Exception as e:
207
+ logger.error(f"Unexpected exception fetching Roblox avatar for User ID {user_id}: {e}")
208
+ return None
209
+
210
+
211
+ # --- Data Processing Functions (Unchanged) ---
212
+ def process_sheet_data(values):
213
+ """Process raw sheet data into structured format for values"""
214
+ if not values: return []
215
+ processed_data = []
216
+ for row in values:
217
+ if not row or not any(cell.strip() for cell in row if cell): continue
218
+ drive_url = row[14] if len(row) > 14 else None
219
+ filtered_row = [cell for i, cell in enumerate(row) if i % 2 == 0]
220
+ if len(filtered_row) >= 4 and isinstance(filtered_row[3], str) and re.search(r'LEVEL \d+ \| HYPERCHROMES', filtered_row[3]): continue
221
+ if len(filtered_row) >= 6:
222
+ processed_item = {
223
+ 'icon': extract_image_url(filtered_row[0], drive_url),
224
+ 'name': clean_string(filtered_row[1], 'N/A') if len(filtered_row) > 1 else 'N/A',
225
+ 'value': format_currency(filtered_row[2]) if len(filtered_row) > 2 else 'N/A',
226
+ 'dupedValue': format_currency(filtered_row[3]) if len(filtered_row) > 3 else 'N/A',
227
+ 'marketValue': format_currency(filtered_row[4]) if len(filtered_row) > 4 else 'N/A',
228
+ 'demand': clean_string(filtered_row[5], 'N/A') if len(filtered_row) > 5 else 'N/A',
229
+ 'notes': clean_string(filtered_row[6], '') if len(filtered_row) > 6 else ''
230
+ }
231
+ processed_data.append(processed_item)
232
+ return processed_data
233
+
234
+ def process_user_scammer_data(values):
235
+ """Process raw user scammer data"""
236
+ if not values: return []
237
+ processed_data = []
238
+ for row in values:
239
+ if not row or len(row) < 2 or not any(clean_string_optional(cell) for cell in row[:2]): continue
240
+ discord_id = clean_string_optional(row[0]) if len(row) > 0 else None
241
+ roblox_username = clean_string_optional(row[1]) if len(row) > 1 else None
242
+ if not discord_id and not roblox_username: continue
243
+ processed_item = {
244
+ 'discord_id': discord_id,
245
+ 'roblox_username': roblox_username,
246
+ 'scam_type': clean_string(row[2]) if len(row) > 2 else 'N/A',
247
+ 'explanation': clean_string(row[3]) if len(row) > 3 else 'N/A',
248
+ 'evidence_link': clean_string_optional(row[4]) if len(row) > 4 else None,
249
+ 'alt_accounts': parse_alt_accounts(row[5]) if len(row) > 5 else [],
250
+ 'roblox_avatar_url': None # Placeholder
251
+ }
252
+ processed_data.append(processed_item)
253
+ return processed_data
254
+
255
+ def process_server_scammer_data(values):
256
+ """Process raw server scammer data"""
257
+ if not values: return []
258
+ processed_data = []
259
+ for row in values:
260
+ if not row or len(row) < 2 or not any(clean_string_optional(cell) for cell in row[:2]): continue
261
+ server_id = clean_string_optional(row[0]) if len(row) > 0 else None
262
+ server_name = clean_string_optional(row[1]) if len(row) > 1 else None
263
+ if not server_id and not server_name: continue
264
+ processed_item = {
265
+ 'server_id': server_id,
266
+ 'server_name': server_name,
267
+ 'scam_type': clean_string(row[2]) if len(row) > 2 else 'N/A',
268
+ 'explanation': clean_string(row[3]) if len(row) > 3 else 'N/A',
269
+ 'evidence_link': clean_string_optional(row[4]) if len(row) > 4 else None
270
+ }
271
+ processed_data.append(processed_item)
272
+ return processed_data
273
+
274
+ def process_dwc_data(values):
275
+ """Process raw DWC data"""
276
+ if not values: return []
277
+ processed_data = []
278
+ for row in values:
279
+ if not row or len(row) < 3 or not any(clean_string_optional(cell) for cell in row[:3]): continue
280
+ user_id = clean_string_optional(row[0]) if len(row) > 0 else None
281
+ server_id = clean_string_optional(row[1]) if len(row) > 1 else None
282
+ roblox_user = clean_string_optional(row[2]) if len(row) > 2 else None
283
+ if not user_id and not server_id and not roblox_user: continue
284
+ processed_item = {
285
+ 'status': 'DWC',
286
+ 'discord_user_id': user_id,
287
+ 'discord_server_id': server_id,
288
+ 'roblox_username': roblox_user,
289
+ 'explanation': clean_string(row[3]) if len(row) > 3 else 'N/A',
290
+ 'evidence_link': clean_string_optional(row[4]) if len(row) > 4 else None,
291
+ 'alt_accounts': parse_alt_accounts(row[5]) if len(row) > 5 else [],
292
+ 'roblox_avatar_url': None # Placeholder
293
+ }
294
+ processed_data.append(processed_item)
295
+ return processed_data
296
+
297
+ def process_dupe_list_data(values):
298
+ """Process raw dupe list data"""
299
+ if not values: return []
300
+ return [row[0].strip().lower() for row in values if row and row[0] and isinstance(row[0], str) and row[0].strip()]
301
+
302
+
303
+ # --- Async Fetching Functions (Used by background task) ---
304
+ async def fetch_sheet_data_async(sheet_name, range_name, processor, value_render_option='FORMATTED_VALUE', spreadsheet_id=SPREADSHEET_ID):
305
+ """Async wrapper to fetch and process sheet data"""
306
+ global sheets_service # Access the initialized service
307
+ if not sheets_service:
308
+ logger.warning(f"Attempted to fetch {sheet_name} but Sheets service is unavailable.")
309
+ raise Exception("Google Sheets service not initialized") # Raise to signal failure in update task
310
+
311
+ try:
312
+ quoted_sheet_name = f"'{sheet_name}'" if not sheet_name.isalnum() else sheet_name
313
+ full_range = f"{quoted_sheet_name}!{range_name}"
314
+
315
+ loop = asyncio.get_event_loop()
316
+ result = await loop.run_in_executor(
317
+ None, # Default executor
318
+ lambda: sheets_service.spreadsheets().values().get(
319
+ spreadsheetId=spreadsheet_id,
320
+ range=full_range,
321
+ valueRenderOption=value_render_option
322
+ ).execute()
323
+ )
324
+ values = result.get('values', [])
325
+ return processor(values)
326
+ except Exception as e:
327
+ logger.error(f"Error fetching/processing {sheet_name} from {spreadsheet_id}: {e}")
328
+ # Re-raise the exception so the update loop knows this part failed
329
+ raise e
330
+
331
+
332
+ # --- Background Cache Update Task ---
333
+
334
+ async def update_cache_periodically():
335
+ """Fetches data from sheets, detects value changes, and updates the cache periodically."""
336
+ global cache
337
+ async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=15)) as session:
338
+ while True:
339
+ if not cache["service_available"]:
340
+ logger.warning("Google Sheets service unavailable, skipping cache update cycle.")
341
+ await asyncio.sleep(CACHE_UPDATE_INTERVAL_SECONDS)
342
+ continue
343
+
344
+ logger.info("Starting cache update cycle...")
345
+ start_time = datetime.now(timezone.utc)
346
+ success = True
347
+
348
+ new_cache_data = {
349
+ "values": {},
350
+ "user_scammers": [],
351
+ "server_scammers": [],
352
+ "dwc": [],
353
+ "dupes": [],
354
+ }
355
+ detected_value_changes = {} # Store changes detected *in this cycle*
356
+
357
+ try:
358
+ # --- Fetch all data concurrently ---
359
+ fetch_tasks = {
360
+ "user_scammers": fetch_sheet_data_async(USER_SCAMMER_SHEET, 'B6:G', process_user_scammer_data, spreadsheet_id=SPREADSHEET_ID),
361
+ "server_scammers": fetch_sheet_data_async(SERVER_SCAMMER_SHEET, 'B6:F', process_server_scammer_data, spreadsheet_id=SPREADSHEET_ID),
362
+ "dwc": fetch_sheet_data_async(DWC_SHEET, 'B6:G', process_dwc_data, spreadsheet_id=SPREADSHEET_ID),
363
+ "dupes": fetch_sheet_data_async(DUPE_LIST_SHEET, 'B2:B', process_dupe_list_data, spreadsheet_id=VALUES_SPREADSHEET_ID),
364
+ # Add tasks for each value category
365
+ **{f"values_{cat}": fetch_sheet_data_async(
366
+ cat, 'B6:P', process_sheet_data,
367
+ value_render_option='FORMULA',
368
+ spreadsheet_id=VALUES_SPREADSHEET_ID
369
+ ) for cat in CATEGORIES}
370
+ }
371
+
372
+ results = await asyncio.gather(*fetch_tasks.values(), return_exceptions=True)
373
+ task_keys = list(fetch_tasks.keys())
374
+
375
+ # --- Process results and update temporary cache ---
376
+ fetched_values = {}
377
+ current_errors = {} # Track errors for specific keys
378
+ for i, result in enumerate(results):
379
+ key = task_keys[i]
380
+ if isinstance(result, Exception):
381
+ logger.error(f"Failed to fetch data for {key}: {result}")
382
+ success = False
383
+ current_errors[key] = str(result) # Log the error
384
+ # Decide: keep old data or clear? We'll keep old cache data by not updating below
385
+ else:
386
+ if key.startswith("values_"):
387
+ category_name = key.split("_", 1)[1]
388
+ fetched_values[category_name] = result
389
+ elif key in new_cache_data: # Only update if key exists
390
+ new_cache_data[key] = result
391
+ else:
392
+ logger.warning(f"Fetched data for unknown key: {key}")
393
+
394
+ # --- Detect Value Changes ---
395
+ logger.info("Comparing fetched values with cached values to detect changes...")
396
+ current_time = datetime.now(timezone.utc)
397
+ fields_to_compare = ['value', 'dupedValue', 'marketValue']
398
+
399
+ for category, new_items in fetched_values.items():
400
+ if category not in cache["values"]: # Category is new or wasn't cached before
401
+ cache["values"][category] = [] # Ensure old category exists for comparison logic below
402
+
403
+ # Create a lookup for old items by name for efficient comparison
404
+ old_items_dict = {item['name']: item for item in cache["values"].get(category, [])}
405
+ category_changes = []
406
+
407
+ for new_item in new_items:
408
+ item_name = new_item['name']
409
+ if item_name in old_items_dict:
410
+ old_item = old_items_dict[item_name]
411
+ for field in fields_to_compare:
412
+ old_val_str = old_item.get(field)
413
+ new_val_str = new_item.get(field)
414
+
415
+ # Normalize "N/A" and potential whitespace before comparison
416
+ old_norm = old_val_str.strip().lower() if isinstance(old_val_str, str) else old_val_str
417
+ new_norm = new_val_str.strip().lower() if isinstance(new_val_str, str) else new_val_str
418
+
419
+ if old_norm == 'n/a': old_norm = None
420
+ if new_norm == 'n/a': new_norm = None
421
+
422
+ # Only record change if values are meaningfully different
423
+ if old_norm != new_norm:
424
+ # Try parsing for better comparison (optional but recommended)
425
+ old_numeric = parse_cached_currency(old_val_str)
426
+ new_numeric = parse_cached_currency(new_val_str)
427
+
428
+ # Compare numeric if possible, otherwise string representations
429
+ # (Handles cases like $10 vs $10.00 becoming same numeric 10.0)
430
+ values_differ = False
431
+ if old_numeric is not None and new_numeric is not None:
432
+ if old_numeric != new_numeric:
433
+ values_differ = True
434
+ elif old_val_str != new_val_str: # Fallback to string comparison if parsing fails or types differ
435
+ values_differ = True
436
+
437
+ if values_differ:
438
+ logger.info(f"Change detected in {category}: {item_name} - {field}: '{old_val_str}' -> '{new_val_str}'")
439
+ category_changes.append({
440
+ "item_name": item_name,
441
+ "field": field,
442
+ "old_value": old_val_str if old_val_str is not None else "N/A",
443
+ "new_value": new_val_str if new_val_str is not None else "N/A",
444
+ "timestamp": current_time.isoformat()
445
+ })
446
+ if category_changes:
447
+ detected_value_changes[category] = category_changes
448
+
449
+ # --- Fetch Roblox Avatars (no changes needed here) ---
450
+ # ... (avatar fetching logic remains the same)
451
+
452
+ # --- Final Cache Update ---
453
+ # Only update the main cache if the fetch cycle didn't have critical errors
454
+ # We allow partial updates if only some fetches failed.
455
+ if not current_errors: # If no errors at all
456
+ logger.info("Updating full cache.")
457
+ cache["values"] = fetched_values
458
+ cache["user_scammers"] = new_cache_data["user_scammers"]
459
+ cache["server_scammers"] = new_cache_data["server_scammers"]
460
+ cache["dwc"] = new_cache_data["dwc"]
461
+ cache["dupes"] = new_cache_data["dupes"]
462
+ cache["value_changes"] = detected_value_changes # Store the detected changes
463
+ cache["last_updated"] = current_time
464
+ cache["is_ready"] = True
465
+ logger.info(f"Cache update cycle completed successfully.")
466
+ else:
467
+ # Update parts that *did* succeed, if any
468
+ partial_update_occurred = False
469
+ if fetched_values: # Only update values if *all* value fetches succeeded
470
+ all_values_fetched = True
471
+ for cat in CATEGORIES:
472
+ if f"values_{cat}" in current_errors:
473
+ all_values_fetched = False
474
+ break
475
+ if all_values_fetched:
476
+ cache["values"] = fetched_values
477
+ cache["value_changes"] = detected_value_changes # Update changes if values updated
478
+ partial_update_occurred = True
479
+ logger.info("Partially updated cache: Values updated.")
480
+ else:
481
+ logger.warning("Values cache not updated due to fetch errors in some categories.")
482
+
483
+
484
+ # Update other sections if they succeeded
485
+ for key in ["user_scammers", "server_scammers", "dwc", "dupes"]:
486
+ if key not in current_errors and new_cache_data.get(key) is not None:
487
+ cache[key] = new_cache_data[key]
488
+ partial_update_occurred = True
489
+ logger.info(f"Partially updated cache: {key} updated.")
490
+
491
+ if partial_update_occurred:
492
+ cache["last_updated"] = current_time # Mark partial update time
493
+ cache["is_ready"] = True # Allow access even if partial
494
+ logger.warning(f"Cache update cycle completed with errors: {current_errors}. Some data might be stale.")
495
+ else:
496
+ logger.error(f"Cache update cycle failed completely. No parts updated. Errors: {current_errors}")
497
+ # Keep cache["is_ready"] as it was.
498
+
499
+
500
+ except Exception as e:
501
+ logger.exception(f"Critical error during cache update cycle: {e}")
502
+ success = False # Should already be false if exception bubbled up
503
+
504
+ # --- Wait for the next cycle ---
505
+ end_time = datetime.now(timezone.utc)
506
+ duration = (end_time - start_time).total_seconds()
507
+ wait_time = max(0, CACHE_UPDATE_INTERVAL_SECONDS - duration)
508
+ logger.info(f"Cache update duration: {duration:.2f}s. Waiting {wait_time:.2f}s for next cycle.")
509
+ await asyncio.sleep(wait_time)
510
+
511
+ # Helper specifically for the background task to update the dict in place
512
+ async def fetch_avatar_for_entry_update(session: aiohttp.ClientSession, entry: dict):
513
+ """Fetches avatar and updates the provided entry dictionary."""
514
+ roblox_username = entry.get('roblox_username')
515
+ if not roblox_username: return
516
+
517
+ try:
518
+ user_id = await get_roblox_user_id(session, roblox_username)
519
+ if user_id:
520
+ avatar_url = await get_roblox_avatar_url(session, user_id)
521
+ entry['roblox_avatar_url'] = avatar_url # Update the dict directly
522
+ # logger.debug(f"Avatar found for {roblox_username}") # Debug level
523
+ # else: logger.debug(f"No Roblox user ID found for {roblox_username}") # Debug level
524
+ except Exception as e:
525
+ # Log errors but don't stop the main update loop
526
+ logger.warning(f"Failed to fetch avatar for {roblox_username}: {e}")
527
+ entry['roblox_avatar_url'] = None # Ensure it's None on error
528
+
529
+
530
+ # --- FastAPI Startup Event ---
531
+ @app.on_event("startup")
532
+ async def startup_event():
533
+ """Starts the background cache update task."""
534
+ if cache["service_available"]:
535
+ logger.info("Starting background cache update task...")
536
+ asyncio.create_task(update_cache_periodically())
537
+ else:
538
+ logger.warning("Google Sheets service not available. Cache update task will not start.")
539
+
540
+
541
+ # --- API Endpoints (Modified to use Cache) ---
542
+
543
+ def check_service_availability():
544
+ """Reusable check for API endpoints"""
545
+ if not cache["service_available"]:
546
+ raise HTTPException(status_code=503, detail="Google Sheets service unavailable. Cannot fetch data.")
547
+ if not cache["is_ready"]:
548
+ raise HTTPException(status_code=503, detail="Cache is not ready yet. Please try again shortly.")
549
+
550
+ @app.get("/")
551
+ async def root():
552
+ return {"message": "JB Vanta API - Running"}
553
+
554
+ @app.get("/api/status")
555
+ async def get_status():
556
+ """Returns the current status of the cache"""
557
+ return {
558
+ "cache_ready": cache["is_ready"],
559
+ "sheets_service_available": cache["service_available"],
560
+ "last_updated": cache["last_updated"].isoformat() if cache["last_updated"] else None,
561
+ "cached_items": {
562
+ "value_categories": len(cache["values"]),
563
+ "user_scammers": len(cache["user_scammers"]),
564
+ "server_scammers": len(cache["server_scammers"]),
565
+ "dwc_entries": len(cache["dwc"]),
566
+ "duped_usernames": len(cache["dupes"]),
567
+ }
568
+ }
569
+
570
+
571
+ @app.get("/api/values")
572
+ async def get_values():
573
+ """Get all values data from cache"""
574
+ check_service_availability()
575
+ return cache["values"]
576
+
577
+ @app.get("/api/values/{category}")
578
+ async def get_category_values(category: str):
579
+ """Get values data for a specific category from cache"""
580
+ check_service_availability()
581
+ category = category.capitalize()
582
+
583
+ if category not in CATEGORIES:
584
+ raise HTTPException(status_code=404, detail=f"Category '{category}' not found or configured.")
585
+
586
+ return {category: cache["values"].get(category, [])}
587
+
588
+ @app.get("/api/value-changes/{category}")
589
+ async def get_category_value_changes(category: str):
590
+ """Get detected value changes for a specific category from the last cache update cycle."""
591
+ check_service_availability()
592
+ category = category.capitalize()
593
+
594
+ if category not in CATEGORIES:
595
+ raise HTTPException(status_code=404, detail=f"Category '{category}' not found or configured.")
596
+
597
+ return {category: cache.get("value_changes", {}).get(category, [])}
598
+
599
+ @app.get("/api/value-changes")
600
+ async def get_all_value_changes():
601
+ """Get all detected value changes from the last cache update cycle."""
602
+ check_service_availability()
603
+ return cache.get("value_changes", {})
604
+
605
+ @app.get("/api/scammers")
606
+ async def get_scammers():
607
+ """Get all scammer and DWC data (users, servers, dwc) from cache"""
608
+ check_service_availability()
609
+ # Data is already fetched and processed (including avatars) by the background task
610
+ return {
611
+ "users": cache["user_scammers"],
612
+ "servers": cache["server_scammers"],
613
+ "dwc": cache["dwc"]
614
+ }
615
+
616
+ @app.get("/api/dupes")
617
+ async def get_dupes():
618
+ """Get all duped usernames from cache"""
619
+ check_service_availability()
620
+ return {"usernames": cache["dupes"]}
621
+
622
+
623
+ class UsernameCheck(BaseModel):
624
+ username: str
625
+
626
+ @app.post("/api/check")
627
+ async def check_username(data: UsernameCheck):
628
+ """Check if a username is duped using cached data and send webhook"""
629
+ check_service_availability() # Ensure cache is ready before checking
630
+
631
+ username_to_check = data.username.strip().lower()
632
+ is_duped = username_to_check in cache["dupes"]
633
+
634
+ # Webhook notification logic (remains the same, consider making it non-blocking)
635
+ if not is_duped:
636
+ webhook_url = os.getenv("WEBHOOK_URL")
637
+ if webhook_url:
638
+ async def send_webhook_notification(): # Wrap in async func
639
+ try:
640
+ async with aiohttp.ClientSession() as session:
641
+ webhook_data = {
642
+ "content": None,
643
+ "embeds": [{
644
+ "title": "New Dupe Check - Not Found",
645
+ "description": f"Username `{data.username}` was checked but not found in the dupe database.",
646
+ "color": 16776960, # Yellow
647
+ "timestamp": datetime.now(timezone.utc).isoformat() # Use timezone aware
648
+ }]
649
+ }
650
+ async with session.post(webhook_url, json=webhook_data) as response:
651
+ if response.status not in [200, 204]:
652
+ logger.warning(f"Failed to send webhook (Status: {response.status}): {await response.text()}")
653
+ except Exception as e:
654
+ logger.error(f"Error sending webhook: {e}")
655
+
656
+ # Run the webhook sending in the background so it doesn't delay the API response
657
+ asyncio.create_task(send_webhook_notification())
658
+ else:
659
+ logger.info("Webhook URL not configured. Skipping notification.")
660
+
661
+ return {"username": data.username, "is_duped": is_duped}
662
+
663
+
664
+ # Optional: Add a health check endpoint (simple version)
665
+ @app.get("/health")
666
+ def health_check():
667
+ # Basic check: is the app running?
668
+ # More advanced: could check cache['is_ready'] or cache['last_updated']
669
+ return {"status": "ok"}
670
+
671
+ # Run with: uvicorn main:app --reload (for development)
672
  # For production: uvicorn main:app --host 0.0.0.0 --port 8000 (or your preferred port)