diff --git a/.gitignore b/.gitignore index 09e3868..838c2ad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ .idea/ .vscode/ .env +pycache +**/__pycache__/ +.venv/ +secrets.json +secrets.json.gpg \ No newline at end of file diff --git a/AttendanceCodeCommunicator.py b/AttendanceCodeCommunicator.py new file mode 100644 index 0000000..a2a7099 --- /dev/null +++ b/AttendanceCodeCommunicator.py @@ -0,0 +1,196 @@ +import ipaddress +import logging +import json +import os +import socket +import sys +import threading +from enum import Enum +from json import JSONDecodeError + +from dotenv import load_dotenv + +from SheetsManager import SheetManager + +load_dotenv() + +class Status(Enum): + DISCONNECTED = 0 + CONNECTING = 1 + CONNECTED = 2 +class AttendanceCodeCommunicator: + + sheet_id = None + sheet_manager = None + received_codes = {} + claimed_codes = [] + status: Status = Status.DISCONNECTED + thread: threading.Thread = None + + def __init__(self): + """ + :raises ValueError: if no valid sheet exists + """ + 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: + 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): + + const_version = 1 + logging.log(logging.DEBUG, "Discovering available endpoints") + + 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: + + 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): + + 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 + + response = { + 'type': 'acknowledge', + 'targeting': connect_message['type'] + } + val1_tmp = json.dumps(response) + val2_tmp = val1_tmp.encode() + sock.send(val2_tmp) + 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: + + data = sock.recv(1024) + if len(data) == 0: + logging.log(logging.WARN, "Remote endpoint disconnected") + return + + message = json.loads(data) + + if message['type'] == 'heartbeat': + response = { + 'type': 'acknowledge', + 'targeting': message['type'], + 'counter': counter + } + counter += 1 + 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'] + 1 + + 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_show_duration + } + 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 + codes_to_remove = [] + for code_to_check, expiry in self.received_codes.items(): + # 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: + 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") + + def _communicate(self, should_fail_on_exception: bool): + """ + Attempt to communicate with the remote screen. + Handles both broadcasts and regular communication. + Intended to be run as a thread + :return: None + """ + while True: + 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, f"Found an endpoint at {host}") + 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 | JSONDecodeError 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): + # 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=lambda: self._communicate(False), daemon=True) + self.thread.start() diff --git a/README.md b/README.md index 0ccceb8..6dc49aa 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,305 @@ -## 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/ -## 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, statbotics, requests, pillow, and dotenv libraries +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` +* `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 +``` +(which is just a more forceful way of using the virtualenv pip.) + +## Environment file and keyrings + +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. + +Entries are formatted like so: +```dotenv +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), +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 +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 + 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. + +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 +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 +# 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 +``` + +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. + +*** + +**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`. + + (Optional) **Add the following line** to `.env` if the secrets file has a + different path: + ```dotenv + secrets_json_path=/path/to/secrets-file.json + ``` +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 + service_account=example@foo.iam.gserviceaccount.com + # the owner of this email will own the main sheet and share it with the + # 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 + some parts. +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 +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. + +# 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. + +* **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 editable/visible. + +Requires the discord.py, statbotics, requests, pillow, and dotenv libraries (as well as attendance libraries mentioned before) ## TODO: - Implement caching TBA responses with the ETag, If-None-Match and Cache-Control headers @@ -27,4 +309,4 @@ Requires the discord.py, statbotics, requests, pillow, and dotenv libraries - EPA Rankings - Events - Match - - Schedule \ No newline at end of file + - Schedule diff --git a/SheetsManager.py b/SheetsManager.py new file mode 100644 index 0000000..3420b3d --- /dev/null +++ b/SheetsManager.py @@ -0,0 +1,161 @@ +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: + """ + 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, keyring.errors.NoKeyringError) 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): + """ + 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 diff --git a/cogs/MarkHere.py b/cogs/MarkHere.py new file mode 100644 index 0000000..ee89909 --- /dev/null +++ b/cogs/MarkHere.py @@ -0,0 +1,58 @@ +import datetime + +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() + self.communicator.run() # starts background thread + + # 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): + + 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 + # 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_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 + # 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})") + +async def setup(bot): + await bot.add_cog(MarkHere(bot)) diff --git a/main.py b/main.py index 54d6cfd..1eb60d6 100644 --- a/main.py +++ b/main.py @@ -1,15 +1,26 @@ +#!/usr/bin/env python3 import os +import sys import discord from discord.ext import commands from dotenv import load_dotenv +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() bot = commands.Bot(command_prefix="!", intents=intents) +disable_attendance = False # This is run when the bot is started by discord.py, it syncs the commands to the guilds specified @bot.event @@ -36,12 +47,51 @@ async def load_extensions(): await bot.load_extension("cogs.TBAStatus") await bot.load_extension("cogs.StatboticsStatus") await bot.load_extension("cogs.Watch") + if not disable_attendance: + await bot.load_extension("cogs.MarkHere") await bot.load_extension("cogs.Rankings") print("Extensions all loaded") # Loads slash commands, starts the bot if __name__ == "__main__": + # parse command line + for arg in sys.argv[1:]: + match arg: + case "--disable-attendance": + if not disable_attendance: + disable_attendance = True + else: + print(f"Duplicate argument {arg}") + os._exit(2) + case "--import-secrets": + if disable_attendance: + 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}") + + # 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()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d4321d0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,49 @@ +aiohappyeyeballs==2.6.1 +aiohttp==3.13.3 +aiosignal==1.4.0 +attrs==25.4.0 +CacheControl==0.12.14 +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 +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 +pyasn1==0.6.2 +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 +statbotics==3.0.0 +typing_extensions==4.15.0 +uritemplate==4.2.0 +urllib3==2.6.3 +yarl==1.22.0 diff --git a/sample.env b/sample.env new file mode 100644 index 0000000..12d8cd0 --- /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=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 +# 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 diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..0ffb7d0 --- /dev/null +++ b/setup.py @@ -0,0 +1,879 @@ +#!/usr/bin/env 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 +import pathlib +import stat +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 + """ + + 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 + + # 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 = 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 setup_help(): + print() + print("Usage: python setup.py ") + print() + print("Available commands:") + 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(): + """ + Main routine that handles all functions. + :return: nothing + """ + + subdir_name = "dozerbot" + wrap_width = 80 + + print(" === Dozer Setup === ") + print("Version 1.0.0") + print("Task: Install") + 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(*textwrap.wrap("ERROR: no installed copy of virtualenv was found, which is" + " required for installation. Please check $PATH and install it if" + " necessary.", wrap_width), sep='\n') + print("Abort. ---") + sys.exit(1) + + # get the install directory + 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 = [ + "Into /usr/local/share, linked into /usr/local/bin and etc", + "Into /opt, in its own subdirectory", + "Into ~/.local/share, linked into ~/.local/bin and etc",] + option = choose_option( + "Which directory should the bot be installed into?\n", + *annotated_install_targets, default=1) # default is /opt + + 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) + 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 + for entry in path_entries: + entry_test_dir = os.path.realpath(os.path.abspath(os.path.expanduser(entry))) + 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() + 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') + 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() + + # install! + print() + print("Step 2 - installing files...") + print() + + install_dir = install_parent_target_abs + sep + subdir_name + print("Starting copy...") + + print("The installer will now ask for superuser permissions.") + + # copy files + try: + # 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: + print(f"ERROR: Failed to copy files! (exit code {e.returncode}," + f" message: {str(e)})") + print("Abort. ---") + sys.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 + # 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 " + 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') + + # done + + # make venv + print() + print("Step 3 - creating virtual environment...") + print() + + venv_folder = install_dir + sep + ".venv" + try: + print("Creating...") + print() + subprocess.run(["sudo", "-E", "virtualenv", venv_folder]) + 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" + f" {e.returncode}, message: {str(e)})") + print("Abort. ---") + sys.exit(1) + # done + + # get pip deps + print() + print("Step 4 - installing dependencies...") + print() + + try: + print("Installing dependencies from requirements.txt...") + 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() + print("Success!") + print() + except subprocess.CalledProcessError as e: + print(f"ERROR: Failed to install dependencies! (exit code" + f" {e.returncode}, message: {str(e)})") + print("Abort. ---") + sys.exit(1) + + print() + 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 is_system: + log_options_annotated.remove("To /var/log/dozer.log") + log_options = ["null", "cache", "varlog", "syslog"] + 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 if is_system else 2)] + + # create a systemd file + if is_system: + 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 = "/etc/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 + # 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=10 +{log_data} + +[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_dir_path = os.path.expanduser("~/.config/systemd/user/") + service_file_path = service_dir_path + "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 + + # 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=10 +{log_data} + +[Install] +WantedBy=default.target +""" + + 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"]) + + 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() + 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 = 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.") + 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: + 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(os.path.expanduser(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") + + # 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, "-"], text=True) + + # 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]\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]\n{out}\n") + 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!") + +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: + # keep trying until it works + while True: + 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: + 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 + + is_user = is_subdir(os.path.expanduser("~"), location) + + # find extra details + index = possible_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") + 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") + + # 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: + 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 "uninstall": + setup_uninstall() + 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 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;