diff --git a/attendance/attendance_stack.py b/attendance/attendance_stack.py index 9307c50..ae6dc86 100644 --- a/attendance/attendance_stack.py +++ b/attendance/attendance_stack.py @@ -25,7 +25,8 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: function_name=construct_id, environment= { "DISCORD_PUBLIC_KEY" : os.getenv('DISCORD_PUBLIC_KEY'), - "ID" : os.getenv('ID'), + "DISCORD_ID" : os.getenv('ID'), + "DISCORD_TOKEN" : os.getenv('TOKEN'), "DYNAMO_USERTABLE" : user_table.table_name, "DYNAMO_CODETABLE" : code_table.table_name }, diff --git a/commands/discord_commands.yaml b/commands/discord_commands.yaml index 7394357..a47cd12 100644 --- a/commands/discord_commands.yaml +++ b/commands/discord_commands.yaml @@ -9,23 +9,36 @@ # required: true - name: attend - description: Attend an event with the given code. You only get three attempts. You can only attend an event once. + description: attend an event with the given code, you can only attend an event once. options: - name: code - description: Event Code + description: event code type: 3 # string required: true + - name: type + description: how you attended the event + type: 3 # string + required: true + choices: + - name: Virtual + value: Virtual + - name: In Person + value: In Person - name: generate - description: Generates an attendence code. Can only be used by Guild Administrators. + description: denerates an attendence code. can only be used by admins. options: - name: expire_in - description: Set when the code expires (in minutes from now) + description: set when the code expires (in minutes from now) type: 4 #int required: true + - name: event_name + description: set the event name + type: 3 # string + required: true - name: validate - description: Check if an attendence code is valid. Can only be used by Guild Administrators. + description: check if an attendence code is valid. Can only be used by admins. options: - name: code description: Event code @@ -33,7 +46,17 @@ required: true - name: reset - description: Reset a user's statistics + description: reset a user's statistics only can be used by admins. + options: + - name: user + description: mention a user to reset their stats + type: 9 # mentionable + required: true - name: stats - description: Get a user's statistics \ No newline at end of file + description: get a user's statistics + options: + - name: user + description: mention a user to get their stats (not required) + type: 9 # mentionable + required: false \ No newline at end of file diff --git a/src/app/db.py b/src/app/db.py index 9b1ebd4..6e8ace1 100644 --- a/src/app/db.py +++ b/src/app/db.py @@ -6,30 +6,28 @@ USER_TABLENAME = os.getenv("DYNAMO_USERTABLE") CODE_TABLENAME = os.getenv("DYNAMO_CODETABLE") -# TODO: update user attendance stuff - -def write_code(code, expiration): +def write_code(code, expiration, event_name): dynamodb = boto3.resource("dynamodb", region_name='us-east-1') table = dynamodb.Table(CODE_TABLENAME) - response = table.put_item(Item={"codeID": code, "expiration": expiration}) + response = table.put_item(Item={"codeID": code, "expiration": expiration, "event_name": event_name}) if response['ResponseMetadata']['HTTPStatusCode'] == 200: print(f"Item {code} added successfully.") else: print("Error adding item...") -def get_code_expiration(code): +def get_code(code): dynamodb = boto3.resource("dynamodb", region_name='us-east-1') table = dynamodb.Table(CODE_TABLENAME) response = table.get_item(Key={"codeID": code}) - expiration = None + code = None if "Item" in response: - expiration = response["Item"]["expiration"] + code = response["Item"] - return expiration + return code def get_user(userid): dynamodb = boto3.resource("dynamodb", region_name='us-east-1') @@ -43,14 +41,15 @@ def get_user(userid): return data -def create_user(userid, attendance_num=0, codes_used=[]): +def create_user(userid, attendance_num=0, codes_used=[], events_attended=[]): dynamodb = boto3.resource("dynamodb", region_name='us-east-1') table = dynamodb.Table(USER_TABLENAME) response = table.put_item(Item={ "userID": userid, "attendance": attendance_num, - "codes_used": codes_used + "codes_used": codes_used, + "events_attended": events_attended }) if response['ResponseMetadata']['HTTPStatusCode'] == 200: @@ -58,17 +57,31 @@ def create_user(userid, attendance_num=0, codes_used=[]): else: print(f"Error updating user {userid}...") -def update_users_attendance(userid, attendance_num, event_serialization): +def update_user(userid, attendance_num, serialized_code, serialized_event): dynamodb = boto3.resource("dynamodb", region_name='us-east-1') table = dynamodb.Table(USER_TABLENAME) response = table.update_item( Key={'userID': userid}, - UpdateExpression="SET codes_used = list_append(codes_used, :val), attendance = :val2", - ExpressionAttributeValues={":val": [event_serialization], ":val2": str(attendance_num)} + UpdateExpression="SET attendance = :val, codes_used = list_append(codes_used, :val2), events_attended = list_append(events_attended, :val3)", + ExpressionAttributeValues={ + ":val": str(attendance_num), + ":val2": [serialized_code], + ":val3": [serialized_event] } ) if response['ResponseMetadata']['HTTPStatusCode'] == 200: print(f"User {userid} updated successfully.") + else: + print(f"Error updating user {userid}...") + +def delete_user(userid): + dynamodb = boto3.resource("dynamodb") + table = dynamodb.Table(USER_TABLENAME) + + response = table.delete_item(Key={'userID': userid}) + + if response['ResponseMetadata']['HTTPStatusCode'] == 200: + print(f"User {userid} deleted successfully.") else: print(f"Error updating user {userid}...") \ No newline at end of file diff --git a/src/app/main.py b/src/app/main.py index 2bd2a74..2076961 100644 --- a/src/app/main.py +++ b/src/app/main.py @@ -6,10 +6,18 @@ from nacl.signing import VerifyKey from nacl.exceptions import BadSignatureError import secrets +from enum import Enum DISCORD_PUBLIC_KEY = os.environ.get("DISCORD_PUBLIC_KEY") +DISCORD_TOKEN = os.environ.get("DISCORD_TOKEN") DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S.%f" +class AttendanceStatus(Enum): + VALID = 0 + EXPIRED = 1 + USED = 2 + NONEXISTENT = 3 + def verify(event): signature = event['headers']['x-signature-ed25519'] timestamp = event['headers']['x-signature-timestamp'] @@ -80,24 +88,44 @@ def interact(raw_request): case "attend": send("Submitting attendance...", id, token) code = str(data["options"][0]["value"]) - status = validate_attendance(userID, code) + type = str(data["options"][1]["value"]) + status = validate_attendance(userID, code, type) match status: - case 0: message = "You have checked in. We hope you enjoy the event and thanks for coming!" - case 1: message = "The attendance code you have entered is expired." - case 2: message = "The attendance code you have entered you have already used." + case AttendanceStatus.VALID: + message = "You have checked in. We hope you enjoy the event and thanks for coming!" + case AttendanceStatus.EXPIRED: + message = "The attendance code you have entered is expired." + case AttendanceStatus.USED: + message = "The attendance code you have entered you have already used." + case AttendanceStatus.NONEXISTENT: + message = "The attendance code you have entered does not exist." update(f"{message}", token) case "generate": if admin: send("Generating attendence code...", id, token) minutes = int(data["options"][0]["value"]) - code = generate_code(minutes) - update(f"Attendence Code is {code} and is valid for {minutes} minutes.", token) + event_name = str(data["options"][1]["value"]) + code = generate_code(minutes, event_name) + update(f"Attendence Code for {event_name} is {code} and is valid for {minutes} minutes.", token) else: send("Only administrators can generate attendance codes!", id, token) case "validate": code = str(data["options"][0]["value"]) status = "valid" if validate_code(code) else "invalid" if admin: send(f"{code} is {status}.", id, token) else: send("Only administrators can validate attendance codes!", id, token) + case "stats": + try: + user = get_mentioned_user(data["options"][0]["value"]) + except: + user = raw_request["member"]["user"] + embeds = build_stats_embed(user) + send_embed(embeds, id, token) + case "reset": + if admin: + send(f"Deleting specified user...", id, token) + db.delete_user(data["options"][0]["value"]) + update("Specified user has been reset.", token) + else: send("Only administrators can reset a user!", id, token) # Send a new message def send(message, id, token): @@ -115,6 +143,24 @@ def send(message, id, token): print("Response status code: ") print(response.status_code) +# Send an embed message +# Documentation on embed objects: https://discord.com/developers/docs/resources/message#embed-object +def send_embed(embeds: dict, id, token): + url = f"https://discord.com/api/interactions/{id}/{token}/callback" + + callback_data = { + "type": 4, + "data": { + "tts": False, + "embeds": [embeds] + } + } + + response = requests.post(url, json=callback_data) + + print("Response status code: ") + print(response.status_code) + # Updates an already sent message. The flags 1 << 6 means "ephemeral message" which means only the person who sent the command can see the result. def update(message, token): app_id = os.environ.get("ID") @@ -132,52 +178,90 @@ def update(message, token): print("Response status code: ") print(response.status_code) -def generate_code(expiration_time: int) -> int: +def generate_code(expiration_time: int, event_name: str) -> int: code = secrets.randbelow(900000) + 100000 expire = datetime.now() expire_time = timedelta(minutes=expiration_time) expire = expire + expire_time - db.write_code(str(code), expire.strftime(DATETIME_FORMAT)) + db.write_code(str(code), expire.strftime(DATETIME_FORMAT), event_name) return code -def validate_code(code: str, output_serialized=False) -> bool | tuple: - expiration = db.get_code_expiration(code) +def validate_code(code: str, expiration=None) -> bool | tuple: + if expiration != None: + code = db.get_code(code) valid = False - if expiration != None: - expiration_datetime = datetime.strptime(expiration, DATETIME_FORMAT) + if code != None or expiration != None: + expiration_datetime = datetime.strptime(code['expiration'], DATETIME_FORMAT) valid = datetime.now() < expiration_datetime - if output_serialized: - valid = (valid, code + '|' + expiration) return valid -def validate_attendance(userid: str, code: str) -> bool: +def validate_attendance(userid: str, code: str, type: str) -> AttendanceStatus: """ - Outputs a status code depending on the user's status with the code.\n - 0 - Valid\n - 1 - Code is expired\n - 2 - Code has already been used\n + Outputs a status code enum depending on the user's status with the code.\n """ - code_serialization = validate_code(code, output_serialized=True) + code_response = db.get_code(code) - status = 2 - if code_serialization: - active, serialized_code = code_serialization + status = AttendanceStatus.NONEXISTENT + if code_response: + active = validate_code(code, expiration=code_response['expiration']) if active: user = db.get_user(userid) + serialized_code = code + '|' + code_response['expiration'] + serialized_event = code_response['event_name'] + '|' + type if user != None: if serialized_code not in user['codes_used']: - status = 0 - db.update_users_attendance(userid, int(user["attendance"]) + 1, serialized_code) + status = AttendanceStatus.VALID + db.update_user(userid, int(user['attendance']) + 1, serialized_code, serialized_event) + else: + status = AttendanceStatus.USED else: - db.create_user(userid, 1, [serialized_code]) - status = 0 + db.create_user(userid, 1, [serialized_code], [serialized_event]) + status = AttendanceStatus.VALID else: - status = 1 + status = AttendanceStatus.EXPIRED + + return status + +def build_stats_embed(user): + embeds = dict() + embeds['thumbnail'] = { + "url": f"https://cdn.discordapp.com/avatars/{user['id']}/{user['avatar']}.png" + } + embeds['color'] = 0x5494de + embeds['title'] = f"{user['username']}'s Attendance Stats" + + # Add Information from Dynamo DB table to embed + user_stats = db.get_user(user['id']) + if user_stats != None: + embeds['description'] = f"Total Attendance: {user_stats['attendance']}" + + # Deserialize event information + embeds['fields'] = [] + i = len(user_stats["events_attended"]) - 1 + while len(embeds['fields']) < 25 and i >= 0: + event_deserialized = user_stats['events_attended'][i].split('|') + embeds['fields'].append({ + 'name': event_deserialized[0], + 'value': event_deserialized[1], + 'inline': True + }) + + i -= 1 + else: + embeds['description'] = f"{user['username']} has not attended any events." + + return embeds + +def get_mentioned_user(userid): + headers = { + "Authorization": f"Bot {DISCORD_TOKEN}" + } + response = requests.get(f"https://discord.com/api/users/{userid}", headers=headers) - return status \ No newline at end of file + return response.json() \ No newline at end of file