From 9fa32dc03c2316a48c3db7a35ed7da0890865ac1 Mon Sep 17 00:00:00 2001 From: ed522 Date: Sun, 30 Nov 2025 14:29:49 -0500 Subject: [PATCH 001/118] commit --- AttendanceCodeCommunicator.py | 75 +++++++++++++++++++++++++++++++++++ cogs/ServerStatus.py | 63 +++++++++++++++++++++++++++++ main.py | 1 + 3 files changed, 139 insertions(+) create mode 100644 AttendanceCodeCommunicator.py create mode 100644 cogs/ServerStatus.py diff --git a/AttendanceCodeCommunicator.py b/AttendanceCodeCommunicator.py new file mode 100644 index 0000000..b3d6c6a --- /dev/null +++ b/AttendanceCodeCommunicator.py @@ -0,0 +1,75 @@ +import logging +import json +import socketserver +from dotenv import load_dotenv + +load_dotenv() + +class AttendanceRequestHandler(socketserver.BaseRequestHandler): + + CONST_CODE_VALID_DURATION = 30 # seconds + + counter = 0 + + def handle(self): + connect_message = json.loads(self.request.recv(256)) + if connect_message['type'] != 'connect': + logging.log(logging.ERROR, f"Failed to connect with client, wrong type {connect_message['type']}") + return # let client try again after 10s + + response = { + 'type': 'acknowledge', + 'targeting': connect_message['type'] + } + self.request.sendall(json.dumps(response)) + + while True: + message = json.loads(self.request.recv(256)) + + if message['type'] == 'heartbeat': + self.counter += 1 + if self.counter != message['counter']: + response = { + 'type': 'heartbeat_error', + 'counter': self.counter + } + self.request.sendall(json.dumps(response)) + + logging.log(logging.WARN, f"Connection error with attendance code client: got counter {message['counter']}, expected {self.counter}") + + else: + response = { + 'type': 'acknowledge', + 'targeting': message['type'], + 'counter': self.counter + } + self.request.sendall(json.dumps(response)) + + elif message['type'] == 'code': + code = int(message['code']) + generation_time = int(message['generation_time']) + response = { + 'type': 'acknowledge', + 'targeting': message['type'], + 'code': code, + 'valid_to': generation_time + self.CONST_CODE_VALID_DURATION + } + self.request.sendall(json.dumps(response)) + # TODO commit to a db somewhere + + else: + logging.log(logging.WARN, f"Unknown message {message['type']}, ignoring") + +def run(): + # Specs: + # On connect, send JSON object with type = "connect" + # Server sends type = "acknowledge", targeting = "connect" + # On every code generation event, type = "code", code = , generation_time = + # Server acknowledges with type = "acknowledge", targeting = "code", code = , valid_to = + # Every 10s, send type = "heartbeat", counter = + # Server sends type = "acknowledge", targeting = "heartbeat", counter = + # Errors: + # If the heartbeat counters do not match, server sends type = "heartbeat_error", counter = + # If connection fails, disconnect and try again after 10s + server = socketserver.TCPServer(('localhost', 5789), AttendanceRequestHandler) + server.serve_forever() \ No newline at end of file diff --git a/cogs/ServerStatus.py b/cogs/ServerStatus.py new file mode 100644 index 0000000..77e1a6b --- /dev/null +++ b/cogs/ServerStatus.py @@ -0,0 +1,63 @@ +# This file is meant to be an example of how to create commands and is not loaded in main.py +# After making your command file add a line to load it in main.py +# The line will look like this in the load_extensions function: + # await self.load_extension("cogs.Example") + +import discord +import requests +from discord.ext import commands +from discord import app_commands +import os + +class ServerStatus(commands.Cog): + def __init__(self, bot): + self.bot = bot + + # Guild syncing + guilds = [ + int(os.getenv("guild_id")) if os.getenv("guild_id") else None, + int(os.getenv("dev_guild_id")) if os.getenv("dev_guild_id") else None, + ] + guilds = [g for g in guilds if g is not None] # remove missing ones + @app_commands.guilds(*guilds) + + # name must be lowercase and can only contain certain special characters like hyphens and underscores + @app_commands.command( + name="status", + description="Check if TBA, Statbotics and the FIRST API are online" + ) + + async def execute(self, interaction: discord.Interaction): + fetch_message = interaction.response.send_message("Fetching server status...") + + # get status + tba_response = requests.get('https://www.thebluealliance.com/api/v3/status') + tba_status = tba_response.status_code + tba_datafeed_down = None + tba_downtime_events = None + if tba_status == 200: + tba_datafeed_down = tba_response.json()['is_datafeed_down'] + tba_downtime_events = tba_response.json()['down_events'] + + stat_response = requests.get('https://www.statbotics.io/event/api?rand=${dayjs().unix()}') + stat_status = stat_response.status_code + + frc_response = requests.get('https://frc-api.firstinspires.org/v3.0?rand=${dayjs().unix()}') + frc_status = frc_response.status_code + + # make sure fetch message finishes + await fetch_message + + # format everything + + message = "# Server Status\n" + message += "**The Blue Alliance**" + message += f"Status: {f"OK ({tba_status})" if tba_status < 400 else f"DOWN ({tba_status})"}\n" + message += f"Is datafeed up? {":x:" if tba_datafeed_down else ":white_check_mark:"}\n" + + # send it + await interaction.response.send_message(message) + + +async def setup(bot): + await bot.add_cog(ServerStatus(bot)) diff --git a/main.py b/main.py index 20ee695..c28dd61 100644 --- a/main.py +++ b/main.py @@ -26,6 +26,7 @@ async def on_ready(): async def load_extensions(): await bot.load_extension("cogs.ScoringGuide") await bot.load_extension("cogs.NoBlueBanners") + await bot.load_extension("cogs.ServerStatus") print("Extensions all loaded") if __name__ == "__main__": From 7d6305f97cb94a96c4d2e5a425deaea251e7a6cd Mon Sep 17 00:00:00 2001 From: marginaldev <147557022+ed522@users.noreply.github.com> Date: Sun, 30 Nov 2025 14:36:17 -0500 Subject: [PATCH 002/118] add info to readme --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8ebd2be..bae3e15 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +### This is a forked version of the original at https://github.com/Graham277/NewDozer. Original readme below + ## About A python rewrite of the original dozer-discord-bot. Much of this project is powered by the blue alliance at https://www.thebluealliance.com/ @@ -17,4 +19,4 @@ Much of this project is powered by the blue alliance at https://www.theblueallia This allows you to develop and test the status on your own server without clogging up the production server. 5. Run main.py -Requires the discord.py and dotenv libraries \ No newline at end of file +Requires the discord.py and dotenv libraries From 6f2a157b21418eb1f65a809205ab2cb35837c469 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Mon, 1 Dec 2025 07:59:54 -0500 Subject: [PATCH 003/118] update attendance communicator --- AttendanceCodeCommunicator.py | 140 ++++++++++++++++++++-------------- 1 file changed, 82 insertions(+), 58 deletions(-) diff --git a/AttendanceCodeCommunicator.py b/AttendanceCodeCommunicator.py index b3d6c6a..5570c47 100644 --- a/AttendanceCodeCommunicator.py +++ b/AttendanceCodeCommunicator.py @@ -1,75 +1,99 @@ import logging import json +import socket import socketserver +import threading +from operator import truediv + from dotenv import load_dotenv load_dotenv() -class AttendanceRequestHandler(socketserver.BaseRequestHandler): +def run(): + # Specs: + # + # A screen sends out a 255.255.255.255 broadcast app: attendance, type: discovery, version: 1 (as of now), host matching local IP + # Server connects to host given in the parameter + # + # On connect, screen sends JSON object with type = "connect" + # Server sends type = "acknowledge", targeting = "connect" + # On every code generation event (screen): type = "code", code = , generation_time = + # Server acknowledges with type = "acknowledge", targeting = "code", code = , valid_to = + # Every 10s, screen sends type = "heartbeat", counter = + # Server sends type = "acknowledge", targeting = "heartbeat", counter = + # + # Errors: + # If the heartbeat counters do not match, server sends type = "heartbeat_error", counter = + # If connection fails, disconnect and try again after 10s + # + # Note that the "server" here is the discord bot, but the server is technically the tablet + def _communicate(): + broadcast_in_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + broadcast_in_sock.connect(('0.0.0.0', 5789)) + + thread = threading.Thread(target=_communicate, daemon=True) + thread.start() + +def _discover(sock: socket.socket): + + const_version = 1 - CONST_CODE_VALID_DURATION = 30 # seconds + while True: + message = json.loads(sock.recv) #something? TODO FIX + if message['app'] == 'attendance' and message['type'] == 'discovery' and message['version'] == const_version: + return message['host'] + +def _communicate_after_handshake(request): + + const_code_valid_duration = 30 # seconds counter = 0 - def handle(self): - connect_message = json.loads(self.request.recv(256)) - if connect_message['type'] != 'connect': - logging.log(logging.ERROR, f"Failed to connect with client, wrong type {connect_message['type']}") - return # let client try again after 10s - - response = { - 'type': 'acknowledge', - 'targeting': connect_message['type'] - } - self.request.sendall(json.dumps(response)) - - while True: - message = json.loads(self.request.recv(256)) - - if message['type'] == 'heartbeat': - self.counter += 1 - if self.counter != message['counter']: - response = { - 'type': 'heartbeat_error', - 'counter': self.counter - } - self.request.sendall(json.dumps(response)) - - logging.log(logging.WARN, f"Connection error with attendance code client: got counter {message['counter']}, expected {self.counter}") - - else: - response = { - 'type': 'acknowledge', - 'targeting': message['type'], - 'counter': self.counter - } - self.request.sendall(json.dumps(response)) - - elif message['type'] == 'code': - code = int(message['code']) - generation_time = int(message['generation_time']) + connect_message = json.loads(request.recv(256)) + if connect_message['type'] != 'connect': + logging.log(logging.ERROR, f"Failed to connect with client, wrong type {connect_message['type']}") + return # let client try again after 10s + + response = { + 'type': 'acknowledge', + 'targeting': connect_message['type'] + } + request.sendall(json.dumps(response)) + + while True: + message = json.loads(request.recv(256)) + + if message['type'] == 'heartbeat': + counter += 1 + if counter != message['counter']: + response = { + 'type': 'heartbeat_error', + 'counter': counter + } + request.sendall(json.dumps(response)) + + logging.log(logging.WARN, + f"Connection error with attendance code client: got counter {message['counter']}, expected {counter}") + + else: response = { 'type': 'acknowledge', 'targeting': message['type'], - 'code': code, - 'valid_to': generation_time + self.CONST_CODE_VALID_DURATION + 'counter': counter } - self.request.sendall(json.dumps(response)) - # TODO commit to a db somewhere + request.sendall(json.dumps(response)) - else: - logging.log(logging.WARN, f"Unknown message {message['type']}, ignoring") + elif message['type'] == 'code': + code = int(message['code']) + generation_time = int(message['generation_time']) + response = { + 'type': 'acknowledge', + 'targeting': message['type'], + 'code': code, + 'valid_to': generation_time + const_code_valid_duration + } + request.sendall(json.dumps(response)) + # TODO commit to a db somewhere -def run(): - # Specs: - # On connect, send JSON object with type = "connect" - # Server sends type = "acknowledge", targeting = "connect" - # On every code generation event, type = "code", code = , generation_time = - # Server acknowledges with type = "acknowledge", targeting = "code", code = , valid_to = - # Every 10s, send type = "heartbeat", counter = - # Server sends type = "acknowledge", targeting = "heartbeat", counter = - # Errors: - # If the heartbeat counters do not match, server sends type = "heartbeat_error", counter = - # If connection fails, disconnect and try again after 10s - server = socketserver.TCPServer(('localhost', 5789), AttendanceRequestHandler) - server.serve_forever() \ No newline at end of file + else: + logging.log(logging.WARN, f"Unknown message {message['type']}, ignoring") \ No newline at end of file From 30747bc1aa91d69f924185ec1b0ca0bcd305d0e8 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 4 Dec 2025 17:29:17 -0500 Subject: [PATCH 004/118] various fixes and improvements --- .gitignore | 1 + AttendanceCodeCommunicator.py | 210 ++++++++++++++++++++-------------- cogs/MarkHere.py | 53 +++++++++ cogs/ServerStatus.py | 4 - main.py | 1 + 5 files changed, 179 insertions(+), 90 deletions(-) create mode 100644 cogs/MarkHere.py diff --git a/.gitignore b/.gitignore index 09e3868..d45a4cd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .idea/ .vscode/ .env +db.sqlite diff --git a/AttendanceCodeCommunicator.py b/AttendanceCodeCommunicator.py index 5570c47..290a9e9 100644 --- a/AttendanceCodeCommunicator.py +++ b/AttendanceCodeCommunicator.py @@ -1,99 +1,137 @@ import logging import json import socket -import socketserver +import sqlite3 import threading -from operator import truediv +from enum import Enum from dotenv import load_dotenv load_dotenv() -def run(): - # Specs: - # - # A screen sends out a 255.255.255.255 broadcast app: attendance, type: discovery, version: 1 (as of now), host matching local IP - # Server connects to host given in the parameter - # - # On connect, screen sends JSON object with type = "connect" - # Server sends type = "acknowledge", targeting = "connect" - # On every code generation event (screen): type = "code", code = , generation_time = - # Server acknowledges with type = "acknowledge", targeting = "code", code = , valid_to = - # Every 10s, screen sends type = "heartbeat", counter = - # Server sends type = "acknowledge", targeting = "heartbeat", counter = - # - # Errors: - # If the heartbeat counters do not match, server sends type = "heartbeat_error", counter = - # If connection fails, disconnect and try again after 10s - # - # Note that the "server" here is the discord bot, but the server is technically the tablet - def _communicate(): - broadcast_in_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - broadcast_in_sock.connect(('0.0.0.0', 5789)) - - thread = threading.Thread(target=_communicate, daemon=True) - thread.start() - -def _discover(sock: socket.socket): - - const_version = 1 - - while True: - message = json.loads(sock.recv) #something? TODO FIX - if message['app'] == 'attendance' and message['type'] == 'discovery' and message['version'] == const_version: - return message['host'] - -def _communicate_after_handshake(request): - - const_code_valid_duration = 30 # seconds - - counter = 0 - - connect_message = json.loads(request.recv(256)) - if connect_message['type'] != 'connect': - logging.log(logging.ERROR, f"Failed to connect with client, wrong type {connect_message['type']}") - return # let client try again after 10s - - response = { - 'type': 'acknowledge', - 'targeting': connect_message['type'] - } - request.sendall(json.dumps(response)) - - while True: - message = json.loads(request.recv(256)) - - if message['type'] == 'heartbeat': - counter += 1 - if counter != message['counter']: - response = { - 'type': 'heartbeat_error', - 'counter': counter - } - request.sendall(json.dumps(response)) - - logging.log(logging.WARN, - f"Connection error with attendance code client: got counter {message['counter']}, expected {counter}") - - else: +class Status(Enum): + DISCONNECTED = 0 + CONNECTING = 1 + CONNECTED = 2 +class AttendanceCodeCommunicator: + + db_connection = None + db_temp = {} + status: Status = Status.DISCONNECTED + + def __init__(self, db_path): + db_connection = sqlite3.connect(db_path) + db_connection.execute("CREATE TABLE IF NOT EXISTS Attendance ( user VARCHAR(255), timestamp INTEGER );") + + def run(self): + # Specs: + # + # A screen sends out a 255.255.255.255 broadcast app: attendance, type: discovery, version: 1 (as of now), host matching local IP + # Server connects to host given in the parameter + # + # On connect, screen sends JSON object with type = "connect" + # Server sends type = "acknowledge", targeting = "connect" + # On every code generation event (screen): type = "code", code = , generation_time = + # Server acknowledges with type = "acknowledge", targeting = "code", code = , valid_to = + # Every 10s, screen sends type = "heartbeat", counter = + # Server sends type = "acknowledge", targeting = "heartbeat", counter = + # + # Errors: + # If the heartbeat counters do not match, server sends type = "heartbeat_error", counter = + # If connection fails, disconnect and try again after 10s + # + # Note that the "server" here is the discord bot, but the server is technically the tablet + def _communicate(): + """ + Attempt to communicate with the remote screen. + Handles both broadcasts and regular communication. + Intended to be run as a thread + :return: None + """ + logging.log(logging.INFO, f"Starting attendance communicator") + broadcast_in_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + broadcast_in_sock.connect(('0.0.0.0', 5789)) + logging.log(logging.INFO, "Now listening on 0.0.0.0 port 5789") + host = self._discover(broadcast_in_sock) + logging.log(logging.INFO, "Found an endpoint") + self.status = Status.CONNECTING + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect((host, 5789)) + + if self._handshake(sock): + self.status = Status.CONNECTED + self._communicate_after_handshake(sock) + + thread = threading.Thread(target=_communicate, daemon=True) + thread.start() + + def _discover(self, sock: socket.socket): + + const_version = 1 + + while True: + try: + message = json.loads(sock.recv(1024)) + except ValueError: + continue + if message['app'] == 'attendance' and message['type'] == 'discovery' and message['version'] == const_version: + return message['host'] + + def _handshake(self, sock: socket.socket): + + connect_message = json.loads(sock.recv(1024)) + if connect_message['type'] != 'connect': + logging.log(logging.ERROR, f"Failed to connect with endpoint, wrong type {connect_message['type']}") + return False # let endpoint try again after 10s + + response = { + 'type': 'acknowledge', + 'targeting': connect_message['type'] + } + sock.sendall(bytes(json.dumps(response), 'utf-8')) + return True + + def _communicate_after_handshake(self, sock: socket.socket): + + const_code_show_duration = 30 # seconds + const_code_valid_duration = 60 # seconds + + counter = 0 + + while True: + message = json.loads(sock.recv(1024)) + + if message['type'] == 'heartbeat': + counter += 1 + if counter != message['counter']: + response = { + 'type': 'heartbeat_error', + 'counter': counter + } + sock.sendall(bytes(json.dumps(response), 'utf-8')) + + logging.log(logging.WARN, + f"Connection error with attendance code endpoint: got counter {message['counter']}, expected {counter}") + + else: + response = { + 'type': 'acknowledge', + 'targeting': message['type'], + 'counter': counter + } + sock.sendall(bytes(json.dumps(response), 'utf-8')) + + elif message['type'] == 'code': + code = int(message['code']) + generation_time = int(message['generation_time']) response = { 'type': 'acknowledge', 'targeting': message['type'], - 'counter': counter + 'code': code, + 'valid_to': generation_time + const_code_show_duration } - request.sendall(json.dumps(response)) - - elif message['type'] == 'code': - code = int(message['code']) - generation_time = int(message['generation_time']) - response = { - 'type': 'acknowledge', - 'targeting': message['type'], - 'code': code, - 'valid_to': generation_time + const_code_valid_duration - } - request.sendall(json.dumps(response)) - # TODO commit to a db somewhere - - else: - logging.log(logging.WARN, f"Unknown message {message['type']}, ignoring") \ No newline at end of file + sock.sendall(bytes(json.dumps(response), 'utf-8')) + self.db_temp[response['code']] = generation_time + const_code_valid_duration + + else: + logging.log(logging.WARN, f"Unknown message {message['type']}, ignoring") \ No newline at end of file diff --git a/cogs/MarkHere.py b/cogs/MarkHere.py new file mode 100644 index 0000000..ec88eb8 --- /dev/null +++ b/cogs/MarkHere.py @@ -0,0 +1,53 @@ +import time +from math import floor + +import discord +from discord.ext import commands +from discord import app_commands +import os + +from AttendanceCodeCommunicator import AttendanceCodeCommunicator + + +class MarkHere(commands.Cog): + + communicator: AttendanceCodeCommunicator + + def __init__(self, bot): + self.bot = bot + self.communicator = AttendanceCodeCommunicator("db.sqlite") + self.communicator.run() # starts background threa + + # Guild syncing + guilds = [ + int(os.getenv("guild_id")) if os.getenv("guild_id") else None, + int(os.getenv("dev_guild_id")) if os.getenv("dev_guild_id") else None, + ] + guilds = [g for g in guilds if g is not None] # remove missing ones + @app_commands.guilds(*guilds) + + # name must be lowercase and can only contain certain special characters like hyphens and underscores + @app_commands.command( + name='markhere', + description='Mark yourself "present" on the attendance sheet.' + ) + + async def mark_here(self, interaction: discord.Interaction, code: int): + + await interaction.response.send_message(f"Marking {interaction.user.name} as present (with code: {code})...") + + timestamp = floor(time.time()) + if code not in self.communicator.db_temp: + await interaction.original_response().edit(content=f"Code does not exist! Was it typed correctly?") + return + # if code is no longer valid + if timestamp > self.communicator.db_temp[code]: + await interaction.original_response().edit(content=f"Code is no longer valid!") + return + + # so commit the record to memory + self.communicator.db_connection.execute("INSERT INTO Attendance (user, timestamp) VALUES (?, ?)", (interaction.user.name, timestamp)) + await interaction.original_response().edit(content=f"Marked {interaction.user.name} as present (code: {code})") + +async def setup(bot): + await bot.add_cog(MarkHere(bot)) diff --git a/cogs/ServerStatus.py b/cogs/ServerStatus.py index 77e1a6b..a83549c 100644 --- a/cogs/ServerStatus.py +++ b/cogs/ServerStatus.py @@ -28,7 +28,6 @@ def __init__(self, bot): ) async def execute(self, interaction: discord.Interaction): - fetch_message = interaction.response.send_message("Fetching server status...") # get status tba_response = requests.get('https://www.thebluealliance.com/api/v3/status') @@ -45,9 +44,6 @@ async def execute(self, interaction: discord.Interaction): frc_response = requests.get('https://frc-api.firstinspires.org/v3.0?rand=${dayjs().unix()}') frc_status = frc_response.status_code - # make sure fetch message finishes - await fetch_message - # format everything message = "# Server Status\n" diff --git a/main.py b/main.py index c28dd61..f0d232d 100644 --- a/main.py +++ b/main.py @@ -27,6 +27,7 @@ async def load_extensions(): await bot.load_extension("cogs.ScoringGuide") await bot.load_extension("cogs.NoBlueBanners") await bot.load_extension("cogs.ServerStatus") + await bot.load_extension("cogs.MarkHere") print("Extensions all loaded") if __name__ == "__main__": From 4e8ba7e28b22e0bcee84b4e8c131d6bb6b375297 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 4 Dec 2025 17:38:35 -0500 Subject: [PATCH 005/118] fix MarkHere messaging --- .../AttendanceCodeCommunicator.cpython-312.pyc | Bin 0 -> 6189 bytes cogs/MarkHere.py | 8 +++----- cogs/__pycache__/MarkHere.cpython-312.pyc | Bin 0 -> 3181 bytes cogs/__pycache__/NoBlueBanners.cpython-312.pyc | Bin 0 -> 2187 bytes cogs/__pycache__/ScoringGuide.cpython-312.pyc | Bin 0 -> 2205 bytes cogs/__pycache__/ServerStatus.cpython-312.pyc | Bin 0 -> 2978 bytes 6 files changed, 3 insertions(+), 5 deletions(-) create mode 100644 __pycache__/AttendanceCodeCommunicator.cpython-312.pyc create mode 100644 cogs/__pycache__/MarkHere.cpython-312.pyc create mode 100644 cogs/__pycache__/NoBlueBanners.cpython-312.pyc create mode 100644 cogs/__pycache__/ScoringGuide.cpython-312.pyc create mode 100644 cogs/__pycache__/ServerStatus.cpython-312.pyc diff --git a/__pycache__/AttendanceCodeCommunicator.cpython-312.pyc b/__pycache__/AttendanceCodeCommunicator.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bb1f585602949c4b09625e2049900728e1baef2a GIT binary patch literal 6189 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 self.communicator.db_temp[code]: - await interaction.original_response().edit(content=f"Code is no longer valid!") + await interaction.response().send_message(content=f"Code is no longer valid!") return # so commit the record to memory self.communicator.db_connection.execute("INSERT INTO Attendance (user, timestamp) VALUES (?, ?)", (interaction.user.name, timestamp)) - await interaction.original_response().edit(content=f"Marked {interaction.user.name} as present (code: {code})") + await interaction.response().send_message(content=f"Marked {interaction.user.name} as present (code: {code})") async def setup(bot): await bot.add_cog(MarkHere(bot)) diff --git a/cogs/__pycache__/MarkHere.cpython-312.pyc b/cogs/__pycache__/MarkHere.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b3e3da0b007c8d8a33ed84a178507a61caad063e GIT binary patch literal 3181 zcmb6bU2GJ`d1im^_I^nZgMVu1;u_Nk70XP1yd zM>{j$eDlqG|KIFi5{W2+b~15H8w(@!DP07OZvmTC0B$1{saQujIx{*hGC2k}acqtS z9IJChKF9kwPG^guT&T4l&V^y0*M*{x6EI@X%Sa7fM{1bxg;c<7XTHZa;apS|uA^LR z2}_ZGQdCyL9zUa-rUihYn8jk=P;G$Y`BF*VweHC{4l&fcp^ynvg_15uQ#L&>L9g zzLJ^`sGf3bn=YWIx6U%T2#HLhRkVr~b`~kjkHN85aTPD)o1rTHIde0@A%(q)ucBN8 zWQab=2_*6+I12XmH}%^+sv$M}zHpCe;?xL<7kYN@xA0LS%y4S#I(M(N8O*ul5~o1$ zG-bGqq_`)%lDFo+M=avSsUO?kYVq(dpkYD#<_rwCQ59E_>~GM)X$1{<35g0tyDjO2xaW>IstQcTy z%1{)yznz{lizKaUMtLzkX)5I+F&sNhqcNQ&mnY2%vC@h;Yo`OnM@mbcAj_JeIkH^& zS{ogpo};%9!mx}Qd-tz!H-*3T9>0HOt#@LDTNk<;LfjR)>q56H9Hl{Gc%#Z|H(2TM;Vg2m^+;+0X#OI$L`7F9#+D_O(_Y5PRe5S=+9=G)06+H-_B zBUED}UnE{kCAMN|B}X$2FQyp|vGR();1tkQY5N2up_aY|eRC2_^nHZEgH9`Vc6+bT z_88E%THukuuUFYB^A-LKGCDX4^8&UGS8+kug=&EX>aoX~ir;4fKG&eUT>%|_#oT6p z%JU%e5~{M#&bi8T!|n5v5H0_Y1ng5Jcyc7iA5#RDg*{)!sT|mX#?oW$Nj0F#&M-li zW5~B>wjuxQJl=+i z+<9>-d;SOF&O0IwmTh7Ui;h+#wv#WGB=Pmk+1FlrRUAA$ES{DsVVXt2ud-JJ_N^Qe z2WcLBN8HAe6t!qNcpSZIa#p%D}?T&k`>I1R${i^3x0p zlSQJG9pZ6R7OMlUlQ=5@lSQkv)k*J80BIrP?K{cG9jT(`!k`6`Z=@ zZ0tpeBK~4SK#3p16h)CT-nLJIncm3kn-uZGPCVJeJ?zRLSUl>&lfB%d?#v6Y_@Dz% z9^^jQ7ou=4rsF|6N_%L5r-f6Ny{Ie~O|`63I4;YV%6YxH!LCfLjIU1J zn7UP1iyvAS`W|#No*#R+?+?;@(*5lE^Ao!$jJ5dTbz$I79UDBG3@x7ld-jNK;^?i+ zouhZZ`?LI7;^^|32H&;(BbV>0^IdNZ)c8Z-{ZJ3Zbl3UrMxwLPab}BWL!pgmf-mED zPq;&;>O-eC5M4b`KKJL%7!YiRQK;{;=P3T)n|o36m`zoBI20Z~5qdbr!RYI$v>zVW zrMB}Qrt<(aA0{aAyD0!q+s-dW6$MX3FhA+3fI`Llg$ifE?%}+uLNd?#0!X;|w?MhH z(v!D|Q!ZI!fH?-;?uEh6#YQx-I&@>`mve8~zbLLn`(3X8L#}_@*X)ghgZK^};O>S7 zu@tvx5_?=R@67qN#`+dr$6C=aLKYp1mIOaqEcz?aj5T(mC6&+oJ!H}6!H;l*Z< zp0a4*Q)z7~+y;v={s_fBM*BZS$&Zl;-$y9&ca+%*_2V*LSzN8&sBR$&Z=MC(!~UKB E0o$Ire*gdg literal 0 HcmV?d00001 diff --git a/cogs/__pycache__/NoBlueBanners.cpython-312.pyc b/cogs/__pycache__/NoBlueBanners.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dec5c4d3df474229b97ff1be324a140c4c9d2a55 GIT binary patch literal 2187 zcmahKO-vM5_`UgO*SUfLUVIkcSmz1bZEwe`#H_rAaPz3+WL zZ+?!&A_$;oqsRP~N9cFPgdjGE+GQZ-5kUm!P>QWw4(GWPhZ)W1P@Yfm0gZD)O5o53 zhzL`Nh?L6)-b07=bs5fKkdlZrg;MeuR^>mLAgN+sG3D_u6i0ohBlY z9?HvHgHvcE+xDW>6^=Ab3Cj&dxTd0Gya83NpMM>x5kHo+6S*Qyq%Dg&uF-HC>|-=5 zZH+}Sk4m_N^gzMT7+S)~CG7K~wx@Ew;L_X`hc$=Gwsm%HpVqG0d8*}1t9U~jwvA$* zTAr&7*@S9IdVSa)r;cXW8CTmAL2qHqS9IO9Oi$Ox+xF^0IiwW1O@Jm*^+4OS@P$$X z2Kh>+Xy%A+lB6Fc^qL;Be$28*;g+NITNVW{-u&j?0~WZI&QrgcP}gwGf@j*6-)veQ zbrIcS9=aTB@~36MsQgF3C?Q{ zwb^J$JzGm0;U4lk)_e)StlzTxpX~^#68~_g!*9X6@F}#y*W5h+nJB`|$IvNsTYSV{ z$Euhdf4#R?GxO;TeW93EW_(g>7Yb?bsxM@1)AD6v zIze@yADD@*Gm&f94)KL^An+B3x&_;EsULNzMf5y%U5G*z9F`QP6_Br(`HarIItPJu zS?N0g9F{D<#jvC%R#EmY$jm3Y^BysHw|*5lgB#kIIr zjt^9nfyc_gw#!EC=Gk`>99|v32_&ozN=WS99@)6bbPm1YWUE{=H zv(pa9mon6&)-|7pRtAPmh3l}Z`*P#`b&fEudE{BEJ6#}wiQszse4Gi!xUVlrlf$0p z05QV-0nikB%3<-~W(2+2w=jC&Tr^iA8{LC5{Pf6lVs>O^tp&Wv9Krat2(#J)<;gU6o607@vF%W(a_ zd2!e@Lz?9@A@DKG)RW5|GnbvxXiU+iUa{b4AUVubtXe_SRU@(46Ei2i$}bF++j`0o zwIZmG1$8^P+RZn5@B;1@7Ns7nDvc61My8kE@EjI%&1$=rkh literal 0 HcmV?d00001 diff --git a/cogs/__pycache__/ScoringGuide.cpython-312.pyc b/cogs/__pycache__/ScoringGuide.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..eeb0ea32cb4fe8281c298fd6f5d6a3fafebc0b48 GIT binary patch literal 2205 zcmahKU1(ER_?-Kf+cZgacC{5ZV!@i|?5+BsYu&(9bY;*Ng-LK+V$Ny2A-OlZH?g)$ zp=cTHaI3;teQaT%rH=V9_BL4G2775uA>q2gU@zNSXZo=9Y2SB~+rPQ_k$mU-JLi1A z=l&E92MIuIEoOW#67m}|KFCd?ejSK8!U&@|lEhWW(Y%lpDAHn%BpDc^@xUJ_7>-j{Om3*sLoVsP_+vBMjAsOI- zi)5C}(zKW%Y2m{FArrJrC+R(BZgYk6pRBs#7 zSlK!{F-OX@Omt6yXpAh<;YI3-H>_e@a3zQ5Mr`a22e(C|o9DEfR-S7)!z|s_E?VhQ zo|{ES8?+eLhWY44Yn6Q7R&TyEt%cFy~C%<;*+f*xC>9m z$Ivcc3UlHovJ8=rk+bBU{IxhrEQI;xo|Ea(B*!bAky-H0(3l#bArJ`&_P|#Tdd?I zAf)Rbl~TEe#ty?~yAzPBWO$LAw_FiA7Z@BB$HrLQK=Vbm-$1T)XuGq$=wSea-aeOR zyvgqCYvctVY#R~&o$mlmkrx7$53UEvvA+2mkBkN5X>hG)U`CuCnoi6P%?#boRzgSC z)b1bJYsb$#?*1me7=Jpvc6@M$g;@z5T~m90Y+skfh%$L;T_uO2)$pnN7v@hbyz^a8HI%S7q^GXm`2_5CDr+QBL>9}OflW%;B8*b6G%{H}nGkgbuv3QS|9%LKnud!dN0 zr3Yo=kD+W;_>ceBm6trO}D5?C@f#VE2OLFsiUxUencr;n@>2 zC%(wdpQ&^ls{~_JDfUc?ZF-^Ic{4`mX^*s^#AsY?mbf`1z5I63MxVVtXtaqq=--bQ zEfO2QPF~43)$@%ed)GbY^sD)3{?yPPt-iW@$J4`2ss%YW zGdnvwGdnZ8e~U&#fQ-$YV*FkL;2)F-2H!yHh#*@40R+<^>3?Ao5}HXf88*p6%45?Y z!zDQnhiN{^GvHN#xLW}6m`z0jrH9{#tdA!}B-{c?X%)uBZzyXf4qZvlWinb8Sp<)0 z`Mes?`k?{(q!XP=Y0(dJ`0^f(LNs0d(29oE>Le&cf+=lb(@9m&x+} zsIr*LU?pv23oFWGPA_C|*0z+19Ky;BUY^Vqv8m{}1xq<-<9ml2u zc9ibYM8GN0Isx9Fw#gh(2N^Fcu5UNSFk+iiZ4wpV*RFU%*(Uau3nO< z%fxy^w`7u^vb~7qsp+5Hn3b>obXwL-Ea$SsdQ82`Y8mW?5w>*G$lFFP>xPZ2jZICb z#IdN+TKkCn>$@a3uLDRTXE(AGfYj0WQlrU0Fvy=Zro^N;pL-Opu?Vbj|f4m3z5={q$vA#p8K3L&XT zPS?Ylh_9iWBwnU~!X@^107X7!vpk9hIN>yZ${xtKlsJBV_yNiM-!NP3FL<7u;VSqMxWoUMU54>fGsVG0+s<2Kin6@CoFHj4mo8u} zoi?q$!P#dBe19BWvXi6I+R#xj>9b z41M47*5_rv%)EjqSfOIaz`!gihHJzv`D(+jfq|k#3#MR=$;Kv0=py&}b-8~Z#*4z_ z>u=0ZG*oPzw&cJ|xsZQZF0x}QW5rWr%ZrAMRh`zKn$gUgV{yTiO#FTUTeju03)ptU zez?f}fUwJ@EK+1d!pi5e7Iq^R<B~ElXR#@vuqH&upiGjo5RVdLS2~l&{ErhT?%W ze$3lE7KyuUWN;()Zj{O&s_^UxbVNu@52r$uYG@Z5##q!8`EZ!@f^Yn{(mJC$;$vJw8e>zu!#Ug<)|Hr$gyTi=FQlUkg%fwDBv>GZzk zy!DRL_HJ2vcSGD0K+EZBbFADPt2Qg;W@SgLG>=xL(OqeD-=bH|hZAQeF2FC(!HHh( zOIaXzJW4MG+Jsfr4XJ7-hYD$mM^yFwf|mAYOnNc6OwMuzl216hhyqu80gv&zZ;#PaigNbAZT@e$OO^T)5!fWJ5#VftvJz;sJWR)?W#@1-@k<-?bM4 z7lyXy{%G7cc0!N)etetVnBKT{cl!49W~vf7|5%EB-d-CR`7HLA_=EV)%;SNHBNEw4 zr1!DZ_o#i3XPbrfDH8qDay2@qPc7O}Yrp=4X9Zy|)Wcu?tg9LyFUQBf1%#-F3A-)=A@;9ULjI@T2AW^As0Uy4 ziq{n3i$RV+&n^Anjv})Mci1c;{5z}!=*#TsVbk}4H%cJrp27G3by1V{WD;mmLx>do zf*)Rbr(1Lv*)JmA!ge8VUM8F|I(3EsuVQMU=-rFAFMgcf9;tL*sD%2fT>mcDzwf#B z;n{w;4g0wJLO+Z*nImM8`78mX;rd6Oz$>;%4fj&QOwc)YA~1sP_5hglQRZ2(FOB=@ z_7n38;ZgIfO9bq(5W;!{z?QE-%in=aeqVv6C)`DNd2@7Y{NDHzK+yWzL@MrS`!A8& B*x3L8 literal 0 HcmV?d00001 From 8141c720d2f098d705bb4840127cb44b3cdd1ac6 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 4 Dec 2025 17:39:09 -0500 Subject: [PATCH 006/118] update gitignore to remove __pycache__ --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d45a4cd..d689f50 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .vscode/ .env db.sqlite +__pycache__/ \ No newline at end of file From 8e196c27d3ab1a6d05ad12ffc39e6207e8965baf Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 4 Dec 2025 18:07:49 -0500 Subject: [PATCH 007/118] fix markhere again --- cogs/MarkHere.py | 6 +++--- cogs/__pycache__/MarkHere.cpython-312.pyc | Bin 3181 -> 3214 bytes main.py | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/cogs/MarkHere.py b/cogs/MarkHere.py index 0cdd246..d1c9543 100644 --- a/cogs/MarkHere.py +++ b/cogs/MarkHere.py @@ -36,16 +36,16 @@ async def mark_here(self, interaction: discord.Interaction, code: int): timestamp = floor(time.time()) if code not in self.communicator.db_temp: - await interaction.response().send_message(content=f"Code does not exist! Was it typed correctly?") + await interaction.response.send_message(content=f"Code does not exist! Was it typed correctly?") return # if code is no longer valid if timestamp > self.communicator.db_temp[code]: - await interaction.response().send_message(content=f"Code is no longer valid!") + await interaction.response.send_message(content=f"Code is no longer valid!") return # so commit the record to memory self.communicator.db_connection.execute("INSERT INTO Attendance (user, timestamp) VALUES (?, ?)", (interaction.user.name, timestamp)) - await interaction.response().send_message(content=f"Marked {interaction.user.name} as present (code: {code})") + await interaction.response.send_message(content=f"Marked {interaction.user.name} as present (code: {code})") async def setup(bot): await bot.add_cog(MarkHere(bot)) diff --git a/cogs/__pycache__/MarkHere.cpython-312.pyc b/cogs/__pycache__/MarkHere.cpython-312.pyc index b3e3da0b007c8d8a33ed84a178507a61caad063e..e80aba0e2b6f8a3d971c34fe2508777f15815f25 100644 GIT binary patch delta 363 zcmaDW(I?4!nwOW00SKabjWR7a@}{vc#!N0@DPq;EVa(#3%*ZS?*^y0jvJh(+n#dH^ zS&T6GT-HI+=>>r4SKEum=W!E{%E^?H85N6=bXZ-PjgMl|1NO3W6mNWh;wgCF9$x+fp zhw-2^ql+5LK^aFDAm@+*ql*U1Aw@@fAm<2>qs4MWkqyMw;A8|*T0nAhHm5tIdO70? z^XuAn7q#s^Fz~XLGag9(1g1Z8GqbUMW@lhi`X~St-+Ys+no+Wxv7@5DrnBY?g9>9g P<7Xd6Mn=^l0iYWIr`&N& delta 375 zcmeB^d@I3wnwOW00SGL3jWUfl@}{vcx=b!%DH2evVa(zHNkGBmf7k_OCNE)~#e=3& zW^x`|1nVJlhMwllr`e(z8D%G{bC`=>7c#ymWPHIc>w=K+WuctwJUJJ5aweB?sKRB! zz%minc_J?GL`>ey5mKu-dx5XA2=9zBY_kb17|7YuVM?JPnsM>T@)A(iZi-M zu^g0eWB_swNie#|upE+fv<7mHh%mayu^bU)1F>ZoT@=_DkH`V3%{`p%jOwL~E6lHJ z+g;SQ`@q1 Date: Tue, 9 Dec 2025 17:19:57 -0500 Subject: [PATCH 008/118] bugfixes --- AttendanceCodeCommunicator.py | 109 ++++++++++++++++++++-------------- cogs/MarkHere.py | 4 +- 2 files changed, 65 insertions(+), 48 deletions(-) diff --git a/AttendanceCodeCommunicator.py b/AttendanceCodeCommunicator.py index 290a9e9..18fcb74 100644 --- a/AttendanceCodeCommunicator.py +++ b/AttendanceCodeCommunicator.py @@ -2,6 +2,7 @@ import json import socket import sqlite3 +import sys import threading from enum import Enum @@ -18,56 +19,16 @@ class AttendanceCodeCommunicator: db_connection = None db_temp = {} status: Status = Status.DISCONNECTED + thread: threading.Thread = None def __init__(self, db_path): db_connection = sqlite3.connect(db_path) db_connection.execute("CREATE TABLE IF NOT EXISTS Attendance ( user VARCHAR(255), timestamp INTEGER );") - def run(self): - # Specs: - # - # A screen sends out a 255.255.255.255 broadcast app: attendance, type: discovery, version: 1 (as of now), host matching local IP - # Server connects to host given in the parameter - # - # On connect, screen sends JSON object with type = "connect" - # Server sends type = "acknowledge", targeting = "connect" - # On every code generation event (screen): type = "code", code = , generation_time = - # Server acknowledges with type = "acknowledge", targeting = "code", code = , valid_to = - # Every 10s, screen sends type = "heartbeat", counter = - # Server sends type = "acknowledge", targeting = "heartbeat", counter = - # - # Errors: - # If the heartbeat counters do not match, server sends type = "heartbeat_error", counter = - # If connection fails, disconnect and try again after 10s - # - # Note that the "server" here is the discord bot, but the server is technically the tablet - def _communicate(): - """ - Attempt to communicate with the remote screen. - Handles both broadcasts and regular communication. - Intended to be run as a thread - :return: None - """ - logging.log(logging.INFO, f"Starting attendance communicator") - broadcast_in_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - broadcast_in_sock.connect(('0.0.0.0', 5789)) - logging.log(logging.INFO, "Now listening on 0.0.0.0 port 5789") - host = self._discover(broadcast_in_sock) - logging.log(logging.INFO, "Found an endpoint") - self.status = Status.CONNECTING - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.connect((host, 5789)) - - if self._handshake(sock): - self.status = Status.CONNECTED - self._communicate_after_handshake(sock) - - thread = threading.Thread(target=_communicate, daemon=True) - thread.start() - def _discover(self, sock: socket.socket): const_version = 1 + logging.log(logging.DEBUG, "Discovering available endpoints") while True: try: @@ -75,6 +36,7 @@ def _discover(self, sock: socket.socket): except ValueError: continue if message['app'] == 'attendance' and message['type'] == 'discovery' and message['version'] == const_version: + logging.log(logging.DEBUG, "Found an available endpoint") return message['host'] def _handshake(self, sock: socket.socket): @@ -82,13 +44,15 @@ def _handshake(self, sock: socket.socket): connect_message = json.loads(sock.recv(1024)) if connect_message['type'] != 'connect': logging.log(logging.ERROR, f"Failed to connect with endpoint, wrong type {connect_message['type']}") - return False # let endpoint try again after 10s + return False # let endpoint try again response = { 'type': 'acknowledge', 'targeting': connect_message['type'] } - sock.sendall(bytes(json.dumps(response), 'utf-8')) + val1_tmp = json.dumps(response) + val2_tmp = val1_tmp.encode() + sock.send(val2_tmp) return True def _communicate_after_handshake(self, sock: socket.socket): @@ -99,7 +63,13 @@ def _communicate_after_handshake(self, sock: socket.socket): counter = 0 while True: - message = json.loads(sock.recv(1024)) + + data = sock.recv(1024) + if len(data) == 0: + logging.log(logging.WARN, "Remote endpoint disconnected") + return + + message = json.loads(data) if message['type'] == 'heartbeat': counter += 1 @@ -134,4 +104,51 @@ def _communicate_after_handshake(self, sock: socket.socket): self.db_temp[response['code']] = generation_time + const_code_valid_duration else: - logging.log(logging.WARN, f"Unknown message {message['type']}, ignoring") \ No newline at end of file + logging.log(logging.WARN, f"Unknown message {message['type']}, ignoring") + + def _communicate(self): + """ + Attempt to communicate with the remote screen. + Handles both broadcasts and regular communication. + Intended to be run as a thread + :return: None + """ + while True: + logging.log(logging.INFO, f"Communicator thread started") + broadcast_in_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + broadcast_in_sock.bind(('0.0.0.0', 5789)) + logging.log(logging.INFO, "Now listening on 0.0.0.0 port 5789") + host = self._discover(broadcast_in_sock) + logging.log(logging.INFO, "Found an endpoint") + self.status = Status.CONNECTING + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect((host, 5789)) + + if self._handshake(sock): + self.status = Status.CONNECTED + self._communicate_after_handshake(sock) + + + def run(self): + # Specs: + # + # A screen sends out a 255.255.255.255 broadcast app: attendance, type: discovery, version: 1 (as of now), host matching local IP + # Server connects to host given in the parameter + # + # On connect, screen sends JSON object with type = "connect" + # Server sends type = "acknowledge", targeting = "connect" + # On every code generation event (screen): type = "code", code = , generation_time = + # Server acknowledges with type = "acknowledge", targeting = "code", code = , valid_to = + # Every 10s, screen sends type = "heartbeat", counter = + # Server sends type = "acknowledge", targeting = "heartbeat", counter = + # + # Errors: + # If the heartbeat counters do not match, server sends type = "heartbeat_error", counter = + # If connection fails, disconnect and try again after 10s + # + # Note that the "server" here is the discord bot, but the server is technically the tablet + + logging.basicConfig(level=logging.DEBUG, stream=sys.stdout) + logging.log(logging.INFO, f"Starting attendance communicator") + self.thread = threading.Thread(target=self._communicate, daemon=True) + self.thread.start() diff --git a/cogs/MarkHere.py b/cogs/MarkHere.py index d1c9543..80b0bde 100644 --- a/cogs/MarkHere.py +++ b/cogs/MarkHere.py @@ -15,8 +15,8 @@ class MarkHere(commands.Cog): def __init__(self, bot): self.bot = bot - self.communicator = AttendanceCodeCommunicator("db.sqlite") - self.communicator.run() # starts background threa + communicator = AttendanceCodeCommunicator("db.sqlite") + communicator.run() # starts background thread # Guild syncing guilds = [ From 95c3dc66366c8788044cd2160a62b75da7e00a12 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Tue, 9 Dec 2025 17:21:00 -0500 Subject: [PATCH 009/118] ignore ALL pycaches --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index d689f50..f331edd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ .vscode/ .env db.sqlite -__pycache__/ \ No newline at end of file +pycache +**/__pycache__/ \ No newline at end of file From 9ebcd02b51dddcd9768ec82672645a56d4c3d2db Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Tue, 9 Dec 2025 18:41:30 -0500 Subject: [PATCH 010/118] fix heartbeat logic --- AttendanceCodeCommunicator.py | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/AttendanceCodeCommunicator.py b/AttendanceCodeCommunicator.py index 18fcb74..4ab4143 100644 --- a/AttendanceCodeCommunicator.py +++ b/AttendanceCodeCommunicator.py @@ -72,24 +72,22 @@ def _communicate_after_handshake(self, sock: socket.socket): message = json.loads(data) if message['type'] == 'heartbeat': + response = { + 'type': 'acknowledge', + 'targeting': message['type'], + 'counter': counter + } counter += 1 - if counter != message['counter']: - response = { - 'type': 'heartbeat_error', - 'counter': counter - } - sock.sendall(bytes(json.dumps(response), 'utf-8')) - - logging.log(logging.WARN, - f"Connection error with attendance code endpoint: got counter {message['counter']}, expected {counter}") - - else: - response = { - 'type': 'acknowledge', - 'targeting': message['type'], - 'counter': counter - } - sock.sendall(bytes(json.dumps(response), 'utf-8')) + sock.sendall(bytes(json.dumps(response), 'utf-8')) + + elif message['type'] == 'heartbeat_error': + response = { + 'type': 'acknowledge', + 'targeting': message['type'] + } + sock.sendall(bytes(json.dumps(response), 'utf-8')) + logging.log(logging.WARN, f"Logged heartbeat error: expected {counter}, told {message['counter']}") + counter = message['counter'] elif message['type'] == 'code': code = int(message['code']) From 20faf8b17833f0ef39f117161a0d104d6643b4e7 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Wed, 10 Dec 2025 22:20:15 -0500 Subject: [PATCH 011/118] various tweaks to get communication working --- .../AttendanceCodeCommunicator.cpython-312.pyc | Bin 6189 -> 0 bytes cogs/__pycache__/MarkHere.cpython-312.pyc | Bin 3214 -> 0 bytes cogs/__pycache__/NoBlueBanners.cpython-312.pyc | Bin 2187 -> 0 bytes cogs/__pycache__/ScoringGuide.cpython-312.pyc | Bin 2205 -> 0 bytes cogs/__pycache__/ServerStatus.cpython-312.pyc | Bin 2978 -> 0 bytes 5 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 __pycache__/AttendanceCodeCommunicator.cpython-312.pyc delete mode 100644 cogs/__pycache__/MarkHere.cpython-312.pyc delete mode 100644 cogs/__pycache__/NoBlueBanners.cpython-312.pyc delete mode 100644 cogs/__pycache__/ScoringGuide.cpython-312.pyc delete mode 100644 cogs/__pycache__/ServerStatus.cpython-312.pyc diff --git a/__pycache__/AttendanceCodeCommunicator.cpython-312.pyc b/__pycache__/AttendanceCodeCommunicator.cpython-312.pyc deleted file mode 100644 index bb1f585602949c4b09625e2049900728e1baef2a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6189 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 diff --git a/cogs/__pycache__/ScoringGuide.cpython-312.pyc b/cogs/__pycache__/ScoringGuide.cpython-312.pyc deleted file mode 100644 index eeb0ea32cb4fe8281c298fd6f5d6a3fafebc0b48..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2205 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 B*x3L8 From 250f78e31ce6b8f1877777dbb8948ad538e6d879 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Wed, 10 Dec 2025 22:24:33 -0500 Subject: [PATCH 012/118] create db if it doesn't exist --- AttendanceCodeCommunicator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/AttendanceCodeCommunicator.py b/AttendanceCodeCommunicator.py index 4ab4143..7b39da5 100644 --- a/AttendanceCodeCommunicator.py +++ b/AttendanceCodeCommunicator.py @@ -1,5 +1,6 @@ import logging import json +import os import socket import sqlite3 import sys @@ -22,6 +23,8 @@ class AttendanceCodeCommunicator: thread: threading.Thread = None def __init__(self, db_path): + if not os.path.isfile(db_path): + open(db_path, 'w').close() db_connection = sqlite3.connect(db_path) db_connection.execute("CREATE TABLE IF NOT EXISTS Attendance ( user VARCHAR(255), timestamp INTEGER );") From 6212e9710a57b65a60b99fb47494fbfde25660ff Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Wed, 10 Dec 2025 23:48:47 -0500 Subject: [PATCH 013/118] add missing 'self.'s and make it so claimed codes can't be reused --- AttendanceCodeCommunicator.py | 9 +++++---- cogs/MarkHere.py | 14 ++++++++++---- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/AttendanceCodeCommunicator.py b/AttendanceCodeCommunicator.py index 7b39da5..cf91d71 100644 --- a/AttendanceCodeCommunicator.py +++ b/AttendanceCodeCommunicator.py @@ -18,15 +18,16 @@ class Status(Enum): class AttendanceCodeCommunicator: db_connection = None - db_temp = {} + received_codes = {} + claimed_codes = [] status: Status = Status.DISCONNECTED thread: threading.Thread = None def __init__(self, db_path): if not os.path.isfile(db_path): open(db_path, 'w').close() - db_connection = sqlite3.connect(db_path) - db_connection.execute("CREATE TABLE IF NOT EXISTS Attendance ( user VARCHAR(255), timestamp INTEGER );") + self.db_connection = sqlite3.connect(db_path) + self.db_connection.execute("CREATE TABLE IF NOT EXISTS Attendance ( user VARCHAR(255), timestamp INTEGER );") def _discover(self, sock: socket.socket): @@ -102,7 +103,7 @@ def _communicate_after_handshake(self, sock: socket.socket): 'valid_to': generation_time + const_code_show_duration } sock.sendall(bytes(json.dumps(response), 'utf-8')) - self.db_temp[response['code']] = generation_time + const_code_valid_duration + self.received_codes[response['code']] = generation_time + const_code_valid_duration else: logging.log(logging.WARN, f"Unknown message {message['type']}, ignoring") diff --git a/cogs/MarkHere.py b/cogs/MarkHere.py index 80b0bde..5a18257 100644 --- a/cogs/MarkHere.py +++ b/cogs/MarkHere.py @@ -15,8 +15,8 @@ class MarkHere(commands.Cog): def __init__(self, bot): self.bot = bot - communicator = AttendanceCodeCommunicator("db.sqlite") - communicator.run() # starts background thread + self.communicator = AttendanceCodeCommunicator("db.sqlite") + self.communicator.run() # starts background thread # Guild syncing guilds = [ @@ -35,16 +35,22 @@ def __init__(self, bot): async def mark_here(self, interaction: discord.Interaction, code: int): timestamp = floor(time.time()) - if code not in self.communicator.db_temp: + if code not in self.communicator.received_codes: await interaction.response.send_message(content=f"Code does not exist! Was it typed correctly?") return + # if someone is reusing a code + if code in self.communicator.claimed_codes: + await interaction.response.send_message(content=f"Code already claimed!") + return # if code is no longer valid - if timestamp > self.communicator.db_temp[code]: + if timestamp > self.communicator.received_codes[code]: await interaction.response.send_message(content=f"Code is no longer valid!") return # so commit the record to memory self.communicator.db_connection.execute("INSERT INTO Attendance (user, timestamp) VALUES (?, ?)", (interaction.user.name, timestamp)) + # TODO: clean up codes periodically to get rid of long-expired ones + self.communicator.claimed_codes.append(code) await interaction.response.send_message(content=f"Marked {interaction.user.name} as present (code: {code})") async def setup(bot): From 928c0af97ef1bc7391b15e602c1a7220ecf01f45 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Wed, 10 Dec 2025 23:48:58 -0500 Subject: [PATCH 014/118] get rid of sqlite-journal --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f331edd..8d8e2d5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ .vscode/ .env db.sqlite +db.sqlite-journal pycache **/__pycache__/ \ No newline at end of file From ea7f4c0ca2717ddf6441710ada344b6d88d879b3 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 11 Dec 2025 23:14:11 -0500 Subject: [PATCH 015/118] commit db changes --- cogs/MarkHere.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cogs/MarkHere.py b/cogs/MarkHere.py index 5a18257..46a07d1 100644 --- a/cogs/MarkHere.py +++ b/cogs/MarkHere.py @@ -49,6 +49,7 @@ async def mark_here(self, interaction: discord.Interaction, code: int): # so commit the record to memory self.communicator.db_connection.execute("INSERT INTO Attendance (user, timestamp) VALUES (?, ?)", (interaction.user.name, timestamp)) + self.communicator.db_connection.commit() # TODO: clean up codes periodically to get rid of long-expired ones self.communicator.claimed_codes.append(code) await interaction.response.send_message(content=f"Marked {interaction.user.name} as present (code: {code})") From 1e64e70d8f6d017862c1d5fd259f359e4a63eec9 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 11 Dec 2025 23:50:33 -0500 Subject: [PATCH 016/118] bind to any address --- AttendanceCodeCommunicator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AttendanceCodeCommunicator.py b/AttendanceCodeCommunicator.py index cf91d71..22beb9e 100644 --- a/AttendanceCodeCommunicator.py +++ b/AttendanceCodeCommunicator.py @@ -118,8 +118,8 @@ def _communicate(self): while True: logging.log(logging.INFO, f"Communicator thread started") broadcast_in_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - broadcast_in_sock.bind(('0.0.0.0', 5789)) - logging.log(logging.INFO, "Now listening on 0.0.0.0 port 5789") + broadcast_in_sock.bind(('', 5789)) + logging.log(logging.INFO, "Now listening on all interfaces, port 5789") host = self._discover(broadcast_in_sock) logging.log(logging.INFO, "Found an endpoint") self.status = Status.CONNECTING From e4eb03d61177b67ff2fa8c5bdefb8cca08a7abad Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Mon, 15 Dec 2025 23:25:27 -0500 Subject: [PATCH 017/118] make code communicator error-tolerant (can be disabled if need be) - defaults to logging exceptions --- AttendanceCodeCommunicator.py | 38 +++++++++++++++++++++-------------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/AttendanceCodeCommunicator.py b/AttendanceCodeCommunicator.py index 22beb9e..06d5af4 100644 --- a/AttendanceCodeCommunicator.py +++ b/AttendanceCodeCommunicator.py @@ -108,7 +108,7 @@ def _communicate_after_handshake(self, sock: socket.socket): else: logging.log(logging.WARN, f"Unknown message {message['type']}, ignoring") - def _communicate(self): + def _communicate(self, should_fail_on_exception: bool): """ Attempt to communicate with the remote screen. Handles both broadcasts and regular communication. @@ -116,19 +116,27 @@ def _communicate(self): :return: None """ while True: - logging.log(logging.INFO, f"Communicator thread started") - broadcast_in_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - broadcast_in_sock.bind(('', 5789)) - logging.log(logging.INFO, "Now listening on all interfaces, port 5789") - host = self._discover(broadcast_in_sock) - logging.log(logging.INFO, "Found an endpoint") - self.status = Status.CONNECTING - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.connect((host, 5789)) - - if self._handshake(sock): - self.status = Status.CONNECTED - self._communicate_after_handshake(sock) + try: + logging.log(logging.INFO, f"Communicator thread started") + broadcast_in_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + broadcast_in_sock.bind(('', 5789)) + logging.log(logging.INFO, "Now listening on all interfaces, port 5789") + host = self._discover(broadcast_in_sock) + logging.log(logging.INFO, "Found an endpoint") + self.status = Status.CONNECTING + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect((host, 5789)) + + if self._handshake(sock): + self.status = Status.CONNECTED + self._communicate_after_handshake(sock) + except socket.error as e: + if should_fail_on_exception: + logging.log(logging.ERROR, f"Exception raised during communication!") + raise e # terminates thread + else: + logging.log(logging.ERROR, f"Exception raised during communication - {e} - continuing anyways!") + # loop again def run(self): @@ -152,5 +160,5 @@ def run(self): logging.basicConfig(level=logging.DEBUG, stream=sys.stdout) logging.log(logging.INFO, f"Starting attendance communicator") - self.thread = threading.Thread(target=self._communicate, daemon=True) + self.thread = threading.Thread(target=lambda: self._communicate(False), daemon=True) self.thread.start() From d786ad88c6c9124f3af297150b8380cdbfe78eeb Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Tue, 16 Dec 2025 00:15:15 -0500 Subject: [PATCH 018/118] reject loopback addresses --- AttendanceCodeCommunicator.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/AttendanceCodeCommunicator.py b/AttendanceCodeCommunicator.py index 06d5af4..4563c64 100644 --- a/AttendanceCodeCommunicator.py +++ b/AttendanceCodeCommunicator.py @@ -1,3 +1,4 @@ +import ipaddress import logging import json import os @@ -40,7 +41,13 @@ def _discover(self, sock: socket.socket): except ValueError: continue if message['app'] == 'attendance' and message['type'] == 'discovery' and message['version'] == const_version: + logging.log(logging.DEBUG, "Found an available endpoint") + if ipaddress.ip_address(message['host']).is_loopback: + logging.log(logging.WARNING, "Received valid broadcast, but the host is a loopback address" + f" ({message['host']}). Check the network configuration on the client.") + continue + return message['host'] def _handshake(self, sock: socket.socket): From 5cca8a3006a81a206933cf2f3884b72ee8fc7880 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Tue, 16 Dec 2025 16:16:27 -0500 Subject: [PATCH 019/118] print host for diagnostics --- AttendanceCodeCommunicator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AttendanceCodeCommunicator.py b/AttendanceCodeCommunicator.py index 4563c64..04c12fa 100644 --- a/AttendanceCodeCommunicator.py +++ b/AttendanceCodeCommunicator.py @@ -129,7 +129,7 @@ def _communicate(self, should_fail_on_exception: bool): broadcast_in_sock.bind(('', 5789)) logging.log(logging.INFO, "Now listening on all interfaces, port 5789") host = self._discover(broadcast_in_sock) - logging.log(logging.INFO, "Found an endpoint") + logging.log(logging.INFO, f"Found an endpoint at {host}") self.status = Status.CONNECTING sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect((host, 5789)) From 2c241275e5e9bb25ab94321834880b47639ea5ae Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Wed, 17 Dec 2025 14:26:53 -0500 Subject: [PATCH 020/118] add command-line option to disable attendance features --- main.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/main.py b/main.py index 5efbb00..c705104 100644 --- a/main.py +++ b/main.py @@ -1,3 +1,5 @@ +import sys + import discord from discord.ext import commands from dotenv import load_dotenv @@ -23,15 +25,28 @@ async def on_ready(): synced = await bot.tree.sync(guild=guild) print(f"Cleared and synced {len(synced)} commands to {guild.id}") -async def load_extensions(): +async def load_extensions(disable_attendance_features=False): await bot.load_extension("cogs.ScoringGuide") await bot.load_extension("cogs.NoBlueBanners") await bot.load_extension("cogs.ServerStatus") - await bot.load_extension("cogs.MarkHere") - await bot.load_extension("cogs.Example") + if not disable_attendance_features: + await bot.load_extension("cogs.MarkHere") print("Extensions all loaded") if __name__ == "__main__": + # parse command line + disable_attendance_features = False + for arg in sys.argv: + match arg: + case "--disable-attendance": + if not disable_attendance_features: + disable_attendance_features = True + else: + print(f"Duplicate argument {arg}") + os._exit(2) + case _: # default + print(f"Unrecognized argument {arg}") + import asyncio asyncio.run(load_extensions()) token = os.getenv("token") From 6326b92cb6a0184e8f74a28cc5951f60bce6ce0b Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Wed, 17 Dec 2025 20:10:48 -0500 Subject: [PATCH 021/118] add command-line option to disable attendance features --- main.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/main.py b/main.py index 5efbb00..c705104 100644 --- a/main.py +++ b/main.py @@ -1,3 +1,5 @@ +import sys + import discord from discord.ext import commands from dotenv import load_dotenv @@ -23,15 +25,28 @@ async def on_ready(): synced = await bot.tree.sync(guild=guild) print(f"Cleared and synced {len(synced)} commands to {guild.id}") -async def load_extensions(): +async def load_extensions(disable_attendance_features=False): await bot.load_extension("cogs.ScoringGuide") await bot.load_extension("cogs.NoBlueBanners") await bot.load_extension("cogs.ServerStatus") - await bot.load_extension("cogs.MarkHere") - await bot.load_extension("cogs.Example") + if not disable_attendance_features: + await bot.load_extension("cogs.MarkHere") print("Extensions all loaded") if __name__ == "__main__": + # parse command line + disable_attendance_features = False + for arg in sys.argv: + match arg: + case "--disable-attendance": + if not disable_attendance_features: + disable_attendance_features = True + else: + print(f"Duplicate argument {arg}") + os._exit(2) + case _: # default + print(f"Unrecognized argument {arg}") + import asyncio asyncio.run(load_extensions()) token = os.getenv("token") From a9a058dc5f6c424abaa61b7b0fa3a5281314892f Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Wed, 17 Dec 2025 20:12:21 -0500 Subject: [PATCH 022/118] remove possibly conflicting server status command --- cogs/ServerStatus.py | 59 -------------------------------------------- 1 file changed, 59 deletions(-) delete mode 100644 cogs/ServerStatus.py diff --git a/cogs/ServerStatus.py b/cogs/ServerStatus.py deleted file mode 100644 index a83549c..0000000 --- a/cogs/ServerStatus.py +++ /dev/null @@ -1,59 +0,0 @@ -# This file is meant to be an example of how to create commands and is not loaded in main.py -# After making your command file add a line to load it in main.py -# The line will look like this in the load_extensions function: - # await self.load_extension("cogs.Example") - -import discord -import requests -from discord.ext import commands -from discord import app_commands -import os - -class ServerStatus(commands.Cog): - def __init__(self, bot): - self.bot = bot - - # Guild syncing - guilds = [ - int(os.getenv("guild_id")) if os.getenv("guild_id") else None, - int(os.getenv("dev_guild_id")) if os.getenv("dev_guild_id") else None, - ] - guilds = [g for g in guilds if g is not None] # remove missing ones - @app_commands.guilds(*guilds) - - # name must be lowercase and can only contain certain special characters like hyphens and underscores - @app_commands.command( - name="status", - description="Check if TBA, Statbotics and the FIRST API are online" - ) - - async def execute(self, interaction: discord.Interaction): - - # get status - tba_response = requests.get('https://www.thebluealliance.com/api/v3/status') - tba_status = tba_response.status_code - tba_datafeed_down = None - tba_downtime_events = None - if tba_status == 200: - tba_datafeed_down = tba_response.json()['is_datafeed_down'] - tba_downtime_events = tba_response.json()['down_events'] - - stat_response = requests.get('https://www.statbotics.io/event/api?rand=${dayjs().unix()}') - stat_status = stat_response.status_code - - frc_response = requests.get('https://frc-api.firstinspires.org/v3.0?rand=${dayjs().unix()}') - frc_status = frc_response.status_code - - # format everything - - message = "# Server Status\n" - message += "**The Blue Alliance**" - message += f"Status: {f"OK ({tba_status})" if tba_status < 400 else f"DOWN ({tba_status})"}\n" - message += f"Is datafeed up? {":x:" if tba_datafeed_down else ":white_check_mark:"}\n" - - # send it - await interaction.response.send_message(message) - - -async def setup(bot): - await bot.add_cog(ServerStatus(bot)) From 7ad46088cfbb2b451135c683f94f705f2e079b02 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Wed, 17 Dec 2025 20:19:11 -0500 Subject: [PATCH 023/118] improve README.md for more relevant instructions, troubleshooting and reflect some architectural changes --- README.md | 89 ++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 72 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index bae3e15..9ca91cb 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,77 @@ -### This is a forked version of the original at https://github.com/Graham277/NewDozer. Original readme below +### This is a forked version of the original at https://github.com/Graham277/NewDozer. ## About A python rewrite of the original dozer-discord-bot. Much of this project is powered by the blue alliance at https://www.thebluealliance.com/ -## Setup -1. To run the bot, create a file called ".env" in the project root. -2. Add your token to the file in this form: - ``` - token=YourTokenHere - ``` -3. Add the id of the guild the bot will be run on to the dotenv. - This provides instant syncing to the guild and is required for this bot. - ``` - guild_id=YourGuildIdHere - ``` -4. (Optional) If doing development, you can add a "dev_guild_id" as well, and the commands will sync to both guilds. - This allows you to develop and test the status on your own server without clogging up the production server. -5. Run main.py - -Requires the discord.py and dotenv libraries +## Setting up +### Dependencies +The project requires multiple dependencies, including: +* `discord` +* `dotenv` +* `keyring` +* various Google APIs + +The package also may require a virtualenv environment to function. +First `cd` to the project directory, then set up like so: +```shell +virtualenv .venv +source .venv/activate # on Linux +``` + +Certain IDEs (i.e. PyCharm) have these functions condensed into a menu. + +To install dependencies, run: +```shell +pip install -r requirements.txt +``` + +… which will install all pip packages in requirements.txt. + +If `pip` has the same problem with system packages, try: +```shell +./.venv/bin/python3 ./.venv/bin/pip install -r requirements.txt +``` +### Discord tokens + +The bot requires Discord tokens for a bot, which need to be placed into a file +named `.env`. + +Add your token to the file in this form: +```dotenv +token=YourTokenHere +``` +Add the id of the guild the bot will be run on to the dotenv. +This provides instant syncing to the guild and is required for this bot. +```dotenv +guild_id=YourGuildIdHere +``` + +If doing development, you can add a "dev_guild_id" as well, and the commands +will sync to both guilds. This allows you to develop and test the status on +your own server without clogging up the production server. + +### Setting up the attendance scripts +1. Install a keyring manager. On a desktop system, this should already exist; + it may need to be added for a server. +2. Configure and OAuth2 client (in Google Cloud) that has access to the `files` + scope for Google Sheets (can read and write files used with this + application). +3. Set up the credentials. TODO: specify which credentials are needed. +4. Install the client on a device on the same network. They should discover + each other. + +## Running +The bot takes the following command-line parameter(s): +* *`--disable-attendance`*: disable all attendance features. The server will + not be started and the OAuth tokens will not be used/verified. + +Launch `main.py` with `python3` to run the app. + +## Troubleshooting +If the bot throws an error about a locked keyring, exit it and unlock the +keyring before trying again. + +If the bot produces "unauthorized" or "forbidden" errors, check the tokens. +Specifically, that they aren't expired and that the correct scopes are +selected. \ No newline at end of file From 697174d71c1f684a8ecb07212bf9a5bf399eee02 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Wed, 17 Dec 2025 20:21:05 -0500 Subject: [PATCH 024/118] ignore .venv --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 8d8e2d5..59f13ff 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ db.sqlite db.sqlite-journal pycache -**/__pycache__/ \ No newline at end of file +**/__pycache__/ +.venv/ \ No newline at end of file From 3d148e240fe23946b220e5cc86c2bfa860f41f11 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Wed, 17 Dec 2025 20:33:34 -0500 Subject: [PATCH 025/118] ignore client_secret.json --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 59f13ff..d4c8bc4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ db.sqlite db.sqlite-journal pycache **/__pycache__/ -.venv/ \ No newline at end of file +.venv/ +client_secret.json \ No newline at end of file From b72536624d8d252bf6da105dcf49203d91be53fa Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 18 Dec 2025 11:26:03 -0500 Subject: [PATCH 026/118] change client_secret to secrets (reflects what will be used for auth) --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index d4c8bc4..ca05bba 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,4 @@ db.sqlite-journal pycache **/__pycache__/ .venv/ -client_secret.json \ No newline at end of file +secrets.json \ No newline at end of file From c61996315c2d43e2974341c7af62856d7c5315c0 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 18 Dec 2025 17:54:11 -0500 Subject: [PATCH 027/118] bug: don't include argv[0] --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.py b/main.py index c705104..d8f389d 100644 --- a/main.py +++ b/main.py @@ -36,7 +36,7 @@ async def load_extensions(disable_attendance_features=False): if __name__ == "__main__": # parse command line disable_attendance_features = False - for arg in sys.argv: + for arg in sys.argv[1:]: match arg: case "--disable-attendance": if not disable_attendance_features: From 9da9e266f1a3c0709d33cc2d7c590474b41012de Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 18 Dec 2025 17:55:06 -0500 Subject: [PATCH 028/118] add requirements.txt --- requirements.txt | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6e97c24 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,45 @@ +aiohappyeyeballs==2.6.1 +aiohttp==3.13.2 +aiosignal==1.4.0 +attrs==25.4.0 +cachetools==6.2.4 +certifi==2025.11.12 +cffi==2.0.0 +charset-normalizer==3.4.4 +cryptography==46.0.3 +discord==2.3.2 +discord.py==2.6.4 +dotenv==0.9.9 +frozenlist==1.8.0 +google-api-core==2.28.1 +google-api-python-client==2.187.0 +google-auth==2.41.1 +google-auth-httplib2==0.3.0 +google-auth-oauthlib==1.2.3 +googleapis-common-protos==1.72.0 +httplib2==0.31.0 +idna==3.11 +jaraco.classes==3.4.0 +jaraco.context==6.0.1 +jaraco.functools==4.3.0 +jeepney==0.9.0 +keyring==25.7.0 +more-itertools==10.8.0 +multidict==6.7.0 +oauthlib==3.3.1 +propcache==0.4.1 +proto-plus==1.27.0 +protobuf==6.33.2 +pyasn1==0.6.1 +pyasn1_modules==0.4.2 +pycparser==2.23 +pyparsing==3.2.5 +python-dotenv==1.2.1 +requests==2.32.5 +requests-oauthlib==2.0.0 +rsa==4.9.1 +SecretStorage==3.5.0 +typing_extensions==4.15.0 +uritemplate==4.2.0 +urllib3==2.6.2 +yarl==1.22.0 From b6226606fa809445b37b30710e65ecf12f8ed557 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 18 Dec 2025 17:56:22 -0500 Subject: [PATCH 029/118] remove weird italics --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9ca91cb..96fd08e 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ your own server without clogging up the production server. ## Running The bot takes the following command-line parameter(s): -* *`--disable-attendance`*: disable all attendance features. The server will +* `--disable-attendance`: disable all attendance features. The server will not be started and the OAuth tokens will not be used/verified. Launch `main.py` with `python3` to run the app. From d4ecbca1475bd03e6c1f593e42a41e4c5e777979 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 18 Dec 2025 18:11:38 -0500 Subject: [PATCH 030/118] remove stale codes from dicts --- AttendanceCodeCommunicator.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/AttendanceCodeCommunicator.py b/AttendanceCodeCommunicator.py index 04c12fa..ea880a6 100644 --- a/AttendanceCodeCommunicator.py +++ b/AttendanceCodeCommunicator.py @@ -112,6 +112,14 @@ def _communicate_after_handshake(self, sock: socket.socket): sock.sendall(bytes(json.dumps(response), 'utf-8')) self.received_codes[response['code']] = generation_time + const_code_valid_duration + # check for expired codes + import datetime + for (code_to_check, timestamp) in self.received_codes: + if datetime.datetime.now(datetime.UTC) > timestamp: + self.received_codes.pop(code_to_check, None) + if code_to_check in self.claimed_codes: + self.claimed_codes.remove(code_to_check) + else: logging.log(logging.WARN, f"Unknown message {message['type']}, ignoring") From bf14c1134654044ae5e75cd05acaf3159f780ce0 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 18 Dec 2025 18:18:04 -0500 Subject: [PATCH 031/118] TODO: add windows instructions for virtualenv --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 96fd08e..0e9c38e 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ First `cd` to the project directory, then set up like so: virtualenv .venv source .venv/activate # on Linux ``` + Certain IDEs (i.e. PyCharm) have these functions condensed into a menu. From d654a4a85ffadd0728261445a09bb34e8c7ee7a4 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 18 Dec 2025 19:07:31 -0500 Subject: [PATCH 032/118] various bugfixes in stale code removal logic --- AttendanceCodeCommunicator.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/AttendanceCodeCommunicator.py b/AttendanceCodeCommunicator.py index ea880a6..eafb989 100644 --- a/AttendanceCodeCommunicator.py +++ b/AttendanceCodeCommunicator.py @@ -114,11 +114,15 @@ def _communicate_after_handshake(self, sock: socket.socket): # check for expired codes import datetime - for (code_to_check, timestamp) in self.received_codes: - if datetime.datetime.now(datetime.UTC) > timestamp: - self.received_codes.pop(code_to_check, None) - if code_to_check in self.claimed_codes: - self.claimed_codes.remove(code_to_check) + codes_to_remove = [] + for code_to_check, expiry in self.received_codes.items(): + if datetime.datetime.now(datetime.UTC).timestamp() > expiry: + codes_to_remove.append(code_to_check) + + for code_to_remove in codes_to_remove: + self.received_codes.pop(code_to_remove, None) + if code_to_remove in self.claimed_codes: + self.claimed_codes.remove(code_to_remove) else: logging.log(logging.WARN, f"Unknown message {message['type']}, ignoring") From 5013318d17b9e3a28502955032844b4bdbb38ed0 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 18 Dec 2025 19:15:32 -0500 Subject: [PATCH 033/118] create SheetsManager: helper to deal with sheets and creds --- SheetsManager.py | 155 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 SheetsManager.py diff --git a/SheetsManager.py b/SheetsManager.py new file mode 100644 index 0000000..139caac --- /dev/null +++ b/SheetsManager.py @@ -0,0 +1,155 @@ +import datetime +import json +import os + +import keyring +from google.oauth2 import service_account +from googleapiclient.discovery import build + +# Partially derived from: +# - https://developers.google.com/workspace/sheets/api/quickstart/python +# - https://developers.google.com/identity/protocols/oauth2/service-account#python +# - https://docs.cloud.google.com/iam/docs/create-short-lived-credentials-direct#python +# See https://www.apache.org/licenses/LICENSE-2.0 for their licenses, even +# though this code is undeniably now a separate work (not under Apache 2.0). + +class SheetManager: + + # These scopes are fairly wide-ranging but only applicable to the service account. + CONST_SCOPES = ["https://www.googleapis.com/auth/drive.readonly", "https://www.googleapis.com/auth/spreadsheets"] + + account_name = None + allowable_owner = None + secret_file = "" + static_token: service_account.Credentials = None + sheets_service = None + + def __init__(self, secret_name: str = "secrets.json"): + self.secret_file = secret_name + self.account_name = os.getenv("service_account") + self.allowable_owner = os.getenv("allowable_owner") + pass + + def import_secrets(self): + """ + Import client secrets from a file to the keyring. + Do not call anything after this method. If the file does not exist, the + method exits the interpreter. + :return: nothing + """ + try: + with open(self.secret_file) as f: + self._lock_token(f.read()) + print("Successfully imported secrets") + except FileNotFoundError: + print(f"The file {self.secret_file} does not exist! Cannot import keys.") + exit(1) + + def unlock_token(self): + """ + Get the service account credentials from the keyring, then put them in + `static_token`. + :return: + """ + raw_data = keyring.get_keyring().get_credential("dozer_service_secrets", "service_auth").password + self.static_token = service_account.Credentials.from_service_account_info(json.loads(raw_data), scopes=self.CONST_SCOPES) + + def _lock_token(self, data: str): + """ + Internal: Save the service account credentials to the keyring. + :return: nothing + """ + keyring.set_password("dozer_service_secrets", "service_auth", data) + + def find_sheet(self, suffix: str): + """ + Find a possible sheet to store attendance, matching the given suffix. + Chooses one arbitrarily if there is more than one sheet. + :param suffix: suffix to match + :return: The sheet's ID, or None if nothing is found + """ + if self.static_token is None: + self.unlock_token() + drive_service = build("drive", "v3", credentials=self.static_token) + results = drive_service.files().list(fields="nextPageToken, files(id, name, mimeType, owners)").execute() + files = results.get("files", []) + + + + for file in files: + owners = [] + for owner in file['owners']: + owners.append(owner["emailAddress"]) + if file['mimeType'] == "application/vnd.google-apps.spreadsheet" and file['name'].endswith(suffix)\ + and self.allowable_owner in owners: + return file['id'] + + return None + + + def add_line(self, timestamp: datetime.datetime, handle: str, display_name: str, sheet_id: str): + """ + Add a line to the specified sheet. + :param timestamp: entry for timestamp column + :param handle: user's handle - mostly internal + :param display_name: user's display/friendly name - probably includes their real name + :param sheet_id: spreadsheet ID + :return: nothing + """ + # First: initialize if not done before - double nested + first_cell = self._get(sheet_id, "A1") + if not any(first_cell): + self._set(sheet_id, "A1:E1", [["Timestamp", "Handle", "Display name", 2, "!! NOTE: avoid editing anything in this sheet!"]]) + + # set the line + # first find the first available row + row_index: int = int(self._get(sheet_id, "D1")[0][0]) + + # check to see if that row is populated. If so, scan down the sheet until it isn't + while any(self._get(sheet_id, f"A{row_index}")): + row_index += 1 + + # insert a new row + self._set(sheet_id, f"A{row_index}:C{row_index}", [[timestamp.strftime("%Y-%m-%d %H:%M:%S"), handle, display_name]]) + row_index += 1 + + # write back index + self._set(sheet_id, "D1", [[row_index]]) + pass + + def _get(self, sheet_id: str, data_range: str): + """ + Internal: Get the data of a specific range. Creates the required + service if none exists. + :param sheet_id: The sheet to operate on + :param data_range: The data range, in the standard API format + :return: A 2d array of data representing the stored values. Always 2d even if the range is not. + """ + if self.static_token is None: + self.unlock_token() + if not self.sheets_service: + self.sheets_service = build("sheets", "v4", credentials=self.static_token).spreadsheets() + result = self.sheets_service.values().get(spreadsheetId=sheet_id, range=data_range).execute() + return result.get("values", []) + + def _set(self, sheet_id: str, data_range: str, data: list[list]): + """ + Internal: Set a specific range to the given 2d array of data. Creates + the required service if none exists. + :param sheet_id: The sheet to operate on + :param data_range: The data range, in the standard API format + :param data: A 2d array of data - even if the data is not really 2d, a 2d array should be used + :return: The amount of cells updated + """ + if self.static_token is None: + self.unlock_token() + if not self.sheets_service: + self.sheets_service = build("sheets", "v4", credentials=self.static_token).spreadsheets() + + result = (self.sheets_service.values() + .update( + spreadsheetId=sheet_id, range=data_range, + valueInputOption="RAW", body={"values": data} + ).execute() + ) + return result.get('updatedCells') \ No newline at end of file From b05296d6e63d1d1ee73623f4c331cee114f1ccef Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 18 Dec 2025 19:16:35 -0500 Subject: [PATCH 034/118] fix a heartbeat off-by-one --- AttendanceCodeCommunicator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AttendanceCodeCommunicator.py b/AttendanceCodeCommunicator.py index eafb989..96fc2c5 100644 --- a/AttendanceCodeCommunicator.py +++ b/AttendanceCodeCommunicator.py @@ -98,7 +98,7 @@ def _communicate_after_handshake(self, sock: socket.socket): } sock.sendall(bytes(json.dumps(response), 'utf-8')) logging.log(logging.WARN, f"Logged heartbeat error: expected {counter}, told {message['counter']}") - counter = message['counter'] + counter = message['counter'] + 1 elif message['type'] == 'code': code = int(message['code']) From ff1497424ac01ffe35fec58e710d01c5ab5cadb8 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 18 Dec 2025 19:17:05 -0500 Subject: [PATCH 035/118] integrate sheets into the rest of the bot, replacing sqlite --- AttendanceCodeCommunicator.py | 23 +++++++++++++++-------- cogs/MarkHere.py | 13 +++++-------- main.py | 18 ++++++++++++++---- 3 files changed, 34 insertions(+), 20 deletions(-) diff --git a/AttendanceCodeCommunicator.py b/AttendanceCodeCommunicator.py index 96fc2c5..5204fc1 100644 --- a/AttendanceCodeCommunicator.py +++ b/AttendanceCodeCommunicator.py @@ -1,15 +1,15 @@ import ipaddress import logging import json -import os import socket -import sqlite3 import sys import threading from enum import Enum from dotenv import load_dotenv +from SheetsManager import SheetManager + load_dotenv() class Status(Enum): @@ -18,17 +18,24 @@ class Status(Enum): CONNECTED = 2 class AttendanceCodeCommunicator: - db_connection = None + sheet_id = None + sheet_manager = None received_codes = {} claimed_codes = [] status: Status = Status.DISCONNECTED thread: threading.Thread = None - def __init__(self, db_path): - if not os.path.isfile(db_path): - open(db_path, 'w').close() - self.db_connection = sqlite3.connect(db_path) - self.db_connection.execute("CREATE TABLE IF NOT EXISTS Attendance ( user VARCHAR(255), timestamp INTEGER );") + def __init__(self): + """ + :raises ValueError: if no valid sheet exists + """ + self.sheet_manager = SheetManager() + self.sheet_id = self.sheet_manager.find_sheet("[attendance-bot]") + + if self.sheet_id is None: + raise ValueError('No valid sheet exists to store attendance codes. Either create one ' + 'with the suffix "[attendance-bot]" (no quotes) or disable ' + 'attendance features with the command-line switch.') def _discover(self, sock: socket.socket): diff --git a/cogs/MarkHere.py b/cogs/MarkHere.py index 46a07d1..292eafb 100644 --- a/cogs/MarkHere.py +++ b/cogs/MarkHere.py @@ -1,5 +1,4 @@ -import time -from math import floor +import datetime import discord from discord.ext import commands @@ -15,7 +14,7 @@ class MarkHere(commands.Cog): def __init__(self, bot): self.bot = bot - self.communicator = AttendanceCodeCommunicator("db.sqlite") + self.communicator = AttendanceCodeCommunicator() self.communicator.run() # starts background thread # Guild syncing @@ -34,7 +33,7 @@ def __init__(self, bot): async def mark_here(self, interaction: discord.Interaction, code: int): - timestamp = floor(time.time()) + timestamp = datetime.datetime.now(datetime.UTC) if code not in self.communicator.received_codes: await interaction.response.send_message(content=f"Code does not exist! Was it typed correctly?") return @@ -43,14 +42,12 @@ async def mark_here(self, interaction: discord.Interaction, code: int): await interaction.response.send_message(content=f"Code already claimed!") return # if code is no longer valid - if timestamp > self.communicator.received_codes[code]: + if timestamp.timestamp() > self.communicator.received_codes[code]: await interaction.response.send_message(content=f"Code is no longer valid!") return # so commit the record to memory - self.communicator.db_connection.execute("INSERT INTO Attendance (user, timestamp) VALUES (?, ?)", (interaction.user.name, timestamp)) - self.communicator.db_connection.commit() - # TODO: clean up codes periodically to get rid of long-expired ones + self.communicator.sheet_manager.add_line(timestamp, interaction.user.name, interaction.user.display_name, self.communicator.sheet_id) self.communicator.claimed_codes.append(code) await interaction.response.send_message(content=f"Marked {interaction.user.name} as present (code: {code})") diff --git a/main.py b/main.py index d8f389d..8d86209 100644 --- a/main.py +++ b/main.py @@ -5,6 +5,8 @@ from dotenv import load_dotenv import os +import SheetsManager + load_dotenv() intents = discord.Intents.default() @@ -25,11 +27,10 @@ async def on_ready(): synced = await bot.tree.sync(guild=guild) print(f"Cleared and synced {len(synced)} commands to {guild.id}") -async def load_extensions(disable_attendance_features=False): +async def load_extensions(disable_attendance=False): await bot.load_extension("cogs.ScoringGuide") await bot.load_extension("cogs.NoBlueBanners") - await bot.load_extension("cogs.ServerStatus") - if not disable_attendance_features: + if not disable_attendance: await bot.load_extension("cogs.MarkHere") print("Extensions all loaded") @@ -44,7 +45,16 @@ async def load_extensions(disable_attendance_features=False): else: print(f"Duplicate argument {arg}") os._exit(2) - case _: # default + case "--import-secrets": + if disable_attendance_features: + print(f"Cannot combine {arg} and --disable-attendance") + os._exit(2) + # search for secrets and import them into the keyring + manager = SheetsManager.SheetManager() + manager.import_secrets() + print("Success! Make sure to never leave the keys in plaintext (keep an encrypted copy on another machine).") + sys.exit(0) + case _: # default print(f"Unrecognized argument {arg}") import asyncio From 772e26247c890ef42833810009500e40446ff28f Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 18 Dec 2025 19:17:25 -0500 Subject: [PATCH 036/118] update README to reflect service account requirements & a new error --- README.md | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 0e9c38e..639ba71 100644 --- a/README.md +++ b/README.md @@ -55,12 +55,26 @@ your own server without clogging up the production server. ### Setting up the attendance scripts 1. Install a keyring manager. On a desktop system, this should already exist; it may need to be added for a server. -2. Configure and OAuth2 client (in Google Cloud) that has access to the `files` - scope for Google Sheets (can read and write files used with this - application). -3. Set up the credentials. TODO: specify which credentials are needed. -4. Install the client on a device on the same network. They should discover - each other. +2. Set up a service account on a Google Cloud project and create a key for it. + **Do not lose or compromise this key**. They can be recreated/disabled but + always keep security in mind. +3. Add two lines to `.env`: + ```dotenv + service_account=example@foo.iam.gserviceaccount.com + allowable_owner=johndoe@example.io + ``` + The first email should match the service account name. + + The second should be a regular account that will own the spreadsheet. +4. Place the token in the same folder as `main.py`, name it `secrets.json` and + run `main.py` with the argument `--import-secrets`. +5. Make sure you have an encrypted copy of the token on another device, and + destroy it. Never leave a plaintext copy on a device. +6. Create a spreadsheet on the account listed in the `allowable_owner` field. + Its name should end with "\[attendance-bot\]" *exactly*. Avoid editing the + top row. Share this sheet with the service account. +7. Install the code client on a device on the same network. They should + discover each other automagically. ## Running The bot takes the following command-line parameter(s): @@ -75,4 +89,10 @@ keyring before trying again. If the bot produces "unauthorized" or "forbidden" errors, check the tokens. Specifically, that they aren't expired and that the correct scopes are -selected. \ No newline at end of file +selected. + +If the bot raises a `ValueError` about how no valid sheet was found, make sure +that there is a sheet that: +* is shared with the designated service account; +* is named with the appropriate suffix (no quotes or extra spaces); and +* is editible/visible. \ No newline at end of file From 92d0a391bdeed286bdc884bde7e09594e7c1a7ed Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Fri, 19 Dec 2025 00:33:30 -0500 Subject: [PATCH 037/118] document import-secrets --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 639ba71..b360e35 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,10 @@ your own server without clogging up the production server. The bot takes the following command-line parameter(s): * `--disable-attendance`: disable all attendance features. The server will not be started and the OAuth tokens will not be used/verified. +* `--import-secrets`: Import the secrets from `secrets.json` into the keyring. + The program will immediately terminate after success/failure. **Always secure + the tokens**, do not leave an unsecured copy on the server (keep an encrypted + copy on a different host). Must not be specified with `--disable-attendance`. Launch `main.py` with `python3` to run the app. From 8264f47bc27a959494f9ae0e53cba6c508efcc47 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Fri, 19 Dec 2025 00:34:00 -0500 Subject: [PATCH 038/118] reformat README to include clearer step-by-step instructions on setup. Include instructions on Discord app setup. --- README.md | 169 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 123 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index b360e35..43c36ce 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,14 @@ ### This is a forked version of the original at https://github.com/Graham277/NewDozer. -## About +# About A python rewrite of the original dozer-discord-bot. Much of this project is powered by the blue alliance at https://www.thebluealliance.com/ -## Setting up -### Dependencies +This version of the bot also includes attendance features. This implementation +uses a Google Sheets spreasheet for storage. + +# Setting up +## Dependencies and environment The project requires multiple dependencies, including: * `discord` * `dotenv` @@ -33,50 +36,124 @@ If `pip` has the same problem with system packages, try: ```shell ./.venv/bin/python3 ./.venv/bin/pip install -r requirements.txt ``` -### Discord tokens +(which is just a more forceful way of using the virtualenv pip.) + +## Environment file and keyrings -The bot requires Discord tokens for a bot, which need to be placed into a file -named `.env`. +This bot requires tokens to access several APIs. Obviously they cannot be +hardcoded, thus they are passed through environment variables in a file named +`.env`, which will never be checked into Git. -Add your token to the file in this form: +Entries are formatted like so: ```dotenv -token=YourTokenHere +key=value +multi_word_key="extra long entry with spaces!" ``` -Add the id of the guild the bot will be run on to the dotenv. -This provides instant syncing to the guild and is required for this bot. + +Make sure to remove trailing whitespace. + +Additionally, certain keys are so sensitive that they cannot be stored in +plaintext. Instead they are stored in a keyring (Windows Credential Manager, +Gnome Secret Service or similar, or macOS's offering), where they are encrypted +at rest. + +A keyring must be installed and unlocked for this app to function. If +* you are using **macOS or Windows**: you already have a keyring +* you are running a **mainstream desktop flavour of Linux**: you probably have + a keyring manager, look for a password manager/keyring app (or similar) +* you are running a server distribution of Linux: check according to your + specific distribution. Try running: + ```shell + gnome-keyring version + ``` + and see if it exists. The agent must implement the Secret Service, thus + GNOME keyring or KDE wallet should work for most situations. + +## Discord tokens + +The bot requires a Discord API token and a guild ID for instant sync. Both are +stored in the `.env` file. + +### Creating an app and getting tokens + +To create an app: +1. Go to the discord developer's portal. +2. Click on 'new application', and give it a name. +3. Go to the 'Bot' tab and generate/copy the bot's token. Paste it into `.env` + as below. + +To find a server's `guild_id`: +1. Enable developer mode (Settings > Advanced > Developer mode). +2. Right click on a server. +3. Click 'Copy ID'. Paste it into `.env` as below. + +To install the app into the server: +1. In the developer's portal, go to 'Installation'. +2. Make sure it has the `applications.commands` scope. +3. Copy the auth link and paste it into the browser. +4. Grant access to the server you want. + +A `dev_guild_id` line can be added for development. Commands will sync to both +the production and dev guilds; the dev guild can be used to avoid clogging the +prod server with commands. + +Add your credentials to the file in this form: ```dotenv -guild_id=YourGuildIdHere +# long Base64 token +token=YourTokenHere +# long integer (longer than shown here), accessible from the Discord client +# (guild is API-speak for server) +guild_id=12345678 +# like above, but a different server for dev purposes +dev_guild_id=87654321 ``` -If doing development, you can add a "dev_guild_id" as well, and the commands -will sync to both guilds. This allows you to develop and test the status on -your own server without clogging up the production server. - -### Setting up the attendance scripts -1. Install a keyring manager. On a desktop system, this should already exist; - it may need to be added for a server. -2. Set up a service account on a Google Cloud project and create a key for it. - **Do not lose or compromise this key**. They can be recreated/disabled but - always keep security in mind. -3. Add two lines to `.env`: +To test it, run: +```shell +python3 main.py --disable-attendance +``` +It should authenticate and provide most of the commands. + +## Google authentication + +The attendance host requires various Google API features to store verification +history. This takes the form of a Google Cloud service account that has a +spreadsheet shared with it. + +To set it up: + +1. **Set up a Google Cloud project**. Go to the console and create a project, + and give it a useful name. +2. **Enable the required APIs**, which include: + * Drive API + * Sheets API +3. **Go to** IAM/Admin > Service Accounts and create a new service account. + Give it a convenient name and email address. +4. **Click on the options menu** next to the account, manage its keys, and + create a new key. +5. **Download this key** to a safe place on a trusted client. **Do not compromise + this key**. Always keep an encrypted copy on hand - not plaintext, and not + on the server. +6. **Transfer the key** (in plaintext) to the server. Move it to the bot's + project root and name it `secrets.json`. +7. **Run `main.py`** with the argument `--import-secrets`. +8. **Add the following lines** to `.env`: ```dotenv + # service account just created service_account=example@foo.iam.gserviceaccount.com - allowable_owner=johndoe@example.io + # the owner of this email will own the main sheet and share it with the + # service account + allowable_owner=johndoe@example.test ``` - The first email should match the service account name. - - The second should be a regular account that will own the spreadsheet. -4. Place the token in the same folder as `main.py`, name it `secrets.json` and - run `main.py` with the argument `--import-secrets`. -5. Make sure you have an encrypted copy of the token on another device, and - destroy it. Never leave a plaintext copy on a device. -6. Create a spreadsheet on the account listed in the `allowable_owner` field. - Its name should end with "\[attendance-bot\]" *exactly*. Avoid editing the - top row. Share this sheet with the service account. -7. Install the code client on a device on the same network. They should - discover each other automagically. - -## Running +9. Using the `allowable_owner`'s associated Google account, **create a + spreadsheet**. Name it anything, but add the suffix "[attendance-bot]" to + the end. Avoid editing this sheet as the app stores metadata and state in + some parts. +10. **Start up the server.** +11. **Expose an attendance client on the same network**, using the associated + Java package. The two should discover each other automagically. + +# Running The bot takes the following command-line parameter(s): * `--disable-attendance`: disable all attendance features. The server will not be started and the OAuth tokens will not be used/verified. @@ -87,16 +164,16 @@ The bot takes the following command-line parameter(s): Launch `main.py` with `python3` to run the app. -## Troubleshooting -If the bot throws an error about a locked keyring, exit it and unlock the +# Troubleshooting +* **If the bot throws an error about a locked keyring**, exit it and unlock the keyring before trying again. -If the bot produces "unauthorized" or "forbidden" errors, check the tokens. -Specifically, that they aren't expired and that the correct scopes are +* **If the bot produces "unauthorized" or "forbidden" errors**, check the +tokens. Specifically, that they aren't expired and that the correct scopes are selected. -If the bot raises a `ValueError` about how no valid sheet was found, make sure -that there is a sheet that: -* is shared with the designated service account; -* is named with the appropriate suffix (no quotes or extra spaces); and -* is editible/visible. \ No newline at end of file +* **If the bot raises a `ValueError` about how no valid sheet was found**, make +sure that there is a sheet that: + * is shared with the designated service account; + * is named with the appropriate suffix (no quotes or extra spaces); and + * is editable/visible. \ No newline at end of file From 9fca458e1d2d5d4215e79a5f7cdb97a581eb4c03 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Fri, 19 Dec 2025 00:36:33 -0500 Subject: [PATCH 039/118] ignore gpg-encrypted secrets as well --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index ca05bba..285c007 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ db.sqlite-journal pycache **/__pycache__/ .venv/ -secrets.json \ No newline at end of file +secrets.json +secrets.json.gpg \ No newline at end of file From 3bd1def12fb26e00822cd8fbb84a9bb94c42b3f6 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Fri, 19 Dec 2025 00:41:35 -0500 Subject: [PATCH 040/118] make --disable-attendance work as expected --- main.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/main.py b/main.py index 8d86209..94c7015 100644 --- a/main.py +++ b/main.py @@ -11,6 +11,7 @@ intents = discord.Intents.default() bot = commands.Bot(command_prefix="!", intents=intents) +disable_attendance = False @bot.event async def on_ready(): @@ -36,17 +37,16 @@ async def load_extensions(disable_attendance=False): if __name__ == "__main__": # parse command line - disable_attendance_features = False for arg in sys.argv[1:]: match arg: case "--disable-attendance": - if not disable_attendance_features: - disable_attendance_features = True + if not disable_attendance: + disable_attendance = True else: print(f"Duplicate argument {arg}") os._exit(2) case "--import-secrets": - if disable_attendance_features: + if disable_attendance: print(f"Cannot combine {arg} and --disable-attendance") os._exit(2) # search for secrets and import them into the keyring From 34e0a0aa45f00dac63eaba253cb2468f834cab60 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Fri, 19 Dec 2025 00:43:43 -0500 Subject: [PATCH 041/118] remove shadowing disable_attendance parameter --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.py b/main.py index 94c7015..aef04b6 100644 --- a/main.py +++ b/main.py @@ -28,7 +28,7 @@ async def on_ready(): synced = await bot.tree.sync(guild=guild) print(f"Cleared and synced {len(synced)} commands to {guild.id}") -async def load_extensions(disable_attendance=False): +async def load_extensions(): await bot.load_extension("cogs.ScoringGuide") await bot.load_extension("cogs.NoBlueBanners") if not disable_attendance: From b0ecfdb3fd0a8aa293a342cc6eb026c23055852f Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Fri, 19 Dec 2025 00:50:08 -0500 Subject: [PATCH 042/118] only purge codes >3m past expiry to give the expired message some use --- AttendanceCodeCommunicator.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AttendanceCodeCommunicator.py b/AttendanceCodeCommunicator.py index 5204fc1..6e54647 100644 --- a/AttendanceCodeCommunicator.py +++ b/AttendanceCodeCommunicator.py @@ -123,7 +123,8 @@ def _communicate_after_handshake(self, sock: socket.socket): import datetime codes_to_remove = [] for code_to_check, expiry in self.received_codes.items(): - if datetime.datetime.now(datetime.UTC).timestamp() > expiry: + # check if the code is 3m past expiry - give a chance to see the expired message + if datetime.datetime.now(datetime.UTC).timestamp() > expiry + 180: codes_to_remove.append(code_to_check) for code_to_remove in codes_to_remove: From f3799f76f59e3edead9be0fd6e684df439e46b88 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Fri, 19 Dec 2025 00:58:19 -0500 Subject: [PATCH 043/118] fix typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 43c36ce..cda22d4 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A python rewrite of the original dozer-discord-bot. Much of this project is powered by the blue alliance at https://www.thebluealliance.com/ This version of the bot also includes attendance features. This implementation -uses a Google Sheets spreasheet for storage. +uses a Google Sheets spreadsheet for storage. # Setting up ## Dependencies and environment From 179df6f5fa393d49d42ef3e5e2d5a0c43620019f Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Fri, 19 Dec 2025 08:46:36 -0500 Subject: [PATCH 044/118] add dependencies for statbotics, pillow --- requirements.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/requirements.txt b/requirements.txt index 6e97c24..80ce977 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ aiohappyeyeballs==2.6.1 aiohttp==3.13.2 aiosignal==1.4.0 attrs==25.4.0 +CacheControl==0.12.14 cachetools==6.2.4 certifi==2025.11.12 cffi==2.0.0 @@ -25,8 +26,10 @@ jaraco.functools==4.3.0 jeepney==0.9.0 keyring==25.7.0 more-itertools==10.8.0 +msgpack==1.1.2 multidict==6.7.0 oauthlib==3.3.1 +pillow==12.0.0 propcache==0.4.1 proto-plus==1.27.0 protobuf==6.33.2 @@ -39,6 +42,7 @@ requests==2.32.5 requests-oauthlib==2.0.0 rsa==4.9.1 SecretStorage==3.5.0 +statbotics==3.0.0 typing_extensions==4.15.0 uritemplate==4.2.0 urllib3==2.6.2 From 51e3f0a3258cf97d30b10680b9b339859d253c72 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Fri, 19 Dec 2025 08:47:44 -0500 Subject: [PATCH 045/118] remove fork header --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index e13be92..9359172 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,3 @@ -### This is a forked version of the original at https://github.com/Graham277/NewDozer. - # About A python rewrite of the original dozer-discord-bot. Much of this project is powered by the blue alliance at https://www.thebluealliance.com/ From 14a5d2959a99bf5f4d698c16e1235392767209b1 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Fri, 19 Dec 2025 09:23:15 -0500 Subject: [PATCH 046/118] add options for relocating .env and secrets.json --- AttendanceCodeCommunicator.py | 6 +++++- README.md | 26 ++++++++++++++++++++------ main.py | 8 +++++++- 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/AttendanceCodeCommunicator.py b/AttendanceCodeCommunicator.py index 6e54647..1a9f8e9 100644 --- a/AttendanceCodeCommunicator.py +++ b/AttendanceCodeCommunicator.py @@ -1,6 +1,7 @@ import ipaddress import logging import json +import os import socket import sys import threading @@ -29,7 +30,10 @@ def __init__(self): """ :raises ValueError: if no valid sheet exists """ - self.sheet_manager = SheetManager() + secrets_location = os.getenv("secrets_json_path") + if not secrets_location: + secrets_location = "secrets.json" + self.sheet_manager = SheetManager(secrets_location) self.sheet_id = self.sheet_manager.find_sheet("[attendance-bot]") if self.sheet_id is None: diff --git a/README.md b/README.md index 9359172..6ea0463 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,15 @@ multi_word_key="extra long entry with spaces!" Make sure to remove trailing whitespace. +If the `.env` file must be located in a different place (i.e. /run/secrets), +set: + +```shell +DOZER_DOTENV_PATH=/path/to/.env +``` + +before running the bot. + Additionally, certain keys are so sensitive that they cannot be stored in plaintext. Instead they are stored in a keyring (Windows Credential Manager, Gnome Secret Service or similar, or macOS's offering), where they are encrypted @@ -134,8 +143,13 @@ To set it up: on the server. 6. **Transfer the key** (in plaintext) to the server. Move it to the bot's project root and name it `secrets.json`. -7. **Run `main.py`** with the argument `--import-secrets`. -8. **Add the following lines** to `.env`: +7. (Optional) **Add the following line** to `.env` if the secrets file has a + different path: + ```dotenv + secrets_json_path=/path/to/secrets-file.json + ``` +8. **Run `main.py`** with the argument `--import-secrets`. +9. **Add the following lines** to `.env`: ```dotenv # service account just created service_account=example@foo.iam.gserviceaccount.com @@ -143,10 +157,10 @@ To set it up: # service account allowable_owner=johndoe@example.test ``` -9. Using the `allowable_owner`'s associated Google account, **create a - spreadsheet**. Name it anything, but add the suffix "[attendance-bot]" to - the end. Avoid editing this sheet as the app stores metadata and state in - some parts. +10. Using the `allowable_owner`'s associated Google account, **create a + spreadsheet**. Name it anything, but add the suffix "[attendance-bot]" to + the end. Avoid editing this sheet as the app stores metadata and state in + some parts. 10. **Start up the server.** 11. **Expose an attendance client on the same network**, using the associated Java package. The two should discover each other automagically. diff --git a/main.py b/main.py index 720fd51..1f18047 100644 --- a/main.py +++ b/main.py @@ -8,7 +8,13 @@ import SheetsManager # Secrets are stored in a dotenv, so we must load it before trying to access it -load_dotenv() +# Before running, see if there is an environment variable called +# DOZER_DOTENV_PATH. +# If so, load it from there. +dotenv_path = os.getenv("DOZER_DOTENV_PATH") +if not dotenv_path: + dotenv_path = None +load_dotenv(dotenv_path=dotenv_path) # Set up the bot intents = discord.Intents.default() From 2aed90106fe98487e3cf2dc80f50003782eb7c7e Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Wed, 24 Dec 2025 00:24:39 -0500 Subject: [PATCH 047/118] add half-done install script. TODO: systemd, log rotation, uninstall --- setup.py | 312 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100755 setup.py diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..232a629 --- /dev/null +++ b/setup.py @@ -0,0 +1,312 @@ +#!/usr/bin/env /usr/bin/python3 +# +# ========= Setup ========= +# Standalone script to install files and to set up dependencies. +# This script makes the following changes: +# 1. Clones all project files into the selected directory. If appropriate, +# creates symlinks to `bin` directories. +# 2. Creates a virtualenv in the installation directory. +# 3. `pip install`s dependencies in the virtualenv. +# 4. Creates a `systemd` service (if available) to run the bot. +# Created as a user service if installed into the home directory, system +# otherwise. +# 5. Configures rsyslog if using system-wide service. Configures logrotate if +# using user service. +# Requires sudo privileges. +import os +from os.path import sep +import shutil +import subprocess +import sys +import textwrap + + +## Helper functions + +def choose_option(message: str, *options: str, default: int | None = None) -> int: + """ + Present a list for the user to choose various options from. + The default option is chosen when the user enters '0' or nothing, and is + marked with an asterisk. + :param message: a header message to inform the user of the message's context + :param options: the set of options the user has + :param default: the index of the option that is chosen by default, or None if none + :return: the index of the option that was chosen + """ + + wrap_col = 80 + + # regularize to non-None int + + # first print out the options + # all printed messages are wrapped in textwrap.wrap() and have sep='\n' + # this is so extra-long messages are put onto multiple lines. + print(*textwrap.wrap(message, wrap_col), sep='\n') + print() + # either: + # Choose an option [1-N, or 0 for default]: | if default not None + # Choose an option [1-N]: | if default None + print(*textwrap.wrap(f"Choose an option [1-{len(options)}" + f"{", or 0 for default" if default is not None else ""}]:", wrap_col), + sep='\n') + + # offset = how long the last index is + offset = len(str(len(options))) + number = 1 + # print each option. each number is space-buffered from the left if it is + # not max length. + # default is marked with an asterisk + for option in options: + buffer = " " * (offset - len(str(number))) + if default is not None and default + 1 == number: + # either (default selected): + # | * NNN. option placeholder + # | NNN. option placeholder + # or (no default): + # | NNN. option placeholder + # | NNN. option placeholder + print(*textwrap.wrap(f"{" *" if default is not None else ""} {buffer}" + f"{number}. {option}", wrap_col), sep='\n') + else: + print(*textwrap.wrap(f"{" " if default is not None else ""} {buffer}" + f"{number}. {option}", wrap_col), sep='\n') + number += 1 + + print() + + # get choice + # keep doing it until a valid input is reached + + # choice_start: lowest valid integer for choice + choice_start = 0 if default is not None else 1 + while True: + # wait for input + # either: + # Choice [0-N]: + # or: + # Choice [1-N]: + val = input(f"Choice [{choice_start}-{len(options)}]: ") + + # validate input + # first, if the string is empty, that means the default was chosen + if not val: + val = "0" + + # check: value is an integer + try: + val_index: int = int(val) - 1 + except ValueError: + print("Invalid input (not a number), please try again.") + continue + + # from now on, index starts at 0 and default == -1 + # check: value is not default if default is unset + if default is None and val_index == -1: + print("Invalid input (no default option), please try again.") + continue + + # collapse default value + # default is not None already implied here + if val_index == -1 and default is not None: + val_index = default + + # check: value in range + if val_index < 0 or val_index >= len(options): + print(f"Input out of range ({choice_start}-{len(options)}), please try again.") + continue + + # all good + return val_index + + assert False, "Unreachable state" + +def confirm(message: str, default_state: bool | None) -> bool: + """ + Presents the user with a confirmation message, which they can respond to in + various ways. + + The values "y", "yes" and "true" yield `True`, while "n", "no" and "false" + yield `False`, an empty string results in the default value, and anything + else makes the user try again. + :param message: + :param default_state: the option to return if the user inputs nothing, or + None if this is not a valid option + :return: a `bool` representing the user's choice + """ + + # essentially: + # default is False -> y/N + # default is True -> Y/n + # default is None -> y/n + possible_inputs = (f"[{'y' if not default_state or default_state is None else 'Y'}/" + f"{'n' if default_state or default_state is None else 'N'}]") + val = input(message + " " + possible_inputs + "? ") + while True: + # if nothing was entered, use default + if not val or len(val.strip()) == 0: + if default_state is not None: + return default_state + # if default is None, reject it and try again + else: + val = input(f"Invalid input. {possible_inputs}? ") + continue + else: + # if it is yes, true + if val.strip().lower() in ("y", "yes", "true"): + return True + # if no, false + elif val.strip().lower() in ("n", "no", "false"): + return False + # if invalid, go again + else: + val = input(f"Invalid input. {possible_inputs}? ") + continue + + assert False, "Unreachable state" + +def wait(): + """ + Waits for the user to press enter. + :return: nothing + """ + input("Press enter to continue...") + +def is_subdir(parent: str | os.PathLike, child: str | os.PathLike) -> bool: + """ + Checks if the child is a subdirectory of or equal to the parent. + :param parent: the parent directory + :param child: the child, which might be a child of the parent + :return: bool: whether the child is a subdirectory of the parent + """ + parent_real = os.path.realpath(os.path.abspath(os.path.expanduser(parent))) + child_real = os.path.realpath(os.path.abspath(os.path.expanduser(child))) + return parent_real == os.path.commonpath([parent_real, child_real]) + +def main(): + """ + Main routine that handles all functions. + :return: nothing + """ + + subdir_name = "dozerbot" + + print(" === Dozer Installer === ") + print("Version 1.0.0") + print() + print("Step 1 - gathering information...") + print() + + # get information + + # only supports Linux atm + if not sys.platform.startswith("linux"): + print("ERROR: This install script is only supported on Linux (and similar) systems.") + print("Abort. ---") + sys.exit(1) + + # check deps (virtualenv) + if shutil.which("virtualenv") is None: + print("ERROR: no installed copy of virtualenv was found, which is" + " required for installation. Please check $PATH and install it if" + " necessary.") + print("Abort. ---") + sys.exit(1) + + # get the install directory + install_targets = ["/usr/local/share", "/opt", "~/.local"] + bin_path_targets = ["/usr/local/bin", "/opt", "~/.local/bin"] + annotated_install_targets = [ + "Into /usr/local/share, linked into /usr/local/bin and etc", + "Into /opt, in its own subdirectory", + "Into ~/.local/share, linked into ~/.local/bin",] + option = choose_option( + "Which directory should the bot be installed into?\n", + *annotated_install_targets) + + # check $PATH + path = os.getenv("PATH") + path_entries = path.split(os.pathsep) + has_found_current = False + + # this awful mess just gets the real path of a file + # (i.e. no symlinks, tildes or relative components) + # this loop checks if the selected directory is in PATH + target_test_dir = os.path.realpath(os.path.abspath(os.path.expanduser(bin_path_targets[option]))) + for entry in path_entries: + entry_test_dir = os.path.realpath(os.path.abspath(os.path.expanduser(entry))) + if entry_test_dir == target_test_dir: + has_found_current = True + break + + # warn user if their installation dir is not in PATH + if not has_found_current: + print("WARNING: Cannot find " + bin_path_targets[option] + + " in $PATH. This will not prevent installation, but may cause" + " issues if running directly from the command line.") + + print() + wait() + + # install! + print() + print("Step 2 - installing files...") + print() + + install_dir = install_targets[option] + sep + subdir_name + needs_root = not is_subdir("~", install_targets[option]) + + if needs_root: + print("The script will now ask for superuser permissions.") + + # copy files + try: + if needs_root: + # make sure directories exist + subprocess.run(["sudo", "mkdir", "-p", install_dir]) + subprocess.run(["sudo", "cp", "-r", ".", install_dir]) + else: + # but with 100% less sudo + subprocess.run(["mkdir", "-p", install_dir]) + subprocess.run(["cp", ".", install_dir]) + except subprocess.CalledProcessError as e: + print(f"ERROR: Failed to copy files! (exit code {e.returncode}," + f" message: {str(e)})") + print("Abort. ---") + exit(1) + # done + + # make venv + print() + print("Step 3 - creating virtual environment...") + print() + + venv_folder = install_dir + sep + ".venv" + try: + subprocess.run(["sudo", "-E", "virtualenv", venv_folder]) + # cannot source it so it has to be explicitly called every time + except subprocess.CalledProcessError as e: + print(f"ERROR: Failed to create virtual environment! (exit code" + f" {e.returncode}, message: {str(e)})") + print("Abort. ---") + exit(1) + # done + + # get pip deps + print() + print("Step 4 - installing dependencies...") + print() + + try: + subprocess.run(["sudo", "-E", venv_folder + sep + "bin" + sep + "pip", + "install", "-r", install_dir + sep + "requirements.txt"]) + except subprocess.CalledProcessError as e: + print(f"ERROR: Failed to install dependencies! (exit code" + f" {e.returncode}, message: {str(e)})") + print("Abort. ---") + exit(1) + + # TODO: symlink [.]local/bin, etc + # TODO: make systemd files/log config + +if __name__ == "__main__": + main() \ No newline at end of file From 2599260e08c403cc46c65cc94d4c8a7167b86388 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Wed, 24 Dec 2025 22:26:43 -0500 Subject: [PATCH 048/118] add helper start.sh script --- start.sh | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100755 start.sh diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..290c2a0 --- /dev/null +++ b/start.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env sh + +# start.sh +# quickly source .venv and start up +# this script should be entirely sh-compliant + +# if no arguments, use .venv +# if there is one argument, use it as a target +# if the one argument is --help/-h/-?, show help +# if there are two or more, throw error + +VENV_TARGET=".venv" + +# print help + +help() { + echo "Usage:"; + echo " setup.sh [--help|-h|-?|]"; + echo " : The location of a virtualenv folder (optional, must not be specified with anything else). Defaults to '.venv'."; + echo " --help/-h/-?: Print this help (optional, must not be specified with anything else)"; + echo "venv-location must not be named '--help', '-h' or '-?'"; + exit "$1"; +} + +# parse argument (just one) + +if [ $# -gt 1 ]; then + echo "Too many arguments"; + help 1; +fi + +if [ $# -eq 1 ]; then + # check for target/help + if [ "$1" = "--help" ] || [ "$1" = "-h" ] || [ "$1" = "-?" ]; then + help 0; + else + VENV_TARGET="$1"; + fi +fi + +# check for venv and main.py +if ! [ -e "$VENV_TARGET" ] || ! [ -d "$VENV_TARGET" ]; then + echo "Cannot find directory $VENV_TARGET"; + help 1; +fi +if ! [ -e "main.py" ] || ! [ -f "main.py" ]; then + echo "Cannot find main.py"; + help 1; +fi + +# source but sh +. "$VENV_TARGET"/bin/activate; + +# run +"$VENV_TARGET"/bin/python3 main.py; From b6e3aa279bc148aa1e0cdd287e35ec2039e902c3 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Wed, 24 Dec 2025 22:41:54 -0500 Subject: [PATCH 049/118] fix broken numbering --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6ea0463..8772523 100644 --- a/README.md +++ b/README.md @@ -161,8 +161,8 @@ To set it up: spreadsheet**. Name it anything, but add the suffix "[attendance-bot]" to the end. Avoid editing this sheet as the app stores metadata and state in some parts. -10. **Start up the server.** -11. **Expose an attendance client on the same network**, using the associated +11. **Start up the server.** +12. **Expose an attendance client on the same network**, using the associated Java package. The two should discover each other automagically. # Running From f87cbfdaf0543e48a01a57b7e1de7d4f776423cb Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Wed, 24 Dec 2025 22:49:48 -0500 Subject: [PATCH 050/118] chmod installed files to have exec permissions --- setup.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/setup.py b/setup.py index 232a629..83dd041 100755 --- a/setup.py +++ b/setup.py @@ -264,10 +264,17 @@ def main(): # make sure directories exist subprocess.run(["sudo", "mkdir", "-p", install_dir]) subprocess.run(["sudo", "cp", "-r", ".", install_dir]) + # and make sure anyone can execute + subprocess.run(["sudo", "chmod", "+x", install_dir + sep + "main.py"]) + subprocess.run(["sudo", "chmod", "+x", install_dir + sep + "start.sh"]) else: # but with 100% less sudo subprocess.run(["mkdir", "-p", install_dir]) subprocess.run(["cp", ".", install_dir]) + os.chmod(install_dir + sep + "main.py", + os.stat(install_dir + sep + "main.py").st_mode | stat.S_IEXEC) + os.chmod(install_dir + sep + "start.sh", + os.stat(install_dir + sep + "start.sh").st_mode | stat.S_IEXEC) except subprocess.CalledProcessError as e: print(f"ERROR: Failed to copy files! (exit code {e.returncode}," f" message: {str(e)})") From 7329fcfebbb42be4af62df4932948e7f078df29c Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Wed, 24 Dec 2025 22:50:06 -0500 Subject: [PATCH 051/118] minor wording change --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 83dd041..4631ca6 100755 --- a/setup.py +++ b/setup.py @@ -256,7 +256,7 @@ def main(): needs_root = not is_subdir("~", install_targets[option]) if needs_root: - print("The script will now ask for superuser permissions.") + print("The installer will now ask for superuser permissions.") # copy files try: From ef8647f7571b72f444c0c16a1d27a141d81de083 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Wed, 24 Dec 2025 22:50:25 -0500 Subject: [PATCH 052/118] add a notice that the user can add the dir to PATH --- setup.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/setup.py b/setup.py index 4631ca6..e94258d 100755 --- a/setup.py +++ b/setup.py @@ -243,6 +243,8 @@ def main(): print("WARNING: Cannot find " + bin_path_targets[option] + " in $PATH. This will not prevent installation, but may cause" " issues if running directly from the command line.") + print("It is recommended to add the directory to PATH if you plan on" + "using it directly from the command line.") print() wait() From cd50323ea569cda3d8fffdc6f5612f8dea218fef Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Wed, 24 Dec 2025 22:50:54 -0500 Subject: [PATCH 053/118] add etc paths as well (for symlinking) --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index e94258d..8dd7ec6 100755 --- a/setup.py +++ b/setup.py @@ -215,6 +215,7 @@ def main(): # get the install directory install_targets = ["/usr/local/share", "/opt", "~/.local"] bin_path_targets = ["/usr/local/bin", "/opt", "~/.local/bin"] + etc_path_targets = ["/usr/local/etc", "/opt", "~/.local/etc"] annotated_install_targets = [ "Into /usr/local/share, linked into /usr/local/bin and etc", "Into /opt, in its own subdirectory", From 8613ce1a1322d62b31e4f6435249cbbb5e91e5fb Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Wed, 24 Dec 2025 22:51:01 -0500 Subject: [PATCH 054/118] fix .local --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 8dd7ec6..c4967dc 100755 --- a/setup.py +++ b/setup.py @@ -213,7 +213,7 @@ def main(): sys.exit(1) # get the install directory - install_targets = ["/usr/local/share", "/opt", "~/.local"] + install_targets = ["/usr/local/share", "/opt", "~/.local/share"] bin_path_targets = ["/usr/local/bin", "/opt", "~/.local/bin"] etc_path_targets = ["/usr/local/etc", "/opt", "~/.local/etc"] annotated_install_targets = [ From 85aa144088327ba15b9ef85d46c84f821c9d6f5f Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Wed, 24 Dec 2025 22:51:21 -0500 Subject: [PATCH 055/118] wrap some extra-long lines --- setup.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/setup.py b/setup.py index c4967dc..3bcb4d3 100755 --- a/setup.py +++ b/setup.py @@ -189,6 +189,7 @@ def main(): """ subdir_name = "dozerbot" + wrap_width = 80 print(" === Dozer Installer === ") print("Version 1.0.0") @@ -206,9 +207,9 @@ def main(): # check deps (virtualenv) if shutil.which("virtualenv") is None: - print("ERROR: no installed copy of virtualenv was found, which is" + print(*textwrap.wrap("ERROR: no installed copy of virtualenv was found, which is" " required for installation. Please check $PATH and install it if" - " necessary.") + " necessary.", wrap_width), sep='\n') print("Abort. ---") sys.exit(1) @@ -241,11 +242,13 @@ def main(): # warn user if their installation dir is not in PATH if not has_found_current: - print("WARNING: Cannot find " + bin_path_targets[option] + + print(*textwrap.wrap("WARNING: Cannot find " + bin_path_targets[option] + " in $PATH. This will not prevent installation, but may cause" - " issues if running directly from the command line.") - print("It is recommended to add the directory to PATH if you plan on" - "using it directly from the command line.") + " issues if running directly from the command line.", + wrap_width), sep='\n') + print(*textwrap.wrap("It is recommended to add the directory to PATH if" + " you plan on using it directly from the command" + " line.", wrap_width), sep='\n') print() wait() From 2f52eac3eea84fe8d3ca3ceee55c1750304c3973 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Wed, 24 Dec 2025 22:53:35 -0500 Subject: [PATCH 056/118] fix .local v2 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 3bcb4d3..8c23a67 100755 --- a/setup.py +++ b/setup.py @@ -220,7 +220,7 @@ def main(): annotated_install_targets = [ "Into /usr/local/share, linked into /usr/local/bin and etc", "Into /opt, in its own subdirectory", - "Into ~/.local/share, linked into ~/.local/bin",] + "Into ~/.local/share, linked into ~/.local/bin and etc",] option = choose_option( "Which directory should the bot be installed into?\n", *annotated_install_targets) From 3134689554d121eec92ad5f257c2d598e58eba46 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Wed, 24 Dec 2025 22:54:16 -0500 Subject: [PATCH 057/118] add import missing from partial commit --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 8c23a67..b756637 100755 --- a/setup.py +++ b/setup.py @@ -14,6 +14,7 @@ # using user service. # Requires sudo privileges. import os +import stat from os.path import sep import shutil import subprocess From c6a6647a4bac06f76a6ddf4d156aea633c0b2a65 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Wed, 24 Dec 2025 22:55:23 -0500 Subject: [PATCH 058/118] add variables for absolute/real/de-usered paths for use in certain functions (and refactor to use them) --- setup.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/setup.py b/setup.py index b756637..644b8b7 100755 --- a/setup.py +++ b/setup.py @@ -226,6 +226,16 @@ def main(): "Which directory should the bot be installed into?\n", *annotated_install_targets) + install_parent_target_abs = os.path.realpath(os.path.abspath( + os.path.expanduser(install_targets[option]) + )) + bin_path_target_abs = os.path.realpath(os.path.abspath( + os.path.expanduser(bin_path_targets[option]) + )) + etc_path_target_abs = os.path.realpath(os.path.abspath( + os.path.expanduser(etc_path_targets[option]) + )) + # check $PATH path = os.getenv("PATH") path_entries = path.split(os.pathsep) @@ -234,16 +244,15 @@ def main(): # this awful mess just gets the real path of a file # (i.e. no symlinks, tildes or relative components) # this loop checks if the selected directory is in PATH - target_test_dir = os.path.realpath(os.path.abspath(os.path.expanduser(bin_path_targets[option]))) for entry in path_entries: entry_test_dir = os.path.realpath(os.path.abspath(os.path.expanduser(entry))) - if entry_test_dir == target_test_dir: + if entry_test_dir == bin_path_target_abs: has_found_current = True break # warn user if their installation dir is not in PATH if not has_found_current: - print(*textwrap.wrap("WARNING: Cannot find " + bin_path_targets[option] + + print(*textwrap.wrap("WARNING: Cannot find " + bin_path_target_abs + " in $PATH. This will not prevent installation, but may cause" " issues if running directly from the command line.", wrap_width), sep='\n') @@ -259,8 +268,8 @@ def main(): print("Step 2 - installing files...") print() - install_dir = install_targets[option] + sep + subdir_name - needs_root = not is_subdir("~", install_targets[option]) + install_dir = install_parent_target_abs + sep + subdir_name + needs_root = not is_subdir("~", install_parent_target_abs) if needs_root: print("The installer will now ask for superuser permissions.") From d818ebb5f25f99b11422cdc393db69fbaf422ae6 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Wed, 24 Dec 2025 23:10:25 -0500 Subject: [PATCH 059/118] visually fence pip output --- setup.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/setup.py b/setup.py index 644b8b7..0636189 100755 --- a/setup.py +++ b/setup.py @@ -320,8 +320,14 @@ def main(): print() try: + print() + print(" -------- pip output begins -------- ") + print() subprocess.run(["sudo", "-E", venv_folder + sep + "bin" + sep + "pip", "install", "-r", install_dir + sep + "requirements.txt"]) + print() + print(" -------- pip output ends -------- ") + print() except subprocess.CalledProcessError as e: print(f"ERROR: Failed to install dependencies! (exit code" f" {e.returncode}, message: {str(e)})") From 9c547d1807cd59dbd27fc67605749a0da9ee8d54 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Wed, 24 Dec 2025 23:18:39 -0500 Subject: [PATCH 060/118] extra status/formatting prints --- setup.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/setup.py b/setup.py index 0636189..c7fbdf1 100755 --- a/setup.py +++ b/setup.py @@ -252,6 +252,7 @@ def main(): # warn user if their installation dir is not in PATH if not has_found_current: + print() print(*textwrap.wrap("WARNING: Cannot find " + bin_path_target_abs + " in $PATH. This will not prevent installation, but may cause" " issues if running directly from the command line.", @@ -270,6 +271,7 @@ def main(): install_dir = install_parent_target_abs + sep + subdir_name needs_root = not is_subdir("~", install_parent_target_abs) + print("Starting copy...") if needs_root: print("The installer will now ask for superuser permissions.") @@ -291,6 +293,8 @@ def main(): os.stat(install_dir + sep + "main.py").st_mode | stat.S_IEXEC) os.chmod(install_dir + sep + "start.sh", os.stat(install_dir + sep + "start.sh").st_mode | stat.S_IEXEC) + print("Success!") + print() except subprocess.CalledProcessError as e: print(f"ERROR: Failed to copy files! (exit code {e.returncode}," f" message: {str(e)})") @@ -305,7 +309,11 @@ def main(): venv_folder = install_dir + sep + ".venv" try: + print("Creating...") + print() subprocess.run(["sudo", "-E", "virtualenv", venv_folder]) + print("Success!") + print() # cannot source it so it has to be explicitly called every time except subprocess.CalledProcessError as e: print(f"ERROR: Failed to create virtual environment! (exit code" @@ -320,6 +328,7 @@ def main(): print() try: + print("Installing dependencies from requirements.txt...") print() print(" -------- pip output begins -------- ") print() @@ -328,6 +337,8 @@ def main(): print() print(" -------- pip output ends -------- ") print() + print("Success!") + print() except subprocess.CalledProcessError as e: print(f"ERROR: Failed to install dependencies! (exit code" f" {e.returncode}, message: {str(e)})") From 94d01691d086c5a7b760249a2a5e637a63546710 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Wed, 24 Dec 2025 23:22:08 -0500 Subject: [PATCH 061/118] add symlinking code --- setup.py | 43 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index c7fbdf1..b9a1838 100755 --- a/setup.py +++ b/setup.py @@ -300,6 +300,48 @@ def main(): f" message: {str(e)})") print("Abort. ---") exit(1) + + # symlink bin and etc directories + + if install_parent_target_abs != bin_path_target_abs: + print("Linking installation...") + + # make links + # each link points from the base directory to the appropriate folder + # (just as an alias) + # + # secrets.json is specifically excluded because it shouldn't exist most + # of the time + # .env can also point somewhere else if required + if needs_root: + # bin + subprocess.run(["sudo", "-E", "ln", "-s", + install_dir + sep + "main.py", + bin_path_target_abs + sep + "dozermain"]) + subprocess.run(["sudo", "-E", "ln", "-s", + install_dir + sep + "start.sh", + bin_path_target_abs + sep + "dozerstart"]) + # etc + subprocess.run(["sudo", "-E", "ln", "-s", + install_dir + sep + ".env", + etc_path_target_abs + sep + "dozer.env"]) + else: + # bin + os.symlink(install_dir + sep + "main.py", + bin_path_target_abs + sep + "dozermain") + os.symlink(install_dir + sep + "start.sh", + bin_path_target_abs + sep + "dozerstart") + # etc + os.symlink(install_dir + sep + ".env", + etc_path_target_abs + sep + "dozer.env") + print(*textwrap.wrap( + f"Success! Executables and configuration may be also found at " + f"{bin_path_target_abs + sep + "dozermain"} (for main.py), " + f"{bin_path_target_abs + sep + "dozerstart"} (for start.sh), and " + f"{(etc_path_target_abs + sep + "dozer.env")} (for .env).", + wrap_width), sep='\n') + print() + # done # make venv @@ -345,7 +387,6 @@ def main(): print("Abort. ---") exit(1) - # TODO: symlink [.]local/bin, etc # TODO: make systemd files/log config if __name__ == "__main__": From c41d70e2721a916e44bbf6ac58ff48d8f731fb7f Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Wed, 24 Dec 2025 23:25:42 -0500 Subject: [PATCH 062/118] move strangely-placed prints --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index b9a1838..3f709b2 100755 --- a/setup.py +++ b/setup.py @@ -334,13 +334,13 @@ def main(): # etc os.symlink(install_dir + sep + ".env", etc_path_target_abs + sep + "dozer.env") + print() print(*textwrap.wrap( f"Success! Executables and configuration may be also found at " f"{bin_path_target_abs + sep + "dozermain"} (for main.py), " f"{bin_path_target_abs + sep + "dozerstart"} (for start.sh), and " f"{(etc_path_target_abs + sep + "dozer.env")} (for .env).", wrap_width), sep='\n') - print() # done @@ -354,8 +354,8 @@ def main(): print("Creating...") print() subprocess.run(["sudo", "-E", "virtualenv", venv_folder]) - print("Success!") print() + print("Success!") # cannot source it so it has to be explicitly called every time except subprocess.CalledProcessError as e: print(f"ERROR: Failed to create virtual environment! (exit code" From 8dfee537caf6e19536b7af23bcc0002190a2983a Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Wed, 24 Dec 2025 23:58:10 -0500 Subject: [PATCH 063/118] add systemd unit creation --- setup.py | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 3f709b2..0756e39 100755 --- a/setup.py +++ b/setup.py @@ -387,7 +387,74 @@ def main(): print("Abort. ---") exit(1) - # TODO: make systemd files/log config + print() + print("Step 5 - creating system service...") + print() + + # create a systemd file + if needs_root: + print("Creating as a system-wide systemd unit") + print("...") + # create a new unit file + start_sh_path = install_dir + sep + "start.sh" + service_tmp_path = "/tmp/dozer.service" + service_file_path = "/lib/systemd/system/dozer.service" + + # taken from nodejs version + service_file_contents = f""" + [Unit] + Description=Dozer discord bot + After=network.target + + [Service] + WorkingDirectory={install_dir} + ExecStart={start_sh_path} + Restart=always + RestartSec=3 + + [Install] + WantedBy=multi-user.target + """ + + # cat into a temp file then move with sudo + with open(service_tmp_path, "w") as f: + f.write(service_file_contents) + subprocess.run(["sudo", "mv", service_tmp_path, service_file_path]) + print("Enabling...") + subprocess.run(["sudo", "systemctl", "enable", "dozer.service"]) + + print("Success! Try systemctl start dozer.service") + else: + print("Creating as a user systemd unit") + print("...") + start_sh_path = install_dir + sep + "start.sh" + service_tmp_path = "/tmp/dozer.service" + service_file_path = os.path.expanduser("~/.config/systemd/user/dozer.service") + + service_file_contents = f""" + [Unit] + Description=Dozer discord bot + After=network.target + + [Service] + WorkingDirectory={install_dir} + ExecStart={start_sh_path} + Restart=always + RestartSec=3 + + [Install] + WantedBy=default.target + """ + + with open(service_tmp_path, "w") as f: + f.write(service_file_contents) + subprocess.run(["mv", service_tmp_path, service_file_path]) + print("Enabling...") + subprocess.run(["systemctl", "enable", "--user", "dozer.service"]) + + print("Success! Try systemctl --user start dozer.service") + + print("Successfully installed Dozer bot.") if __name__ == "__main__": main() \ No newline at end of file From bdbbdcb5b58e6e3f7377c6975163c3a82ad323a8 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 00:02:40 -0500 Subject: [PATCH 064/118] make parents of symlink directories if necessary --- setup.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/setup.py b/setup.py index 0756e39..3564cd3 100755 --- a/setup.py +++ b/setup.py @@ -14,6 +14,7 @@ # using user service. # Requires sudo privileges. import os +import pathlib import stat from os.path import sep import shutil @@ -314,6 +315,9 @@ def main(): # of the time # .env can also point somewhere else if required if needs_root: + # make sure symlinking directories exist + subprocess.run(["sudo", "mkdir", "-p", bin_path_target_abs]) + subprocess.run(["sudo", "mkdir", "-p", etc_path_target_abs]) # bin subprocess.run(["sudo", "-E", "ln", "-s", install_dir + sep + "main.py", @@ -326,6 +330,9 @@ def main(): install_dir + sep + ".env", etc_path_target_abs + sep + "dozer.env"]) else: + # same + pathlib.Path(bin_path_target_abs).mkdir(parents=True) + pathlib.Path(etc_path_target_abs).mkdir(parents=True) # bin os.symlink(install_dir + sep + "main.py", bin_path_target_abs + sep + "dozermain") From cd23578ca7605a07a540c53f2c63a7643bcaa7b9 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 00:32:55 -0500 Subject: [PATCH 065/118] add logging options --- setup.py | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/setup.py b/setup.py index 3564cd3..29b012d 100755 --- a/setup.py +++ b/setup.py @@ -398,6 +398,12 @@ def main(): print("Step 5 - creating system service...") print() + log_options_annotated = ["Sink (/dev/null)", "To ~/.cache/dozer.log", + "To /var/log/dozer.log", "To syslog"] + log_options = ["null", "cache", "varlog", "syslog", "default"] + log_option = log_options[choose_option("Where should log messages be sent?", + *log_options_annotated, default=3)] + # create a systemd file if needs_root: print("Creating as a system-wide systemd unit") @@ -407,6 +413,37 @@ def main(): service_tmp_path = "/tmp/dozer.service" service_file_path = "/lib/systemd/system/dozer.service" + log_data = "" + match log_option: + case "null": + log_data = """ + StandardOutput=/dev/null + StandardError=/dev/null + """ + print("WARNING: The `null` log output was selected. This can" + "make troubleshooting much more difficult.") + print() + case "cache": + log_data = f""" + StandardOutput={os.path.expanduser("~/.cache/dozer.log")} + StandardError={os.path.expanduser("~/.cache/dozer.log")} + """ + print("Note: It is recommended to set up a log rotation service" + " (like logrotate) to avoid having the log grow" + " uncontrollably.") + print() + case "varlog": + log_data = f""" + StandardOutput=/var/log/dozer.log + StandardError=/var/log/dozer.log + """ + print("Note: It is recommended to set up a log rotation service" + " (like logrotate) to avoid having the log grow" + " uncontrollably.") + print() + case "syslog": + pass # default, no action needed + # taken from nodejs version service_file_contents = f""" [Unit] @@ -418,6 +455,7 @@ def main(): ExecStart={start_sh_path} Restart=always RestartSec=3 + {log_data} [Install] WantedBy=multi-user.target @@ -448,6 +486,7 @@ def main(): ExecStart={start_sh_path} Restart=always RestartSec=3 + StandardOutput=/var/log/systemd/system/dozer.service [Install] WantedBy=default.target From 010e74b7a117bf2425f1f4cf13feb0c65eee0865 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 00:47:42 -0500 Subject: [PATCH 066/118] add extra notices at the end of the setup process --- setup.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/setup.py b/setup.py index 29b012d..c168eca 100755 --- a/setup.py +++ b/setup.py @@ -500,7 +500,14 @@ def main(): print("Success! Try systemctl --user start dozer.service") + print() print("Successfully installed Dozer bot.") + print() + print(*textwrap.wrap("Note: A keyring (a DBus Secret Service provider) is" + " required to run the bot with attendance features." + " Ensure one is installed before running the bot for" + " the first time.", wrap_width), sep='\n') + print("You will probably also need to manually import credentials.") if __name__ == "__main__": main() \ No newline at end of file From 858cbe61aa4a9458412656b62d611107699eb6b7 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 00:50:35 -0500 Subject: [PATCH 067/118] make logging actually do the thing it was supposed to do --- setup.py | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index c168eca..d2339b2 100755 --- a/setup.py +++ b/setup.py @@ -400,7 +400,11 @@ def main(): log_options_annotated = ["Sink (/dev/null)", "To ~/.cache/dozer.log", "To /var/log/dozer.log", "To syslog"] - log_options = ["null", "cache", "varlog", "syslog", "default"] + if not needs_root: + log_options_annotated.remove("To /var/log/dozer.log") + log_options = ["null", "cache", "varlog", "syslog"] + if not needs_root: + log_options.remove("varlog") log_option = log_options[choose_option("Where should log messages be sent?", *log_options_annotated, default=3)] @@ -476,6 +480,28 @@ def main(): service_tmp_path = "/tmp/dozer.service" service_file_path = os.path.expanduser("~/.config/systemd/user/dozer.service") + log_data = "" + match log_option: + case "null": + log_data = """ + StandardOutput=/dev/null + StandardError=/dev/null + """ + print("WARNING: The `null` log output was selected. This can" + "make troubleshooting much more difficult.") + print() + case "cache": + log_data = f""" + StandardOutput={os.path.expanduser("~/.cache/dozer.log")} + StandardError={os.path.expanduser("~/.cache/dozer.log")} + """ + print("Note: It is recommended to set up a log rotation service" + " (like logrotate) to avoid having the log grow" + " uncontrollably.") + print() + case "syslog": + pass # default, no action needed + service_file_contents = f""" [Unit] Description=Dozer discord bot @@ -486,7 +512,7 @@ def main(): ExecStart={start_sh_path} Restart=always RestartSec=3 - StandardOutput=/var/log/systemd/system/dozer.service + {log_data} [Install] WantedBy=default.target From 59e0f9dee5f5a4be7dca153829b2c504a8f74823 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 18:24:54 -0500 Subject: [PATCH 068/118] add import subcommand --- setup.py | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/setup.py b/setup.py index d2339b2..65e50cb 100755 --- a/setup.py +++ b/setup.py @@ -533,7 +533,92 @@ def main(): " required to run the bot with attendance features." " Ensure one is installed before running the bot for" " the first time.", wrap_width), sep='\n') + print() print("You will probably also need to manually import credentials.") + print("For that, run setup.py with the subcommand 'import'.") + +def setup_import(): + print() + print(" === Dozer Setup ===") + print("Version 1.0.0") + print("Task: Import secrets") + print() + + # import systemd secrets + + print("Where is secrets.json located?") + print() + while True: + secrets_path = input("Enter a path: ") + + if not os.path.exists(secrets_path): + print(f"Couldn't find secrets file at {secrets_path}, try again.") + print() + continue + if not os.path.isfile(secrets_path): + print(f"{secrets_path} path is not a file, try again.") + print() + continue + + break + + cred_locations = ["Into keyring", "Into the unit file (encrypted)", "Cancel"] + cred_location = cred_locations[choose_option("How should the" + " credentials be imported?", + *cred_locations, default=1)] + + if cred_location == "Cancel": + print("Abort. --- ") + sys.exit(0) + + is_systemd = False if cred_location == "Into keyring" else True + is_system = False # early assignment for the print + + if is_systemd: + install_paths = ["/usr/local/share/dozerbot", "/opt/dozerbot", "~/.local/share/dozerbot"] + install_path = install_paths[choose_option("Where is the bot installed? ")] + + is_system = not install_path.startswith("~") + unit_file_conf_path = os.path.expanduser("~/.config/systemd/user/dozer.service.d/")\ + if not is_system else "/etc/systemd/system/dozer.service.d/" + + print(f"Using {"system" if is_system else "user"} service's unit file") + print() + print("Where is the secrets file located?") + secrets_path = input("Enter a path: ") + + # encrypt + print("The script will now ask for superuser (required for encryption).") + out = subprocess.check_call(["sudo", "systemd-creds", "encrypt", "-p", + "--name=service_auth", secrets_path, "-"]) + + # write to conf + if not is_system: + # can use native functions + pathlib.Path(unit_file_conf_path).mkdir(parents=True, exist_ok=True) + with open(unit_file_conf_path + "10-creds.conf", "w") as f2: + f2.write( + f""" + [Service] + {out} + """) + else: + subprocess.run(["sudo", "mkdir", "-p", unit_file_conf_path]) + with open("/tmp/10-creds.conf", "w") as f2: + f2.write( + f""" + [Service] + {out} + """) + subprocess.run(["sudo", "mv", "/tmp/10-creds.conf", + unit_file_conf_path + "10-creds.conf"]) + + print() + if is_systemd: + print(f"Successfully imported secrets! Try systemctl" + f" {"--user" if not is_system else ""} restart dozer.service") + else: + print("Successfully imported secrets!") if __name__ == "__main__": main() \ No newline at end of file From 40f2637babd4aec5e46d5c00c65ec68c92c550c4 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 18:25:20 -0500 Subject: [PATCH 069/118] docs: add (now untested) support for systemd-creds --- README.md | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8772523..434a918 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,29 @@ This version of the bot also includes attendance features. This implementation uses a Google Sheets spreadsheet for storage. # Setting up + +## Installation +The project comes with an installer script, `setup.py`. After cloning the +repository, one can simply: +```shell +python3 setup.py install +``` +and quickly install the project, with a guided flow. + +The script is intended to be run on `systemd`-based Linux distributions. + +It supports multiple installation targets, but the defaults should work best +for most applications. It will take care of generating a virtualenv environment, +dependencies and creating a systemd service. + +After installing, one can also: +```shell +python3 setup.py import +``` +to import Google API keys. This also bypasses the need for a keyring. + +If using the setup script, skip **§ Dependencies and environment**. + ## Dependencies and environment The project requires multiple dependencies, including: * `discord` @@ -64,6 +87,9 @@ plaintext. Instead they are stored in a keyring (Windows Credential Manager, Gnome Secret Service or similar, or macOS's offering), where they are encrypted at rest. +> *Note that this only applies if `systemd-creds` (from the setup script) is not +> used.* + A keyring must be installed and unlocked for this app to function. If * you are using **macOS or Windows**: you already have a keyring * you are running a **mainstream desktop flavour of Linux**: you probably have @@ -76,6 +102,11 @@ A keyring must be installed and unlocked for this app to function. If and see if it exists. The agent must implement the Secret Service, thus GNOME keyring or KDE wallet should work for most situations. +If you are running a version of Linux and using the installer script to run the +app, you can also store the secrets using `systemd-creds`. Run the installer +with the subcommand `import` – it will add the encrypted credentials directly +into the `systemd` service file. In this case, no further action is needed. + ## Discord tokens The bot requires a Discord API token and a guild ID for instant sync. Both are @@ -141,14 +172,24 @@ To set it up: 5. **Download this key** to a safe place on a trusted client. **Do not compromise this key**. Always keep an encrypted copy on hand - not plaintext, and not on the server. + +*** + +**If using a plain install (no setup.py)**: + 6. **Transfer the key** (in plaintext) to the server. Move it to the bot's project root and name it `secrets.json`. -7. (Optional) **Add the following line** to `.env` if the secrets file has a + + (Optional) **Add the following line** to `.env` if the secrets file has a different path: ```dotenv secrets_json_path=/path/to/secrets-file.json ``` -8. **Run `main.py`** with the argument `--import-secrets`. +7. **Run `main.py`** with the argument `--import-secrets`. +8. **Securely delete** the plaintext key, as it is no longer needed: + ```shell + shred -u /path/to/secrets.json + ``` 9. **Add the following lines** to `.env`: ```dotenv # service account just created @@ -157,6 +198,20 @@ To set it up: # service account allowable_owner=johndoe@example.test ``` + +**If using setup.py and systemd-creds**: + +6. **Transfer the key** (in plaintext) to the server. Move it somewhere + convenient, like `/home/user/secrets.json` +7. **Run setup.py** with the subcommand `import`. Choose `systemd-creds`. +8. **Restart the dozer service**. +9. **Securely delete** the plaintext key, as it is no longer needed: + ```shell + shred -u /path/to/secrets.json + ``` + +*** + 10. Using the `allowable_owner`'s associated Google account, **create a spreadsheet**. Name it anything, but add the suffix "[attendance-bot]" to the end. Avoid editing this sheet as the app stores metadata and state in From daf0c04d37ecc2f12c59aab9879f6e99e114458e Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 18:25:41 -0500 Subject: [PATCH 070/118] add (now untested) support for systemd-creds --- SheetsManager.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/SheetsManager.py b/SheetsManager.py index 139caac..6056768 100644 --- a/SheetsManager.py +++ b/SheetsManager.py @@ -51,8 +51,16 @@ def unlock_token(self): `static_token`. :return: """ - raw_data = keyring.get_keyring().get_credential("dozer_service_secrets", "service_auth").password - self.static_token = service_account.Credentials.from_service_account_info(json.loads(raw_data), scopes=self.CONST_SCOPES) + try: + raw_data = keyring.get_keyring().get_credential("dozer_service_secrets", "service_auth").password + self.static_token = service_account.Credentials.from_service_account_info(json.loads(raw_data), scopes=self.CONST_SCOPES) + except keyring.errors.InitError as e: + # use systemd-creds + creds_folder = os.getenv("CREDENTIALS_DIRECTORY") + if creds_folder is None: + raise e + raw_data = open(creds_folder + os.path.sep + "service_auth").read() + self.static_token = service_account.Credentials.from_service_account_info(json.loads(raw_data), scopes=self.CONST_SCOPES) def _lock_token(self, data: str): """ From b6470e8aabca1f2c139358fd2d71f17a2475cd79 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 18:26:02 -0500 Subject: [PATCH 071/118] add argument parsing/subcommand logic --- setup.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 65e50cb..647e02e 100755 --- a/setup.py +++ b/setup.py @@ -621,4 +621,28 @@ def setup_import(): print("Successfully imported secrets!") if __name__ == "__main__": - main() \ No newline at end of file + if len(sys.argv) < 2: + print("Missing subcommand.") + setup_help() + sys.exit(1) + if len(sys.argv) > 2: + print("Too many arguments.") + setup_help() + sys.exit(1) + + match sys.argv[1]: + case "import": + setup_import() + sys.exit(0) + case "install": + setup_install() + sys.exit(0) + case "help": + print(" === Dozer Setup === ") + print("Version 1.0.0") + print("Task: Help") + setup_help() + case _: + print("Unknown command.") + setup_help() + sys.exit(1) \ No newline at end of file From 47092d157b916bedcf608cca36f188884e56e83a Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 18:26:22 -0500 Subject: [PATCH 072/118] TODO: add uninstall command --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 647e02e..b340c75 100755 --- a/setup.py +++ b/setup.py @@ -637,6 +637,7 @@ def setup_import(): case "install": setup_install() sys.exit(0) + # TODO: add uninstall command case "help": print(" === Dozer Setup === ") print("Version 1.0.0") From 7b741bcca637fddbeb72570aee2d996c8a8577f4 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 18:26:53 -0500 Subject: [PATCH 073/118] add help subcommand --- setup.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b340c75..044dc40 100755 --- a/setup.py +++ b/setup.py @@ -184,7 +184,16 @@ def is_subdir(parent: str | os.PathLike, child: str | os.PathLike) -> bool: child_real = os.path.realpath(os.path.abspath(os.path.expanduser(child))) return parent_real == os.path.commonpath([parent_real, child_real]) -def main(): +def setup_help(): + print() + print("Usage: python setup.py ") + print() + print("Available commands:") + print(" - install: install the bot (guided)") + print(" - import: import credentials from a secrets file (guided)") + print(" - help: show this help") + +def setup_install(): """ Main routine that handles all functions. :return: nothing From f31be9a71bda8c84a47c1d98e7135d065b7d9e9d Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 18:27:02 -0500 Subject: [PATCH 074/118] minor string changes --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 044dc40..9c0296a 100755 --- a/setup.py +++ b/setup.py @@ -202,8 +202,9 @@ def setup_install(): subdir_name = "dozerbot" wrap_width = 80 - print(" === Dozer Installer === ") + print(" === Dozer Setup === ") print("Version 1.0.0") + print("Task: Install") print() print("Step 1 - gathering information...") print() From d90d02bc6b58586fc2ffb945eb7ed3c6f1b1edc2 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 18:27:17 -0500 Subject: [PATCH 075/118] fix exit calls --- setup.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 9c0296a..319f494 100755 --- a/setup.py +++ b/setup.py @@ -310,7 +310,7 @@ def setup_install(): print(f"ERROR: Failed to copy files! (exit code {e.returncode}," f" message: {str(e)})") print("Abort. ---") - exit(1) + sys.exit(1) # symlink bin and etc directories @@ -378,7 +378,7 @@ def setup_install(): print(f"ERROR: Failed to create virtual environment! (exit code" f" {e.returncode}, message: {str(e)})") print("Abort. ---") - exit(1) + sys.exit(1) # done # get pip deps @@ -402,7 +402,7 @@ def setup_install(): print(f"ERROR: Failed to install dependencies! (exit code" f" {e.returncode}, message: {str(e)})") print("Abort. ---") - exit(1) + sys.exit(1) print() print("Step 5 - creating system service...") From 354beb6a487d393e81b677eaa380843af23e8f0f Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 18:39:12 -0500 Subject: [PATCH 076/118] fix incorrect cp command for user mode --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 319f494..a2847a2 100755 --- a/setup.py +++ b/setup.py @@ -299,7 +299,7 @@ def setup_install(): else: # but with 100% less sudo subprocess.run(["mkdir", "-p", install_dir]) - subprocess.run(["cp", ".", install_dir]) + subprocess.run(["cp", "-r", ".", install_dir]) os.chmod(install_dir + sep + "main.py", os.stat(install_dir + sep + "main.py").st_mode | stat.S_IEXEC) os.chmod(install_dir + sep + "start.sh", From 90b5c56fc6f26a86d276f17cc77e7c2bd708dc42 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 18:40:25 -0500 Subject: [PATCH 077/118] touch .env --- setup.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/setup.py b/setup.py index a2847a2..2667073 100755 --- a/setup.py +++ b/setup.py @@ -296,6 +296,8 @@ def setup_install(): # and make sure anyone can execute subprocess.run(["sudo", "chmod", "+x", install_dir + sep + "main.py"]) subprocess.run(["sudo", "chmod", "+x", install_dir + sep + "start.sh"]) + # make sure .env exists + subprocess.run(["sudo", "touch", install_dir + sep + ".env"]) else: # but with 100% less sudo subprocess.run(["mkdir", "-p", install_dir]) @@ -304,6 +306,8 @@ def setup_install(): os.stat(install_dir + sep + "main.py").st_mode | stat.S_IEXEC) os.chmod(install_dir + sep + "start.sh", os.stat(install_dir + sep + "start.sh").st_mode | stat.S_IEXEC) + # make sure .env exists + pathlib.Path(install_dir + sep + ".env").touch(exist_ok=True) print("Success!") print() except subprocess.CalledProcessError as e: From f454bbc94cf6ae4bdc072f428070f58c4d689c6b Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 18:45:51 -0500 Subject: [PATCH 078/118] duplicate secrets entry? --- setup.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/setup.py b/setup.py index 2667073..6c481c3 100755 --- a/setup.py +++ b/setup.py @@ -597,9 +597,6 @@ def setup_import(): if not is_system else "/etc/systemd/system/dozer.service.d/" print(f"Using {"system" if is_system else "user"} service's unit file") - print() - print("Where is the secrets file located?") - secrets_path = input("Enter a path: ") # encrypt print("The script will now ask for superuser (required for encryption).") From 0c7c391a33b820cfe684652f2dd53d4f4c223156 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 18:46:27 -0500 Subject: [PATCH 079/118] oops (missing argument) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 6c481c3..ef13ae2 100755 --- a/setup.py +++ b/setup.py @@ -590,7 +590,7 @@ def setup_import(): if is_systemd: install_paths = ["/usr/local/share/dozerbot", "/opt/dozerbot", "~/.local/share/dozerbot"] - install_path = install_paths[choose_option("Where is the bot installed? ")] + install_path = install_paths[choose_option("Where is the bot installed? ", *install_paths)] is_system = not install_path.startswith("~") unit_file_conf_path = os.path.expanduser("~/.config/systemd/user/dozer.service.d/")\ From 085baad35b40fc283537f04dd586b65b6ba3b9bc Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 18:47:28 -0500 Subject: [PATCH 080/118] make choose_option throw on missing options --- setup.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/setup.py b/setup.py index ef13ae2..5b54c41 100755 --- a/setup.py +++ b/setup.py @@ -36,6 +36,9 @@ def choose_option(message: str, *options: str, default: int | None = None) -> in :return: the index of the option that was chosen """ + if options is None or len(options) == 0: + raise ValueError("choose_option needs a list of options. This is a bug.") + wrap_col = 80 # regularize to non-None int From f5a823be8a1c2e9fe714e193485f38b5ce0b034e Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 18:51:14 -0500 Subject: [PATCH 081/118] check option validity for installation directory --- setup.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 5b54c41..71dbf52 100755 --- a/setup.py +++ b/setup.py @@ -592,12 +592,26 @@ def setup_import(): is_system = False # early assignment for the print if is_systemd: - install_paths = ["/usr/local/share/dozerbot", "/opt/dozerbot", "~/.local/share/dozerbot"] - install_path = install_paths[choose_option("Where is the bot installed? ", *install_paths)] + while True: + install_paths = ["/usr/local/share/dozerbot", "/opt/dozerbot", "~/.local/share/dozerbot"] + install_path = install_paths[choose_option("Where is the bot installed? ", *install_paths)] + + if not os.path.exists(install_path): + print(f"{install_path} does not exist, try again.") + continue + + break is_system = not install_path.startswith("~") unit_file_conf_path = os.path.expanduser("~/.config/systemd/user/dozer.service.d/")\ if not is_system else "/etc/systemd/system/dozer.service.d/" + unit_file_path = os.path.expanduser("~/.config/systemd/user/dozer.service")\ + if not is_system else "/etc/systemd/system/dozer.service" + + if not os.path.exists(unit_file_path): + print(f"ERROR: {unit_file_path} does not exist.") + print("Abort. --- ") + sys.exit(1) print(f"Using {"system" if is_system else "user"} service's unit file") From 49281c3e97f878b85bfeefb8e46bbdc3bbb2626e Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 19:03:00 -0500 Subject: [PATCH 082/118] make parents if user unit folder does not exist --- setup.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 71dbf52..8557bd7 100755 --- a/setup.py +++ b/setup.py @@ -495,7 +495,8 @@ def setup_install(): print("...") start_sh_path = install_dir + sep + "start.sh" service_tmp_path = "/tmp/dozer.service" - service_file_path = os.path.expanduser("~/.config/systemd/user/dozer.service") + service_dir_path = os.path.expanduser("~/.config/systemd/user/") + service_file_path = service_dir_path + "dozer.service" log_data = "" match log_option: @@ -537,6 +538,7 @@ def setup_install(): with open(service_tmp_path, "w") as f: f.write(service_file_contents) + subprocess.run(["mkdir", "-p", service_dir_path]) subprocess.run(["mv", service_tmp_path, service_file_path]) print("Enabling...") subprocess.run(["systemctl", "enable", "--user", "dozer.service"]) From e3b5aeb1728421e7f3e90e8fd2e19000816eb0e0 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 19:06:42 -0500 Subject: [PATCH 083/118] correct systemd unit path --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 8557bd7..5993c4c 100755 --- a/setup.py +++ b/setup.py @@ -432,7 +432,7 @@ def setup_install(): # create a new unit file start_sh_path = install_dir + sep + "start.sh" service_tmp_path = "/tmp/dozer.service" - service_file_path = "/lib/systemd/system/dozer.service" + service_file_path = "/etc/systemd/system/dozer.service" log_data = "" match log_option: From 72dbb63f14b1917e16dba8035883bef828a2355d Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 20:37:46 -0500 Subject: [PATCH 084/118] bug: also catch NoKeyringError --- SheetsManager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SheetsManager.py b/SheetsManager.py index 6056768..6bd662d 100644 --- a/SheetsManager.py +++ b/SheetsManager.py @@ -54,7 +54,7 @@ def unlock_token(self): try: raw_data = keyring.get_keyring().get_credential("dozer_service_secrets", "service_auth").password self.static_token = service_account.Credentials.from_service_account_info(json.loads(raw_data), scopes=self.CONST_SCOPES) - except keyring.errors.InitError as e: + except keyring.errors.InitError | keyring.errors.NoKeyringError as e: # use systemd-creds creds_folder = os.getenv("CREDENTIALS_DIRECTORY") if creds_folder is None: From 78cc9531252b072db1690373f04def6b023d6dd2 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 20:37:46 -0500 Subject: [PATCH 085/118] bug: also catch NoKeyringError --- SheetsManager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SheetsManager.py b/SheetsManager.py index 6056768..ba92a5b 100644 --- a/SheetsManager.py +++ b/SheetsManager.py @@ -54,7 +54,7 @@ def unlock_token(self): try: raw_data = keyring.get_keyring().get_credential("dozer_service_secrets", "service_auth").password self.static_token = service_account.Credentials.from_service_account_info(json.loads(raw_data), scopes=self.CONST_SCOPES) - except keyring.errors.InitError as e: + except (keyring.errors.InitError, keyring.errors.NoKeyringError) as e: # use systemd-creds creds_folder = os.getenv("CREDENTIALS_DIRECTORY") if creds_folder is None: From 9c96b0327aa61fc401d0044ee6d492277b2bb2b6 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 20:49:07 -0500 Subject: [PATCH 086/118] fix check output call --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 5993c4c..c98bb58 100755 --- a/setup.py +++ b/setup.py @@ -619,7 +619,7 @@ def setup_import(): # encrypt print("The script will now ask for superuser (required for encryption).") - out = subprocess.check_call(["sudo", "systemd-creds", "encrypt", "-p", + out = subprocess.check_output(["sudo", "systemd-creds", "encrypt", "-p", "--name=service_auth", secrets_path, "-"]) # write to conf From 9f482f39de3f19e72d188df271a3e64a76626553 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 20:49:51 -0500 Subject: [PATCH 087/118] fix wonky service file formatting --- setup.py | 76 +++++++++++++++++++++++++++----------------------------- 1 file changed, 36 insertions(+), 40 deletions(-) diff --git a/setup.py b/setup.py index c98bb58..decda25 100755 --- a/setup.py +++ b/setup.py @@ -466,21 +466,23 @@ def setup_install(): pass # default, no action needed # taken from nodejs version - service_file_contents = f""" - [Unit] - Description=Dozer discord bot - After=network.target - - [Service] - WorkingDirectory={install_dir} - ExecStart={start_sh_path} - Restart=always - RestartSec=3 - {log_data} - - [Install] - WantedBy=multi-user.target - """ + # bad formatting here but looks much nicer in situ + service_file_contents = \ +f""" +[Unit] +Description=Dozer discord bot +After=network.target + +[Service] +WorkingDirectory={install_dir} +ExecStart={start_sh_path} +Restart=always +RestartSec=3 +{log_data} + +[Install] +WantedBy=multi-user.target +""" # cat into a temp file then move with sudo with open(service_tmp_path, "w") as f: @@ -520,21 +522,23 @@ def setup_install(): case "syslog": pass # default, no action needed - service_file_contents = f""" - [Unit] - Description=Dozer discord bot - After=network.target - - [Service] - WorkingDirectory={install_dir} - ExecStart={start_sh_path} - Restart=always - RestartSec=3 - {log_data} - - [Install] - WantedBy=default.target - """ + # bad formatting here but looks much nicer in situ + service_file_contents = \ +f""" +[Unit] +Description=Dozer discord bot +After=network.target + +[Service] +WorkingDirectory={install_dir} +ExecStart={start_sh_path} +Restart=always +RestartSec=3 +{log_data} + +[Install] +WantedBy=default.target +""" with open(service_tmp_path, "w") as f: f.write(service_file_contents) @@ -627,19 +631,11 @@ def setup_import(): # can use native functions pathlib.Path(unit_file_conf_path).mkdir(parents=True, exist_ok=True) with open(unit_file_conf_path + "10-creds.conf", "w") as f2: - f2.write( - f""" - [Service] - {out} - """) + f2.write(f"[Service]\n{out}\n") else: subprocess.run(["sudo", "mkdir", "-p", unit_file_conf_path]) with open("/tmp/10-creds.conf", "w") as f2: - f2.write( - f""" - [Service] - {out} - """) + f2.write(f"[Service]\n{out}\n") subprocess.run(["sudo", "mv", "/tmp/10-creds.conf", unit_file_conf_path + "10-creds.conf"]) From 8d83f94ab09030160946b77ba11dad32b0ba19ad Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 21:07:53 -0500 Subject: [PATCH 088/118] add sample .env file --- README.md | 2 ++ sample.env | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 sample.env diff --git a/README.md b/README.md index 434a918..4d2a6c2 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,8 @@ key=value multi_word_key="extra long entry with spaces!" ``` +A sample is provided in this project under `sample.env`. + Make sure to remove trailing whitespace. If the `.env` file must be located in a different place (i.e. /run/secrets), diff --git a/sample.env b/sample.env new file mode 100644 index 0000000..e46feae --- /dev/null +++ b/sample.env @@ -0,0 +1,54 @@ +# ========================== +# | Environment file | +# ========================== +# This is a template .env file. +# Each field is filled with example data. The README has instructions on what +# information is needed for each field. +# +# To use in production, rename it to '.env'. The real .env should never be +# checked into version control. +# +# If the .env file is located somewhere else, you can set +# DOZER_DOTENV_LOCATION to an arbitrary path: +# DOZER_DOTENV_PATH=/path/to/.env +# It is recommended to keep the file name as .env to prevent accidental +# commits. + +# == Discord information == +# +# Discord token - see instructions in README. +# Sensitive information (anyone with the token can control the bot). +token=A.Discord.Token +# Production guild (server) ID - with developer mode on, you can copy this by +# right-clicking on any server you are in and clicking "Copy Server ID". +# Make sure that the server you choose has the bot authorized (see README). +# Not sensitive. +guild_id=12345678 +# Development guild. Commands are synced to both guilds, but a separate server +# can be used for convenience in testing. +# Leave blank if unused. +# Not sensitive. +dev_guild_id=87654321 + +# == Google information == +# +# "Trusted account", which means the account that is supposed to own the sheet. +# This is used to prevent some random person from sharing an unrelated sheet +# and hijacking the data. +# Sensitivity varies depending on how sensitive you consider your email to be. +allowable_owner=johndoe@example.test +# The service account responsible for editing the spreadsheet. This email +# should correspond to the secret token. The account referenced by the +# allowable_owner also needs to share the spreadsheet with this account. +# Instructions on creating a GCP project and service account are in the README. +# Not very sensitive, but don't go posting it around everywhere. +service_account=service-account@foo.example +# The path to the service account's JSON token. +# Generating a key is covered in the README. +# By default, the app searches in its current working directory for a file +# named "secrets.json". +# After the key has been imported, the path is unused. +# The path is not sensitive, but the file is. +# Do not store the token in plaintext any more than is required, i.e. delete +# the server's copy after importing, and keep an encrypted copy elsewhere +secrets_json_path=/path/to/secrets-file.json From df3712f3759c9f77166902e6dca7451c6aeec270 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 21:11:04 -0500 Subject: [PATCH 089/118] REALLY fix check output call (for systemd-creds encrypt) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index decda25..3289959 100755 --- a/setup.py +++ b/setup.py @@ -624,7 +624,7 @@ def setup_import(): # encrypt print("The script will now ask for superuser (required for encryption).") out = subprocess.check_output(["sudo", "systemd-creds", "encrypt", "-p", - "--name=service_auth", secrets_path, "-"]) + "--name=service_auth", secrets_path, "-"], text=True) # write to conf if not is_system: From b4f2cb30c7954f74af4a100faa4ae4ebd29676ef Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 21:13:38 -0500 Subject: [PATCH 090/118] minor doc change (sample.env) --- sample.env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sample.env b/sample.env index e46feae..12d8cd0 100644 --- a/sample.env +++ b/sample.env @@ -42,7 +42,7 @@ allowable_owner=johndoe@example.test # allowable_owner also needs to share the spreadsheet with this account. # Instructions on creating a GCP project and service account are in the README. # Not very sensitive, but don't go posting it around everywhere. -service_account=service-account@foo.example +service_account=example@foo.iam.gserviceaccount.com # The path to the service account's JSON token. # Generating a key is covered in the README. # By default, the app searches in its current working directory for a file From 6a1d80a218d4a7962b8e1555272a13b39690f9ed Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 21:19:24 -0500 Subject: [PATCH 091/118] add a check to make sure .env is complete before attempting to launch (makes diagnosis easier) --- main.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index 1f18047..ee13a46 100644 --- a/main.py +++ b/main.py @@ -68,11 +68,27 @@ async def load_extensions(): # search for secrets and import them into the keyring manager = SheetsManager.SheetManager() manager.import_secrets() - print("Success! Make sure to never leave the keys in plaintext (keep an encrypted copy on another machine).") + print("Success! Make sure to never leave the keys in plaintext" + " (keep an encrypted copy on another machine).") sys.exit(0) case _: # default print(f"Unrecognized argument {arg}") + # quickly check dotenv entries + if not os.getenv("token"): + print("No Discord token provided. Make sure .env is completely filled in.") + sys.exit(1) + if not os.getenv("guild_id") and not os.getenv("dev_guild_id"): + print("No guild ID provided. Make sure .env is completely filled in.") + sys.exit(1) + if not disable_attendance: + if not os.getenv("allowable_owner"): + print("No allowable owner provided. Make sure .env is completely filled in.") + sys.exit(1) + if not os.getenv("service_account"): + print("No GCP service account provided. Make sure .env is completely filled in.") + sys.exit(1) + import asyncio asyncio.run(load_extensions()) From 23f3a2369b100b55bb10495fec6499f226d0f70c Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 21:38:28 -0500 Subject: [PATCH 092/118] less journalctl spam pls --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 3289959..d3a613e 100755 --- a/setup.py +++ b/setup.py @@ -477,7 +477,7 @@ def setup_install(): WorkingDirectory={install_dir} ExecStart={start_sh_path} Restart=always -RestartSec=3 +RestartSec=10 {log_data} [Install] @@ -533,7 +533,7 @@ def setup_install(): WorkingDirectory={install_dir} ExecStart={start_sh_path} Restart=always -RestartSec=3 +RestartSec=10 {log_data} [Install] From 547317725757587a76150892a473463922b3f033 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 22:09:24 -0500 Subject: [PATCH 093/118] more unit formatting fixes --- setup.py | 45 +++++++++++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/setup.py b/setup.py index d3a613e..7760ce7 100755 --- a/setup.py +++ b/setup.py @@ -437,27 +437,30 @@ def setup_install(): log_data = "" match log_option: case "null": - log_data = """ - StandardOutput=/dev/null - StandardError=/dev/null - """ + log_data = \ +""" +StandardOutput=/dev/null +StandardError=/dev/null +""" print("WARNING: The `null` log output was selected. This can" "make troubleshooting much more difficult.") print() case "cache": - log_data = f""" - StandardOutput={os.path.expanduser("~/.cache/dozer.log")} - StandardError={os.path.expanduser("~/.cache/dozer.log")} - """ + log_data = \ +f""" +StandardOutput={os.path.expanduser("~/.cache/dozer.log")} +StandardError={os.path.expanduser("~/.cache/dozer.log")} +""" print("Note: It is recommended to set up a log rotation service" " (like logrotate) to avoid having the log grow" " uncontrollably.") print() case "varlog": - log_data = f""" - StandardOutput=/var/log/dozer.log - StandardError=/var/log/dozer.log - """ + log_data = \ +f""" +StandardOutput=/var/log/dozer.log +StandardError=/var/log/dozer.log +""" print("Note: It is recommended to set up a log rotation service" " (like logrotate) to avoid having the log grow" " uncontrollably.") @@ -503,18 +506,20 @@ def setup_install(): log_data = "" match log_option: case "null": - log_data = """ - StandardOutput=/dev/null - StandardError=/dev/null - """ + log_data = \ +""" +StandardOutput=/dev/null +StandardError=/dev/null +""" print("WARNING: The `null` log output was selected. This can" "make troubleshooting much more difficult.") print() case "cache": - log_data = f""" - StandardOutput={os.path.expanduser("~/.cache/dozer.log")} - StandardError={os.path.expanduser("~/.cache/dozer.log")} - """ + log_data = \ +f""" +StandardOutput={os.path.expanduser("~/.cache/dozer.log")} +StandardError={os.path.expanduser("~/.cache/dozer.log")} +""" print("Note: It is recommended to set up a log rotation service" " (like logrotate) to avoid having the log grow" " uncontrollably.") From 5fc36aee7bbfb3fd80b3a981fec703315b42726b Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 22:10:01 -0500 Subject: [PATCH 094/118] missing space --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 7760ce7..33da608 100755 --- a/setup.py +++ b/setup.py @@ -443,7 +443,7 @@ def setup_install(): StandardError=/dev/null """ print("WARNING: The `null` log output was selected. This can" - "make troubleshooting much more difficult.") + " make troubleshooting much more difficult.") print() case "cache": log_data = \ From 925ce0fb5b9bff8ca48a71ea85d9beeb109fc40d Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 22:15:26 -0500 Subject: [PATCH 095/118] change confirm's sig: make default_state kw-only and optional --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 33da608..b4ec01c 100755 --- a/setup.py +++ b/setup.py @@ -125,7 +125,7 @@ def choose_option(message: str, *options: str, default: int | None = None) -> in assert False, "Unreachable state" -def confirm(message: str, default_state: bool | None) -> bool: +def confirm(message: str, *, default_state: bool | None = None) -> bool: """ Presents the user with a confirmation message, which they can respond to in various ways. From 45fdf523e80eba0a50489177498d4a2c0d24940f Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 22:15:50 -0500 Subject: [PATCH 096/118] add uninstallation instructions (setup command to come) --- README.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/README.md b/README.md index 4d2a6c2..0e4057b 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,58 @@ The bot takes the following command-line parameter(s): Launch `main.py` with `python3` to run the app. +# Uninstallation +If using a simple (manual) installation, nothing is needed. The bot does not +install files outside of its directory, unless `setup.py was used`. Note that +some keyring remnants may remain; those can be removed without harm (as long as +they are not lost permanently). + +If using `setup.py`, run: +```shell +python3 setup.py uninstall +``` +... which will do everything automatically. Keyring entries will remain as +above. Make sure that secrets in `.env` are not permanently lost. + +> **Note**: Make sure there is another copy of the tokens in `.env`, because +> they might be permanently lost after uninstallation. The uninstaller will +> give you an option to copy it to a directory of your choice. + +## Manual uninstallation with `setup.py` +If the setup script cannot uninstall the bot automatically, it can be removed +manually. + +> **WARNING**: This set of instructions will permanently delete your `.env` +> file, which could mean your Discord token is lost. Make sure a copy is stored +> somewhere else. + +1. **Locate the installation directory**. If you forget, look in the following + folders for a directory named 'dozerbot': + * `/usr/local/share` + * `/opt` + * `~/.local/share` +2. **Delete the folder**. +3. **If using** either `/usr/local/share` or `~/.local/share`, remove the + broken symlinks. + + The files `main.py`, `start.sh` and `.env` are linked into `[...]/local/bin` + and `etc` as `bin/dozermain`, `bin/dozerstart` and `etc/dozer.env`. Unlink + those with `unlink`. +4. **Delete the `systemd` units and configuration**, with: + ```shell + systemctl [--user] disable dozer.service + ``` + Use `--user` if the bot was installed into the home directory. + + Then, **delete**: + * `/dozer.service` + * `/dozer.service.d/` (the whole folder, if it exists) + + ...where `` is `~/.config/systemd/user` if installed into the home + directory, or `/etc/systemd/system` otherwise (most cases). +5. (Optional) **Clean the logs**. If set up to use a logfile, you can delete it + from `~/.config/dozer.log` or `/var/log/dozer.log` (alongside any logrotate artifacts). + # Troubleshooting * **If the bot throws an error about a locked keyring**, exit it and unlock the keyring before trying again. From c1789034af16db3dc2482be272fb89b376b015ba Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 22:15:50 -0500 Subject: [PATCH 097/118] whitespace --- README.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++ SheetsManager.py | 2 -- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4d2a6c2..0e4057b 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,58 @@ The bot takes the following command-line parameter(s): Launch `main.py` with `python3` to run the app. +# Uninstallation +If using a simple (manual) installation, nothing is needed. The bot does not +install files outside of its directory, unless `setup.py was used`. Note that +some keyring remnants may remain; those can be removed without harm (as long as +they are not lost permanently). + +If using `setup.py`, run: +```shell +python3 setup.py uninstall +``` +... which will do everything automatically. Keyring entries will remain as +above. Make sure that secrets in `.env` are not permanently lost. + +> **Note**: Make sure there is another copy of the tokens in `.env`, because +> they might be permanently lost after uninstallation. The uninstaller will +> give you an option to copy it to a directory of your choice. + +## Manual uninstallation with `setup.py` +If the setup script cannot uninstall the bot automatically, it can be removed +manually. + +> **WARNING**: This set of instructions will permanently delete your `.env` +> file, which could mean your Discord token is lost. Make sure a copy is stored +> somewhere else. + +1. **Locate the installation directory**. If you forget, look in the following + folders for a directory named 'dozerbot': + * `/usr/local/share` + * `/opt` + * `~/.local/share` +2. **Delete the folder**. +3. **If using** either `/usr/local/share` or `~/.local/share`, remove the + broken symlinks. + + The files `main.py`, `start.sh` and `.env` are linked into `[...]/local/bin` + and `etc` as `bin/dozermain`, `bin/dozerstart` and `etc/dozer.env`. Unlink + those with `unlink`. +4. **Delete the `systemd` units and configuration**, with: + ```shell + systemctl [--user] disable dozer.service + ``` + Use `--user` if the bot was installed into the home directory. + + Then, **delete**: + * `/dozer.service` + * `/dozer.service.d/` (the whole folder, if it exists) + + ...where `` is `~/.config/systemd/user` if installed into the home + directory, or `/etc/systemd/system` otherwise (most cases). +5. (Optional) **Clean the logs**. If set up to use a logfile, you can delete it + from `~/.config/dozer.log` or `/var/log/dozer.log` (alongside any logrotate artifacts). + # Troubleshooting * **If the bot throws an error about a locked keyring**, exit it and unlock the keyring before trying again. diff --git a/SheetsManager.py b/SheetsManager.py index ba92a5b..3420b3d 100644 --- a/SheetsManager.py +++ b/SheetsManager.py @@ -82,8 +82,6 @@ def find_sheet(self, suffix: str): results = drive_service.files().list(fields="nextPageToken, files(id, name, mimeType, owners)").execute() files = results.get("files", []) - - for file in files: owners = [] for owner in file['owners']: From c7b6cf2e0f3a114d0adb0c8a5b3b824388dbd75e Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 22:38:19 -0500 Subject: [PATCH 098/118] shebang main.py --- main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/main.py b/main.py index ee13a46..d0b7395 100644 --- a/main.py +++ b/main.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python3 import os import sys From ec214fe46980f41a7ce5bf16115348d05952671f Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 22:39:18 -0500 Subject: [PATCH 099/118] fix shebang --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b4ec01c..1fd9525 100755 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ -#!/usr/bin/env /usr/bin/python3 +#!/usr/bin/env python3 # # ========= Setup ========= # Standalone script to install files and to set up dependencies. From 03f34f427a5d4e0771ce76f255001a7bb4987a4c Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 23:29:48 -0500 Subject: [PATCH 100/118] add uninstall function --- setup.py | 125 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/setup.py b/setup.py index 1fd9525..6d744a6 100755 --- a/setup.py +++ b/setup.py @@ -651,6 +651,131 @@ def setup_import(): else: print("Successfully imported secrets!") +def setup_uninstall(): + print() + print(" === Dozer Setup ===") + print("Version 1.0.0") + print("Task: Uninstall") + print() + print(*textwrap.wrap("See README.md for manual instructions if" + " uninstallation does not work for whatever reason.", + 80), sep='\n') + print() + + print("Finding installations...") + + # installation locations + # dict to associate each "value" with other constant lists + possible_locations = ["/usr/local/share/dozerbot", "/opt/dozerbot", + os.path.expanduser("~/.local/share/dozerbot")] + # some lookup tables to keep all of the constants in one place + unlink_locations = [ + ["/usr/local/bin/dozermain", "/usr/local/bin/dozerstart", + "/usr/local/etc/dozer.env"], + [], + [os.path.expanduser("~/.local/bin/dozermain"), + os.path.expanduser("~/.local/bin/dozerstart"), + os.path.expanduser("~/.local/etc/dozer.env")], + ] + unit_locations = ["/etc/systemd/system/dozer.service", + "/etc/systemd/system/dozer.service", + "~/.config/systemd/user/dozer.service"] + + # valid installations that were found + locations = [] + + for location in possible_locations: + if os.path.exists(location) and os.path.isdir(location) and\ + os.path.exists(f"{location}{sep}main.py"): + locations.append(location) + + if len(locations) == 0: + print("No installations found.") + print("See README.md for manual uninstallation instructions if this is incorrect.") + print() + exit(0) + + print(f"Found installation{'s' if len(locations) > 1 else ''}:") + for location in locations: + print(f" * {location}") + + print() + confirmation = confirm("Uninstall everything") + + if not confirmation: + print("Abort. --- ") + sys.exit(1) + + # uninstall everything + for location in locations: + print(f"Uninstalling {location}...") + + # give option to back up .env + if os.path.exists(f"{location}{sep}.env"): + back_option = confirm("Back up .env file", default_state=True) + if back_option: + while True: + back_path = input("Choose a target path (default = ~/.env: ") + try: + os.rename(f"{location}{sep}.env", back_path) + except FileExistsError as e: + print("File already exists at target") + continue + except OSError as e: + print("Could not move file: " + str(e)) + continue + print("Successfully backed up .env") + break + + is_user = is_subdir(os.path.expanduser("~"), location) + + # find extra details + index = locations.index(location) + files_to_unlink: list[str] = unlink_locations[index] + unit = unit_locations[index] + + if is_user: + # stop target + found_unit = os.path.exists(unit) + if found_unit: + subprocess.run(["systemctl", "--user", "stop", "dozer.service"]) + subprocess.run(["systemctl", "--user", "disable", "dozer.service"]) + print("Stopped and disabled service") + + # reconfirm + print("Found the following targets with appropriate action:") + print(f" * directory {location}: remove tree") + for target in files_to_unlink: + print(f" * symlink {target}: unlink") + if found_unit: + print(f" * systemd unit file {unit}: delete") + if os.path.exists(unit + ".d"): + print(f" * systemd unit config {unit + ".d"}: remove tree") + + confirm_option = confirm("Do you want to continue") + + if not confirm_option: + print("Abort. --- ") + continue + + # unlink + for target in files_to_unlink: + os.unlink(target) + print("Unlinked " + target) + + # remove tree (main) + shutil.rmtree(location) + print("Removed main installation files at " + location) + + # delete unit + if found_unit: + os.remove(unit) + print("Removed systemd unit file at " + unit) + if os.path.exists(unit + ".d"): + shutil.rmtree(unit + ".d") + print("Removed systemd unit config at " + unit + ".d") + + if __name__ == "__main__": if len(sys.argv) < 2: print("Missing subcommand.") From 9b4454b56801ac8a2b9bb6ff8da28336f53de2c6 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 23:33:35 -0500 Subject: [PATCH 101/118] add system-wide uninstall logic --- setup.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/setup.py b/setup.py index 6d744a6..698b8f4 100755 --- a/setup.py +++ b/setup.py @@ -774,6 +774,49 @@ def setup_uninstall(): if os.path.exists(unit + ".d"): shutil.rmtree(unit + ".d") print("Removed systemd unit config at " + unit + ".d") + else: + # stop target + found_unit = os.path.exists(unit) + if found_unit: + subprocess.run(["sudo", "systemctl", "stop", "dozer.service"]) + subprocess.run(["sudo", "systemctl", "disable", "dozer.service"]) + print("Stopped and disabled service") + + # reconfirm + print("Found the following targets with appropriate actions:") + print(f" * directory {location}: remove tree") + for target in files_to_unlink: + print(f" * symlink {target}: unlink") + if found_unit: + print(f" * systemd unit file {unit}: delete") + if os.path.exists(unit + ".d"): + print(f" * systemd unit config {unit + ".d"}: remove tree") + print("(remove tree = delete all subdirectories, and then the" + " directory itself)") + print() + + confirm_option = confirm("Do you want to continue") + + if not confirm_option: + print("Abort. --- ") + continue + + # unlink + for target in files_to_unlink: + subprocess.run(["sudo", "unlink", target]) + print("Unlinked " + target) + + # remove tree (main) + subprocess.run(["sudo", "rm", "-rf", location]) + print("Removed main installation files at " + location) + + # delete unit + if found_unit: + subprocess.run(["sudo", "rm", "-f", unit]) + print("Removed systemd unit file at " + unit) + if os.path.exists(unit + ".d"): + subprocess.run(["sudo", "rm", "-rf", unit + ".d"]) + print("Removed systemd unit config at " + unit + ".d") if __name__ == "__main__": From 1f35f4b2e477e49a13399bf028ca453b341887c0 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 23:35:46 -0500 Subject: [PATCH 102/118] add uninstall to parser and help --- setup.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 698b8f4..87fc75a 100755 --- a/setup.py +++ b/setup.py @@ -193,6 +193,7 @@ def setup_help(): print() print("Available commands:") print(" - install: install the bot (guided)") + print(" - uninstall: uninstall the bot (guided)") print(" - import: import credentials from a secrets file (guided)") print(" - help: show this help") @@ -836,7 +837,9 @@ def setup_uninstall(): case "install": setup_install() sys.exit(0) - # TODO: add uninstall command + case "uninstall": + setup_uninstall() + sys.exit(0) case "help": print(" === Dozer Setup === ") print("Version 1.0.0") From 262fb9180f7946c1b8a1e595ee2d4703eefc3101 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 23:36:12 -0500 Subject: [PATCH 103/118] remove '(guided)' from every help option --- setup.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 87fc75a..aa361af 100755 --- a/setup.py +++ b/setup.py @@ -192,9 +192,9 @@ def setup_help(): print("Usage: python setup.py ") print() print("Available commands:") - print(" - install: install the bot (guided)") - print(" - uninstall: uninstall the bot (guided)") - print(" - import: import credentials from a secrets file (guided)") + print(" - install: install the bot") + print(" - uninstall: uninstall the bot") + print(" - import: import credentials from a secrets file") print(" - help: show this help") def setup_install(): From eecfea8e807c438694ee7f1e5e6357246a92fb1e Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 23:37:57 -0500 Subject: [PATCH 104/118] fix errant backtick --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0e4057b..a6eb72b 100644 --- a/README.md +++ b/README.md @@ -235,7 +235,7 @@ Launch `main.py` with `python3` to run the app. # Uninstallation If using a simple (manual) installation, nothing is needed. The bot does not -install files outside of its directory, unless `setup.py was used`. Note that +install files outside of its directory, unless `setup.py` was used. Note that some keyring remnants may remain; those can be removed without harm (as long as they are not lost permanently). From ba284825ec563c585eac76ad49ea86961d10bd55 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 23:39:30 -0500 Subject: [PATCH 105/118] remove old sqlite db references --- .gitignore | 2 -- 1 file changed, 2 deletions(-) diff --git a/.gitignore b/.gitignore index 285c007..838c2ad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,6 @@ .idea/ .vscode/ .env -db.sqlite -db.sqlite-journal pycache **/__pycache__/ .venv/ From 119bf8e9e89865b681ea1a6212d4104135cf9890 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 25 Dec 2025 23:47:41 -0500 Subject: [PATCH 106/118] fix .env backup --- setup.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index aa361af..92ac053 100755 --- a/setup.py +++ b/setup.py @@ -715,16 +715,25 @@ def setup_uninstall(): if os.path.exists(f"{location}{sep}.env"): back_option = confirm("Back up .env file", default_state=True) if back_option: + # keep trying until it works while True: - back_path = input("Choose a target path (default = ~/.env: ") + back_path = input("Choose a target path (default = ~/.env): ") + if not back_path: + back_path = os.path.expanduser("~/.env") + + # manually move file to dodge permissions errors try: - os.rename(f"{location}{sep}.env", back_path) - except FileExistsError as e: + with open(f"{location}{sep}.env", "r") as i: + with open(back_path, "w") as o: + o.write(i.read()) + + except FileExistsError: print("File already exists at target") continue except OSError as e: print("Could not move file: " + str(e)) continue + print("Successfully backed up .env") break From 4b28fdbb35231bbcfe7dd637b2181a7765f1c4a4 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Fri, 26 Dec 2025 00:02:01 -0500 Subject: [PATCH 107/118] fix: find correct location --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 92ac053..8247920 100755 --- a/setup.py +++ b/setup.py @@ -740,7 +740,7 @@ def setup_uninstall(): is_user = is_subdir(os.path.expanduser("~"), location) # find extra details - index = locations.index(location) + index = possible_locations.index(location) files_to_unlink: list[str] = unlink_locations[index] unit = unit_locations[index] From c406631d2958ed142b16f6f9e4ea9be5c4d829f2 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Fri, 26 Dec 2025 12:24:10 -0500 Subject: [PATCH 108/118] try to remove logs on uninstall --- setup.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/setup.py b/setup.py index 8247920..e57b3a8 100755 --- a/setup.py +++ b/setup.py @@ -828,6 +828,47 @@ def setup_uninstall(): subprocess.run(["sudo", "rm", "-rf", unit + ".d"]) print("Removed systemd unit config at " + unit + ".d") + # remove logs if applicable + print() + # find logs + varlog_location = "/var/log/dozer.log" + cache_location = os.path.expanduser("~/.cache/dozer.log") + + if not os.path.exists(varlog_location): + varlog_location = None + if not os.path.exists(cache_location): + cache_location = None + + if varlog_location is not None or cache_location is not None: + + # first give the user the list of files + print(f"Found log file{"s" if varlog_location is not None + and cache_location is not None else ""}:") + if varlog_location is not None: + print(f" * {varlog_location}") + if cache_location is not None: + print(f" * {cache_location}") + # then ask if they want them gone + confirm_remove_logs = confirm("Remove log files") + + if confirm_remove_logs: + # i.e. + # no logs: + # one log: Found log file: + # two logs: Found log files: + + cont = confirm("Delete these logs") + if cont: + if varlog_location is not None: + subprocess.run(["sudo", "rm", "-f", varlog_location]) + print("Removed " + varlog_location) + if cache_location is not None: + os.remove(cache_location) + print("Removed " + cache_location) + print() + print("Done. Check for any logrotate leftovers (if applicable).") + print() + print("Successfully uninstalled") if __name__ == "__main__": if len(sys.argv) < 2: From 6e86e4ab0cb19a6c222c9cbc384832142521aeca Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Fri, 26 Dec 2025 12:42:02 -0500 Subject: [PATCH 109/118] make /opt default --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index e57b3a8..e4cf84c 100755 --- a/setup.py +++ b/setup.py @@ -239,7 +239,7 @@ def setup_install(): "Into ~/.local/share, linked into ~/.local/bin and etc",] option = choose_option( "Which directory should the bot be installed into?\n", - *annotated_install_targets) + *annotated_install_targets, default=1) # default is /opt install_parent_target_abs = os.path.realpath(os.path.abspath( os.path.expanduser(install_targets[option]) From 0d15649537e7d9821ebabcb30bb98cc5e5adcc95 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Fri, 26 Dec 2025 14:16:33 -0500 Subject: [PATCH 110/118] expand user path in import --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index e4cf84c..3d6e5f9 100755 --- a/setup.py +++ b/setup.py @@ -578,7 +578,7 @@ def setup_import(): print("Where is secrets.json located?") print() while True: - secrets_path = input("Enter a path: ") + secrets_path = os.path.expanduser(input("Enter a path: ")) if not os.path.exists(secrets_path): print(f"Couldn't find secrets file at {secrets_path}, try again.") @@ -608,7 +608,7 @@ def setup_import(): install_paths = ["/usr/local/share/dozerbot", "/opt/dozerbot", "~/.local/share/dozerbot"] install_path = install_paths[choose_option("Where is the bot installed? ", *install_paths)] - if not os.path.exists(install_path): + if not os.path.exists(os.path.expanduser(install_path)): print(f"{install_path} does not exist, try again.") continue From 9a096f36094449e5db70c813f5ed95bc0f4aedbc Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Fri, 26 Dec 2025 14:18:23 -0500 Subject: [PATCH 111/118] fix #6: make installer use sudo for ~/.local --- setup.py | 84 +++++++++++++++++++++----------------------------------- 1 file changed, 31 insertions(+), 53 deletions(-) diff --git a/setup.py b/setup.py index 3d6e5f9..0ffb7d0 100755 --- a/setup.py +++ b/setup.py @@ -285,33 +285,21 @@ def setup_install(): print() install_dir = install_parent_target_abs + sep + subdir_name - needs_root = not is_subdir("~", install_parent_target_abs) print("Starting copy...") - if needs_root: - print("The installer will now ask for superuser permissions.") + print("The installer will now ask for superuser permissions.") # copy files try: - if needs_root: - # make sure directories exist - subprocess.run(["sudo", "mkdir", "-p", install_dir]) - subprocess.run(["sudo", "cp", "-r", ".", install_dir]) - # and make sure anyone can execute - subprocess.run(["sudo", "chmod", "+x", install_dir + sep + "main.py"]) - subprocess.run(["sudo", "chmod", "+x", install_dir + sep + "start.sh"]) - # make sure .env exists - subprocess.run(["sudo", "touch", install_dir + sep + ".env"]) - else: - # but with 100% less sudo - subprocess.run(["mkdir", "-p", install_dir]) - subprocess.run(["cp", "-r", ".", install_dir]) - os.chmod(install_dir + sep + "main.py", - os.stat(install_dir + sep + "main.py").st_mode | stat.S_IEXEC) - os.chmod(install_dir + sep + "start.sh", - os.stat(install_dir + sep + "start.sh").st_mode | stat.S_IEXEC) - # make sure .env exists - pathlib.Path(install_dir + sep + ".env").touch(exist_ok=True) + # make sure directories exist + subprocess.run(["sudo", "mkdir", "-p", install_dir]) + subprocess.run(["sudo", "cp", "-r", ".", install_dir]) + # and make sure anyone can execute + subprocess.run(["sudo", "chmod", "+x", install_dir + sep + "main.py"]) + subprocess.run(["sudo", "chmod", "+x", install_dir + sep + "start.sh"]) + # make sure .env exists + subprocess.run(["sudo", "touch", install_dir + sep + ".env"]) + print("Success!") print() except subprocess.CalledProcessError as e: @@ -332,33 +320,21 @@ def setup_install(): # secrets.json is specifically excluded because it shouldn't exist most # of the time # .env can also point somewhere else if required - if needs_root: - # make sure symlinking directories exist - subprocess.run(["sudo", "mkdir", "-p", bin_path_target_abs]) - subprocess.run(["sudo", "mkdir", "-p", etc_path_target_abs]) - # bin - subprocess.run(["sudo", "-E", "ln", "-s", - install_dir + sep + "main.py", - bin_path_target_abs + sep + "dozermain"]) - subprocess.run(["sudo", "-E", "ln", "-s", - install_dir + sep + "start.sh", - bin_path_target_abs + sep + "dozerstart"]) - # etc - subprocess.run(["sudo", "-E", "ln", "-s", - install_dir + sep + ".env", - etc_path_target_abs + sep + "dozer.env"]) - else: - # same - pathlib.Path(bin_path_target_abs).mkdir(parents=True) - pathlib.Path(etc_path_target_abs).mkdir(parents=True) - # bin - os.symlink(install_dir + sep + "main.py", - bin_path_target_abs + sep + "dozermain") - os.symlink(install_dir + sep + "start.sh", - bin_path_target_abs + sep + "dozerstart") - # etc - os.symlink(install_dir + sep + ".env", - etc_path_target_abs + sep + "dozer.env") + # make sure symlinking directories exist + subprocess.run(["sudo", "mkdir", "-p", bin_path_target_abs]) + subprocess.run(["sudo", "mkdir", "-p", etc_path_target_abs]) + + # bin + subprocess.run(["sudo", "-E", "ln", "-s", + install_dir + sep + "main.py", + bin_path_target_abs + sep + "dozermain"]) + subprocess.run(["sudo", "-E", "ln", "-s", + install_dir + sep + "start.sh", + bin_path_target_abs + sep + "dozerstart"]) + # etc + subprocess.run(["sudo", "-E", "ln", "-s", + install_dir + sep + ".env", + etc_path_target_abs + sep + "dozer.env"]) print() print(*textwrap.wrap( f"Success! Executables and configuration may be also found at " @@ -416,18 +392,20 @@ def setup_install(): print("Step 5 - creating system service...") print() + is_system = not is_subdir(os.path.expanduser("~"), install_dir) log_options_annotated = ["Sink (/dev/null)", "To ~/.cache/dozer.log", "To /var/log/dozer.log", "To syslog"] - if not needs_root: + if not is_system: log_options_annotated.remove("To /var/log/dozer.log") log_options = ["null", "cache", "varlog", "syslog"] - if not needs_root: + if not is_system: log_options.remove("varlog") log_option = log_options[choose_option("Where should log messages be sent?", - *log_options_annotated, default=3)] + *log_options_annotated, + default=3 if is_system else 2)] # create a systemd file - if needs_root: + if is_system: print("Creating as a system-wide systemd unit") print("...") # create a new unit file From 416955def01510e0a2935393bc618526db518db9 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Fri, 26 Dec 2025 15:03:22 -0500 Subject: [PATCH 112/118] defer response (timeouts caused 404s and exceptions) --- cogs/MarkHere.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cogs/MarkHere.py b/cogs/MarkHere.py index 292eafb..d60b72d 100644 --- a/cogs/MarkHere.py +++ b/cogs/MarkHere.py @@ -46,10 +46,11 @@ async def mark_here(self, interaction: discord.Interaction, code: int): await interaction.response.send_message(content=f"Code is no longer valid!") return + interaction.response.defer() # so commit the record to memory self.communicator.sheet_manager.add_line(timestamp, interaction.user.name, interaction.user.display_name, self.communicator.sheet_id) self.communicator.claimed_codes.append(code) - await interaction.response.send_message(content=f"Marked {interaction.user.name} as present (code: {code})") + await interaction.followup.send(f"Marked {interaction.user.name} as present (code: {code})") async def setup(bot): await bot.add_cog(MarkHere(bot)) From 3db5cb335954136d39fff3060f7d021dcd26c4f3 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Fri, 26 Dec 2025 15:03:22 -0500 Subject: [PATCH 113/118] defer response (timeouts caused 404s and exceptions) --- cogs/MarkHere.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cogs/MarkHere.py b/cogs/MarkHere.py index d60b72d..0cf6c6a 100644 --- a/cogs/MarkHere.py +++ b/cogs/MarkHere.py @@ -46,7 +46,7 @@ async def mark_here(self, interaction: discord.Interaction, code: int): await interaction.response.send_message(content=f"Code is no longer valid!") return - interaction.response.defer() + await interaction.response.defer() # so commit the record to memory self.communicator.sheet_manager.add_line(timestamp, interaction.user.name, interaction.user.display_name, self.communicator.sheet_id) self.communicator.claimed_codes.append(code) From 521cba6ecd115187a9165818c4d084db4426d8f3 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Thu, 1 Jan 2026 11:39:39 -0500 Subject: [PATCH 114/118] minor: use local time on the sheet --- cogs/MarkHere.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/cogs/MarkHere.py b/cogs/MarkHere.py index 0cf6c6a..ee89909 100644 --- a/cogs/MarkHere.py +++ b/cogs/MarkHere.py @@ -33,7 +33,8 @@ def __init__(self, bot): async def mark_here(self, interaction: discord.Interaction, code: int): - timestamp = datetime.datetime.now(datetime.UTC) + timestamp_utc = datetime.datetime.now(datetime.UTC) + timestamp_local = datetime.datetime.now() if code not in self.communicator.received_codes: await interaction.response.send_message(content=f"Code does not exist! Was it typed correctly?") return @@ -42,13 +43,14 @@ async def mark_here(self, interaction: discord.Interaction, code: int): await interaction.response.send_message(content=f"Code already claimed!") return # if code is no longer valid - if timestamp.timestamp() > self.communicator.received_codes[code]: + if timestamp_utc.timestamp() > self.communicator.received_codes[code]: await interaction.response.send_message(content=f"Code is no longer valid!") return await interaction.response.defer() # so commit the record to memory - self.communicator.sheet_manager.add_line(timestamp, interaction.user.name, interaction.user.display_name, self.communicator.sheet_id) + # use local time to make it more readable + self.communicator.sheet_manager.add_line(timestamp_local, interaction.user.name, interaction.user.display_name, self.communicator.sheet_id) self.communicator.claimed_codes.append(code) await interaction.followup.send(f"Marked {interaction.user.name} as present (code: {code})") From af3b2cd50b227677bbee502ebee956c943305335 Mon Sep 17 00:00:00 2001 From: ed522 <147557022+ed522@users.noreply.github.com> Date: Mon, 12 Jan 2026 18:48:51 -0500 Subject: [PATCH 115/118] Also catch JSONDecodeErrors so that a bad message doesn't kill the thread --- AttendanceCodeCommunicator.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AttendanceCodeCommunicator.py b/AttendanceCodeCommunicator.py index 1a9f8e9..a2a7099 100644 --- a/AttendanceCodeCommunicator.py +++ b/AttendanceCodeCommunicator.py @@ -6,6 +6,7 @@ import sys import threading from enum import Enum +from json import JSONDecodeError from dotenv import load_dotenv @@ -161,7 +162,7 @@ def _communicate(self, should_fail_on_exception: bool): if self._handshake(sock): self.status = Status.CONNECTED self._communicate_after_handshake(sock) - except socket.error as e: + except socket.error | JSONDecodeError as e: if should_fail_on_exception: logging.log(logging.ERROR, f"Exception raised during communication!") raise e # terminates thread From 312f4fbcc8aa32e7b1725ce72562dcc47995517c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 23:55:57 +0000 Subject: [PATCH 116/118] Bump urllib3 from 2.6.2 to 2.6.3 Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.6.2 to 2.6.3. - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/2.6.2...2.6.3) --- updated-dependencies: - dependency-name: urllib3 dependency-version: 2.6.3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 80ce977..6b3dd18 100644 --- a/requirements.txt +++ b/requirements.txt @@ -45,5 +45,5 @@ SecretStorage==3.5.0 statbotics==3.0.0 typing_extensions==4.15.0 uritemplate==4.2.0 -urllib3==2.6.2 +urllib3==2.6.3 yarl==1.22.0 From 72fbd16a2fdb3d171b4b426839bb6f252fe9623d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 23:56:23 +0000 Subject: [PATCH 117/118] Bump aiohttp from 3.13.2 to 3.13.3 --- updated-dependencies: - dependency-name: aiohttp dependency-version: 3.13.3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 80ce977..9251841 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ aiohappyeyeballs==2.6.1 -aiohttp==3.13.2 +aiohttp==3.13.3 aiosignal==1.4.0 attrs==25.4.0 CacheControl==0.12.14 From 4bf1f7c182fec06228cf69e0e0fc37c87219c5b2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 17 Jan 2026 18:10:54 +0000 Subject: [PATCH 118/118] Bump pyasn1 from 0.6.1 to 0.6.2 Bumps [pyasn1](https://github.com/pyasn1/pyasn1) from 0.6.1 to 0.6.2. - [Release notes](https://github.com/pyasn1/pyasn1/releases) - [Changelog](https://github.com/pyasn1/pyasn1/blob/main/CHANGES.rst) - [Commits](https://github.com/pyasn1/pyasn1/compare/v0.6.1...v0.6.2) --- updated-dependencies: - dependency-name: pyasn1 dependency-version: 0.6.2 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ecb7e3f..d4321d0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,7 +33,7 @@ pillow==12.0.0 propcache==0.4.1 proto-plus==1.27.0 protobuf==6.33.2 -pyasn1==0.6.1 +pyasn1==0.6.2 pyasn1_modules==0.4.2 pycparser==2.23 pyparsing==3.2.5