Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e9a67d7ff5 | |||
| ee3bb9f22e | |||
| 01ff07d027 | |||
| 807a151b94 | |||
| 18f863b535 | |||
| f477cfb5a5 | |||
| 84403dc3fb | |||
| b5bc8075f0 | |||
| 1669bbd766 | |||
| edd3eee178 | |||
| 933865d80c | |||
| b2541c4054 | |||
| bc490bd084 | |||
| 1e653c60e3 |
@@ -1,251 +1,161 @@
|
||||
# Community Bot
|
||||
[](https://matrix.to/#/#dev:mssj.me)
|
||||
<p align="center">
|
||||
<a href="https://ztfr.eu/matrix">
|
||||
<img src="assets/community-badge.png" alt="Join Zeitfresser Matrix Community" height="70" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
a maubot plugin that helps administrators of communities on matrix, based on the concept of a matrix space. you may want
|
||||
to leverage [join](https://github.com/williamkray/maubot-join) to ensure your bot doesn't end up somewhere it's not
|
||||
supposed to be.
|
||||
# Advanced Community Bot
|
||||
|
||||
# important upgrade notes
|
||||
Advanced Community Bot is a powerful Maubot plugin designed to help you manage Matrix communities that are structured around Spaces. It combines moderation tools, automation, and community-driven workflows into a single, opinionated solution that focuses on simplicity, reliability, and clean integration with modern Matrix clients.
|
||||
|
||||
## v0.4.1
|
||||
The plugin is particularly well suited for communities that want strong control over membership, clear moderation flows, and a balance between automation and human oversight.
|
||||
|
||||
📢 Reaction-Based Reporting: Community members can now flag suspicious messages or spam by simply reacting with specific emojis (default: 🚩 or ⚠️). The bot immediately alerts moderators in their private channel with a direct link to the incident.
|
||||
It was originally created as Community Bot by <a href="https://github.com/williamkray/maubot-communitybot">William Kray</a>. This fork uses his bases and adds additional features and refinments to the bot.
|
||||
|
||||
⚖️ Majority-Based Auto-Redaction: For the first time, the bot can act on community consensus. If more than 50% of the human members in a room flag a message, the bot will automatically redact it. This is a game-changer for handling overnight spam attacks when moderators are asleep.
|
||||
---
|
||||
|
||||
🛡️ Sync-Retry Safeguards: We've implemented advanced event tracking to prevent "ghost notifications" caused by Matrix sync retries. This ensures that each report is processed exactly once.
|
||||
## 🛠 What this bot is for
|
||||
|
||||
🧹 Code Refinement: Under the hood, we’ve cleaned up the codebase, fixed indentation issues, and optimized member synchronization performance for larger spaces.
|
||||
Advanced Community Bot is not meant to replace large-scale moderation frameworks like Draupnir or Mjolnir. Instead, it focuses on providing a cohesive set of tools for managing structured communities with minimal overhead.
|
||||
|
||||
🔗 You can now use the new {room_link} variable in your configuration to display a clickable room link, while the existing {room} variable remains fully backward compatible for plain text names.
|
||||
It is a strong fit if you:
|
||||
|
||||
## v0.3
|
||||
- already run Maubot or prefer lightweight, Python-based tooling
|
||||
- manage a space-centric community with multiple rooms
|
||||
- want simple but effective moderation and automation features
|
||||
- prefer an opinionated setup over highly complex configuration
|
||||
|
||||
New functionality to support room v12 and newer has been added, as well as some significant restructuring of the code
|
||||
and commands! v0.3.0 is potentially a breaking change, please make a backup of your old bot configuration and database
|
||||
as necessary before updating in case anything goes horribly wrong. i take no responsibility.
|
||||
Communities that benefit most from this plugin typically follow a structure where a central Space controls access to multiple rooms, often with a mix of private and public entry points.
|
||||
|
||||
commands are now broken up into more logical groupings, so instead of `!community createroom` it's `!community room
|
||||
create`, etc. helpful usage messages are usually passed if you do things wrong so this shouldn't be too complicated but
|
||||
i'm too lazy to update the below readme to reflect the new command structures. use your brain.
|
||||
## 🚀 Core Features
|
||||
|
||||
## v0.2
|
||||
### Community initialization
|
||||
|
||||
if you are upgrading from an earlier version to v0.2.0, please note that the user permission model has changed to be easier to manage, but will require some intervention.
|
||||
The bot can bootstrap an entire community structure from scratch using a single command. It creates a Space, sets up initial rooms, assigns permissions, and prepares a working moderation environment.
|
||||
|
||||
statically defined `admins` and `moderators` in the config will no longer be used. instead, user permissions in rooms will be inherited from the parent space or room, and changes will cascade to all child rooms.
|
||||
This allows you to go from zero to a fully structured community in minutes, following best practices for access control and moderation.
|
||||
|
||||
to migrate, ensure your bot is an admin of the parent space and use the `!community sync` command to make users in your admin and moderator lists appropriately leveled in that parent space. this will also clear out these lists to prepare for deprecation in a later version. you may want to run `!community setpower` to update your child rooms if there are significant changes.
|
||||
### Greetings and join notifications
|
||||
|
||||
# should i use this?
|
||||
The bot can greet users when they join rooms and optionally notify moderators or administrators about new arrivals.
|
||||
|
||||
why does this exist? there are some great tools out there already that do probably a much better job at combatting spam
|
||||
and abuse on matrix, like [Draupnir](https://github.com/the-draupnir-project/Draupnir). this plugin might make sense for
|
||||
you if:
|
||||
Messages support templating and make use of native Matrix pills for users and rooms, resulting in clean, readable, and interactive notifications.
|
||||
|
||||
- you're more interested in basic community management tools (like room creation, user activity tracking, etc)
|
||||
- you already are running Maubot, or plan to
|
||||
- you're afraid of mjolnir/draupnir for some reason
|
||||
- you just really love python and want to contribute to this project
|
||||
### Activity tracking and pruning
|
||||
|
||||
my opinion is that your community should probably be configured with the following restrictions to best align
|
||||
with this plugin's capabilities:
|
||||
User activity is tracked across rooms, allowing you to generate reports on inactive members and take action where needed.
|
||||
|
||||
- your Space is invite-only
|
||||
- most rooms are join-restricted to only allow members of your space
|
||||
- you have a smaller subset of rooms which are publicly facing, where users can join freely and ask admins to be added
|
||||
to the space
|
||||
You can:
|
||||
- identify inactive users
|
||||
- exclude specific accounts from pruning
|
||||
- remove inactive members from your community
|
||||
|
||||
by following this structure, you reduce the amount of surface area you have to spend time defending against spam and
|
||||
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.
|
||||
This is especially useful for keeping invite-only communities clean and manageable over time.
|
||||
|
||||
if that doesn't sound like how you want to structure your online community, you might be better off using something like
|
||||
Draupnir, Meowlnir, or Mjolnir.
|
||||
### User management
|
||||
|
||||
# features
|
||||
Advanced Community Bot provides a full set of tools for managing users across your entire space:
|
||||
|
||||
please read through the comments in the `base-config.yaml` for more thorough explanations, but this covers the high
|
||||
points.
|
||||
- kick users from all rooms
|
||||
- ban and unban users globally
|
||||
- optionally redact recent messages when banning
|
||||
- prevent unauthorized invitations via power level control
|
||||
|
||||
## initialize a community from scratch
|
||||
All actions are applied consistently across your space and its child rooms.
|
||||
|
||||
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:
|
||||
### Crowd moderation
|
||||
|
||||
`!community initialize <your community name>`
|
||||
The bot includes a lightweight, community-driven moderation system.
|
||||
|
||||
this will perform several actions on your behalf:
|
||||
Users can report problematic messages by reacting with configured emojis. Reports are aggregated and forwarded to a moderation room, including direct links to the affected content.
|
||||
|
||||
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
|
||||
If enabled, the bot can automatically redact messages once a majority of users in a room have reported them. This allows communities to react quickly to spam or abuse, even when moderators are not immediately available.
|
||||
|
||||
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!
|
||||
### Moderation workflows
|
||||
|
||||
attempts to run this command once a parent room has been set will fail.
|
||||
Moderation messages, reports, and redactions are designed to be easy to read and interact with.
|
||||
|
||||
## greet new users on joining a room
|
||||
All relevant entities—users, rooms, and events—are linked using native Matrix URIs, allowing moderators to jump directly to the relevant context inside their client.
|
||||
|
||||
configure your bot to send a custom greeting to users whenever they join a room! configuration file provides a greeting
|
||||
map (define multiple greetings each with an identifier) and then a configuration of which rooms to greet users in, and
|
||||
which greeting message the bot should send them.
|
||||
This significantly improves the speed and usability of moderation workflows.
|
||||
|
||||
Configure a `notification_room` to receive messages when someone joins one of the greeting rooms. If you just want
|
||||
notifications (perhaps when someone joins the space, where the bot likely cannot send a greeting anyway) set the
|
||||
greeting name to `'none'` in the greeting map, and the bot will skip the greeting and send a notification to your
|
||||
notification room.
|
||||
### Room management
|
||||
|
||||
## activity tracking and reporting
|
||||
The bot simplifies working with rooms inside a space:
|
||||
|
||||
tracks the last message timestamp of a user across any room that the bot is in, and generates a simple report. intended
|
||||
to be used to boot people from a matrix space and all space rooms after a period of inactivity (prune inactive users)
|
||||
with the `purge` subcommand.
|
||||
- create rooms with consistent settings and permissions
|
||||
- enforce join restrictions based on space membership
|
||||
- automatically invite configured users
|
||||
- manage encryption settings during creation
|
||||
|
||||
supports simple threshold configuration and the option to also track "reaction" activity.
|
||||
Room creation follows a predictable pattern, ensuring consistency across your community.
|
||||
|
||||
you can also exempt users from showing as "inactive" in the report by setting their ignore status with the `ignore` and
|
||||
`unignore` subcommands, e.g. `!community ignore @takinabreak:fromthis.group`. this is helpful to avoid accidentally
|
||||
purging admin accounts, backup accounts, rarely used bots, etc.
|
||||
### Room archival and replacement
|
||||
|
||||
`sync` subcommand will actively sync your space member list with the database to track active members properly. new
|
||||
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.
|
||||
Rooms can be archived or replaced when necessary.
|
||||
|
||||
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.
|
||||
Archiving removes a room from active use while preserving its history. Replacement allows you to create a fresh room while retaining names and aliases, which is useful when permissions become inconsistent or settings need to be reset.
|
||||
|
||||
## user management
|
||||
### Public banlist support
|
||||
|
||||
prevent people from inviting randos to your community rooms and bypassing space membership requirements by setting the
|
||||
`invite_power_level` value in your config. this is used for all room creation commands.
|
||||
The bot can consume external banlists in read-only mode. When users join, they are checked against these lists and automatically banned if necessary.
|
||||
|
||||
purge inactive users with the `purge` subcommand (i.e. `!community purge`).
|
||||
This allows you to integrate with broader moderation ecosystems without managing policies yourself.
|
||||
|
||||
kick an individual user from your space and all child rooms, regardless of activity status, with the `kick` subcommand
|
||||
(e.g. `!community kick @malicious:user.here`). this is useful in communities built on the concept of private (invite
|
||||
only) matrix spaces.
|
||||
### Message redaction and filtering
|
||||
|
||||
if you want more sever action, use the `ban` and `unban` subcommands to ban users from all rooms in the space (this action
|
||||
will automatically kick them from those rooms as well). if you've made a mistake, use the unban option, but they will
|
||||
need to rejoin all rooms themselves or be re-invited.
|
||||
Basic content moderation features are included:
|
||||
|
||||
if configured with the `redact_on_ban` setting, banning a user from your space will also queue up to their last 100 messages in each room for redaction. if not, you can redact their messages in each individual room using the `!community redact` command.
|
||||
- automatic redaction of messages based on keywords
|
||||
- optional blocking/redaction of file uploads
|
||||
- configurable scope (global or per room)
|
||||
|
||||
use the `guests` subcommand to see who is in a room but NOT a member of the parent space (invited guests) e.g.
|
||||
`!community guests #myroom:alias.here`.
|
||||
These tools are intentionally simple and best used in combination with a well-structured community setup.
|
||||
|
||||
## public banlist support
|
||||
### User verification
|
||||
|
||||
initial support for public banlists (as used by other tools like mjolnir/draupnir) is here! this bot leverages
|
||||
banlists in read-only mode, just have your bot join one of these banlist rooms, and it will cross reference new room
|
||||
members against these lists and immediately ban offenders. there is no intention to add new policy creation features
|
||||
to this bot, as those concepts are probably best left to more featureful tools.
|
||||
Optional verification flows can be enabled for specific rooms.
|
||||
|
||||
## crowd moderation
|
||||
New users are required to complete a simple challenge via direct message before being allowed to participate. This can help reduce spam in publicly accessible entry rooms.
|
||||
|
||||
The bot includes a community-driven reporting system that allows users to flag problematic content without needing direct moderator intervention for every incident.
|
||||
## 🧠 Design Philosophy
|
||||
|
||||
### how it works
|
||||
1. **Reporting**: When a user reacts to a message with a configured emoji (e.g., 🚩 or ⚠️), the bot sends a notification to the `notification_room` containing a link to the message and the room name.
|
||||
2. **Auto-Redaction**: If `auto_redact_majority` is enabled, the bot tracks unique reporters per message. If the number of reports exceeds 50% of the human members in that room, the bot automatically redacts the message to prevent further harm.
|
||||
3. **Transparency**: An automated notice is sent to the notification room whenever a message is auto-redacted, citing the community vote as the reason.
|
||||
Advanced Community Bot follows a few key principles:
|
||||
|
||||
## admin/moderator management
|
||||
- **Keep things simple** – avoid unnecessary configuration complexity
|
||||
- **Leverage Matrix-native features** – rely on client capabilities where possible
|
||||
- **Be opinionated** – provide sensible defaults instead of endless options
|
||||
- **Stay maintainable** – prioritize clean structure and predictable behavior
|
||||
|
||||
set consistent power levels across all your rooms for your community administrators! user powerlevels will be
|
||||
cascaded to all rooms when changed in your parent space. running the setpower subcommand (i.e.
|
||||
`!community setpower`) will roll through all rooms in the space and attempt to true-up user
|
||||
permissions to match. it will skip rooms that you have enabled verification flows on, unless you pass the room-id
|
||||
as an argument to the command. this ensures you don't accidentally un-verify everyone unless you mean to.
|
||||
The codebase has been continuously refactored to support these goals, with a strong focus on reducing duplication, improving structure, and making future changes easier.
|
||||
|
||||
if you are running legacy rooms not managed by the bot, and the bot does not have permission to
|
||||
send power-level state events to the room, it will return a list for you to handle manually.
|
||||
## ✨ Modern Matrix-Native Experience
|
||||
|
||||
## room creation
|
||||
One of the core goals of this project is to align closely with how modern Matrix clients behave.
|
||||
|
||||
use the `createroom` subcommand to create a new room according to your preferences, and join it into the parent space.
|
||||
include the `--encrypt` flag in your command to encrypt the room even if the default configuration is to create rooms
|
||||
unencrypted.
|
||||
User and room references throughout the bot are rendered using native `matrix:` URIs instead of legacy `matrix.to` links. This means that interactions happen directly inside the client, without external redirects, resulting in a faster and more seamless experience.
|
||||
|
||||
will attempt to sanitize the room name and assign a room alias automatically. the bot user will be assigned very high
|
||||
power level (1000) and set permissions based on the parent space user power-levels. this ensures that the
|
||||
bot is still able to manage room admins. the bot will also invite other users to these new rooms as configured in the
|
||||
`invitees` list. populate this list with your space admins, other bots, or any other account you want to make sure gets
|
||||
invited to the new room!
|
||||
At the same time, the visual representation has been intentionally simplified. Instead of exposing raw Matrix identifiers, the bot displays clean, human-readable names such as display names and room names. In supporting clients, these elements appear as clickable pills, combining clarity with interactivity.
|
||||
|
||||
rooms created by the bot will have join restriction limited to members of the space.
|
||||
## ⚙️ Configuration
|
||||
|
||||
## room archival and replacement
|
||||
Configuration is handled via `base-config.yaml`.
|
||||
|
||||
use the `archive` subcommand to archive a room. this will remove the room from the parent space, remove all room aliases, and add a tombstone event to indicate the room is archived
|
||||
Templates support placeholders such as `{user}`, `{room}`, `{user_id}`, and `{room_link}`. The `{user}` and `{room}` placeholders render as clickable elements using native Matrix URIs, while link-specific placeholders retain their original behavior.
|
||||
|
||||
use the `replaceroom` subcommand to replace an existing room with a new one. this is useful when:
|
||||
- room members have power levels that cannot be corrected, or room members you cannot kick out
|
||||
- you need to revert encryption settings
|
||||
- you want to start fresh with a new room while preserving the old room's name and aliases
|
||||
The configuration surface is intentionally kept minimal. Advanced customization can still be achieved by adjusting internal constants if needed.
|
||||
|
||||
the replacement process will create a new room with the same name and avatar, transfer all room aliases to the new room, and archive the old room with a pointer to the new room. the new room will have standard join rules that restrict membership to space members. this logic is a little clunky, but it seems to work.
|
||||
## 📦 Installation
|
||||
|
||||
replacement will also prompt the bot to review its config, and rotate instances of the old room-id with the new room id to retain
|
||||
functionality where necessary.
|
||||
Install the plugin like any other Maubot plugin:
|
||||
|
||||
## get room ID
|
||||
- package it using `mbc build` or use the pre ompiled .mbp file in the release section.
|
||||
- upload it via the Maubot web interface
|
||||
|
||||
sometimes you need to know a rooms identifier, but if the room has an alias associated with it not all clients make it
|
||||
easy (or possible) to find. this subcommand (`!community roomid`) can be used to return the room id that a room alias
|
||||
points to. with no argument passed, it will return the current room's ID, or you can pass it an alias (e.g. `!community
|
||||
roomid #whatisthisroom:myserver.tld`).
|
||||
Make sure the bot has sufficient permissions in your rooms (especially for kicking, banning, and redacting messages), otherwise some features will not function correctly.
|
||||
|
||||
## message redaction
|
||||
## 🧭 Final Notes
|
||||
|
||||
the bot can be configured to redact messages automatically to protect your users. set `censor` to either `true`,
|
||||
`false`, or a list of room IDs to enable censorship in.
|
||||
Advanced Community Bot aims to strike a balance between usability and control. It provides the tools needed to manage a structured Matrix community effectively, without overwhelming administrators with complexity.
|
||||
|
||||
set `censor_files` to have the bot immediately redact file uploads in any censored rooms. define trigger words in
|
||||
`censor_wordlist` to flag messages for automatic redaction.
|
||||
|
||||
please keep in mind that wordlist-based censorship is problematic and may redact false positives. writing a matching
|
||||
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. every permitted user goes in the state event, so this
|
||||
will become problematic and expensive for very large rooms... strong recommend not to
|
||||
use this if you expect to have thousands of room members.
|
||||
|
||||
if you enable user verification in an existing room, but you don't want to disrupt the
|
||||
current users' ability to send messages, you can use the `!community verify-migrate`
|
||||
command to set permissions correctly. **DO NOT DO THIS IN LARGE ROOMS**. if you have more
|
||||
than a handful of people, consider how many of them actually say anything in a given day
|
||||
and whether or not it's worth filling your state event with them. consider alternative
|
||||
options.
|
||||
|
||||
# installation
|
||||
|
||||
install this like any other maubot plugin: zip the contents of this repo into a file and upload via the web interface,
|
||||
or use the `mbc` utility to package and upload to your maubot server.
|
||||
|
||||
be sure to give your bot permission to kick people from all rooms, otherwise management features will not work!
|
||||
If your use case requires highly advanced policy management or federation-wide moderation, you may want to look at tools like Draupnir. For most community-centric setups, however, this bot offers a practical and efficient solution.
|
||||
|
||||
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 277 KiB |
+24
-3
@@ -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,8 +58,13 @@ 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:
|
||||
@@ -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} has joined {room}.
|
||||
|
||||
# whether to censor files/messages
|
||||
# can be boolean (true/false) for all-or-nothing behavior,
|
||||
@@ -173,3 +189,8 @@ 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"
|
||||
|
||||
+455
-356
@@ -1,14 +1,20 @@
|
||||
# kickbot - a maubot plugin to track user activity and remove inactive users from rooms/spaces.
|
||||
|
||||
from typing import Awaitable, Type, Optional, Tuple, Dict
|
||||
from typing import Awaitable, Type, Optional, Tuple, Dict, Any
|
||||
import json
|
||||
import time
|
||||
import re
|
||||
import fnmatch
|
||||
import asyncio
|
||||
from html import escape
|
||||
from urllib.parse import quote
|
||||
import random
|
||||
import asyncpg.exceptions
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass
|
||||
|
||||
DEFAULT_USER_PILL_PREFIX = ""
|
||||
DEFAULT_ROOM_PILL_PREFIX = ""
|
||||
|
||||
from mautrix.client import (
|
||||
Client,
|
||||
@@ -65,12 +71,22 @@ from .helpers import (
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RenderContext:
|
||||
user_id: str
|
||||
user_display: str
|
||||
room_id: Optional[str] = None
|
||||
room_text: Optional[str] = None
|
||||
event_id: Optional[str] = None
|
||||
|
||||
|
||||
class Config(BaseProxyConfig):
|
||||
def do_update(self, helper: ConfigUpdateHelper) -> None:
|
||||
helper.copy("sleep")
|
||||
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 +94,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,25 +117,376 @@ class Config(BaseProxyConfig):
|
||||
|
||||
class CommunityBot(Plugin):
|
||||
|
||||
_redaction_tasks: asyncio.Task = None
|
||||
_verification_states: Dict[str, Dict] = {}
|
||||
_report_counts: Dict[str, set] = {}
|
||||
def _get_matrix_to_base_url(self) -> str:
|
||||
return str(self.config.get("matrix_to_base_url", "https://matrix.to")).rstrip("/")
|
||||
|
||||
|
||||
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>"
|
||||
|
||||
|
||||
def _encode_matrix_uri_part(self, value: str, sigils: str = "@!#$") -> str:
|
||||
"""Encode an MXID-like identifier for safe use inside a matrix: URI path."""
|
||||
return quote(str(value).lstrip(sigils), safe=":.=_+-")
|
||||
|
||||
def _matrix_uri_user(self, user_id: str) -> str:
|
||||
return f"matrix:u/{self._encode_matrix_uri_part(user_id, '@')}?action=chat"
|
||||
|
||||
def _matrix_uri_room(self, room_id: str) -> str:
|
||||
return f"matrix:roomid/{self._encode_matrix_uri_part(room_id, '!')}"
|
||||
|
||||
def _matrix_uri_event(self, room_id: str, event_id: str) -> str:
|
||||
encoded_room_id = self._encode_matrix_uri_part(room_id, "!")
|
||||
encoded_event_id = self._encode_matrix_uri_part(event_id, "$")
|
||||
return f"matrix:roomid/{encoded_room_id}/e/{encoded_event_id}"
|
||||
|
||||
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 as e:
|
||||
self.log.debug(f"Failed to fetch display name for {user_id} in {room_id}: {e}")
|
||||
|
||||
try:
|
||||
return self.client.parse_user_id(user_id)[0]
|
||||
except Exception:
|
||||
return user_id
|
||||
|
||||
|
||||
def _matrix_user_uri(self, user_id: str) -> str:
|
||||
"""Build a Matrix URI for a user."""
|
||||
return self._matrix_uri_user(user_id)
|
||||
|
||||
def _matrix_room_uri(self, room_id: str, room_alias: str | None = None) -> str:
|
||||
"""Build a Matrix URI for a room, preferring canonical alias when available."""
|
||||
if room_alias:
|
||||
return f"matrix:r/{self._encode_matrix_uri_part(room_alias, '#')}"
|
||||
return self._matrix_uri_room(room_id)
|
||||
|
||||
def _matrix_event_uri(self, room_id: str, event_id: str) -> str:
|
||||
"""Build a Matrix URI for an event inside a room."""
|
||||
return self._matrix_uri_event(room_id, event_id)
|
||||
|
||||
|
||||
async def _get_room_display_text(self, room_id: RoomID) -> str:
|
||||
"""Return a human-friendly room name, falling back to the room ID."""
|
||||
try:
|
||||
room_name_event = await self.client.get_state_event(room_id, EventType.ROOM_NAME)
|
||||
room_name = getattr(room_name_event, "name", None)
|
||||
if room_name:
|
||||
return str(room_name)
|
||||
except Exception as e:
|
||||
self.log.debug(f"Failed to fetch room name for {room_id}: {e}")
|
||||
return str(room_id)
|
||||
|
||||
async def _build_render_context(self, room_id: RoomID, user_id: str) -> RenderContext:
|
||||
"""Fetch the values needed for template rendering once per join event."""
|
||||
user_display = await self._get_user_display_name(room_id, user_id)
|
||||
room_text = await self._get_room_display_text(room_id)
|
||||
return RenderContext(
|
||||
user_id=str(user_id),
|
||||
user_display=user_display,
|
||||
room_id=str(room_id),
|
||||
room_text=room_text,
|
||||
)
|
||||
|
||||
async def _send_rendered_notice(
|
||||
self,
|
||||
target_room_id: RoomID,
|
||||
template: str,
|
||||
context: RenderContext,
|
||||
) -> None:
|
||||
"""Render a template once and send plaintext + HTML variants."""
|
||||
plain_text, html_message = self._render_message_template(
|
||||
template,
|
||||
context.user_id,
|
||||
context.user_display,
|
||||
context.room_id,
|
||||
context.room_text,
|
||||
)
|
||||
await self.client.send_notice(target_room_id, plain_text, html=html_message)
|
||||
|
||||
async def _handle_join_notifications(self, evt: StateEvent) -> None:
|
||||
"""Send configured greetings and join notifications for a new member."""
|
||||
room_id = str(evt.room_id)
|
||||
if room_id not in self.config["greeting_rooms"]:
|
||||
return
|
||||
|
||||
greeting_name = self.config["greeting_rooms"][room_id]
|
||||
context = await self._build_render_context(evt.room_id, evt.sender)
|
||||
|
||||
if greeting_name != "none":
|
||||
greeting_map = self.config["greetings"]
|
||||
await self._sleep_if_configured(self.config["welcome_sleep"])
|
||||
await self._send_rendered_notice(evt.room_id, greeting_map[greeting_name], context)
|
||||
|
||||
if self.config["notification_room"]:
|
||||
await self._send_rendered_notice(
|
||||
self.config["notification_room"],
|
||||
self.config["join_notification_message"],
|
||||
context,
|
||||
)
|
||||
|
||||
def _is_human_verification_enabled_for_room(self, room_id: RoomID) -> bool:
|
||||
configured = self.config["check_if_human"]
|
||||
if isinstance(configured, bool):
|
||||
return configured
|
||||
if isinstance(configured, list):
|
||||
return room_id in configured
|
||||
return False
|
||||
|
||||
async def _user_requires_human_verification(
|
||||
self, user_id: UserID, room_id: RoomID
|
||||
) -> Optional[int]:
|
||||
"""Return the required message power level if verification should proceed."""
|
||||
if await self.user_has_unlimited_power(user_id, room_id):
|
||||
self.log.debug(
|
||||
f"User {user_id} has unlimited power in {room_id}, skipping verification"
|
||||
)
|
||||
return None
|
||||
|
||||
power_levels = await self.client.get_state_event(room_id, EventType.ROOM_POWER_LEVELS)
|
||||
user_level = power_levels.get_user_level(user_id)
|
||||
required_level = power_levels.events.get(
|
||||
str(EventType.ROOM_MESSAGE), power_levels.events_default
|
||||
)
|
||||
self.log.debug(
|
||||
f"User {user_id} has power level {user_level}, required level is {required_level}"
|
||||
)
|
||||
if user_level >= required_level:
|
||||
self.log.debug(
|
||||
f"User {user_id} already has sufficient power level ({user_level} >= {required_level})"
|
||||
)
|
||||
return None
|
||||
return required_level
|
||||
|
||||
async def _create_verification_dm(
|
||||
self, user_id: UserID, roomname: str
|
||||
) -> Optional[RoomID]:
|
||||
"""Create a DM room for human verification with bounded retries."""
|
||||
max_retries = 3
|
||||
retry_delay = 1
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
dm_room = await self.client.create_room(
|
||||
preset=RoomCreatePreset.PRIVATE,
|
||||
invitees=[user_id],
|
||||
is_direct=True,
|
||||
initial_state=[
|
||||
{
|
||||
"type": str(EventType.ROOM_NAME),
|
||||
"content": {"name": f"[{roomname}] join verification"},
|
||||
}
|
||||
],
|
||||
)
|
||||
self.log.info(f"Created DM room {dm_room} for {user_id}")
|
||||
return dm_room
|
||||
except Exception as e:
|
||||
if attempt < max_retries - 1:
|
||||
self.log.warning(
|
||||
f"Failed to create DM room (attempt {attempt + 1}/{max_retries}): {e}"
|
||||
)
|
||||
await asyncio.sleep(retry_delay)
|
||||
else:
|
||||
self.log.error(
|
||||
f"Failed to initiate verification process after {max_retries} attempts: {e}"
|
||||
)
|
||||
return None
|
||||
|
||||
async def _maybe_start_human_verification(
|
||||
self, evt: StateEvent, room_label: str
|
||||
) -> None:
|
||||
"""Run the human verification flow for a newly joined member when configured."""
|
||||
if not (self.config["check_if_human"] and self.config["verification_phrases"]):
|
||||
return
|
||||
|
||||
verification_enabled = self._is_human_verification_enabled_for_room(evt.room_id)
|
||||
self.log.debug(
|
||||
f"Verification enabled for room {evt.room_id}: {verification_enabled}"
|
||||
)
|
||||
if not verification_enabled:
|
||||
return
|
||||
|
||||
try:
|
||||
required_level = await self._user_requires_human_verification(
|
||||
evt.sender, evt.room_id
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.error(f"Failed to check user power level: {e}")
|
||||
return
|
||||
|
||||
if required_level is None:
|
||||
return
|
||||
|
||||
dm_room = await self._create_verification_dm(evt.sender, room_label)
|
||||
if not dm_room:
|
||||
return
|
||||
|
||||
verification_phrase = random.choice(self.config["verification_phrases"])
|
||||
verification_state = {
|
||||
"user": evt.sender,
|
||||
"target_room": evt.room_id,
|
||||
"phrase": verification_phrase,
|
||||
"attempts": self.config["verification_attempts"],
|
||||
"required_level": required_level,
|
||||
}
|
||||
await self.store_verification_state(dm_room, verification_state)
|
||||
|
||||
greeting = self.config["verification_message"].format(
|
||||
room=room_label, phrase=verification_phrase
|
||||
)
|
||||
await self.client.send_notice(dm_room, html=greeting)
|
||||
self.log.info(
|
||||
f"Started verification process for {evt.sender} in room {evt.room_id} for room {room_label}"
|
||||
)
|
||||
|
||||
async def _resolve_room_identifier(self, room: str) -> str:
|
||||
"""Resolve either a room alias or a room ID into a room ID."""
|
||||
if room.startswith("#"):
|
||||
resolved = await self.client.resolve_room_alias(room)
|
||||
room_id = resolved["room_id"]
|
||||
self.log.info(f"Resolved alias '{room}' to room ID: {room_id}")
|
||||
return room_id
|
||||
self.log.info(f"Using direct room ID: {room}")
|
||||
return room
|
||||
|
||||
async def _get_room_metadata(self, room_id: str) -> Dict[str, Optional[str]]:
|
||||
"""Fetch the room name and topic once for room replacement flows."""
|
||||
metadata: Dict[str, Optional[str]] = {"room_name": None, "room_topic": None}
|
||||
try:
|
||||
room_name_event = await self.client.get_state_event(room_id, EventType.ROOM_NAME)
|
||||
metadata["room_name"] = getattr(room_name_event, "name", None)
|
||||
except Exception as e:
|
||||
self.log.warning(f"Failed to get room name: {e}")
|
||||
|
||||
try:
|
||||
room_topic_event = await self.client.get_state_event(room_id, EventType.ROOM_TOPIC)
|
||||
metadata["room_topic"] = getattr(room_topic_event, "topic", None)
|
||||
except Exception as e:
|
||||
self.log.warning(f"Failed to get room topic: {e}")
|
||||
|
||||
return metadata
|
||||
|
||||
async def _detect_space_type(self, room_id: str) -> bool:
|
||||
"""Return True when the target room is a space."""
|
||||
try:
|
||||
state_events = await self.client.get_state(room_id)
|
||||
for state_event in state_events:
|
||||
if str(state_event.type) != "m.room.create":
|
||||
continue
|
||||
space_type = state_event.content.get("type")
|
||||
is_space = space_type == "m.space"
|
||||
self.log.info(f"Detected room type {space_type!r} for {room_id}")
|
||||
return is_space
|
||||
except Exception as e:
|
||||
self.log.error(f"Failed to detect room type for {room_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _format_user_pill(
|
||||
self,
|
||||
user_id: str,
|
||||
user_display: Optional[str] = None,
|
||||
) -> Tuple[str, str]:
|
||||
"""Return plaintext and HTML variants for the {user} placeholder."""
|
||||
safe_user_display = user_display or user_id
|
||||
prefix = DEFAULT_USER_PILL_PREFIX
|
||||
label = f"{prefix}{safe_user_display}"
|
||||
href = self._matrix_uri_user(str(user_id))
|
||||
|
||||
return label, f"<a href='{href}'>{escape(label)}</a>"
|
||||
|
||||
def _format_room_pill(
|
||||
self,
|
||||
room_id: Optional[str] = None,
|
||||
room_text: Optional[str] = None,
|
||||
) -> Tuple[str, str]:
|
||||
"""Return plaintext and HTML variants for the {room} placeholder."""
|
||||
safe_room_id = str(room_id or "")
|
||||
safe_room_text = room_text or safe_room_id
|
||||
prefix = DEFAULT_ROOM_PILL_PREFIX
|
||||
label = f"{prefix}{safe_room_text}"
|
||||
|
||||
if safe_room_id:
|
||||
href = self._matrix_uri_room(safe_room_id)
|
||||
html = f"<a href='{href}'>{escape(label)}</a>"
|
||||
else:
|
||||
html = escape(label)
|
||||
|
||||
return label, html
|
||||
|
||||
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 ""
|
||||
user_plain, user_html = self._format_user_pill(user_id, safe_user_display)
|
||||
room_plain, room_html = self._format_room_pill(safe_room_id, safe_room_text)
|
||||
|
||||
plain_text = template.format(
|
||||
user=user_plain,
|
||||
user_id=user_id,
|
||||
user_link=user_url,
|
||||
room=room_plain,
|
||||
room_link=room_url,
|
||||
room_id=safe_room_id,
|
||||
)
|
||||
|
||||
html_message = template.format(
|
||||
user=user_html,
|
||||
user_id=escape(user_id),
|
||||
user_link=f"<a href='{user_url}'>{escape(safe_user_display)}</a>",
|
||||
room=room_html,
|
||||
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()
|
||||
self.config_manager = config_manager.ConfigManager(self.config)
|
||||
self.client.add_dispatcher(MembershipEventDispatcher)
|
||||
# Start background redaction task
|
||||
self._redaction_tasks = asyncio.create_task(self._redaction_loop())
|
||||
# Clean up stale verification states
|
||||
self._redaction_task: Optional[asyncio.Task] = None
|
||||
self._report_counts: Dict[str, set[str]] = {}
|
||||
self._redaction_task = asyncio.create_task(self._redaction_loop())
|
||||
await self.cleanup_stale_verification_states()
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._redaction_tasks:
|
||||
self._redaction_tasks.cancel()
|
||||
if self._redaction_task:
|
||||
self._redaction_task.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 +546,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 +557,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 +702,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 +1239,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}")
|
||||
@@ -971,10 +1346,7 @@ class CommunityBot(Plugin):
|
||||
async def newjoin(self, evt: StateEvent) -> None:
|
||||
if evt.source & SyncStream.STATE:
|
||||
return
|
||||
else:
|
||||
# we only care about join events in rooms in the space
|
||||
# this avoids trying to verify users in other rooms the bot might be in,
|
||||
# such as public banlist policy rooms
|
||||
|
||||
space_rooms = await self.get_space_roomlist()
|
||||
if evt.room_id not in space_rooms:
|
||||
return
|
||||
@@ -984,182 +1356,23 @@ class CommunityBot(Plugin):
|
||||
except Exception as e:
|
||||
self.log.error(f"Failed to check if {evt.sender} is banned: {e}")
|
||||
on_banlist = False
|
||||
|
||||
if on_banlist:
|
||||
await self.ban_this_user(evt.sender)
|
||||
return
|
||||
# passive sync of tracking db
|
||||
|
||||
if evt.room_id == self.config["parent_room"]:
|
||||
await self.do_sync()
|
||||
# greeting activities
|
||||
room_id = str(evt.room_id)
|
||||
self.log.debug(f"New join in room {room_id} by {evt.sender}")
|
||||
self.log.debug(f"Greeting rooms config: {self.config['greeting_rooms']}")
|
||||
self.log.debug(f"Check if human config: {self.config['check_if_human']}")
|
||||
self.log.debug(
|
||||
f"Verification phrases config: {self.config['verification_phrases']}"
|
||||
)
|
||||
|
||||
if room_id in self.config["greeting_rooms"]:
|
||||
if on_banlist:
|
||||
self.log.debug(f"New join in room {evt.room_id} by {evt.sender}")
|
||||
await self._handle_join_notifications(evt)
|
||||
|
||||
if not (self.config["check_if_human"] and self.config["verification_phrases"]):
|
||||
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
|
||||
)
|
||||
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)
|
||||
else:
|
||||
pass
|
||||
|
||||
if self.config["notification_room"]:
|
||||
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)
|
||||
|
||||
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
|
||||
)
|
||||
await self.client.send_notice(
|
||||
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"]
|
||||
)
|
||||
|
||||
self.log.debug(
|
||||
f"Verification enabled for room {room_id}: {verification_enabled}"
|
||||
)
|
||||
|
||||
if not verification_enabled:
|
||||
return
|
||||
|
||||
# Get room name for greeting
|
||||
roomname = "this room"
|
||||
roomname = await common_utils.get_room_name(
|
||||
self.client, evt.room_id, self.log
|
||||
)
|
||||
|
||||
# Check if user already has sufficient power level or unlimited power
|
||||
try:
|
||||
# First check if user has unlimited power (creator in modern room versions)
|
||||
if await self.user_has_unlimited_power(evt.sender, evt.room_id):
|
||||
self.log.debug(
|
||||
f"User {evt.sender} has unlimited power in {evt.room_id}, skipping verification"
|
||||
)
|
||||
return
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
self.log.debug(
|
||||
f"User {evt.sender} has power level {user_level}, required level is {required_level}"
|
||||
)
|
||||
|
||||
# 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
|
||||
max_retries = 3
|
||||
retry_delay = 1 # seconds
|
||||
last_error = None
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
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"
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
self.log.info(f"Created DM room {dm_room} for {evt.sender}")
|
||||
break
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
if (
|
||||
attempt < max_retries - 1
|
||||
): # Don't sleep on the last attempt
|
||||
self.log.warning(
|
||||
f"Failed to create DM room (attempt {attempt + 1}/{max_retries}): {e}"
|
||||
)
|
||||
await asyncio.sleep(retry_delay)
|
||||
else:
|
||||
self.log.error(
|
||||
f"Failed to initiate verification process after {max_retries} attempts: {e}"
|
||||
)
|
||||
return
|
||||
|
||||
# Select random verification phrase
|
||||
verification_phrase = random.choice(
|
||||
self.config["verification_phrases"]
|
||||
)
|
||||
|
||||
# Store verification state
|
||||
verification_state = {
|
||||
"user": evt.sender,
|
||||
"target_room": evt.room_id,
|
||||
"phrase": verification_phrase,
|
||||
"attempts": self.config["verification_attempts"],
|
||||
"required_level": required_level,
|
||||
}
|
||||
await self.store_verification_state(dm_room, verification_state)
|
||||
|
||||
# Send greeting
|
||||
greeting = self.config["verification_message"].format(
|
||||
room=roomname, phrase=verification_phrase
|
||||
)
|
||||
await self.client.send_notice(dm_room, html=greeting)
|
||||
self.log.info(
|
||||
f"Started verification process for {evt.sender} in room {room_id} for room {roomname}"
|
||||
)
|
||||
|
||||
room_label = await common_utils.get_room_name(self.client, evt.room_id, self.log)
|
||||
await self._maybe_start_human_verification(evt, room_label)
|
||||
except Exception as e:
|
||||
self.log.error(f"Failed to start verification process: {e}")
|
||||
|
||||
@@ -1343,17 +1556,17 @@ class CommunityBot(Plugin):
|
||||
|
||||
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}"
|
||||
reporter_display = await self._get_user_display_name(evt.room_id, evt.sender)
|
||||
room_plain, room_link_html = self._format_room_pill(str(evt.room_id), room_text)
|
||||
reporter_plain, reporter_link_html = self._format_user_pill(str(evt.sender), reporter_display)
|
||||
room_url = room_plain
|
||||
message_url = self._matrix_uri_event(str(evt.room_id), str(target_event_id))
|
||||
message_link_html = f"<a href='{escape(message_url, quote=True)}'>Original Event Link</a>"
|
||||
|
||||
# --- AUTO-REDACT LOGIC ---
|
||||
if self.config.get("auto_redact_majority", False):
|
||||
try:
|
||||
members = await self.client.get_joined_members(evt.room_id)
|
||||
@@ -1367,13 +1580,23 @@ class CommunityBot(Plugin):
|
||||
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}"
|
||||
)
|
||||
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,
|
||||
)
|
||||
await self.client.send_notice(self.config["notification_room"], html=notification)
|
||||
|
||||
del self._report_counts[target_event_id]
|
||||
return
|
||||
@@ -1381,14 +1604,24 @@ class CommunityBot(Plugin):
|
||||
self.log.error(f"Failed to auto-redact reported message: {e}")
|
||||
|
||||
if current_reports == 1:
|
||||
notification = (
|
||||
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>"
|
||||
notification_text = (
|
||||
"🚨 Message Reported\n"
|
||||
f"First Reporter: {reporter_plain}\n"
|
||||
f"Room: {room_url}\n"
|
||||
f"Action: {message_url}"
|
||||
)
|
||||
notification_html = (
|
||||
f"🚨 <b>Message Reported</b><br>"
|
||||
f"<b>First Reporter:</b> {reporter_link_html}<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 +2017,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 +2167,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 +2214,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."
|
||||
)
|
||||
@@ -2038,176 +2272,40 @@ class CommunityBot(Plugin):
|
||||
@decorators.require_parent_room
|
||||
@decorators.require_permission(min_level=100)
|
||||
async def room_replace(self, evt: MessageEvent, room: str) -> None:
|
||||
self.log.info(f"=== REPLACEROOM COMMAND STARTED ===")
|
||||
self.log.info(f"Command arguments: room='{room}', evt.room_id='{evt.room_id}'")
|
||||
|
||||
await evt.mark_read()
|
||||
|
||||
if not room:
|
||||
room = evt.room_id
|
||||
# first we need to get relevant room state of the room we want to replace
|
||||
# this includes the room name, alias, and join rules
|
||||
if room.startswith("#"):
|
||||
room_id = await self.client.resolve_room_alias(room)
|
||||
room_id = room_id["room_id"]
|
||||
self.log.info(f"Resolved alias '{room}' to room ID: {room_id}")
|
||||
else:
|
||||
room_id = room
|
||||
self.log.info(f"Using direct room ID: {room_id}")
|
||||
|
||||
# Check bot permissions in the old room
|
||||
self.log.info(f"=== CHECKING BOT PERMISSIONS ===")
|
||||
room_id = await self._resolve_room_identifier(room)
|
||||
|
||||
has_perms, error_msg, _ = await self.check_bot_permissions(
|
||||
room_id, evt, ["state", "tombstone", "power_levels"]
|
||||
)
|
||||
self.log.info(
|
||||
f"Bot permissions check result: has_perms={has_perms}, error_msg='{error_msg}'"
|
||||
)
|
||||
if not has_perms:
|
||||
await evt.respond(f"Cannot replace room: {error_msg}")
|
||||
self.log.info("Bot permissions check failed, returning")
|
||||
return
|
||||
|
||||
# Get the room name from the state event
|
||||
room_name = None
|
||||
try:
|
||||
room_name_event = await self.client.get_state_event(
|
||||
room_id, EventType.ROOM_NAME
|
||||
)
|
||||
room_name = room_name_event.name
|
||||
self.log.info(f"Retrieved room name: '{room_name}'")
|
||||
except Exception as e:
|
||||
self.log.warning(f"Failed to get room name: {e}")
|
||||
# room_name remains None
|
||||
|
||||
# get the room topic from the state event
|
||||
room_topic = None
|
||||
try:
|
||||
room_topic_event = await self.client.get_state_event(
|
||||
room_id, EventType.ROOM_TOPIC
|
||||
)
|
||||
room_topic = room_topic_event.topic
|
||||
except Exception as e:
|
||||
self.log.warning(f"Failed to get room topic: {e}")
|
||||
# room_topic remains None
|
||||
|
||||
# Check if the room being replaced is a space
|
||||
is_space = False
|
||||
self.log.info(f"=== ABOUT TO START SPACE DETECTION ===")
|
||||
self.log.info(f"=== SPACE DETECTION DEBUG START ===")
|
||||
self.log.info(f"Room ID being checked: {room_id}")
|
||||
self.log.info(f"EventType module: {EventType}")
|
||||
self.log.info(
|
||||
f"EventType.ROOM_CREATE exists: {hasattr(EventType, 'ROOM_CREATE')}"
|
||||
)
|
||||
if hasattr(EventType, "ROOM_CREATE"):
|
||||
self.log.info(
|
||||
f"EventType.ROOM_CREATE value: {getattr(EventType, 'ROOM_CREATE')}"
|
||||
)
|
||||
else:
|
||||
self.log.warning("EventType.ROOM_CREATE does not exist!")
|
||||
|
||||
try:
|
||||
# Get the room creation event to check if it's a space
|
||||
state_events = await self.client.get_state(room_id)
|
||||
self.log.info(
|
||||
f"Retrieved {len(state_events)} state events for space detection"
|
||||
)
|
||||
|
||||
# Log all event types for debugging
|
||||
event_types = [event.type for event in state_events]
|
||||
self.log.info(f"Event types found: {event_types}")
|
||||
|
||||
# Debug EventType.ROOM_CREATE constant
|
||||
self.log.info(f"EventType.ROOM_CREATE value: {EventType.ROOM_CREATE}")
|
||||
self.log.info(f"EventType.ROOM_CREATE type: {type(EventType.ROOM_CREATE)}")
|
||||
|
||||
# Also try string comparison as fallback
|
||||
room_create_string = "m.room.create"
|
||||
self.log.info(f"String comparison value: {room_create_string}")
|
||||
|
||||
# Try to find the room creation event using multiple methods
|
||||
room_create_event = None
|
||||
|
||||
for i, event in enumerate(state_events):
|
||||
self.log.info(
|
||||
f"Event {i}: type={event.type} (type: {type(event.type)})"
|
||||
)
|
||||
|
||||
# Try multiple comparison methods
|
||||
if (
|
||||
hasattr(EventType, "ROOM_CREATE")
|
||||
and event.type == EventType.ROOM_CREATE
|
||||
):
|
||||
self.log.info(f"✓ Matched EventType.ROOM_CREATE")
|
||||
room_create_event = event
|
||||
break
|
||||
elif str(event.type) == room_create_string:
|
||||
self.log.info(f"✓ Matched string comparison 'm.room.create'")
|
||||
room_create_event = event
|
||||
break
|
||||
elif event.type == "m.room.create":
|
||||
self.log.info(f"✓ Matched direct string comparison")
|
||||
room_create_event = event
|
||||
break
|
||||
else:
|
||||
self.log.info(f"✗ No match for event {i}")
|
||||
|
||||
# Now process the room creation event if found
|
||||
if room_create_event:
|
||||
space_type = room_create_event.content.get("type")
|
||||
self.log.info(f"Found ROOM_CREATE event with type: {space_type}")
|
||||
self.log.info(f"Full ROOM_CREATE content: {room_create_event.content}")
|
||||
is_space = space_type == "m.space"
|
||||
self.log.info(f"Space detection result: {is_space}")
|
||||
else:
|
||||
self.log.warning("No ROOM_CREATE event found using any method")
|
||||
|
||||
if is_space:
|
||||
self.log.info(
|
||||
f"✓ FINAL RESULT: Room {room_id} IS a space - will create new space"
|
||||
)
|
||||
else:
|
||||
self.log.info(
|
||||
f"✗ FINAL RESULT: Room {room_id} is NOT a space - will create regular room"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.log.error(f"❌ ERROR during space detection: {e}")
|
||||
import traceback
|
||||
|
||||
self.log.error(f"Traceback: {traceback.format_exc()}")
|
||||
# Assume it's not a space if we can't determine
|
||||
is_space = False
|
||||
|
||||
self.log.info(f"=== SPACE DETECTION DEBUG END - is_space={is_space} ===")
|
||||
|
||||
# Get list of aliases to transfer while removing them from the old room
|
||||
metadata = await self._get_room_metadata(room_id)
|
||||
room_name = metadata["room_name"]
|
||||
room_topic = metadata["room_topic"]
|
||||
is_space = await self._detect_space_type(room_id)
|
||||
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."
|
||||
)
|
||||
return
|
||||
|
||||
# Inform user about what type of room is being replaced
|
||||
if not room_name:
|
||||
room_name = f"Room {room_id[:8]}..." # Fallback name
|
||||
room_name = f"Room {room_id[:8]}..."
|
||||
self.log.warning(f"Using fallback room name: {room_name}")
|
||||
|
||||
self.log.info(
|
||||
f"Final decision - is_space: {is_space}, room_name: '{room_name}'"
|
||||
)
|
||||
self.log.info(f"About to send user message - is_space: {is_space}")
|
||||
|
||||
if is_space:
|
||||
await evt.respond(f"Replacing space '{room_name}' with a new space...")
|
||||
self.log.info(f"✓ Sent 'Replacing space' message to user")
|
||||
else:
|
||||
await evt.respond(f"Replacing room '{room_name}' with a new room...")
|
||||
self.log.info(f"✗ Sent 'Replacing room' message to user")
|
||||
|
||||
# Validate that the new room alias is available
|
||||
is_valid, conflicting_aliases = await self.validate_room_aliases(
|
||||
@@ -3124,7 +3222,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 +3404,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,
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
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]
|
||||
|
||||
@@ -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)
|
||||
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)
|
||||
|
||||
@@ -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,8 +115,13 @@ 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:
|
||||
@@ -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} has joined {room}.
|
||||
|
||||
# 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,
|
||||
|
||||
+8
-2
@@ -1,11 +1,17 @@
|
||||
maubot: 0.1.0
|
||||
id: org.jobmachine.communitybot
|
||||
version: 0.4.1
|
||||
id: advanced-community-bot
|
||||
name: Advanced Community Bot
|
||||
description: Advanced Community Bot is a Maubot Bot Plugin to manage Matrix servers.
|
||||
version: 1.0.0
|
||||
license: MIT
|
||||
|
||||
modules:
|
||||
- community
|
||||
|
||||
main_class: CommunityBot
|
||||
|
||||
extra_files:
|
||||
- base-config.yaml
|
||||
|
||||
database: true
|
||||
database_type: asyncpg
|
||||
|
||||
Executable → Regular
Executable → Regular
@@ -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>"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import html
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from community.bot import CommunityBot, DEFAULT_ROOM_PILL_PREFIX, DEFAULT_USER_PILL_PREFIX
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def bot() -> CommunityBot:
|
||||
plugin = CommunityBot.__new__(CommunityBot)
|
||||
plugin.config = {}
|
||||
return plugin
|
||||
|
||||
|
||||
def test_user_uri_helper_strips_at_and_uses_chat_action(bot: CommunityBot) -> None:
|
||||
assert bot._matrix_user_uri("@alice:example.org") == "matrix:u/alice:example.org?action=chat"
|
||||
|
||||
|
||||
def test_room_uri_helper_prefers_alias(bot: CommunityBot) -> None:
|
||||
assert bot._matrix_room_uri("!roomid:example.org", "#general:example.org") == "matrix:r/general:example.org"
|
||||
|
||||
|
||||
def test_room_uri_helper_falls_back_to_room_id_without_bang(bot: CommunityBot) -> None:
|
||||
assert bot._matrix_room_uri("!roomid:example.org", None) == "matrix:roomid/roomid:example.org"
|
||||
|
||||
|
||||
def test_event_uri_helper_strips_prefixes(bot: CommunityBot) -> None:
|
||||
assert bot._matrix_event_uri("!roomid:example.org", "$eventid") == "matrix:roomid/roomid:example.org/e/eventid"
|
||||
|
||||
|
||||
def test_format_user_pill_uses_clean_default_prefix(bot: CommunityBot) -> None:
|
||||
plain, formatted = bot._format_user_pill("@alice:example.org", "Alice")
|
||||
assert plain == f"{DEFAULT_USER_PILL_PREFIX}Alice"
|
||||
assert 'href="matrix:u/alice:example.org?action=chat"' in formatted
|
||||
assert ">Alice<" in formatted
|
||||
|
||||
|
||||
def test_format_room_pill_uses_alias_when_available(bot: CommunityBot) -> None:
|
||||
plain, formatted = bot._format_room_pill("!roomid:example.org", "General", "#general:example.org")
|
||||
assert plain == f"{DEFAULT_ROOM_PILL_PREFIX}General"
|
||||
assert formatted == '<a href="matrix:r/general:example.org">General</a>'
|
||||
|
||||
|
||||
def test_format_room_pill_falls_back_to_room_id(bot: CommunityBot) -> None:
|
||||
plain, formatted = bot._format_room_pill("!roomid:example.org", "General", None)
|
||||
assert plain == f"{DEFAULT_ROOM_PILL_PREFIX}General"
|
||||
assert formatted == '<a href="matrix:roomid/roomid:example.org">General</a>'
|
||||
|
||||
|
||||
def test_format_user_pill_escapes_displayname(bot: CommunityBot) -> None:
|
||||
plain, formatted = bot._format_user_pill("@alice:example.org", '<Admin & Ops>')
|
||||
assert plain == f"{DEFAULT_USER_PILL_PREFIX}<Admin & Ops>"
|
||||
# Keep this broad enough to avoid coupling to quote style.
|
||||
assert "matrix:u/alice:example.org?action=chat" in formatted
|
||||
assert html.escape('<Admin & Ops>') in formatted
|
||||
@@ -0,0 +1,10 @@
|
||||
from community.bot import CommunityBot
|
||||
|
||||
|
||||
def test_matrix_uri_wrappers_delegate_to_canonical_helpers() -> None:
|
||||
bot = CommunityBot.__new__(CommunityBot)
|
||||
bot.config = {}
|
||||
assert bot._matrix_user_uri("@alice:example.org") == "matrix:u/alice:example.org?action=chat"
|
||||
assert bot._matrix_room_uri("!roomid:example.org") == "matrix:roomid/roomid:example.org"
|
||||
assert bot._matrix_room_uri("!roomid:example.org", "#general:example.org") == "matrix:r/general:example.org"
|
||||
assert bot._matrix_event_uri("!roomid:example.org", "$eventid") == "matrix:roomid/roomid:example.org/e/eventid"
|
||||
@@ -46,24 +46,41 @@ 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, [])
|
||||
result = await validate_room_aliases(client, ["room1", "room2"], "", False, "example.com")
|
||||
assert result == (True, [])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_room_aliases_success(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", "example.com")
|
||||
result = await validate_room_aliases(client, ["room1", "room2"], "test", False, "example.com")
|
||||
assert result == (True, [])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_room_aliases_conflicts(self):
|
||||
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()
|
||||
|
||||
@@ -75,7 +92,23 @@ class TestRoomUtils:
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""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",
|
||||
"user_pill_prefix": "@",
|
||||
"room_pill_prefix": "#",
|
||||
}
|
||||
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
|
||||
|
||||
|
||||
def test_render_message_template_uses_configurable_user_and_room_pill_prefixes(bot):
|
||||
bot.config["user_pill_prefix"] = ""
|
||||
bot.config["room_pill_prefix"] = ""
|
||||
|
||||
plain, html = bot._render_message_template(
|
||||
"{user} has joined {room}.",
|
||||
"@alice:example.org",
|
||||
"Alice",
|
||||
"!room:example.org",
|
||||
"General",
|
||||
)
|
||||
|
||||
assert plain == "Alice has joined General."
|
||||
assert "<a href='matrix:u/alice:example.org?action=chat'>Alice</a>" in html
|
||||
assert "<a href='matrix:roomid/room:example.org'>General</a>" in html
|
||||
|
||||
|
||||
def test_render_message_template_defaults_to_prefixed_user_and_room_pills(bot):
|
||||
plain, html = bot._render_message_template(
|
||||
"{user} has joined {room}.",
|
||||
"@alice:example.org",
|
||||
"Alice",
|
||||
"!room:example.org",
|
||||
"General",
|
||||
)
|
||||
|
||||
assert plain == "@Alice has joined #General."
|
||||
assert "<a href='matrix:u/alice:example.org?action=chat'>@Alice</a>" in html
|
||||
assert "<a href='matrix:roomid/room:example.org'>#General</a>" in html
|
||||
|
||||
|
||||
def test_matrix_uri_helpers_are_consistent():
|
||||
from community.bot import CommunityBot
|
||||
|
||||
bot = CommunityBot.__new__(CommunityBot)
|
||||
bot.config = {}
|
||||
|
||||
assert bot._matrix_user_uri("@alice:example.org") == "matrix:u/alice:example.org?action=chat"
|
||||
assert bot._matrix_room_uri("!roomid:example.org", "#general:example.org") == "matrix:r/general:example.org"
|
||||
assert bot._matrix_room_uri("!roomid:example.org", None) == "matrix:roomid/roomid:example.org"
|
||||
assert bot._matrix_event_uri("!roomid:example.org", "$eventid") == "matrix:roomid/roomid:example.org/e/eventid"
|
||||
Reference in New Issue
Block a user