diff --git a/base-config.yaml b/base-config.yaml index 3bae0ae..596efa9 100644 --- a/base-config.yaml +++ b/base-config.yaml @@ -5,6 +5,11 @@ # based on opinionated defaults. parent_room: '' +# community slug +# this will be used to suffix room aliases in order to avoid collisions with other communities +# leave blank to generate an acronym of your community name during initialization +community_slug: '' + # sleep time between actions. you can drop this to 0 if your bot has no # ratelimits imposed on its homeserver, otherwise you may want to increase this # to avoid errors. diff --git a/community/bot.py b/community/bot.py index 9d8feec..27928bd 100644 --- a/community/bot.py +++ b/community/bot.py @@ -35,7 +35,7 @@ from mautrix.types import ( SpaceParentStateEventContent, JoinRulesStateEventContent, JoinRule, - RoomCreatePreset, + RoomCreatePreset ) from mautrix.errors import MNotFound from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper @@ -55,6 +55,7 @@ class Config(BaseProxyConfig): helper.copy("admins") helper.copy("moderators") helper.copy("parent_room") + helper.copy("community_slug") helper.copy("track_users") helper.copy("track_messages") helper.copy("track_reactions") @@ -120,6 +121,131 @@ class CommunityBot(Plugin): self.log.error(f"Failed to check user power level: {e}") return False + def generate_community_slug(self, community_name: str) -> str: + """Generate a community slug from the community name. + + Args: + community_name: The full community name + + Returns: + str: A slug made from the first letter of each word, lowercase + """ + # Split by whitespace and get first letter of each word + words = community_name.strip().split() + slug = ''.join(word[0].lower() for word in words if word) + return slug + + async def validate_room_alias(self, alias_localpart: str, server: str) -> bool: + """Check if a room alias already exists. + + Args: + alias_localpart: The localpart of the alias (without # and :server) + server: The server domain + + Returns: + bool: True if alias is available, False if it already exists + """ + try: + full_alias = f"#{alias_localpart}:{server}" + await self.client.resolve_room_alias(full_alias) + # If we get here, the alias exists + return False + except MNotFound: + # Alias doesn't exist, so it's available + return True + except Exception as e: + # For other errors, assume alias is available to be safe + self.log.warning(f"Error checking alias {full_alias}: {e}") + return True + + async def validate_room_aliases(self, room_names: list[str], evt: MessageEvent = None) -> tuple[bool, list[str]]: + """Validate that all room aliases are available. + + Args: + room_names: List of room names to validate + evt: Optional MessageEvent for progress updates + + Returns: + tuple: (is_valid, list_of_conflicting_aliases) + """ + if not self.config["community_slug"]: + if evt: + await evt.respond("Error: No community slug configured. Please run initialize command first.") + return False, [] + + server = self.client.parse_user_id(self.client.mxid)[1] + conflicting_aliases = [] + + for room_name in room_names: + # Clean the room name and create alias + sanitized_name = re.sub(r"[^a-zA-Z0-9]", "", room_name).lower() + alias_localpart = f"{sanitized_name}-{self.config['community_slug']}" + + # Check if alias is available + is_available = await self.validate_room_alias(alias_localpart, server) + if not is_available: + conflicting_aliases.append(f"#{alias_localpart}:{server}") + + return len(conflicting_aliases) == 0, conflicting_aliases + + async def create_space(self, space_name: str, evt: MessageEvent = None, power_level_override: Optional[PowerLevelStateEventContent] = None) -> tuple[str, str]: + """Create a new space without community slug suffix. + + Args: + space_name: The name for the new space + evt: Optional MessageEvent for progress updates + power_level_override: Optional power levels to use + + Returns: + tuple: (space_id, space_alias) if successful, None if failed + """ + mymsg = None + try: + sanitized_name = re.sub(r"[^a-zA-Z0-9]", "", space_name).lower() + invitees = self.config["invitees"] + server = self.client.parse_user_id(self.client.mxid)[1] + + # Validate that the space alias is available + is_available = await self.validate_room_alias(sanitized_name, server) + if not is_available: + error_msg = f"Space alias #{sanitized_name}:{server} already exists. Cannot create space." + self.log.error(error_msg) + if evt: + await evt.respond(error_msg) + return None, None + + if evt: + mymsg = await evt.respond( + f"creating space {sanitized_name}, give me a minute..." + ) + + # Create the space with space-specific content + space_id = await self.client.create_room( + alias_localpart=sanitized_name, + name=space_name, + invitees=invitees, + power_level_override=power_level_override, + creation_content={"type": "m.space"} + ) + + if evt: + await evt.respond( + f"#{sanitized_name}:{server} has been created.", + edits=mymsg, + allow_html=True + ) + + return space_id, f"#{sanitized_name}:{server}" + + except Exception as e: + error_msg = f"Failed to create space: {e}" + self.log.error(error_msg) + if evt and mymsg: + await evt.respond(error_msg, edits=mymsg) + elif evt: + await evt.respond(error_msg) + return None, None + async def _redaction_loop(self) -> None: while True: try: @@ -1593,6 +1719,7 @@ class CommunityBot(Plugin): Returns: tuple: (room_id, room_alias) if successful, None if failed """ + mymsg = None encrypted_flag_regex = re.compile(r"(\s+|^)-+encrypt(ed)?\s?") unencrypted_flag_regex = re.compile(r"(\s+|^)-+unencrypt(ed)?\s?") force_encryption = bool(encrypted_flag_regex.search(roomname)) @@ -1607,6 +1734,26 @@ class CommunityBot(Plugin): parent_room = self.config["parent_room"] server = self.client.parse_user_id(self.client.mxid)[1] + # Check if community slug is configured + if not self.config["community_slug"]: + error_msg = "No community slug configured. Please run initialize command first." + self.log.error(error_msg) + if evt: + await evt.respond(error_msg) + return None + + # Create alias with community slug + alias_localpart = f"{sanitized_name}-{self.config['community_slug']}" + + # Validate that the alias is available + is_available = await self.validate_room_alias(alias_localpart, server) + if not is_available: + error_msg = f"Room alias #{alias_localpart}:{server} already exists. Cannot create room." + self.log.error(error_msg) + if evt: + await evt.respond(error_msg) + return None + # Get power levels from parent room if not provided if not power_level_override and parent_room: power_levels = await self.client.get_state_event( @@ -1629,7 +1776,7 @@ class CommunityBot(Plugin): if evt: mymsg = await evt.respond( - f"creating {sanitized_name}, give me a minute..." + f"creating {alias_localpart}, give me a minute..." ) # Prepare initial state events @@ -1667,9 +1814,18 @@ class CommunityBot(Plugin): } }) + # Add history visibility if specified in creation_content + if creation_content and "m.room.history_visibility" in creation_content: + initial_state.append({ + "type": str(EventType.ROOM_HISTORY_VISIBILITY), + "content": { + "history_visibility": creation_content["m.room.history_visibility"] + } + }) + # Create the room with all initial states room_id = await self.client.create_room( - alias_localpart=sanitized_name, + alias_localpart=alias_localpart, name=roomname, invitees=invitees, initial_state=initial_state, @@ -1692,18 +1848,20 @@ class CommunityBot(Plugin): if evt: await evt.respond( - f"#{sanitized_name}:{server} has been created and added to the space.", + f"#{alias_localpart}:{server} has been created and added to the space.", edits=mymsg, allow_html=True ) - return room_id, f"#{sanitized_name}:{server}" + return room_id, f"#{alias_localpart}:{server}" except Exception as e: error_msg = f"Failed to create room: {e}" self.log.error(error_msg) - if evt: + if evt and mymsg: await evt.respond(error_msg, edits=mymsg) + elif evt: + await evt.respond(error_msg) return None @community.subcommand( @@ -1727,6 +1885,17 @@ class CommunityBot(Plugin): await evt.reply("You don't have permission to use this command") return + # Check if community slug is configured + if not self.config["community_slug"]: + await evt.reply("No community slug configured. Please run initialize command first.") + return + + # Validate the room alias before creating + is_valid, conflicting_aliases = await self.validate_room_aliases([roomname], evt) + if not is_valid: + await evt.reply(f"Cannot create room: {conflicting_aliases[0]} already exists.") + return + result = await self.create_room(roomname, evt) if not result: return # Error already logged and reported to user by create_room @@ -1821,6 +1990,17 @@ class CommunityBot(Plugin): # Get list of aliases to transfer while removing them from the old room aliases_to_transfer = await self.remove_room_aliases(room_id, evt) + # Check if community slug is configured + if not self.config["community_slug"]: + await evt.respond("No community slug configured. Please run initialize command first.") + return + + # Validate that the new room alias is available + is_valid, conflicting_aliases = await self.validate_room_aliases([room_name], evt) + if not is_valid: + await evt.respond(f"Cannot replace room: {conflicting_aliases[0]} already exists.") + return + # Now we can start the process of replacing the room # First we need to create the new room. this will create the initial alias, # as well as bot defaults such as power levels, initial invitations, encryption, @@ -2277,6 +2457,25 @@ class CommunityBot(Plugin): msg = await evt.respond("Initializing new community space...") try: + # Generate community slug if not already set + if not self.config["community_slug"]: + community_slug = self.generate_community_slug(community_name) + self.config["community_slug"] = community_slug + self.log.info(f"Generated community slug: {community_slug}") + + # Define child rooms that will be created during initialization (excluding the space itself) + child_rooms_to_create = [ + f"{community_name} Moderators", # Moderators room + f"{community_name} Waiting Room" # Waiting room + ] + + # Validate child room aliases before creating any rooms + is_valid, conflicting_aliases = await self.validate_room_aliases(child_rooms_to_create, evt) + if not is_valid: + error_msg = f"Cannot initialize community: The following room aliases already exist:\n" + "\n".join(conflicting_aliases) + await evt.respond(error_msg, edits=msg) + return + # Add initiator to invitees list if not already there if evt.sender not in self.config["invitees"]: self.config["invitees"].append(evt.sender) @@ -2297,13 +2496,16 @@ class CommunityBot(Plugin): power_levels.invite = self.config["invite_power_level"] # Create the space with appropriate metadata and power levels - space_id, space_alias = await self.create_room( + space_id, space_alias = await self.create_space( community_name, evt, - power_level_override=power_levels, - creation_content={"type": "m.space"} + power_level_override=power_levels ) + if not space_id: + await evt.respond("Failed to create space", edits=msg) + return + # Set the space as the parent room in config self.config["parent_room"] = space_id @@ -2336,10 +2538,18 @@ class CommunityBot(Plugin): # Create waiting room waiting_room_id, waiting_room_alias = await self.create_room( - f"{community_name} Waiting Room", - evt + f"{community_name} Waiting Room --unencrypted", + evt, + creation_content={ + "m.federate": True, + "m.room.history_visibility": "joined" + } ) + if not waiting_room_id: + await evt.respond("Failed to create waiting room", edits=msg) + return + # Set waiting room to be joinable by anyone await self.client.send_state_event( waiting_room_id, @@ -2361,11 +2571,17 @@ class CommunityBot(Plugin): # Save the updated config self.config.save() + # Check if default encryption is enabled and add warning for waiting room + warning_msg = "" + if self.config.get("encrypt", False): + warning_msg = "\n\n⚠️ **Note: Waiting room created without encryption (as it is a public room)**" + await evt.respond( f"Community space initialized successfully!\n\n" + f"Community Slug: {self.config['community_slug']}\n" f"Space: {space_alias}\n" f"Moderators Room: {mod_room_alias}\n" - f"Waiting Room: {waiting_room_alias}", + f"Waiting Room: {waiting_room_alias}{warning_msg}", edits=msg, allow_html=True ) diff --git a/example-standalone-config.yaml b/example-standalone-config.yaml index 2665346..c1d6195 100644 --- a/example-standalone-config.yaml +++ b/example-standalone-config.yaml @@ -62,6 +62,11 @@ plugin_config: # leave this empty to use the initialize command to create a new community to manage, # based on opinionated defaults. parent_room: '' + # community slug + # this will be used to suffix room aliases in order to avoid collisions with other communities + # leave blank to generate an acronym of your community name during initialization + community_slug: '' + # sleep time between actions. you can drop this to 0 if your bot has no # ratelimits imposed on its homeserver, otherwise you may want to increase this