Latest fixes.
This commit is contained in:
+183
-46
@@ -2,10 +2,15 @@
|
||||
"""
|
||||
plugins/admin.py – Full room moderation commands.
|
||||
Supports multi‑word display names, standalone commands (!op, !kick, etc.)
|
||||
Automatic flood detection:
|
||||
– message flood (5 msgs in 3s) → auto‑ban + kick
|
||||
– join flood (5 joins in 3s, any domain) → room locked to invite‑only
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
import re
|
||||
from collections import defaultdict, deque
|
||||
import simplematrixbotlib as botlib
|
||||
|
||||
logger = logging.getLogger("admin")
|
||||
@@ -17,6 +22,17 @@ _pending_resolution = {} # room_id → {"matches": [...], "expires": timestamp}
|
||||
_name_cache = {} # room_id → {display_name.lower(): mxid}
|
||||
RESOLUTION_TIMEOUT = 60
|
||||
|
||||
# Flood detection settings
|
||||
FLOOD_MAX_MESSAGES = 15
|
||||
FLOOD_TIME_WINDOW = 3.0 # seconds
|
||||
JOIN_FLOOD_MAX = 5
|
||||
JOIN_FLOOD_WINDOW = 3.0 # seconds
|
||||
|
||||
# Per-room per-user message timestamps
|
||||
_flood_tracker: dict[str, dict[str, deque[float]]] = defaultdict(lambda: defaultdict(deque))
|
||||
# Per-room join event timestamps (any domain)
|
||||
_join_flood_tracker: dict[str, deque[float]] = defaultdict(deque)
|
||||
|
||||
def _cleanup_resolutions():
|
||||
now = time.time()
|
||||
expired = [r for r, v in _pending_resolution.items() if v["expires"] < now]
|
||||
@@ -28,7 +44,6 @@ class UserResolutionError(Exception):
|
||||
self.matches = matches # list of {"mxid": ..., "display_name": ...}
|
||||
|
||||
async def _populate_name_cache(bot, room_id):
|
||||
"""Fetch the full member list and cache display names."""
|
||||
if room_id in _name_cache:
|
||||
return
|
||||
try:
|
||||
@@ -39,7 +54,6 @@ async def _populate_name_cache(bot, room_id):
|
||||
for member in resp.members:
|
||||
display = (member.display_name or "").strip().lower()
|
||||
if display:
|
||||
# If duplicate display name, store None to indicate ambiguity
|
||||
if display in cache:
|
||||
cache[display] = None
|
||||
else:
|
||||
@@ -50,23 +64,17 @@ async def _populate_name_cache(bot, room_id):
|
||||
logger.error(f"Could not cache members: {e}")
|
||||
|
||||
async def _resolve_multiword(bot, room_id, tokens):
|
||||
"""
|
||||
Given a list of word tokens, try to find a matching display name
|
||||
by testing progressively longer prefixes of the token list.
|
||||
Returns (mxid, display_name) or raises ValueError if no match.
|
||||
"""
|
||||
clean_tokens = [re.sub(r'<[^>]+>', '', t).strip() for t in tokens]
|
||||
await _populate_name_cache(bot, room_id)
|
||||
cache = _name_cache.get(room_id, {})
|
||||
|
||||
# Build candidates from 1 token up to all tokens
|
||||
for end in range(len(tokens), 0, -1):
|
||||
candidate = " ".join(tokens[:end]).strip().lower()
|
||||
for end in range(len(clean_tokens), 0, -1):
|
||||
candidate = " ".join(clean_tokens[:end]).strip().lower()
|
||||
if candidate in cache:
|
||||
mxid = cache[candidate]
|
||||
if mxid is not None:
|
||||
return mxid, candidate
|
||||
else:
|
||||
# Duplicate display name → fall through to ambiguity handling
|
||||
resp = await bot.async_client.joined_members(room_id)
|
||||
matches = []
|
||||
for member in resp.members:
|
||||
@@ -76,23 +84,15 @@ async def _resolve_multiword(bot, room_id, tokens):
|
||||
return matches[0]["mxid"], matches[0]["display_name"]
|
||||
elif len(matches) > 1:
|
||||
raise UserResolutionError(matches)
|
||||
# else: not found (unlikely) → continue
|
||||
raise ValueError(f"No member with display name '{' '.join(tokens)}' found.")
|
||||
raise ValueError(f"No member with display name '{' '.join(clean_tokens)}' found.")
|
||||
|
||||
async def resolve_user_from_target(bot, room_id, target):
|
||||
"""
|
||||
Resolve a target string to a Matrix user ID.
|
||||
Accepts: full MXID (@user:domain), display name (multi‑word), or number
|
||||
(referring to a previous ambiguous resolution).
|
||||
Returns (mxid, display_name_or_None).
|
||||
Raises ValueError or UserResolutionError.
|
||||
"""
|
||||
target = re.sub(r'<[^>]+>', '', target).strip()
|
||||
if target.startswith("@"):
|
||||
return target, None
|
||||
|
||||
_cleanup_resolutions()
|
||||
|
||||
# Check for number reference to a previous ambiguous match
|
||||
if target.isdigit():
|
||||
idx = int(target) - 1
|
||||
if room_id in _pending_resolution:
|
||||
@@ -106,16 +106,12 @@ async def resolve_user_from_target(bot, room_id, target):
|
||||
else:
|
||||
raise ValueError("No pending resolution. Use @user:domain or display name.")
|
||||
|
||||
# If we reached here, `target` is a single token, but might be part of a longer name.
|
||||
# That case is handled by calling _resolve_multiword from handle_command.
|
||||
# But for completeness, we still attempt a direct cache match.
|
||||
await _populate_name_cache(bot, room_id)
|
||||
cache = _name_cache.get(room_id, {})
|
||||
mxid = cache.get(target.strip().lower())
|
||||
if mxid:
|
||||
return mxid, target.strip().lower()
|
||||
elif mxid is None:
|
||||
# Ambiguous: fetch and raise
|
||||
resp = await bot.async_client.joined_members(room_id)
|
||||
matches = []
|
||||
for member in resp.members:
|
||||
@@ -183,11 +179,61 @@ async def get_banned_users(bot, room_id):
|
||||
logger.error(f"Failed to fetch bans: {e}")
|
||||
return []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Flood detection (message + global join)
|
||||
# ------------------------------------------------------------------
|
||||
def _check_flood(room_id, user_id) -> bool:
|
||||
now = time.monotonic()
|
||||
q = _flood_tracker[room_id][user_id]
|
||||
while q and q[0] < now - FLOOD_TIME_WINDOW:
|
||||
q.popleft()
|
||||
q.append(now)
|
||||
if len(q) >= FLOOD_MAX_MESSAGES:
|
||||
q.clear()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _check_join_flood(room_id) -> bool:
|
||||
now = time.monotonic()
|
||||
q = _join_flood_tracker[room_id]
|
||||
while q and q[0] < now - JOIN_FLOOD_WINDOW:
|
||||
q.popleft()
|
||||
q.append(now)
|
||||
if len(q) >= JOIN_FLOOD_MAX:
|
||||
q.clear()
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _kick_user(bot, room_id, user_id, reason):
|
||||
try:
|
||||
await bot.async_client.room_kick(room_id, user_id, reason)
|
||||
logger.info(f"Kicked {user_id} from {room_id}: {reason}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to kick {user_id}: {e}")
|
||||
|
||||
async def _ban_user(bot, room_id, user_id, reason):
|
||||
try:
|
||||
await bot.async_client.room_ban(room_id, user_id, reason)
|
||||
logger.info(f"Banned {user_id} from {room_id}: {reason}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to ban {user_id}: {e}")
|
||||
|
||||
async def _lock_room(bot, room_id):
|
||||
"""Set room join rule to 'invite'."""
|
||||
try:
|
||||
await bot.async_client.room_put_state(
|
||||
room_id,
|
||||
"m.room.join_rules",
|
||||
{"join_rule": "invite"}
|
||||
)
|
||||
logger.info(f"Room {room_id} locked to invite‑only (join flood detected).")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to lock room {room_id}: {e}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Main command handler
|
||||
# ------------------------------------------------------------------
|
||||
async def handle_command(room, message, bot, prefix, config):
|
||||
"""Dispatches !admin or standalone moderation commands."""
|
||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||
if not match.is_not_from_this_bot() or not match.prefix():
|
||||
return
|
||||
@@ -200,7 +246,7 @@ async def handle_command(room, message, bot, prefix, config):
|
||||
"ban": "ban",
|
||||
"unban": "unban",
|
||||
"invite": "invite",
|
||||
"userinfo": "whois", # <-- renamed from "whois" to "userinfo"
|
||||
"userinfo": "whois",
|
||||
"op": "op",
|
||||
"deop": "deop",
|
||||
"topic": "topic",
|
||||
@@ -208,6 +254,8 @@ async def handle_command(room, message, bot, prefix, config):
|
||||
"avatar": "avatar",
|
||||
"members": "members",
|
||||
"bans": "bans",
|
||||
"mkick": "mkick",
|
||||
"joinrule": "joinrule",
|
||||
"modhelp": "help",
|
||||
"admin": "admin",
|
||||
}
|
||||
@@ -216,7 +264,6 @@ async def handle_command(room, message, bot, prefix, config):
|
||||
if cmd not in standalone_actions:
|
||||
return
|
||||
|
||||
# Permission gate (skip for help)
|
||||
if cmd not in ("modhelp", "help"):
|
||||
if not await has_mod_permission(bot, room_id, sender, config):
|
||||
await bot.api.send_text_message(
|
||||
@@ -226,7 +273,6 @@ async def handle_command(room, message, bot, prefix, config):
|
||||
|
||||
args = match.args()
|
||||
|
||||
# Determine action and sub_args
|
||||
if cmd == "admin":
|
||||
if not args:
|
||||
await bot.api.send_text_message(room_id, "Usage: !admin <action> [args...]")
|
||||
@@ -238,36 +284,76 @@ async def handle_command(room, message, bot, prefix, config):
|
||||
sub_args = args
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# User-targeting actions (kick, ban, invite, userinfo, op, deop)
|
||||
# Mass‑kick by domain
|
||||
# ------------------------------------------------------------
|
||||
if action in ("kick", "ban", "invite", "userinfo", "op", "deop"):
|
||||
if action == "mkick":
|
||||
if not sub_args:
|
||||
await bot.api.send_text_message(room_id, "Usage: !mkick <domain>\nExample: !mkick evilbots.net")
|
||||
return
|
||||
domain = sub_args[0].strip().lower()
|
||||
if ':' in domain:
|
||||
domain = domain.split(':')[-1]
|
||||
try:
|
||||
resp = await bot.async_client.joined_members(room_id)
|
||||
if not resp.members:
|
||||
await bot.api.send_text_message(room_id, "Could not fetch member list.")
|
||||
return
|
||||
targets = [m for m in resp.members if m.user_id.endswith(f":{domain}")]
|
||||
if not targets:
|
||||
await bot.api.send_text_message(room_id, f"No users found from domain '{domain}'.")
|
||||
return
|
||||
reason = f"Mass‑kick of domain {domain}"
|
||||
count = 0
|
||||
for member in targets:
|
||||
await _kick_user(bot, room_id, member.user_id, reason)
|
||||
count += 1
|
||||
await bot.api.send_text_message(room_id, f"👢 Kicked {count} user(s) from {domain}.")
|
||||
except Exception as e:
|
||||
await bot.api.send_text_message(room_id, f"❌ Mass‑kick failed: {e}")
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Join rule toggle
|
||||
# ------------------------------------------------------------
|
||||
elif action == "joinrule":
|
||||
if not sub_args or sub_args[0] not in ("public", "invite"):
|
||||
await bot.api.send_text_message(room_id, "Usage: !joinrule <public|invite>")
|
||||
return
|
||||
new_rule = sub_args[0].lower()
|
||||
try:
|
||||
await bot.async_client.room_put_state(
|
||||
room_id,
|
||||
"m.room.join_rules",
|
||||
{"join_rule": new_rule}
|
||||
)
|
||||
await bot.api.send_text_message(room_id, f"🔐 Join rule set to **{new_rule}**.")
|
||||
except Exception as e:
|
||||
await bot.api.send_text_message(room_id, f"❌ Failed to set join rule: {e}")
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# User-targeting actions
|
||||
# ------------------------------------------------------------
|
||||
elif action in ("kick", "ban", "invite", "userinfo", "op", "deop"):
|
||||
if not sub_args:
|
||||
await bot.api.send_text_message(
|
||||
room_id, f"Missing user. Usage: !{cmd} <@user|name> [reason...]"
|
||||
)
|
||||
return
|
||||
|
||||
# For op/deop, the last token might be a power level (number)
|
||||
if action in ("op", "deop"):
|
||||
# Try to parse last token as power level
|
||||
potential_pl = sub_args[-1]
|
||||
try:
|
||||
power = int(potential_pl)
|
||||
# Success: power level found, name is sub_args[:-1]
|
||||
name_tokens = sub_args[:-1]
|
||||
if not name_tokens:
|
||||
await bot.api.send_text_message(room_id, "Missing user name.")
|
||||
return
|
||||
except ValueError:
|
||||
# No numeric power, whole sub_args is the name
|
||||
name_tokens = sub_args
|
||||
power = None
|
||||
else:
|
||||
# kick, ban, invite, userinfo
|
||||
name_tokens = sub_args # entire args is the name
|
||||
name_tokens = sub_args
|
||||
power = None
|
||||
|
||||
# Resolve the multi-word name
|
||||
try:
|
||||
target_mxid, target_display = await _resolve_multiword(bot, room_id, name_tokens)
|
||||
except UserResolutionError as e:
|
||||
@@ -279,7 +365,6 @@ async def handle_command(room, message, bot, prefix, config):
|
||||
await bot.api.send_text_message(room_id, "\n".join(lines))
|
||||
return
|
||||
except ValueError as e:
|
||||
# Fallback: also try the old way with just the first token (maybe they used @user)
|
||||
target_str = sub_args[0]
|
||||
try:
|
||||
target_mxid, target_display = await resolve_user_from_target(bot, room_id, target_str)
|
||||
@@ -287,7 +372,6 @@ async def handle_command(room, message, bot, prefix, config):
|
||||
await bot.api.send_text_message(room_id, str(e2))
|
||||
return
|
||||
|
||||
# Determine reason and power level for op/deop
|
||||
if action in ("op", "deop"):
|
||||
if action == "op":
|
||||
requested_pl = power if power is not None else 50
|
||||
@@ -321,7 +405,6 @@ async def handle_command(room, message, bot, prefix, config):
|
||||
await bot.api.send_text_message(room_id, f"❌ Failed to set power: {e}")
|
||||
|
||||
else:
|
||||
# For kick/ban/invite/userinfo: reason is everything after the name tokens
|
||||
reason = " ".join(sub_args[len(name_tokens):]) if len(sub_args) > len(name_tokens) else ""
|
||||
|
||||
if action == "kick":
|
||||
@@ -345,7 +428,7 @@ async def handle_command(room, message, bot, prefix, config):
|
||||
except Exception as e:
|
||||
await bot.api.send_text_message(room_id, f"❌ Failed to invite: {e}")
|
||||
|
||||
elif action == "userinfo": # <-- was "whois", now "userinfo"
|
||||
elif action == "userinfo":
|
||||
try:
|
||||
resp = await bot.async_client.joined_members(room_id)
|
||||
member_info = None
|
||||
@@ -385,7 +468,6 @@ async def handle_command(room, message, bot, prefix, config):
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# TOPIC, ROOMNAME, AVATAR, MEMBERS, BANS, HELP ...
|
||||
# (unchanged)
|
||||
# ------------------------------------------------------------
|
||||
elif action == "topic":
|
||||
if not sub_args:
|
||||
@@ -477,6 +559,8 @@ async def handle_command(room, message, bot, prefix, config):
|
||||
- `!ban <@user|name> [reason]` – Ban a user
|
||||
- `!unban <@user:domain>` – Unban (full MXID required)
|
||||
- `!invite <@user|name>` – Invite a user
|
||||
- `!mkick <domain>` – Kick all users from the given domain
|
||||
- `!joinrule <public|invite>` – Manually set the room join rule
|
||||
- `!userinfo <@user|name>` – Show user details & power level (was `!whois`)
|
||||
- `!op <@user|name> [pl=50]` – Promote user (max 50, moderator)
|
||||
- `!deop <@user|name>` – Demote user to power level 0
|
||||
@@ -497,18 +581,65 @@ If the name is ambiguous you'll be asked to choose from a numbered list.
|
||||
room_id, f"Unknown action: {action}. Use `!modhelp`."
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Plugin setup – register flood detectors
|
||||
# ------------------------------------------------------------------
|
||||
def setup(bot):
|
||||
"""Initialize the admin plugin and register flood detectors."""
|
||||
# Message flood detector (bans + kicks)
|
||||
@bot.listener.on_message_event
|
||||
async def _message_flood(room, message):
|
||||
room_id = room.room_id
|
||||
sender = message.sender
|
||||
if sender == bot.async_client.user_id:
|
||||
return
|
||||
if _check_flood(room_id, sender):
|
||||
disp = sender
|
||||
try:
|
||||
resp = await bot.async_client.joined_members(room_id)
|
||||
if resp.members:
|
||||
for m in resp.members:
|
||||
if m.user_id == sender:
|
||||
disp = m.display_name or sender
|
||||
break
|
||||
except:
|
||||
pass
|
||||
reason = f"Auto‑ban for flooding ({FLOOD_MAX_MESSAGES} messages in {FLOOD_TIME_WINDOW}s)"
|
||||
await _ban_user(bot, room_id, sender, reason)
|
||||
await _kick_user(bot, room_id, sender, reason)
|
||||
|
||||
# Join flood detector (any domain)
|
||||
@bot.listener.on_custom_event(botlib.nio.RoomMemberEvent)
|
||||
async def _join_flood(room, event):
|
||||
room_id = room.room_id
|
||||
if event.membership != "join":
|
||||
return
|
||||
sender = event.state_key
|
||||
if sender == bot.async_client.user_id:
|
||||
return
|
||||
if _check_join_flood(room_id):
|
||||
await _lock_room(bot, room_id)
|
||||
await bot.api.send_text_message(
|
||||
room_id,
|
||||
"🔐 Join flood detected – room locked to invite‑only. Use `!joinrule public` to reopen."
|
||||
)
|
||||
|
||||
logger.info("Admin plugin flood detectors registered")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Plugin metadata
|
||||
# ------------------------------------------------------------------
|
||||
__version__ = "1.1.1"
|
||||
__version__ = "1.2.3"
|
||||
__author__ = "Funguy Admin"
|
||||
__description__ = "Full room moderation – multi‑word name support"
|
||||
__description__ = "Full room moderation – multi‑word name support + flood detection + mass domain kick"
|
||||
__help__ = """
|
||||
<details>
|
||||
<summary><strong>Admin / Moderator Commands</strong></summary>
|
||||
<ul>
|
||||
<li><code>!kick</code>, <code>!ban</code>, <code>!unban</code>, <code>!invite</code></li>
|
||||
<li><code>!userinfo</code> – Show user details & power level (was !whois)</li>
|
||||
<li><code>!mkick <domain></code> – Kick all users from a domain</li>
|
||||
<li><code>!joinrule <public|invite></code> – Change room join rule</li>
|
||||
<li><code>!userinfo</code> – Show user details & power level</li>
|
||||
<li><code>!op</code> (max PL 50), <code>!deop</code></li>
|
||||
<li><code>!topic</code>, <code>!roomname</code>, <code>!avatar</code></li>
|
||||
<li><code>!members</code>, <code>!bans</code></li>
|
||||
@@ -516,5 +647,11 @@ __help__ = """
|
||||
</ul>
|
||||
<p>Power level ≥ 50 required (or global admin).</p>
|
||||
<p>Multi‑word display names are automatically recognized.</p>
|
||||
<p><strong>Flood detection:</strong>
|
||||
<ul>
|
||||
<li>Message flood: 5 messages in 3 seconds → auto‑ban + kick</li>
|
||||
<li>Join flood: 5 users in 3 seconds (any domain) → room locked to invite‑only</li>
|
||||
</ul>
|
||||
</p>
|
||||
</details>
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user