diff --git a/.gitignore b/.gitignore index 17f73ab..6b034a4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,148 @@ -*.log -*.cfg -/.vscode -.pylintrc +*.log +*.cfg +*.sqlite +/.vscode +.pylintrc +/__pycache__ +Procfile +*.code-workspace + + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ diff --git a/.gitmessage b/.gitmessage new file mode 100644 index 0000000..582cb55 --- /dev/null +++ b/.gitmessage @@ -0,0 +1,10 @@ +[commit title] + +#: [commit title] + +Closes #: [commit title] + + + description + reasoning + diff --git a/CONFIG.py b/CONFIG.py new file mode 100644 index 0000000..6fc2cf1 --- /dev/null +++ b/CONFIG.py @@ -0,0 +1,21 @@ +import os +import pathlib + +discord_token = "YOUR_TOKEN_HERE" +client_id = "YOUR_CLIENT_ID" +prefix = "YOUR_PREFIX_HERE" +bot_folder = "YOUR_STORAGE_FOLDER_NAME_HERE" +cfg_name = "botdata.cfg" +sqlite_name = "santabotdb.sqlite" +dbg_name = "debug.log" +role_channel = -1 +min_budget = 10 +max_budget = 20 + + +############################################### +### DO NOT CHANGE BELOW ### +############################################### +cfg_path = os.path.join(str(pathlib.Path(__file__).parent), bot_folder, cfg_name) +sqlite_path = os.path.join(str(pathlib.Path(__file__).parent), bot_folder, sqlite_name) +dbg_path = os.path.join(str(pathlib.Path(__file__).parent), bot_folder, dbg_name) diff --git a/CONFIG.py.example b/CONFIG.py.example new file mode 100644 index 0000000..e67c566 --- /dev/null +++ b/CONFIG.py.example @@ -0,0 +1,21 @@ +import os +import pathlib + +discord_token = "ABCDEFGHIJKLMNOPQRSTUVWXYZ.1234567890" +client_id = "0123456789" +prefix = "s!" +bot_folder = "files" +cfg_name = "botdata_example.cfg" +sqlite_name = "santabotdb_example.sqlite" +dbg_name = "debug_example.log" +role_channel = 9876543210 +min_budget = 10 +max_budget = 20 + + +############################################### +### DO NOT CHANGE BELOW ### +############################################### +cfg_path = os.path.join(str(pathlib.Path(__file__).parent), bot_folder, cfg_name) +sqlite_path = os.path.join(str(pathlib.Path(__file__).parent), bot_folder, sqlite_name) +dbg_path = os.path.join(str(pathlib.Path(__file__).parent), bot_folder, dbg_name) diff --git a/README.md b/README.md index b442f77..c11225a 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,123 @@ -#SantaBot +# SantaBot -A Discord bot to organize secret santa gift exchanges using the discord.py Python library +A Discord bot to organize secret santa gift exchanges using the discord.py Python library + some other admin-type stuff for my server -##Instructions: +- This bot code must be pulled (or forked) and run locally +- If you don't want the admin-type stuff, just go in and comment out the add_cog(SantaAdministrative(...)) line -###Installation and Dependencies: +### Requirements +- python 3.6 or later (can be installed [here](https://www.python.org/downloads/)) +- pip3 -To add this bot to your Discord server, first ensure you have [Python version 3.5 or later installed](https://www.python.org/downloads/), along with [the discord.py library](https://github.com/Rapptz/discord.py) and [the configobj library](https://www.voidspace.org.uk/python/configobj.html#downloading). The asyncio, logging, os.path, and random libraries are also required, but they should be included in most Python installations by default. +### Steps to run: +1. Run `pip3 install -r requirements.txt` +2. Once all of the dependencies are installed, create a Discord bot token following the instructions [here](https://github.com/reactiflux/discord-irc/wiki/Creating-a-discord-bot-&-getting-a-token). + - **IMPORTANT:** you will need to explicitly enable the Server Members Intent under the Bot tab in the [Discord Application Developer Portal](https://discord.com/developers/applications/) ![as pictured](https://i.imgur.com/mvwTiPE.png) +3. Replace variables in CONFIG.py (I have provided CONFIG.py.example as an example) -Once all of the dependencies are installed, create a Discord bot token following the instructions [here](https://github.com/reactiflux/discord-irc/wiki/Creating-a-discord-bot-&-getting-a-token). Then, open the santa-bot.py file with your favorite plaintext editor, and replace the word `'token'` in the last line that reads `client.run('token')` with the token you have generated, keeping the single quotes. The santa-bot.py file can now be excecuted, and the bot should function as normal. + 3a. Replace `discord_token` and `client_id` in CONFIG.py with your bot token - these two are REQUIRED for all functionality and for the bot to even start -###Bot Commands: + 3b. Replace other variables as you want -- `$$join` adds you to the list of secret santa participants. -- `$$setaddress` saves your mailing address for gifts to be sent to. -- `$$setprefs` saves your gift preferences so your secret santa will know what kind of things you would like to receive. Keep in mind that your exact input is sent to your secret santa as is. -- `$$listparticipants` makes the bot list all of the people currently participating in the secret santa exchange. -- `$$totalparticipants` makes the bot give the number of people currently participating in the secret santa exchange -- *`$$start` to have the bot assign each participant a partner -- *`$$shutdown` to make the bot self-terminate + - `role_channel` is REQUIRED for using reaction roles - will print an error in the console if left as -1 -all commands marked with a * can only be run by a server admin. \ No newline at end of file + - `prefix` change it to whatever you want otherwise commands default to YOUR_PREFIX_HEREjoin, YOUR_PREFIX_HEREunpin_all, etc. + + - `bot_folder` this is where the .cfg for the Secret Santa participants, the debug log, and the SQLite database files are stored + + - `cfg_name`, `dbg_name`, `sqlite_name` don't *need* to do anything here unless you want to + + - `min_budget`, `max_budget` are used for the Secret Santa functionality and can be changed depending on what your Discord server agrees upon. +4. Run `python3 santa-bot.py` + +### Add a Systemd Service for SantaBot to Autostart on Reboot +To keep SantaBot running even after restart you can create a service. + +1. Copy the `run_santa.sh` to `/usr/local/bin/` directory + +- sudo cp run_santa.sh /usr/local/bin/ + +2. Edit `run_santa.sh` in `/usr/local/bin` with your favorite editor to include the absolute path to the santa-bot.py script. For example `/home/pi/Santabot/santa-bot.py`. If you're not sure the absolute, in the Santabot directory type `pwd` + +- pwd + /home/pi/Santabot +- sudo nano /usr/local/bin/run_santa.sh + + - while true + - do + - python3 /home/pi/Santabot/santa-bot.py + - sleep 10 + - done + + +3. Create a service file or edit the included `santabot.service` using your favorite text editor and add the default user name to `User=` and the Group they belong to in `Group=`. To find out what default group a user belongs use `id -gn usernamehere` + +- nano santabot.service + + - [Unit] + - Description=Santa Discord Bot + + - [Service] + - User= root + - Group= root + - Restart=on-abort + - WorkingDirectory= "Full Path to Santa Bot Here" + - ExecStart= /usr/local/bin/run_santa.sh + + - [Install] + - WantedBy=multi-user.target + +4. Copy the santabot.service to `/lib/systemd/system/` + +- sudo cp santabot.service /lib/systemd/system/ + +5. Enable the service so it will autostart on reboot, then start with the commands: + +- sudo systemctl enable santabot.service +- sudo systemctl start santabot.service + +6. Then check the status + +- sudo systemctl status santabot.service + +7. When done using the bot for the season, to disable use both commands: + +- sudo systemctl disable santabot.service +- sudo systemctl stop santabot.service + +#### FAQ: +1. What if my wishlist URL or preference is multiple words? + - The bot supports that, just surround it with quotation marks. (e.g. `s!setprefs dog "stuffed rabbit" cat`) +2. Does the wishlist *have* to be a URL? What if one of the Secret Santas prefers sending handmade gifts? + - No that field is really about having a destination for users to send their gifts. Feel free to use addresses instead of URLs if you so desire. Keep in mind that addresses will need to follow question #1 of this FAQ (e.g. `s!setwishlisturl amazonurl/123 "P Sherman 42 Wallaby Way" rightstufanime`) + +#### Secret Santa Commands: + +- `s!join` = join the Secret Santa +- `s!leave` = leave the Secret Santa +- `s![setwishlisturl|swlurl] [wishlist URL]` = set your wishlist URL (replaces current). You may also add your mailing address. __wishlist URL is required__. +- `s![getwishlisturl|gwlurl]` = bot will PM you your current wishlist +- `s![setprefs|sprefs] [specific preferences, the things you like]` = set preferences (replaces current). Put N/A if none. __preferences are required__. +- `s![getprefs|gprefs]` = bot will PM you your current preferences +- `s![listparticipants|lp]` **(admin only)** = get the current participants +- `s![totalparticipants|tp]` = get the total number of participants +- `s!partnerinfo` = be DM'd your partner's information +- `s!start` **(admin only)** = assign Secret Santa partners +- `s!restart` **(admin only)** = attempt to restart Secret Santa after pause without changing partners +- `s!pause` **(admin only)** = pause Secret Santa (will require `s!start` and will reshuffle partners) +- `s!end` **(admin only)** = end Secret Santa + +#### Administrative Commands: +- `s!assign_role_channel CHANNEL` **(admin only)** = change the channel the bot looks at for reaction roles +- `s!archive_pins SRC_CHANNEL DEST_CHANNEL` **(admin only)** = archive all pins from the source channel to the destination channel as messages (ex. `s!archive_pins #general #archive`) +- `s!unpin_all [CHANNEL_ID]` **(admin only)** = unpin all messages in the indicated channel (defaults to the channel the command is called in) + +#### Utility Commands: +- `s!emote [any number of emotes]` = returns the URL of the emote image/gif for convenience +- `s![countdown|cd]` = set/check a countdown (global for the server, e.g. time until a Manga Club event) - help text is returns as needed + +#### Miscellaneous Commands: + +- `s!ping` = check if bot is alive +- `s!echo` = make the bot say stuff +- `s!ding` = dong diff --git a/cogs/SantaAdministrative.py b/cogs/SantaAdministrative.py new file mode 100644 index 0000000..8af05cd --- /dev/null +++ b/cogs/SantaAdministrative.py @@ -0,0 +1,175 @@ +import pendulum +import logging + +import discord +from discord.ext import commands +from discord.ext.commands import has_permissions, MissingPermissions, MissingRequiredArgument +from discord import Game as discordGame + +import CONFIG +import helpers.BOT_ERROR as BOT_ERROR + +class SantaAdministrative(commands.Cog, name='Administrative'): + def __init__(self, bot: commands.Bot): + self.bot = bot + self.role_channel = bot.get_channel(CONFIG.role_channel) if (CONFIG.role_channel != -1) else None + self.logger = logging.getLogger(name="SantaBot.SantaAdministrative") + self.logger.info("Creating cog") + + @commands.command() + @has_permissions(manage_roles=True, ban_members=True) + async def assign_role_channel(self, ctx: commands.Context, reaction_role_channel: discord.TextChannel = None): + ''' + Not recommended. Allows a reaction role channel to be assigned. + The recommended route is to set the role_channel variable in the bot's config file to the channel ID you want. + ''' + if reaction_role_channel != None: + self.role_channel = reaction_role_channel # use given channel + else: + if(isinstance(ctx.channel, discord.PrivateChannel)): + await ctx.send(content=BOT_ERROR.REACTION_ROLE_UNASSIGNED) + else: + self.role_channel = ctx.channel # use current channel + + if(self.role_channel != None): + await ctx.send(content=f"Reaction role channel assigned to {self.role_channel.mention}") + + @commands.command() + @has_permissions(manage_roles=True, ban_members=True) + async def archive_pins(self, ctx: commands.Context, channel_to_archive: discord.TextChannel, channel_to_message: discord.TextChannel): + ''' + Archive the pins in one channel to another channel as messages + + ex. `s!archive_pins #general #archive` + ''' + + start_message = f"Attempting to archive pinned messages from {channel_to_archive.mention} to {channel_to_message.mention}" + await ctx.send(content=start_message) + + pins_to_archive = await channel_to_archive.pins() + pins_to_archive.reverse() + + for pin in pins_to_archive: + attachments = pin.attachments + attachment_str = "" + for attachment in attachments: + attachment_str += f"{attachment.url}\n" # add link to attachments + + pin_author = pin.author.name + pin_pendulum = pendulum.instance(pin.created_at) + pin_dt_str = pin_pendulum.format("MMMM DD, YYYY") + + pin_url = pin.jump_url + pin_content = pin.content + output_str = f"-**(from `{pin_author}` on {pin_dt_str})** {pin_content}\n" + output_str += f"Message link: <{pin_url}>\n" + output_str += f"Attachment links: {attachment_str}" + if len(output_str) > 2000: + await ctx.send(content=BOT_ERROR.ARCHIVE_ERROR_LENGTH(pin_url)) + else: + await channel_to_message.send(content=output_str) + + end_message = f"Pinned message are archived in {channel_to_message.mention}. If the archive messages look good, use **{CONFIG.prefix}unpin_all** to remove the pins in {channel_to_archive.mention}" + await ctx.send(content=end_message) + + @commands.command() + @has_permissions(manage_roles=True, ban_members=True) + async def unpin_all(self, ctx: commands.Context, channel_to_unpin: discord.TextChannel = None): + ''' + Unpins all the pinned messages in the channel. Called to clean up after archive_pins. + Defaults to the channel in which it's called. + ''' + if(channel_to_unpin == None): + channel_to_unpin = ctx.channel() + + start_message = f"Attempting to remove all pinned messages from {channel_to_unpin.mention}." + await ctx.send(content=start_message) + if(channel_to_unpin == None): + await ctx.send(BOT_ERROR.INACCESSIBLE_CHANNEL) + return + pins_to_remove = await channel_to_unpin.pins() + for pin in pins_to_remove: + await pin.unpin() + end_message = f"All pinned messages removed from {channel_to_unpin.mention}." + await ctx.send(content=end_message) + + @unpin_all.error + @archive_pins.error + async def pin_archive_error(self, error, ctx): + if isinstance(error, MissingPermissions): + text = BOT_ERROR.NO_PERMISSION("mod") + await ctx.send(content=text) + elif isinstance(error, MissingRequiredArgument): + await ctx.send(content=BOT_ERROR.MISSING_ARGUMENTS) + elif isinstance(error, commands.CommandError): + await ctx.send(content=error) + else: + await ctx.send(content=BOT_ERROR.UNDETERMINED_CONTACT_CODE_OWNER) + + @commands.Cog.listener(name='on_raw_reaction_add') + @commands.Cog.listener(name='on_raw_reaction_remove') + async def manage_reactions(self, payload: discord.RawReactionActionEvent): + if(self.role_channel == None): # if no role channel assigned, + self.role_channel = self.bot.get_channel(CONFIG.role_channel) if (CONFIG.role_channel != -1) else None # attempt to get role channel + if(self.role_channel == None): # if that fails (CONFIG.role_channel == -1 or get_channel fails) + BOT_ERROR.output_error(BOT_ERROR.REACTION_ROLE_UNASSIGNED, self.logger) + return # end command + else: + pass # reassignment of role_channel worked + else: + pass # role_channel is assigned + + message_id = payload.message_id + channel_id = payload.channel_id + emoji = payload.emoji + emoji_str = str(payload.emoji) + guild = self.bot.get_guild(payload.guild_id) + user = guild.get_member(payload.user_id) + + if channel_id == self.role_channel.id: + message = None + async for message in self.role_channel.history(limit=200): + if message.id == message_id: + break + + if message != None: + content = message.content.split("\n") + for line in content: + if((line[0:2] == "~~") and (line[-1] == "~")): # don't pay attention to lines that are crossed out + continue + line_tokens = [i for i in line.split(" ") if i] # remove empty strings for quality of life (doesn't break with extra spaces) + test_emote_idx = for_idx = test_role_id_idx = -1 + try: + for_idx = line_tokens.index("for") + test_emote_idx, test_role_id_idx = for_idx - 1, for_idx + 1 + except: + for_idx = -1 + if (for_idx != -1): # no 'for' or 'for' is in the wrong place + test_emote = line_tokens[test_emote_idx] + test_role_id = int(line_tokens[test_role_id_idx][3:-1]) + BOT_ERROR.output_info(f"Test emote={test_emote.encode('raw_unicode_escape')}, Input emote={emoji_str.encode('raw_unicode_escape')}", self.logger) + found_emoji = False + if emoji.is_unicode_emoji(): + found_emoji = (emoji_str == test_emote) + else: + found_emoji = (emoji_str[1:-1] in test_emote) + if found_emoji: + role = guild.get_role(test_role_id) + BOT_ERROR.output_info(f"Role added/removed=@{role.name}, user={user.name}", self.logger) + if payload.event_type == "REACTION_ADD": + await user.add_roles(role) + else: + await user.remove_roles(role) + return + + @commands.Cog.listener(name='on_ready') + async def nice_ready_print(self): + """print message when client is connected""" + currentDT = pendulum.now() + print('------') + print (currentDT.format("YYYY-MM-D H:mm:ss")) + print("Logged in as") + print(self.bot.user.name) + print(self.bot.user.id) + await self.bot.change_presence(activity = discordGame(name = CONFIG.prefix + "help")) + print('------') diff --git a/cogs/SantaMiscellaneous.py b/cogs/SantaMiscellaneous.py new file mode 100644 index 0000000..4d6f5e8 --- /dev/null +++ b/cogs/SantaMiscellaneous.py @@ -0,0 +1,40 @@ +import logging + +from discord.ext import commands + +import CONFIG +import helpers.BOT_ERROR as BOT_ERROR + +class SantaMiscellaneous(commands.Cog, name='Miscellaneous'): + def __init__(self, bot: commands.Bot): + self.bot = bot + self.logger = logging.getLogger(name="SantaBot.SantaMiscellaneous") + self.logger.info("Creating cog") + + # @commands.command() ### normally commented out because this bot is not meant to be sharded + async def invite(self, ctx: commands.Context): + ''' + Creates an invite for this bot + ''' + link = f"https://discordapp.com/oauth2/authorize?client_id={CONFIG.client_id}&scope=bot&permissions=67185664" + await ctx.send_message(f"Bot invite link: {link}") + + @commands.command() + async def echo(self, ctx: commands.Context, *, content:str): + ''' + [content] = echos back the [content] + ''' + BOT_ERROR.output_info(content, self.logger) + await ctx.send(content) + + @commands.command() + async def ding(self, ctx: commands.Context): + await ctx.send("dong") + + @commands.command() + async def ping(self, ctx: commands.Context): + ''' + = Basic ping command + ''' + latency = self.bot.latency + await ctx.send(f"{str(round(latency, 4)*1000)} milliseconds") diff --git a/cogs/SantaUtilities.py b/cogs/SantaUtilities.py new file mode 100644 index 0000000..91c675e --- /dev/null +++ b/cogs/SantaUtilities.py @@ -0,0 +1,55 @@ +import logging + +from discord.ext import commands +import discord + +import CONFIG +import helpers.BOT_ERROR as BOT_ERROR +from helpers.SantaCountdownHelper import SantaCountdownHelper +from helpers.SQLiteHelper import SQLiteHelper + +class SantaUtilities(commands.Cog, name='Utilities'): + def __init__(self, bot: commands.Bot, sqlitehelper: SQLiteHelper): + self.bot = bot + self.cd_helper = SantaCountdownHelper(sqlitehelper) + self.logger = logging.getLogger(name="SantaBot.SantaUtilities") + self.logger.info("Creating cog") + + @commands.command(aliases=['cd']) + async def countdown(self, ctx: commands.Context, command: str="", *, arg=""): + ''' + Add a countdown timer + Commands: set, change, check, list, remove, clean + ''' + if(command == ""): + usage_guide = f"Proper usage: `{CONFIG.prefix} `\n" + usage_guide += f"Use each sub-command (`{CONFIG.prefix} `) for more information on the necessary arguments" + await ctx.send(usage_guide) + return + + args = arg.split(sep="|") # minimum separator if the user misses a space somewhere (spaces stripped in next few lines) + countdown_name = "" + countdown_time = "" + if(len(args) > 0): + countdown_name = args[0].strip() + if(len(args) > 1): + countdown_time = args[1].strip() + + relay_message = self.cd_helper.run_countdown_command(ctx, command, countdown_name, countdown_time) + + await ctx.send(content=relay_message) + return + + @commands.command() + async def emote(self, ctx: commands.Context, *emotes: discord.PartialEmoji): + ''' + Get the URL to emotes without needing to open the link. Custom emotes only. + ''' + for passed_emote in emotes: + emote_url = str(passed_emote.url) + emote_name = passed_emote.name + emote_id = passed_emote.id + img_type = "gif" if passed_emote.animated else "png" + BOT_ERROR.output_info(f"Name={emote_name}, ID={emote_id}, IMG_TYPE={img_type}, url={emote_url}", self.logger) + await ctx.send(content=emote_url) + return diff --git a/cogs/SecretSanta.py b/cogs/SecretSanta.py new file mode 100644 index 0000000..52e9c65 --- /dev/null +++ b/cogs/SecretSanta.py @@ -0,0 +1,492 @@ +from traceback import TracebackException +from configobj import ConfigObj +import copy +import logging + +from discord.ext import commands + +import CONFIG +import helpers.BOT_ERROR as BOT_ERROR +from helpers.SecretSantaConstants import SecretSantaConstants +from helpers.SecretSantaHelpers import SecretSantaHelpers +from helpers.SecretSantaParticipant import SecretSantaParticipant + +class SecretSanta(commands.Cog, name='Secret Santa'): + + def __init__(self, bot: commands.Bot, config: ConfigObj): + self.bot = bot + self.exchange_started = False + self.server = '' + self.usr_list = [] + self.highest_key = 0 + self.user_left_during_pause = False + self.is_paused = False + self.SecretSantaHelper = SecretSantaHelpers() + self.config = config + self.logger = logging.getLogger(name="SantaBot.SecretSanta") + self.logger.info("Creating cog") + + # retrieve data from config file + self.logger.info(f"Reading and loading Secret Santa participants from {CONFIG.cfg_name}") + self.exchange_started = self.config['programData'].as_bool('exchange_started') + for key in self.config['members']: + data = self.config['members'][str(key)] + usr = SecretSantaParticipant(data[SecretSantaConstants.NAME], data[SecretSantaConstants.DISCRIMINATOR], data[SecretSantaConstants.IDSTR], data[SecretSantaConstants.USRNUM], data[SecretSantaConstants.WISHLISTURL], data[SecretSantaConstants.PREFERENCES], data[SecretSantaConstants.PARTNERID]) + self.usr_list.append(usr) + self.highest_key = int(key) + + @commands.command(aliases=["swlurl"]) + async def setwishlisturl(self, ctx: commands.Context, *destination:str): + ''' + [Any number of wishlist URLs or mailing addresses] = set wishlist destinations or mailing address. Surround mailing address with quotation marks and separate EACH wishlist destination with a space (eg. amazonurl/123 "Sesame Street" http://rightstufanime.com/). + ''' + currAuthor = ctx.author + if self.SecretSantaHelper.user_is_participant(currAuthor.id, self.usr_list): + if(self.SecretSantaHelper.channel_is_guild(ctx.channel)): + await ctx.message.delete() # delete the message before people can see + (index, user) = self.SecretSantaHelper.get_participant_object(currAuthor.id, self.usr_list) + new_wishlist = "None" + if(len(destination) == 0): + pass + else: + new_wishlist = " | ".join(destination) + try: + # save to config file + self.config['members'][str(user.usrnum)][SecretSantaConstants.WISHLISTURL] = new_wishlist + self.config.write() + # add the input to the value in the user's class instance + user.wishlisturl = new_wishlist + try: + await currAuthor.send(f"New wishlist URL(s): {new_wishlist}") + if(not user.pref_is_set()): + userPrompt = f"Great! Now please specify what your preferences for your wishlist might be. Use `{CONFIG.prefix}setprefs [preferences separated by a space]` (e.g. `{CONFIG.prefix}setprefs hippopotamus \"stuffed rabbit\" dog`). Defaults to N/A if not entered." + await currAuthor.send(userPrompt) + if(user.wishlisturl_is_set() and user.pref_is_set()): + signup_complete_msg = f"Congrats, you're now officially enrolled in the Secret Santa! You may change your wishlist URL or preferences with `{CONFIG.prefix}setwishlisturl` or `{CONFIG.prefix}setprefs` any time before the admin begins the Secret Santa." + await currAuthor.send(signup_complete_msg) + except Exception as e: + BOT_ERROR.output_exception(e, self.logger) + await ctx.send(currAuthor.mention + BOT_ERROR.DM_FAILED) + BOT_ERROR.output_error(currAuthor.mention + BOT_ERROR.DM_FAILED, self.logger) + except Exception as e: + BOT_ERROR.output_exception(e, self.logger) + try: + await currAuthor.send(BOT_ERROR.INVALID_INPUT) + except Exception as e: + BOT_ERROR.output_exception(e, self.logger) + await ctx.send(currAuthor.mention + BOT_ERROR.DM_FAILED) + BOT_ERROR.output_error(currAuthor.mention + BOT_ERROR.DM_FAILED, self.logger) + else: + await ctx.send(BOT_ERROR.UNJOINED) + return + + @commands.command(aliases=["gwlurl"]) + async def getwishlisturl(self, ctx: commands.Context): + ''' + Get current wishlist + ''' + currAuthor = ctx.author + if self.SecretSantaHelper.user_is_participant(ctx.author.id, self.usr_list): + (index, user) = self.SecretSantaHelper.get_participant_object(ctx.author.id, self.usr_list) + try: + await currAuthor.send(f"Current wishlist destination(s): {user.wishlisturl}") + except Exception as e: + BOT_ERROR.output_exception(e, self.logger) + await ctx.send(currAuthor.mention + BOT_ERROR.DM_FAILED) + BOT_ERROR.output_error(currAuthor.mention + BOT_ERROR.DM_FAILED, self.logger) + else: + await ctx.send(BOT_ERROR.UNJOINED) + return + + @commands.command(aliases=["sprefs"]) + async def setprefs(self, ctx: commands.Context, *preferences:str): + ''' + Set new preferences + ''' + currAuthor = ctx.author + if self.SecretSantaHelper.user_is_participant(currAuthor.id, self.usr_list): + if(self.SecretSantaHelper.channel_is_guild(ctx.channel)): + await ctx.message.delete() # delete the message before people can see + (index, user) = self.SecretSantaHelper.get_participant_object(currAuthor.id, self.usr_list) + new_prefs = "None" + if(len(preferences) == 0): + pass + else: + new_prefs = " | ".join(preferences) + try: + #save to config file + self.config['members'][str(user.usrnum)][SecretSantaConstants.PREFERENCES] = str(new_prefs) + self.config.write() + #add the input to the value in the user's class instance + user.preferences = new_prefs + try: + await currAuthor.send(f"New preferences: {new_prefs}") + if(not user.wishlisturl_is_set()): + userPrompt = f"Great! Now please specify what your wishlist URL or mailing address. Use `{CONFIG.prefix}setwishlisturl [wishlist urls separated by a space]` (e.g. `{CONFIG.prefix}setwishlisturl amazonurl/123 \"sesame street\"`) to set your wishlist URL." + await currAuthor.send(userPrompt) + if(user.wishlisturl_is_set() and user.pref_is_set()): + signup_complete_msg = f"Congrats, you're now officially enrolled in the Secret Santa! You may change your wishlist URL or preferences with `{CONFIG.prefix}setwishlisturl` or `{CONFIG.prefix}setprefs` any time before the admin begins the Secret Santa." + await currAuthor.send(signup_complete_msg) + except Exception as e: + BOT_ERROR.output_exception(e, self.logger) + await ctx.send(currAuthor.mention + BOT_ERROR.DM_FAILED) + BOT_ERROR.output_error(currAuthor.mention + BOT_ERROR.DM_FAILED, self.logger) + except Exception as e: + BOT_ERROR.output_exception(e, self.logger) + try: + await currAuthor.send(BOT_ERROR.INVALID_INPUT) + except Exception as e: + BOT_ERROR.output_exception(e, self.logger) + await ctx.send(currAuthor.mention + BOT_ERROR.DM_FAILED) + BOT_ERROR.output_error(currAuthor.mention + BOT_ERROR.DM_FAILED, self.logger) + else: + await ctx.send(BOT_ERROR.UNJOINED) + return + + @commands.command(aliases=["gprefs"]) + async def getprefs(self, ctx: commands.Context): + ''' + Get current preferences + ''' + currAuthor = ctx.author + if self.SecretSantaHelper.user_is_participant(ctx.author.id, self.usr_list): + (index, user) = self.SecretSantaHelper.get_participant_object(ctx.author.id, self.usr_list) + try: + await currAuthor.send(f"Current preference(s): {user.preferences}") + except Exception as e: + BOT_ERROR.output_exception(e, self.logger) + await ctx.send(currAuthor.mention + BOT_ERROR.DM_FAILED) + BOT_ERROR.output_error(currAuthor.mention + BOT_ERROR.DM_FAILED, self.logger) + else: + await ctx.send(BOT_ERROR.UNJOINED) + return + + @commands.guild_only() + @commands.command() + async def start(self, ctx: commands.Context): + ''' + Begin the Secret Santa + ''' + currAuthor = ctx.author + if(currAuthor.top_role == ctx.guild.roles[-1]): + # first ensure all users have all info submitted + all_fields_complete = True + for user in self.usr_list: + if(user.wishlisturl_is_set() and user.pref_is_set()): + pass + else: + all_fields_complete = False + try: + await currAuthor.send(BOT_ERROR.HAS_NOT_SUBMITTED(user.name)) + await ctx.send("`Partner assignment cancelled: participant info incomplete.`") + except Exception as e: + BOT_ERROR.output_exception(e, self.logger) + await ctx.send(currAuthor.mention + BOT_ERROR.DM_FAILED) + BOT_ERROR.output_error(currAuthor.mention + BOT_ERROR.DM_FAILED, self.logger) + + # select a random partner for each participant if all information is complete and there are enough people to do it + if(all_fields_complete and (len(self.usr_list) > 1)): + BOT_ERROR.output_info("Proposing a partner list", self.logger) + potential_list = self.SecretSantaHelper.propose_partner_list(self.usr_list) + while(not self.SecretSantaHelper.partners_are_valid(potential_list)): + BOT_ERROR.output_info("Proposing a partner list", self.logger) + potential_list = self.SecretSantaHelper.propose_partner_list(self.usr_list) + # save to config file + BOT_ERROR.output_info("Partner assignment successful", self.logger) + for user in potential_list: + (temp_index, temp_user) = self.SecretSantaHelper.get_participant_object(int(user.idstr), self.usr_list) # get the user's object in usr_list + (index, partner) = self.SecretSantaHelper.get_participant_object(int(user.partnerid), self.usr_list) # get their partner + temp_user.partnerid = user.partnerid + self.config['members'][str(user.usrnum)][SecretSantaConstants.PARTNERID] = user.partnerid + self.config.write() + # tell participants who their partner is + this_user = ctx.guild.get_member(int(user.idstr)) + message_pt1 = f"{str(partner.name)}#{str(partner.discriminator)} is your Secret Santa partner! Mosey on over to their wishlist URL(s) and pick out a gift! Remember to keep it in the ${CONFIG.min_budget}-{CONFIG.max_budget} range.\n" + message_pt2 = f"Their wishlist(s) can be found here: {partner.wishlisturl}\n" + message_pt3 = f"And their gift preferences can be found here: {partner.preferences}\n" + message_pt4 = f"If you have trouble accessing your partner's wishlist, try `{CONFIG.prefix}dmpartner` or contact an admin to get in touch with them. This is a *secret* santa, after all!" + santa_message = message_pt1 + message_pt2 + message_pt3 + message_pt4 + try: + await this_user.send(santa_message) + except Exception as e: + BOT_ERROR.output_exception(e, self.logger) + await currAuthor.send(f"Failed to send message to {this_user.name}#{this_user.discriminator} about their partner. Ask them to turn on server DMs for Secret Santa stuff.") + + # mark the exchange as in-progress + self.exchange_started = True + self.is_paused = False + self.config['programData']['exchange_started'] = True + self.config.write() + usr_list = copy.deepcopy(potential_list) + await ctx.send("Secret Santa pairs have been picked! Check your PMs and remember not to let your partner know. Have fun!") + elif not all_fields_complete: + await ctx.send(currAuthor.mention + BOT_ERROR.SIGNUPS_INCOMPLETE) + elif not (len(self.usr_list) > 1): + await ctx.send(BOT_ERROR.NOT_ENOUGH_SIGNUPS) + else: + await ctx.send(BOT_ERROR.UNREACHABLE) + else: + await ctx.send(BOT_ERROR.NO_PERMISSION(ctx.guild.roles[-1])) + return + + @commands.guild_only() + @commands.command() + async def restart(self, ctx: commands.Context): + ''' + Restart the Secret Santa after pause + ''' + currAuthor = ctx.author + is_paused = True + if((currAuthor.top_role == ctx.guild.roles[-1]) and is_paused): + # ensure all users have all info submitted + all_fields_complete = True + for user in self.usr_list: + if(user.wishlisturl_is_set() and user.pref_is_set()): + pass + else: + all_fields_complete = False + try: + await currAuthor.send(BOT_ERROR.HAS_NOT_SUBMITTED(user.name)) + await ctx.send("`Partner assignment cancelled: participant info incomplete.`") + except Exception as e: + BOT_ERROR.output_exception(e, self.logger) + await ctx.send(currAuthor.mention + BOT_ERROR.DM_FAILED) + BOT_ERROR.output_error(currAuthor.mention + BOT_ERROR.DM_FAILED, self.logger) + list_changed = self.SecretSantaHelper.usr_list_changed_during_pause(self.usr_list, self.user_left_during_pause) + if(list_changed): + ctx.send(f"User list changed during the pause. Partners must be picked again with `{CONFIG.prefix}start`.") + else: + self.exchange_started = True + is_paused = False + self.config['programData']['exchange_started'] = True + self.config.write() + await ctx.send("No change was made during the pause. Secret Santa resumed with the same partners.") + elif(currAuthor.top_role != ctx.guild.roles[-1]): + await ctx.send(BOT_ERROR.NO_PERMISSION(ctx.guild.roles[-1])) + elif(not is_paused): + await ctx.send(BOT_ERROR.NOT_PAUSED) + else: + await ctx.send(BOT_ERROR.UNREACHABLE) + return + + @commands.guild_only() + @commands.command() + async def pause(self, ctx: commands.Context): + ''' + Pause for people that want to join last-minute (reshuffles and matches upon restart) + ''' + if(ctx.author.top_role == ctx.guild.roles[-1]): + self.exchange_started = False + self.config['programData']['exchange_started'] = False + self.config.write() + self.is_paused = True + await ctx.send("Secret Santa has been paused. New people may now join.") + else: + await ctx.send(BOT_ERROR.NO_PERMISSION(ctx.guild.roles[-1])) + return + + @commands.command() + async def join(self, ctx: commands.Context): + ''' + Join Secret Santa if it has not started. Contact the Secret Santa admin if you wish to join. + ''' + currAuthor = ctx.author + # check if the exchange has already started + if self.SecretSantaHelper.user_is_participant(currAuthor.id, self.usr_list): + await ctx.send(BOT_ERROR.ALREADY_JOINED) + else: + # initialize instance of Participant for the author + self.highest_key = self.highest_key + 1 + self.usr_list.append(SecretSantaParticipant(currAuthor.name, currAuthor.discriminator, currAuthor.id, self.highest_key)) + # write details of the class instance to config and increment total_users + self.config['members'][str(self.highest_key)] = [currAuthor.name, currAuthor.discriminator, currAuthor.id, self.highest_key, "", "N/A", ""] + self.config.write() + + # prompt user about inputting info + await ctx.send(currAuthor.mention + f" has been added to the {str(ctx.guild)} Secret Santa exchange!" + "\nMore instructions have been DMd to you.") + try: + userPrompt = f"Welcome to the __{str(ctx.guild)}__ Secret Santa! To complete your enrollment you'll need to input your wishlist URL and preferences (by DMing this bot) so your Secret Santa can send you something\n" + await currAuthor.send(userPrompt) + userPrompt = f".\nFirst we need your wishlist (or the destination for sending gifts). Please use `{CONFIG.prefix}setwishlisturl [wishlist urls separated by a space]` (e.g. `{CONFIG.prefix}setwishlisturl amazonurl/123 \"sesame street\"`) to set your wishlist URL (you may also add your mailing address)." + await currAuthor.send(userPrompt) + except Exception as e: + BOT_ERROR.output_exception(e, self.logger) + await ctx.send(currAuthor.mention + BOT_ERROR.DM_FAILED) + BOT_ERROR.output_error(currAuthor.mention + BOT_ERROR.DM_FAILED, self.logger) + return + + @commands.command() + async def leave(self, ctx: commands.Context): + ''' + Leave the Secret Santa + ''' + if(self.exchange_started): + await ctx.send(BOT_ERROR.EXCHANGE_IN_PROGRESS_LEAVE("a mod")) + return + + currAuthor = ctx.author + if(self.SecretSantaHelper.user_is_participant(currAuthor.id, self.usr_list)): + (index, user) = self.SecretSantaHelper.get_participant_object(currAuthor.id, self.usr_list) + self.usr_list.remove(user) + popped_user = self.config['members'].pop(str(user.usrnum)) + self.config.write() + if(self.is_paused): + self.user_left_during_pause = True + await ctx.send(currAuthor.mention + f" has left the {str(ctx.guild)} Secret Santa exchange") + else: + await ctx.send(BOT_ERROR.UNJOINED) + return + + @commands.guild_only() + @commands.command() + async def end(self, ctx: commands.Context): + ''' + End the Secret Santa + ''' + if(ctx.author.top_role == ctx.guild.roles[-1]): + self.exchange_started = False + self.is_paused = False + self.config['programData']['exchange_started'] = False + self.highest_key = 0 + del self.usr_list[:] + self.config['members'].clear() + self.config.write() + await ctx.send("Secret Santa ended") + else: + await ctx.send(BOT_ERROR.NO_PERMISSION(ctx.guild.roles[-1])) + return + + @commands.command(aliases=["lp"]) + async def listparticipants(self, ctx: commands.Context): + ''' + List Secret Santa participants + ''' + if(self.highest_key == 0): + await ctx.send(f"```Nobody has signed up for the secret Santa exchange yet. Use `{CONFIG.prefix}join` to enter the exchange.```") + else: + msg = '```The following people are signed up for the Secret Santa exchange:\n' + for user in self.usr_list: + msg = msg + str(user.name) + "#" + str(user.discriminator) + "\n" + msg = msg + f"\nUse `{CONFIG.prefix}join` to enter the exchange.```" + await ctx.send(msg) + return + + @commands.command(aliases=["tp"]) + async def totalparticipants(self, ctx: commands.Context): + ''' + Find out how many people have joined the Secret Santa + ''' + if self.highest_key == 0: + await ctx.send(f"Nobody has signed up for the Secret Santa exchange yet. Use `{CONFIG.prefix}join` to enter the exchange.") + elif self.highest_key == 1: + await ctx.send(f"1 person has signed up for the Secret Santa exchange. Use `{CONFIG.prefix}join` to enter the exchange.") + else: + await ctx.send(f"{str(len(self.usr_list))} people have joined the Secret Santa exchange so far. Use `{CONFIG.prefix}join` to enter the exchange.") + return + + @commands.command() + async def partnerinfo(self, ctx: commands.Context): + ''' + Get your partner information via DM (partner name, wishlist, gift preference) + ''' + currAuthor = ctx.author + authorIsParticipant = self.SecretSantaHelper.user_is_participant(currAuthor.id, self.usr_list) + if(self.exchange_started and authorIsParticipant): + (usr_index, user) = self.SecretSantaHelper.get_participant_object(currAuthor.id, self.usr_list) + (partner_index, partnerobj) = self.SecretSantaHelper.get_participant_object(user.partnerid, self.usr_list) + msg = "Your partner is " + partnerobj.name + "#" + partnerobj.discriminator + "\n" + msg = msg + "Their wishlist(s) can be found here: " + partnerobj.wishlisturl + "\n" + msg = msg + "And their gift preferences can be found here: " + partnerobj.preferences + "\n" + msg = msg + "If you have trouble accessing your partner's wishlist, please contact an admin to get in touch with your partner. This is a *Secret* Santa, after all!" + try: + await currAuthor.send(msg) + await ctx.send("The information has been sent to your DMs.") + except Exception as e: + BOT_ERROR.output_exception(e, self.logger) + await ctx.send(currAuthor.mention + BOT_ERROR.DM_FAILED) + BOT_ERROR.output_error(currAuthor.mention + BOT_ERROR.DM_FAILED, self.logger) + elif((not self.exchange_started) and authorIsParticipant): + await ctx.send(BOT_ERROR.NOT_STARTED) + elif(self.exchange_started and (not authorIsParticipant)): + await ctx.send(BOT_ERROR.EXCHANGE_STARTED_UNJOINED) + elif((not self.exchange_started) and (not authorIsParticipant)): + await ctx.send(BOT_ERROR.UNJOINED) + else: + await ctx.send(BOT_ERROR.UNREACHABLE) + return + + @commands.command() + async def dmpartner(self, ctx: commands.Context, *, message:str): + ''' + DM your Secret Santa partner anonymously (the person you have) via the bot + ''' + if(self.SecretSantaHelper.channel_is_guild(ctx.channel)): + await ctx.message.delete() # delete the message before people can see + (curr_idx, curr_user) = self.SecretSantaHelper.get_participant_object(ctx.author.id, self.usr_list) + if((curr_idx != -1) and self.exchange_started): # user has joined and the exchange has started + partner = self.bot.get_user(int(curr_user.partnerid)) + try: + msg = "You have a message from your secret santa\n" + msg += f"```{message}```" + await partner.send(msg) + BOT_ERROR.output_info(f"{ctx.author.name}#{ctx.author.discriminator} DMd {partner.name}#{partner.discriminator}: {message}", self.logger) + author_msg = "Message sent to your Secret Santa partner\n" + author_msg += f"```{message}```" + await ctx.author.send(author_msg) + return + except Exception as e: + BOT_ERROR.output_exception(e, self.logger) + await ctx.author.send(f"Failed to send message to {partner.name}#{partner.discriminator} about their partner. Harass them to turn on server DMs for Secret Santa stuff.") + elif(curr_idx == -1): # user has not joined + msg = BOT_ERROR.UNJOINED + await ctx.author.send(msg) + elif(not self.exchange_started): # exchange hasn't started (there are no partners) + msg = BOT_ERROR.NOT_STARTED + await ctx.author.send(msg) + else: + await ctx.send(BOT_ERROR.UNDETERMINED_CONTACT_CODE_OWNER) + return + + @commands.command() + async def dmsanta(self, ctx: commands.Context, *, message:str): + ''' + DM your Secret Santa (the person that has you) via the bot + ''' + if(self.SecretSantaHelper.channel_is_guild(ctx.channel)): + await ctx.message.delete() # delete the message before people can see + (santa_idx, santa_user) = self.SecretSantaHelper.get_participant_object(ctx.author.id, self.usr_list, id_is_partner=True) + if((santa_idx != -1) and self.exchange_started): # user has joined and the exchange has started + santa = self.bot.get_user(int(santa_user.idstr)) + try: + msg = "You have a message from your secret santa partner\n" + msg += f"```{message}```" + await santa.send(msg) + BOT_ERROR.output_info(f"{ctx.author.name}#{ctx.author.discriminator} DMd {santa.name}#{santa.discriminator}: {message}", self.logger) + author_msg = "Message sent to your Secret Santa\n" + author_msg += f"```{message}```" + await ctx.author.send(author_msg) + return + except Exception as e: + BOT_ERROR.output_exception(e, self.logger) + await ctx.author.send(f"Failed to send the message to your secret santa. Their DMs may be off. Please ask your Secret Santa admin.") + elif(santa_idx == -1): # user has not joined + msg = BOT_ERROR.UNJOINED + await ctx.author.send(msg) + elif(not self.exchange_started): # exchange hasn't started (there are no partners) + msg = BOT_ERROR.NOT_STARTED + await ctx.author.send(msg) + else: + await ctx.send(BOT_ERROR.UNDETERMINED_CONTACT_CODE_OWNER) + return + + @start.error + @restart.error + @pause.error + @end.error + async def dm_error(self, ctx: commands.Context, error): + if(isinstance(error, commands.NoPrivateMessage)): + await ctx.send(BOT_ERROR.DM_ERROR) + else: + BOT_ERROR.output_exception(error, self.logger) + await ctx.send(BOT_ERROR.UNDETERMINED_CONTACT_CODE_OWNER) + return diff --git a/helpers/BOT_ERROR.py b/helpers/BOT_ERROR.py new file mode 100644 index 0000000..0ddee39 --- /dev/null +++ b/helpers/BOT_ERROR.py @@ -0,0 +1,48 @@ +from logging import Logger +from traceback import TracebackException + +ALREADY_JOINED = "ERROR: You have already joined." +COUNTDOWN_NAME_TAKEN = "ERROR: That countdown name is already in use." +DM_ERROR = "ERROR: This command cannot be used in private messages." +DM_FAILED = "ERROR: DM with information failed to send. Please turn on server DMs to receive important Secret Santa-related messages." +EXCHANGE_IN_PROGRESS = "ERROR: The gift exchange is already in progress." +EXCHANGE_STARTED_UNJOINED = "ERROR: The exchange is already in progress. Please contact an admin about pausing the exchange before using s!join." +INACCESSIBLE_CHANNEL = "ERROR: the channel specified is not accessible to this bot." +INVALID_INPUT = "ERROR: invalid input." +MISSING_ARGUMENTS = "ERROR: command usage is missing arguments." +NOT_ENOUGH_SIGNUPS = "ERROR: Secret Santa not started. Need more people." +NOT_PAUSED = "ERROR: Secret Santa is not paused" +NOT_STARTED = "ERROR: partners have not been assigned yet." +REACTION_ROLE_UNASSIGNED = "ERROR: no reaction role channel has been assigned." +SIGNUPS_INCOMPLETE = "ERROR: Signups incomplete. There may be an incomplete wishlisturl." +UNDETERMINED_CONTACT_CODE_OWNER = "ERROR: undetermined please contact <@224949031514800128> for more information." # @mentions me, the maintainer of SantaBot +UNJOINED = "ERROR: you have not yet joined the Secret Santa exchange. Use s!join to join the exchange." +UNREACHABLE = "ERROR: this shouldn't happen." + +def ARCHIVE_ERROR_LENGTH(msg_url): + return f"ERROR: output message is too long pinned message {msg_url} was not archived." +def EXCHANGE_IN_PROGRESS_LEAVE(role): + return f"ERROR: The gift exchange is already in progress. Please contact <@224949031514800128> or {role} about leaving." +def HAS_NOT_SUBMITTED(usrname): + return f"ERROR: {usrname} has not submitted either a mailing wishlist URL or gift preferences." +def INVALID_COUNTDOWN_COMMAND(attempted_command): + return f"ERROR: invalid countdown option `{attempted_command}`." +def INVALID_COUNTDOWN_NAME(cd_name): + return f"ERROR: countdown timer `{cd_name}` does not exist. Use `s!countdown list` to list all countdown timers." +def CANNOT_CHANGE_COUNTDOWN(author_name): + return f"ERROR: you do not have permission to change that countdown timer. Please contact {author_name}" +def NO_PERMISSION(role): + return f"ERROR: you do not have permissions to do this.\nYou need the {role} role for that." + +def output_error(message: str, logger: Logger): + print(message) + logger.debug(message) + +def output_info(message: str, logger: Logger): + print(message) + logger.info(message) + +def output_exception(error, logger: Logger): + tb = TracebackException.from_exception(error) + exception_message = ''.join(tb.stack.format()) + output_error(exception_message, logger) diff --git a/helpers/SQLiteHelper.py b/helpers/SQLiteHelper.py new file mode 100644 index 0000000..94e0161 --- /dev/null +++ b/helpers/SQLiteHelper.py @@ -0,0 +1,136 @@ +import logging +import sqlite3 +from sqlite3 import Error + +class SQLiteHelper(): + def __init__(self, connection_path: str): + self.__connection_path = connection_path + self.__connection = None + self.logger = logging.getLogger('SQLiteHelper') + self.logger.info("Creating SQLiteHelper") + + def create_connection(self): + try: + if(self.__connection == None): + self.__connection = sqlite3.connect(self.__connection_path) + self.logger.info("Connection to SQLite DB successful", self.logger) + except Error as e: + self.logger.debug(f"The error '{e}' occurred", self.logger) + + def if_table_exists(self, table_name): + if(self.__connection == None): + self.logger.debug("Connection has not been created", self.logger) + return False + + cursor = self.__connection.cursor() + cursor.execute(f''' SELECT count(name) FROM sqlite_master WHERE type='table' AND name='{table_name}' ''') + #if the count is 1, then table exists + if cursor.fetchone()[0]==1 : + return True + return False + + def execute_query(self, query): + if(self.__connection == None): + self.logger.debug("Connection has not been created", self.logger) + return False + + cursor = self.__connection.cursor() + try: + cursor.execute(query) + self.__connection.commit() + self.logger.info("Query executed successfully", self.logger) + return True + except Error as e: + self.logger.debug(f"The error '{e.with_traceback}' occurred", self.logger) + return False + + def create_table(self, table_name: str, table_format: str): + ''' + example_usage --> + + ex_table_name = "users" + + ex_table_format = "( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + age INTEGER, + gender TEXT, + nationality TEXT + )" + + ~~~ + + CREATE TABLE IF NOT EXISTS users ( + + id INTEGER PRIMARY KEY AUTOINCREMENT, + + name TEXT NOT NULL, + + age INTEGER, + + gender TEXT, + + nationality TEXT + + ); + ''' + table_query = f""" + CREATE TABLE IF NOT EXISTS {table_name} {table_format}; + """ + return self.execute_query(table_query) + + + def insert_records(self, table_name: str, column_list: str, values: list): + ''' + example usage --> + + ex_table_name = "users", + + ex_column_list = "(name, age, gender, nationality)", + + ex_values = ["('James', 25, 'male', 'USA')", "('Leila', 32, 'female', 'France')"] + + ~~~ + + INSERT INTO users (name, age, gender, nationality) + + VALUES + + ('James', 25, 'male', 'USA'), + + ('Leila', 32, 'female', 'France'); + ''' + value_str = ",".join(values) + table_query = f""" + INSERT INTO + {table_name} {column_list} + VALUES + {value_str}; + """ + + return self.execute_query(table_query) + + def execute_read_query(self, query): + cursor = self.__connection.cursor() + result = None + try: + cursor.execute(query) + result = cursor.fetchall() + return result + except Error as e: + self.logger.debug(f"Problem reading - the error '{e}' occurred", self.logger) + + def execute_delete_query(self, table_name: str, conditions: str): + delete_query = f"DELETE FROM {table_name} WHERE {conditions}" + return self.execute_query(delete_query) + + def execute_update_query(self, table_name: str, new_column_data: str, conditions: str): + update_query = f""" + UPDATE + {table_name} + SET + {new_column_data} + WHERE + {conditions} + """ + return self.execute_query(update_query) \ No newline at end of file diff --git a/helpers/SantaCountdownHelper.py b/helpers/SantaCountdownHelper.py new file mode 100644 index 0000000..1a588a9 --- /dev/null +++ b/helpers/SantaCountdownHelper.py @@ -0,0 +1,190 @@ +import logging +import pendulum + +from discord.ext import commands + +import helpers.BOT_ERROR as BOT_ERROR +from helpers.SQLiteHelper import SQLiteHelper +import CONFIG + +class SantaCountdownHelper(): + def __init__(self, sqlitehelper: SQLiteHelper): + self.pend_format = "M/D/YY [@] h:m A Z" + self.cd_table_name = "Countdowns" + self.sqlhelp = sqlitehelper + self.logger = logging.getLogger('SantaBot.SantaCountdownHelper') + self.logger.info("Creating SantaCountdownHelper") + + def __get_cd_table_name(self, guild_id: str): + return f"{self.cd_table_name}_{guild_id}" + + def __countdown_cmd_set(self, ctx: commands.Context, cd_name: str, cd_time: str): + result_str = "" + try: + pend_test_convert = pendulum.from_format(cd_time, self.pend_format) # check that the format is correct + if(self.sqlhelp.insert_records(self.__get_cd_table_name(ctx.guild.id), "(name, time, user_id)", [f"('{cd_name}', '{cd_time}', {ctx.author.id})"])): + diff_str = self.__find_pend_diff_str(pend_test_convert) + result_str = f"{cd_name} countdown set for {cd_time} ({diff_str})" + else: + result_str = BOT_ERROR.COUNTDOWN_NAME_TAKEN + except ValueError as error: + expected = "ERROR: inputted time does not match expected format `month/day/year @ hour:minute AM/PM UTC_offset`\n" + result_str = expected + "ex. `5/17/20 @ 1:00 PM -06:00`" + BOT_ERROR.output_debug(result_str, self.logger) + finally: + return result_str + + def __countdown_cmd_change(self, ctx: commands.Context, cd_name: str, cd_time: str): + result_str = "" + try: + pend_test_convert = pendulum.from_format(cd_time, self.pend_format) # check that the format is correct + except ValueError as error: + expected = "ERROR: inputted time does not match expected format `month/day/year @ hour:minute AM/PM UTC_offset`\n" + result_str = expected + " ex. `5/17/20 @ 1:00 PM -06:00`" + BOT_ERROR.output_debug(result_str, self.logger) + return result_str + + query_get_timer_by_name = f"SELECT * FROM {self.__get_cd_table_name(ctx.guild.id)} WHERE name=\'{cd_name}\';" + query_result = self.sqlhelp.execute_read_query(query_get_timer_by_name) + if(query_result != None): + if(len(query_result) > 0): + (query_id, query_name, query_time, query_user_id) = query_result[0] + if(ctx.author.id == query_user_id): + if(self.sqlhelp.execute_update_query(self.__get_cd_table_name(ctx.guild.id), f"time=\'{cd_time}\'", f"id={query_id}")): + diff_str = self.__find_pend_diff_str(pend_test_convert) + result_str = f"Updated countdown for {cd_name}. Now set for {diff_str}" + else: + result_str = BOT_ERROR.INVALID_COUNTDOWN_NAME(cd_name) + else: + cd_owner = ctx.guild.get_member(query_user_id) + result_str = BOT_ERROR.CANNOT_CHANGE_COUNTDOWN(cd_owner.name) + else: + result_str = BOT_ERROR.INVALID_COUNTDOWN_NAME(cd_name) + + return result_str + + def __countdown_cmd_check(self, ctx: commands.Context, cd_name: str): + result_str = "" + query_get_timer_by_name = f"SELECT * FROM {self.__get_cd_table_name(ctx.guild.id)} WHERE name=\'{cd_name}\';" + query_result = self.sqlhelp.execute_read_query(query_get_timer_by_name) + if(query_result != None): + (query_id, query_name, query_time, query_user_id) = query_result[0] + cd_pend = pendulum.from_format(query_time, self.pend_format) + diff_str = self.__find_pend_diff_str(cd_pend) + result_str = f"Time until {cd_name}: {diff_str}" + else: + result_str = BOT_ERROR.INVALID_COUNTDOWN_NAME(cd_name) + return result_str + + def __countdown_cmd_remove(self, ctx: commands.Context, cd_name: str): + result_str = "" + query_get_timer_by_name = f"SELECT * FROM {self.__get_cd_table_name(ctx.guild.id)} WHERE name=\'{cd_name}\';" + query_result = self.sqlhelp.execute_read_query(query_get_timer_by_name) + if(query_result != None): + (query_id, query_name, query_time, query_user_id) = query_result[0] + if(query_user_id == ctx.author.id): + if(self.sqlhelp.execute_delete_query(self.__get_cd_table_name(ctx.guild.id), f"id={query_id}")): + result_str = f"Countdown timer `{query_name}` removed." + else: + result_str = BOT_ERROR.INVALID_COUNTDOWN_NAME(cd_name) + else: + cd_owner = ctx.guild.get_member(query_user_id) + result_str = BOT_ERROR.CANNOT_CHANGE_COUNTDOWN(cd_owner.name) + else: + result_str = BOT_ERROR.INVALID_COUNTDOWN_NAME(cd_name) + return result_str + + def __countdown_cmd_list(self, ctx: commands.Context, ): + result_str = "" + query_get_all_timers = f"SELECT * FROM {self.__get_cd_table_name(ctx.guild.id)};" + query_results = self.sqlhelp.execute_read_query(query_get_all_timers) + result_str = "Countdown Name | Owner | Time | Time Until\n" + if(query_results != None): + for (query_id, query_name, query_time, query_user_id) in query_results: + cd_pend = pendulum.from_format(query_time, self.pend_format) # convert to pendulum + diff_str = self.__find_pend_diff_str(cd_pend) + time_until_str = f"Time until {query_name}: {diff_str}" + cd_owner = ctx.guild.get_member(query_user_id).name + result_str += f"{query_name} | {cd_owner} | {query_time} | {time_until_str}\n" + return result_str + + def __countdown_cmd_clean(self, ctx: commands.Context): + result_str = "" + query_get_all_timers = f"SELECT * FROM {self.__get_cd_table_name(ctx.guild.id)};" + query_results = self.sqlhelp.execute_read_query(query_get_all_timers) # get all the countdowns + if(query_results != None): + for (query_id, query_name, query_time, query_user_id) in query_results: + if(not pendulum.from_format(query_time, self.pend_format).is_future()): # if the countdown has passed, delete + result_str += f"{query_time} has passed. Deleting {query_name} countdown.\n" + self.sqlhelp.execute_delete_query(self.__get_cd_table_name(ctx.guild.id), f"id = {query_id}") + return result_str + + def __find_countdown_hints(self, cd_command: str, cd_name: str, cd_time: str): + ''' + Get argument hints based on the command input and user input - only called from SantaAdministrative.countdown() + ''' + missing_args_str = "Missing argument(s):" + missing_name_str = "" + missing_time_str = "" + missing_time_hint = "Formatted time ex. `5/17/20 @ 1:00 PM -06:00`" + complete_command_str = f"Complete command: `{CONFIG.prefix}countdown {cd_command}" + argument_help = "" + if(cd_command == "set"): + if(cd_name == ""): + argument_help = f"{missing_args_str} {missing_name_str} | {missing_time_str}\n{missing_time_hint}\n" + argument_help += f"{complete_command_str} {missing_name_str} | {missing_time_str}`" + elif(cd_time == ""): + argument_help = f"{missing_args_str} {missing_time_str}\n{missing_time_hint}\n" + argument_help += f"{complete_command_str} {cd_name} | {missing_time_str}`" + elif(cd_command == "change"): + if(cd_name == ""): + argument_help = f"{missing_args_str} {missing_name_str} | {missing_time_str}\n{missing_time_hint}\n" + argument_help += f"{complete_command_str} {missing_name_str} | {missing_time_str}`" + elif(cd_time == ""): + argument_help = f"{missing_args_str} {missing_time_str}\n{missing_time_hint}\n" + argument_help += f"{complete_command_str} {cd_name} | {missing_time_str}`" + elif(cd_command == "check"): + if(cd_name == ""): + argument_help = f"{missing_args_str} {missing_name_str}\n" + argument_help += f"{complete_command_str} {missing_name_str}`" + elif(cd_command == "remove"): + if(cd_name == ""): + argument_help = f"{missing_args_str} {missing_name_str}\n" + argument_help += f"{complete_command_str} {missing_name_str}`" + elif(cd_command == "list"): + pass + elif(cd_command == "clean"): + pass + return argument_help + + def __find_pend_diff_str(self, pend: pendulum.DateTime): + cd_diff = pend.diff(pendulum.now()) + (diff_days, diff_hours, diff_minutes) = (cd_diff.days, cd_diff.hours, cd_diff.minutes) + if(not pend.is_future()): + (diff_days, diff_hours, diff_minutes) = (-diff_days, -diff_hours, -diff_minutes) + diff_str = f"{diff_days} days, {diff_hours} hours, {diff_minutes} minutes from now" + return diff_str + + def run_countdown_command(self, ctx: commands.Context, cd_command: str, cd_name: str, cd_time: str): + output = self.__find_countdown_hints(cd_command, cd_name, cd_time) + if(output == ""): # no hints were needed + if(not self.sqlhelp.if_table_exists(self.__get_cd_table_name(ctx.guild.id))): # make sure table exists before executing the CD command + self.sqlhelp.create_table(self.__get_cd_table_name(ctx.guild.id), "(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, time TEXT NOT NULL, user_id INTEGER NOT NULL, UNIQUE(name))") + + if(cd_command == "set"): + output = self.__countdown_cmd_set(ctx, cd_name, cd_time) + elif(cd_command == "change"): + output = self.__countdown_cmd_change(ctx, cd_name, cd_time) + elif(cd_command == "check"): + output = self.__countdown_cmd_check(ctx, cd_name) + elif(cd_command == "remove"): + output = self.__countdown_cmd_remove(ctx, cd_name) + elif(cd_command == "list"): + output = self.__countdown_cmd_list(ctx) + elif(cd_command == "clean"): + output = self.__countdown_cmd_clean(ctx) + else: + output = BOT_ERROR.INVALID_COUNTDOWN_COMMAND(cd_command) + output += "\nCountdown options/sub-commands: `set`, `change`, `check` , `remove`, `list`, `clean`." + + return output \ No newline at end of file diff --git a/helpers/SecretSantaConstants.py b/helpers/SecretSantaConstants.py new file mode 100644 index 0000000..a44c437 --- /dev/null +++ b/helpers/SecretSantaConstants.py @@ -0,0 +1,11 @@ +from enum import IntEnum + +class SecretSantaConstants(IntEnum): + """Constants for indexing into each Secret Santa participant's field when parsing the .cfg file""" + NAME = 0 + DISCRIMINATOR = 1 + IDSTR = 2 + USRNUM = 3 + WISHLISTURL = 4 + PREFERENCES = 5 + PARTNERID = 6 diff --git a/helpers/SecretSantaHelpers.py b/helpers/SecretSantaHelpers.py new file mode 100644 index 0000000..3292a2e --- /dev/null +++ b/helpers/SecretSantaHelpers.py @@ -0,0 +1,110 @@ +import copy +import random +from discord import Member +from discord.abc import GuildChannel, PrivateChannel +import discord +from helpers.SecretSantaParticipant import SecretSantaParticipant + +class SecretSantaHelpers(): + + def isListOfParticipants(self, usrlist: list): + for usr in usrlist: + if(not isinstance(usr, SecretSantaParticipant)): + return False + + def user_is_participant(self, usrid: discord.User.id, usrlist: list): + """Takes a discord user ID string and returns whether + a user with that ID is in usr_list""" + for person in usrlist: + if int(person.idstr) == usrid: + return True + return False + + def get_participant_object(self, usrid: int, usrlist: list, id_is_partner=False): + """takes a discord user ID string and list of + participant objects, and returns the first + participant object with matching id. + + Parameters: + usrid (int): the ID of the user you're looking for + usrlist (list): the list the user resides in + id_is_partner (bool): if True, this function will find the SecretSantaParticipant in usrlist with the usrid as its partner + """ + for (index, person) in enumerate(usrlist): + if(id_is_partner): + if(person.partnerid != ""): + if(int(person.partnerid) == usrid): + return (index, person) + else: + if(int(person.idstr) == usrid): + return (index, person) + return (-1, None) + + def propose_partner_list(self, usrlist: list): + """Generate a proposed partner list""" + usr_list_copy = copy.deepcopy(usrlist) + partners = copy.deepcopy(usrlist) + ## propose partner list + for user in usr_list_copy: + candidates = partners + partner = candidates[random.randint(0, len(candidates) - 1)] + while(partner.idstr == user.idstr): + partner = candidates[random.randint(0, len(candidates) - 1)] + if((len(candidates) == 1) & (candidates[0].idstr == user.idstr)): + break # no choice but to pick yourself (this will be declared invalid later) + #remove user's partner from list of possible partners + partners.remove(partner) + #save the partner id to the participant's class instance + user.partnerid = partner.idstr + return usr_list_copy + + ## everybody has a partner, nobody's partnered with themselves + def partners_are_valid(self, usrlist: list): + """Make sure that everybody has a partner + and nobody is partnered with themselves""" + if(not usrlist): + return False + result = True + for user in usrlist: + result = result & (user.partnerid != '') & (user.partnerid != user.idstr) + return result + + ## checks if the user list changed during a pause + def usr_list_changed_during_pause(self, usrlist: list, usr_left: bool): + """checks if the user list changed during a pause""" + if(usr_left):# there's probably a better boolean logic way but this is easy + usr_left = False # acknowledge + return True + + has_changed = True + for user in usrlist: + has_match = (not (str(user.partnerid) == "")) + has_changed = has_changed & has_match # figures out if all users have a match + has_changed = has_changed & (not usr_left) + return (not has_changed) ## if not all users have a match + + def channelIsPrivate(self, channel): + return isinstance(channel, PrivateChannel) + + def channel_is_guild(self, channel): + return isinstance(channel, GuildChannel) + + def member_is_mod(self, person: discord.Member, mod_list: list): + """Checks that a given member is in the mod list + @param person the Member in question""" + if(isinstance(person, Member)): + person_roles = person.roles + person_roles_ids = [] + for person_role in person_roles: + person_roles_ids.append(person_role.id) + lst3 = [value for value in person_roles_ids if value in mod_list] + if(lst3): + return True + return False + + def is_role_in_server(self, p_role: str, server_role_hierarchy: list): + if(isinstance(p_role, str)): + for server_role in server_role_hierarchy: + if((str(server_role) == p_role) or (str(server_role.mention) == p_role)): + return server_role + return False diff --git a/helpers/SecretSantaParticipant.py b/helpers/SecretSantaParticipant.py new file mode 100644 index 0000000..067f046 --- /dev/null +++ b/helpers/SecretSantaParticipant.py @@ -0,0 +1,36 @@ +class SecretSantaParticipant(object): + """class defining a participant and info associated with them""" + def __init__(self, name, discriminator, idstr, usrnum, wishlisturl="", preferences="N/A", partnerid=""): + self.name = name #string containing name of user + self.discriminator = discriminator #string containing discriminant of user + self.idstr = idstr #string containing id of user + self.usrnum = usrnum #int value referencing the instance's location in usr_list + self.wishlisturl = wishlisturl #string for user's wishlisturl + self.preferences = preferences #string for user's gift preferences + self.partnerid = partnerid #string for id of partner + + #def __repr__(self): + # return f"Participant(User: {self.name}#{self.discriminator}, Key: {self.usrnum}, wishlisturl={self.wishlisturl}, preferences={self.preferences}, partnerid={self.partnerid})" + + #def __str__(self): + # return f"User: {self.name}#{self.discriminator}, Key: {self.usrnum}" + + def __hash__(self): + return hash(self.idstr) + + def __eq__(self, other): + return self.idstr == other.idstr + + def wishlisturl_is_set(self): + """returns whether the user has set an wishlisturl""" + if (self.wishlisturl == "None") or (self.wishlisturl == ""): + return False + else: + return True + + def pref_is_set(self): + """returns whether the user has set gift preferences""" + if (self.preferences == "None") or (self.preferences == ""): + return False + else: + return True diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5bf4960 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +discord.py==1.6.0 +configobj +pendulum +urllib3 diff --git a/run_santa.sh b/run_santa.sh new file mode 100755 index 0000000..563ac6b --- /dev/null +++ b/run_santa.sh @@ -0,0 +1,10 @@ +#!/bin/bash +### Honestly don't use this unless you KNOW it's not going to fail. +### This script was written because discord.py does not relaunch bots that run into WebSocket errors but the rewrite does. Requires two keyboard interrupts to end. +### This is to get around that. + +while true +do + python3 santa-bot.py + sleep 10 +done \ No newline at end of file diff --git a/santa-bot.py b/santa-bot.py index e2713f1..40c3a0c 100644 --- a/santa-bot.py +++ b/santa-bot.py @@ -1,235 +1,68 @@ import logging -import asyncio -import os.path -import random -import discord -from configobj import ConfigObj - -class Participant(object): - """class defining a participant and info associated with them""" - def __init__(self, name, idstr, usrnum, address='', preferences='', partnerid=''): - self.name = name #string containing name of user - self.idstr = idstr #string containing id of user - self.usrnum = usrnum #int value referencing the instance's location in usr_list - self.address = address #string for user's address - self.preferences = preferences #string for user's gift preferences - self.partnerid = partnerid #string for id of partner - - def address_is_set(self): - """returns whether the user has set an address""" - if self.address == '': - return False - else: - return True - - def pref_is_set(self): - """returns whether the user has set gift preferences""" - if self.preferences == '': - return False - else: - return True +import os +import pathlib -#initialize config file -try: - config = ConfigObj('./files/botdata.cfg') -except: - os.mkdir('./files/') - config = ConfigObj('./files/botdata.cfg') - config['programData'] = {'exchange_started': False, 'discord_token': 'token'} - config['members'] = {} - config.write() - -#initialize data from config file -usr_list = [] -total_users = 0 -exchange_started = config['programData'].as_bool('exchange_started') -for key in config['members']: - total_users = total_users + 1 - usr_list.append(Participant(key[0], key[1], total_users, key[2], key[3], key[4])) +from configobj import ConfigObj +from discord.ext import commands +from discord import Intents -def user_is_participant(usrid, usrlist=usr_list): - """Takes a discord user ID string and returns whether - a user with that ID is in usr_list""" - result = False - for person in usrlist: - if person.idstr == usrid: - result = True - break - return result +import CONFIG +from cogs.SecretSanta import SecretSanta +from cogs.SantaAdministrative import SantaAdministrative +from cogs.SantaMiscellaneous import SantaMiscellaneous +from cogs.SantaUtilities import SantaUtilities +from helpers.SQLiteHelper import SQLiteHelper +from helpers.SantaCountdownHelper import SantaCountdownHelper -def get_participant_object(usrid, usrlist=usr_list): - """takes a discord user ID string and list of - participant objects, and returns the first - participant object with matching id.""" - for person in usrlist: - if person.idstr == usrid: - return person - break +def start_santa_bot(): + #initialize config file + config = None + try: + config = ConfigObj(CONFIG.cfg_path, file_error = True) + except Exception as e: + try: + os.mkdir(CONFIG.bot_folder) + except Exception as e: + pass + config = ConfigObj() + config.filename = CONFIG.cfg_path + config['programData'] = {'exchange_started': False} + config['members'] = {} + config.write() -#set up discord connection debug logging -client_log = logging.getLogger('discord') -client_log.setLevel(logging.DEBUG) -client_handler = logging.FileHandler(filename='./files/debug.log', encoding='utf-8', mode='w') -client_handler.setFormatter(logging.Formatter('%(asctime)s:%(levelname)s:%(name)s: %(message)s')) -client_log.addHandler(client_handler) + # initialize the SQLite db + sqlitehelper = SQLiteHelper(CONFIG.sqlite_path) + sqlitehelper.create_connection() -#initialize client class instance -client = discord.Client() + #set up discord connection debug logging + discord_api_logger = logging.getLogger('discord') + discord_api_logger.setLevel(logging.DEBUG) + discord_api_handler = logging.FileHandler(filename=os.path.join(str(pathlib.Path(__file__).parent), CONFIG.bot_folder, "discord_api.log"), encoding='utf-8', mode='w') + discord_api_handler.setFormatter(logging.Formatter('%(asctime)s:%(levelname)s:%(name)s: %(message)s')) + discord_api_logger.addHandler(discord_api_handler) -#handler for all on_message events -@client.event -async def on_message(message): - #declare global vars - global usr_list - global total_users - global exchange_started - #write all messages to a chatlog - with open('./files/chat.log', 'a+') as chat_log: - chat_log.write('[' + message.author.name + message.author.id + ' in ' + message.channel.name + ' at ' + str(message.timestamp) + ']' + message.content + '\n') - - #ignore messages from the bot itself - if message.author == client.user: - return - - #event for a user joining the secret santa - elif message.content.startswith('$$join'): - #check if message author has already joined - if user_is_participant(message.author.id): - await client.send_message(message.channel, '`Error: You have already joined.`') - #check if the exchange has already started - elif exchange_started: - await client.send_message(message.channel, '`Error: Too late, the gift exchange is already in progress.`') - else: - #initialize instance of participant class for the author - usr_list.append(Participant(message.author.name, message.author.id, total_users)) - #write details of the class instance to config and increment total_users - total_users = total_users + 1 - config['members'][str(total_users)] = [message.author.name, message.author.id, total_users, '', '', ''] - config.write() - - #prompt user about inputting info - await client.send_message(message.channel, message.author.mention + ' Has been added to the OfficialFam Secret Santa exchange!') - await client.send_message(message.author, 'Please input your mailing address so your secret Santa can send you something!\n' - + 'Use `$$setaddress` to set your mailing adress\n' - + 'Use `$$setpreference` to set gift preferences for your secret santa') - - #accept address of participants - elif message.content.startswith('$$setaddress'): - #check if author has joined the exchange yet - if user_is_participant(message.author.id): - #add the input to the value in the user's class instance - user = get_participant_object(message.author.id) - user.address = message.content.replace('$$setaddress', '', 1) - print(user.address) - #save to config file - config['members'][str(user.usrnum)][3] = user.address - config.write() - else: - await client.send_message(message.author, 'Error: you have not yet joined the secret santa exchange. Use `$$join` to join the exchange.') - - #accept gift preferences of participants - elif message.content.startswith('$$setprefs'): - #check if author has joined the exchange yet - if user_is_participant(message.author.id): - #add the input to the value in the user's class instance - user = get_participant_object(message.author.id) - user.preferences = message.content.replace('$$setpref', '', 1) - #save to config file - config['members'][str(user.usrnum)][4] = user.preferences - config.write() - else: - await client.send_message(message.author, 'Error: you have not yet joined the secret santa exchange. Use `$$join` to join the exchange.') - - #command for admin to begin the secret santa partner assignmenet - elif message.content.startswith('$$start'): - #only allow people with admin permissions to run - if message.author.top_role == message.server.role_heirarchy[0]: - #first ensure all users have all info submitted - all_fields_complete = True - for user in usr_list: - if user.address_is_set() and user.pref_is_set(): - pass - else: - all_fields_complete = False - await client.send_message(message.author, '`Error: ' + user.name + ' has not submitted either a mailing address or gift preferences.`') - await client.send_message(message.author, '`Partner assignment canceled: participant info incomplete.`') - - #select a random partner for each participant if above loop found no empty values - if all_fields_complete: - partners = usr_list - for user in usr_list: - candidates = partners - candidates.remove(user) - partner = candidates[random.randint(0, len(candidates) - 1)] - #remove user's partner from list of possible partners - partners.remove(partner) - #save the partner id to the participant's class instance - user.partnerid = partner.idstr - #save to config file - config['users'][str(user.usrnum)][5] = user.partnerid - config.write() + # set up SantaBot debug logging + santabot_logger = logging.getLogger('SantaBot') + santabot_logger.setLevel(logging.DEBUG) + santabot_log_handler = logging.FileHandler(filename=CONFIG.dbg_path, encoding='utf-8', mode='w') + santabot_log_handler.setLevel(logging.DEBUG) + santabot_log_handler.setFormatter(logging.Formatter('%(asctime)s:%(levelname)s:%(name)s: %(message)s')) + santabot_logger.addHandler(santabot_log_handler) - #tell participants who their partner is - await client.send_message(user, partner.name + partner.idstr + 'Is your secret santa partner! Now pick out a gift for them and send it to them!') - await client.send_message(user, 'Their mailing address is ' + partner.address) - await client.send_message(user, 'Here are their gift preferences:') - #set exchange_started + assoc. cfg value to True - exchange_started = True - config['programData']['exchange_started'] = True - else: - await client.send_message(message.channel, '`Error: you do not have permission to do this.`') - - #allows a way to exit the bot - elif message.content.startswith('$$shutdown') and not message.channel.is_private: - #Fonly allow ppl with admin permissions to run - if message.author.top_role == message.server.role_heirarchy[0]: - await client.send_message(message.channel, 'Curse your sudden but inevitable betrayal!') - raise KeyboardInterrupt - else: - await client.send_message(message.channel, '`Error: you do not have permission to do this.`') - - #lists off all participant names and id's - elif message.content.startswith('$$listparticipants'): - if total_users == 0: - await client.send_message(message.channel, 'Nobody has signed up for the secret santa exchange yet. Use `$$join` to enter the exchange.') - else: - msg = '```The following people are signed up for the secret santa exchange:\n' - for user in usr_list: - msg = msg + user.name + user.idstr + '\n' - msg = msg + 'Use `$$join` to enter the exchange.```' - await client.send_message(message.channel, msg) - - #lists total number of participants - elif message.content.startswith('$$totalparticipants'): - if total_users == 0: - await client.send_message(message.channel, 'Nobody has signed up for the secret santa exchange yet. Use `$$join` to enter the exchange.') - elif total_users == 1: - await client.send_message(message.channel, '1 person has signed up for the secret santa exchange. Use `$$join` to enter the exchange.') - else: - await client.send_message(message.channel, 'A total of ' + total_users + ' users have joined the secret santa exchange so far. Use `$$join` to enter the exchange.') - - #allows a user to have the details of their partner restated - elif message.content.startswith('$$partnerinfo'): - if exchange_started and user_is_participant(message.author.id): - userobj = get_participant_object(message.author.id) - partnerobj = get_participant_object(userobj.partnerid) - msg = 'Your partner is ' + user.partner + user.partnerid + '\n' - msg = msg + 'Their mailing address is ' + partnerobj.address + '\n' - msg = msg + 'their gift preference is as follows:\n' - msg = msg + partnerobj.preferences - await client.send_message(message.author, msg) - elif exchange_started: - await client.send_message(message.channel, '`Error: partners have not been assigned yet.`') - else: - await client.send_message(message.author, '`Error: You are not participating in the gift exchange.`') + # add the cogs + intents = Intents.default() + intents.members = True + bot = commands.Bot(command_prefix = CONFIG.prefix, intents=intents) + bot.add_cog(SantaAdministrative(bot)) + bot.add_cog(SantaMiscellaneous(bot)) + bot.add_cog(SantaUtilities(bot, sqlitehelper)) + bot.add_cog(SecretSanta(bot, config)) -@client.event -async def on_ready(): - """print message when client is connected""" - print('Logged in as') - print(client.user.name) - print(client.user.id) - print('------') + # kick off the bot + bot.run(CONFIG.discord_token, reconnect = True) -#event loop and discord initiation -client.run(config['programData']['discord_token']) +if __name__ == '__main__': + start_santa_bot() +else: + print("santa-bot.py does nothing unless it is run as the main program") + quit() \ No newline at end of file diff --git a/santabot.service b/santabot.service new file mode 100644 index 0000000..1746a2c --- /dev/null +++ b/santabot.service @@ -0,0 +1,15 @@ +[Unit] +Description=Santa Discord Bot + +[Service] +#Add user and group they belong to. Find default group with "id -gn username" +User=usernamehere +Group=defaultgrouphere +Restart=on-abort +#Add full directory for where santabot lives. If santabot is in /home/pi/Santabot then place that there. +WorkingDirectory= /home/pi/Santabot +#Copy run_santa.sh from Santabot directory into /usr/local/bin/ +ExecStart= /usr/local/bin/run_santa.sh + +[Install] +WantedBy=multi-user.target