From 23a01ac8662e6151bac9d2d76f80f405089f8e4e Mon Sep 17 00:00:00 2001 From: Anthony <59890724+narlock@users.noreply.github.com> Date: Thu, 26 Sep 2024 07:16:47 -0500 Subject: [PATCH 1/4] Begin reminders implementation --- api/app/controller/user_controller.py | 21 +++++++++++++ api/app/model/user_model.py | 1 + api/app/schema/user_schema.py | 1 + bot/apps/info/timezone.py | 38 +++++++++++++++++++++++ bot/apps/productivity/reminder.py | 7 +++++ bot/client/alder/interface/user_client.py | 16 ++++++++++ bot/main.py | 21 +++++++++++++ setupdb.sql | 16 ++++++++++ 8 files changed, 121 insertions(+) create mode 100644 bot/apps/info/timezone.py create mode 100644 bot/apps/productivity/reminder.py diff --git a/api/app/controller/user_controller.py b/api/app/controller/user_controller.py index 9a65389..6a89249 100644 --- a/api/app/controller/user_controller.py +++ b/api/app/controller/user_controller.py @@ -110,6 +110,27 @@ def delete_user(id): # Return 204 No Content return '', 204 +# Set Timezone endpoint +@user_bp.route('/user//timezone', methods=['PUT']) +def set_user_timezone(id): + user = User.query.get(id) + if user is None: + return jsonify({'message': 'User not found'}), 404 + + data = request.get_json() + + # Check if timezone is provided in the request body + if 'timezone' not in data: + return jsonify({'message': 'Timezone is required'}), 400 + + timezone = data['timezone'] + + # Update user's timezone if valid + user.timezone = timezone + db.session.commit() + + return jsonify(user_schema.dump(user)) + # Search endpoint @user_bp.route('/user/search', methods=['POST']) def search_users(): diff --git a/api/app/model/user_model.py b/api/app/model/user_model.py index 93c6c1a..e834bdd 100644 --- a/api/app/model/user_model.py +++ b/api/app/model/user_model.py @@ -6,5 +6,6 @@ class User(db.Model): id = db.Column(db.BigInteger, primary_key=True, autoincrement=False) tokens = db.Column(db.Integer, nullable=False) stime = db.Column(db.BigInteger, nullable=False) + timezone = db.Column(db.String(100), nullable=False, default='UTC') hex = db.Column(db.String(7), nullable=True) trivia = db.Column(db.Integer, nullable=True) diff --git a/api/app/schema/user_schema.py b/api/app/schema/user_schema.py index c6633e3..e792d40 100644 --- a/api/app/schema/user_schema.py +++ b/api/app/schema/user_schema.py @@ -4,5 +4,6 @@ class UserSchema(Schema): id = fields.Int(required=True) tokens = fields.Int(required=True) stime = fields.Int(required=True) + timezone = fields.Str(required=False) hex = fields.Str(required=False) trivia = fields.Int(required=False) diff --git a/bot/apps/info/timezone.py b/bot/apps/info/timezone.py new file mode 100644 index 0000000..fbb36ff --- /dev/null +++ b/bot/apps/info/timezone.py @@ -0,0 +1,38 @@ +""" +timezone.py +author: narlock + +Interface to change timezone information for a user. +""" + +import cfg +import discord +import pytz + +from datetime import datetime +from client.alder.interface.user_client import UserClient + +class TimeZoneApp(): + def set_timezone(self, interaction: discord.Interaction, timezone: str): + """ + Sets the user's timezone. + """ + + # Validate the timezone string + try: + pytz.timezone(timezone) + except: + message = f'The timezone {timezone} is not a valid timezone.\nPlease try again.\n\nExample: America/New_York' + return cfg.ErrorEmbed.message(message) + + # The timezone string is valid. Set it + try: + UserClient.set_timezone(interaction.user.id, timezone) + embed = discord.Embed(title=f'Timezone Changed', color=0xffa500) + embed.add_field(name='\u200b', value=f'Your timezone has been updated to {timezone}.', inline=False) + embed.add_field(name='\u200b', value=cfg.EMBED_FOOTER_STRING, inline=False) + return embed + except: + message = f'An unexpected error occurred' + return cfg.ErrorEmbed.message(message) + \ No newline at end of file diff --git a/bot/apps/productivity/reminder.py b/bot/apps/productivity/reminder.py new file mode 100644 index 0000000..53fa4a8 --- /dev/null +++ b/bot/apps/productivity/reminder.py @@ -0,0 +1,7 @@ +""" +reminder.py +author: narlock + +Interface for sending reminders to users +""" + diff --git a/bot/client/alder/interface/user_client.py b/bot/client/alder/interface/user_client.py index b9f03c1..ba27a46 100644 --- a/bot/client/alder/interface/user_client.py +++ b/bot/client/alder/interface/user_client.py @@ -89,6 +89,22 @@ def delete_user(id): """ return AlderAPIClient.delete(f'/user/{id}') + @staticmethod + def set_timezone(id: str, timezone: str): + """ + Calls the set timezone endpoint which puts the + value of {timezone} parameter in the user with + {id} row. + """ + # Construct request body + request_body = { + "timezone": timezone + } + + # Perform set timezone operation + response = AlderAPIClient.put(f"/user/{id}/timezone", request_body) + return json.loads(response.text) + # ===================== # HELPER FUNCTIONS # ===================== diff --git a/bot/main.py b/bot/main.py index dda4a7e..3a99ad6 100644 --- a/bot/main.py +++ b/bot/main.py @@ -329,6 +329,16 @@ async def resetmonth(ctx: commands.Context): await ctx.send("LOL! :rofl:") Logger.warn(f"Reset Month attempt failure. User {ctx.author.name} has insufficient permissions.") +@bot.command(name='dmtest') +async def dmtest(ctx: commands.Context): + """ + test + """ + try: + await ctx.author.send("Hello") + except discord.Forbidden: + await ctx.send('forbidden') + ########################################## ########################################## # Voice Events @@ -468,6 +478,17 @@ async def top(interaction: discord.Interaction, board: str = None): # Send the embed in response to the interaction await interaction.response.send_message(embed=embed) +@bot.tree.command(name='timezone', description='Change your timezone') +async def timezone(interaction: discord.Interaction, timezone: str): + """ + /timezone {timezone_string} + + Sets the timezone of the user to the {timezone_string} parameter. + Currently, timezone is only used for the reminders application. + """ + embed = timezone_app.set_timezone(interaction, timezone) + await interaction.response.send_message(embed=embed, ephemeral=True) + # ########################################## # ########################################## # # Shop Commands diff --git a/setupdb.sql b/setupdb.sql index 3511057..eb2b713 100644 --- a/setupdb.sql +++ b/setupdb.sql @@ -9,6 +9,7 @@ CREATE TABLE user( id BIGINT UNSIGNED NOT NULL PRIMARY KEY, tokens INT UNSIGNED NOT NULL, stime BIGINT UNSIGNED NOT NULL, + timezone VARCHAR(100) NOT NULL DEFAULT 'UTC', hex VARCHAR(7), trivia INT UNSIGNED ); @@ -112,6 +113,21 @@ CREATE TABLE kanban ( FOREIGN KEY (user_id) REFERENCES user(id) ); +DROP TABLE IF EXISTS `reminder`; +CREATE TABLE reminder ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT, + remind_at DATETIME NOT NULL, + repeat_interval VARCHAR(50), -- e.g., 'daily', 'weekly', 'monthly', 'yearly', or a custom like: 'every_other_day', 'every_monday', 'every_tuesday', 'every_wednesday', 'every_thursday', 'every_friday', 'every_saturday', 'every_sunday', or combination of 'mtwhfsu' + repeat_until DATETIME, -- The date until which the reminder should repeat, NULL means indefinitely + repeat_count INT UNSIGNED, -- The number of times to repeat, NULL for indefinite or repeat until a specific date + created_at DATETIME, + updated_at DATETIME, + FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE +); + -- Insert sample data INSERT INTO triviaquestion (title, option_a, option_b, option_c, option_d, correct, author, category) VALUES ('What is Discord''s signature color?', 'Red', 'Blurple', 'Green', 'Gray', 1, 'Alder', 'Entertainment'); INSERT INTO triviaquestion (title, option_a, option_b, option_c, option_d, correct, author, category) VALUES ('What is the capital of France?', 'Berlin', 'Paris', 'Madrid', 'Rome', 1, 'Alder', 'Geography'); From ada8fff6a79c0142cd9b8b26988e4822a26a7220 Mon Sep 17 00:00:00 2001 From: Anthony <59890724+narlock@users.noreply.github.com> Date: Thu, 26 Sep 2024 08:14:27 -0500 Subject: [PATCH 2/4] Add reminder API --- api/app/__init__.py | 4 + api/app/controller/reminder_controller.py | 108 ++++++++++++++++++++++ api/app/model/reminder_model.py | 16 ++++ api/app/schema/reminder_schema.py | 27 ++++++ 4 files changed, 155 insertions(+) create mode 100644 api/app/controller/reminder_controller.py create mode 100644 api/app/model/reminder_model.py create mode 100644 api/app/schema/reminder_schema.py diff --git a/api/app/__init__.py b/api/app/__init__.py index a79b31b..838c6bb 100644 --- a/api/app/__init__.py +++ b/api/app/__init__.py @@ -54,5 +54,9 @@ def create_app(): # Register kanban controller from app.controller.kanban_controller import kanban_bp app.register_blueprint(kanban_bp) + + # Register reminder controller + from app.controller.reminder_controller import reminder_bp + app.register_blueprint(reminder_bp) return app diff --git a/api/app/controller/reminder_controller.py b/api/app/controller/reminder_controller.py new file mode 100644 index 0000000..5799129 --- /dev/null +++ b/api/app/controller/reminder_controller.py @@ -0,0 +1,108 @@ +from flask import Blueprint, request, jsonify +from app.model.reminder_model import ReminderModel +from app.schema.reminder_schema import ReminderSchema +from app import db + +# Define Reminder Blueprint +reminder_bp = Blueprint('reminder_bp', __name__) + +# Initialize Reminder Schema +reminder_schema = ReminderSchema() +reminders_schema = ReminderSchema(many=True) + +# Endpoint to create a new reminder +@reminder_bp.route('/reminder', methods=['POST']) +def create_reminder(): + """ + Creates a reminder based on the contents of + the request body for the user. + """ + data = request.get_json() + + # Validate incoming request data + errors = reminder_schema.validate(data) + if errors: + return jsonify(errors), 400 + + # Create a new reminder instance + new_reminder = ReminderModel( + user_id=data['user_id'], + title=data['title'], + description=data.get('description'), + remind_at=data['remind_at'], + repeat_interval=data.get('repeat_interval'), + repeat_until=data.get('repeat_until'), + repeat_count=data.get('repeat_count') + ) + + # Add and commit the reminder to the database + db.session.add(new_reminder) + db.session.commit() + + # Return the newly created reminder + return jsonify(reminder_schema.dump(new_reminder)), 201 + + +# Endpoint to retrieve all reminders for a specific user +@reminder_bp.route('/reminder/user/', methods=['GET']) +def get_user_reminders(user_id): + """ + Retrieves all reminders for the user by user_id. + """ + # Query the database for reminders by user_id + reminders = ReminderModel.query.filter_by(user_id=user_id).all() + + if not reminders: + return jsonify({"message": "No reminders found for this user."}), 404 + + # Serialize the list of reminders and return + return jsonify(reminders_schema.dump(reminders)), 200 + + +# Endpoint to retrieve all reminders +@reminder_bp.route('/reminder', methods=['GET']) +def get_all_reminders(): + """ + Returns all reminders stored in the database. + """ + # Query the database for all reminders + reminders = ReminderModel.query.all() + + # Serialize the list of reminders and return + return jsonify(reminders_schema.dump(reminders)), 200 + + +# Endpoint to retrieve a reminder by its ID +@reminder_bp.route('/reminder/', methods=['GET']) +def get_reminder(id): + """ + Retrieves a reminder by its ID. + """ + # Query the database for a reminder by its ID + reminder = ReminderModel.query.get(id) + + if reminder is None: + return jsonify({"message": "Reminder not found."}), 404 + + # Serialize and return the reminder + return jsonify(reminder_schema.dump(reminder)), 200 + + +# Endpoint to delete a reminder by its ID +@reminder_bp.route('/reminder/', methods=['DELETE']) +def delete_reminder(id): + """ + Delete a reminder by its ID. + """ + # Query the database for a reminder by its ID + reminder = ReminderModel.query.get(id) + + if reminder is None: + return jsonify({"message": "Reminder not found."}), 404 + + # Delete the reminder and commit the changes + db.session.delete(reminder) + db.session.commit() + + # Return 204 No Content status + return '', 204 \ No newline at end of file diff --git a/api/app/model/reminder_model.py b/api/app/model/reminder_model.py new file mode 100644 index 0000000..9a4b441 --- /dev/null +++ b/api/app/model/reminder_model.py @@ -0,0 +1,16 @@ +from app import db +from datetime import datetime + +class ReminderModel(db.Model): + __tablename__ = 'reminder' + + id = db.Column(db.BigInteger, primary_key=True, autoincrement=True) + user_id = db.Column(db.BigInteger, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False) + title = db.Column(db.String(255), nullable=False) + description = db.Column(db.Text, nullable=True) + remind_at = db.Column(db.DateTime, nullable=False) + repeat_interval = db.Column(db.String(50), nullable=True) + repeat_until = db.Column(db.DateTime, nullable=True) # NULL means no end + repeat_count = db.Column(db.Integer, nullable=True) # NULL means indefinite + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/api/app/schema/reminder_schema.py b/api/app/schema/reminder_schema.py new file mode 100644 index 0000000..2eabbd1 --- /dev/null +++ b/api/app/schema/reminder_schema.py @@ -0,0 +1,27 @@ +from marshmallow import Schema, fields, validate, ValidationError +import re + +# Custom validator for combinations of mtwhfsu +def validate_repeat_interval(value): + # Allow standard intervals + standard_intervals = ['daily', 'weekly', 'monthly', 'yearly'] + if value in standard_intervals: + return True + + # Regex pattern to match any combination of mtwhfsu + if re.fullmatch(r'[mtwhfsu]+', value): + return True + + raise ValidationError(f"Invalid repeat_interval: {value}. Must be a valid combination of 'mtwhfsu' or a standard interval (daily, weekly, monthly, yearly).") + +class ReminderSchema(Schema): + id = fields.Int(dump_only=True) + user_id = fields.Int(required=True) + title = fields.Str(required=True, validate=validate.Length(max=255)) + description = fields.Str() + remind_at = fields.DateTime(required=True, format="%Y-%m-%dT%H:%M:%S") + repeat_interval = fields.Str(validate=validate_repeat_interval) # Custom validator here + repeat_until = fields.DateTime(format="%Y-%m-%dT%H:%M:%S") + repeat_count = fields.Int(validate=validate.Range(min=0)) + created_at = fields.DateTime(dump_only=True) + updated_at = fields.DateTime(dump_only=True) From f6bc1a857376956f0d0b062c1d471bd887c5788f Mon Sep 17 00:00:00 2001 From: Anthony <59890724+narlock@users.noreply.github.com> Date: Sat, 28 Sep 2024 16:32:56 -0500 Subject: [PATCH 3/4] reminder --- api/app/controller/reminder_controller.py | 54 +++- api/tools/utils.py | 7 +- bot/apps/info/timezone.py | 10 +- bot/client/alder/interface/reminder_client.py | 123 +++++++++ bot/client/alder/interface/user_client.py | 4 + bot/main.py | 251 ++++++++++++++++-- docs/install.markdown | 1 + 7 files changed, 426 insertions(+), 24 deletions(-) create mode 100644 bot/client/alder/interface/reminder_client.py diff --git a/api/app/controller/reminder_controller.py b/api/app/controller/reminder_controller.py index 5799129..10730f5 100644 --- a/api/app/controller/reminder_controller.py +++ b/api/app/controller/reminder_controller.py @@ -3,6 +3,8 @@ from app.schema.reminder_schema import ReminderSchema from app import db +from datetime import datetime + # Define Reminder Blueprint reminder_bp = Blueprint('reminder_bp', __name__) @@ -105,4 +107,54 @@ def delete_reminder(id): db.session.commit() # Return 204 No Content status - return '', 204 \ No newline at end of file + return '', 204 + +@reminder_bp.route('/reminder//date', methods=['PUT']) +def update_reminder_date(id): + """ + Updates the remind_at date of a reminder using the given date string (YYYY-MM-DD). + The time component of the remind_at field will remain unchanged. + """ + # Get the existing reminder by its ID + reminder = ReminderModel.query.get(id) + + if reminder is None: + return jsonify({"message": "Reminder not found."}), 404 + + # Parse the incoming JSON data + data = request.get_json() + new_date_str = data.get('remind_at') + + # Validate the date string + if not new_date_str: + return jsonify({"message": "The 'remind_at' date field is required."}), 400 + + try: + # Parse the new date from the string + new_date = datetime.strptime(new_date_str, "%Y-%m-%d") + except ValueError: + return jsonify({"message": "Invalid date format. Use 'YYYY-MM-DD'."}), 400 + + # Check if reminder.remind_at is a datetime object or string + if isinstance(reminder.remind_at, str): + try: + # Convert string to datetime if reminder.remind_at is in ISO 8601 format + current_remind_at = datetime.fromisoformat(reminder.remind_at) + except ValueError: + return jsonify({"message": "The existing remind_at field has an invalid format."}), 500 + elif isinstance(reminder.remind_at, datetime): + current_remind_at = reminder.remind_at + else: + return jsonify({"message": "Unexpected format for remind_at field."}), 500 + + # Create a new remind_at datetime by combining the new date with the existing time + updated_remind_at = datetime.combine(new_date, current_remind_at.time()) + + # Update the remind_at field of the reminder + reminder.remind_at = updated_remind_at.isoformat() # Convert back to ISO format string + + # Commit the changes to the database + db.session.commit() + + # Return the updated reminder + return jsonify(ReminderSchema().dump(reminder)), 200 \ No newline at end of file diff --git a/api/tools/utils.py b/api/tools/utils.py index 35fa654..16218b1 100644 --- a/api/tools/utils.py +++ b/api/tools/utils.py @@ -1,10 +1,11 @@ -from datetime import datetime, timezone +import pytz +from datetime import datetime class DateTimeUtils(): @staticmethod def get_utc_date_now(): - now_utc = datetime.now(timezone.utc) + now_utc = datetime.now(pytz.utc) d = now_utc.day mth = now_utc.month yr = now_utc.year @@ -13,7 +14,7 @@ def get_utc_date_now(): @staticmethod def get_utc_month_year_now(): - now_utc = datetime.now(timezone.utc) + now_utc = datetime.now(pytz.utc) mth = now_utc.month yr = now_utc.year return mth, yr \ No newline at end of file diff --git a/bot/apps/info/timezone.py b/bot/apps/info/timezone.py index fbb36ff..c16bf47 100644 --- a/bot/apps/info/timezone.py +++ b/bot/apps/info/timezone.py @@ -13,10 +13,12 @@ from client.alder.interface.user_client import UserClient class TimeZoneApp(): - def set_timezone(self, interaction: discord.Interaction, timezone: str): + @staticmethod + def set_timezone(interaction: discord.Interaction, timezone: str): """ Sets the user's timezone. """ + UserClient.create_user_if_dne(interaction.user.id) # Validate the timezone string try: @@ -27,7 +29,11 @@ def set_timezone(self, interaction: discord.Interaction, timezone: str): # The timezone string is valid. Set it try: - UserClient.set_timezone(interaction.user.id, timezone) + response = UserClient.set_timezone(interaction.user.id, timezone) + + if response is None: + raise Exception + embed = discord.Embed(title=f'Timezone Changed', color=0xffa500) embed.add_field(name='\u200b', value=f'Your timezone has been updated to {timezone}.', inline=False) embed.add_field(name='\u200b', value=cfg.EMBED_FOOTER_STRING, inline=False) diff --git a/bot/client/alder/interface/reminder_client.py b/bot/client/alder/interface/reminder_client.py new file mode 100644 index 0000000..1b8f978 --- /dev/null +++ b/bot/client/alder/interface/reminder_client.py @@ -0,0 +1,123 @@ +""" +reminder_client.py +author: narlock + +Alder interface for making HTTP requests to the +reminder resource on the Alder API +""" + +import json + +from client.alder.alder_api_client import AlderAPIClient + +class ReminderClient(): + """ + Alder interface for making HTTP requests to the + reminder resource on the Alder API. + """ + + @staticmethod + def get_all_reminders(): + """ + Retrieves all reminders that are in the database + """ + response = AlderAPIClient.get('/reminder') + print(response) + + # If the response is None, empty, or not successful, return None + if not response or response.status_code != 200: + return None + + return json.loads(response.text) + + @staticmethod + def get_user_reminders(user_id: int): + """ + Retrieve all reminders for the user denoted by their + user_id. + """ + response = AlderAPIClient.get(f'/reminder/user/{user_id}') + + # If the response is None, empty or not successful, return None + if not response or response.status_code != 200: + return None + + return json.loads(response.text) + + @staticmethod + def get_reminder_by_id(id: int): + """ + Retrieve a reminder by its unique identifier. + """ + response = AlderAPIClient.get(f'/reminder/{id}') + + # If the response is None or not successful, return None + if not response or response.status_code != 200: + return None + + return json.loads(response.text) + + @staticmethod + def create_reminder(user_id: int, title: str, remind_at, repeat_interval = None, repeat_until = None, repeat_count = None): + """ + Creates a reminder for the user_id called title. Will be reminded at + remind_at parameter. The reminder will repeat if provided repeat + criteria. + + Repeat Interval can be 'daily', 'weekly', 'monthly', 'yearly', and a combination of 'mtwhfsu' + """ + # Create a JSON payload with the provided parameters + request_body = { + 'user_id': user_id, + 'title': title, + 'remind_at': remind_at, + 'repeat_interval': repeat_interval, + 'repeat_until': repeat_until, + 'repeat_count': repeat_count + } + + # Remove any keys with None values to avoid sending them in the request body + request_body = {k: v for k, v in request_body.items() if v is not None} + + # Send the POST request to the '/reminder' endpoint + response = AlderAPIClient.post('/reminder', request_body) + + # If the response is not successful, return None + if not response or response.status_code != 201: + return None + + # Return the response as JSON + return json.loads(response.text) + + @staticmethod + def delete_reminder_by_id(id: int): + """ + Deletes a reminder by its id. + """ + return AlderAPIClient.delete(f'/reminder/{id}') + + @staticmethod + def update_reminder_date(id: int, remind_date: str): + """ + Updates the remind_at date for the reminder with the given id. + The time component of the remind_at field will remain unchanged. + + :param id: The unique identifier of the reminder. + :param remind_date: The new date string (YYYY-MM-DD) to set for the remind_at field. + """ + # Create the request body with the new remind_at date + request_body = { + 'remind_at': remind_date # Only update the date part, leave time unchanged + } + + # Send the PUT request to the '/reminder//date' endpoint + response = AlderAPIClient.put(f'/reminder/{id}/date', request_body) + + print(response) + + # If the response is not successful, return None + if not response or response.status_code != 200: + return None + + # Return the response as JSON + return json.loads(response.text) diff --git a/bot/client/alder/interface/user_client.py b/bot/client/alder/interface/user_client.py index ba27a46..3d2f356 100644 --- a/bot/client/alder/interface/user_client.py +++ b/bot/client/alder/interface/user_client.py @@ -103,6 +103,10 @@ def set_timezone(id: str, timezone: str): # Perform set timezone operation response = AlderAPIClient.put(f"/user/{id}/timezone", request_body) + + if not response or response.status_code == 404: + return None + return json.loads(response.text) # ===================== diff --git a/bot/main.py b/bot/main.py index 3a99ad6..7c4dbdd 100644 --- a/bot/main.py +++ b/bot/main.py @@ -14,16 +14,19 @@ # Time for tracking how long an operation takes import time +import pytz + # Python dependencies import re import traceback -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta # API clients from client.alder.interface.user_client import UserClient from client.alder.interface.accomplishment_client import AccomplishmentClient from client.alder.interface.rogueboss_client import RbClient from client.alder.interface.dailytoken_client import DailyTokenClient +from client.alder.interface.reminder_client import ReminderClient # Discord dependencies import discord @@ -55,6 +58,7 @@ from apps.info.rules import Rules from apps.info.stats import Stats from apps.info.achievement import Achievements +from apps.info.timezone import TimeZoneApp # Arcade application dependencies from apps.arcade.trivia import Trivia @@ -161,6 +165,10 @@ async def on_ready(): # Begin scheduled tasks sync_time_track.start() + # Check for reminders + await load_all_reminders() + check_reminders.start() + # Indicate on_ready is complete Logger.success('AlderBot is officially ready for use!') # End and log execution time for the command @@ -190,9 +198,8 @@ async def on_guild_join(guild): # Scheduled events ########################################## ########################################## -# stored_utc_month = datetime.now(timezone.utc).month -stored_utc_month = datetime.now(timezone.utc).month -stored_utc_year = datetime.now(timezone.utc).year +stored_utc_month = datetime.now(pytz.utc).month +stored_utc_year = datetime.now(pytz.utc).year @tasks.loop(minutes=15) async def sync_time_track(): @@ -209,8 +216,8 @@ async def sync_time_track(): time_tracker.update_connected_users(guild) # Check for month reset - current_utc_month = datetime.now(timezone.utc).month - current_utc_year = datetime.now(timezone.utc).year + current_utc_month = datetime.now(pytz.utc).month + current_utc_year = datetime.now(pytz.utc).year if(stored_utc_month != current_utc_month): # If the stored month is not the current month, perform reset Logger.debug(f'Performing month reset...') @@ -329,16 +336,6 @@ async def resetmonth(ctx: commands.Context): await ctx.send("LOL! :rofl:") Logger.warn(f"Reset Month attempt failure. User {ctx.author.name} has insufficient permissions.") -@bot.command(name='dmtest') -async def dmtest(ctx: commands.Context): - """ - test - """ - try: - await ctx.author.send("Hello") - except discord.Forbidden: - await ctx.send('forbidden') - ########################################## ########################################## # Voice Events @@ -479,14 +476,15 @@ async def top(interaction: discord.Interaction, board: str = None): await interaction.response.send_message(embed=embed) @bot.tree.command(name='timezone', description='Change your timezone') -async def timezone(interaction: discord.Interaction, timezone: str): +async def timezone(interaction: discord.Interaction, timezone_string: str): """ /timezone {timezone_string} Sets the timezone of the user to the {timezone_string} parameter. Currently, timezone is only used for the reminders application. """ - embed = timezone_app.set_timezone(interaction, timezone) + Logger.info(f'{interaction.user.id} calling timezone with timezone of {timezone_string}') + embed = TimeZoneApp.set_timezone(interaction, timezone_string) await interaction.response.send_message(embed=embed, ephemeral=True) # ########################################## @@ -1037,6 +1035,105 @@ def parse_flags(text, flag, delimiter=' '): Logger.debug('Adding the kanban item to user\'s board') await interaction.response.send_message(embed=embed, ephemeral=True) +@bot.tree.command(name="reminders", description="Manage and view your reminders") +async def reminders( + interaction: discord.Interaction, + title: str = None, + remind_date: str = None, + remind_time: str = None, + repeat_interval: str = None +): + global reminders_dict # Use the global reminders_dict to store reminders + + user_id = interaction.user.id + + # Case 1: Display reminders if no parameters are provided + if not title and not remind_date and not remind_time: + # Call ReminderClient.get_user_reminders to get the user's reminders + reminders = ReminderClient.get_user_reminders(user_id) + + # Check if reminders are present and send them as an embed response + if reminders: + # Create an embed with a clock emoji and title + embed = discord.Embed(title=":clock: Reminders", color=discord.Color.blue()) + + # Add a field for each reminder in the list, where each reminder has a single field + for reminder in reminders: + # Extract information for each reminder + reminder_id = reminder.get('id') + title = reminder.get('title') + remind_at = reminder.get('remind_at') + + # Parse remind_at to get date and time separately + try: + remind_at_datetime = datetime.fromisoformat(remind_at) + remind_date_str = remind_at_datetime.strftime("%Y-%m-%d") # Format: YYYY-MM-DD + remind_time_str = remind_at_datetime.strftime("%H:%M:%S") # Format: HH:MM:SS + except ValueError: + remind_date_str = remind_at + remind_time_str = "" + + # Optional fields + repeat_interval = reminder.get('repeat_interval', 'None') + repeat_until = reminder.get('repeat_until', 'None') + repeat_count = reminder.get('repeat_count', 'None') + + # Construct the reminder details message - all details on a single field + reminder_details = ( + f"[**{reminder_id}**] \"{title}\" Next: **{remind_date_str}** at **{remind_time_str}**. Repeats: {repeat_interval}" + ) + + # Add a field to the embed for this reminder, all details in one field + embed.add_field(name='\u200b', value=reminder_details, inline=False) + + # Send the embed as a response + embed.add_field(name='\u200b', value=cfg.EMBED_FOOTER_STRING, inline=False) + await interaction.response.send_message(embed=embed) + else: + await interaction.response.send_message("You have no reminders.") + + # Case 2: Create a new reminder if title, remind_date, and remind_time are provided + elif title and remind_date and remind_time: + # Validate the remind_date and remind_time format (Optional validation) + try: + datetime.strptime(remind_date, "%Y-%m-%d") + datetime.strptime(remind_time, "%H:%M") + except ValueError: + await interaction.response.send_message("Invalid date or time format. Please use YYYY-MM-DD for date and HH:MM for time.") + return + + # Combine remind_date and remind_time into an ISO 8601 string + remind_at = f"{remind_date}T{remind_time}:00" # Example: "2024-09-22T10:00:00" + + # Call ReminderClient.create_reminder to create the new reminder + new_reminder = ReminderClient.create_reminder( + user_id=user_id, + title=title, + remind_at=remind_at, # Pass the ISO 8601 formatted string + repeat_interval=repeat_interval + ) + + # If the reminder was successfully created, add it to the global reminders_dict + if new_reminder: + # Parse remind_at into a datetime object with UTC timezone + remind_at_dt = datetime.strptime(remind_at, "%Y-%m-%dT%H:%M:%S").replace(tzinfo=pytz.utc) + + # Add the new reminder to the global dictionary + reminders_dict[new_reminder['id']] = { + 'user_id': user_id, + 'title': title, + 'remind_at': remind_at_dt, # Store as datetime object for easy comparisons + 'repeat_interval': repeat_interval + } + + await interaction.response.send_message(f"Reminder '{title}' created successfully and added to the global reminder list!") + else: + await interaction.response.send_message("Failed to create the reminder. Please try again later.") + + # Case 3: If some required fields for creating a reminder are missing, show an error + else: + await interaction.response.send_message("To create a reminder, you must include the title, remind_date (YYYY-MM-DD), and remind_time (HH:MM).") + # ########################################## # ########################################## # # Sponsor commands @@ -1144,5 +1241,123 @@ async def daily(interaction: discord.Interaction): embed.add_field(name='\u200b', value=cfg.EMBED_FOOTER_STRING, inline=False) await interaction.response.send_message(embed=embed) + +# ########################################## +# ########################################## +# Reminders functionality +# TODO split this apart if possible +# ########################################## +# ########################################## + +# Store reminders in a dictionary for in-memory access +reminders_dict = {} + +async def load_all_reminders(): + """ + Load all reminders from the API and store them in an in-memory dictionary. + """ + global reminders_dict + + # Fetch all reminders using the ReminderClient + all_reminders = ReminderClient.get_all_reminders() + + if all_reminders: + for reminder in all_reminders: + # Parse the remind_at field to a datetime object in UTC + remind_at = datetime.strptime(reminder['remind_at'], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=pytz.utc) + reminders_dict[reminder['id']] = { + 'user_id': reminder['user_id'], + 'title': reminder['title'], + 'remind_at': remind_at, + 'repeat_interval': reminder.get('repeat_interval') + } + + print(f"Loaded {len(reminders_dict)} reminders.") + else: + print("No reminders found.") + +@tasks.loop(seconds=60) +async def check_reminders(): + """ + Periodically checks if any reminders are due and sends a direct message. + Updates the next reminder time in the dictionary and the database if the reminder repeats. + """ + Logger.info(f'Checking reminders...{len(reminders_dict)} in dictionary.') + current_time = datetime.now(pytz.utc) # Get the current UTC time + + for reminder_id, reminder in list(reminders_dict.items()): + remind_at = reminder['remind_at'] + + # Check if it's time to send the reminder + if remind_at <= current_time: + try: + # Get the user object using the user_id + user = await bot.fetch_user(reminder['user_id']) + + if user: + # Send the reminder message to the user + await user.send(f"Reminder: {reminder['title']}") + + # Handle repeated reminders if necessary + if reminder['repeat_interval']: + # Calculate the next reminder time based on the repeat_interval + next_remind_at = get_next_reminder_time(remind_at, reminder['repeat_interval']) + + # Update the dictionary with the new remind_at time + reminders_dict[reminder_id]['remind_at'] = next_remind_at + + # Extract only the date part of the new remind_at datetime (e.g., "2024-10-01") + new_remind_date_str = next_remind_at.strftime("%Y-%m-%d") + + # Update the reminder in the database using ReminderClient + updated_reminder = ReminderClient.update_reminder_date(reminder_id, new_remind_date_str) + if updated_reminder: + Logger.info(f"Updated reminder {reminder_id} in the database with new remind_at date: {new_remind_date_str}") + else: + Logger.error(f"Failed to update reminder {reminder_id} in the database.") + else: + # Remove the reminder from the dictionary if it doesn't repeat + del reminders_dict[reminder_id] + + # Delete the reminder from the database + deleted = ReminderClient.delete_reminder_by_id(reminder_id) + if deleted: + Logger.info(f"Deleted non-repeating reminder {reminder_id} from the database.") + else: + Logger.error(f"Failed to delete reminder {reminder_id} from the database.") + except discord.errors.NotFound: + # If the user is not found, delete the reminder from the dictionary and database + del reminders_dict[reminder_id] + + # Delete the reminder from the database + deleted = ReminderClient.delete_reminder_by_id(reminder_id) + if deleted: + Logger.info(f"Deleted reminder {reminder_id} from the database due to user not found.") + else: + Logger.error(f"Failed to delete reminder {reminder_id} from the database due to user not found.") + except Exception as e: + # Log any other unexpected exceptions + Logger.error(f"An unexpected error occurred: {str(e)}") + traceback.print_exc() + +# Helper function to calculate the next reminder time based on interval +def get_next_reminder_time(current_time, repeat_interval): + """ + Calculate the next reminder time based on the repeat_interval. + """ + if repeat_interval == 'daily': + return current_time + timedelta(days=1) + elif repeat_interval == 'weekly': + return current_time + timedelta(weeks=1) + elif repeat_interval == 'monthly': + # Increment the month, taking care of year transition + new_month = current_time.month + 1 if current_time.month < 12 else 1 + new_year = current_time.year if current_time.month < 12 else current_time.year + 1 + return current_time.replace(year=new_year, month=new_month) + elif repeat_interval == 'yearly': + return current_time.replace(year=current_time.year + 1) + else: + return current_time + # Starts AlderBot bot.run(cfg.TOKEN) diff --git a/docs/install.markdown b/docs/install.markdown index ced9640..0e5b60f 100644 --- a/docs/install.markdown +++ b/docs/install.markdown @@ -23,6 +23,7 @@ The official bot that runs this source code is found on the [narlock Discord ser - [PyMySQL](https://pypi.org/project/PyMySQL/) - [Flask-SQLAlchemy](https://pypi.org/project/Flask-SQLAlchemy/) - [marshmallow-sqlalchemy](https://pypi.org/project/marshmallow-sqlalchemy/) +- [pytz](https://pypi.org/project/pytz/) ## Python This Discord bot is programmed using the [Python](https://www.python.org/) programming language. This means that if you wish to self host the bot for yourself or contribute to the bot's functionality, you will need to install Python. From 14d945c0b6dc7a92e0999cbcd6b036500a1ac682 Mon Sep 17 00:00:00 2001 From: Anthony <59890724+narlock@users.noreply.github.com> Date: Sat, 5 Oct 2024 12:42:56 -0500 Subject: [PATCH 4/4] Update main.py --- bot/main.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/bot/main.py b/bot/main.py index 7c4dbdd..4513e32 100644 --- a/bot/main.py +++ b/bot/main.py @@ -1035,6 +1035,50 @@ def parse_flags(text, flag, delimiter=' '): Logger.debug('Adding the kanban item to user\'s board') await interaction.response.send_message(embed=embed, ephemeral=True) +@bot.tree.command(name="deletereminder", description="Delete an existing reminder") +async def delete_reminder(interaction: discord.Interaction, reminder_id: str): + """ + Using the reminder id and the interaction, delete the reminder for the + calling user. + """ + global reminders_dict # Use the global reminders_dict to store reminders + + # Ensure that the reminder_id is an integer. + if not reminder_id_int.isdigit(): + await interaction.response.send_message( + embed=discord.Embed( + title="Invalid ID", + description="The provided reminder ID is not a valid number.", + color=discord.Color.red(), + ), + ephemeral=True, + ) + return + + reminder_id_int = int(reminder_id) + user_id = interaction.user.id + + # Get the user's reminders to verify the reminder_id belongs to them. + user_reminders = ReminderClient.get_user_reminders(user_id) + if not user_reminders: + await interaction.response.send_message(embed=cfg.ErrorEmbed.message(f'No reminder found with {reminder_id_int}'), ephemeral=True) + return + + # Check if the reminder_id exists in the user's reminders. + reminder_to_delete = next((reminder for reminder in user_reminders if reminder['id'] == reminder_id_int), None) + if not reminder_to_delete: + await interaction.response.send_message(embed=cfg.ErrorEmbed.message(f'No reminder found with {reminder_id_int}'), ephemeral=True) + return + + # Try to delete the reminder via the ReminderClient. + delete_response = ReminderClient.delete_reminder_by_id(reminder_id_int) + if delete_response and delete_response.status_code == 204: + del reminders_dict[reminder_id] + await interaction.response.send_message(embed=discord.Embed(title="Reminder Deleted", description=f"Successfully deleted reminder with ID {reminder_id_int}.", color=discord.Color.green()), ephemeral=True) + else: + await interaction.response.send_message(embed=cfg.ErrorEmbed.message('An unexpected error ocurred when attempting to delete reminder'), ephemeral=True) + + @bot.tree.command(name="reminders", description="Manage and view your reminders") async def reminders( interaction: discord.Interaction,