diff --git a/.dockerignore b/.dockerignore index 81d69cf..c20fbac 100644 --- a/.dockerignore +++ b/.dockerignore @@ -18,5 +18,10 @@ Dockerfile # Documentation README.md +#User Data +userconfig.yaml +exampleconfig.yaml +/user_config + # Backup **/config.py.bak \ No newline at end of file diff --git a/.gitignore b/.gitignore index c8940d3..9c1354f 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,9 @@ Thumbs.db private.py config.py config.py.bak +userconfig.yaml +exampleconfig.yaml +/user_config # Docker docker-compose.yaml diff --git a/Dockerfile b/Dockerfile index 02c7f19..58da041 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,10 @@ RUN apk add opus ENV DISCORD_TOKEN= ENV RAINWAVE_ID= ENV RAINWAVE_KEY= +ENV LOG_LEVEL="INFO" COPY . /rainwavediscordbot -CMD [ "python", "./app/rainwavebot.py" ] \ No newline at end of file +CMD [ "python", "./app/rainwavebot.py" ] + +#Build command `docker build -t rainwavetest .` \ No newline at end of file diff --git a/app/config/config.py.example b/app/config/config.py.example deleted file mode 100644 index 909b75f..0000000 --- a/app/config/config.py.example +++ /dev/null @@ -1,72 +0,0 @@ -class botChannels: - #To restrict which voice channels the bot can use, set below to true. - restrictVoiceChannels = False - #If above is set to true, list allowed channel/s IDs in a list as shown below. - allowedVoiceChannels = [123456789012345678, 987654321098765432] - - #To restrict which test channels the bot can receive commands on, set below to true. - restrictTextChannels = False - #If above is set to true, list allowed channel/s IDs in a list as shown below. - allowedTextChannels = [112233445566778899, 998877665544332211] - - #If you want the bot to log, login and error info in a channel, set below to True - enableLogChannel = True - #If above is set to true, list your logging channel ID below - logChannel = 246813579024681357 - -class private: - #Discord bot token - discordBotToken = 'F4K3T0K3N_ikb331nmGsvgHPGAv8jwFV3gKFs9eR.nF4lgje68ZdrEX9aSJ' - - #Rainwave API ID - rainwaveID = 12345 - - #Rainwave API Key - rainwaveKey = '12345abcde' - -class dependencies: - #Location of FFMPEG file. - ffmpeg = "ffmpeg-2021-11-22/bin/ffmpeg.exe" - - #Location of opus file. - opus = "" - -class options: - #Change the name of your bot here - botName = "Rain.Wave" - - #Change the prefix of your bot here - botPrefix = "rw." - - #Enables display of song progress in a numerical style timer e.g. [01:05/01:21] - enableProgressTimes = True - - #Enables display of song progress in a graphical manner e.g. ▰▰▰▱▱▱▱▱▱▱▱ - enableProgressBar = True - - #Allows selection of progress bar style between 1 and 2 - #1 is a left to right "fill" style progress bar e.g. ▰▰▰▱▱▱▱▱▱▱▱ - #2 is a moving indicator style progress bar e.g. ▱▱▱▰▱▱▱▱▱▱▱ - progressBarStyle = 1 - - #Allows selection of progress bar character length - progressBarLength = 14 - - #Allows selection of characters which make up the progress bar - #for style 1 ['▰','▱'] or ['▶','▷'] or ['█','░'] is suggested - #for style 2 ['═','╪'] or ['—','⎔'] or ['▬',':radio_button:'] is suggested - progressBarCharacters = ['▰','▱'] - - #Allows selection of embed sidebar color as an (r, g, b) value. - #You can use https://it-tools.tech/color-converter to generate an rgb color value - embedColor = (24, 135, 210) - - #Numer of seconds between refresh of progress bar and timer, can be increased if user is being rate limited. - #Minimum value can never be below 6. - refreshDelay = 6 - - #If set to `True`, bot will disconnect if no users are present in the bots voice channel. - autoDisconnect = True - - #Logging level, can be set to DEBUG, INFO, WARNING, ERROR, CRITICAL. If INFO provides too much info, switch to WARNING - logLevel = "INFO" \ No newline at end of file diff --git a/app/load_config/defaultconfig.yaml b/app/load_config/defaultconfig.yaml new file mode 100644 index 0000000..e7c9c47 --- /dev/null +++ b/app/load_config/defaultconfig.yaml @@ -0,0 +1,72 @@ +botChannels: + #To restrict which voice channels the bot can use, set below to true. + restrictVoiceChannels: False + #If above is set to true, list allowed channel/s IDs in a list as shown below. + allowedVoiceChannels: [123456789012345678, 987654321098765432] + + #To restrict which test channels the bot can receive commands on, set below to true. + restrictTextChannels: False + #If above is set to true, list allowed channel/s IDs in a list as shown below. + allowedTextChannels: [112233445566778899, 998877665544332211] + + #If you want the bot to log, login and error info in a channel, set below to True + enableLogChannel: False + #If above is set to true, list your logging channel ID below + logChannel: 246813579024681357 + +private: + #Discord bot token + discordBotToken: F4K3T0K3N_ikb331nmGsvgHPGAv8jwFV3gKFs9eR.nF4lgje68ZdrEX9aSJ + + #Rainwave API ID + rainwaveID: 12345 + + #Rainwave API Key + rainwaveKey: 12345abcde + +dependencies: + #Location of FFMPEG file. + ffmpegLocation: "/usr/bin/ffmpeg" + + #Location of opus file. + opusLocation: "/usr/lib/libopus.so.0" + +options: + #Change the name of your bot here + botName: "Rain.Wave" + + #Change the prefix of your bot here + botPrefix: "rw." + + #Enables display of song progress in a numerical style timer e.g. [01:05/01:21] + enableProgressTimes: True + + #Enables display of song progress in a graphical manner e.g. ▰▰▰▱▱▱▱▱▱▱▱ + enableProgressBar: True + + #Allows selection of progress bar style between 1 and 2 + #1 is a left to right "fill" style progress bar e.g. ▰▰▰▱▱▱▱▱▱▱▱ + #2 is a moving indicator style progress bar e.g. ▱▱▱▰▱▱▱▱▱▱▱ + progressBarStyle: 1 + + #Allows selection of progress bar character length + progressBarLength: 14 + + #Allows selection of characters which make up the progress bar + #for style 1 ['▰','▱'] or ['▶','▷'] or ['█','░'] is suggested + #for style 2 ['═','╪'] or ['—','⎔'] or ['▬',':radio_button:'] is suggested + progressBarCharacters: ['▰','▱'] + + #Allows selection of embed sidebar color as an (r, g, b) value. + #You can use https://it-tools.tech/color-converter to generate an rgb color value + embedColor: [24, 135, 210] + + #Numer of seconds between refresh of progress bar and timer, can be increased if user is being rate limited. + #Minimum value can never be below 6. + refreshDelay: 6 + + #If set to `True`, bot will disconnect if no users are present in the bots voice channel. + autoDisconnect: True + + #Logging level, can be set to DEBUG, INFO, WARNING, ERROR, CRITICAL. If INFO provides too much info, switch to WARNING + logLevel: "INFO" \ No newline at end of file diff --git a/app/load_config/load_config.py b/app/load_config/load_config.py new file mode 100644 index 0000000..fadc736 --- /dev/null +++ b/app/load_config/load_config.py @@ -0,0 +1,63 @@ +import os +import shutil +import yaml #Needs to be added to docker setup. +from logger import logger + +OS_SLASH = '/' +USER_CONFIG_FOLDER = f"{OS_SLASH}user_config" +DEFAULT_CONFIG_PATH = f"{OS_SLASH}load_config{OS_SLASH}defaultconfig.yaml" +EXAMPLE_CONFIG_PATH = f"{USER_CONFIG_FOLDER}{OS_SLASH}exampleconfig.yaml" +USER_CONFIG_PATH = f"{USER_CONFIG_FOLDER}{OS_SLASH}userconfig.yaml" + +EXAMPLE_CONFIG_MESSAGE = "#This is the example config file. It is recreated on every launch.\n#ALL DATA STORED IN THIS FILE WILL BE DELETED!!!" +USER_CONFIG_MESSAGE = "#This is the user config file, changes to this file are persistent.\n#If you delete this file, it will be recreated and all default values will be restored." + +class config: + def __init__(self, startingPath): + with open(startingPath+DEFAULT_CONFIG_PATH, "r+") as openDefaultFile: + #NOTE this section fails on windows: `UnicodeDecodeError: 'charmap' codec can't decode byte 0x90 in position 1992: character maps to ` + #which can be fixed with `encoding='utf-8'` or by adding a windows environmental variable `PYTHONUTF8` with a value of `1` + if os.path.isdir(startingPath+USER_CONFIG_FOLDER) is False: #Create /user_config if it doesn't exist + logger.debug(f"Creating {USER_CONFIG_FOLDER}") + os.mkdir(startingPath+USER_CONFIG_FOLDER) + else: + logger.debug(f"Located {USER_CONFIG_FOLDER}") + logger.debug(f"Config file info: {openDefaultFile}") + if os.path.isfile(startingPath+EXAMPLE_CONFIG_PATH): + logger.debug("Removing old exampleconfig.yaml") + os.remove(startingPath+EXAMPLE_CONFIG_PATH) + logger.debug("Creating exampleconfig.yaml") + with open(startingPath+EXAMPLE_CONFIG_PATH, 'a') as openExampleFile: + openExampleFile.write(f"{EXAMPLE_CONFIG_MESSAGE}\n\n") #write this comment into the first line + shutil.copyfileobj(openDefaultFile, openExampleFile) #append the default file to the example file + logger.debug("Created exampleconfig.yaml") + + openDefaultFile.seek(0) #This puts us back at the beginning of openDefaultFile, since we've already indexed to the end + + if os.path.isfile(startingPath+USER_CONFIG_PATH): + logger.info("Using previous userconfig.yaml") + else: + logger.debug("Creating userconfig.yaml") + with open(startingPath+USER_CONFIG_PATH, 'a') as openUserFile: + openUserFile.write(f"{USER_CONFIG_MESSAGE}\n\n") #write this comment into the first line + shutil.copyfileobj(openDefaultFile, openUserFile) #append the default file to the user file + logger.info("Created userconfig.yaml") + + logger.info("Loading config files") + with open(startingPath+DEFAULT_CONFIG_PATH, "r") as openDefaultFile: + defaultconfig = yaml.safe_load(openDefaultFile) + with open(startingPath+USER_CONFIG_PATH, "r") as openUserFile: + userconfig = yaml.safe_load(openUserFile) + self.config = {} + for section, item in defaultconfig.items(): + logger.debug(f"Loading {section} ------") + for key, value in item.items(): + try: + if key in userconfig[section]: + logger.debug(f"'{key}' found in userconfig") + self.config[key] = userconfig[section][key] + else: + logger.warning(f"'{key}' not found in user config, default loaded.") #Warn + self.config[key] = value + except: + logger.warning(f"Config section {section} not found") #Warn \ No newline at end of file diff --git a/app/logger.py b/app/logger.py new file mode 100644 index 0000000..9d70ed8 --- /dev/null +++ b/app/logger.py @@ -0,0 +1,17 @@ +import logging +import os +from sys import stdout + +#Possible log levels are, DEBUG, INFO, WARNING, ERROR, CRITICAL +#Written as debug(), info(), warning(), error() and critical() +logLevel = os.getenv("LOG_LEVEL", default="DEBUG") #Get loglevel environmental from docker + +#Set Up logger +logger = logging.getLogger('RWDB_Logger') #Create logger instance with an arbitrary name +logger.setLevel(logLevel) # set logger level +logFormatter = logging.Formatter\ +("%(asctime)s %(levelname)-8s %(filename)s:%(funcName)s:%(lineno)d %(message)s", "%Y-%m-%d %H:%M:%S") #What the log string looks like +consoleHandler = logging.StreamHandler(stdout) #set streamhandler to stdout +consoleHandler.setFormatter(logFormatter) #Apply the formatter +logger.addHandler(consoleHandler) #Apply stdout handler to logger +logger.info(f"Logger set to level: {logLevel}") \ No newline at end of file diff --git a/app/rainwavebot.py b/app/rainwavebot.py index 7bcd7fb..28b29e3 100644 --- a/app/rainwavebot.py +++ b/app/rainwavebot.py @@ -5,7 +5,6 @@ import os #Built in import traceback #Built in import logging #Built in -from sys import stdout from datetime import datetime, timedelta, timezone #Built in #Libraries to install @@ -17,22 +16,18 @@ from rainwaveclient import RainwaveClient #NOTE Command to upgrade the rainwaveclient api: pip install -U python-rainwave-client #Local imports -from config.config import botChannels -from config.config import private -from config.config import dependencies -from config.config import options +import load_config.load_config #Global Constants MINIMUM_REFRESH_DELAY = 6 +#Load Config +config = load_config.load_config.config(os.path.dirname(os.path.abspath(__file__))) +config = config.config #Move the dict out of a class, for a shorter variable. +#Format for fetching config settings is `config["botPrefix"]` + #Set Up logger -logger = logging.getLogger('RWDB_Logger') #Create logger instance with an arbitrary name -logger.setLevel(options.logLevel) # set logger level via config -logFormatter = logging.Formatter\ -("%(asctime)s %(levelname)-8s %(filename)s:%(funcName)s:%(lineno)d %(message)s", "%Y-%m-%d %H:%M:%S") #What the log string looks like -consoleHandler = logging.StreamHandler(stdout) #set streamhandler to stdout -consoleHandler.setFormatter(logFormatter) #Apply the formatter -logger.addHandler(consoleHandler) #Apply stdout handler to logger +from logger import logger #Discord Permissions intents = discord.Intents.default() @@ -40,8 +35,8 @@ intents.members = True #Create Bot instance w/ settings -bot = commands.Bot(command_prefix=options.botPrefix, - description=f"rainwave.cc bot, in development by Roach\nUse `{options.botPrefix}play` to get started", intents=intents) +bot = commands.Bot(command_prefix=config["botPrefix"], + description=f"rainwave.cc bot, in development by Roach\nUse `{config["botPrefix"]}play` to get started", intents=intents) class current: voiceChannel = None @@ -50,9 +45,9 @@ class current: message = None class login: - discordBotToken = os.getenv("DISCORD_TOKEN", default=private.discordBotToken) - rainwaveID = os.getenv("RAINWAVE_ID", default=private.rainwaveID) - rainwaveKey = os.getenv("RAINWAVE_KEY", default=private.rainwaveKey) + discordBotToken = os.getenv("DISCORD_TOKEN", default=config["discordBotToken"]) + rainwaveID = os.getenv("RAINWAVE_ID", default=config["rainwaveID"]) + rainwaveKey = os.getenv("RAINWAVE_KEY", default=config["rainwaveKey"]) def fetchMetaData(): return current.selectedStream.schedule_current.songs[0] @@ -114,25 +109,25 @@ def generateProgressBar(metaData, stopping=False): timeSinceStartInSeconds = metaData.length #If time is too long, set max. else: #Else just do a seconds conversion. timeSinceStartInSeconds = times.timeSinceStart.seconds - if ((options.enableProgressBar == False - and options.enableProgressTimes == False) + if ((config["enableProgressBar"] == False + and config["enableProgressTimes"] == False) or stopping == True): return(None) - if options.enableProgressTimes == False: + if config["enableProgressTimes"] == False: timer = '' else: timer = f"`[{formatSecondsToMinutes(timeSinceStartInSeconds)}/{formatSecondsToMinutes(metaData.length)}]`" - progress = int(options.progressBarLength * (timeSinceStartInSeconds/metaData.length)) - if options.enableProgressBar == False: + progress = int(config["progressBarLength"] * (timeSinceStartInSeconds/metaData.length)) + if config["enableProgressBar"] == False: progressBar = '' - elif options.progressBarStyle == 1: #Left to right "fill" - progressBar = f"{options.progressBarCharacters[0] * progress}{options.progressBarCharacters[1] * (options.progressBarLength - progress)}" - elif options.progressBarStyle == 2: #Left to right indicator - progressBar = f"{options.progressBarCharacters[0] * (progress - 1)}{options.progressBarCharacters[1]}{options.progressBarCharacters[0] * (options.progressBarLength - progress)}" + elif config["progressBarStyle"] == 1: #Left to right "fill" + progressBar = f"{config["progressBarCharacters"][0] * progress}{config["progressBarCharacters"][1] * (config["progressBarLength"] - progress)}" + elif config["progressBarStyle"] == 2: #Left to right indicator + progressBar = f"{config["progressBarCharacters"][0] * (progress - 1)}{config["progressBarCharacters"][1]}{config["progressBarCharacters"][0] * (config["progressBarLength"] - progress)}" #TODO See if we can prevent the flickering from the formatting of Style3 - #elif options.progressBarStyle == 3: #Left to right color fill - # progressBar = f"```ansi\n{options.progressBarChars[0] * progress}{options.progressBarChars[0] * (options.progressBarLength - progress)}{timer}\n```" - if options.enableProgressBar == True and options.enableProgressTimes == True: + #elif config["progressBarStyle"] == 3: #Left to right color fill + # progressBar = f"```ansi\n{options.progressBarChars[0] * progress}{options.progressBarChars[0] * (config["progressBarLength"] - progress)}{timer}\n```" + if config["enableProgressBar"] == True and config["enableProgressTimes"] == True: spacer = ' ' else: spacer = '' @@ -150,7 +145,7 @@ class formatedEmbed: embed = discord.Embed(title=f"{intro} Rainwave {metaData.album.channel.name} Radio", url=current.selectedStream.url, description=generateProgressBar(metaData, stopping), - color = discord.Colour.from_rgb(options.embedColor[0],options.embedColor[1],options.embedColor[2])) + color = discord.Colour.from_rgb(config["embedColor"][0],config["embedColor"][1],config["embedColor"][2])) if metaData.url: artistData = f"[{metaData.artist_string}]({metaData.url})" else: @@ -163,14 +158,14 @@ class formatedEmbed: async def validChannelCheck(ctx, checkVoiceChannel = False): response = True #Assume nothing is wrong - if (botChannels.restrictTextChannels #If disallowed channel - and (ctx.message.channel.id not in botChannels.allowedTextChannels)): + if (config["restrictTextChannels"] #If disallowed channel + and (ctx.message.channel.id not in config["allowedTextChannels"])): response = f"{bot.user.name} commands not allowed in {ctx.message.channel.name}" elif checkVoiceChannel == True: try: authorsChannel = ctx.message.author.voice.channel.id #Creating this variable checks that they're in a channel at all. - if (botChannels.restrictVoiceChannels #If disallowed voice channel - and (authorsChannel not in botChannels.allowedVoiceChannels)): + if (config["restrictVoiceChannels"] #If disallowed voice channel + and (authorsChannel not in config["allowedVoiceChannels"])): response = f"{bot.user.name} music playback not allowed in {ctx.message.author.voice.channel.name}" except: #If user not in a visible voice channel response = 'You do not appear to be in a voice channel' @@ -195,13 +190,13 @@ async def stopConnection(): await setDefaultActivity() async def setDefaultActivity(): - await bot.change_presence(activity=discord.Activity(type=discord.ActivityType.watching, name=f" for `{options.botPrefix}play`")) + await bot.change_presence(activity=discord.Activity(type=discord.ActivityType.watching, name=f" for `{config["botPrefix"]}play`")) def loadOpus(): opusStatus = "Failed" if discord.opus.is_loaded() == False: try: - discord.opus.load_opus(dependencies.opus) + discord.opus.load_opus(config["opusLocation"]) opusStatus = "Initialized" except Exception as returnedException: logger.warn(f"Opus loading error error: {returnedException}") @@ -217,11 +212,11 @@ def checkUserPresence(): break return(userPresent) -@tasks.loop(seconds = options.refreshDelay) #TODO Determine if 6 is actually safe, and if we can go lower +@tasks.loop(seconds = config["refreshDelay"]) #TODO Determine if 6 is actually safe, and if we can go lower async def updatePlaying(): usersPresent = checkUserPresence() if ((usersPresent == False) - and (options.autoDisconnect == True)): + and (config["autoDisconnect"] == True)): logger.info("All alone, disconnecting") await stopUpdates(gracefully = True) await stopConnection() @@ -234,12 +229,12 @@ async def on_ready(): current_day = now.strftime("%d/%m/%y") current_time = now.strftime("%H:%M:%S") opusStatus = loadOpus() - await bot.user.edit(username=options.botName) + await bot.user.edit(username=config["botName"]) loginReport = f'Logged into Discord as `{bot.user} (ID: {bot.user.id})` and Rainwave as `(ID: {rainwaveClient.user_id})` at `{current_time}` on `{current_day}`' logger.info(loginReport) logger.debug(f"Opus: {opusStatus}") - if botChannels.enableLogChannel: - await bot.get_channel(botChannels.logChannel).send(loginReport) + if config["enableLogChannel"]: + await bot.get_channel(config["logChannel"]).send(loginReport) await setDefaultActivity() @bot.command(aliases=['p']) @@ -266,10 +261,10 @@ async def play(ctx, station = 'help'): await postCurrentlyListening(ctx) updatePlaying.start() elif (station.lower() == ('help' or 'list')): - await ctx.send(f"To start playback use `{options.botPrefix}play` followed by one of the available channels: {channelList}" - f"\nExample: `{options.botPrefix}play {channelList[0]}`") + await ctx.send(f"To start playback use `{config["botPrefix"]}play` followed by one of the available channels: {channelList}" + f"\nExample: `{config["botPrefix"]}play {channelList[0]}`") else: - await ctx.send(f"Station not found, use `{options.botPrefix}play help` for more info") + await ctx.send(f"Station not found, use `{config["botPrefix"]}play help` for more info") @bot.command(aliases=['leave','s']) ##, 'stop' async def stop(ctx): @@ -308,7 +303,7 @@ async def ping(ctx): rainwaveClient.user_id = login.rainwaveID rainwaveClient.key = login.rainwaveKey -if options.refreshDelay < MINIMUM_REFRESH_DELAY: - options.refreshDelay = MINIMUM_REFRESH_DELAY +if config["refreshDelay"] < MINIMUM_REFRESH_DELAY: + config["refreshDelay"] = MINIMUM_REFRESH_DELAY logger.warning(f"WARN refreshDelay overridden to: {MINIMUM_REFRESH_DELAY}") bot.run(login.discordBotToken) #Start Bot diff --git a/requirements.txt b/requirements.txt index 005ae42..58a21ba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,9 @@ #A list of dependencies for rainwavebot.py #The first three were found using `pipreqs`, is that a good choice? It did miss PyNaCl. -aiocron==1.8 +aiocron==1.8 #Required for discord.py discord.py==2.4.0 python_rainwave_client==2024.2 PyNaCl==1.5.0 #I got this by using `pip show pynacl`. If I hadn't known what would I do? +PyYAML==6.0.2 #Allows reading Yamls #Maybe more? \ No newline at end of file