saq1b commited on
Commit
052ec92
·
verified ·
1 Parent(s): 643a3a8

Upload 3 files

Browse files
Files changed (3) hide show
  1. Dockerfile +37 -0
  2. main.py +672 -0
  3. requirements.txt +9 -0
Dockerfile ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use a full Python image
2
+ FROM python:3.12
3
+
4
+ # Create a new user named "user" with user ID 1000
5
+ RUN useradd -m -u 1000 user
6
+
7
+ # Switch to the "user" user for the remainder of the build
8
+ USER user
9
+
10
+ # Set home to the user's home directory and update PATH accordingly
11
+ ENV HOME=/home/user \
12
+ PATH=/home/user/.local/bin:$PATH
13
+
14
+ # Set the working directory to the user's home directory
15
+ WORKDIR $HOME/app
16
+
17
+ # Upgrade pip (as non-root) to avoid permission issues
18
+ RUN pip install --no-cache-dir --upgrade pip
19
+
20
+ # Copy the requirements file (with the correct ownership) to leverage Docker cache
21
+ COPY --chown=user requirements.txt $HOME/app/requirements.txt
22
+
23
+ # Install Python dependencies
24
+ RUN pip install -r requirements.txt
25
+
26
+ # Copy the rest of the application code into the container (with proper ownership)
27
+ COPY --chown=user . $HOME/app
28
+
29
+ # (Optional) If you need to download a checkpoint or other assets:
30
+ # RUN mkdir -p content
31
+ # ADD --chown=user https://<SOME_ASSET_URL> content/<SOME_ASSET_NAME>
32
+
33
+ # Expose port 7860 (Hugging Face Spaces expects your app to listen on this port)
34
+ EXPOSE 7860
35
+
36
+ # Command to run the FastAPI app using uvicorn.
37
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
main.py ADDED
@@ -0,0 +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)
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ aiohttp
4
+ pydantic
5
+ python-dotenv
6
+ google-api-python-client
7
+ google-auth-httplib2
8
+ google-auth-oauthlib
9
+ python-multipart