Changing the project to being called Nessa now

This commit is contained in:
Dan
2024-05-05 16:38:32 -04:00
parent e5a189d9cb
commit ce1d4626a8
12 changed files with 105 additions and 51 deletions

144
nessa/commands.py Normal file
View File

@ -0,0 +1,144 @@
import discord
from discord import app_commands
from .database import add_project, get_project_id, get_project_name, add_task_to_project, update_task, list_projects, list_tasks_for_project, remove_task, remove_project
from .consent import check_user_consent, store_user_consent, revoke_user_consent
from .confirm import ConfirmView, ConfirmTaskDeletionView
from datetime import datetime
import logging
from dotenv import load_dotenv
import os
load_dotenv()
contact_user_id = int(os.getenv("AUTHORIZED_USER_ID"))
logger = logging.getLogger('Nessa')
class ProjectCommands(app_commands.Group):
def __init__(self):
super().__init__(name="project", description="Manage projects.")
@app_commands.command(name="create", description="Create a new project.")
async def create_project(self, interaction: discord.Interaction, name: str, description: str):
try:
project_id = await add_project(name, description)
await interaction.response.send_message(f"Noted. I've created a new project called '{name}'. You can look it up using the `/project list` command. Your project ID is {project_id}.")
except Exception as e:
await interaction.response.send_message("Whoops! Looks like we hit a snag! Try creating the project again! :)", ephemeral=True)
logger.error(f"Error in create_project: {e}")
@app_commands.command(name="list", description="List all projects.")
async def list_projects(self, interaction: discord.Interaction):
try:
projects = await list_projects()
if projects:
message = "\n".join([f"ID: {id}, Name: {name}, Description: {description}" for id, name, description in projects])
await interaction.response.send_message(f"Here's the list of Projects:\n{message} \nIf you'd like to add a new project, use the `/project create` command.")
else:
await interaction.response.send_message("Mmmm, I couldn't find any projects. If you'd like to add one, use the `/project create` command.")
except Exception as e:
await interaction.response.send_message("Whoops! I failed to retrieve the list of projects. Did I misplace that database somewhere? Try again later!.", ephemeral=True)
logger.error(f"Error in list_projects: {e}")
@app_commands.command(name="remove", description="Remove a specific project and all its tasks.")
async def remove_project_command(self, interaction: discord.Interaction, project_id: int):
# Send a message with the confirmation view
try:
view = ConfirmView(project_id)
await interaction.response.send_message(f"Hey there! Are you sure you wanna me to forgot the project with ID {project_id}? All associated tasks will be removed as well. Once I remove the project, I won't be able to retrieve it again. You have 180 seconds to confirm.", view=view, ephemeral=True)
except Exception as e:
await interaction.response.send_message(f"Whoops! I failed to remove the project. Error: {e}", ephemeral=True)
logger.error(f"Preparation error in remove_project_command for Project ID {project_id}: {e}")
class TaskCommands(app_commands.Group):
def __init__(self):
super().__init__(name="task", description="Manage tasks.")
@app_commands.command(name="add", description="Add a new task to a project.")
async def add_task(self, interaction: discord.Interaction, project_name: str, description: str, assignee: str, deadline: str, status: str, priority: str):
try:
datetime.strptime(deadline, "%m/%d/%Y") # Validate date format
project_id = await get_project_id(project_name)
if project_id:
await add_task_to_project(project_id, description, assignee, deadline, status, priority)
await interaction.response.send_message(f"Okay so we are adding the task called '{description}' to project '{project_name}'. got it!. Make sure you filled out your project and task details correctly. \nIf you need to update the task, use the `/task update` command.")
logger.info(f"Task added to project {project_name}: {description}")
else:
await interaction.response.send_message(f"Whoops! I don't have that project named '{project_name}'. Did you mean to use the `/project create` command? or did you enter the name wrong?", ephemeral=True)
logger.warning(f"Attempted to add task to non-existent project: {project_name}")
except ValueError:
await interaction.response.send_message("Silly! I know that's not a date! Please use the right format, i.e. MM/DD/YYYY format!", ephemeral=True)
logger.error(f"Invalid date format provided by user: {deadline}")
except Exception as e:
await interaction.response.send_message("Huh, that's never happened before... Try again!", ephemeral=True)
logger.error(f"Unexpected error in add_task: {e}")
@app_commands.command(name="update", description="Update an existing task.")
async def update_task_command(self, interaction: discord.Interaction, task_id: int, description: str, assignee: str, deadline: str, status: str, priority: str):
try:
datetime.strptime(deadline, "%m/%d/%Y") # Validate date format
await update_task(task_id, description, assignee, deadline, status, priority)
await interaction.response.send_message(f"Okay hun, so I updated the task with ID {task_id} with the changes you wanted made!")
logger.info(f"Task ID {task_id} updated: {description}")
except ValueError:
await interaction.response.send_message("Silly! I know that's not a date! Please use the right format, i.e. MM/DD/YYYY format!", ephemeral=True)
logger.error(f"Invalid date format provided by user for task update: {deadline}")
except Exception as e:
await interaction.response.send_message("Huh, that's never happened before... Try again!", ephemeral=True)
logger.error(f"Unexpected error in update_task_command: {e}")
@app_commands.command(name="list", description="List tasks for a project.")
async def list_tasks(self, interaction: discord.Interaction, project_id: int):
try:
project_name = await get_project_name(project_id)
if not project_name:
await interaction.response.send_message(f"I may be the godesss of memory, but I don't have a project with that ID {project_id} in my memory. are you sure you entered the right ID?", ephemeral=True)
return
tasks = await list_tasks_for_project(project_id)
if tasks:
message = "\n".join([f"ID: {id}, Description: {description}, Assignee: {assignee}, Deadline: {deadline}, Status: {status}, Priority: {priority}"
for id, description, assignee, deadline, status, priority in tasks])
await interaction.response.send_message(f"So, Here are all the tasks I found for that project '{project_name}':\n{message}")
else:
await interaction.response.send_message(f"Whoops! I couldn't find any tasks for that project '{project_name}' Is it brand new?.")
except Exception as e:
await interaction.response.send_message(f"My memory must be slipping, I think I couldn't find any tasks for that project {project_id}.", ephemeral=True)
logger.error(f"Error in list_tasks: {e}")
@app_commands.command(name="remove", description="Remove a specific task.")
async def remove_task_command(self, interaction: discord.Interaction, task_id: int):
# Send a message with the confirmation view
view = ConfirmTaskDeletionView(task_id)
await interaction.response.send_message(f"So, are you sure you want to remove Task ID {task_id}? Once I forget, that's it! You'll have 180 seconds to confirm.", view=view, ephemeral=True)
class ConsentCommands(app_commands.Group):
def __init__(self):
super().__init__(name="consent", description="Manage data consent settings.")
@app_commands.command(name="opt-in", description="Opt-in to data storage and use Nessa's Services.")
async def opt_in(self, interaction: discord.Interaction):
await store_user_consent(interaction.user.id)
await interaction.response.send_message("Hey there! Thanks for opt-ing in to allowing me to store the data for your projects.", ephemeral=True)
@app_commands.command(name="opt-out", description="Opt-out of data storage and stop using Nessa's Services.")
async def opt_out(self, interaction: discord.Interaction):
await revoke_user_consent(interaction.user.id)
await interaction.response.send_message("Awe. It's ok. I won't store your data but this also means you won't be able to use my services. You can always opt-in again. Bye!", ephemeral=True)
@app_commands.command(name="privacy-policy", description="View Nessa's data handling policy.")
async def privacy_policy(self, interaction: discord.Interaction):
# Replace 'YOUR_USER_ID' with the actual numeric ID of the user 'advtech'
policy_details = f"""Hi there! So you're looking to hire Nessa, the godess of memory! Let me tell you what you need to know. You need to consent to me storing what you send me in my 'memory' (better known as a database). What I am storing is: Your Disocrd ID, Projects, Tasks, and other data related to your projects. Why I need it? Well, I need your ID so as track your consent to store your data. If I don't have your consent, You can't use my services and your data isn't stored. As far as project and task management is concerned, I store just what you send me. I do not share it with any third-parties. I do not use any external services. I store it in my local 'memory'. Your data is not used for anything else, including but not limited to machine learning. Your data can be removed at any time using the commands. If you wish to opt-out, please use the `/consent opt-out` command. You need anything else, talk to my boss: <@{contact_user_id}> on Discord."""
await interaction.response.send_message(policy_details, ephemeral=True)
class NessaTracker(app_commands.Group, name="nessa", description="Nessa the Project Tracker."):
def __init__(self):
super().__init__()
self.add_command(ProjectCommands())
self.add_command(TaskCommands())
self.add_command(ConsentCommands())

48
nessa/confirm.py Normal file
View File

@ -0,0 +1,48 @@
from discord.ui import View, Button
class ConfirmView(View):
def __init__(self, project_id, timeout=180): # 180 seconds until the view expires
super().__init__(timeout=timeout)
self.project_id = project_id
self.value = None
@discord.ui.button(label="Confirm", style=discord.ButtonStyle.danger)
async def confirm(self, interaction: discord.Interaction, button: Button):
# Attempt to remove the project
try:
await remove_project(self.project_id)
await interaction.response.edit_message(content=f"Project ID {self.project_id} and all associated tasks have been successfully removed.", view=None)
logger.info(f"Project ID {self.project_id} and all associated tasks removed successfully.")
except Exception as e:
error_message = f"Failed to remove the project. Error: {e}"
await interaction.response.edit_message(content=error_message, view=None)
logger.error(f"Error in remove_project_command: {e}")
self.stop() # Stopping the view after an error is usually a good practice to clean up the UI.
@discord.ui.button(label="Cancel", style=discord.ButtonStyle.secondary)
async def cancel(self, interaction: discord.Interaction, button: Button):
await interaction.response.edit_message(content="Project removal cancelled.", view=None)
self.stop()
class ConfirmTaskDeletionView(View):
def __init__(self, task_id, timeout=180): # 180 seconds until the view expires
super().__init__(timeout=timeout)
self.task_id = task_id
self.value = None
@discord.ui.button(label="Confirm", style=discord.ButtonStyle.danger)
async def confirm(self, interaction: discord.Interaction, button: Button):
# Attempt to remove the task
try:
await remove_task(self.task_id)
await interaction.response.edit_message(content=f"Task ID {self.task_id} has been successfully removed.", view=None)
logger.info(f"Task ID {self.task_id} removed successfully.")
except Exception as e:
error_message = f"Failed to remove the task. Error: {e}"
await interaction.response.edit_message(content=error_message, view=None)
logger.error(f"Error in remove_task_command: {e}")
@discord.ui.button(label="Cancel", style=discord.ButtonStyle.secondary)
async def cancel(self, interaction: discord.Interaction, button: Button):
await interaction.response.edit_message(content="Task removal cancelled.", view=None)
self.stop()

42
nessa/consent.py Normal file
View File

@ -0,0 +1,42 @@
import discord
from discord.ui import View, Button
import aiosqlite
DATABASE = "dolly.db"
class ConsentView(View):
def __init__(self):
super().__init__()
self.value = None
@discord.ui.button(label="Consent to Data Storage", style=discord.ButtonStyle.green)
async def confirm(self, interaction: discord.Interaction, button: Button):
self.value = True
await store_user_consent(interaction.user.id)
await interaction.response.edit_message(content="Thank you for consenting to data storage!", view=None)
self.stop()
@discord.ui.button(label="Decline Data Storage", style=discord.ButtonStyle.grey)
async def cancel(self, interaction: discord.Interaction, button: Button):
self.value = False
await interaction.response.edit_message(content="You have declined data storage. Since you have, You can't use my services.", view=None)
self.stop()
async def check_user_consent(user_id):
"""Check if the user has given consent to data storage. Assume no consent if no record exists."""
async with aiosqlite.connect(DATABASE) as db:
cursor = await db.execute("SELECT consent_given FROM user_consents WHERE user_id = ?", (user_id,))
result = await cursor.fetchone()
return result[0] if result else False # Return False if no record exists, prompting consent dialogue
async def store_user_consent(user_id):
"""Store the user's consent to data storage."""
async with aiosqlite.connect(DATABASE) as db:
await db.execute("INSERT OR REPLACE INTO user_consents (user_id, consent_given) VALUES (?, TRUE)", (user_id,))
await db.commit()
async def revoke_user_consent(user_id):
"""Revoke the user's consent to data storage."""
async with aiosqlite.connect(DATABASE) as db:
await db.execute("UPDATE user_consents SET consent_given = FALSE WHERE user_id = ?", (user_id,))
await db.commit()

89
nessa/database.py Normal file
View File

@ -0,0 +1,89 @@
import aiosqlite
from datetime import datetime
DATABASE = "nessa.db"
async def init_db():
async with aiosqlite.connect(DATABASE) as db:
await db.execute(
'''CREATE TABLE IF NOT EXISTS projects(
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE,
description TEXT)'''
)
await db.execute(
'''CREATE TABLE IF NOT EXISTS tasks(
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER,
description TEXT,
assignee TEXT,
deadline TEXT,
status TEXT,
priority TEXT,
FOREIGN KEY(project_id) REFERENCES projects(id))
'''
)
await db.execute(
'''CREATE TABLE IF NOT EXISTS user_consents(
user_id INTEGER PRIMARY KEY,
consent_given BOOLEAN NOT NULL
)'''
)
await db.commit()
async def add_project(name, description):
async with aiosqlite.connect(DATABASE) as db:
cursor = await db.execute("INSERT INTO projects(name, description) VALUES(?, ?)", (name, description))
await db.commit()
return cursor.lastrowid # Returns the ID of the newly created project
async def get_project_id(name):
async with aiosqlite.connect(DATABASE) as db:
cursor = await db.execute("SELECT id FROM projects WHERE name = ?", (name,))
result = await cursor.fetchone()
return result[0] if result else None
async def get_project_name(project_id):
async with aiosqlite.connect(DATABASE) as db:
cursor = await db.execute("SELECT name FROM projects WHERE id = ?", (project_id,))
result = await cursor.fetchone()
return result[0] if result else None
async def add_task_to_project(project_id, description, assignee, deadline, status, priority, reminder_time=None):
async with aiosqlite.connect(DATABASE) as db:
await db.execute(
"INSERT INTO tasks (project_id, description, assignee, deadline, status, priority, reminder_time) VALUES (?, ?, ?, ?, ?, ?, ?)",
(project_id, description, assignee, deadline, status, priority, reminder_time)
)
await db.commit()
async def update_task(task_id, description, assignee, deadline, status, priority, reminder_time=None):
async with aiosqlite.connect(DATABASE) as db:
await db.execute(
"UPDATE tasks SET description=?, assignee=?, deadline=?, status=?, priority=?, reminder_time=? WHERE id=?",
(description, assignee, deadline, status, priority, reminder_time, task_id)
)
await db.commit()
async def list_projects():
async with aiosqlite.connect(DATABASE) as db:
cursor = await db.execute("SELECT id, name, description FROM projects")
projects = await cursor.fetchall()
return projects
async def list_tasks_for_project(project_id):
async with aiosqlite.connect(DATABASE) as db:
cursor = await db.execute("SELECT id, description, assignee, deadline, status, priority FROM tasks WHERE project_id = ?", (project_id,))
tasks = await cursor.fetchall()
return tasks
async def remove_task(task_id):
async with aiosqlite.connect(DATABASE) as db:
await db.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
await db.commit()
async def remove_project(project_id):
async with aiosqlite.connect(DATABASE) as db:
await db.execute("DELETE FROM tasks WHERE project_id = ?", (project_id,)) # Remove all tasks under the project
await db.execute("DELETE FROM projects WHERE id = ?", (project_id,))
await db.commit()

17
nessa/logger.py Normal file
View File

@ -0,0 +1,17 @@
import logging
from logging.handlers import RotatingFileHandler
# Configure basic logger
logging.basicConfig(level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S')
# Create a rotating file handler which logs even debug messages
log_file = 'dolly.log'
handler = RotatingFileHandler(log_file, maxBytes=100000, backupCount=3)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
handler.setFormatter(formatter)
logger = logging.getLogger('Dolly')
logger.addHandler(handler)
logger.setLevel(logging.INFO)

63
nessa/nessa.py Normal file
View File

@ -0,0 +1,63 @@
import discord
from discord import app_commands
from .commands import NessaTracker
from .consent import ConsentView, check_user_consent, store_user_consent
from dotenv import load_dotenv
import os
load_dotenv()
GUILD_ID = int(os.getenv("DISCORD_GUILD_ID"))
OWNER_ID = int(os.getenv("AUTHORIZED_USER_ID"))
class Nessa(discord.Client):
def __init__(self):
super().__init__(intents=discord.Intents.default())
self.tree = app_commands.CommandTree(self)
async def setup_hook(self):
self.tree.add_command(NessaTracker())
self.tree.copy_global_to(guild=discord.Object(id=GUILD_ID))
await self.tree.sync(guild=discord.Object(id=GUILD_ID))
@self.tree.command(name="shutdown", description="Shut down Nessa", guild=discord.Object(id=GUILD_ID))
async def shutdown(interaction: discord.Interaction):
if interaction.user.id == OWNER_ID:
await interaction.response.send_message("Night, Night. Shutting down...")
await self.close()
else:
await interaction.response.send_message("Hey! I don't know you! You don't have permission to use this command. Only the owner can do that.", ephemeral=True)
async def on_ready(self):
print(f"Logged on as {self.user}!")
self.tree.copy_global_to(guild=discord.Object(id=GUILD_ID))
await self.tree.sync(guild=discord.Object(id=GUILD_ID))
async def on_interaction(self, interaction: discord.Interaction):
if interaction.type == discord.InteractionType.application_command:
# First, check if the user has consented to data storage.
consented = await check_user_consent(interaction.user.id)
if not consented:
# If there is no consent, show the consent dialog,
# unless the command is to opt-out, which should be accessible without prior consent.
if interaction.command.name == 'opt-out':
# Allow users to opt-out directly if they mistakenly initiated any command.
return
view = ConsentView()
await interaction.response.send_message(
"By using the services I, Nessa, provide, you consent to the storage of your data necessary for functionality. Please confirm your consent. See /nessa consent privacy-policy for more details.",
view=view,
ephemeral=True
)
await view.wait()
if view.value:
await store_user_consent(interaction.user.id)
else:
await interaction.followup.send("Whoops! You have to give me your okay to do store your data before you can use my services!", ephemeral=True)
return # Stop processing if they do not consent
# For opt-in command, check if they're trying to opt-in after opting out.
if interaction.command.name == 'opt-in':
if consented:
await interaction.response.send_message("Hey! Thanks, but you've already opted in.", ephemeral=True)
return