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.
discord_broadcast.py ADDED
@@ -0,0 +1,70 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ from datetime import datetime
5
+
6
+ import requests
7
+
8
+ DEFAULT_TIMEOUT = 5
9
+
10
+
11
+ class DiscordBroadcaster:
12
+ """Lightweight Discord webhook broadcaster with profile prefixing."""
13
+
14
+ def __init__(self, credentials_path='credentials.json', logger=None, profile_name=None):
15
+ self.logger = logger or logging.getLogger("attendance_bot")
16
+ self.credentials_path = credentials_path
17
+ self.webhook_url = None
18
+ self.enabled = False
19
+ self.profile_name = profile_name or "Unknown"
20
+ self._load_settings()
21
+
22
+ def _load_settings(self):
23
+ try:
24
+ with open(self.credentials_path, 'r') as f:
25
+ data = json.load(f)
26
+ self.webhook_url = data.get('discord_webhook_url')
27
+ if not self.profile_name and data.get('profile_nickname'):
28
+ self.profile_name = data.get('profile_nickname')
29
+ enabled_flag = data.get('enable_discord_webhook', True)
30
+ self.enabled = bool(self.webhook_url) and bool(enabled_flag)
31
+ except Exception:
32
+ self.enabled = False
33
+ self.webhook_url = None
34
+
35
+ def _send(self, content):
36
+ if not self.enabled or not self.webhook_url:
37
+ return False
38
+ message = content
39
+ if self.profile_name:
40
+ message = f"[{self.profile_name}] {content}"
41
+ try:
42
+ resp = requests.post(self.webhook_url, json={"content": message}, timeout=DEFAULT_TIMEOUT)
43
+ if resp.status_code >= 400:
44
+ self.logger.warning(f"Discord webhook failed: {resp.status_code} {resp.text}")
45
+ return False
46
+ return True
47
+ except Exception as e:
48
+ self.logger.warning(f"Discord webhook error: {e}")
49
+ return False
50
+
51
+ def notify_bot_started(self, version_label=None):
52
+ suffix = f" ({version_label})" if version_label else ""
53
+ return self._send(f"🚀 Bot started{suffix}")
54
+
55
+ def notify_bot_stopped(self, runtime=None):
56
+ suffix = f" (runtime {runtime})" if runtime else ""
57
+ return self._send(f"🛑 Bot stopped{suffix}")
58
+
59
+ def notify_renew_login_success(self):
60
+ return self._send("✅ renew_login succeeded")
61
+
62
+ def notify_attendance_success(self, event_name, event_time=None):
63
+ when = ""
64
+ if isinstance(event_time, datetime):
65
+ try:
66
+ local_time = event_time.astimezone()
67
+ when = f" at {local_time.strftime('%Y-%m-%d %H:%M:%S')}"
68
+ except Exception:
69
+ pass
70
+ return self._send(f"✅ Attendance marked: {event_name}{when}")
display_manager.py ADDED
@@ -0,0 +1,274 @@
1
+ import sys
2
+ import time
3
+ import colorsys
4
+ import logging
5
+ import threading
6
+ from collections import deque
7
+ from datetime import datetime
8
+
9
+ try:
10
+ import termios
11
+ except ImportError: # pragma: no cover - non-POSIX
12
+ termios = None
13
+
14
+ from rich.align import Align
15
+ from rich.console import Console
16
+ from rich.live import Live
17
+ from rich.panel import Panel
18
+ from rich.table import Table
19
+ from rich.text import Text
20
+ from pynput import mouse
21
+
22
+
23
+ class BufferLogHandler(logging.Handler):
24
+ def __init__(self, buffer, buffer_lock):
25
+ super().__init__()
26
+ self.buffer = buffer
27
+ self.buffer_lock = buffer_lock
28
+
29
+ def emit(self, record):
30
+ log_entry = self.format(record)
31
+ with self.buffer_lock:
32
+ ts = datetime.now().strftime("%m/%d %H:%M:%S")
33
+ prefix = f"[yellow][{ts}][/yellow] "
34
+ # Add color to log levels (gray override for background noise)
35
+ if getattr(record, "gray", False):
36
+ log_entry = f"[bright_black]{log_entry}[/bright_black]"
37
+ elif record.levelno == logging.INFO:
38
+ log_entry = f"[green]{log_entry}[/green]"
39
+ elif record.levelno == logging.WARNING:
40
+ log_entry = f"[yellow]{log_entry}[/yellow]"
41
+ elif record.levelno == logging.ERROR:
42
+ log_entry = f"[red]{log_entry}[/red]"
43
+ elif record.levelno == logging.DEBUG:
44
+ log_entry = f"[blue]{log_entry}[/blue]"
45
+ self.buffer.append(prefix + log_entry)
46
+
47
+
48
+ class DisplayManager:
49
+ """Render the Rich dashboard in its own loop."""
50
+
51
+ def __init__(
52
+ self,
53
+ console: Console,
54
+ profile_nickname,
55
+ broadcaster,
56
+ get_git_info,
57
+ get_runtime_duration,
58
+ get_attendance_count,
59
+ log_window=7,
60
+ ):
61
+ self.console = console
62
+ self.profile_nickname = profile_nickname
63
+ self.broadcaster = broadcaster
64
+ self.get_git_info = get_git_info
65
+ self.get_runtime_duration = get_runtime_duration
66
+ self.get_attendance_count = get_attendance_count
67
+ self.log_window = log_window
68
+ self.log_buffer = deque(maxlen=200)
69
+ self.log_buffer_lock = threading.Lock()
70
+ self.log_scroll_offset = 0
71
+ self.log_scroll_lock = threading.Lock()
72
+ self.refresh_event = threading.Event()
73
+ self._buffer_handler = BufferLogHandler(self.log_buffer, self.log_buffer_lock)
74
+ self._stdin_fd = None
75
+ self._old_term_attrs = None
76
+
77
+ def get_log_handler(self):
78
+ return self._buffer_handler
79
+
80
+ def adjust_log_scroll(self, delta):
81
+ with self.log_buffer_lock, self.log_scroll_lock:
82
+ max_offset = max(len(self.log_buffer) - self.log_window, 0)
83
+ self.log_scroll_offset = min(max(self.log_scroll_offset + delta, 0), max_offset)
84
+ self.refresh_event.set()
85
+
86
+ def get_log_state(self):
87
+ with self.log_buffer_lock, self.log_scroll_lock:
88
+ n = len(self.log_buffer)
89
+ offset = min(self.log_scroll_offset, max(n - self.log_window, 0))
90
+ if n <= self.log_window:
91
+ lines = list(self.log_buffer)
92
+ else:
93
+ bottom = max(self.log_window, min(n, n - offset))
94
+ start = bottom - self.log_window
95
+ end = bottom
96
+ lines = list(self.log_buffer)[start:end]
97
+ return {
98
+ "lines": lines,
99
+ "total": n,
100
+ "offset": offset,
101
+ "window": self.log_window,
102
+ }
103
+
104
+ def listen_for_scroll(self, exit_event):
105
+ def on_scroll(x, y, dx, dy):
106
+ try:
107
+ if dy > 0:
108
+ self.adjust_log_scroll(1)
109
+ elif dy < 0:
110
+ self.adjust_log_scroll(-1)
111
+ except Exception:
112
+ pass
113
+
114
+ listener = mouse.Listener(on_scroll=on_scroll, suppress=False)
115
+ listener.start()
116
+ while not exit_event.is_set():
117
+ time.sleep(0.2)
118
+ listener.stop()
119
+
120
+ def _nickname_color(self, t=None, speed=0.05):
121
+ """Return a smoothly changing hex color for the profile label."""
122
+ if t is None:
123
+ t = time.time()
124
+ hue = (t * speed) % 1.0
125
+ r, g, b = colorsys.hsv_to_rgb(hue, 1.0, 1.0)
126
+ return f"#{int(r*255):02x}{int(g*255):02x}{int(b*255):02x}"
127
+
128
+ def _enter_noecho(self):
129
+ if termios is None:
130
+ return
131
+ if not sys.stdin or not sys.stdin.isatty():
132
+ return
133
+ try:
134
+ fd = sys.stdin.fileno()
135
+ attrs = termios.tcgetattr(fd)
136
+ new_attrs = termios.tcgetattr(fd)
137
+ new_attrs[3] = new_attrs[3] & ~termios.ECHO
138
+ termios.tcsetattr(fd, termios.TCSADRAIN, new_attrs)
139
+ self._stdin_fd = fd
140
+ self._old_term_attrs = attrs
141
+ except Exception:
142
+ self._stdin_fd = None
143
+ self._old_term_attrs = None
144
+
145
+ def _restore_noecho(self):
146
+ if termios is None:
147
+ return
148
+ if self._stdin_fd is None or self._old_term_attrs is None:
149
+ return
150
+ try:
151
+ termios.tcsetattr(self._stdin_fd, termios.TCSADRAIN, self._old_term_attrs)
152
+ except Exception:
153
+ pass
154
+ self._stdin_fd = None
155
+ self._old_term_attrs = None
156
+
157
+ def run(self, exit_event):
158
+ git_commit, git_date, git_count = self.get_git_info()
159
+ try:
160
+ git_count_display = str(int(git_count) - 1)
161
+ except Exception:
162
+ git_count_display = git_count
163
+
164
+ self._enter_noecho()
165
+ try:
166
+ with Live(refresh_per_second=60, console=self.console, screen=True) as live:
167
+ while not exit_event.is_set():
168
+ current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
169
+ runtime = self.get_runtime_duration()
170
+ attendance = self.get_attendance_count()
171
+ log_state = self.get_log_state()
172
+ latest_logs = log_state.get("lines", [])
173
+ total_logs = log_state.get("total", 0)
174
+ offset = log_state.get("offset", 0)
175
+ window = log_state.get("window", 7)
176
+
177
+ broadcast_enabled = False
178
+ try:
179
+ broadcast_enabled = bool(getattr(self.broadcaster, "enabled", False))
180
+ except Exception:
181
+ broadcast_enabled = False
182
+
183
+ info_table = Table.grid(expand=True)
184
+ info_table.add_row(
185
+ f"[bold cyan]Current Time:[/bold cyan] {current_time}",
186
+ f"[bold green]Runtime Duration:[/bold green] {runtime}",
187
+ f"[bold yellow]Attendance Success Count:[/bold yellow] {attendance}",
188
+ f"[bold magenta]PandaQuQ:[/bold magenta] [link=https://github.com/PandaQuQ/RHUL_attendance_bot]GitHub Repo[/link]",
189
+ )
190
+
191
+ log_content = "\n".join(latest_logs) if latest_logs else "No logs available."
192
+
193
+ logs_table = Table.grid(expand=True)
194
+ if total_logs <= window:
195
+ logs_table.add_column(ratio=1)
196
+ logs_table.add_row(
197
+ Panel(
198
+ log_content,
199
+ title="[bold green]Latest Logs[/bold green]",
200
+ border_style="green",
201
+ padding=(1, 2),
202
+ )
203
+ )
204
+ else:
205
+ scrollbar_lines = []
206
+ thumb_size = max(1, int(window * window / total_logs))
207
+ track_size = window
208
+ max_thumb_pos = max(track_size - thumb_size, 0)
209
+ thumb_pos = max_thumb_pos - int((offset / max(total_logs - window, 1)) * max_thumb_pos)
210
+ for i in range(track_size):
211
+ if thumb_pos <= i < thumb_pos + thumb_size:
212
+ scrollbar_lines.append("â–ˆ")
213
+ else:
214
+ scrollbar_lines.append("│")
215
+
216
+ scrollbar_text = Text("\n".join(scrollbar_lines))
217
+
218
+ logs_table.add_column(ratio=30)
219
+ logs_table.add_column(ratio=1, width=1)
220
+ logs_table.add_row(
221
+ Panel(
222
+ log_content,
223
+ title="[bold green]Latest Logs[/bold green]",
224
+ border_style="green",
225
+ padding=(1, 2),
226
+ ),
227
+ Panel(scrollbar_text, border_style="green", padding=(1, 0)),
228
+ )
229
+
230
+ instructions = Text.from_markup(
231
+ "Press [yellow][[/yellow] then [yellow]][/yellow] to manually trigger the next event\n"
232
+ "Press [yellow][[/yellow] then [yellow]q[/yellow] to exit the script",
233
+ justify="center",
234
+ )
235
+ shortcut_instructions = Align.center(instructions, vertical="middle")
236
+
237
+ version_number_text = Align.center(
238
+ f"This is the No.[red]{git_count_display}[/red] version", vertical="middle"
239
+ )
240
+ nick_color = self._nickname_color()
241
+ nickname_text = Align.center(
242
+ f"[bold]{'[white]Profile:[/]'} [{nick_color}]{self.profile_nickname}[/]", vertical="middle"
243
+ )
244
+ broadcast_status = (
245
+ "[green]Enabled[/green]" if broadcast_enabled else "[red]Disabled[/red]"
246
+ )
247
+ broadcast_text = Align.center(
248
+ f"Discord Broadcast: {broadcast_status}", vertical="middle"
249
+ )
250
+ version_text = Align.center(
251
+ f"[bright_black]Commit: {git_commit} ({git_date})[/bright_black]",
252
+ vertical="middle",
253
+ )
254
+
255
+ layout = Table.grid(expand=True)
256
+ layout.add_row(info_table)
257
+ layout.add_row(logs_table)
258
+ layout.add_row(nickname_text)
259
+ layout.add_row(broadcast_text)
260
+ layout.add_row(shortcut_instructions)
261
+ layout.add_row(version_text)
262
+ layout.add_row(version_number_text)
263
+
264
+ live.update(layout)
265
+
266
+ if exit_event.wait(timeout=0):
267
+ break
268
+ # Wake early on scroll; otherwise short sleep to keep CPU low
269
+ if self.refresh_event.wait(timeout=0.02):
270
+ self.refresh_event.clear()
271
+ continue
272
+ time.sleep(0.02)
273
+ finally:
274
+ self._restore_noecho()
fetch_ics.py ADDED
@@ -0,0 +1,103 @@
1
+ import os
2
+ import json
3
+ import time
4
+ from selenium import webdriver
5
+ from selenium.webdriver.chrome.service import Service
6
+ from selenium.webdriver.common.by import By
7
+ from selenium.webdriver.support.ui import WebDriverWait, Select
8
+ from selenium.webdriver.support import expected_conditions as EC
9
+ from selenium.webdriver.chrome.options import Options
10
+ from webdriver_manager.chrome import ChromeDriverManager
11
+ from app_paths import get_credentials_path, get_ics_dir, prompt_select_profile
12
+ TIMETABLE_URL = 'https://webtimetables.royalholloway.ac.uk/'
13
+
14
+
15
+ def load_credentials(profile_name=None):
16
+ credentials_path = get_credentials_path(profile_name)
17
+ if not os.path.exists(credentials_path):
18
+ raise RuntimeError('Missing credentials.json')
19
+ with open(credentials_path, 'r') as f:
20
+ return json.load(f)
21
+
22
+ def start_driver():
23
+ options = Options()
24
+ options.add_argument('--no-sandbox')
25
+ options.add_argument('--disable-dev-shm-usage')
26
+ options.add_experimental_option('excludeSwitches', ['enable-logging'])
27
+ service = Service(ChromeDriverManager().install())
28
+ driver = webdriver.Chrome(service=service, options=options)
29
+ return driver
30
+
31
+ def fetch_ics_url(profile_name=None):
32
+ creds = load_credentials(profile_name)
33
+ username = creds['username'].split('@')[0]
34
+ password = creds['password']
35
+ driver = start_driver()
36
+ driver.get(TIMETABLE_URL)
37
+ try:
38
+ # Login
39
+ WebDriverWait(driver, 20).until(EC.presence_of_element_located((By.ID, 'tUserName')))
40
+ driver.find_element(By.ID, 'tUserName').send_keys(username)
41
+ driver.find_element(By.ID, 'tPassword').send_keys(password)
42
+ driver.find_element(By.ID, 'bLogin').click()
43
+ time.sleep(2)
44
+ # Click My Timetable
45
+ WebDriverWait(driver, 20).until(EC.element_to_be_clickable((By.ID, 'LinkBtn_studentMyTimetable')))
46
+ driver.find_element(By.ID, 'LinkBtn_studentMyTimetable').click()
47
+ time.sleep(1)
48
+ # Select weeks: Autumn, Spring & Summer Term
49
+ WebDriverWait(driver, 20).until(EC.presence_of_element_located((By.ID, 'lbWeeks')))
50
+ select_weeks = Select(driver.find_element(By.ID, 'lbWeeks'))
51
+ select_weeks.select_by_value('2;3;4;5;6;7;8;9;10;11;12;18;19;20;21;22;23;24;25;26;27;28;33;34;35;36;37;38')
52
+ time.sleep(1)
53
+ # Select iCal radio
54
+ WebDriverWait(driver, 20).until(EC.element_to_be_clickable((By.ID, 'RadioType_2')))
55
+ driver.find_element(By.ID, 'RadioType_2').click()
56
+ time.sleep(1)
57
+ # Click View Timetable
58
+ WebDriverWait(driver, 20).until(EC.element_to_be_clickable((By.ID, 'bGetTimetable')))
59
+ driver.find_element(By.ID, 'bGetTimetable').click()
60
+ time.sleep(1)
61
+ # Click Android link
62
+ WebDriverWait(driver, 20).until(EC.element_to_be_clickable((By.ID, 'android')))
63
+ driver.find_element(By.ID, 'android').click()
64
+ time.sleep(1)
65
+ # Extract iCal URL
66
+ ical_url = None
67
+ strongs = driver.find_elements(By.TAG_NAME, 'strong')
68
+ for s in strongs:
69
+ text = s.text.strip()
70
+ if text.startswith('https://webtimetables.royalholloway.ac.uk/ical/default.aspx?'):
71
+ ical_url = text
72
+ break
73
+ if ical_url:
74
+ print(f'iCal URL: {ical_url}')
75
+ # Download the .ics file
76
+ import requests
77
+ # Campus site has broken/unknown cert; disable verification for this download.
78
+ requests.packages.urllib3.disable_warnings() # suppress InsecureRequestWarning
79
+ response = requests.get(ical_url, verify=False)
80
+ if response.status_code == 200:
81
+ ics_folder = get_ics_dir(profile_name)
82
+ ics_path = os.path.join(ics_folder, 'student_timetable.ics')
83
+ with open(ics_path, 'wb') as f:
84
+ f.write(response.content)
85
+ print(f'.ics file saved to {ics_path}')
86
+ else:
87
+ print(f'Failed to download .ics file. Status code: {response.status_code}')
88
+ else:
89
+ print('Could not find iCal URL on the page.')
90
+ except Exception as e:
91
+ print(f'Error during timetable automation: {e}')
92
+ finally:
93
+ time.sleep(5)
94
+ driver.quit()
95
+
96
+ if __name__ == '__main__':
97
+ import argparse
98
+
99
+ parser = argparse.ArgumentParser(description="RHUL timetable ICS fetcher")
100
+ parser.add_argument("-user", "--user", dest="profile", default=None, help="Profile name")
101
+ args = parser.parse_args()
102
+ profile_name = args.profile if args.profile else prompt_select_profile()
103
+ fetch_ics_url(profile_name=profile_name)
local_2fa.py ADDED
@@ -0,0 +1,31 @@
1
+ import pyotp
2
+ import json
3
+ import os
4
+ from datetime import datetime
5
+
6
+ CONFIG_FILE = '2fa_config.json'
7
+
8
+ def bind(secret: str):
9
+ """Bind the Microsoft Authenticator secret (Base32 string)."""
10
+ with open(CONFIG_FILE, 'w') as f:
11
+ json.dump({'secret': secret}, f)
12
+
13
+
14
+ def load_secret():
15
+ if not os.path.exists(CONFIG_FILE):
16
+ return None
17
+ with open(CONFIG_FILE, 'r') as f:
18
+ return json.load(f).get('secret')
19
+
20
+
21
+ def get_otp():
22
+ """Return the current OTP for the bound secret."""
23
+ secret = load_secret()
24
+ if not secret:
25
+ raise ValueError('No secret bound. Please bind first.')
26
+ totp = pyotp.TOTP(secret)
27
+ return totp.now()
28
+
29
+ # Example usage:
30
+ # bind('YOUR_BASE32_SECRET')
31
+ # print(get_otp())