commands to initialize a community from scratch, with sane default base-config to allow this action

This commit is contained in:
William Kray
2025-06-17 14:24:15 -07:00
parent 510ef36067
commit 2430baf4cd
4 changed files with 277 additions and 54 deletions
+27 -3
View File
@@ -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 <some name for your community>` 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 <your community name>`
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
+20 -9
View File
@@ -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}"
+229 -41
View File
@@ -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 <community_name>")
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: <a href='https://matrix.to/#/{space_alias}'>{space_alias}</a>\n"
f"Moderators Room: <a href='https://matrix.to/#/{mod_room_alias}'>{mod_room_alias}</a>\n"
f"Waiting Room: <a href='https://matrix.to/#/{waiting_room_alias}'>{waiting_room_alias}</a>",
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)
+1 -1
View File
@@ -1,6 +1,6 @@
maubot: 0.1.0
id: org.jobmachine.communitybot
version: 0.2.7
version: 0.2.8
license: MIT
modules:
- community