Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2541c4054 | |||
| bc490bd084 | |||
| 1e653c60e3 |
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+27
-5
@@ -10,6 +10,10 @@ parent_room: ''
|
||||
# leave blank to generate an acronym of your community name during initialization
|
||||
community_slug: ''
|
||||
|
||||
# use_community_slug
|
||||
# whether to use the community slug as a suffix for room aliases
|
||||
use_community_slug: true
|
||||
|
||||
# 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.
|
||||
@@ -54,16 +58,21 @@ invitees: []
|
||||
|
||||
# auto-greet users in rooms with these messages
|
||||
# map greeting messages to a room
|
||||
# you can use {user} to reference the joining user in this message using a
|
||||
# matrix.to link (rendered as a "pill" in element clients)
|
||||
# available placeholders:
|
||||
# - {user}: display name of the joining user (falls back to localpart or user ID)
|
||||
# - {user_id}: full Matrix user ID
|
||||
# - {user_link}: clickable matrix.to-compatible link to the joining user
|
||||
# - {room}: room name (or room ID if no name is set)
|
||||
# - {room_link}: clickable matrix.to-compatible link to the room
|
||||
# - {room_id}: raw room ID
|
||||
# html formatting is supported
|
||||
# set to {} if you don't care about greetings
|
||||
greetings:
|
||||
generic: |
|
||||
Welcome {user}! Please be sure to read the topic for helpful links and information.
|
||||
Welcome {user_id}! Please be sure to read the topic for helpful links and information.
|
||||
Use <a href="https://google.com">Google</a> for all other queries ;)
|
||||
encrypted: |
|
||||
welcome {user}, this is an encrypted room, so you may not be able to see messages previously sent here. don't be
|
||||
welcome {user_id}, this is an encrypted room, so you may not be able to see messages previously sent here. don't be
|
||||
alarmed.
|
||||
|
||||
# which of the above greetings should be used in which rooms? use the exact name of each greeting
|
||||
@@ -83,8 +92,15 @@ welcome_sleep: 0
|
||||
notification_room:
|
||||
|
||||
# message to send to the notification room when someone joins one of the above rooms:
|
||||
# available placeholders:
|
||||
# - {user}: display name of the joining user (falls back to localpart or user ID)
|
||||
# - {user_id}: full Matrix user ID
|
||||
# - {user_link}: clickable matrix.to-compatible link to the joining user
|
||||
# - {room}: room name (or room ID if no name is set)
|
||||
# - {room_link}: clickable matrix.to-compatible link to the room
|
||||
# - {room_id}: raw room ID
|
||||
join_notification_message: |
|
||||
{user} has joined {room_link}.
|
||||
{user_link} ({user_id}) has joined {room_link}.
|
||||
|
||||
# whether to censor files/messages
|
||||
# can be boolean (true/false) for all-or-nothing behavior,
|
||||
@@ -173,3 +189,9 @@ 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 content: "{phrase}"
|
||||
|
||||
|
||||
# Base URL for Matrix permalink generation.
|
||||
# This is used for placeholders such as {user_link} and {room_link}.
|
||||
# Set this to your own matrix.to-compatible instance if you do not want to use https://matrix.to.
|
||||
matrix_to_base_url: "https://matrix.to"
|
||||
|
||||
Regular → Executable
Regular → Executable
+180
-58
@@ -6,6 +6,7 @@ import time
|
||||
import re
|
||||
import fnmatch
|
||||
import asyncio
|
||||
from html import escape
|
||||
import random
|
||||
import asyncpg.exceptions
|
||||
from datetime import datetime
|
||||
@@ -71,6 +72,7 @@ class Config(BaseProxyConfig):
|
||||
helper.copy("welcome_sleep")
|
||||
helper.copy("parent_room")
|
||||
helper.copy("community_slug")
|
||||
helper.copy("use_community_slug")
|
||||
helper.copy("track_users")
|
||||
helper.copy("warn_threshold_days")
|
||||
helper.copy("kick_threshold_days")
|
||||
@@ -78,6 +80,7 @@ class Config(BaseProxyConfig):
|
||||
helper.copy("invitees")
|
||||
helper.copy("notification_room")
|
||||
helper.copy("join_notification_message")
|
||||
helper.copy("matrix_to_base_url")
|
||||
helper.copy_dict("greeting_rooms")
|
||||
helper.copy_dict("greetings")
|
||||
helper.copy("censor")
|
||||
@@ -100,10 +103,79 @@ class Config(BaseProxyConfig):
|
||||
|
||||
class CommunityBot(Plugin):
|
||||
|
||||
def _get_matrix_to_base_url(self) -> str:
|
||||
return str(self.config.get("matrix_to_base_url", "https://matrix.to")).rstrip("/")
|
||||
|
||||
|
||||
_redaction_tasks: asyncio.Task = None
|
||||
_verification_states: Dict[str, Dict] = {}
|
||||
_report_counts: Dict[str, set] = {}
|
||||
|
||||
|
||||
def _matrix_to_url(self, target: str) -> str:
|
||||
base_url = self._get_matrix_to_base_url()
|
||||
return f"{base_url}/#/{target}"
|
||||
|
||||
|
||||
def _render_html_link(self, target: str, label: str) -> str:
|
||||
return f"<a href='{escape(self._matrix_to_url(target), quote=True)}'>{escape(label)}</a>"
|
||||
|
||||
async def _get_user_display_name(self, room_id: RoomID, user_id: str) -> str:
|
||||
try:
|
||||
member_state = await self.client.get_state_event(
|
||||
room_id,
|
||||
EventType.ROOM_MEMBER,
|
||||
state_key=user_id,
|
||||
)
|
||||
displayname = getattr(member_state, "displayname", None)
|
||||
if displayname:
|
||||
return str(displayname)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
return self.client.parse_user_id(user_id)[0]
|
||||
except Exception:
|
||||
return user_id
|
||||
|
||||
def _render_message_template(
|
||||
self,
|
||||
template: str,
|
||||
user_id: str,
|
||||
user_display: Optional[str] = None,
|
||||
room_id: Optional[str] = None,
|
||||
room_text: Optional[str] = None,
|
||||
) -> Tuple[str, str]:
|
||||
user_url = self._matrix_to_url(user_id)
|
||||
safe_user_display = user_display or user_id
|
||||
safe_room_id = room_id or ""
|
||||
safe_room_text = room_text or safe_room_id
|
||||
room_url = self._matrix_to_url(safe_room_id) if safe_room_id else ""
|
||||
|
||||
plain_text = template.format(
|
||||
user=safe_user_display,
|
||||
user_id=user_id,
|
||||
user_link=user_url,
|
||||
room=safe_room_text,
|
||||
room_link=room_url,
|
||||
room_id=safe_room_id,
|
||||
)
|
||||
|
||||
html_message = template.format(
|
||||
user=escape(safe_user_display),
|
||||
user_id=escape(user_id),
|
||||
user_link=f"<a href='{user_url}'>{escape(safe_user_display)}</a>",
|
||||
room=escape(safe_room_text),
|
||||
room_link=(
|
||||
f"<a href='{room_url}'>{escape(safe_room_text)}</a>"
|
||||
if room_url
|
||||
else escape(safe_room_text)
|
||||
),
|
||||
room_id=escape(safe_room_id),
|
||||
)
|
||||
|
||||
return plain_text, html_message
|
||||
|
||||
async def start(self) -> None:
|
||||
await super().start()
|
||||
self.config.load_and_update()
|
||||
@@ -119,6 +191,11 @@ class CommunityBot(Plugin):
|
||||
self._redaction_tasks.cancel()
|
||||
await super().stop()
|
||||
|
||||
async def _sleep_if_configured(self, delay: float) -> None:
|
||||
"""Sleep without blocking the event loop when a delay is configured."""
|
||||
if delay and delay > 0:
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
async def user_permitted(
|
||||
self, user_id: UserID, min_level: int = 50, room_id: str = None
|
||||
) -> bool:
|
||||
@@ -178,7 +255,9 @@ class CommunityBot(Plugin):
|
||||
Returns:
|
||||
tuple: (is_valid, list_of_conflicting_aliases)
|
||||
"""
|
||||
if not self.config.get("community_slug", ""):
|
||||
if self.config.get("use_community_slug", True) and not self.config.get(
|
||||
"community_slug", ""
|
||||
):
|
||||
if evt:
|
||||
await evt.respond(
|
||||
"Error: No community slug configured. Please run initialize command first."
|
||||
@@ -187,7 +266,11 @@ class CommunityBot(Plugin):
|
||||
|
||||
server = self.client.parse_user_id(self.client.mxid)[1]
|
||||
return await room_utils.validate_room_aliases(
|
||||
self.client, room_names, self.config.get("community_slug", ""), server
|
||||
self.client,
|
||||
room_names,
|
||||
self.config.get("community_slug", ""),
|
||||
self.config.get("use_community_slug", True),
|
||||
server,
|
||||
)
|
||||
|
||||
async def get_moderators_and_above(self) -> list[str]:
|
||||
@@ -328,8 +411,9 @@ class CommunityBot(Plugin):
|
||||
self.log.warning(f"Could not verify space creation: {e}")
|
||||
|
||||
if evt:
|
||||
space_alias = f"#{sanitized_name}:{server}"
|
||||
await evt.respond(
|
||||
f"<a href='https://matrix.to/#/#{sanitized_name}:{server}'>#{sanitized_name}:{server}</a> has been created.",
|
||||
f"{self._render_html_link(space_alias, space_alias)} has been created.",
|
||||
edits=mymsg,
|
||||
allow_html=True,
|
||||
)
|
||||
@@ -864,7 +948,7 @@ class CommunityBot(Plugin):
|
||||
)
|
||||
failed_rooms.append(roomname or room_id)
|
||||
|
||||
time.sleep(self.config["sleep"])
|
||||
await self._sleep_if_configured(self.config["sleep"])
|
||||
|
||||
except Exception as e:
|
||||
self.log.warning(f"Failed to update power levels in {room_id}: {e}")
|
||||
@@ -1004,14 +1088,29 @@ class CommunityBot(Plugin):
|
||||
return
|
||||
greeting_map = self.config["greetings"]
|
||||
greeting_name = self.config["greeting_rooms"][room_id]
|
||||
nick = self.client.parse_user_id(evt.sender)[0]
|
||||
pill = '<a href="https://matrix.to/#/{mxid}">{nick}</a>'.format(
|
||||
mxid=evt.sender, nick=nick
|
||||
)
|
||||
user_display = await self._get_user_display_name(evt.room_id, evt.sender)
|
||||
try:
|
||||
roomnamestate = await self.client.get_state_event(
|
||||
evt.room_id, "m.room.name"
|
||||
)
|
||||
room_text = getattr(roomnamestate, "name", str(evt.room_id))
|
||||
except Exception:
|
||||
room_text = str(evt.room_id)
|
||||
|
||||
if greeting_name != "none":
|
||||
greeting = greeting_map[greeting_name].format(user=pill)
|
||||
time.sleep(self.config["welcome_sleep"])
|
||||
await self.client.send_notice(evt.room_id, html=greeting)
|
||||
greeting_text, greeting_html = self._render_message_template(
|
||||
greeting_map[greeting_name],
|
||||
evt.sender,
|
||||
user_display,
|
||||
evt.room_id,
|
||||
room_text,
|
||||
)
|
||||
await self._sleep_if_configured(self.config["welcome_sleep"])
|
||||
await self.client.send_notice(
|
||||
evt.room_id,
|
||||
greeting_text,
|
||||
html=greeting_html,
|
||||
)
|
||||
else:
|
||||
pass
|
||||
|
||||
@@ -1025,18 +1124,18 @@ class CommunityBot(Plugin):
|
||||
except Exception:
|
||||
room_text = str(evt.room_id)
|
||||
|
||||
room_link = f"<a href='https://matrix.to/#/{evt.room_id}'>{room_text}</a>"
|
||||
|
||||
notification_message = self.config[
|
||||
"join_notification_message"
|
||||
].format(
|
||||
user=evt.sender,
|
||||
room=room_text,
|
||||
room_link=room_link,
|
||||
room_id=evt.room_id
|
||||
user_display = await self._get_user_display_name(evt.room_id, evt.sender)
|
||||
notification_text, notification_html = self._render_message_template(
|
||||
self.config["join_notification_message"],
|
||||
evt.sender,
|
||||
user_display,
|
||||
evt.room_id,
|
||||
room_text,
|
||||
)
|
||||
await self.client.send_notice(
|
||||
self.config["notification_room"], html=notification_message
|
||||
self.config["notification_room"],
|
||||
notification_text,
|
||||
html=notification_html,
|
||||
)
|
||||
|
||||
# Human verification logic
|
||||
@@ -1306,7 +1405,7 @@ class CommunityBot(Plugin):
|
||||
|
||||
@event.on(EventType.REACTION)
|
||||
async def handle_reactions(self, evt: MessageEvent) -> None:
|
||||
|
||||
|
||||
if evt.sender == self.client.mxid:
|
||||
return
|
||||
|
||||
@@ -1331,29 +1430,30 @@ class CommunityBot(Plugin):
|
||||
return
|
||||
|
||||
target_event_id = relates_to.event_id
|
||||
|
||||
|
||||
if target_event_id not in self._report_counts:
|
||||
self._report_counts[target_event_id] = set()
|
||||
|
||||
|
||||
if evt.sender in self._report_counts[target_event_id]:
|
||||
return
|
||||
|
||||
|
||||
self._report_counts[target_event_id].add(evt.sender)
|
||||
current_reports = len(self._report_counts[target_event_id])
|
||||
|
||||
try:
|
||||
roomnamestate = await self.client.get_state_event(evt.room_id, "m.room.name")
|
||||
# Wir nennen es intern erst einmal room_text
|
||||
room_text = roomnamestate.get("name") if roomnamestate else str(evt.room_id)
|
||||
except:
|
||||
except Exception:
|
||||
room_text = str(evt.room_id)
|
||||
|
||||
# Klickable Links
|
||||
room = room_text
|
||||
room_link = f"<a href='https://matrix.to/#/{evt.room_id}'>{room_text}</a>"
|
||||
message_link = f"https://matrix.to/#/{evt.room_id}/{target_event_id}"
|
||||
|
||||
# --- AUTO-REDACT LOGIC ---
|
||||
reporter_display = await self._get_user_display_name(evt.room_id, evt.sender)
|
||||
room_url = self._matrix_to_url(evt.room_id)
|
||||
message_url = self._matrix_to_url(f"{evt.room_id}/{target_event_id}")
|
||||
|
||||
room_link_html = self._render_html_link(evt.room_id, room_text)
|
||||
reporter_link_html = self._render_html_link(evt.sender, reporter_display)
|
||||
message_link_html = f"<a href='{escape(message_url, quote=True)}'>Original Event Link</a>"
|
||||
|
||||
if self.config.get("auto_redact_majority", False):
|
||||
try:
|
||||
members = await self.client.get_joined_members(evt.room_id)
|
||||
@@ -1362,33 +1462,53 @@ class CommunityBot(Plugin):
|
||||
|
||||
if current_reports > threshold:
|
||||
await self.client.redact(
|
||||
evt.room_id,
|
||||
target_event_id,
|
||||
evt.room_id,
|
||||
target_event_id,
|
||||
reason=f"Auto-redacted: Reached majority vote ({current_reports}/{human_count} users)"
|
||||
)
|
||||
|
||||
notification = (
|
||||
f"<b>Message Auto-Redacted</b> 🗑️<br>"
|
||||
f"<b>Room:</b> {room_link}<br>"
|
||||
f"<b>Reason:</b> Community majority vote reached ({current_reports} out of {human_count} members).<br>"
|
||||
f"<b>Context:</b> <a href='{message_link}'>Original Event Link</a>"
|
||||
|
||||
notification_text = (
|
||||
"Message Auto-Redacted 🗑️\n"
|
||||
f"Room: {room_url}\n"
|
||||
f"Reason: Community majority vote reached ({current_reports} out of {human_count} members).\n"
|
||||
f"Context: {message_url}"
|
||||
)
|
||||
await self.client.send_notice(self.config["notification_room"], html=notification)
|
||||
|
||||
notification_html = (
|
||||
f"<b>Message Auto-Redacted</b> 🗑️<br>"
|
||||
f"<b>Room:</b> {room_link_html}<br>"
|
||||
f"<b>Reason:</b> Community majority vote reached ({current_reports} out of {human_count} members).<br>"
|
||||
f"<b>Context:</b> {message_link_html}"
|
||||
)
|
||||
await self.client.send_notice(
|
||||
self.config["notification_room"],
|
||||
notification_text,
|
||||
html=notification_html,
|
||||
)
|
||||
|
||||
del self._report_counts[target_event_id]
|
||||
return
|
||||
except Exception as e:
|
||||
self.log.error(f"Failed to auto-redact reported message: {e}")
|
||||
|
||||
if current_reports == 1:
|
||||
notification = (
|
||||
notification_text = (
|
||||
"Message Reported 🚨\n"
|
||||
f"First Reporter: {reporter_display} ({evt.sender})\n"
|
||||
f"Room: {room_url}\n"
|
||||
f"Action: {message_url}"
|
||||
)
|
||||
notification_html = (
|
||||
f"<b>Message Reported</b> 🚨<br>"
|
||||
f"<b>First Reporter:</b> {evt.sender}<br>"
|
||||
f"<b>Room:</b> {room_link}<br>"
|
||||
f"<b>Action:</b> <a href='{message_link}'>Click here to inspect and moderate</a>"
|
||||
f"<b>First Reporter:</b> {reporter_link_html} ({escape(evt.sender)})<br>"
|
||||
f"<b>Room:</b> {room_link_html}<br>"
|
||||
f"<b>Action:</b> <a href='{escape(message_url, quote=True)}'>Click here to inspect and moderate</a>"
|
||||
)
|
||||
try:
|
||||
await self.client.send_notice(self.config["notification_room"], html=notification)
|
||||
await self.client.send_notice(
|
||||
self.config["notification_room"],
|
||||
notification_text,
|
||||
html=notification_html,
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.error(f"Failed to send report notification: {e}")
|
||||
|
||||
@@ -1784,7 +1904,7 @@ class CommunityBot(Plugin):
|
||||
kick_list[user].append(roomname)
|
||||
else:
|
||||
kick_list[user].append(room)
|
||||
time.sleep(self.config["sleep"])
|
||||
await self._sleep_if_configured(self.config["sleep"])
|
||||
except MNotFound:
|
||||
pass
|
||||
except Exception as e:
|
||||
@@ -1934,8 +2054,9 @@ class CommunityBot(Plugin):
|
||||
)
|
||||
|
||||
if evt:
|
||||
room_alias = f"#{alias_localpart}:{server}"
|
||||
await evt.respond(
|
||||
f"<a href='https://matrix.to/#/#{alias_localpart}:{server}'>#{alias_localpart}:{server}</a> has been created and added to the space.",
|
||||
f"{self._render_html_link(room_alias, room_alias)} has been created and added to the space.",
|
||||
edits=mymsg,
|
||||
allow_html=True,
|
||||
)
|
||||
@@ -1980,7 +2101,7 @@ class CommunityBot(Plugin):
|
||||
return
|
||||
|
||||
# Check if community slug is configured
|
||||
if not self.config["community_slug"]:
|
||||
if self.config["use_community_slug"] and not self.config["community_slug"]:
|
||||
await evt.reply(
|
||||
"No community slug configured. Please run initialize command first."
|
||||
)
|
||||
@@ -2186,7 +2307,7 @@ class CommunityBot(Plugin):
|
||||
aliases_to_transfer = await self.remove_room_aliases(room_id, evt)
|
||||
|
||||
# Check if community slug is configured
|
||||
if not self.config["community_slug"]:
|
||||
if self.config["use_community_slug"] and not self.config["community_slug"]:
|
||||
await evt.respond(
|
||||
"No community slug configured. Please run initialize command first."
|
||||
)
|
||||
@@ -3124,7 +3245,7 @@ class CommunityBot(Plugin):
|
||||
|
||||
try:
|
||||
# Generate community slug if not already set
|
||||
if not self.config["community_slug"]:
|
||||
if self.config["use_community_slug"] and 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}")
|
||||
@@ -3306,11 +3427,12 @@ class CommunityBot(Plugin):
|
||||
|
||||
await evt.respond(
|
||||
f"Community space initialized successfully!<br /><br />"
|
||||
f"Community Slug: {self.config['community_slug']}<br />"
|
||||
f"Room Version: {self.config['room_version']}<br />"
|
||||
f"Space: <a href='https://matrix.to/#/{space_alias}'>{space_alias}</a><br />"
|
||||
f"Moderators Room: <a href='https://matrix.to/#/{mod_room_alias}'>{mod_room_alias}</a><br />"
|
||||
f"Waiting Room: <a href='https://matrix.to/#/{waiting_room_alias}'>{waiting_room_alias}</a>{warning_msg}",
|
||||
f"Community Slug: {escape(str(self.config['community_slug']))}<br />"
|
||||
f"Use Community Slug: {escape(str(self.config['use_community_slug']))}"
|
||||
f"Room Version: {escape(str(self.config['room_version']))}<br />"
|
||||
f"Space: {self._render_html_link(space_alias, space_alias)}<br />"
|
||||
f"Moderators Room: {self._render_html_link(mod_room_alias, mod_room_alias)}<br />"
|
||||
f"Waiting Room: {self._render_html_link(waiting_room_alias, waiting_room_alias)}{warning_msg}",
|
||||
edits=msg,
|
||||
allow_html=True,
|
||||
)
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+15
-1
@@ -99,6 +99,14 @@ class ConfigManager:
|
||||
"""
|
||||
return self.config.get("community_slug")
|
||||
|
||||
def get_use_community_slug(self) -> Optional[str]:
|
||||
"""Get the community slug suffix setting.
|
||||
|
||||
Returns:
|
||||
bool: Whether to use the community slug as a room suffix
|
||||
"""
|
||||
return self.config.get("use_community_slug")
|
||||
|
||||
def get_parent_room(self) -> Optional[str]:
|
||||
"""Get the parent room ID.
|
||||
|
||||
@@ -201,7 +209,12 @@ class ConfigManager:
|
||||
Returns:
|
||||
List[str]: List of missing required configuration keys
|
||||
"""
|
||||
required_configs = ["parent_room", "room_version", "community_slug"]
|
||||
required_configs = [
|
||||
"parent_room",
|
||||
"room_version",
|
||||
"community_slug",
|
||||
"use_community_slug",
|
||||
]
|
||||
|
||||
missing = []
|
||||
for config_key in required_configs:
|
||||
@@ -231,6 +244,7 @@ class ConfigManager:
|
||||
return {
|
||||
"room_version": self.get_room_version(),
|
||||
"community_slug": self.get_community_slug(),
|
||||
"use_community_slug": self.get_use_community_slug(),
|
||||
"invitees": self.get_invitees(),
|
||||
"invite_power_level": self.get_invite_power_level(),
|
||||
"encrypt": self.is_encryption_enabled(),
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+5
-2
@@ -38,7 +38,7 @@ async def validate_room_creation_params(
|
||||
sanitized_name = re.sub(r"[^a-zA-Z0-9]", "", roomname).lower()
|
||||
|
||||
# Check if community slug is configured
|
||||
if not config.get("community_slug", ""):
|
||||
if config.get("use_community_slug", True) and not config.get("community_slug", ""):
|
||||
error_msg = "No community slug configured. Please run initialize command first."
|
||||
return sanitized_name, force_encryption, force_unencryption, error_msg, roomname
|
||||
|
||||
@@ -63,7 +63,10 @@ async def prepare_room_creation_data(
|
||||
Tuple of (alias_localpart, server, room_invitees, parent_room)
|
||||
"""
|
||||
# Create alias with community slug
|
||||
alias_localpart = f"{sanitized_name}-{config.get('community_slug', '')}"
|
||||
if config.get("use_community_slug", True):
|
||||
alias_localpart = f"{sanitized_name}-{config.get('community_slug', '')}"
|
||||
else:
|
||||
alias_localpart = sanitized_name
|
||||
|
||||
# Get server and invitees
|
||||
server = client.parse_user_id(client.mxid)[1]
|
||||
|
||||
Regular → Executable
+11
-3
@@ -31,7 +31,11 @@ async def validate_room_alias(client, alias_localpart: str, server: str) -> bool
|
||||
|
||||
|
||||
async def validate_room_aliases(
|
||||
client, room_names: list[str], community_slug: str, server: str
|
||||
client,
|
||||
room_names: list[str],
|
||||
community_slug: str,
|
||||
use_community_slug: bool,
|
||||
server: str,
|
||||
) -> Tuple[bool, List[str]]:
|
||||
"""Validate that all room aliases are available.
|
||||
|
||||
@@ -39,12 +43,13 @@ async def validate_room_aliases(
|
||||
client: Matrix client instance
|
||||
room_names: List of room names to validate
|
||||
community_slug: The community slug to append
|
||||
use_community_slug: Whether to append a community slug
|
||||
server: The server domain
|
||||
|
||||
Returns:
|
||||
tuple: (is_valid, list_of_conflicting_aliases)
|
||||
"""
|
||||
if not community_slug:
|
||||
if use_community_slug and not community_slug:
|
||||
return False, []
|
||||
|
||||
conflicting_aliases = []
|
||||
@@ -54,7 +59,10 @@ async def validate_room_aliases(
|
||||
from .message_utils import sanitize_room_name
|
||||
|
||||
sanitized_name = sanitize_room_name(room_name)
|
||||
alias_localpart = f"{sanitized_name}-{community_slug}"
|
||||
if use_community_slug:
|
||||
alias_localpart = f"{sanitized_name}-{community_slug}"
|
||||
else:
|
||||
alias_localpart = sanitized_name
|
||||
|
||||
# Check if alias is available
|
||||
is_available = await validate_room_alias(client, alias_localpart, server)
|
||||
|
||||
Regular → Executable
Regular → Executable
+18
-5
@@ -67,6 +67,9 @@ plugin_config:
|
||||
# leave blank to generate an acronym of your community name during initialization
|
||||
community_slug: ''
|
||||
|
||||
# use_community_slug
|
||||
# whether to use the community slug as a suffix for room aliases
|
||||
use_community_slug: true
|
||||
|
||||
# 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
|
||||
@@ -112,16 +115,21 @@ plugin_config:
|
||||
|
||||
# auto-greet users in rooms with these messages
|
||||
# map greeting messages to a room
|
||||
# you can use {user} to reference the joining user in this message using a
|
||||
# matrix.to link (rendered as a "pill" in element clients)
|
||||
# available placeholders:
|
||||
# - {user}: display name of the joining user (falls back to localpart or user ID)
|
||||
# - {user_id}: full Matrix user ID
|
||||
# - {user_link}: clickable matrix.to-compatible link to the joining user
|
||||
# - {room}: room name (or room ID if no name is set)
|
||||
# - {room_link}: clickable matrix.to-compatible link to the room
|
||||
# - {room_id}: raw room ID
|
||||
# html formatting is supported
|
||||
# set to {} if you don't care about greetings
|
||||
greetings:
|
||||
generic: |
|
||||
Welcome {user}! Please be sure to read the topic for helpful links and information.
|
||||
Welcome {user_link}! Please be sure to read the topic for helpful links and information.
|
||||
Use <a href="https://google.com">Google</a> for all other queries ;)
|
||||
encrypted: |
|
||||
welcome {user}, this is an encrypted room, so you may not be able to see messages previously sent here. don't be
|
||||
welcome {user_link}, this is an encrypted room, so you may not be able to see messages previously sent here. don't be
|
||||
alarmed.
|
||||
|
||||
# which of the above greetings should be used in which rooms? use the exact name of each greeting
|
||||
@@ -142,7 +150,12 @@ plugin_config:
|
||||
|
||||
# message to send to the notification room when someone joins one of the above rooms:
|
||||
join_notification_message: |
|
||||
User <code>{user}</code> has joined <code>{room}</code>.
|
||||
{user_link} has joined {room_link} ({user_id}).
|
||||
|
||||
# Base URL for Matrix permalink generation.
|
||||
# This is used for placeholders such as {user_link} and {room_link}.
|
||||
# Set this to your own matrix.to-compatible instance if you do not want to use https://matrix.to.
|
||||
matrix_to_base_url: "https://matrix.to"
|
||||
|
||||
# whether to censor files/messages
|
||||
# can be boolean (true/false) for all-or-nothing behavior,
|
||||
|
||||
Regular → Executable
+1
-1
@@ -1,6 +1,6 @@
|
||||
maubot: 0.1.0
|
||||
id: org.jobmachine.communitybot
|
||||
version: 0.4.1
|
||||
version: 0.5.0
|
||||
license: MIT
|
||||
modules:
|
||||
- community
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+142
@@ -264,6 +264,47 @@ class TestBotEvents:
|
||||
# Should update user timestamp
|
||||
real_bot.upsert_user_timestamp.assert_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_newjoin_notification_supports_user_link(self, bot, mock_state_evt):
|
||||
"""Test join notification formatting with user_link placeholder."""
|
||||
from community.bot import CommunityBot
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = {
|
||||
**bot.config,
|
||||
"greeting_rooms": {"!room:example.com": "none"},
|
||||
"greetings": {},
|
||||
"notification_room": "!notifications:example.com",
|
||||
"join_notification_message": "{user_link} joined {room_link} ({room_id})"
|
||||
}
|
||||
real_bot.client = bot.client
|
||||
real_bot.database = bot.database
|
||||
real_bot.log = bot.log
|
||||
|
||||
real_bot.client.parse_user_id = Mock(return_value=("alice", "example.com"))
|
||||
room_name_state = Mock()
|
||||
room_name_state.name = "Test Room"
|
||||
real_bot.client.get_state_event = AsyncMock(return_value=room_name_state)
|
||||
real_bot.client.send_notice = AsyncMock()
|
||||
real_bot.database.execute = AsyncMock()
|
||||
|
||||
mock_state_evt.source = 0
|
||||
|
||||
with patch.object(real_bot, 'get_space_roomlist', return_value=["!room:example.com"]), \
|
||||
patch.object(real_bot, 'check_if_banned', return_value=False), \
|
||||
patch.object(real_bot, 'upsert_user_timestamp', return_value=None):
|
||||
await real_bot.newjoin(mock_state_evt)
|
||||
|
||||
real_bot.client.send_notice.assert_called_once()
|
||||
args, kwargs = real_bot.client.send_notice.call_args
|
||||
assert args[0] == "!notifications:example.com"
|
||||
assert args[1] == "https://matrix.to/#/@user:example.com joined https://matrix.to/#/!room:example.com (!room:example.com)"
|
||||
assert "https://matrix.to/#/@user:example.com" in kwargs["html"]
|
||||
assert ">alice</a>" in kwargs["html"]
|
||||
assert "https://matrix.to/#/!room:example.com" in kwargs["html"]
|
||||
assert ">Test Room</a>" in kwargs["html"]
|
||||
assert "(!room:example.com)" in kwargs["html"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_message_timestamp_tracking_enabled(self, bot, mock_message_evt):
|
||||
"""Test message timestamp update with tracking enabled."""
|
||||
@@ -450,3 +491,104 @@ class TestBotEvents:
|
||||
|
||||
# Should not update user timestamp
|
||||
real_bot.upsert_user_timestamp.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sleep_if_configured_uses_asyncio_sleep(self):
|
||||
"""Test that configured delays use asyncio.sleep without blocking."""
|
||||
real_bot = CommunityBot()
|
||||
with patch("community.bot.asyncio.sleep", new=AsyncMock()) as sleep_mock:
|
||||
await real_bot._sleep_if_configured(1.5)
|
||||
sleep_mock.assert_awaited_once_with(1.5)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_newjoin_uses_async_delay_for_greeting(self, bot, mock_state_evt):
|
||||
"""Test that join greetings use the async delay helper."""
|
||||
real_bot = CommunityBot()
|
||||
real_bot.client = Mock()
|
||||
real_bot.client.parse_user_id.return_value = ("alice", "example.com")
|
||||
real_bot.client.send_notice = AsyncMock()
|
||||
real_bot.client.get_state_event = AsyncMock(return_value=Mock(name="Test Room"))
|
||||
real_bot.database = bot.database
|
||||
real_bot.log = bot.log
|
||||
real_bot.config = {
|
||||
**bot.config,
|
||||
"parent_room": "!parent:example.com",
|
||||
"greeting_rooms": {"!room:example.com": "default"},
|
||||
"greetings": {"default": "Welcome {user}"},
|
||||
"welcome_sleep": 2,
|
||||
"notification_room": "!notif:example.com",
|
||||
"join_notification_message": "{user_link} joined {room_link}",
|
||||
}
|
||||
real_bot._sleep_if_configured = AsyncMock()
|
||||
real_bot.check_if_banned = AsyncMock(return_value=False)
|
||||
real_bot.ban_this_user = AsyncMock()
|
||||
real_bot.do_sync = AsyncMock()
|
||||
real_bot.get_space_roomlist = AsyncMock(return_value=["!room:example.com"])
|
||||
|
||||
await real_bot.newjoin(mock_state_evt)
|
||||
|
||||
real_bot._sleep_if_configured.assert_awaited_once_with(2)
|
||||
assert real_bot.client.send_notice.await_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_kick_uses_async_delay(self, bot):
|
||||
"""Test that user kicking uses the async delay helper between rooms."""
|
||||
real_bot = CommunityBot()
|
||||
real_bot.client = Mock()
|
||||
real_bot.client.get_state_event = AsyncMock(side_effect=[
|
||||
{"name": "Room One"},
|
||||
{}, # membership lookup
|
||||
])
|
||||
real_bot.client.kick_user = AsyncMock()
|
||||
real_bot.database = bot.database
|
||||
real_bot.log = bot.log
|
||||
real_bot.config = {**bot.config, "sleep": 0.5, "parent_room": "!room:example.com"}
|
||||
real_bot._sleep_if_configured = AsyncMock()
|
||||
|
||||
evt = Mock(spec=MessageEvent)
|
||||
evt.mark_read = AsyncMock()
|
||||
evt.respond = AsyncMock()
|
||||
evt.reply = AsyncMock()
|
||||
|
||||
with patch.object(real_bot, "get_space_roomlist", AsyncMock(return_value=[])):
|
||||
await real_bot.user_kick(evt, "@user:example.com")
|
||||
|
||||
real_bot._sleep_if_configured.assert_awaited_once_with(0.5)
|
||||
|
||||
def test_render_message_template_uses_consistent_user_placeholders(self):
|
||||
"""{user} should stay plain while {user_link} becomes clickable."""
|
||||
real_bot = CommunityBot()
|
||||
|
||||
plain_text, html_text = real_bot._render_message_template(
|
||||
"{user} | {user_link} | {room} | {room_link} | {room_id}",
|
||||
"@alice:example.com",
|
||||
"alice",
|
||||
"!room:example.com",
|
||||
"Test Room",
|
||||
)
|
||||
|
||||
assert plain_text == (
|
||||
"@alice:example.com | https://matrix.to/#/@alice:example.com | "
|
||||
"Test Room | https://matrix.to/#/!room:example.com | !room:example.com"
|
||||
)
|
||||
assert "@alice:example.com" in html_text
|
||||
assert "<a href='https://matrix.to/#/@alice:example.com'>alice</a>" in html_text
|
||||
assert "<a href='https://matrix.to/#/!room:example.com'>Test Room</a>" in html_text
|
||||
|
||||
def test_render_message_template_without_room_keeps_room_placeholders_safe(self):
|
||||
"""Greeting templates without room data should not break placeholder rendering."""
|
||||
real_bot = CommunityBot()
|
||||
|
||||
plain_text, html_text = real_bot._render_message_template(
|
||||
"Welcome {user} / {user_link}",
|
||||
"@alice:example.com",
|
||||
"alice",
|
||||
)
|
||||
|
||||
assert plain_text == "Welcome @alice:example.com / https://matrix.to/#/@alice:example.com"
|
||||
assert html_text == (
|
||||
"Welcome @alice:example.com / "
|
||||
"<a href='https://matrix.to/#/@alice:example.com'>alice</a>"
|
||||
)
|
||||
|
||||
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+49
-16
@@ -46,36 +46,69 @@ class TestRoomUtils:
|
||||
assert result == True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_room_aliases_no_slug(self):
|
||||
async def test_validate_room_aliases_slug_not_required_with_no_slug(self):
|
||||
"""Test alias validation without community slug."""
|
||||
client = Mock()
|
||||
|
||||
result = await validate_room_aliases(client, ["room1", "room2"], "", "example.com")
|
||||
assert result == (False, [])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_room_aliases_success(self):
|
||||
"""Test successful alias validation."""
|
||||
client = Mock()
|
||||
client.resolve_room_alias = AsyncMock(side_effect=MNotFound("Room not found", 404))
|
||||
|
||||
result = await validate_room_aliases(client, ["room1", "room2"], "test", "example.com")
|
||||
result = await validate_room_aliases(client, ["room1", "room2"], "", False, "example.com")
|
||||
assert result == (True, [])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_room_aliases_conflicts(self):
|
||||
async def test_validate_room_aliases_slug_not_required_with_slug(self):
|
||||
"""Test successful alias validation."""
|
||||
client = Mock()
|
||||
client.resolve_room_alias = AsyncMock(side_effect=MNotFound("Room not found", 404))
|
||||
|
||||
result = await validate_room_aliases(client, ["room1", "room2"], "test", False, "example.com")
|
||||
assert result == (True, [])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_room_aliases_slug_required_with_no_slug(self):
|
||||
"""Test alias validation without community slug."""
|
||||
client = Mock()
|
||||
|
||||
result = await validate_room_aliases(client, ["room1", "room2"], "", True, "example.com")
|
||||
assert result == (False, [])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_room_aliases_slug_required_with_slug(self):
|
||||
"""Test successful alias validation."""
|
||||
client = Mock()
|
||||
client.resolve_room_alias = AsyncMock(side_effect=MNotFound("Room not found", 404))
|
||||
|
||||
result = await validate_room_aliases(client, ["room1", "room2"], "test", True, "example.com")
|
||||
assert result == (True, [])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_room_aliases_conflicts_slug_not_required(self):
|
||||
"""Test alias validation with conflicts."""
|
||||
client = Mock()
|
||||
|
||||
|
||||
def resolve_side_effect(alias):
|
||||
if "room1" in alias:
|
||||
return {"room_id": "!room1:example.com"} # Exists
|
||||
else:
|
||||
raise MNotFound() # Doesn't exist
|
||||
|
||||
|
||||
client.resolve_room_alias = AsyncMock(side_effect=resolve_side_effect)
|
||||
|
||||
result = await validate_room_aliases(client, ["room1", "room2"], "test", "example.com")
|
||||
|
||||
result = await validate_room_aliases(client, ["room1", "room2"], "", False, "example.com")
|
||||
assert result == (False, ["#room1:example.com"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_room_aliases_conflicts_slug_required(self):
|
||||
"""Test alias validation with conflicts."""
|
||||
client = Mock()
|
||||
|
||||
def resolve_side_effect(alias):
|
||||
if "room1" in alias:
|
||||
return {"room_id": "!room1:example.com"} # Exists
|
||||
else:
|
||||
raise MNotFound() # Doesn't exist
|
||||
|
||||
client.resolve_room_alias = AsyncMock(side_effect=resolve_side_effect)
|
||||
|
||||
result = await validate_room_aliases(client, ["room1", "room2"], "test", True, "example.com")
|
||||
assert result == (False, ["#room1-test:example.com"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Regular → Executable
Executable
+57
@@ -0,0 +1,57 @@
|
||||
"""Tests for notification and greeting template rendering."""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from mautrix.types import EventType, RoomID
|
||||
|
||||
from community.bot import CommunityBot
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bot():
|
||||
bot = CommunityBot.__new__(CommunityBot)
|
||||
bot.client = Mock()
|
||||
bot.config = {"matrix_to_base_url": "https://matrix.to"}
|
||||
return bot
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_display_name_uses_member_displayname(bot):
|
||||
member_state = Mock()
|
||||
member_state.displayname = "Alice"
|
||||
bot.client.get_state_event = AsyncMock(return_value=member_state)
|
||||
|
||||
result = await bot._get_user_display_name(RoomID("!room:example.org"), "@alice:example.org")
|
||||
|
||||
assert result == "Alice"
|
||||
bot.client.get_state_event.assert_awaited_once_with(
|
||||
RoomID("!room:example.org"),
|
||||
EventType.ROOM_MEMBER,
|
||||
state_key="@alice:example.org",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_display_name_falls_back_to_localpart(bot):
|
||||
bot.client.get_state_event = AsyncMock(side_effect=Exception("missing"))
|
||||
bot.client.parse_user_id = Mock(return_value=("alice", "example.org"))
|
||||
|
||||
result = await bot._get_user_display_name(RoomID("!room:example.org"), "@alice:example.org")
|
||||
|
||||
assert result == "alice"
|
||||
|
||||
|
||||
def test_render_message_template_supports_user_id_and_user_link(bot):
|
||||
plain, html = bot._render_message_template(
|
||||
"{user} / {user_id} / {user_link}",
|
||||
"@alice:example.org",
|
||||
"Alice",
|
||||
"!room:example.org",
|
||||
"General",
|
||||
)
|
||||
|
||||
assert plain == "Alice / @alice:example.org / https://matrix.to/#/@alice:example.org"
|
||||
assert "Alice / @alice:example.org / " in html
|
||||
assert '<a href=' in html
|
||||
assert '>Alice</a>' in html
|
||||
Regular → Executable
Reference in New Issue
Block a user