1010import dotenv
1111from httpx import Client , HTTPStatusError , Response
1212
13- log = logging .getLogger ("botstrap" ) # note this instance will not have the .trace level
13+ log = logging .getLogger ("botstrap" ) # Note this instance will not have the .trace level
1414
1515# TODO: Remove once better error handling for constants.py is in place.
1616if (dotenv .dotenv_values ().get ("BOT_TOKEN" ) or os .getenv ("BOT_TOKEN" )) is None :
@@ -61,7 +61,7 @@ class SilencedDict(dict[str, Any]):
6161 """A dictionary that silences KeyError exceptions upon subscription to non existent items."""
6262
6363 def __init__ (self , name : str ):
64- self .name = name
64+ self .name : str = name
6565 super ().__init__ ()
6666
6767 def __getitem__ (self , item : str ):
@@ -92,7 +92,7 @@ def __init__(self, *, guild_id: int | str, bot_token: str):
9292 headers = {"Authorization" : f"Bot { bot_token } " },
9393 event_hooks = {"response" : [self ._raise_for_status ]},
9494 )
95- self .guild_id = guild_id
95+ self .guild_id : int | str = guild_id
9696 self ._app_info : dict [str , Any ] | None = None
9797 self ._guild_info : dict [str , Any ] | None = None
9898 self ._guild_channels : list [dict [str , Any ]] | None = None
@@ -161,17 +161,17 @@ def check_if_in_guild(self) -> bool:
161161
162162 def upgrade_server_to_community_if_necessary (
163163 self ,
164- rules_channel_id_ : int | str ,
165- announcements_channel_id_ : int | str ,
164+ rules_channel_id : int | str ,
165+ announcements_channel_id : int | str ,
166166 ) -> bool :
167167 """Fetches server info & upgrades to COMMUNITY if necessary."""
168168 payload = self .guild_info
169169
170170 if COMMUNITY_FEATURE not in payload ["features" ]:
171171 log .info ("This server is currently not a community, upgrading." )
172172 payload ["features" ].append (COMMUNITY_FEATURE )
173- payload ["rules_channel_id" ] = rules_channel_id_
174- payload ["public_updates_channel_id" ] = announcements_channel_id_
173+ payload ["rules_channel_id" ] = rules_channel_id
174+ payload ["public_updates_channel_id" ] = announcements_channel_id
175175 self ._guild_info = self .patch (f"/guilds/{ self .guild_id } " , json = payload ).json ()
176176 log .info ("Server %s has been successfully updated to a community." , self .guild_id )
177177 return True
@@ -215,11 +215,11 @@ def get_all_guild_webhooks(self) -> list[dict[str, Any]]:
215215 response = self .get (f"/guilds/{ self .guild_id } /webhooks" )
216216 return response .json ()
217217
218- def create_webhook (self , name : str , channel_id_ : int ) -> str :
218+ def create_webhook (self , name : str , channel_id : int | str ) -> str :
219219 """Creates a new webhook for a particular channel."""
220220 payload = {"name" : name }
221221 response = self .post (
222- f"/channels/{ channel_id_ } /webhooks" ,
222+ f"/channels/{ channel_id } /webhooks" ,
223223 json = payload ,
224224 headers = {"X-Audit-Log-Reason" : "Creating webhook as part of PyDis botstrap" },
225225 )
@@ -272,9 +272,9 @@ def __init__(
272272 env_file : Path ,
273273 bot_token : str ,
274274 ):
275- self .guild_id = guild_id
276- self .client = DiscordClient (guild_id = guild_id , bot_token = bot_token )
277- self .env_file = env_file
275+ self .guild_id : int | str = guild_id
276+ self .client : DiscordClient = DiscordClient (guild_id = guild_id , bot_token = bot_token )
277+ self .env_file : Path = env_file
278278
279279 def __enter__ (self ):
280280 return self
@@ -310,12 +310,13 @@ def check_guild_membership(self) -> None:
310310 def upgrade_guild (self , announcements_channel_id : str , rules_channel_id : str ) -> bool :
311311 """Upgrade the guild to a community if necessary."""
312312 return self .client .upgrade_server_to_community_if_necessary (
313- rules_channel_id_ = rules_channel_id ,
314- announcements_channel_id_ = announcements_channel_id ,
313+ rules_channel_id = rules_channel_id ,
314+ announcements_channel_id = announcements_channel_id ,
315315 )
316316
317317 def get_roles (self ) -> dict [str , Any ]:
318318 """Get a config map of all of the roles in the guild."""
319+ log .debug ("Syncing roles with bot configuration." )
319320 all_roles = self .client .get_all_roles ()
320321
321322 data : dict [str , int ] = {}
@@ -332,6 +333,7 @@ def get_roles(self) -> dict[str, Any]:
332333
333334 def get_channels (self ) -> dict [str , Any ]:
334335 """Get a config map of all of the channels in the guild."""
336+ log .debug ("Syncing channels with bot configuration." )
335337 all_channels , _categories = self .client .get_all_channels_and_categories ()
336338
337339 data : dict [str , str ] = {}
@@ -349,6 +351,7 @@ def get_channels(self) -> dict[str, Any]:
349351
350352 def get_categories (self ) -> dict [str , Any ]:
351353 """Get a config map of all of the categories in guild."""
354+ log .debug ("Syncing categories with bot configuration." )
352355 _channels , all_categories = self .client .get_all_channels_and_categories ()
353356
354357 data : dict [str , str ] = {}
@@ -365,27 +368,29 @@ def get_categories(self) -> dict[str, Any]:
365368
366369 def sync_webhooks (self ) -> dict [str , Any ]:
367370 """Get webhook config. Will create all webhooks that cannot be found."""
371+ log .debug ("Syncing webhooks with bot configuration." )
372+
368373 all_channels , _categories = self .client .get_all_channels_and_categories ()
369374
370375 data : dict [str , Any ] = {}
371376
372377 existing_webhooks = self .client .get_all_guild_webhooks ()
373- for webhook_name , webhook_model in Webhooks :
378+ for webhook_name , configured_webhook in Webhooks . model_dump (). items () :
374379 formatted_webhook_name = webhook_name .replace ("_" , " " ).title ()
380+ configured_webhook_id = str (configured_webhook ["id" ])
381+
375382 for existing_hook in existing_webhooks :
376- if (
377- # Check the existing ID matches the configured one
378- existing_hook ["id" ] == str (webhook_model .id )
379- or (
380- # Check if the name and the channel ID match the configured ones
381- existing_hook ["name" ] == formatted_webhook_name
382- and existing_hook ["channel_id" ] == str (all_channels [webhook_name ])
383- )
383+ existing_hook_id : str = existing_hook ["id" ]
384+
385+ if existing_hook_id == configured_webhook_id or (
386+ existing_hook ["name" ] == formatted_webhook_name
387+ # This requires the normalized channel name matches the webhook attribute
388+ and existing_hook ["channel_id" ] == str (all_channels [webhook_name ])
384389 ):
385- webhook_id = existing_hook [ "id" ]
390+ webhook_id = existing_hook_id
386391 break
387392 else :
388- webhook_channel_id = int ( all_channels [webhook_name ])
393+ webhook_channel_id = all_channels [webhook_name ]
389394 webhook_id = self .client .create_webhook (formatted_webhook_name , webhook_channel_id )
390395
391396 data [webhook_name + "__id" ] = webhook_id
@@ -396,12 +401,12 @@ def sync_emojis(self) -> dict[str, Any]:
396401 """Get emoji config. Will create all emojis that cannot be found."""
397402 existing_emojis = self .client .list_emojis ()
398403 log .debug ("Syncing emojis with bot configuration." )
399- data : dict [str , Any ] = {}
404+ data : dict [str , str ] = {}
400405 for emoji_config_name , emoji_config in _Emojis .model_fields .items ():
401- if not (match := EMOJI_REGEX .match (emoji_config .default )):
406+ if not (match := EMOJI_REGEX .fullmatch (emoji_config .default )):
402407 continue
403408 emoji_name = match .group (1 )
404- emoji_id = match .group (2 )
409+ emoji_id : str = match .group (2 )
405410
406411 for emoji in existing_emojis :
407412 if emoji ["name" ] == emoji_name :
@@ -415,30 +420,44 @@ def sync_emojis(self) -> dict[str, Any]:
415420
416421 return data
417422
418- def write_config_env (self , config : dict [str , dict [str , Any ]]) -> None :
423+ def write_config_env (self , config : dict [str , dict [str , Any ]]) -> bool :
419424 """Write the configuration to the specified env_file."""
420- with self .env_file .open ("wb" ) as file :
421- for category , category_values in config .items ():
425+ with self .env_file .open ("r+" ) as file :
426+ before = file .read ()
427+ file .seek (0 )
428+ for num , (category , category_values ) in enumerate (config .items ()):
422429 # In order to support commented sections, we write the following
423- file .write (f"# { category .capitalize ()} \n " . encode () )
430+ file .write (f"# { category .capitalize ()} \n " )
424431 # Format the dictionary into .env style
425432 for key , value in category_values .items ():
426- file .write (f"{ category } _{ key } ={ value } \n " .encode ())
427- file .write (b"\n " )
433+ file .write (f"{ category } _{ key } ={ value } \n " )
434+ if num < len (config ) - 1 :
435+ file .write ("\n " )
436+
437+ file .truncate ()
438+ file .seek (0 )
439+ after = file .read ()
440+
441+ return before != after
428442
429- def run (self ) -> None :
443+ def run (self ) -> bool :
430444 """Runs the botstrap process."""
445+ # Track if any changes were made and exit with an error code if so.
446+ changes : bool = False
431447 config : dict [str , dict [str , object ]] = {}
432- self .upgrade_client ()
448+ changes |= self .upgrade_client ()
433449 self .check_guild_membership ()
434450
435451 channels = self .get_channels ()
436452
437453 # Ensure the guild is upgraded to a community if necessary.
438454 # This isn't strictly necessary for bot functionality, but
439455 # it prevents weird transients since PyDis is a community server.
440- self .upgrade_guild (channels [ANNOUNCEMENTS_CHANNEL_NAME ], channels [RULES_CHANNEL_NAME ])
456+ changes |= self .upgrade_guild (channels [ANNOUNCEMENTS_CHANNEL_NAME ], channels [RULES_CHANNEL_NAME ])
441457
458+ # Though sync_webhooks and sync_emojis DO make api calls that may modify server state,
459+ # those changes will be reflected in the config written to the .env file.
460+ # Therefore, we don't need to track if any emojis or webhooks are being changed within those settings.
442461 config = {
443462 "categories" : self .get_categories (),
444463 "channels" : channels ,
@@ -447,11 +466,16 @@ def run(self) -> None:
447466 "emojis" : self .sync_emojis (),
448467 }
449468
450- self .write_config_env (config , self .env_file )
469+ changes |= self .write_config_env (config )
470+ return changes
451471
452472
453473if __name__ == "__main__" :
454474 botstrap = BotStrapper (guild_id = GuildConstants .id , env_file = ENV_FILE , bot_token = BotConstants .token )
455475 with botstrap :
456- botstrap .run ()
457- log .info ("Botstrap completed successfully. Configuration has been written to %s" , ENV_FILE )
476+ changes_made = botstrap .run ()
477+ if changes_made :
478+ log .info ("Botstrap completed successfully. Updated configuration has been written to %s" , ENV_FILE )
479+ else :
480+ log .info ("Botstrap completed successfully. No changes were necessary." )
481+ sys .exit (changes_made )
0 commit comments