Add the rest
All checks were successful
test and deploy / test (3.11.5) (push) Successful in 1m23s
test and deploy / push-and-build (push) Successful in 59s
Test Helper Module / test (3.11.5) (push) Successful in 1m28s

This commit is contained in:
2025-10-16 14:04:02 -06:00
parent ab860b8cab
commit 3032210a85
18 changed files with 2319 additions and 0 deletions

51
src/py/bot_aux.py Normal file
View File

@@ -0,0 +1,51 @@
"""
This module is the automated poster to discord.
This module will post to the discord whenever the script is run, detailing the callouts for the current raid for the next seven days.
This automation will be run on a daily basis, through a cron job + docker.
@author: Gabriella 'contrastellar' Agathon
"""
import argparse
import os
import discord
import helper.db_helper
DATABASE_CONN = None
intents = discord.Intents.default()
intents.message_content = True
intents.guild_messages = True
intents.presences = False
client = discord.Client(intents=intents)
NUMBER_OF_DAYS = 7
parser: argparse.ArgumentParser = argparse.ArgumentParser(prog='callouts aux',
description='The poster for the callouts bot functionality')
parser.add_argument('database')
parser.add_argument('token')
parser.add_argument('guild_id', type=int)
parser.add_argument('channel_id', type=int)
args: argparse.Namespace = parser.parse_args()
@client.event
async def on_ready():
print(f'{client.user} has connected.')
print(args.guild_id)
guild: discord.Guild = client.get_guild(args.guild_id)
channel: discord.TextChannel = guild.get_channel(args.channel_id)
callouts = DATABASE_CONN.query_callouts(NUMBER_OF_DAYS)
formatted_callouts = DATABASE_CONN.formatted_list_of_callouts(callouts)
output = f'Callouts for the next {NUMBER_OF_DAYS} days:\n' + formatted_callouts
await channel.send(output)
await client.close() # Another way to exit, a little bit cleaner than exit(0)
return
DATABASE_CONN = helper.db_helper.DBHelper(args.database)
TOKEN = open(args.token, encoding='utf-8').read()
client.run(TOKEN)

295
src/py/bot_core.py Normal file
View File

@@ -0,0 +1,295 @@
# pylint: disable=consider-using-with, no-member
"""
This module is the listener to discord.
This module will listen to the discord server for two things:
1. the /schedule command -- which will report the current callouts for the next X days, where X is either supplied or is the default of 7
2. the /callout command -- which will allow users to add a new scheduled callout
3. a /ping command, to test the bot's current status!
4. a /registercharacter command, to allow users to register their character's name independently of their server nickname
5. a /checkcharname command, to allow users to verify their character's name
6. a /remove_callout command, to allow users to remove callouts that are no longer necessary
7. a /help command, to direct users to the github for this bot!
@author: Gabriella 'contrastellar' Agathon
"""
import datetime
import argparse
import os
import discord
import psycopg2
from discord.ext import commands
import helper.db_helper
# module constants
DAYS_FOR_CALLOUTS = 7
CONTRASTELLAR = 181187505448681472
DATABASE_CONN: helper.db_helper.DBHelper = None
# psycopg2 'imports'
UNIQUEVIOLATION: psycopg2.Error = psycopg2.errors.UniqueViolation
INVALIDDATETIMEFORMAT: psycopg2.Error = psycopg2.errors.InvalidDatetimeFormat
FOREIGNKEYVIOLATION: psycopg2.Error = psycopg2.errors.ForeignKeyViolation
# discord variables
intents = discord.Intents.default()
intents.message_content = True
intents.guild_messages = True
intents.presences = False
# client declaration
client = commands.Bot(command_prefix='!', intents=intents)
# parser declaration
parser: argparse.ArgumentParser = argparse.ArgumentParser(prog='callouts core',
description='The listener for the callouts bot functionality')
parser.add_argument('database')
parser.add_argument('token')
parser.add_argument('guild_id', type=int)
parser.add_argument('channel_id', type=int)
# utility methods
def cleanup_invalidate() -> None:
DATABASE_CONN.is_procedure_queued = False
def delete_invalidate() -> None:
DATABASE_CONN.is_unregister_queued = False
# discord commands
@client.event
async def on_ready() -> None:
await client.tree.sync()
print(f'{client.user} has connected to Discord!')
print(args.guild_id)
if 'RAID_CALLOUTS_DEV' in os.environ:
return
guild: discord.Guild = client.get_guild(args.guild_id)
channel: discord.TextChannel = guild.get_channel(args.channel_id)
output = f'The bot is now running!\nPlease message <@{CONTRASTELLAR}> with any errors!'
await channel.send(output)
return
@client.event
async def on_error(interaction: discord.Interaction) -> None:
delete_invalidate()
cleanup_invalidate()
output = "Something awful has happened. In all honesty you should never see this message. Reporting to <@{CONTRASTELLAR}>."
await interaction.response.send_message(output)
return
# === slash commands are below here
@client.tree.command()
async def registercharacter(interaction: discord.Interaction, character_name: str) -> None:
delete_invalidate()
cleanup_invalidate()
user_id = interaction.user.id
user_nick = interaction.user.display_name
try:
DATABASE_CONN.register_char_name(user_id, character_name)
except psycopg2.Error as e:
char_name = DATABASE_CONN.return_char_name(user_id)
await interaction.response.send_message(f'User {char_name} -- you have already registered a character!\n{e}')
else:
await interaction.response.send_message(f'{user_nick} -- you have registered your discord account with {character_name}!')
return
@client.tree.command()
async def check_char_name(interaction: discord.Interaction) -> None:
delete_invalidate()
cleanup_invalidate()
charname: str = DATABASE_CONN.return_char_name(interaction.user.id)
if charname == "":
await interaction.response.send_message("You have not registered! Please do with `/registercharacter`")
return
if interaction.user.id == 151162055142014976:
await interaction.response.send_message("You are: " + charname + "... in case you forgot.")
return
await interaction.response.send_message("You are: " + charname)
return
@client.tree.command()
async def remove_registration(interaction: discord.Interaction) -> None:
delete_invalidate()
cleanup_invalidate()
await interaction.response.send_message("To remove your registration with the boss, please run the `/confirm_unregister` command\nPlease be aware that this will also remove all of your callouts from the bot! ***This is in an irreversable action!***")
DATABASE_CONN.is_unregister_queued = True
return
@client.tree.command()
async def validate_unregister(interaction: discord.Interaction) -> None:
cleanup_invalidate()
user_id = interaction.user.id
user_nick = interaction.user.nick
await interaction.response.defer(thinking=True)
print(f"Removing {user_id} from the database!")
DATABASE_CONN.remove_registration(user_id, DATABASE_CONN.is_unregister_queued)
await interaction.followup.send(f"{user_nick}, you have been unregistered!")
delete_invalidate()
@client.tree.command()
async def invalidate_unregister(interaction: discord.Interaction) -> None:
cleanup_invalidate()
delete_invalidate()
print("User deletion has been invalidated! Aborting process!")
await interaction.response.send_message("Unregister has been invalidated!")
return
@client.tree.command()
async def ping(interaction: discord.Interaction) -> None:
delete_invalidate()
cleanup_invalidate()
user_id = interaction.user.id
await interaction.response.send_message(f'Pong! {user_id} -- the bot is active, please message contrastellar with issues!')
return
@client.tree.command()
async def cleanup(interaction: discord.Interaction) -> None:
delete_invalidate()
cleanup_invalidate()
number_to_be_affected: int = DATABASE_CONN.number_affected_in_cleanup()
await interaction.response.send_message(f"Is the bot being weird or slow? You can try the `/validate_cleanup` command to clear out old database entries!\nBe warned that this is an admin-level command, and may have unintended side effects!\n{number_to_be_affected} rows will be affected by the `/validate_cleanup` command!\nThese entries are all in the past.")
DATABASE_CONN.is_procedure_queued = True
print("Bot has been primed for cleanup!")
return
@client.tree.command()
async def validate_cleanup(interaction: discord.Interaction) -> None:
delete_invalidate()
user_nickname = interaction.user.nick
await interaction.response.defer(thinking=True)
print(f"{user_nickname} has called validate_cleanup!\n\nCalling now.")
number_rows_affected: int
try:
number_rows_affected = DATABASE_CONN.call_cleanup(DATABASE_CONN.is_procedure_queued)
except psycopg2.Error as e:
print(e)
await interaction.followup.send(f"Something happened! This message is to inform <@{CONTRASTELLAR}> of this error!\n`{e}`")
return
print("cleanup should be complete. Setting queue variable to False")
DATABASE_CONN.is_procedure_queued = False
await interaction.followup.send(f"Database has been cleaned!\n\n{number_rows_affected} rows have been purged!")
return
@client.tree.command()
async def invalidate_cleanup(interaction: discord.Interaction) -> None:
delete_invalidate()
cleanup_invalidate()
await interaction.response.defer(thinking=True)
print(f"{interaction.user.id} has called the invalidate command!")
print("Cleanup has been invalidated!")
await interaction.followup.send("The queued action has been cancelled!")
return
@client.tree.command()
async def callout(interaction: discord.Interaction, day: int, month: int, year: int, reason: str = '', fill: str = '') -> None:
delete_invalidate()
cleanup_invalidate()
user_id = interaction.user.id
user_nick = interaction.user.display_name
user_char_name = DATABASE_CONN.return_char_name(user_id)
today: datetime.date = datetime.date.today()
callout_date: datetime.date = datetime.date(year=year, month=month, day=day)
if today > callout_date:
await interaction.response.send_message(f'{user_char_name}, date in the past given. Please give a date for today or in the future!')
return
if len(reason) > 512:
await interaction.response.send_message(f'{user_char_name}, your reason was too long. Keep it to 512 characters or less.')
return
try:
DATABASE_CONN.add_callout(user_id=user_id, callout=callout_date, reason=reason, nickname=user_nick, char_name=user_char_name, potential_fill=fill)
except UNIQUEVIOLATION:
await interaction.response.send_message(f'{user_char_name} -- you have already added a callout for {callout_date} with reason: {reason}')
except INVALIDDATETIMEFORMAT:
await interaction.response.send_message(f'{user_char_name} -- please format the date as the following format: MM/DD/YYYY')
except FOREIGNKEYVIOLATION:
await interaction.response.send_message(f'{user_nick} -- please register with the bot using the following command!\n`/registercharacter`\n Please use your in-game name!')
except helper.db_helper.DateTimeError:
await interaction.response.send_message(f'{user_nick}, you\'re trying to submit a callout for a time in the past! Please verify that this is what you want to do!')
except psycopg2.Error as e:
await interaction.response.send_message(f'{user_nick} -- an error has occured!\nNotifying <@{CONTRASTELLAR}> of this error. Error is as follows --\n{e}')
else:
await interaction.response.send_message(f'{user_char_name} -- you added a callout for {callout_date} with reason: {reason}')
await interaction.followup.send(f'{DATABASE_CONN.format_list_of_callouts(DATABASE_CONN.query_callouts(7))}')
@client.tree.command()
async def remove_callout(interaction: discord.Interaction, day: int, month: int, year: int) -> None:
delete_invalidate()
cleanup_invalidate()
user_id = interaction.user.id
user_char_name = DATABASE_CONN.return_char_name(user_id)
callout_date: datetime.date = datetime.date(year=year, month=month, day=day)
try:
DATABASE_CONN.remove_callout(user_id=user_id, callout=callout_date)
except psycopg2.Error:
await interaction.response.send_message(f'{user_char_name} -- you have not added a callout for {callout_date}')
else:
await interaction.response.send_message(f'{user_char_name} removed a callout for {callout_date}')
@client.tree.command()
async def schedule(interaction: discord.Interaction, days: int = DAYS_FOR_CALLOUTS) -> None:
delete_invalidate()
cleanup_invalidate()
await interaction.response.defer(thinking=True)
callouts: list = DATABASE_CONN.query_callouts(days=days)
callouts: str = DATABASE_CONN.formatted_list_of_callouts(callouts)
await interaction.followup.send(f'Callouts for the next {days} days:\n{callouts}')
return
@client.tree.command()
async def self_callouts(interaction: discord.Interaction, days: int = 365) -> None:
delete_invalidate()
cleanup_invalidate()
uid = interaction.user.id
await interaction.response.defer(thinking=True)
callouts: list = DATABASE_CONN.query_self_callouts(user_id=uid, days=days)
callouts: str = DATABASE_CONN.formatted_list_of_callouts(callouts)
await interaction.followup.send(f'Callouts for the next **{days}** for user **{DATABASE_CONN.return_char_name(uid)}**:\n{callouts}')
args: argparse.Namespace = parser.parse_args()
# To be used for reading/writing to the database
# #will not handle the parsing of the returns from the db
DATABASE_CONN = helper.db_helper.DBHelper(args.database)
TOKEN: str = open(args.token, encoding='utf-8').read()
client.run(TOKEN)

59
src/py/db_helper_tests.py Normal file
View File

@@ -0,0 +1,59 @@
"""
Database functionality tests.
@author: Gabriella "Contrastellar" Agathon
"""
import datetime
import helper.db_helper
class TestClass():
"""
Test class for the database functionality.
These tests should only be assertions of type, not of return values.
"""
DATABASE_CONN = helper.db_helper.DBHelper("xiv-database.ini")
def test_add_registration(self) -> None:
registration = self.DATABASE_CONN.register_char_name(uid=1, char_name="test")
assert registration is None
def test_query_callouts(self) -> None:
callouts = self.DATABASE_CONN.query_callouts(7)
assert type(callouts) is list
def test_add_callout(self) -> None:
callout = self.DATABASE_CONN.add_callout(user_id=1, callout=datetime.date.today(), reason='test', nickname='test', char_name='test', potential_fill='test')
assert callout is None
def test_self_query_callouts(self) -> None:
self_callouts = self.DATABASE_CONN.query_self_callouts(user_id=1, days=365)
assert self_callouts is not None
def test_callouts(self) -> None:
callout = self.DATABASE_CONN.query_callouts(days=7)
assert callout is not None
def test_remove_callout(self) -> None:
remove_callout = self.DATABASE_CONN.remove_callout(user_id=1, callout=datetime.date.today())
assert remove_callout is None
def test_char_name(self) -> None:
char_name = self.DATABASE_CONN.return_char_name(uid=1)
assert type(char_name) is str
assert char_name == "test"
def test_remove_registration(self) -> None:
registration = self.DATABASE_CONN.remove_registration(uid=1, isOkay=True)
assert registration is None
def test_format_list_of_callouts(self) -> None:
callouts = self.DATABASE_CONN.query_callouts(days=7)
formatted_callouts = self.DATABASE_CONN.format_list_of_callouts(callouts=callouts)
assert formatted_callouts.__class__ is str

295
src/py/helper/db_helper.py Normal file
View File

@@ -0,0 +1,295 @@
"""
The helper core of the raid-callouts bot.
This module(s) will contain all of the helper functions for the bot
@author: Gabriella 'contrastellar' Agathon
"""
import psycopg2
import psycopg2.extensions
from configparser import ConfigParser
import datetime
def load_config(filename='database.ini', section='postgresql'):
"""
Args:
filename (str, optional): filename for the ini file. Defaults to 'database.ini'.
section (str, optional): defines the section for the ini file to read from. Defaults to 'postgresql'.
Raises:
Exception: Will raise an exception if the postgresql section is not found in the ini file.
Returns:
dict: A dictionary containing the parsed values from the ini file, to be used for the database connection.
"""
parser = ConfigParser()
parser.read(filename)
# get section, default is postgresql
config = {}
if parser.has_section(section):
params = parser.items(section)
for param in params:
config[param[0]] = param[1]
else:
raise Exception('Section {0} not found in the {1} file'.format(section, filename))
return config
def connect_config(config) -> psycopg2.extensions.connection:
""" Connect to the PostgreSQL database server """
try:
# connecting to the PostgreSQL server
with psycopg2.connect(**config) as conn:
print('Connected to the PostgreSQL server.')
return conn
except (psycopg2.DatabaseError, Exception) as error:
print(error)
finally:
if conn is None:
raise psycopg2.DatabaseError('Failed to connect to the PostgreSQL database')
class DateTimeError(Exception):
def __init__(self, *args):
super().__init__(*args)
class DBHelper():
"""
The helper class for the raid-callouts bot.
This class will contain all of the helper functions for the bot
"""
_config: dict
__CONN: psycopg2.extensions.connection = None
is_procedure_queued: bool = False
is_unregister_queued: bool = False
def __init__(self, filename = 'database.ini', section = 'postgresql') -> None:
self._config = load_config(filename=filename, section=section)
def __del__(self):
"""
Destructor for the DBHelper class
No need to do anything here
"""
# self.__CONN.close()
pass
def query_callouts(self, days: int) -> list:
"""This function will query the database for the callouts for the next X days, where X is defined by the days parameter.
Args:
days int: number of days in the future to query for callouts
Returns:
list: list of users + their callouts for the next X days
"""
self.__CONN = connect_config(self._config)
self.__CONN.autocommit = True
cursor = self.__CONN.cursor()
# Weird query, but it grabs the callouts from the last day to the next X days.
cursor.execute(f"SELECT * FROM newcallouts WHERE date >= NOW() - INTERVAL '1 day' AND date <= NOW() + INTERVAL '{days} days' ORDER BY date ASC;")
self.__CONN.commit()
return cursor.fetchall()
def query_self_callouts(self, user_id: int, days: int = 365):
self.__CONN = connect_config(self._config)
self.__CONN.autocommit = True
cursor = self.__CONN.cursor()
cursor.execute(f"SELECT * FROM newcallouts WHERE date >= NOW() - INTERVAL '1 day' AND date <= NOW() + INTERVAL '{days} days' AND user_id = {user_id} ORDER BY date ASC;")
self.__CONN.commit()
return cursor.fetchall()
def add_callout(self, user_id: int, callout: datetime.date, reason: str, nickname: str, char_name: str, potential_fill: str) -> None:
"""Add a callout to the database
Args:
user_id (int): the Discord UUID of the user adding things to the db
callout (datetime.date): The day of the callout
reason (str): The reason of the callout
nickname (str): The server(guild) nickname of the user who is making the callout
char_name (str): The character name (as supplied from registration) of the user inserting a callout
"""
self.__CONN = connect_config(self._config)
self.__CONN.autocommit = True
cursor = self.__CONN.cursor()
cursor.execute("INSERT INTO newcallouts (user_id, date, reason, nickname, charname, fill) VALUES (%s, %s, %s, %s, %s, %s)", (user_id, callout, reason, nickname, char_name, potential_fill))
self.__CONN.commit()
return
def remove_callout(self, user_id: int, callout: datetime.date) -> None:
"""Remove a callout based on user + date, which form the primary key in the db
Args:
user_id (int): The Discord UUID of the user removing something from the db
callout (datetime.datetime): The date of the callout
"""
self.__CONN = connect_config(self._config)
self.__CONN.autocommit = True
cursor = self.__CONN.cursor()
cursor.execute("DELETE FROM newcallouts WHERE user_id = %s AND date = %s", (user_id, callout))
self.__CONN.commit()
return
def formatted_list_of_callouts(self, callouts: list) -> str:
"""Format the python list of callouts.
Args:
callouts (list): The list that needs to be formatted
Returns:
str: The formatted list
"""
length = len(callouts)
output: str = ''
# Quick and dirty way to say that there were no callouts found during the query
if length == 0:
return 'No callouts found for the requested timeframe'
for entry in callouts:
# this is a bit wonky, but we take the known constant width of each entry (4 columns)
# then we use python's range function to turn "item" into an interator
# Then we do some funky logic on the entry that we're iterating over
# in order to get the proper formatting
for item in range(5):
if item == 0:
# skip discord user ID always
continue
elif item == 1:
# handles the date displaying logic
if datetime.date.today() == entry[1]:
output += '**TODAY** • '
else:
output += f'**{entry[1]}** • '
elif item == 2:
# in the database, this is actually the "reason" place
# instead of doing that, we call the last column's value
# which is the char name
# this was requested by Yasu
output += "**" + entry[4] + '** • '
elif item == 3:
# Finally add the reason for the user's callout
# two line breaks as Yasu requested
output += entry[2] + ' '
elif item == 4:
if entry[5] is not None:
output += f'• potential fill -- {entry[5]}\n--\n'
else:
output += '\n--\n'
output += "END OF MESSAGE"
return output
def format_list_of_callouts(self, callouts: list) -> str:
"""Format the python list of callouts.
Args:
callouts (list): The list that needs to be formatted
Returns:
str: The formatted list
"""
return self.formatted_list_of_callouts(callouts=callouts)
def register_char_name(self, uid: int, char_name: str) -> None:
""" allows users to register their character name with the bot, allowing silly nicknames to be used independent of their
character's name
Arguments:
uid -- Discord User ID of the user to be registered
char_name -- User-supplied character name, to be inserted into the table
"""
self.__CONN = connect_config(self._config)
self.__CONN.autocommit = True
cursor = self.__CONN.cursor()
cursor.execute("INSERT INTO charnames (uid, charname) VALUES (%s, %s)", (uid, char_name))
self.__CONN.commit()
return
def return_char_name(self, uid: int) -> str:
"""Utility method to return the character name based on a specific discord ID
Arguments:
uid -- Discord User ID of the user to be queried
Returns:
String; either character name or empty.
"""
self.__CONN = connect_config(self._config)
self.__CONN.autocommit = True
cursor = self.__CONN.cursor()
# was getting weird index error on this line due to tuples, so we're using an f-string
cursor.execute(f"SELECT charname FROM charnames WHERE uid = {uid}")
output: str = ""
try:
output = cursor.fetchone()[0]
except TypeError:
return ""
else:
return output
def remove_registration(self, uid: int, isOkay: bool) -> None:
self.__CONN = connect_config(self._config)
self.__CONN.autocommit = True
cursor = self.__CONN.cursor()
# need to remove all callouts!
cursor.execute(f"DELETE FROM newcallouts WHERE user_id = {uid}")
cursor.execute(f"DELETE FROM charnames WHERE uid = {uid}")
return
def number_affected_in_cleanup(self) -> int:
self.__CONN = connect_config(self._config)
self.__CONN.autocommit = True
cursor = self.__CONN.cursor()
cursor.execute(f"SELECT count(*) FROM newcallouts WHERE date < NOW();")
return cursor.fetchone()[0]
def call_cleanup(self, is_okay: bool) -> int:
number_to_be_affected = self.number_affected_in_cleanup()
if not is_okay:
raise Exception("Not queued properly!")
self.__CONN = connect_config(self._config)
self.__CONN.autocommit = True
cursor = self.__CONN.cursor()
cursor.execute(f"CALL cleanup();")
print("Cleanup was called!")
return number_to_be_affected

17
src/sql/current-table.sql Normal file
View File

@@ -0,0 +1,17 @@
-- Table: public.callouts
-- DROP TABLE IF EXISTS public.callouts;
CREATE TABLE IF NOT EXISTS public.callouts
(
user_id bigint NOT NULL,
date date NOT NULL,
reason text COLLATE pg_catalog."default",
nickname text COLLATE pg_catalog."default",
CONSTRAINT callouts_pkey PRIMARY KEY (user_id, date)
)
TABLESPACE pg_default;
ALTER TABLE IF EXISTS public.callouts
OWNER to opossumbot;

View File

@@ -0,0 +1,7 @@
create table absent_players (
user_id int NOT NULL,
user_name varchar(20) NOT NULL,
date_ab date NOT NULL,
reason_ab varchar(200),
has_passed int DEFAULT 0);
/

13
src/sql/sqlscripts.sql Normal file
View File

@@ -0,0 +1,13 @@
-- These are scripts that are formatted for accesing the table, more comments can be provided as needed
select count(user_id) from absent_players where user_id = 000000 and has_passed = 1;
-- Select the number of times a user has been absent so far(replace user_ID)
select user_name, reason_ab from absent_players where date_ab = SYSDATE;
-- select users name, reason they're absent today (reoplace date with today's date)
select user_name, date_ab, reason_ab from absent_players where has_passed = 0;
-- selects all future players who are absent, the reason they are, and when they are.
delete from absent_players where user_ID = 00000 and date_ab = date and has_passed = 0;
-- deletes a future absence from the table, cannot remove older absences.