diff --git a/README.md b/README.md index 759f28a..4be999c 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,26 @@ please keep in mind that wordlist-based censorship is problematic and may redact algorithm that is perfect is impossible. consider configuring your community such that censorship need only be applied in a limited subset of rooms. +# user verification + +configure your rooms (all, or a list of room-ids) to use the `check_if_human` setting. use this in conjunction with a room power-level configuration that +requires elevated permission to send messages. for example, a "waiting-room" +with a default power level of -1 for new users, while the power-level required +to send messages in that room remains 0. + +enabling this and associated configuration will perform the following +validation: + +1. when a user joins one of these rooms, the bot will check to see if they have + permission to send messages. +2. if not, the bot will start a DM with that user and ask them to repeat a phrase, + randomly chosen from your list of verification phrases. they have three tries. +3. when they send the matching verification phrase, the bot will bump their power + level up to that required to send messages in your room, and leave the DM. + +not the most user-friendly experience, but may help cut down if you are experiencing +significant spam in your rooms. + # installation install this like any other maubot plugin: zip the contents of this repo into a file and upload via the web interface, diff --git a/base-config.yaml b/base-config.yaml index 65dbcb0..982e86c 100644 --- a/base-config.yaml +++ b/base-config.yaml @@ -115,4 +115,26 @@ banlists: proactive_banning: true # should we redact messages when a user is banned? -redact_on_ban: true \ No newline at end of file +redact_on_ban: true + +# should we verify that users are human before allowing them to send messages? +# can be boolean (true/false) for all-or-nothing behavior, +# or pass a list of room IDs to only verify users in certain rooms +# use this in conjunction with room power-levels that require elevated permission +# to send messages in a room. +check_if_human: false + +# list of phrases that users must type to verify they are human +# if check_if_human is true but this list is empty, verification will be skipped +# make these your favorite movie quotes, core values of your community, or +# whatever you want. the more unique and obscure, the better. +verification_phrases: +- Yes, I am a human! +- I am a robot, but I'm nice. +- My name is Inigo Montoya. +- The wet bird flies at night. +- Be excellent to each other. +- Party on, dudes. + +# number of attempts a user has to enter the correct verification phrase +verification_attempts: 3 \ No newline at end of file diff --git a/community/bot.py b/community/bot.py index a639384..167f561 100644 --- a/community/bot.py +++ b/community/bot.py @@ -1,11 +1,12 @@ # kickbot - a maubot plugin to track user activity and remove inactive users from rooms/spaces. -from typing import Awaitable, Type, Optional, Tuple +from typing import Awaitable, Type, Optional, Tuple, Dict import json import time import re import fnmatch import asyncio +import random import asyncpg.exceptions from mautrix.client import ( @@ -33,6 +34,7 @@ from mautrix.types import ( SpaceParentStateEventContent, JoinRulesStateEventContent, JoinRule, + RoomCreatePreset, ) from mautrix.errors import MNotFound from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper @@ -71,11 +73,15 @@ class Config(BaseProxyConfig): helper.copy("banlists") helper.copy("proactive_banning") helper.copy("redact_on_ban") + helper.copy("check_if_human") + helper.copy("verification_phrases") + helper.copy("verification_attempts") class CommunityBot(Plugin): _redaction_tasks: asyncio.Task = None + _verification_states: Dict[str, Dict] = {} async def start(self) -> None: await super().start() @@ -754,8 +760,6 @@ class CommunityBot(Plugin): else: on_banlist = await self.check_if_banned(evt.sender) if on_banlist: - # self.log.debug(f"DEBUG user is on banlist!") - # ban this account in managed rooms, don't bother with anything else await self.ban_this_user(evt.sender) return # passive sync of tracking db @@ -764,7 +768,6 @@ class CommunityBot(Plugin): # greeting activities room_id = str(evt.room_id) if room_id in self.config["greeting_rooms"]: - # just in case we got here even if the person is on the banlists if on_banlist: return greeting_map = self.config["greetings"] @@ -791,6 +794,132 @@ class CommunityBot(Plugin): self.config["notification_room"], html=notification_message ) + # Human verification logic + if self.config["check_if_human"] and self.config["verification_phrases"]: + try: + # Check if verification is enabled for this room + verification_enabled = False + if isinstance(self.config["check_if_human"], bool): + verification_enabled = self.config["check_if_human"] + elif isinstance(self.config["check_if_human"], list): + verification_enabled = evt.room_id in self.config["check_if_human"] + + if not verification_enabled: + return + + # Get room name for greeting + roomname = "this room" + try: + roomnamestate = await self.client.get_state_event(evt.room_id, "m.room.name") + roomname = roomnamestate["name"] + except: + pass + + # Check if user already has sufficient power level + try: + power_levels = await self.client.get_state_event( + evt.room_id, EventType.ROOM_POWER_LEVELS + ) + user_level = power_levels.get_user_level(evt.sender) + events_default = power_levels.events_default + events = power_levels.events + + # Get the required power level for sending messages + required_level = events.get(str(EventType.ROOM_MESSAGE), events_default) + + # If user already has sufficient power level, skip verification + if user_level >= required_level: + self.log.debug(f"User {evt.sender} already has sufficient power level ({user_level} >= {required_level})") + return + except Exception as e: + self.log.error(f"Failed to check user power level: {e}") + return + + # Create DM room with name + dm_room = await self.client.create_room( + preset=RoomCreatePreset.PRIVATE, + invitees=[evt.sender], + is_direct=True, + initial_state=[ + { + "type": str(EventType.ROOM_NAME), + "content": {"name": f"{roomname} join verification check"} + } + ] + ) + + # Select random verification phrase + verification_phrase = random.choice(self.config["verification_phrases"]) + + # Store verification state + self._verification_states[dm_room] = { + "user": evt.sender, + "target_room": evt.room_id, + "phrase": verification_phrase, + "attempts": self.config["verification_attempts"], + "required_level": required_level + } + + # Send greeting + greeting = f"""Thank you for joining {roomname}. 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: "{verification_phrase}" """ + await self.client.send_notice(dm_room, greeting) + + except Exception as e: + self.log.error(f"Failed to start verification process: {e}") + + @event.on(EventType.ROOM_MESSAGE) + async def handle_verification(self, evt: MessageEvent) -> None: + # Ignore messages from the bot itself + if evt.sender == self.client.mxid: + return + + if evt.room_id not in self._verification_states: + return + + state = self._verification_states[evt.room_id] + user_phrase = evt.content.body.strip().lower() + expected_phrase = state["phrase"].lower() + + # Remove punctuation and compare + user_phrase = re.sub(r'[^\w\s]', '', user_phrase) + expected_phrase = re.sub(r'[^\w\s]', '', expected_phrase) + + if user_phrase == expected_phrase: + try: + # Update power levels in target room + power_levels = await self.client.get_state_event( + state["target_room"], EventType.ROOM_POWER_LEVELS + ) + power_levels.users[state["user"]] = state["required_level"] + await self.client.send_state_event( + state["target_room"], EventType.ROOM_POWER_LEVELS, power_levels + ) + await self.client.send_notice(evt.room_id, "Success! My work here is done. You can leave this room now.") + except Exception as e: + await self.client.send_notice( + evt.room_id, + f"Something went wrong: {str(e)}. Please report this to the room moderators." + ) + finally: + await self.client.leave_room(evt.room_id) + del self._verification_states[evt.room_id] + else: + state["attempts"] -= 1 + if state["attempts"] <= 0: + await self.client.send_notice( + evt.room_id, + "You have run out of attempts. Please contact a room moderator for assistance." + ) + await self.client.leave_room(evt.room_id) + del self._verification_states[evt.room_id] + else: + await self.client.send_notice( + evt.room_id, + f"Phrase does not match, you have {state['attempts']} tries remaining." + ) + @event.on(EventType.ROOM_MESSAGE) async def update_message_timestamp(self, evt: MessageEvent) -> None: power_levels = await self.client.get_state_event( diff --git a/maubot.yaml b/maubot.yaml index b4308da..0f13ec4 100644 --- a/maubot.yaml +++ b/maubot.yaml @@ -1,6 +1,6 @@ maubot: 0.1.0 id: org.jobmachine.communitybot -version: 0.2.0 +version: 0.2.1 license: MIT modules: - community