diff --git a/README.md b/README.md index d7fc5b4..85c4b3d 100644 --- a/README.md +++ b/README.md @@ -33,16 +33,38 @@ with this plugin's capabilities: to the space by following this structure, you reduce the amount of surface area you have to spend time defending against spam and -implementing censorship rules. +implementing censorship rules. the handy `!community initialize ` command will get you +from zero to an opinionated community structured this way quickly and easily. if that doesn't sound like how you want to structure your online community, you might be better off using something like -Draupnir or Mjolnir. +Draupnir, Meowlnir, or Mjolnir. # features please read through the comments in the `base-config.yaml` for more thorough explanations, but this covers the high points. +## initialize a community from scratch + +just installed the plugin for the first time, and want to get started on the right foot? start a DM with your bot and run: + +`!community initialize ` + +this will perform several actions on your behalf: + +1. create a space named for your community, with an appropriate alias on the homeserver, and save the config with this parent room ID +2. add you to the "invitee" list in the config to be invited to all new rooms +3. set the bot's power level to 1000, and invite you as an administrator with power level 100 +4. create a room within the space for admins/moderators to execute bot commands, this room is invite only +5. create a publicly facing room called the waiting room to allow newcomers to join and ask for invitation to your space +6. enable basic keyword and file upload censorship only on the waiting room +7. all rooms will require moderator permissions to invite additional users, to prevent rogue invitations or unexpected guests + +once these actions have been taken, you can manage moderators, change room avatars, etc as you like, and add more rooms with +other commands. happy community-managing! + +attempts to run this command once a parent room has been set will fail. + ## greet new users on joining a room configure your bot to send a custom greeting to users whenever they join a room! configuration file provides a greeting @@ -70,7 +92,9 @@ purging admin accounts, backup accounts, rarely used bots, etc. members to the space automatically trigger a sync, as do most other commands. this command is mostly deprecated but you may want to run it just to see what it does. -generate a report with the `report` subcommand (i.e. `!community report`) to see your inactive users. +generate a report with the `report` subcommand (i.e. `!community report`) to see your inactive users. you can also +generate more specific reports using the `inactive`, `purgable`, and `ignored` commands to see users in those specific +categories. ## user management diff --git a/base-config.yaml b/base-config.yaml index fd0806b..3bae0ae 100644 --- a/base-config.yaml +++ b/base-config.yaml @@ -1,7 +1,9 @@ # the room-id of the matrix room or space to use as your "full user list" # changes to user power levelsin this room will affect all rooms in the space # some features may not work if this is a regular room. use a space. -parent_room: "!somerandomcharacters:server.tld" +# leave this empty to use the initialize command to create a new community to manage, +# based on opinionated defaults. +parent_room: '' # 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 @@ -13,10 +15,19 @@ sleep: 5 # when creating new rooms encrypt: False +# when creating a new room, what power-level should be required to invite users? +# this is helpful to prevent malicious accounts from inviting spam bots by restricting +# room defaults to moderators being the only people who can invite new users from outside +# of your managed community. otherwise, you must be a space member to join the rooms. +invite_power_level: 50 + # number of days of inactivity to be considered in the "warning zone" +# users in this category will appear in the report as inactive warn_threshold_days: 30 # number of days of inactivity to be considered in the "danger zone" +# users in this category will appear in the purgable report and are +# subject to removal by the purge command. kick_threshold_days: 60 # track users? if false, will disable all tracking and avoid writing anything to the database. @@ -39,9 +50,8 @@ admins: [] moderators: [] # list of users who should be invited to new rooms immediately (other bots, moderators, perhaps) -invitees: -- "@mybot:server.tld" -- "@secondaryadmin:server.tld" +# use full matrix IDs here +invitees: [] # auto-greet users in rooms with these messages # map greeting messages to a room @@ -66,7 +76,7 @@ greeting_rooms: '!someotherroom:server.tld': generic '!myencryptedroomid:server.tld': encrypted -# how long to wait (in seconds) before sending a greeting to a new +# how long to wait (in seconds) before sending a greeting to a new joiner welcome_sleep: 0 # add a room ID here to send a message to when someone joins the above rooms @@ -109,13 +119,14 @@ censor_wordlist_instaban: [] banlists: - '#community-moderation-effort-bl:neko.dev' -# should we ban proactively? this will generate ban events across all rooms every time -# the ban lists have a new policy added, which may be noisy. however, without this enabled, +# should we ban proactively? this will ban users in your rooms if a new ban event is added to +# the banlist policy room for their account. however, without this enabled, # an account may join your rooms, THEN get added to the banlist, and you will have to manually # ban them from your rooms. proactive_banning: true -# should we redact messages when a user is banned? +# should we redact messages when a user is banned? limited to their last 100 messages in each room. +# redactions are processed every minute, they are not immediate. redact_on_ban: true # should we verify that users are human before allowing them to send messages? @@ -146,4 +157,4 @@ verification_attempts: 3 verification_message: | Thank you for joining {room}. As an anti-spam measure, you must demonstrate that you are a real person before you can send messages in its rooms. - Please send a message to this chat with the phrase: "{phrase}" + Please send a message to this chat with the content: "{phrase}" diff --git a/community/bot.py b/community/bot.py index fa2d970..3f601e1 100644 --- a/community/bot.py +++ b/community/bot.py @@ -78,6 +78,7 @@ class Config(BaseProxyConfig): helper.copy("verification_phrases") helper.copy("verification_attempts") helper.copy("verification_message") + helper.copy("invite_power_level") class CommunityBot(Plugin): @@ -1109,11 +1110,22 @@ class CommunityBot(Plugin): async def community(self) -> None: pass + async def check_parent_room(self, evt: MessageEvent) -> bool: + """Check if parent room is configured and handle the response if not.""" + if not self.config["parent_room"]: + await evt.reply( + "No parent room configured. Please use the 'initialize' command to set up your community space first." + ) + return False + return True + @community.subcommand( "bancheck", help="check subscribed banlists for a user's mxid" ) @command.argument("mxid", "full matrix ID", required=True) async def check_banlists(self, evt: MessageEvent, mxid: UserID) -> None: + if not await self.check_parent_room(evt): + return ban_status = await self.check_if_banned(mxid) await evt.reply(f"user on banlist: {ban_status}") @@ -1123,6 +1135,8 @@ class CommunityBot(Plugin): in case they are missing", ) async def sync_space_members(self, evt: MessageEvent) -> None: + if not await self.check_parent_room(evt): + return if not await self.user_permitted(evt.sender): await evt.reply("You don't have permission to use this command") return @@ -1189,6 +1203,8 @@ class CommunityBot(Plugin): ) @command.argument("mxid", "full matrix ID", required=True) async def ignore_inactivity(self, evt: MessageEvent, mxid: UserID) -> None: + if not await self.check_parent_room(evt): + return if not await self.user_permitted(evt.sender): await evt.reply("You don't have permission to use this command") return @@ -1214,6 +1230,8 @@ class CommunityBot(Plugin): ) @command.argument("mxid", "full matrix ID", required=True) async def unignore_inactivity(self, evt: MessageEvent, mxid: UserID) -> None: + if not await self.check_parent_room(evt): + return if not await self.user_permitted(evt.sender): await evt.reply("You don't have permission to use this command") return @@ -1238,6 +1256,8 @@ class CommunityBot(Plugin): "report", help="generate a full list of activity tracking status" ) async def get_report(self, evt: MessageEvent) -> None: + if not await self.check_parent_room(evt): + return if not await self.user_permitted(evt.sender): await evt.reply("You don't have permission to use this command") return @@ -1263,6 +1283,8 @@ class CommunityBot(Plugin): "inactive", help="generate a list of mxids who have been inactive" ) async def get_inactive_report(self, evt: MessageEvent) -> None: + if not await self.check_parent_room(evt): + return if not await self.user_permitted(evt.sender): await evt.reply("You don't have permission to use this command") return @@ -1284,6 +1306,8 @@ class CommunityBot(Plugin): "purgable", help="generate a list of matrix IDs that have been inactive long enough to be purged" ) async def get_purgable_report(self, evt: MessageEvent) -> None: + if not await self.check_parent_room(evt): + return if not await self.user_permitted(evt.sender): await evt.reply("You don't have permission to use this command") return @@ -1304,6 +1328,8 @@ class CommunityBot(Plugin): "ignored", help="generate a list of matrix IDs that have activity tracking disabled" ) async def get_ignored_report(self, evt: MessageEvent) -> None: + if not await self.check_parent_room(evt): + return if not await self.user_permitted(evt.sender): await evt.reply("You don't have permission to use this command") return @@ -1323,6 +1349,8 @@ class CommunityBot(Plugin): @community.subcommand("purge", help="kick users for excessive inactivity") async def kick_users(self, evt: MessageEvent) -> None: + if not await self.check_parent_room(evt): + return await evt.mark_read() if not await self.user_permitted(evt.sender): await evt.reply("You don't have permission to use this command") @@ -1377,6 +1405,8 @@ class CommunityBot(Plugin): ) @command.argument("mxid", "full matrix ID", required=True) async def kick_user(self, evt: MessageEvent, mxid: UserID) -> None: + if not await self.check_parent_room(evt): + return await evt.mark_read() if not await self.user_permitted(evt.sender): await evt.reply("You don't have permission to use this command") @@ -1427,6 +1457,8 @@ class CommunityBot(Plugin): ) @command.argument("mxid", "full matrix ID", required=True) async def ban_user(self, evt: MessageEvent, mxid: UserID) -> None: + if not await self.check_parent_room(evt): + return await evt.mark_read() if not await self.user_permitted(evt.sender): await evt.reply("You don't have permission to use this command") @@ -1450,6 +1482,8 @@ class CommunityBot(Plugin): ) @command.argument("mxid", "full matrix ID", required=True) async def unban_user(self, evt: MessageEvent, mxid: UserID) -> None: + if not await self.check_parent_room(evt): + return await evt.mark_read() if not await self.user_permitted(evt.sender): await evt.reply("You don't have permission to use this command") @@ -1504,6 +1538,8 @@ class CommunityBot(Plugin): async def mark_for_redaction( self, evt: MessageEvent, mxid: UserID, room: str ) -> None: + if not await self.check_parent_room(evt): + return await evt.mark_read() if not await self.user_permitted(evt.sender): await evt.reply("You don't have permission to use this command") @@ -1532,12 +1568,14 @@ class CommunityBot(Plugin): ) await evt.respond(f"Queued {len(messages)} messages for redaction in {room_id}") - async def create_room(self, roomname: str, evt: MessageEvent = None) -> None: + async def create_room(self, roomname: str, evt: MessageEvent = None, power_level_override: Optional[PowerLevelStateEventContent] = None, creation_content: Optional[dict] = None) -> None: """Create a new room and add it to the parent space. Args: roomname: The name for the new room evt: Optional MessageEvent for progress updates. If provided, will send status messages. + power_level_override: Optional power levels to use. If not provided, will try to get from parent room. + creation_content: Optional creation content to use when creating the room. Returns: tuple: (room_id, room_alias) if successful, None if failed @@ -1552,16 +1590,25 @@ class CommunityBot(Plugin): parent_room = self.config["parent_room"] server = self.client.parse_user_id(self.client.mxid)[1] - # Get parent room power levels to use as template - power_levels = await self.client.get_state_event( - self.config["parent_room"], EventType.ROOM_POWER_LEVELS - ) - - user_power_levels = power_levels.users - - # ensure bot has highest power - user_power_levels[self.client.mxid] = 1000 - self.log.debug(f"DEBUG user power levels: {user_power_levels}") + # 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( + parent_room, EventType.ROOM_POWER_LEVELS + ) + user_power_levels = power_levels.users + # ensure bot has highest power + user_power_levels[self.client.mxid] = 1000 + power_levels.users = user_power_levels + power_level_override = power_levels + elif not power_level_override: + # If no parent room and no override provided, create default power levels + power_levels = PowerLevelStateEventContent() + power_levels.users = { + self.client.mxid: 1000, # Bot gets highest power + } + # Set invite power level from config + power_levels.invite = self.config["invite_power_level"] + power_level_override = power_levels if evt: mymsg = await evt.respond( @@ -1569,26 +1616,30 @@ class CommunityBot(Plugin): ) # Prepare initial state events - initial_state = [ - { - "type": str(EventType.SPACE_PARENT), - "state_key": parent_room, - "content": { - "via": [server], - "canonical": True + initial_state = [] + + # Only add space parent state if we have a parent room + if parent_room: + initial_state.extend([ + { + "type": str(EventType.SPACE_PARENT), + "state_key": parent_room, + "content": { + "via": [server], + "canonical": True + } + }, + { + "type": str(EventType.ROOM_JOIN_RULES), + "content": { + "join_rule": "restricted", + "allow": [{ + "type": "m.room_membership", + "room_id": parent_room + }] + } } - }, - { - "type": str(EventType.ROOM_JOIN_RULES), - "content": { - "join_rule": "restricted", - "allow": [{ - "type": "m.room_membership", - "room_id": parent_room - }] - } - } - ] + ]) # Add encryption if needed if self.config["encrypt"] or force_encryption: @@ -1605,20 +1656,22 @@ class CommunityBot(Plugin): name=roomname, invitees=invitees, initial_state=initial_state, - power_level_override={"users": user_power_levels} + power_level_override=power_level_override, + creation_content=creation_content ) # The space child relationship needs to be set in the parent room separately - await self.client.send_state_event( - parent_room, - EventType.SPACE_CHILD, - { - "via": [server], - "suggested": False - }, - state_key=room_id - ) - await asyncio.sleep(self.config["sleep"]) + if parent_room: + await self.client.send_state_event( + parent_room, + EventType.SPACE_CHILD, + { + "via": [server], + "suggested": False + }, + state_key=room_id + ) + await asyncio.sleep(self.config["sleep"]) if evt: await evt.respond( @@ -1643,6 +1696,8 @@ class CommunityBot(Plugin): ) @command.argument("roomname", pass_raw=True, required=True) async def create_that_room(self, evt: MessageEvent, roomname: str) -> None: + if not await self.check_parent_room(evt): + return if (roomname == "help") or len(roomname) == 0: await evt.reply( 'pass me a room name (like "cool topic") and i will create it and add it to the space. \ @@ -1662,6 +1717,8 @@ class CommunityBot(Plugin): @community.subcommand("archive", help="archive a room") @command.argument("room", required=False) async def archive_room(self, evt: MessageEvent, room: str) -> None: + if not await self.check_parent_room(evt): + return await evt.mark_read() if not await self.user_permitted(evt.sender): @@ -1697,6 +1754,8 @@ class CommunityBot(Plugin): @community.subcommand("replaceroom", help="replace a room with a new one") @command.argument("room", required=False) async def replace_room(self, evt: MessageEvent, room: str) -> None: + if not await self.check_parent_room(evt): + return await evt.mark_read() if not await self.user_permitted(evt.sender): @@ -1857,6 +1916,8 @@ class CommunityBot(Plugin): ) @command.argument("room", required=False) async def get_guestlist(self, evt: MessageEvent, room: str) -> None: + if not await self.check_parent_room(evt): + return space_members_obj = await self.client.get_joined_members( self.config["parent_room"] ) @@ -1895,6 +1956,8 @@ class CommunityBot(Plugin): ) @command.argument("room", required=False) async def get_roomid(self, evt: MessageEvent, room: str) -> None: + if not await self.check_parent_room(evt): + return room_id = None if room: if room.startswith("#"): @@ -1922,6 +1985,8 @@ class CommunityBot(Plugin): evt: MessageEvent, target_room: str = None ) -> None: + if not await self.check_parent_room(evt): + return await evt.mark_read() if not await self.user_permitted(evt.sender, min_level=100): await evt.reply("You don't have permission to use this command") @@ -2021,6 +2086,8 @@ class CommunityBot(Plugin): help="migrate a room to a verification-based permission model, ensuring current members can still send messages while new joiners require verification", ) async def verify_migrate(self, evt: MessageEvent) -> None: + if not await self.check_parent_room(evt): + return await evt.mark_read() if not await self.user_permitted(evt.sender): await evt.reply("You don't have permission to use this command") @@ -2137,6 +2204,8 @@ class CommunityBot(Plugin): # If we can't check the state, assume it's stale await self.delete_verification_state(state["dm_room_id"]) + + @classmethod def get_db_upgrade_table(cls) -> None: return upgrade_table @@ -2144,3 +2213,122 @@ class CommunityBot(Plugin): @classmethod def get_config_class(cls) -> Type[BaseProxyConfig]: return Config + + @community.subcommand( + "initialize", + help="initialize a new community space with the given name. this command can only be used if no parent room is configured." + ) + @command.argument("community_name", pass_raw=True, required=True) + async def initialize_community(self, evt: MessageEvent, community_name: str) -> None: + await evt.mark_read() + + # Check if parent room is already configured + if self.config["parent_room"]: + await evt.reply("Cannot initialize: a parent room is already configured. Please remove the parent_room configuration first.") + return + + # Validate community name + if not community_name or community_name.isspace(): + await evt.reply("Please provide a community name. Usage: !community initialize ") + return + + msg = await evt.respond("Initializing new community space...") + + try: + # Add initiator to invitees list if not already there + if evt.sender not in self.config["invitees"]: + self.config["invitees"].append(evt.sender) + # Save the updated config + self.config.save() + + # Create the space + server = self.client.parse_user_id(self.client.mxid)[1] + sanitized_name = re.sub(r"[^a-zA-Z0-9]", "", community_name).lower() + + # Set up power levels for the space + power_levels = PowerLevelStateEventContent() + power_levels.users = { + self.client.mxid: 1000, # Bot gets highest power + evt.sender: 100 # Initiator gets admin power + } + # Set invite power level from config + 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( + community_name, + evt, + power_level_override=power_levels, + creation_content={"type": "m.space"} + ) + + # Set the space as the parent room in config + self.config["parent_room"] = space_id + + # Save the updated config + self.config.save() + + # Verify the space exists and has correct power levels + try: + space_power_levels = await self.client.get_state_event(space_id, EventType.ROOM_POWER_LEVELS) + if space_power_levels.users.get(self.client.mxid) != 1000: + raise Exception("Space power levels not set correctly") + except Exception as e: + error_msg = f"Failed to verify space setup: {e}" + self.log.error(error_msg) + await evt.respond(error_msg, edits=msg) + return + + # Create moderators room + mod_room_id, mod_room_alias = await self.create_room( + f"{community_name} Moderators", + evt + ) + + # Set moderators room to invite-only + await self.client.send_state_event( + mod_room_id, + EventType.ROOM_JOIN_RULES, + JoinRulesStateEventContent(join_rule=JoinRule.INVITE) + ) + + # Create waiting room + waiting_room_id, waiting_room_alias = await self.create_room( + f"{community_name} Waiting Room", + evt + ) + + # Set waiting room to be joinable by anyone + await self.client.send_state_event( + waiting_room_id, + EventType.ROOM_JOIN_RULES, + JoinRulesStateEventContent(join_rule=JoinRule.PUBLIC) + ) + + # Update censor configuration based on current value + current_censor = self.config["censor"] + if current_censor is False: + # If censor is false, set it to a list with just the waiting room + self.config["censor"] = [waiting_room_id] + elif isinstance(current_censor, list) and waiting_room_id not in current_censor: + # If censor is already a list and waiting room isn't in it, append it + current_censor.append(waiting_room_id) + self.config["censor"] = current_censor + # If censor is True or waiting room is already in the list, leave it as is + + # Save the updated config + self.config.save() + + await evt.respond( + f"Community space initialized successfully!\n\n" + f"Space: {space_alias}\n" + f"Moderators Room: {mod_room_alias}\n" + f"Waiting Room: {waiting_room_alias}", + edits=msg, + allow_html=True + ) + + except Exception as e: + error_msg = f"Failed to initialize community: {e}" + self.log.error(error_msg) + await evt.respond(error_msg, edits=msg) diff --git a/maubot.yaml b/maubot.yaml index 0689285..4268a63 100644 --- a/maubot.yaml +++ b/maubot.yaml @@ -1,6 +1,6 @@ maubot: 0.1.0 id: org.jobmachine.communitybot -version: 0.2.7 +version: 0.2.8 license: MIT modules: - community