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 +580 -0
- app_paths.py +74 -0
- auto_login.py +739 -0
- discord_broadcast.py +70 -0
- display_manager.py +274 -0
- fetch_ics.py +103 -0
- local_2fa.py +31 -0
- rhul_attendance_bot-0.1.48.dist-info/METADATA +234 -0
- rhul_attendance_bot-0.1.48.dist-info/RECORD +14 -0
- rhul_attendance_bot-0.1.48.dist-info/WHEEL +5 -0
- rhul_attendance_bot-0.1.48.dist-info/entry_points.txt +2 -0
- rhul_attendance_bot-0.1.48.dist-info/licenses/LICENSE +22 -0
- rhul_attendance_bot-0.1.48.dist-info/top_level.txt +8 -0
- update.py +67 -0
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
|