rhul-attendance-bot 0.1.48__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
RHUL_attendance_bot.py ADDED
@@ -0,0 +1,580 @@
1
+ import os
2
+ import sys
3
+ import os
4
+ import sys
5
+ import logging
6
+ import math
7
+ import json
8
+ import threading
9
+ import time
10
+ import random
11
+ import shutil
12
+ import subprocess
13
+ import zoneinfo # For Python 3.9 and above
14
+ import ntplib # Used to check system time synchronization
15
+ from app_paths import (
16
+ get_app_dir,
17
+ get_profile_dir,
18
+ get_credentials_path,
19
+ get_ics_dir,
20
+ get_chrome_user_data_dir,
21
+ prompt_select_profile,
22
+ )
23
+
24
+ # Create a logger
25
+ logger = logging.getLogger("attendance_bot")
26
+ logger.setLevel(logging.DEBUG)
27
+
28
+ def check_virtual_environment():
29
+ if hasattr(sys, 'real_prefix') or (hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix):
30
+ logger.info("Running inside a virtual environment.")
31
+ else:
32
+ logger.error("You are not running inside a Python virtual environment.")
33
+ logger.error("Please create a Python virtual environment and activate it before running this script.")
34
+ sys.exit(1)
35
+
36
+ def check_dependencies():
37
+ required_packages = [
38
+ "ics",
39
+ "rich",
40
+ "selenium",
41
+ "webdriver-manager",
42
+ "pynput",
43
+ "ntplib",
44
+ "pyotp",
45
+ ]
46
+ missing_packages = []
47
+ try:
48
+ try:
49
+ from importlib.metadata import version as get_version
50
+ except Exception: # pragma: no cover
51
+ from importlib_metadata import version as get_version
52
+ for pkg in required_packages:
53
+ try:
54
+ get_version(pkg)
55
+ except Exception:
56
+ missing_packages.append(pkg)
57
+ except Exception:
58
+ logger.error("Failed to check installed packages.")
59
+ sys.exit(1)
60
+
61
+ if missing_packages:
62
+ logger.error(f"Missing dependencies: {', '.join(missing_packages)}")
63
+ logger.error("Please install the dependencies by running 'pip install rhul-attendance-bot' or 'pip install -r requirements.txt'")
64
+ sys.exit(1)
65
+ else:
66
+ logger.info("All dependencies are installed.")
67
+
68
+ def check_chrome_installed():
69
+ from selenium import webdriver
70
+ from selenium.webdriver.chrome.service import Service
71
+ from webdriver_manager.chrome import ChromeDriverManager
72
+ try:
73
+ options = webdriver.ChromeOptions()
74
+ options.add_argument("--headless") # Run in headless mode for testing
75
+ service = Service(ChromeDriverManager().install())
76
+ driver = webdriver.Chrome(service=service, options=options)
77
+ driver.quit()
78
+ logger.info("Chrome and ChromeDriver are installed and working.")
79
+ except Exception as e:
80
+ logger.error(f"Failed to initialize Chrome WebDriver: {e}")
81
+ logger.error("Please ensure that Google Chrome is installed and accessible.")
82
+ sys.exit(1)
83
+
84
+ def main():
85
+ import argparse
86
+
87
+ parser = argparse.ArgumentParser(description="RHUL attendance bot")
88
+ parser.add_argument("-user", "--user", dest="profile", default=None, help="Profile name")
89
+ args = parser.parse_args()
90
+ profile_name = args.profile if args.profile else prompt_select_profile()
91
+
92
+ script_dir = os.path.dirname(os.path.abspath(__file__))
93
+ os.chdir(script_dir)
94
+ check_virtual_environment()
95
+ check_dependencies()
96
+ check_chrome_installed()
97
+
98
+ app_dir = get_app_dir()
99
+ profile_dir = get_profile_dir(profile_name)
100
+
101
+ # First-run check: credentials and timetable
102
+ credentials_path = get_credentials_path(profile_name)
103
+ ics_folder = get_ics_dir(profile_name)
104
+ ics_file = os.path.join(ics_folder, 'student_timetable.ics')
105
+ first_run = False
106
+ # Check credentials
107
+ creds_ok = False
108
+ if os.path.exists(credentials_path):
109
+ try:
110
+ import json
111
+ with open(credentials_path, 'r') as f:
112
+ creds = json.load(f)
113
+ if 'username' in creds and 'password' in creds and creds['username'] and creds['password']:
114
+ creds_ok = True
115
+ except Exception:
116
+ creds_ok = False
117
+ # Check timetable
118
+ timetable_ok = os.path.exists(ics_file)
119
+ if not creds_ok or not timetable_ok:
120
+ first_run = True
121
+
122
+ # Onboarding if first run
123
+ if first_run:
124
+ print('First run detected: running onboarding steps...')
125
+ # Run auto_login.py for first-time login
126
+ subprocess.run([sys.executable, os.path.join(script_dir, 'auto_login.py'), '--user', profile_name])
127
+ # Run fetch_ics.py to get timetable
128
+ subprocess.run([sys.executable, os.path.join(script_dir, 'fetch_ics.py'), '--user', profile_name])
129
+
130
+ # Now proceed to import the rest of the modules
131
+ import threading
132
+ import time
133
+ import random
134
+ import shutil
135
+ from datetime import datetime, timedelta, timezone
136
+ from ics import Calendar
137
+ from rich.console import Console
138
+ from selenium import webdriver
139
+ from selenium.webdriver.chrome.service import Service
140
+ from selenium.webdriver.chrome.options import Options
141
+ from selenium.webdriver.common.by import By
142
+ from selenium.webdriver.support.ui import WebDriverWait
143
+ from selenium.webdriver.support import expected_conditions as EC
144
+ from webdriver_manager.chrome import ChromeDriverManager
145
+ from pynput import keyboard
146
+ from collections import deque
147
+ import zoneinfo # For Python 3.9 and above
148
+ import ntplib # Used to check system time synchronization
149
+ from auto_login import attempt_login
150
+ from update import check_for_updates
151
+ from discord_broadcast import DiscordBroadcaster
152
+ from display_manager import DisplayManager
153
+ # Reuse helpers but add custom MFA fallback below
154
+
155
+ # Initialize Rich console
156
+ console = Console()
157
+
158
+ # Reconfigure logger to add file handler and buffer handler
159
+ logger.handlers = [] # Remove previous handlers
160
+ logger.setLevel(logging.DEBUG)
161
+
162
+ # Create a file handler to log INFO and above messages with timestamps
163
+ file_handler = logging.FileHandler(os.path.join(profile_dir, 'automation.log'), encoding='utf-8', mode='a')
164
+ file_handler.setLevel(logging.INFO)
165
+ file_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
166
+ file_handler.setFormatter(file_formatter)
167
+ logger.addHandler(file_handler)
168
+
169
+ # BufferLogHandler and log buffer live in DisplayManager now
170
+
171
+ # Initialize global variables
172
+ global start_time, attendance_success_count, counter_lock, events_lock, exit_event
173
+ start_time = datetime.now()
174
+ attendance_success_count = 0
175
+
176
+ # Locks for thread-safe updates
177
+ counter_lock = threading.Lock()
178
+ events_lock = threading.Lock() # Lock for upcoming_events
179
+ exit_event = threading.Event() # Event to signal exit
180
+
181
+ def initialize_webdriver(user_data_dir):
182
+ chrome_options = Options()
183
+ chrome_options.add_argument(f"user-data-dir={user_data_dir}")
184
+ chrome_options.add_argument("--log-level=3")
185
+ chrome_options.add_experimental_option('excludeSwitches', ['enable-logging'])
186
+ # Add more options if needed
187
+ chrome_options.add_argument("--no-sandbox")
188
+ chrome_options.add_argument("--disable-dev-shm-usage")
189
+
190
+ try:
191
+ service = Service(ChromeDriverManager().install())
192
+ driver = webdriver.Chrome(service=service, options=chrome_options)
193
+ logger.info("Chrome WebDriver initialized successfully.", extra={"gray": True})
194
+ return driver
195
+ except Exception as e:
196
+ logger.error(f"Failed to initialize Chrome WebDriver: {e}", exc_info=True)
197
+ return None
198
+
199
+ def click_button_if_visible(driver, button_ids):
200
+ button_flag = False
201
+ for button_id in button_ids:
202
+ try:
203
+ button = WebDriverWait(driver, 20).until(EC.element_to_be_clickable((By.ID, button_id)))
204
+ driver.execute_script("arguments[0].click();", button)
205
+ logger.info(f"Clicked button: {button_id}")
206
+ button_flag = True
207
+ return True
208
+ except Exception:
209
+ pass
210
+ if button_flag is False:
211
+ logger.error("Error clicking buttons: Can't find any button", exc_info=True)
212
+ logger.warning("No clickable button found.")
213
+ return False
214
+
215
+ broadcaster = None
216
+
217
+ def automated_function(event_time, event_name, upcoming_events):
218
+ global attendance_success_count
219
+ user_data_dir = get_chrome_user_data_dir(profile_name)
220
+
221
+ driver = initialize_webdriver(user_data_dir)
222
+ if not driver:
223
+ logger.error("WebDriver initialization failed. Exiting function.")
224
+ return False
225
+
226
+ try:
227
+ # Use your actual attendance URL
228
+ driver.get("https://generalssb-prod.ec.royalholloway.ac.uk/BannerExtensibility/customPage/page/RHUL_Attendance_Student")
229
+ logger.info("Opened attendance page.", extra={"gray": True})
230
+
231
+ expected_url = "https://generalssb-prod.ec.royalholloway.ac.uk/BannerExtensibility/customPage/page/RHUL_Attendance_Student"
232
+ # 尝试自动登录(凭证 + OTP),若已登录则快速通过
233
+ if not attempt_login(driver, expected_url, broadcaster=broadcaster, logger=logger, profile_name=profile_name):
234
+ logger.error("Auto-login or verification failed.")
235
+ return False
236
+
237
+ WebDriverWait(driver, 30).until(EC.presence_of_element_located((By.ID, "pbid-blockFoundHappeningNowAttending")))
238
+ logger.info("Attendance page loaded successfully.", extra={"gray": True})
239
+
240
+ attending_div = driver.find_element(By.ID, "pbid-blockFoundHappeningNowAttending")
241
+ attending_aria_hidden = attending_div.get_attribute("aria-hidden")
242
+
243
+ if attending_aria_hidden == "false":
244
+ current_time = datetime.now(timezone.utc)
245
+
246
+ if current_time < event_time:
247
+ logger.info("Attendance has already been marked, but event time has not occurred yet. Will not update next event.", extra={"gray": True})
248
+ else:
249
+ logger.info("Attendance has already been marked. Removing event and logging next event.")
250
+
251
+ with events_lock:
252
+ for event in upcoming_events:
253
+ if event[0] == event_time and event[1] == event_name:
254
+ upcoming_events.remove(event)
255
+ break
256
+
257
+ return True
258
+
259
+ # 如果都找不到按钮,才报错
260
+ if click_button_if_visible(driver, ["pbid-buttonFoundHappeningNowButtonsOneHere", "pbid-buttonFoundHappeningNowButtonsTwoHere"]):
261
+ logger.info("Successfully clicked attendance button.")
262
+ else:
263
+ logger.warning("No clickable button found. Ending function.")
264
+ return False
265
+
266
+ # Verify that attendance has been marked
267
+ time.sleep(2) # Wait for any potential page updates
268
+ attending_div = driver.find_element(By.ID, "pbid-blockFoundHappeningNowAttending")
269
+ attending_aria_hidden = attending_div.get_attribute("aria-hidden")
270
+ if attending_aria_hidden == "false":
271
+ logger.info("Attendance successfully marked.")
272
+ with counter_lock:
273
+ attendance_success_count += 1
274
+
275
+ if broadcaster:
276
+ broadcaster.notify_attendance_success(event_name, event_time)
277
+
278
+ with events_lock:
279
+ for event in upcoming_events:
280
+ if event[0] == event_time and event[1] == event_name:
281
+ upcoming_events.remove(event)
282
+ break
283
+ return True
284
+ else:
285
+ logger.error("Failed to confirm attendance after clicking.")
286
+ return False
287
+
288
+ except Exception as e:
289
+ logger.error(f"Failed during attendance marking: {e}", exc_info=True)
290
+ return False
291
+ finally:
292
+ if driver:
293
+ try:
294
+ driver.quit()
295
+ except Exception as e:
296
+ logger.warning(f"Failed to quit WebDriver cleanly: {e}")
297
+ # Log the next event
298
+ with events_lock:
299
+ if upcoming_events:
300
+ next_event_start, next_event_name, _, next_event_end = upcoming_events[0]
301
+ local_next_event_start = next_event_start.astimezone()
302
+ duration = next_event_end - next_event_start
303
+ logger.info(
304
+ f"Waiting for next event: [bold magenta]{next_event_name}[/bold magenta] at "
305
+ f"[bold cyan]{local_next_event_start.strftime('%Y-%m-%d %H:%M:%S')}[/bold cyan] "
306
+ f"(duration: [bold green]{str(duration).split('.')[0]}[/bold green])"
307
+ )
308
+ else:
309
+ logger.info("No further upcoming events.")
310
+
311
+ def load_calendar(file_path):
312
+ try:
313
+ with open(file_path, 'r', encoding='utf-8') as f:
314
+ calendar = Calendar(f.read())
315
+ return calendar
316
+ except FileNotFoundError:
317
+ logger.error(f"Calendar file not found: {file_path}")
318
+ return None
319
+ except Exception as e:
320
+ logger.error(f"Error loading calendar: {e}", exc_info=True)
321
+ return None
322
+
323
+ def get_upcoming_events(calendar):
324
+ now = datetime.now(timezone.utc)
325
+ upcoming_events = []
326
+ for event in calendar.events:
327
+ # Convert event start and end times to UTC
328
+ event_start = event.begin.to('UTC').datetime
329
+ event_end = event.end.to('UTC').datetime
330
+ event_name = event.name
331
+ trigger_time = calculate_trigger_time(event_start)
332
+ if event_start > now and 'Optional Attendance' not in event_name:
333
+ upcoming_events.append((event_start, event_name, trigger_time, event_end))
334
+ upcoming_events.sort(key=lambda x: x[2]) # Sort by trigger_time
335
+ return upcoming_events
336
+
337
+ def calculate_trigger_time(event_time):
338
+ minutes_after = random.randint(3, 8)
339
+ trigger_time = event_time + timedelta(minutes=minutes_after)
340
+ return trigger_time
341
+
342
+ def wait_and_trigger(upcoming_events, exit_event):
343
+ # Log the next event at the beginning
344
+ with events_lock:
345
+ if upcoming_events:
346
+ next_event_start, next_event_name, _, next_event_end = upcoming_events[0]
347
+ local_next_event_start = next_event_start.astimezone()
348
+ duration = next_event_end - next_event_start
349
+ logger.info(
350
+ f"Waiting for next event: [bold magenta]{next_event_name}[/bold magenta] at "
351
+ f"[bold cyan]{local_next_event_start.strftime('%Y-%m-%d %H:%M:%S')}[/bold cyan] "
352
+ f"(duration: [bold green]{str(duration).split('.')[0]}[/bold green])"
353
+ )
354
+
355
+ logger.info("Waiting in the background for events to trigger...", extra={"gray": True})
356
+ while not exit_event.is_set():
357
+ now = datetime.now(timezone.utc)
358
+ with events_lock:
359
+ if not upcoming_events:
360
+ logger.info("All events have been processed, exiting the script.")
361
+ exit_event.set()
362
+ break
363
+ event = upcoming_events[0]
364
+ event_time, event_name, trigger_time, event_end = event
365
+ if now >= trigger_time.astimezone(timezone.utc):
366
+ local_event_time = event_time.astimezone()
367
+ logger.info(
368
+ f"[bold red]Triggering event:[/bold red] [bold magenta]{event_name}[/bold magenta] at "
369
+ f"[bold cyan]{local_event_time.strftime('%Y-%m-%d %H:%M:%S')}[/bold cyan]"
370
+ )
371
+ # Run automated_function in a new thread
372
+ threading.Thread(target=automated_function, args=(event_time, event_name, upcoming_events), daemon=True).start()
373
+ # Event processed, remove it
374
+ upcoming_events.pop(0)
375
+ else:
376
+ sleep_duration = max((trigger_time - now).total_seconds(), 0)
377
+ if exit_event.wait(timeout=min(sleep_duration, 60)):
378
+ break
379
+
380
+ def listen_for_keypress(upcoming_events, exit_event):
381
+ ctrl_pressed = [False] # Use a mutable object to share state
382
+
383
+ def on_press(key):
384
+ try:
385
+ if key.char == '[':
386
+ ctrl_pressed[0] = True
387
+ elif key.char == ']':
388
+ if ctrl_pressed[0]:
389
+ with events_lock:
390
+ if upcoming_events:
391
+ next_event_time, next_event_name, _, next_event_end = upcoming_events[0]
392
+ logger.info(
393
+ f"[bold magenta]Manually triggered automation for:[/bold magenta] [bold magenta]{next_event_name}[/bold magenta]"
394
+ )
395
+ # Run automated_function in a new thread
396
+ threading.Thread(target=automated_function, args=(next_event_time, next_event_name, upcoming_events), daemon=True).start()
397
+ else:
398
+ logger.warning("No upcoming events to process.")
399
+ elif key.char == 'q':
400
+ if ctrl_pressed[0]:
401
+ logger.info("Exit shortcut pressed. Terminating the script.")
402
+ exit_event.set()
403
+ except AttributeError:
404
+ pass
405
+
406
+ def on_release(key):
407
+ try:
408
+ if key.char == '[':
409
+ ctrl_pressed[0] = False
410
+ except AttributeError:
411
+ pass
412
+
413
+ listener = keyboard.Listener(on_press=on_press, on_release=on_release)
414
+ listener.start()
415
+ while not exit_event.is_set():
416
+ time.sleep(1)
417
+ listener.stop()
418
+
419
+
420
+ def get_single_ics_file():
421
+ ics_folder = get_ics_dir(profile_name)
422
+
423
+ ics_files = [os.path.join(ics_folder, file) for file in os.listdir(ics_folder) if file.endswith('.ics')]
424
+ if len(ics_files) == 0:
425
+ logger.error("Error: No .ics file found.")
426
+ return None
427
+ elif len(ics_files) > 1:
428
+ logger.error("Error: Multiple .ics files found, please ensure only one file is in the ics folder.")
429
+ return None
430
+ else:
431
+ return ics_files[0]
432
+
433
+ def get_runtime_duration():
434
+ delta = datetime.now() - start_time
435
+ return str(delta).split('.')[0]
436
+
437
+ def ensure_profile_nickname():
438
+ try:
439
+ with open(credentials_path, 'r') as f:
440
+ data = json.load(f)
441
+ except Exception:
442
+ return None
443
+ if data.get('profile_nickname'):
444
+ return data.get('profile_nickname')
445
+ while True:
446
+ nickname = input('Enter your profile nickname: ').strip()
447
+ if nickname:
448
+ break
449
+ print('Profile nickname cannot be empty. Please enter again.')
450
+ webhook_url = input('Enter your Discord webhook URL (leave blank to disable): ').strip()
451
+ data['discord_webhook_url'] = webhook_url
452
+ data['enable_discord_webhook'] = bool(webhook_url)
453
+ data['profile_nickname'] = nickname
454
+ try:
455
+ with open(credentials_path, 'w') as f:
456
+ json.dump(data, f)
457
+ except Exception:
458
+ pass
459
+ return nickname
460
+
461
+ def load_profile_nickname():
462
+ try:
463
+ with open(credentials_path, 'r') as f:
464
+ data = json.load(f)
465
+ nickname = data.get('profile_nickname')
466
+ if nickname:
467
+ return nickname
468
+ return "Not set"
469
+ except Exception:
470
+ return "Not set"
471
+
472
+ ensure_profile_nickname()
473
+ profile_nickname = load_profile_nickname()
474
+ broadcaster = DiscordBroadcaster(credentials_path=credentials_path, logger=logger, profile_name=profile_nickname)
475
+
476
+ def get_git_info():
477
+ """Return (hash, date, count) for current HEAD; fallback to unknown."""
478
+ try:
479
+ commit = subprocess.check_output(['git', 'rev-parse', '--short', 'HEAD'], cwd=script_dir).decode().strip()
480
+ commit_date = subprocess.check_output(['git', 'show', '-s', '--format=%cd', '--date=iso-strict', 'HEAD'], cwd=script_dir).decode().strip()
481
+ commit_count = subprocess.check_output(['git', 'rev-list', '--count', 'HEAD'], cwd=script_dir).decode().strip()
482
+ return commit, commit_date, commit_count
483
+ except Exception:
484
+ return "unknown", "unknown", "unknown"
485
+
486
+ def get_attendance_count():
487
+ with counter_lock:
488
+ return attendance_success_count
489
+
490
+ display_manager = DisplayManager(
491
+ console=console,
492
+ profile_nickname=profile_nickname,
493
+ broadcaster=broadcaster,
494
+ get_git_info=get_git_info,
495
+ get_runtime_duration=get_runtime_duration,
496
+ get_attendance_count=get_attendance_count,
497
+ )
498
+ buffer_handler = display_manager.get_log_handler()
499
+ buffer_handler.setLevel(logging.DEBUG)
500
+ buffer_handler.setFormatter(logging.Formatter('%(message)s'))
501
+ logger.addHandler(buffer_handler)
502
+
503
+ def check_system_time():
504
+ try:
505
+ client = ntplib.NTPClient()
506
+ response = client.request('pool.ntp.org')
507
+ system_time = datetime.now(timezone.utc).timestamp()
508
+ ntp_time = response.tx_time
509
+ time_difference = abs(system_time - ntp_time)
510
+ if time_difference > 5:
511
+ logger.warning(f"System time is off by {time_difference} seconds. Consider synchronizing your clock.")
512
+ else:
513
+ logger.info("System time is synchronized with NTP server.", extra={"gray": True})
514
+ except Exception as e:
515
+ logger.error(f"Failed to check system time: {e}", exc_info=True)
516
+
517
+ try:
518
+ check_for_updates(logger=logger, timeout=10)
519
+ check_system_time()
520
+
521
+ ics_file = get_single_ics_file()
522
+ if not ics_file:
523
+ logger.info("Program terminated due to missing or multiple .ics files.")
524
+ return
525
+
526
+ calendar = load_calendar(ics_file)
527
+ if not calendar:
528
+ logger.error("Failed to load calendar. Exiting.")
529
+ return
530
+
531
+ upcoming_events = get_upcoming_events(calendar)
532
+ if not upcoming_events:
533
+ logger.info("No upcoming events.")
534
+ return
535
+
536
+ git_commit, git_date, git_count = get_git_info()
537
+
538
+ if broadcaster:
539
+ try:
540
+ version_count = str(int(git_count) - 1)
541
+ except Exception:
542
+ version_count = git_count
543
+ version_label = f"No.{version_count} version"
544
+ broadcaster.notify_bot_started(version_label=version_label)
545
+
546
+ # Start threads with exit_event
547
+ display_thread = threading.Thread(target=display_manager.run, args=(exit_event,), daemon=True)
548
+ keypress_thread = threading.Thread(target=listen_for_keypress, args=(upcoming_events, exit_event), daemon=True)
549
+ scroll_thread = threading.Thread(target=display_manager.listen_for_scroll, args=(exit_event,), daemon=True)
550
+ display_thread.start()
551
+ keypress_thread.start()
552
+ scroll_thread.start()
553
+
554
+ wait_and_trigger(upcoming_events, exit_event)
555
+ exit_event.set()
556
+ display_thread.join(timeout=3)
557
+ keypress_thread.join(timeout=3)
558
+ scroll_thread.join(timeout=3)
559
+
560
+ except KeyboardInterrupt:
561
+ logger.info("Script terminated by user.")
562
+ exit_event.set()
563
+ except Exception as e:
564
+ logger.error(f"Unhandled exception: {e}", exc_info=True)
565
+ exit_event.set()
566
+ finally:
567
+ if broadcaster:
568
+ try:
569
+ broadcaster.notify_bot_stopped(runtime=get_runtime_duration())
570
+ except Exception:
571
+ pass
572
+ try:
573
+ display_thread.join(timeout=3)
574
+ keypress_thread.join(timeout=3)
575
+ scroll_thread.join(timeout=3)
576
+ except Exception:
577
+ pass
578
+
579
+ if __name__ == "__main__":
580
+ main()
app_paths.py ADDED
@@ -0,0 +1,74 @@
1
+ import os
2
+
3
+ APP_DIR_NAME = ".rhul_attendance_bot"
4
+ PROFILES_DIR_NAME = "profiles"
5
+
6
+
7
+ def get_app_dir():
8
+ app_dir = os.path.join(os.path.expanduser("~"), APP_DIR_NAME)
9
+ os.makedirs(app_dir, exist_ok=True)
10
+ return app_dir
11
+
12
+
13
+ def _normalize_profile(profile_name=None):
14
+ name = (profile_name or "default").strip() or "default"
15
+ name = name.replace(os.sep, "_")
16
+ if os.altsep:
17
+ name = name.replace(os.altsep, "_")
18
+ return name
19
+
20
+
21
+ def get_profile_dir(profile_name=None):
22
+ base = os.path.join(get_app_dir(), PROFILES_DIR_NAME)
23
+ os.makedirs(base, exist_ok=True)
24
+ profile_dir = os.path.join(base, _normalize_profile(profile_name))
25
+ os.makedirs(profile_dir, exist_ok=True)
26
+ return profile_dir
27
+
28
+
29
+ def list_profiles():
30
+ base = os.path.join(get_app_dir(), PROFILES_DIR_NAME)
31
+ if not os.path.isdir(base):
32
+ return []
33
+ profiles = [name for name in os.listdir(base) if os.path.isdir(os.path.join(base, name))]
34
+ profiles.sort()
35
+ return profiles
36
+
37
+
38
+ def prompt_select_profile():
39
+ profiles = list_profiles()
40
+ if not profiles:
41
+ return "default"
42
+
43
+ print("Select a profile:")
44
+ for idx, name in enumerate(profiles, start=1):
45
+ print(f" {idx}. {name}")
46
+ while True:
47
+ choice = input("Enter number or type a new profile name: ").strip()
48
+ if choice.isdigit():
49
+ idx = int(choice)
50
+ if 1 <= idx <= len(profiles):
51
+ return profiles[idx - 1]
52
+ elif choice:
53
+ return _normalize_profile(choice)
54
+ print("Invalid selection. Please try again.")
55
+
56
+
57
+ def get_credentials_path(profile_name=None):
58
+ return os.path.join(get_profile_dir(profile_name), "credentials.json")
59
+
60
+
61
+ def get_2fa_config_path(profile_name=None):
62
+ return os.path.join(get_profile_dir(profile_name), "2fa_config.json")
63
+
64
+
65
+ def get_ics_dir(profile_name=None):
66
+ ics_dir = os.path.join(get_profile_dir(profile_name), "ics")
67
+ os.makedirs(ics_dir, exist_ok=True)
68
+ return ics_dir
69
+
70
+
71
+ def get_chrome_user_data_dir(profile_name=None):
72
+ user_dir = os.path.join(get_profile_dir(profile_name), "chrome_user_data")
73
+ os.makedirs(user_dir, exist_ok=True)
74
+ return user_dir