rhul-attendance-bot 0.1.48__py3-none-any.whl → 0.1.49__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 CHANGED
@@ -19,6 +19,9 @@ from app_paths import (
19
19
  get_ics_dir,
20
20
  get_chrome_user_data_dir,
21
21
  prompt_select_profile,
22
+ delete_app_data,
23
+ profile_exists,
24
+ list_profiles,
22
25
  )
23
26
 
24
27
  # Create a logger
@@ -85,9 +88,25 @@ def main():
85
88
  import argparse
86
89
 
87
90
  parser = argparse.ArgumentParser(description="RHUL attendance bot")
88
- parser.add_argument("-user", "--user", dest="profile", default=None, help="Profile name")
91
+ parser.add_argument("-user", "--user", dest="profile", default=None, help="Start with profile name")
92
+ parser.add_argument("-clean", action="store_true", help="Delete all local app data")
89
93
  args = parser.parse_args()
90
- profile_name = args.profile if args.profile else prompt_select_profile()
94
+ if args.clean:
95
+ deleted = delete_app_data()
96
+ if deleted:
97
+ print("Local app data removed: ~/.rhul_attendance_bot")
98
+ else:
99
+ print("No local app data found to remove.")
100
+ return
101
+ profiles_before = list_profiles()
102
+
103
+ if args.profile:
104
+ if not profile_exists(args.profile):
105
+ print("Profile not exist.")
106
+ return
107
+ profile_name = args.profile
108
+ else:
109
+ profile_name = prompt_select_profile()
91
110
 
92
111
  script_dir = os.path.dirname(os.path.abspath(__file__))
93
112
  os.chdir(script_dir)
@@ -123,10 +142,22 @@ def main():
123
142
  if first_run:
124
143
  print('First run detected: running onboarding steps...')
125
144
  # Run auto_login.py for first-time login
126
- subprocess.run([sys.executable, os.path.join(script_dir, 'auto_login.py'), '--user', profile_name])
145
+ if profiles_before:
146
+ subprocess.run([sys.executable, os.path.join(script_dir, 'auto_login.py'), '--user', profile_name])
147
+ else:
148
+ subprocess.run([sys.executable, os.path.join(script_dir, 'auto_login.py')])
149
+ profiles_after = list_profiles()
150
+ if len(profiles_after) == 1:
151
+ profile_name = profiles_after[0]
127
152
  # Run fetch_ics.py to get timetable
128
153
  subprocess.run([sys.executable, os.path.join(script_dir, 'fetch_ics.py'), '--user', profile_name])
129
154
 
155
+ # Recompute profile paths if profile_name changed during onboarding
156
+ profile_dir = get_profile_dir(profile_name)
157
+ credentials_path = get_credentials_path(profile_name)
158
+ ics_folder = get_ics_dir(profile_name)
159
+ ics_file = os.path.join(ics_folder, 'student_timetable.ics')
160
+
130
161
  # Now proceed to import the rest of the modules
131
162
  import threading
132
163
  import time
@@ -148,6 +179,7 @@ def main():
148
179
  import ntplib # Used to check system time synchronization
149
180
  from auto_login import attempt_login
150
181
  from update import check_for_updates
182
+ from fetch_ics import fetch_ics_url, refresh_calendar, renew_calendar
151
183
  from discord_broadcast import DiscordBroadcaster
152
184
  from display_manager import DisplayManager
153
185
  # Reuse helpers but add custom MFA fallback below
@@ -377,6 +409,7 @@ def main():
377
409
  if exit_event.wait(timeout=min(sleep_duration, 60)):
378
410
  break
379
411
 
412
+
380
413
  def listen_for_keypress(upcoming_events, exit_event):
381
414
  ctrl_pressed = [False] # Use a mutable object to share state
382
415
 
@@ -396,6 +429,21 @@ def main():
396
429
  threading.Thread(target=automated_function, args=(next_event_time, next_event_name, upcoming_events), daemon=True).start()
397
430
  else:
398
431
  logger.warning("No upcoming events to process.")
432
+ elif key.char == 'c':
433
+ if ctrl_pressed[0]:
434
+ logger.info("Refreshing calendar (fetching ICS)...", extra={"gray": True})
435
+ threading.Thread(
436
+ target=renew_calendar,
437
+ args=(
438
+ upcoming_events,
439
+ events_lock,
440
+ profile_name,
441
+ load_calendar,
442
+ get_upcoming_events,
443
+ logger,
444
+ ),
445
+ daemon=True,
446
+ ).start()
399
447
  elif key.char == 'q':
400
448
  if ctrl_pressed[0]:
401
449
  logger.info("Exit shortcut pressed. Terminating the script.")
app_paths.py CHANGED
@@ -26,6 +26,32 @@ def get_profile_dir(profile_name=None):
26
26
  return profile_dir
27
27
 
28
28
 
29
+ def profile_exists(profile_name):
30
+ base = os.path.join(get_app_dir(), PROFILES_DIR_NAME)
31
+ name = _normalize_profile(profile_name)
32
+ return os.path.isdir(os.path.join(base, name))
33
+
34
+
35
+ def rename_profile(old_name, new_name):
36
+ old_norm = _normalize_profile(old_name)
37
+ new_norm = _normalize_profile(new_name)
38
+ if old_norm == new_norm:
39
+ return True
40
+ base = os.path.join(get_app_dir(), PROFILES_DIR_NAME)
41
+ old_path = os.path.join(base, old_norm)
42
+ new_path = os.path.join(base, new_norm)
43
+ if not os.path.isdir(old_path):
44
+ return False
45
+ if os.path.exists(new_path):
46
+ return False
47
+ os.makedirs(base, exist_ok=True)
48
+ try:
49
+ os.rename(old_path, new_path)
50
+ return True
51
+ except Exception:
52
+ return False
53
+
54
+
29
55
  def list_profiles():
30
56
  base = os.path.join(get_app_dir(), PROFILES_DIR_NAME)
31
57
  if not os.path.isdir(base):
@@ -72,3 +98,25 @@ def get_chrome_user_data_dir(profile_name=None):
72
98
  user_dir = os.path.join(get_profile_dir(profile_name), "chrome_user_data")
73
99
  os.makedirs(user_dir, exist_ok=True)
74
100
  return user_dir
101
+
102
+
103
+ def delete_app_data():
104
+ app_dir = get_app_dir()
105
+ if not os.path.isdir(app_dir):
106
+ return False
107
+ for root, dirs, files in os.walk(app_dir, topdown=False):
108
+ for name in files:
109
+ try:
110
+ os.remove(os.path.join(root, name))
111
+ except Exception:
112
+ pass
113
+ for name in dirs:
114
+ try:
115
+ os.rmdir(os.path.join(root, name))
116
+ except Exception:
117
+ pass
118
+ try:
119
+ os.rmdir(app_dir)
120
+ except Exception:
121
+ pass
122
+ return True
auto_login.py CHANGED
@@ -12,7 +12,13 @@ from selenium.webdriver.support import expected_conditions as EC
12
12
  from selenium.webdriver.chrome.options import Options
13
13
  from webdriver_manager.chrome import ChromeDriverManager
14
14
  from local_2fa import bind, get_otp
15
- from app_paths import get_2fa_config_path, get_credentials_path, prompt_select_profile
15
+ from app_paths import (
16
+ get_2fa_config_path,
17
+ get_credentials_path,
18
+ prompt_select_profile,
19
+ profile_exists,
20
+ rename_profile,
21
+ )
16
22
  SECURITY_INFO_URL = 'https://mysignins.microsoft.com/security-info'
17
23
  LOGIN_URL = 'https://mysignins.microsoft.com/security-info'
18
24
 
@@ -157,6 +163,11 @@ def first_time_setup(profile_name=None):
157
163
  payload['discord_webhook_url'] = discord_webhook_url
158
164
  payload['enable_discord_webhook'] = bool(discord_webhook_url) if enable_discord_webhook is None else bool(enable_discord_webhook)
159
165
 
166
+ # Align profile folder name to profile nickname if needed
167
+ if profile_name and profile_nickname and profile_name != profile_nickname:
168
+ rename_profile(profile_name, profile_nickname)
169
+ profile_name = profile_nickname
170
+
160
171
  credentials_path = get_credentials_path(profile_name)
161
172
  with open(credentials_path, 'w') as f:
162
173
  json.dump(payload, f)
@@ -654,7 +665,7 @@ def renew_login(driver, expected_url=None, logger=None, profile_name=None):
654
665
  current_url = driver.current_url
655
666
  page_src = driver.page_source
656
667
  if ('login.microsoftonline.com' in current_url) or ('mysignins.microsoft.com' in current_url) or ('loginfmt' in page_src):
657
- _log(logger, logging.INFO, "Detected Microsoft login page; attempting auto login...")
668
+ _log(logger, logging.INFO, "Detected Microsoft login page; attempting auto login...", gray=True)
658
669
  fill_ms_login(driver, username, password)
659
670
  handle_mfa_code(driver)
660
671
  handle_kmsi(driver)
@@ -665,7 +676,7 @@ def renew_login(driver, expected_url=None, logger=None, profile_name=None):
665
676
  if expected_url:
666
677
  try:
667
678
  WebDriverWait(driver, 60).until(EC.url_contains(expected_url))
668
- _log(logger, logging.INFO, "Login detected via URL match.")
679
+ _log(logger, logging.INFO, "Login detected via URL match.", gray=True)
669
680
  return True
670
681
  except Exception:
671
682
  _log(logger, logging.ERROR if logger else logging.INFO, "Login not detected within timeout.")
@@ -686,7 +697,7 @@ def verify_login(driver, expected_url, max_wait_minutes=30, logger=None):
686
697
  current_url = driver.current_url
687
698
 
688
699
  if current_url == expected_url:
689
- _log(logger, logging.INFO, "Already logged in.")
700
+ _log(logger, logging.INFO, "Already logged in.", gray=True)
690
701
  return True
691
702
  else:
692
703
  _log(logger, logging.INFO, "Need to login.")
@@ -713,7 +724,7 @@ def attempt_login(driver, expected_url, broadcaster=None, logger=None, profile_n
713
724
  _log(logger, logging.INFO, "Existing session is valid; skipping renew_login.", gray=True)
714
725
  return True
715
726
  except Exception:
716
- _log(logger, logging.INFO, "Session check did not confirm login; attempting renew_login.")
727
+ _log(logger, logging.INFO, "Session check did not confirm login; attempting renew_login.", gray=True)
717
728
 
718
729
  result = renew_login(driver, expected_url, logger=logger, profile_name=profile_name)
719
730
  if result:
@@ -733,7 +744,14 @@ if __name__ == '__main__':
733
744
  import argparse
734
745
 
735
746
  parser = argparse.ArgumentParser(description="RHUL auto login setup")
747
+ parser.add_argument("-help", action="help", help="Show this help message and exit")
736
748
  parser.add_argument("-user", "--user", dest="profile", default=None, help="Profile name")
737
749
  args = parser.parse_args()
738
- profile_name = args.profile if args.profile else prompt_select_profile()
750
+ if args.profile:
751
+ if not profile_exists(args.profile):
752
+ print("Profile not exist.")
753
+ raise SystemExit(1)
754
+ profile_name = args.profile
755
+ else:
756
+ profile_name = prompt_select_profile()
739
757
  first_time_setup(profile_name=profile_name)
display_manager.py CHANGED
@@ -229,6 +229,7 @@ class DisplayManager:
229
229
 
230
230
  instructions = Text.from_markup(
231
231
  "Press [yellow][[/yellow] then [yellow]][/yellow] to manually trigger the next event\n"
232
+ "Press [yellow][[/yellow] then [yellow]c[/yellow] to refresh the calendar\n"
232
233
  "Press [yellow][[/yellow] then [yellow]q[/yellow] to exit the script",
233
234
  justify="center",
234
235
  )
fetch_ics.py CHANGED
@@ -8,7 +8,7 @@ from selenium.webdriver.support.ui import WebDriverWait, Select
8
8
  from selenium.webdriver.support import expected_conditions as EC
9
9
  from selenium.webdriver.chrome.options import Options
10
10
  from webdriver_manager.chrome import ChromeDriverManager
11
- from app_paths import get_credentials_path, get_ics_dir, prompt_select_profile
11
+ from app_paths import get_credentials_path, get_ics_dir, prompt_select_profile, profile_exists
12
12
  TIMETABLE_URL = 'https://webtimetables.royalholloway.ac.uk/'
13
13
 
14
14
 
@@ -93,11 +93,84 @@ def fetch_ics_url(profile_name=None):
93
93
  time.sleep(5)
94
94
  driver.quit()
95
95
 
96
+
97
+ def refresh_calendar(profile_name=None, load_calendar_fn=None, get_upcoming_events_fn=None, logger=None):
98
+ def _log(msg, level="info"):
99
+ if logger:
100
+ try:
101
+ getattr(logger, level)(msg)
102
+ return
103
+ except Exception:
104
+ pass
105
+ print(msg)
106
+
107
+ ics_path = os.path.join(get_ics_dir(profile_name), 'student_timetable.ics')
108
+ try:
109
+ if os.path.exists(ics_path):
110
+ os.remove(ics_path)
111
+ fetch_ics_url(profile_name=profile_name)
112
+
113
+ if load_calendar_fn and get_upcoming_events_fn:
114
+ calendar = load_calendar_fn(ics_path)
115
+ if not calendar:
116
+ _log("Failed to reload calendar after refresh.", level="error")
117
+ return None, ics_path
118
+ new_events = get_upcoming_events_fn(calendar)
119
+ return new_events, ics_path
120
+ return None, ics_path
121
+ except Exception as e:
122
+ _log(f"Failed to refresh calendar: {e}", level="error")
123
+ return None, ics_path
124
+
125
+
126
+ def renew_calendar(upcoming_events, events_lock, profile_name=None, load_calendar_fn=None, get_upcoming_events_fn=None, logger=None):
127
+ new_events, _ = refresh_calendar(
128
+ profile_name=profile_name,
129
+ load_calendar_fn=load_calendar_fn,
130
+ get_upcoming_events_fn=get_upcoming_events_fn,
131
+ logger=logger,
132
+ )
133
+ if new_events is None:
134
+ return
135
+ with events_lock:
136
+ upcoming_events.clear()
137
+ upcoming_events.extend(new_events)
138
+ if new_events:
139
+ next_event_start, next_event_name, _, next_event_end = new_events[0]
140
+ local_next_event_start = next_event_start.astimezone()
141
+ duration = next_event_end - next_event_start
142
+ msg = (
143
+ f"Waiting for next event: [bold magenta]{next_event_name}[/bold magenta] at "
144
+ f"[bold cyan]{local_next_event_start.strftime('%Y-%m-%d %H:%M:%S')}[/bold cyan] "
145
+ f"(duration: [bold green]{str(duration).split('.')[0]}[/bold green])"
146
+ )
147
+ if logger:
148
+ logger.info(msg)
149
+ else:
150
+ print(msg)
151
+ else:
152
+ if logger:
153
+ logger.info("No upcoming events after refresh.")
154
+ else:
155
+ print("No upcoming events after refresh.")
156
+
157
+ if logger:
158
+ logger.info("Calendar refreshed successfully.", extra={"gray": True})
159
+ else:
160
+ print("Calendar refreshed successfully.")
161
+
96
162
  if __name__ == '__main__':
97
163
  import argparse
98
164
 
99
165
  parser = argparse.ArgumentParser(description="RHUL timetable ICS fetcher")
166
+ parser.add_argument("-help", action="help", help="Show this help message and exit")
100
167
  parser.add_argument("-user", "--user", dest="profile", default=None, help="Profile name")
101
168
  args = parser.parse_args()
102
- profile_name = args.profile if args.profile else prompt_select_profile()
169
+ if args.profile:
170
+ if not profile_exists(args.profile):
171
+ print("Profile not exist.")
172
+ raise SystemExit(1)
173
+ profile_name = args.profile
174
+ else:
175
+ profile_name = prompt_select_profile()
103
176
  fetch_ics_url(profile_name=profile_name)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rhul-attendance-bot
3
- Version: 0.1.48
3
+ Version: 0.1.49
4
4
  Summary: RHUL attendance automation bot
5
5
  Author: PandaQuQ
6
6
  License:
@@ -77,13 +77,26 @@ The RHUL Attendance Bot automates attendance marking for Royal Holloway students
77
77
 
78
78
  Install Google Chrome if you don't have it yet: [download page](https://www.google.com/chrome/) (macOS users can also `brew install --cask google-chrome`).
79
79
 
80
+ Choose one of the two modes below:
81
+
82
+ 1) **Recommended: Install via pip**
83
+ 2) **Run from source**
84
+
85
+ ### Mode 1: Install via pip (recommended)
86
+
87
+ ```bash
88
+ pip install rhul-attendance-bot
89
+ ```
90
+
91
+ ### Mode 2: Run from source
92
+
80
93
  ### Step 1: Clone the Repository
81
94
 
82
95
  ```bash
83
96
  git clone https://github.com/PandaQuQ/RHUL_attendance_bot.git
84
97
  ```
85
98
 
86
- ### Step 2: Navigate to the Project Directory
99
+ ### Step 2: Navigate to the Project Directory (source install only)
87
100
 
88
101
  ```bash
89
102
  cd RHUL_attendance_bot
@@ -103,15 +116,7 @@ python3 -m venv venv
103
116
  source venv/bin/activate
104
117
  ```
105
118
 
106
- ### Step 4: Install Dependencies
107
-
108
- #### Option A: Install from PyPI (recommended)
109
-
110
- ```bash
111
- pip install rhul-attendance-bot
112
- ```
113
-
114
- #### Option B: Install from source
119
+ ### Step 4: Install dependencies
115
120
 
116
121
  ```bash
117
122
  pip install -r requirements.txt
@@ -167,11 +172,20 @@ The bot now auto-downloads your timetable on first run and stores it in `ics/`.
167
172
  ```bash
168
173
  rhul-attendance-bot -user Alice
169
174
  ```
170
- - If `-user` is not provided, the app will list existing profiles and ask you to choose (or create a new one).
175
+ - If `-user` is not provided and profiles exist, the app will list them and ask you to choose.
176
+ - If no profiles exist, it starts onboarding automatically and then renames the profile folder to your Profile Nickname.
177
+
178
+ 5. **Cleanup (delete local data)**:
179
+
180
+ - Use `-clean` to remove all local app data under `~/.rhul_attendance_bot`:
181
+ ```bash
182
+ rhul-attendance-bot -clean
183
+ ```
171
184
 
172
- 5. **Keyboard Shortcuts**:
185
+ 6. **Keyboard Shortcuts**:
173
186
 
174
187
  - **Manually Trigger the Next Event**: Press `[`, then `]`
188
+ - **Refresh Calendar (re-fetch ICS)**: Press `[`, then `c`
175
189
  - **Exit the Script**: Press `[`, then `q`
176
190
 
177
191
  ## Important Notes
@@ -220,10 +234,11 @@ Current focus / future ideas:
220
234
  - ✅ **Automatic Login**: Stored credentials + TOTP drive a fully automatic login flow.
221
235
  - ✅ **2FA Code Reading**: OTP is generated locally from your saved secret.
222
236
  - ✅ **Discord Webhook Bot**: Discord webhook notifications for attendance status, login, and lifecycle (optional; disable by leaving webhook URL empty)
237
+ - ✅ **PyPI Release**: Package published and installable via `pip install rhul-attendance-bot`
223
238
 
224
239
  ## License
225
240
 
226
- This project is licensed under the MIT License with an additional clause. See the [LICENSE](LICENSE) file for details.
241
+ This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
227
242
 
228
243
  ## Acknowledgments
229
244
 
@@ -0,0 +1,14 @@
1
+ RHUL_attendance_bot.py,sha256=j6zr2yQHvzSmzO-7b66WVhEA-vL9RTVsDi7YooEEkfI,26103
2
+ app_paths.py,sha256=xMpjXSCiMkLIvpsCfoRj61rHMLnWnkGzFOQ4JV8_J5A,3482
3
+ auto_login.py,sha256=IfNu7WA-i75oLbJ5dQSWCZUAyloS86cKwhHue5wpoao,35234
4
+ discord_broadcast.py,sha256=0hRJ_vronQK_TvRgC06NNWxaj8892h-Q7Hzmrp3-lB4,2615
5
+ display_manager.py,sha256=-hW4YocMrdhj_6AcGD-XU9WDIy9BFIym5mRqVH--iLc,11180
6
+ fetch_ics.py,sha256=DkfH-XfdTeD-b9ZQvuLmfRE4R_wPwL0VjJaQwSP7LBM,7277
7
+ local_2fa.py,sha256=2PY6XtczgIxjKqWls9DeN27MWLIiNKbQdk9E3GZVJxU,729
8
+ update.py,sha256=kw_KWBJLu20JpMMmJQAAxu2ZZIlcUeRxppaVGr3S1r8,2354
9
+ rhul_attendance_bot-0.1.49.dist-info/licenses/LICENSE,sha256=iBk7hceb7NgwcLnsFlT_v2SE2-m9i8CzsjlvBw8xmPg,1075
10
+ rhul_attendance_bot-0.1.49.dist-info/METADATA,sha256=4ywwXdXS-uZm9cK8GvOYgZAD-eIhvbayhuFCEL_BVcY,10250
11
+ rhul_attendance_bot-0.1.49.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
12
+ rhul_attendance_bot-0.1.49.dist-info/entry_points.txt,sha256=TH2lA3ukDDxlnFr2CIy0xd1ZbsaO6FMjuUTDUIbkShs,65
13
+ rhul_attendance_bot-0.1.49.dist-info/top_level.txt,sha256=TABKd3ZTI6hvA899O5XSRt0lk58h9dxrv6QHChtJqmU,102
14
+ rhul_attendance_bot-0.1.49.dist-info/RECORD,,
@@ -1,14 +0,0 @@
1
- RHUL_attendance_bot.py,sha256=J-5aSVrfGL1gjlGgsBcb_QYW4OEXL81mFVL0kLgqE-I,24193
2
- app_paths.py,sha256=1VnUSQBaXCbAzDjivhCHyRTZJ3aHKMj6ewkcaMVDuHg,2161
3
- auto_login.py,sha256=CfSr2kiUz7SyfEAHDaZmisiSXO3RXuSUIm_QU087iEE,34654
4
- discord_broadcast.py,sha256=0hRJ_vronQK_TvRgC06NNWxaj8892h-Q7Hzmrp3-lB4,2615
5
- display_manager.py,sha256=iRPWxwFiQWSvWl-QazKsiaLbvX2T7LPPFItfMZ52-Yg,11079
6
- fetch_ics.py,sha256=5G2SYuYALqRxmT8TqUMTvNBvMHeoXuJueI9agyyApKs,4617
7
- local_2fa.py,sha256=2PY6XtczgIxjKqWls9DeN27MWLIiNKbQdk9E3GZVJxU,729
8
- update.py,sha256=kw_KWBJLu20JpMMmJQAAxu2ZZIlcUeRxppaVGr3S1r8,2354
9
- rhul_attendance_bot-0.1.48.dist-info/licenses/LICENSE,sha256=iBk7hceb7NgwcLnsFlT_v2SE2-m9i8CzsjlvBw8xmPg,1075
10
- rhul_attendance_bot-0.1.48.dist-info/METADATA,sha256=gx0sOQD_9mo0QzcFONZZHET94OD9-NFQPvIi7fzNze0,9721
11
- rhul_attendance_bot-0.1.48.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
12
- rhul_attendance_bot-0.1.48.dist-info/entry_points.txt,sha256=TH2lA3ukDDxlnFr2CIy0xd1ZbsaO6FMjuUTDUIbkShs,65
13
- rhul_attendance_bot-0.1.48.dist-info/top_level.txt,sha256=TABKd3ZTI6hvA899O5XSRt0lk58h9dxrv6QHChtJqmU,102
14
- rhul_attendance_bot-0.1.48.dist-info/RECORD,,