11import logging
22import re
33from dataclasses import asdict , dataclass
4+ from datetime import datetime , timezone
45from pathlib import Path
56from typing import Type
67
@@ -37,6 +38,35 @@ def to_dict(self) -> dict:
3738 return {k : str (v ) if isinstance (v , Path ) else v for k , v in asdict (self ).items ()}
3839
3940
41+ @dataclass
42+ class GuildProfile :
43+ name : str
44+ creator : int
45+ created : datetime
46+ icon_id : int
47+ banner_id : int
48+
49+ @classmethod
50+ def from_dict (cls : Type ["GuildProfile" ], name : str , data : dict ) -> "GuildProfile" :
51+ """Create a GuildProfile from a dictionary."""
52+ return cls (
53+ name = name ,
54+ creator = int (data ["creator" ]),
55+ created = datetime .fromtimestamp (int (data ["created" ]), tz = timezone .utc ),
56+ icon_id = int (data ["icon_id" ]),
57+ banner_id = int (data ["banner_id" ]),
58+ )
59+
60+ def to_dict (self ) -> dict :
61+ """Convert a GuildProfile to a dictionary for storage (excluding name)."""
62+ profile_dict = asdict (self )
63+ # Remove name from the dict since it's used as the key in storage
64+ profile_dict .pop ("name" , None )
65+ # Convert datetime to timestamp for storage
66+ profile_dict ["created" ] = int (self .created .timestamp ())
67+ return profile_dict
68+
69+
4070class GuildProfilesCog (commands .Cog ):
4171 """Cog for managing guild profiles (icon and banner)"""
4272
@@ -171,6 +201,31 @@ async def _delete_asset(self, guild: discord.Guild, id: int, asset: GuildAsset):
171201 raise ValueError (f"Asset ID { asset_id } does not exist in the guild's assets." )
172202 del assets [asset_id ]
173203
204+ async def _get_profile (self , guild : discord .Guild , name : str ) -> GuildProfile :
205+ """Get a GuildProfile object for a given profile name.
206+
207+ Args:
208+ guild: The guild to which the profile belongs.
209+ name: The name of the profile to retrieve.
210+
211+ Returns:
212+ A GuildProfile object containing the profile's details.
213+
214+ Raises:
215+ ValueError: If the profile name does not exist in the guild's profiles.
216+ """
217+ profile_name = name .lower ()
218+
219+ profiles = await self .config .guild (guild ).profiles ()
220+ if profile_name not in profiles :
221+ log .error (f"Profile '{ profile_name } ' does not exist in guild { guild .id } ." )
222+ raise ValueError (f"Profile '{ profile_name } ' does not exist in the guild's profiles." )
223+
224+ profile_data = profiles [profile_name ]
225+ log .debug (f"Retrieved profile: { profile_name } for guild { guild .id } " )
226+
227+ return GuildProfile .from_dict (profile_name , profile_data )
228+
174229 async def _check_asset_assigned_profiles (self , guild : discord .Guild , id : int ) -> list [str ]:
175230 """Check if an asset ID is currently in use by any guild profile.
176231
@@ -184,8 +239,9 @@ async def _check_asset_assigned_profiles(self, guild: discord.Guild, id: int) ->
184239 asset_assigned_profiles = []
185240 async with self .config .guild (guild ).profiles () as profiles :
186241 for profile_name , profile_data in profiles .items ():
187- if int (profile_data .get ("icon_id" )) == id or int (profile_data .get ("banner_id" )) == id :
188- asset_assigned_profiles .append (profile_name )
242+ profile = GuildProfile .from_dict (profile_name , profile_data )
243+ if id in (profile .icon_id , profile .banner_id ):
244+ asset_assigned_profiles .append (profile .name )
189245 return asset_assigned_profiles
190246
191247 @commands .group (name = "guildprofile" ) # type: ignore
@@ -248,12 +304,14 @@ async def create_profile_cmd(self, ctx: commands.GuildContext, name: str, icon_i
248304 return await ctx .send (f"A profile named '{ name } ' already exists. Please use a different name." )
249305
250306 # Create the profile
251- profiles [profile_name ] = {
252- "creator" : ctx .author .id ,
253- "created" : int (discord .utils .utcnow ().timestamp ()),
254- "icon_id" : icon_id ,
255- "banner_id" : banner_id ,
256- }
307+ new_profile = GuildProfile (
308+ name = profile_name ,
309+ creator = ctx .author .id ,
310+ created = discord .utils .utcnow (),
311+ icon_id = icon_id ,
312+ banner_id = banner_id ,
313+ )
314+ profiles [profile_name ] = new_profile .to_dict ()
257315
258316 log .info (
259317 f"User { ctx .author .global_name } ({ ctx .author .id } ) in guild { ctx .guild .name } ({ ctx .guild .id } ) "
@@ -268,14 +326,16 @@ async def list_profiles_cmd(self, ctx: commands.GuildContext):
268326 profiles = await self .config .guild (ctx .guild ).profiles ()
269327
270328 if not profiles :
271- return await ctx .send ("No guild profiles have been created yet." )
329+ await ctx .send ("No guild profiles have been created yet." )
330+ return
272331
273332 profile_list = []
274333 for name , data in profiles .items ():
275- creator = ctx .guild .get_member (data ["creator" ])
334+ profile = GuildProfile .from_dict (name , data )
335+ creator = ctx .guild .get_member (profile .creator )
276336 creator_name = creator .display_name if creator else "Unknown User"
277- created_time = f"<t:{ data [ ' created' ] } :R>"
278- profile_list .append (f"- **{ name } ** - Created by { creator_name } { created_time } " )
337+ created_time = f"<t:{ int ( profile . created . timestamp ()) } :R>"
338+ profile_list .append (f"- **{ profile . name } ** - Created by { creator_name } { created_time } " )
279339
280340 profile_chunks = [
281341 profile_list [i : i + self .EMBED_PAGE_LENGTH ] for i in range (0 , len (profile_list ), self .EMBED_PAGE_LENGTH )
@@ -295,10 +355,9 @@ async def list_profiles_cmd(self, ctx: commands.GuildContext):
295355 @profile_cmd .command (name = "info" )
296356 async def profile_info_cmd (self , ctx : commands .GuildContext , name : str ):
297357 """View information about a specific guild profile."""
298- profiles = await self .config .guild (ctx .guild ).profiles ()
299- name = name .lower ()
300-
301- if name not in profiles :
358+ try :
359+ profile = await self ._get_profile (ctx .guild , name )
360+ except ValueError :
302361 log .debug (
303362 f"User { ctx .author .global_name } ({ ctx .author .id } ) in guild { ctx .guild .name } ({ ctx .guild .id } ) "
304363 + f"attempted to view a non-existent profile: { name } "
@@ -308,18 +367,17 @@ async def profile_info_cmd(self, ctx: commands.GuildContext, name: str):
308367 # Show typing indicator while processing
309368 await ctx .typing ()
310369
311- profile = profiles [name ]
312- creator = ctx .guild .get_member (profile ["creator" ])
370+ creator = ctx .guild .get_member (profile .creator )
313371 creator_name = creator .mention if creator else "Unknown User"
314372
315- embed = discord .Embed (title = f"Guild Profile: { name } " , color = await ctx .embed_colour ())
373+ embed = discord .Embed (title = f"Guild Profile: { profile . name } " , color = await ctx .embed_colour ())
316374 embed .add_field (name = "Creator" , value = creator_name , inline = True )
317- embed .add_field (name = "Created" , value = f"<t:{ profile [ ' created' ] } :F>" , inline = True )
375+ embed .add_field (name = "Created" , value = f"<t:{ int ( profile . created . timestamp ()) } :F>" , inline = True )
318376
319377 # Get file paths for icon and banner
320378 try :
321- icon_asset = await self ._get_asset (ctx .guild , profile [ " icon_id" ] )
322- banner_asset = await self ._get_asset (ctx .guild , profile [ " banner_id" ] )
379+ icon_asset = await self ._get_asset (ctx .guild , profile . icon_id )
380+ banner_asset = await self ._get_asset (ctx .guild , profile . banner_id )
323381 except (ValueError , FileNotFoundError ) as e :
324382 return await ctx .send (f"Failed to retrieve asset: { e !s} " )
325383
@@ -345,10 +403,9 @@ async def update_profile_cmd(self, ctx: commands.GuildContext, name: str, icon_i
345403 - An icon asset ID to update the profile's icon.
346404 - A banner asset ID to update the profile's banner.
347405 """
348- profiles = await self .config .guild (ctx .guild ).profiles ()
349- name = name .lower ()
350-
351- if name not in profiles :
406+ try :
407+ profile = await self ._get_profile (ctx .guild , name )
408+ except ValueError :
352409 log .debug (
353410 f"User { ctx .author .global_name } ({ ctx .author .id } ) in guild { ctx .guild .name } ({ ctx .guild .id } ) "
354411 + f"attempted to update a non-existent profile: { name } "
@@ -362,12 +419,10 @@ async def update_profile_cmd(self, ctx: commands.GuildContext, name: str, icon_i
362419 except (ValueError , FileNotFoundError ) as e :
363420 return await ctx .send (f"Failed to retrieve asset: { e !s} " )
364421
365- async with self .config .guild (ctx .guild ).profiles () as profiles :
366- profiles [name ]["icon_id" ] = icon_id
367-
422+ profile .icon_id = icon_id
368423 log .info (
369424 f"User { ctx .author .global_name } ({ ctx .author .id } ) in guild { ctx .guild .name } ({ ctx .guild .id } ) "
370- + f"updated the icon for profile { name } to asset ID { icon_id } "
425+ + f"updated the icon for profile { profile . name } to asset ID { icon_id } "
371426 )
372427
373428 # Update banner if provided
@@ -377,50 +432,50 @@ async def update_profile_cmd(self, ctx: commands.GuildContext, name: str, icon_i
377432 except (ValueError , FileNotFoundError ) as e :
378433 return await ctx .send (f"Failed to retrieve asset: { e !s} " )
379434
380- async with self .config .guild (ctx .guild ).profiles () as profiles :
381- profiles [name ]["banner_id" ] = banner_id
382-
435+ profile .banner_id = banner_id
383436 log .info (
384437 f"User { ctx .author .global_name } ({ ctx .author .id } ) in guild { ctx .guild .name } ({ ctx .guild .id } ) "
385- + f"updated the banner for profile { name } to asset ID { banner_id } "
438+ + f"updated the banner for profile { profile . name } to asset ID { banner_id } "
386439 )
387440
388- await ctx .send (f"Guild profile '{ name } ' updated successfully." )
441+ # Save the updated profile back to config
442+ async with self .config .guild (ctx .guild ).profiles () as profiles :
443+ profiles [profile .name ] = profile .to_dict ()
444+
445+ await ctx .send (f"Guild profile '{ profile .name } ' updated successfully." )
389446
390447 @profile_cmd .command (name = "delete" )
391448 async def delete_profile_cmd (self , ctx : commands .GuildContext , name : str ):
392449 """Delete a guild profile."""
393- name = name .lower ()
394-
395- async with self .config .guild (ctx .guild ).profiles () as profiles :
396- if name not in profiles :
397- log .debug (
398- f"User { ctx .author .global_name } ({ ctx .author .id } ) in guild { ctx .guild .name } ({ ctx .guild .id } ) "
399- + f"attempted to delete a non-existent profile: { name } "
400- )
401- return await ctx .send (f"No profile named '{ name } ' exists." )
402-
403- profile = profiles [name ]
450+ try :
451+ profile = await self ._get_profile (ctx .guild , name )
452+ except ValueError :
453+ log .debug (
454+ f"User { ctx .author .global_name } ({ ctx .author .id } ) in guild { ctx .guild .name } ({ ctx .guild .id } ) "
455+ + f"attempted to delete a non-existent profile: { name } "
456+ )
457+ return await ctx .send (f"No profile named '{ name } ' exists." )
404458
405- # Check if user is creator or has admin privileges
406- if profile [ " creator" ] != ctx .author .id and not await is_admin_or_superior (self .bot , ctx .author ):
407- log .warning (
408- f"User { ctx .author .global_name } ({ ctx .author .id } ) in guild { ctx .guild .name } ({ ctx .guild .id } ) "
409- + f"attempted to delete profile { name } without the necessary permissions."
410- )
411- return await ctx .send (
412- "You don't have permission to delete this profile. " + "Only the creator or admins can delete it."
413- )
459+ # Check if user is creator or has admin privileges
460+ if profile . creator != ctx .author .id and not await is_admin_or_superior (self .bot , ctx .author ):
461+ log .warning (
462+ f"User { ctx .author .global_name } ({ ctx .author .id } ) in guild { ctx .guild .name } ({ ctx .guild .id } ) "
463+ + f"attempted to delete profile { profile . name } without the necessary permissions."
464+ )
465+ return await ctx .send (
466+ "You don't have permission to delete this profile. " + "Only the creator or admins can delete it."
467+ )
414468
415- # Remove the profile from config
416- del profiles [name ]
469+ # Remove the profile from config
470+ async with self .config .guild (ctx .guild ).profiles () as profiles :
471+ del profiles [profile .name ]
417472
418473 log .info (
419474 f"User { ctx .author .global_name } ({ ctx .author .id } ) in guild { ctx .guild .name } ({ ctx .guild .id } ) "
420- + f"deleted the profile { name } "
475+ + f"deleted the profile { profile . name } "
421476 )
422477
423- await ctx .send (f"Guild profile '{ name } ' deleted successfully." )
478+ await ctx .send (f"Guild profile '{ profile . name } ' deleted successfully." )
424479
425480 @profile_cmd .command (name = "apply" )
426481 async def apply_profile_cmd (self , ctx : commands .GuildContext , name : str ):
@@ -429,10 +484,9 @@ async def apply_profile_cmd(self, ctx: commands.GuildContext, name: str):
429484
430485 This will update the guild's icon and banner to match the profile.
431486 """
432- profiles = await self .config .guild (ctx .guild ).profiles ()
433- name = name .lower ()
434-
435- if name not in profiles :
487+ try :
488+ profile = await self ._get_profile (ctx .guild , name )
489+ except ValueError :
436490 log .debug (
437491 f"User { ctx .author .global_name } ({ ctx .author .id } ) in guild { ctx .guild .name } ({ ctx .guild .id } ) "
438492 + f"attempted to apply a non-existent profile: { name } "
@@ -442,39 +496,40 @@ async def apply_profile_cmd(self, ctx: commands.GuildContext, name: str):
442496 # Show typing indicator while processing
443497 await ctx .typing ()
444498
445- profile = profiles [name ]
446-
447499 # Get file paths
448500 try :
449- icon_asset = await self ._get_asset (ctx .guild , profile [ " icon_id" ] )
450- banner_asset = await self ._get_asset (ctx .guild , profile [ " banner_id" ] )
501+ icon_asset = await self ._get_asset (ctx .guild , profile . icon_id )
502+ banner_asset = await self ._get_asset (ctx .guild , profile . banner_id )
451503 except (ValueError , FileNotFoundError ) as e :
452504 return await ctx .send (f"Failed to retrieve asset: { e !s} " )
453505
454506 # Apply changes
455507 try :
456508 async with aio_open (icon_asset .path , "rb" ) as icon_file :
457- icon_data = await icon_file .read ()
509+ icon_bytes = await icon_file .read ()
458510
459511 async with aio_open (banner_asset .path , "rb" ) as banner_file :
460- banner_data = await banner_file .read ()
512+ banner_bytes = await banner_file .read ()
461513
462- await ctx .guild .edit (icon = icon_data , banner = banner_data , reason = f"Guild profile '{ name } ' applied by { ctx .author } " )
514+ await ctx .guild .edit (
515+ icon = icon_bytes , banner = banner_bytes , reason = f"Guild profile '{ profile .name } ' applied by { ctx .author } "
516+ )
463517 log .info (
464518 f"User { ctx .author .global_name } ({ ctx .author .id } ) in guild { ctx .guild .name } ({ ctx .guild .id } ) "
465- + f"applied profile '{ name } ' to the guild."
519+ + f"applied profile '{ profile . name } ' to the guild."
466520 )
467- await ctx .send (f"Guild profile '{ name } ' has been applied to the guild." )
521+ await ctx .send (f"Guild profile '{ profile . name } ' has been applied to the guild." )
468522 except discord .Forbidden :
469523 log .error (
470524 f"User { ctx .author .global_name } ({ ctx .author .id } ) in guild { ctx .guild .name } ({ ctx .guild .id } ) "
471- + f"attempted to apply profile '{ name } ' but I don't have permission to change the guild's icon and banner."
525+ + f"attempted to apply profile '{ profile .name } ' but I don't have permission to change the guild's "
526+ + "icon and banner."
472527 )
473528 await ctx .send ("I don't have permission to change the guild's icon and banner." )
474529 except discord .HTTPException as e :
475530 log .error (
476531 f"User { ctx .author .global_name } ({ ctx .author .id } ) in guild { ctx .guild .name } ({ ctx .guild .id } ) "
477- + f"attempted to apply profile '{ name } ' but an HTTP error occurred: { e !s} "
532+ + f"attempted to apply profile '{ profile . name } ' but an HTTP error occurred: { e !s} "
478533 )
479534 await ctx .send (f"An error occurred while updating the guild: { e !s} " )
480535
0 commit comments