├── .gitignore ├── README.md ├── conf_ex.json ├── logs └── .gitignore ├── preview └── questionnaire01.gif ├── requirements.txt └── src ├── bot ├── __init__.py └── discord.py ├── config ├── __init__.py └── base.py ├── connection ├── __init__.py ├── api.py ├── base.py └── db.py ├── controller ├── __init__.py └── game.py ├── game ├── __init__.py ├── base.py └── questionnaire.py ├── main.py ├── misc ├── __init__.py └── stats.py ├── server ├── __init__.py └── base.py └── utils ├── __init__.py ├── debug.py └── file.py /.gitignore: -------------------------------------------------------------------------------- 1 | venv 2 | conf.json 3 | __pycache__ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Not Finished & A Work In Progress 2 | 3 | An open source [Discord](https://discord.com/) bot written in Python that allows users on servers to play mini-games and get awarded points! So far, there is only a questionnaire game in development, but more games will be added in the future! 4 | 5 | **NOTE** - As stated before, this bot is not finished and unusable in its current state. I'm pushing to this repository for transparency with the project and I also want to save to the cloud. 6 | 7 | ## Previews 8 | Previews as of *November 3rd, 2024* (still not finished!). 9 | 10 | ### Questionnaire 11 | ![Questionnaire GIF](./preview/questionnaire01.gif) 12 | 13 | ## Command Line Usage 14 | The following flags are supported in the command line. 15 | 16 | | Name | Flags | Default | Description | 17 | | ---- | ----- | ------- | ----------- | 18 | | CFG Path | `-c --cfg` | `./conf.json` | The path to the JSON Config file. | 19 | | List | `-l --list` | N/A | Lists all values from config. | 20 | | Help | `-h --help` | N/A | Prints the help menu. | 21 | 22 | ## Stats & Database 23 | *To Do...* 24 | 25 | ## Configuration 26 | The default config file is located at `./conf.json`. 27 | 28 | | Name | Key | Type | Default | Description | 29 | | ---- | --- | ---- | ------- | ----------- | 30 | | Debug | `debug` | Debug Object | `{}` | The debug object. | 31 | | General | `general` | General Object | `{}` | The general object. | 32 | | Discord Bot | `bot` | Discord Bot Object | `{}` | The Discord bot object. | 33 | | Connections | `connections` | Connections Object | `{}` | The connections object. | 34 | | Servers | `servers` | Servers Object | `{}` | The servers object. | 35 | 36 | ### Debug Object 37 | The debug object contains settings on debugging. 38 | 39 | | Name | Key | Type | Default | Description | 40 | | ---- | --- | ---- | ------- | ----------- | 41 | | Verbose Level | `verbose` | int | `1` | The verbose level for debugging. | 42 | | Log To File | `log_to_file` | bool | `false` | Whether to log to a file. | 43 | | Log Directory | `log_dir` | string | `./logs` | The logs directory. | 44 | 45 | ### General Object 46 | The general object contains general settings for the project. 47 | 48 | | Name | Key | Type | Default | Description | 49 | | ---- | --- | ---- | ------- | ----------- | 50 | 51 | *To Do...* 52 | 53 | ### Discord Bot Object 54 | The Discord Bot object contains settings related to the Discord bot. 55 | 56 | | Name | Key | Type | Default | Description | 57 | | ---- | --- | ---- | ------- | ----------- | 58 | | Token | `token` | string | `""` | The bot token. | 59 | 60 | ### Connections Object 61 | The connections object contains settings related to the web API and database connections. These settings aren't required, but having both disabled will disable stats. 62 | 63 | | Name | Key | Type | Default | Description | 64 | | ---- | --- | ---- | ------- | ----------- | 65 | | Web API | `api` | Web API Object | `{}` | The web API object. | 66 | | Database | `db` | Database Object | `{}` | The database object. | 67 | 68 | #### Web API Object 69 | The web API object contains settings on the web API for the web back-end. 70 | 71 | | Name | Key | Type | Default | Description | 72 | | ---- | --- | ---- | ------- | ----------- | 73 | | Enabled | `enabled` | bool | `false` | Whether to enable the web API. | 74 | | Host | `host` | string | `"http://localhost"` | The web host (port may be included). | 75 | | Token | `token` | string | None | The authorization token passed with each web request. | 76 | | Web Config | `web_config` | bool | `true` | Whether to pull configuration from the web API when enabled. | 77 | 78 | #### Database Object 79 | The database object contains settings on the **PostgreSQL** database. 80 | 81 | | Name | Key | Type | Default | Description | 82 | | ---- | --- | ---- | ------- | ----------- | 83 | | Enabled | `enabled` | bool | `false` | Whether to enable the database. | 84 | | Host | `host` | string | `"localhost"` | The database host. | 85 | | Port | `port` | int | `5432` | The database port. | 86 | | Username | `user` | string | `"root"` | The database user. | 87 | | Password | `password` | string | `""` | The database password. | 88 | | Web Config | `web_config` | bool | `true` | Whether to pull configuration from the database when enabled. | 89 | 90 | ### Servers Object 91 | The server's object contains server-specific settings including what games to run, its settings, and more! 92 | 93 | The object contains sub-objects where the key (string) is the server GUID (you may retrieve this through Discord if you have the proper permissions to the server). 94 | 95 | The value is another object with these settings. 96 | 97 | | Name | Key | Type | Default | Description | 98 | | ---- | --- | ---- | ------- | ----------- | 99 | | Next Game Random | `next_game_random` | bool | `true` | Whether to pick a random next game. | 100 | | Next Game Cooldown | `next_game_cooldown` | float | `120.0` | The cooldown between starting a new game when automatically starting a game. | 101 | | Game Start Auto | `game_start_auto` | bool | `true` | Whether to start games automatically. | 102 | | Game Start Command | `game_start_cmd` | bool | `true` | Whether to allow starting a game through a command. | 103 | | Game Start Manual | `game_start_manual` | bool | `true` | Whether to allow starting a specific game manually by passing an argument to the start command. | 104 | 105 | There is also a `games` object that is used for determining what games should be enabled for the server and the games settings. Sub objects inside of the `games` object should contain a key with the game's module name (the file names excluding `.py` in [`src/game`](./src/game), ex: `questionnaire`). The sub-object's value should be another object containing additional settings. Check the config example for more information! 106 | 107 | ## Web Back-End 108 | While the bot's configuration can be handled locally inside of the JSON config file, you may also setup the bot's web back-end which comes with an authentication system and also allows users to sign in through Discord, invite the bot to their Discord server, and then configure it to their needs. 109 | 110 | *Not Finished...* 111 | 112 | ## Requirements 113 | The [Discord.py](https://discordpy.readthedocs.io/en/stable/) Python library is required for this project to run. 114 | 115 | With that said, I also recommend using a Python virtual environment when installing these libraries for development. 116 | 117 | ```bash 118 | # Create virtual environment. 119 | python3 -m venv venv/ 120 | 121 | # Source new environment. 122 | source ./venv/bin/activate 123 | ``` 124 | 125 | You may use `pip` to install the requirements with the following command afterwards. 126 | 127 | ```bash 128 | pip install -r requirements.txt 129 | ``` 130 | 131 | ## Running 132 | Firstly, make sure you configure the project. I recommend copying the [`conf_ex.json`](./conf_ex.json) to `./conf.json`. 133 | 134 | You may then use Python to run the program. You must run this project with Python version 3 or higher. 135 | 136 | Here are some examples. 137 | 138 | ```bash 139 | # Run bot with no arguments. 140 | python3 src/main.py 141 | 142 | # Set config file to /etc/discord-mg.json 143 | python3 src/main.py -c /etc/discord-mg.json 144 | 145 | # List all config values. 146 | python3 src/main.py -l 147 | 148 | ## Print help menu. 149 | python3 src/main.py -h 150 | ``` 151 | 152 | ### Running With Docker 153 | *To Do...* 154 | 155 | ## Credits 156 | * [Christian Deacon](https://github.com/gamemann) -------------------------------------------------------------------------------- /conf_ex.json: -------------------------------------------------------------------------------- 1 | { 2 | "debug": { 3 | "verbose": 1 4 | }, 5 | "bot": { 6 | "token": "" 7 | }, 8 | "servers": { 9 | "123": { 10 | "game_start_auto": true, 11 | "games": { 12 | "questionnaire": { 13 | "name": "Questionnaire", 14 | "default_channel": 123456789, 15 | "pick_weight": 30.0, 16 | "questions": [ 17 | { 18 | "question": "What city is this?", 19 | "image": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4b/La_Tour_Eiffel_vue_de_la_Tour_Saint-Jacques%2C_Paris_ao%C3%BBt_2014_%282%29.jpg/800px-La_Tour_Eiffel_vue_de_la_Tour_Saint-Jacques%2C_Paris_ao%C3%BBt_2014_%282%29.jpg", 20 | "answers": [ 21 | { 22 | "answer": "paris" 23 | } 24 | ] 25 | }, 26 | { 27 | "question": "How many gumballs are in this jar?", 28 | "image": "https://m.media-amazon.com/images/I/81PCepMUcyL.jpg", 29 | "answers": [ 30 | { 31 | "answer": "123" 32 | }, 33 | { 34 | "answer": "one hundred three" 35 | } 36 | ] 37 | }, 38 | { 39 | "question": "How many weeks are in a year (rounded)?", 40 | "answers": [ 41 | { 42 | "answer": "52" 43 | }, 44 | { 45 | "answer": "fifty two" 46 | } 47 | ] 48 | }, 49 | { 50 | "question": "What MLB team won the World Series in 2008?", 51 | "answers": [ 52 | { 53 | "answer": "phillies" 54 | } 55 | ] 56 | }, 57 | { 58 | "question": "How many years has the oldest dog lived for?", 59 | "answers": [ 60 | { 61 | "answer": "29.5" 62 | }, 63 | { 64 | "answer": "29.50" 65 | } 66 | ] 67 | }, 68 | { 69 | "question": "What is a fear of long words called?", 70 | "answers": [ 71 | { 72 | "answer": "hippopotomonstrosesquippedaliophobia" 73 | } 74 | ] 75 | }, 76 | { 77 | "question": "How many triangles are there?", 78 | "image": "https://www.rd.com/wp-content/uploads/2021/06/How-Many-Triangles-Do-You-See-In-This-Puzzle-1.jpg", 79 | "answers": [ 80 | { 81 | "answer": "24" 82 | }, 83 | { 84 | "answer": "25" 85 | }, 86 | { 87 | "answer": "twenty four" 88 | }, 89 | { 90 | "answer": "twenty five" 91 | } 92 | ] 93 | } 94 | ] 95 | } 96 | } 97 | } 98 | } 99 | } -------------------------------------------------------------------------------- /logs/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /preview/questionnaire01.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gamemann/discord-mg-bot/6c8cd33bd52486072fd79f5d81911dd60236486e/preview/questionnaire01.gif -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | discord.py==2.4.0 2 | psycopg==3.2.3 3 | psycopg-binary==3.2.3 -------------------------------------------------------------------------------- /src/bot/__init__.py: -------------------------------------------------------------------------------- 1 | __title__ = "Bot" 2 | __version__ = "1.0.0" 3 | 4 | from .discord import Discord -------------------------------------------------------------------------------- /src/bot/discord.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands 3 | 4 | class Discord(commands.Bot): 5 | def __init__(self, 6 | token: str, 7 | intents: discord.Intents 8 | ): 9 | self.token = token 10 | self.ready = False 11 | 12 | super().__init__(command_prefix = '/', intents = intents) 13 | 14 | async def on_ready(self): 15 | self.ready = True 16 | 17 | async def connect_and_run(self): 18 | await self.start(token = self.token, reconnect = True) -------------------------------------------------------------------------------- /src/config/__init__.py: -------------------------------------------------------------------------------- 1 | __title__ = "Config" 2 | __version__ = "1.0.0" 3 | 4 | from .base import Config -------------------------------------------------------------------------------- /src/config/base.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from utils import safe_write 4 | 5 | class Debug(): 6 | def __init__(self): 7 | self.verbose = 1 8 | self.log_to_file = False 9 | self.log_dir = "./logs" 10 | 11 | def as_json(self): 12 | return { 13 | "verbose": self.verbose, 14 | "log_to_file": self.log_to_file, 15 | "log_dir": self.log_dir 16 | } 17 | 18 | class General(): 19 | def __init__(self): 20 | self.save_locally = True 21 | self.game_check_interval = 120.0 22 | 23 | def as_json(self): 24 | return { 25 | "save_locally": self.save_locally, 26 | "game_check_interval": self.game_check_interval 27 | } 28 | 29 | class ConnectionApi(): 30 | def __init__(self): 31 | self.enabled = False 32 | self.host = "http://localhost" 33 | self.token = None 34 | self.web_config = True 35 | 36 | def as_json(self): 37 | return { 38 | "enabled": self.enabled, 39 | "host": self.host, 40 | "token": self.token, 41 | "web_config": self.web_config 42 | } 43 | 44 | class ConnectionDb(): 45 | def __init__(self): 46 | self.enabled = False 47 | self.host = "localhost" 48 | self.port = 5432 49 | self.name = "discord_mg" 50 | self.user = "root" 51 | self.password = "" 52 | self.web_config = True 53 | 54 | def as_json(self): 55 | return { 56 | "enabled": self.enabled, 57 | "host": self.host, 58 | "port": self.port, 59 | "name": self.name, 60 | "user": self.user, 61 | "password": self.password, 62 | "web_config": self.web_config 63 | } 64 | 65 | class Connections(): 66 | def __init__(self): 67 | self.api = ConnectionApi() 68 | self.db = ConnectionDb() 69 | 70 | def as_json(self): 71 | return { 72 | "api": self.api.as_json(), 73 | "db": self.db.as_json() 74 | } 75 | 76 | class Bot(): 77 | def __init__(self): 78 | self.token: str = None 79 | 80 | def as_json(self): 81 | return { 82 | "token": self.token 83 | } 84 | 85 | class Server(): 86 | def __init__(self): 87 | self.games: dict[str, any] = {} 88 | 89 | self.next_game_cooldown = 120.0 90 | self.next_game_random = True 91 | 92 | self.game_start_auto = False 93 | self.game_start_cmd = True 94 | self.game_start_manual = True 95 | 96 | def as_json(self): 97 | return { 98 | "games": self.games, 99 | "next_game_cooldown": self.next_game_cooldown, 100 | "next_game_random": self.next_game_random, 101 | "game_start_auto": self.game_start_auto, 102 | "game_start_cmd": self.game_start_cmd, 103 | "game_start_manual": self.game_start_manual 104 | } 105 | 106 | class Config(): 107 | def __init__(self): 108 | self.debug = Debug() 109 | self.general = General() 110 | self.connections = Connections() 111 | self.bot = Bot() 112 | self.servers: dict[int, Server] = {} 113 | 114 | def as_json(self): 115 | return { 116 | "debug": self.debug.as_json(), 117 | "general": self.general.as_json(), 118 | "connections": self.connections.as_json(), 119 | "bot": self.connections.as_json(), 120 | "servers": {k: v.as_json() for k, v in self.servers.items()} 121 | } 122 | 123 | def load_from_fs(self, path: str = "./conf.json"): 124 | data = {} 125 | 126 | with open(path) as f: 127 | data = json.load(f) 128 | 129 | if data is None: 130 | raise Exception("JSON data is None.") 131 | 132 | # Debug settings. 133 | if "debug" in data: 134 | debug = data["debug"] 135 | 136 | self.debug.verbose = debug.get("verbose", self.debug.verbose) 137 | self.debug.log_to_file = debug.get("log_to_file", self.debug.log_to_file) 138 | self.debug.log_dir = debug.get("log_dir", self.debug.log_dir) 139 | 140 | # General settings. 141 | if "general" in data: 142 | general = data["general"] 143 | 144 | self.general.save_locally = general.get("save_locally", self.general.save_locally) 145 | self.general.game_check_interval = general.get("game_check_interval", self.general.game_check_interval) 146 | 147 | # Load connections. 148 | if "connections" in data: 149 | conns = data["connections"] 150 | 151 | # Load API. 152 | if "api" in conns: 153 | api = conns["api"] 154 | 155 | self.connections.api.enabled = api.get("enabled", self.connections.api.enabled) 156 | self.connections.api.host = api.get("host", self.connections.api.host) 157 | self.connections.api.token = api.get("token", self.connections.token) 158 | self.connections.api.web_config = api.get("web_config", self.connections.api.web_config) 159 | 160 | # Load database. 161 | if "db" in conns: 162 | db = conns["db"] 163 | 164 | self.connections.db.enabled = db.get("enabled", self.connections.db.enabled) 165 | self.connections.db.host = db.get("host", self.connections.db.host) 166 | self.connections.db.port = db.get("port", self.connections.db.port) 167 | self.connections.db.name = db.get("name", self.connections.db.name) 168 | self.connections.db.user = db.get("user", self.connections.db.user) 169 | self.connections.db.password = db.get("password", self.connections.db.password) 170 | self.connections.db.web_config = db.get("web_config", self.connections.db.web_config) 171 | 172 | # Load bot settings. 173 | if "bot" in data: 174 | bot = data["bot"] 175 | 176 | self.bot.token = bot.get("token", self.bot.token) 177 | 178 | if "servers" in data: 179 | servers = data["servers"] 180 | 181 | for id, srv in servers.items(): 182 | val = Server() 183 | 184 | val.next_game_random = srv.get("next_game_random", val.next_game_random) 185 | val.next_game_cooldown = srv.get("next_game_cooldown", val.next_game_cooldown) 186 | 187 | val.game_start_auto = srv.get("game_start_auto", val.game_start_auto) 188 | val.game_start_cmd = srv.get("game_start_cmd", val.game_start_cmd) 189 | val.game_start_manual = srv.get("game_start_manual", val.game_start_manual) 190 | 191 | # Check for games. 192 | if "games" in srv: 193 | games = srv["games"] 194 | 195 | for k, v in games.items(): 196 | val.games[k] = v 197 | 198 | self.servers[int(id)] = val 199 | 200 | def save_to_fs(self, path: str): 201 | contents = json.dump(self.as_json(), indent = 4) 202 | 203 | # Safely save to file system. 204 | safe_write(path, contents) 205 | 206 | def print(self): 207 | print("Settings") 208 | 209 | print("\tDebug") 210 | debug = self.debug 211 | 212 | print(f"\t\tVerbose => {debug.verbose}") 213 | print(f"\t\tLog To File => {debug.log_to_file}") 214 | print(f"\t\tLog Directory => {debug.log_dir}") 215 | 216 | # General settings 217 | print(f"\tGeneral") 218 | 219 | print(f"\t\tSave Config Locally => {self.general.save_locally}") 220 | print(f"\t\tGame Check Interval => {self.general.game_check_interval}") 221 | 222 | # Bot settings 223 | print(f"\tDiscord Bot") 224 | print(f"\t\tToken => {self.bot.token}") 225 | 226 | # Connection settings 227 | print(f"\tConnections") 228 | 229 | print(f"\t\tAPI") 230 | api = self.connections.api 231 | 232 | print(f"\t\t\tEnabled => {api.enabled}") 233 | print(f"\t\t\tHost => {api.host}") 234 | print(f"\t\t\tToken => {api.token}") 235 | print(f"\t\t\tWeb Config => {api.web_config}") 236 | 237 | print(f"\t\tDatabase") 238 | db = self.connections.db 239 | 240 | print(f"\t\t\tEnabled => {db.enabled}") 241 | print(f"\t\t\tHost => {db.host}") 242 | print(f"\t\t\tPort => {db.port}") 243 | print(f"\t\t\tUser => {db.user}") 244 | print(f"\t\t\tPassword => {db.password}") 245 | print(f"\t\t\tWeb Config => {db.web_config}") 246 | 247 | # Server settings 248 | print(f"\t\tServers") 249 | 250 | for k, v in self.servers.items(): 251 | print(f"\t\t\tServer #{k}") 252 | 253 | print(f"\t\t\t\tNext Game Random => {v.next_game_random}") 254 | print(f"\t\t\t\tNext Game Cooldown => {v.next_game_cooldown}") 255 | 256 | print(f"\t\t\t\tGame Start Auto => {v.game_start_auto}") 257 | print(f"\t\t\t\tGame Start Command => {v.game_start_cmd}") 258 | print(f"\t\t\t\tGame Start Manual => {v.game_start_manual}") 259 | 260 | if len(v.games) > 0: 261 | print(f"\t\t\t\tGames") 262 | 263 | for k, v in v.games.items(): 264 | print(f"\t\t\t\t\t{k}:") 265 | 266 | for k2, v2 in v.items(): 267 | print(f"\t\t\t\t\t\t{k2} => {v2}") -------------------------------------------------------------------------------- /src/connection/__init__.py: -------------------------------------------------------------------------------- 1 | __title__ = "Connection" 2 | __version__ = "1.0.0" 3 | 4 | from .base import Connection 5 | from .api import ConnectionApi 6 | from .db import ConnectionDb -------------------------------------------------------------------------------- /src/connection/api.py: -------------------------------------------------------------------------------- 1 | from connection import Connection 2 | from config import Config 3 | from misc import UserStats 4 | 5 | class ConnectionApi(Connection): 6 | def __init__(self, 7 | host: str, 8 | token: str 9 | ): 10 | self.host = host 11 | self.token = token 12 | 13 | # Load HTTP listener. 14 | self.listener = None 15 | 16 | super().__init__() 17 | 18 | async def get_cfg(self) -> Config: 19 | # To Do: Get config from REST API. 20 | pass 21 | 22 | async def get_user_stats(self, sid: str, uid: str) -> UserStats: 23 | stats: UserStats = UserStats() 24 | 25 | # To Do: Get user stats from REST API. 26 | 27 | return stats 28 | 29 | async def add_user_points(self, sid: str, uid: str, game: str = "unknown", points: int = 0): 30 | # To Do: Update user stats via REST API. 31 | pass 32 | -------------------------------------------------------------------------------- /src/connection/base.py: -------------------------------------------------------------------------------- 1 | from config import Config 2 | from misc import UserStats 3 | 4 | class Connection(): 5 | def __init__(self): 6 | super().__init__() 7 | 8 | async def get_cfg(self) -> Config: 9 | pass 10 | 11 | async def get_user_stats(self, sid: str, uid: str) -> UserStats: 12 | pass 13 | 14 | async def add_user_points(self, sid: str, uid: str, game: str = "unknown", points: int = 0): 15 | pass -------------------------------------------------------------------------------- /src/connection/db.py: -------------------------------------------------------------------------------- 1 | from connection import Connection 2 | from config import Config 3 | from misc import UserStats 4 | 5 | import psycopg 6 | 7 | class ConnectionDb(Connection): 8 | def __init__(self, 9 | host: str = "localhost", 10 | port: int = 5432, 11 | name: str = "discord_mg", 12 | user: str = "root", 13 | password: str = "" 14 | ): 15 | self.host = host 16 | self.port = port 17 | self.name = name 18 | self.user = user 19 | self.password = password 20 | 21 | # Load PostgreSQL connection. 22 | self.db: psycopg.AsyncConnection = None 23 | 24 | super().__init__() 25 | 26 | async def connect(self): 27 | info = f"host={self.host} port={self.port} dbname={self.name} user={self.user} password={self.password}" 28 | 29 | self.db = await psycopg.AsyncConnection.connect( 30 | conninfo = info 31 | ) 32 | 33 | async def setup(self): 34 | # Servers table 35 | table_servers = """ 36 | CREATE TABLE IF NOT EXISTS servers ( 37 | sid BIGINT PRIMARY KEY 38 | ); 39 | """ 40 | 41 | # Users table 42 | table_users = """ 43 | CREATE TABLE IF NOT EXISTS discord_users ( 44 | id BIGINT PRIMARY KEY, 45 | display_name VARCHAR (255), 46 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 47 | updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 48 | ); 49 | """ 50 | 51 | # Points table. 52 | table_points = """ 53 | CREATE TABLE IF NOT EXISTS points ( 54 | id SERIAL PRIMARY KEY, 55 | uid BIGINT NOT NULL, 56 | sid BIGINT NOT NULL, 57 | game VARCHAR(255), 58 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 59 | updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 60 | points int NOT NULL DEFAULT 0, 61 | FOREIGN KEY (uid) REFERENCES discord_users(id) ON DELETE CASCADE, 62 | FOREIGN KEY (sid) REFERENCES servers(sid) ON DELETE CASCADE 63 | ); 64 | """ 65 | 66 | # Execute queries. 67 | await self.db.execute(table_servers) 68 | await self.db.execute(table_users) 69 | await self.db.execute(table_points) 70 | 71 | await self.db.commit() 72 | 73 | async def drop_tables(self): 74 | await self.db.execute("DROP TABLE IF EXISTS discord_users") 75 | await self.db.execute("DROP TABLE IF EXISTS servers") 76 | await self.db.execute("DROP TABLE IF EXISTS points") 77 | 78 | async def get_cfg(self) -> Config: 79 | pass 80 | 81 | async def get_user_stats(self, sid: str, uid: str) -> UserStats: 82 | stats: UserStats = UserStats() 83 | 84 | q = """ 85 | WITH ins_server AS ( 86 | INSERT INTO servers (sid) 87 | VALUES (%s) 88 | ON CONFLICT (sid) DO NOTHING 89 | ), 90 | ins_user AS ( 91 | INSERT INTO discord_users (id) 92 | VALUES (%s) 93 | ON CONFLICT(id) DO NOTHING 94 | ) 95 | SELECT 96 | COALESCE(SUM(CASE WHEN sid = %s THEN points END), 0) AS srv_points, 97 | COALESCE(SUM(points), 0) AS global_points 98 | FROM points 99 | WHERE uid = %s 100 | """ 101 | 102 | cur = self.db.cursor() 103 | await cur.execute(q, (sid, uid, sid, uid)) 104 | res = await cur.fetchone() 105 | 106 | if res is not None: 107 | stats.srv_points = int(res[0]) 108 | stats.global_points += int(res[1]) 109 | 110 | await cur.close() 111 | 112 | return stats 113 | 114 | async def add_user_points(self, sid: str, uid: str, game: str = "unknown", points: int = 0): 115 | q = """ 116 | WITH ins_server AS ( 117 | INSERT INTO servers (sid) 118 | VALUES (%s) 119 | ON CONFLICT (sid) DO NOTHING 120 | ), 121 | ins_user AS ( 122 | INSERT INTO discord_users (id) 123 | VALUES (%s) 124 | ON CONFLICT (id) DO NOTHING 125 | ) 126 | INSERT INTO points (sid, uid, game, points) 127 | VALUES (%s, %s, %s, %s) 128 | """ 129 | 130 | cur = self.db.cursor() 131 | await cur.execute(q, (sid, uid, sid, uid, game, points)) 132 | 133 | await cur.close() 134 | 135 | async def close(self): 136 | await self.db.close() -------------------------------------------------------------------------------- /src/controller/__init__.py: -------------------------------------------------------------------------------- 1 | __title__ = "Controller" 2 | __version__ = "1.0.0" 3 | 4 | from .game import GameController -------------------------------------------------------------------------------- /src/controller/game.py: -------------------------------------------------------------------------------- 1 | import discord 2 | import asyncio 3 | import traceback 4 | 5 | from datetime import datetime 6 | from bot import Discord 7 | from server import Server 8 | from config import Config 9 | from utils import debug_msg 10 | from connection import Connection 11 | 12 | class GameController(): 13 | def __init__(self, bot: Discord, cfg: Config, conn: Connection): 14 | self.bot = bot 15 | self.cfg = cfg 16 | self.conn = conn 17 | self.servers: dict[int, Server] = {} 18 | 19 | # Parse servers. 20 | self.parse_servers() 21 | 22 | # Register events and commands. 23 | self.register_events() 24 | self.register_commands() 25 | 26 | def parse_servers(self): 27 | # Fill servers from config. 28 | for k, srv in self.cfg.servers.items(): 29 | debug_msg(2, self.cfg, f"Setting up server #{k}...") 30 | 31 | # We need to handle our games first. 32 | games: dict[str, any] = {} 33 | 34 | for k2, game in srv.games.items(): 35 | debug_msg(3, self.cfg, f"Adding game '{k2}' to server #{k}...") 36 | 37 | games[k2] = game 38 | 39 | self.servers[int(k)] = Server( 40 | bot = self.bot, 41 | cfg = self.cfg, 42 | conn = self.conn, 43 | id = int(k), 44 | games = games, 45 | next_game_random = srv.next_game_random, 46 | next_game_cooldown = srv.next_game_cooldown, 47 | game_start_auto = srv.game_start_auto, 48 | game_start_cmd = srv.game_start_cmd, 49 | game_start_manual = srv.game_start_manual 50 | ) 51 | 52 | async def game_thread(self): 53 | debug_msg(1, self.cfg, "Starting game controller thread...") 54 | 55 | while True: 56 | debug_msg(4, self.cfg, "Checking servers in game controller thread...") 57 | 58 | # Get currentr date time. 59 | now = datetime.now() 60 | 61 | # Loop through all servers and check 62 | for k, srv in self.servers.items(): 63 | # If we aren't ready for a new game, ignore. 64 | if not srv.game_start_auto or srv.cur_game is not None or srv.next_game_cooldown < 1 or (srv.last_game is not None and now.timestamp() < (srv.last_game.timestamp() + srv.next_game_cooldown)): 65 | continue 66 | 67 | debug_msg(4, self.cfg, f"Found server #{k} ready for a new game...") 68 | 69 | try: 70 | # Start new game. 71 | asyncio.create_task(srv.start_new_game()) 72 | except Exception as e: 73 | print(f"Failed to start new game for server ID '{k}' due to exception.") 74 | print(e) 75 | traceback.print_exc() 76 | 77 | # Sleep. 78 | await asyncio.sleep(self.cfg.general.game_check_interval) 79 | 80 | def register_events(self): 81 | @self.bot.event 82 | async def on_message(msg: discord.Message): 83 | # Make sure this is from within a server. 84 | if msg.guild is None: 85 | return 86 | 87 | # Extract server ID 88 | sid = msg.guild.id 89 | 90 | if sid not in self.servers: 91 | return 92 | 93 | srv = self.servers[sid] 94 | 95 | if srv.cur_game is not None: 96 | await srv.cur_game.process_msg(msg) 97 | 98 | # Process commands. 99 | await self.bot.process_commands(msg) 100 | 101 | def register_commands(self): 102 | @self.bot.command("start") 103 | async def start(ctx): 104 | print("Executed start") 105 | 106 | @self.bot.command("stop") 107 | async def stop(ctx): 108 | print("Executed stop") 109 | 110 | @self.bot.command("stats") 111 | async def stats(ctx): 112 | author_id = ctx.author.id 113 | srv_id = ctx.guild.id 114 | 115 | if not self.conn: 116 | await ctx.send(f"<@{author_id}> Connection not available.") 117 | 118 | return 119 | 120 | try: 121 | stats = await self.conn.get_user_stats(str(srv_id), str(author_id)) 122 | except Exception as e: 123 | debug_msg(0, self.cfg, f"[CMD] Failed to retrieve user stats for server '{srv_id}' and user ID '{author_id}' due to exception.") 124 | debug_msg(0, self.cfg, e) 125 | 126 | await ctx.send(f"<@{author_id}> You currently have **{stats.srv_points}** server points and **{stats.global_points}** global points!") -------------------------------------------------------------------------------- /src/game/__init__.py: -------------------------------------------------------------------------------- 1 | __title__ = "Games" 2 | __version__ = "1.0.0" 3 | 4 | from .base import GameBase -------------------------------------------------------------------------------- /src/game/base.py: -------------------------------------------------------------------------------- 1 | import discord 2 | 3 | from datetime import datetime 4 | 5 | from bot import Discord 6 | from config import Config 7 | from server import Server 8 | from connection import Connection 9 | 10 | class GameBase(): 11 | points: dict[int, int] = {} 12 | 13 | def __init__(self, 14 | bot: Discord, 15 | cfg: Config, 16 | conn: Connection, 17 | srv: Server, 18 | name: str = "Game", 19 | pick_weight = 50.0, 20 | channels: list[int] = [] 21 | ): 22 | self.bot = bot 23 | self.cfg = cfg 24 | self.conn = conn 25 | self.srv = srv 26 | self.name = name 27 | self.pick_weight = pick_weight 28 | self.channels = channels 29 | 30 | async def start(self): 31 | pass 32 | 33 | async def end(self): 34 | # Adjust last game time and current game. 35 | self.srv.last_game = datetime.now() 36 | self.srv.cur_game = None 37 | 38 | def process_msg(self, msg: discord.Message): 39 | pass 40 | -------------------------------------------------------------------------------- /src/game/questionnaire.py: -------------------------------------------------------------------------------- 1 | import discord 2 | import random 3 | import asyncio 4 | 5 | from bot import Discord 6 | from config import Config 7 | from game import GameBase 8 | from server import Server 9 | from utils import debug_msg 10 | 11 | from connection import Connection 12 | 13 | class Answer(): 14 | def __init__(self, answer: str, case_sensitive: bool = False, contains: bool = False): 15 | self.answer = answer 16 | self.case_sensitive = case_sensitive 17 | self.contains = contains 18 | 19 | class Question(): 20 | def __init__(self, question: str, answers: list[Answer], points: int = 1, image: str = None, duration: float = None): 21 | self.question = question 22 | self.answers = answers 23 | self.points = points 24 | self.image = image 25 | self.duration = duration 26 | 27 | def __eq__(self, o): 28 | if isinstance(o, Question): 29 | return self.question == o.question 30 | 31 | return False 32 | 33 | def __hash__(self): 34 | return hash(self.question) 35 | 36 | class Game(GameBase): 37 | cur_question: Question = None 38 | questions_asked: list[Question] = [] 39 | users_answered: list[int] = [] 40 | 41 | def __init__(self, 42 | bot: Discord, 43 | cfg: Config, 44 | conn: Connection, 45 | srv: Server, 46 | questions: list[Question], 47 | channels: list[int] = [], 48 | default_channel: int = None, 49 | name: str = "Questionnaire", 50 | pick_weight: float = 50.0, 51 | time_per_question = 30.0, 52 | min_questions_per_round = 5, 53 | max_questions_per_round = 10, 54 | announce_end = True 55 | ): 56 | self.bot = bot 57 | self.cfg = cfg 58 | self.conn = conn 59 | self.srv = srv 60 | self.channels = channels 61 | self.default_channel = default_channel 62 | 63 | # If we don't have a default channel, try to pick a random channel from channels list. 64 | if self.default_channel is None and len(self.channels) > 0: 65 | self.default_channel = random.choice(self.channels) 66 | 67 | self.name = name 68 | self.pick_weight = pick_weight 69 | 70 | # Retrieve questions and parse if needed. 71 | all_questions: list[Question] = [] 72 | 73 | if isinstance(questions, list): 74 | if all(isinstance(q, Question) for q in questions): 75 | all_questions = questions 76 | # If this is a dictionary, we need to convert to Question classes. 77 | elif all(isinstance(q, dict) for q in questions): 78 | # Parse questions dictionary into question class. 79 | for q in questions: 80 | if "answers" not in q: 81 | continue 82 | 83 | answers: list[Answer] = [] 84 | 85 | # Compile answers. 86 | for a in q["answers"]: 87 | if "answer" not in a: 88 | continue 89 | 90 | new_ans = Answer( 91 | answer = str(a["answer"]), 92 | case_sensitive = bool(a["case_sensitive"]) if "case_sensitive" in a else False, 93 | contains = bool(a["contains"]) if "contains" in a else False 94 | ) 95 | 96 | answers.append(new_ans) 97 | 98 | new_q = Question( 99 | question = str(q["question"]) if "question" in q else None, 100 | points = int(q["points"]) if "points" in q else 1, 101 | image = str(q["image"]) if "image" in q else None, 102 | duration = float(q["duration"]) if "duration" in q else None, 103 | answers = answers 104 | ) 105 | 106 | all_questions.append(new_q) 107 | 108 | self.questions = all_questions 109 | self.time_per_question = float(time_per_question) 110 | self.min_questions_per_round = int(min_questions_per_round) 111 | self.max_questions_per_round = int(max_questions_per_round) 112 | self.announce_end = bool(announce_end) 113 | 114 | super().__init__( 115 | bot = bot, 116 | cfg = cfg, 117 | conn = conn, 118 | srv = srv 119 | ) 120 | 121 | async def start(self, chan_id: int = None): 122 | # Execute base class. 123 | await super().start() 124 | 125 | # If channel is None, pick default channel. 126 | if chan_id is None: 127 | chan_id = self.default_channel 128 | 129 | # Make sure we have a channel. 130 | if chan_id is None: 131 | debug_msg(3, self.cfg, f"[Questionnaire] No channel found for game. Aborting...") 132 | 133 | return 134 | 135 | # Empty questions asked. 136 | self.questions_asked = [] 137 | 138 | # Get max questions. 139 | questions_max = random.randint(self.min_questions_per_round, self.max_questions_per_round) 140 | 141 | debug_msg(3, self.cfg, f"[Questionnaire] Starting game for server #{self.srv.id} (questions => {questions_max}, per question time => {self.time_per_question})...") 142 | 143 | # Loop through amount of questions. 144 | for cnt in range(questions_max): 145 | # Ask question. 146 | await self.ask_new_question(chan_id) 147 | 148 | # Make sure we got a valid question. 149 | if self.cur_question is None: 150 | break 151 | 152 | # Determine duration. 153 | dur = self.time_per_question 154 | 155 | if self.cur_question.duration is not None and self.cur_question.duration > 0.0: 156 | dur = self.cur_question.duration 157 | 158 | # Give it time. 159 | await asyncio.sleep(dur) 160 | 161 | # Append to questions asked. 162 | self.questions_asked.append(self.cur_question) 163 | 164 | # Clear users answered list. 165 | self.users_answered = [] 166 | 167 | # Shut down game. 168 | await self.end(chan_id) 169 | 170 | async def end(self, chan_id: int): 171 | # Empty questions asked and users answered. 172 | self.questions_asked = [] 173 | 174 | debug_msg(3, self.cfg, f"[Questionnaire] Ending game for server #{self.srv.id}...") 175 | 176 | # Execute base class. 177 | await super().end() 178 | 179 | # Check if we should announce. 180 | if self.announce_end: 181 | chan = self.bot.get_channel(chan_id) 182 | 183 | if chan: 184 | embed = discord.Embed( 185 | title = "Questionnaire", 186 | description = "The game has ended!" 187 | 188 | ) 189 | 190 | await chan.send(embed = embed) 191 | 192 | async def ask_new_question(self, chan_id: int): 193 | # We need to make sure we don't ask the same question twice in the same round! 194 | questions_available = [question for question in self.questions if question not in self.questions_asked] 195 | 196 | # Make sure we have more questions to pick from. 197 | if not questions_available: 198 | self.cur_question = None 199 | 200 | return 201 | 202 | # Get new question. 203 | self.cur_question = random.choice(questions_available) 204 | 205 | debug_msg(3, self.cfg, f"[Questionnaire] Picking new question '{self.cur_question.question}' for server #{self.srv.id}!") 206 | 207 | # Get channel. 208 | chan = self.bot.get_channel(chan_id) 209 | 210 | if chan is None: 211 | debug_msg(3, self.cfg, f"[Questionnaire] Failed to retrieve channel #{chan_id} when sending questions for server #{self.srv.id}") 212 | 213 | return 214 | 215 | embed = discord.Embed( 216 | title = "Questionnaire", 217 | description = f"Question: {self.cur_question.question}", 218 | ) 219 | 220 | # Check if we should set an image. 221 | if self.cur_question.image is not None: 222 | embed.set_image( 223 | url = self.cur_question.image 224 | ) 225 | 226 | # Send the question. 227 | await chan.send(embed = embed) 228 | 229 | def is_correct(self, input: str): 230 | if self.cur_question is None: 231 | return False 232 | 233 | # Loop through answers 234 | for answer in self.cur_question.answers: 235 | # Strip input and answer. 236 | input_f = input.strip() 237 | answer_f = answer.answer.strip() 238 | 239 | # Retrieve case sensitive. 240 | case_sensitive = False 241 | 242 | if answer.case_sensitive: 243 | case_sensitive = True 244 | 245 | # Retrieve contains. 246 | contains = False 247 | 248 | if answer.contains: 249 | contains = True 250 | 251 | # Check if we should lower-case. 252 | if not case_sensitive: 253 | input_f = input_f.lower() 254 | answer_f = answer_f.lower() 255 | 256 | # Check input and answer. 257 | if input_f == answer_f or (contains and input_f in answer_f): 258 | return True 259 | 260 | return False 261 | 262 | async def process_msg(self, msg: discord.Message): 263 | if self.cur_question is None or msg.author.id == self.bot.user.id: 264 | return 265 | 266 | if len(self.channels) > 0 and msg.channel.id not in self.channels: 267 | return 268 | 269 | author_id = msg.author.id 270 | srv_id = msg.guild.id 271 | 272 | # Check if we've answered this question already. 273 | if author_id in self.users_answered: 274 | return 275 | 276 | # Check if our content's is correct to the current question. 277 | try: 278 | if self.is_correct(msg.content): 279 | # Add points. 280 | points = self.cur_question.points 281 | 282 | if author_id not in self.points: 283 | self.points[author_id] = points 284 | else: 285 | self.points[author_id] += points 286 | 287 | await msg.channel.send(f"<@{author_id}> was correct and awarded **{points}** points!") 288 | 289 | # Attempt to award points. 290 | try: 291 | await self.conn.add_user_points(str(srv_id), str(author_id), "questionnaire", points) 292 | except Exception as e: 293 | debug_msg(0, self.cfg, f"[Questionnaire] Failed to add {points} points to user ID '{author_id}' due to exception") 294 | debug_msg(0, self.cfg, e) 295 | 296 | self.users_answered.append(author_id) 297 | except Exception as e: 298 | debug_msg(0, self.cfg, f"[Questionnaire] Failed to process message due to exception.") 299 | debug_msg(0, self.cfg, e) -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import traceback 3 | import discord 4 | import asyncio 5 | 6 | from bot import Discord 7 | from config import Config 8 | from controller import GameController 9 | from connection import ConnectionApi, ConnectionDb 10 | from server import Server 11 | from utils import debug_msg 12 | 13 | HELP_MENU = f"""USAGE: python3 src/main.py [--cfg= -l -h] 14 | \t--cfg => The path to the config file. 15 | \t-s --setup => Setup database tables. 16 | \t-d --drop => Drop all database tables. 17 | \t-l --list => Whether to list config contents. 18 | \t-h --help => Whether to print the help menu.""" 19 | 20 | async def main(): 21 | # CLI arguments. 22 | cfg_path = "./conf.json" 23 | setup = False 24 | drop_tables = False 25 | list = False 26 | help = False 27 | 28 | # Parse CLI. 29 | for k, arg in enumerate(sys.argv): 30 | # Handle config path. 31 | if arg.startswith("cfg="): 32 | cfg_path = arg.split('=')[1] 33 | elif arg == "--cfg" or arg == "c": 34 | val_idx = k + 1 35 | 36 | if val_idx < len(sys.argv): 37 | cfg_path = sys.argv[val_idx] 38 | 39 | # Setup. 40 | if arg == "-s" or arg == "--setup": 41 | setup = True 42 | 43 | # Drop tables. 44 | if arg == "-d" or arg == "--drop": 45 | drop_tables = True 46 | 47 | # Handle list. 48 | if arg == "-l" or arg == "--list": 49 | list = True 50 | 51 | # Handle help menu. 52 | if arg == "-h" or arg == "--help": 53 | help = True 54 | 55 | # Print help menu if we need to. 56 | if help: 57 | print(HELP_MENU) 58 | 59 | sys.exit(0) 60 | 61 | # Load config. 62 | cfg = Config() 63 | 64 | try: 65 | cfg.load_from_fs(cfg_path) 66 | except Exception as e: 67 | print("Failed to load config!") 68 | print(e) 69 | 70 | # traceback.print_exc() 71 | 72 | sys.exit(1) 73 | 74 | # Check if we want to print the config. 75 | if list: 76 | cfg.print() 77 | 78 | sys.exit(0) 79 | 80 | # Create connection. 81 | conn = None 82 | save_to_fs = False 83 | 84 | # Check API connection. 85 | if cfg.connections.api.enabled: 86 | try: 87 | conn = ConnectionApi(cfg.connections.api.host, cfg.connections.api.token) 88 | except Exception as e: 89 | debug_msg(0, cfg, "Failed to setup API connection. Falling back to database if enabled.") 90 | debug_msg(0, cfg, e) 91 | 92 | # Check web config. 93 | if conn is not None and cfg.connections.api.web_config: 94 | try: 95 | await conn.get_cfg() 96 | 97 | save_to_fs = True 98 | except Exception as e: 99 | debug_msg(0, cfg, "Failed to retrieve config through web API due to exception.") 100 | debug_msg(0, cfg, e) 101 | 102 | # Fallback to database. 103 | if conn is None and cfg.connections.db.enabled: 104 | try: 105 | conn = ConnectionDb( 106 | host = cfg.connections.db.host, 107 | port = cfg.connections.db.port, 108 | name = cfg.connections.db.name, 109 | user = cfg.connections.db.user, 110 | password = cfg.connections.db.password 111 | ) 112 | 113 | # Connect to database. 114 | await conn.connect() 115 | 116 | debug_msg(1, cfg, f"[DB] Connected to '{conn.host}:{conn.port}'...") 117 | except Exception as e: 118 | debug_msg(0, cfg, "[DB] Failed to setup database due to exception. Web config and stats will be disabled!") 119 | debug_msg(0, cfg, e) 120 | 121 | # Check for drop tables. 122 | if drop_tables: 123 | try: 124 | await conn.drop_tables() 125 | 126 | debug_msg(1, cfg, "[DB] Dropped all tables!") 127 | except Exception as e: 128 | debug_msg(0, cfg, "[DB] Failed to drop tables in database due to exception.") 129 | debug_msg(0, cfg, e) 130 | 131 | # Setup tables. 132 | if setup: 133 | try: 134 | await conn.setup() 135 | 136 | debug_msg(1, cfg, "[DB] Setup all tables!") 137 | except Exception as e: 138 | debug_msg(0, cfg, "[DB] Failed to setup tables in database due to exception.") 139 | debug_msg(0, cfg, e) 140 | 141 | # Check web config 142 | if conn is not None and cfg.connections.db.web_config: 143 | try: 144 | await conn.get_cfg() 145 | 146 | save_to_fs = True 147 | except Exception as e: 148 | debug_msg(0, cfg, "Failed to retrieve config through the database due to exception.") 149 | debug_msg(0, cfg, e) 150 | 151 | # Check if we should save the config to our file system. 152 | if cfg.general.save_locally and save_to_fs: 153 | try: 154 | cfg.save_to_fs(cfg_path) 155 | except Exception as e: 156 | debug_msg(0, cfg, f"Failed to save config ({cfg_path}) to file system due to exception.") 157 | debug_msg(0, cfg, e) 158 | 159 | # Configure Discord intents. 160 | intents = discord.Intents.default() 161 | intents.message_content = True 162 | 163 | # Create Discord bot. 164 | try: 165 | bot = Discord(cfg.bot.token, intents) 166 | 167 | # Connect and run bot. 168 | asyncio.create_task(bot.connect_and_run()) 169 | except Exception as e: 170 | debug_msg(0, cfg, "Failed to start and run Discord bot due to exception!") 171 | debug_msg(0, cfg, e) 172 | 173 | traceback.print_exc() 174 | 175 | sys.exit(1) 176 | 177 | debug_msg(1, cfg, "Connecting Discord bot...") 178 | 179 | # Wait for bot to become ready. 180 | while True: 181 | if bot.ready: 182 | break 183 | 184 | await asyncio.sleep(1) 185 | 186 | debug_msg(2, cfg, f"Discord bot connected successfully!") 187 | debug_msg(2, cfg, f"Creating game controller...") 188 | 189 | # Create controller and pass Discord bot. 190 | controller = GameController(bot, cfg, conn) 191 | 192 | debug_msg(2, cfg, f"Starting game controller task...") 193 | 194 | # Create controller task for handling games. 195 | await asyncio.create_task(controller.game_thread()) 196 | 197 | debug_msg(1, cfg, "Exiting program!") 198 | 199 | # Cleanup connection. 200 | if conn is not None: 201 | await conn.close() 202 | 203 | if __name__ == "__main__": 204 | asyncio.run(main()) -------------------------------------------------------------------------------- /src/misc/__init__.py: -------------------------------------------------------------------------------- 1 | __title__ = "Misc" 2 | __version__ = "1.0.0" 3 | 4 | from .stats import UserStats -------------------------------------------------------------------------------- /src/misc/stats.py: -------------------------------------------------------------------------------- 1 | class UserStats(): 2 | def __init__(self): 3 | self.srv_points = 0 4 | self.global_points = 0 -------------------------------------------------------------------------------- /src/server/__init__.py: -------------------------------------------------------------------------------- 1 | __title__ = "Servers" 2 | __version__ = "1.0.0" 3 | 4 | from .base import Server -------------------------------------------------------------------------------- /src/server/base.py: -------------------------------------------------------------------------------- 1 | import random 2 | import importlib 3 | 4 | from datetime import datetime 5 | from bot import Discord 6 | from config import Config 7 | from connection import Connection 8 | 9 | class Server(): 10 | def __init__(self, 11 | bot: Discord, 12 | cfg: Config, 13 | conn: Connection, 14 | id: int, 15 | games: dict[str, any], 16 | next_game_random = True, 17 | next_game_cooldown = 120.0, 18 | game_start_auto = False, 19 | game_start_cmd = True, 20 | game_start_manual = True 21 | ): 22 | self.bot = bot 23 | self.id = id 24 | self.cfg = cfg 25 | self.conn = conn 26 | 27 | self.cur_game = None 28 | self.last_game: datetime = None 29 | 30 | # Assign settings. 31 | self.next_game_cooldown = next_game_cooldown 32 | self.next_game_random = next_game_random 33 | 34 | self.game_start_auto = game_start_auto 35 | self.game_start_cmd = game_start_cmd 36 | self.game_start_manual = game_start_manual 37 | 38 | # Parse server games. 39 | self.games: dict[str, any] = {} 40 | 41 | for k, v in games.items(): 42 | settings = v 43 | 44 | # We'll want to load the custom game class. 45 | # To Do: FIND A BETTER WAY TO DO THIS WITHOUT IMPORTING THE GAME MODULE FOR EVERY SERVER'S GAME. 46 | try: 47 | m = importlib.import_module(f"game.{k}") 48 | 49 | game_cl = m.Game( 50 | bot = self.bot, 51 | cfg = self.cfg, 52 | conn = self.conn, 53 | srv = self, 54 | **settings 55 | ) 56 | except Exception as e: 57 | print(f"Failed to load game '{k}' for server '{self.id}' due to exception.") 58 | print(e) 59 | 60 | continue 61 | 62 | self.games[k] = game_cl 63 | 64 | def to_dict(self): 65 | return { 66 | "next_game_cooldown": self.next_game_cooldown, 67 | "next_game_random": self.next_game_random, 68 | "game_start_auto": self.game_start_auto, 69 | "game_start_cmd": self.game_start_cmd, 70 | "game_start_manual": self.game_start_manual, 71 | "games": self.games 72 | } 73 | 74 | def get_next_game_key(self): 75 | # Check for random. 76 | if self.next_game_random: 77 | next_key = random.choice(list(self.games.keys())) 78 | 79 | return next_key 80 | 81 | # Return next game in dictionary. 82 | keys = self.games.keys() 83 | cur_key = self.cur_game 84 | 85 | if cur_key not in self.games: 86 | raise Exception("Current game is not in games dictionary.") 87 | 88 | cur_idx = keys.index(cur_key) 89 | 90 | next_idx = (cur_idx + 1) % len(keys) 91 | 92 | return keys[next_idx] 93 | 94 | async def start_new_game(self): 95 | # Get next game and start. 96 | next_game = self.get_next_game_key() 97 | 98 | self.cur_game = self.games[next_game] 99 | 100 | await self.cur_game.start() -------------------------------------------------------------------------------- /src/utils/__init__.py: -------------------------------------------------------------------------------- 1 | __title__ = "Utils" 2 | __version__ = "1.0.0" 3 | 4 | from .file import safe_write 5 | from .debug import debug_msg, debug_msg_raw -------------------------------------------------------------------------------- /src/utils/debug.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | def debug_msg_raw(req_lvl: int, cur_lvl: int, msg: str, log_dir: str = None): 4 | if cur_lvl < req_lvl: 5 | return 6 | 7 | raw_msg = f"[{req_lvl}] {msg}" 8 | 9 | print(raw_msg) 10 | 11 | # Check for logging to a file. 12 | if log_dir is not None: 13 | # Format message with date prepended. 14 | date_f = datetime.now().strftime("%y-%m-%d %H-%M:%S") 15 | 16 | log_msg = f"[{date_f}]{raw_msg}" 17 | 18 | # Format file name. 19 | date_f = datetime.now().strftime("%y-%m-%d") 20 | file_f = f"{date_f}.log" 21 | 22 | full_path = f"{log_dir}/{file_f}" 23 | 24 | with open(full_path, 'a+') as f: 25 | f.write(f"{log_msg}\n") 26 | 27 | def debug_msg(req_lvl: int, cfg: any, msg: str): 28 | log_dir: str = None 29 | 30 | if cfg.debug.log_to_file: 31 | log_dir = cfg.debug.log_dir 32 | 33 | debug_msg_raw(req_lvl, cfg.debug.verbose, msg, log_dir) -------------------------------------------------------------------------------- /src/utils/file.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | def safe_write(path: str, contents: str, remove_temp: bool = True): 4 | tmp = f"{path}.tmp" 5 | 6 | with open(tmp, 'w') as f: 7 | f.write(contents) 8 | 9 | os.replace(tmp, path) 10 | 11 | if remove_temp: 12 | os.remove(tmp) --------------------------------------------------------------------------------