Latest fixes.
This commit is contained in:
@@ -19,7 +19,7 @@ from collections import defaultdict
|
|||||||
from plugins.config import FunguyConfig
|
from plugins.config import FunguyConfig
|
||||||
|
|
||||||
# Rate limiter settings
|
# Rate limiter settings
|
||||||
RATE_LIMIT_WINDOW = 5.0 # seconds
|
RATE_LIMIT_WINDOW = 15.0 # seconds
|
||||||
MAX_COMMANDS_PER_WINDOW = 3
|
MAX_COMMANDS_PER_WINDOW = 3
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+183
-46
@@ -2,10 +2,15 @@
|
|||||||
"""
|
"""
|
||||||
plugins/admin.py – Full room moderation commands.
|
plugins/admin.py – Full room moderation commands.
|
||||||
Supports multi‑word display names, standalone commands (!op, !kick, etc.)
|
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 time
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
from collections import defaultdict, deque
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
|
|
||||||
logger = logging.getLogger("admin")
|
logger = logging.getLogger("admin")
|
||||||
@@ -17,6 +22,17 @@ _pending_resolution = {} # room_id → {"matches": [...], "expires": timestamp}
|
|||||||
_name_cache = {} # room_id → {display_name.lower(): mxid}
|
_name_cache = {} # room_id → {display_name.lower(): mxid}
|
||||||
RESOLUTION_TIMEOUT = 60
|
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():
|
def _cleanup_resolutions():
|
||||||
now = time.time()
|
now = time.time()
|
||||||
expired = [r for r, v in _pending_resolution.items() if v["expires"] < now]
|
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": ...}
|
self.matches = matches # list of {"mxid": ..., "display_name": ...}
|
||||||
|
|
||||||
async def _populate_name_cache(bot, room_id):
|
async def _populate_name_cache(bot, room_id):
|
||||||
"""Fetch the full member list and cache display names."""
|
|
||||||
if room_id in _name_cache:
|
if room_id in _name_cache:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
@@ -39,7 +54,6 @@ async def _populate_name_cache(bot, room_id):
|
|||||||
for member in resp.members:
|
for member in resp.members:
|
||||||
display = (member.display_name or "").strip().lower()
|
display = (member.display_name or "").strip().lower()
|
||||||
if display:
|
if display:
|
||||||
# If duplicate display name, store None to indicate ambiguity
|
|
||||||
if display in cache:
|
if display in cache:
|
||||||
cache[display] = None
|
cache[display] = None
|
||||||
else:
|
else:
|
||||||
@@ -50,23 +64,17 @@ async def _populate_name_cache(bot, room_id):
|
|||||||
logger.error(f"Could not cache members: {e}")
|
logger.error(f"Could not cache members: {e}")
|
||||||
|
|
||||||
async def _resolve_multiword(bot, room_id, tokens):
|
async def _resolve_multiword(bot, room_id, tokens):
|
||||||
"""
|
clean_tokens = [re.sub(r'<[^>]+>', '', t).strip() for t in 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.
|
|
||||||
"""
|
|
||||||
await _populate_name_cache(bot, room_id)
|
await _populate_name_cache(bot, room_id)
|
||||||
cache = _name_cache.get(room_id, {})
|
cache = _name_cache.get(room_id, {})
|
||||||
|
|
||||||
# Build candidates from 1 token up to all tokens
|
for end in range(len(clean_tokens), 0, -1):
|
||||||
for end in range(len(tokens), 0, -1):
|
candidate = " ".join(clean_tokens[:end]).strip().lower()
|
||||||
candidate = " ".join(tokens[:end]).strip().lower()
|
|
||||||
if candidate in cache:
|
if candidate in cache:
|
||||||
mxid = cache[candidate]
|
mxid = cache[candidate]
|
||||||
if mxid is not None:
|
if mxid is not None:
|
||||||
return mxid, candidate
|
return mxid, candidate
|
||||||
else:
|
else:
|
||||||
# Duplicate display name → fall through to ambiguity handling
|
|
||||||
resp = await bot.async_client.joined_members(room_id)
|
resp = await bot.async_client.joined_members(room_id)
|
||||||
matches = []
|
matches = []
|
||||||
for member in resp.members:
|
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"]
|
return matches[0]["mxid"], matches[0]["display_name"]
|
||||||
elif len(matches) > 1:
|
elif len(matches) > 1:
|
||||||
raise UserResolutionError(matches)
|
raise UserResolutionError(matches)
|
||||||
# else: not found (unlikely) → continue
|
raise ValueError(f"No member with display name '{' '.join(clean_tokens)}' found.")
|
||||||
raise ValueError(f"No member with display name '{' '.join(tokens)}' found.")
|
|
||||||
|
|
||||||
async def resolve_user_from_target(bot, room_id, target):
|
async def resolve_user_from_target(bot, room_id, target):
|
||||||
"""
|
target = re.sub(r'<[^>]+>', '', target).strip()
|
||||||
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.
|
|
||||||
"""
|
|
||||||
if target.startswith("@"):
|
if target.startswith("@"):
|
||||||
return target, None
|
return target, None
|
||||||
|
|
||||||
_cleanup_resolutions()
|
_cleanup_resolutions()
|
||||||
|
|
||||||
# Check for number reference to a previous ambiguous match
|
|
||||||
if target.isdigit():
|
if target.isdigit():
|
||||||
idx = int(target) - 1
|
idx = int(target) - 1
|
||||||
if room_id in _pending_resolution:
|
if room_id in _pending_resolution:
|
||||||
@@ -106,16 +106,12 @@ async def resolve_user_from_target(bot, room_id, target):
|
|||||||
else:
|
else:
|
||||||
raise ValueError("No pending resolution. Use @user:domain or display name.")
|
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)
|
await _populate_name_cache(bot, room_id)
|
||||||
cache = _name_cache.get(room_id, {})
|
cache = _name_cache.get(room_id, {})
|
||||||
mxid = cache.get(target.strip().lower())
|
mxid = cache.get(target.strip().lower())
|
||||||
if mxid:
|
if mxid:
|
||||||
return mxid, target.strip().lower()
|
return mxid, target.strip().lower()
|
||||||
elif mxid is None:
|
elif mxid is None:
|
||||||
# Ambiguous: fetch and raise
|
|
||||||
resp = await bot.async_client.joined_members(room_id)
|
resp = await bot.async_client.joined_members(room_id)
|
||||||
matches = []
|
matches = []
|
||||||
for member in resp.members:
|
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}")
|
logger.error(f"Failed to fetch bans: {e}")
|
||||||
return []
|
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
|
# Main command handler
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
"""Dispatches !admin or standalone moderation commands."""
|
|
||||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
if not match.is_not_from_this_bot() or not match.prefix():
|
if not match.is_not_from_this_bot() or not match.prefix():
|
||||||
return
|
return
|
||||||
@@ -200,7 +246,7 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
"ban": "ban",
|
"ban": "ban",
|
||||||
"unban": "unban",
|
"unban": "unban",
|
||||||
"invite": "invite",
|
"invite": "invite",
|
||||||
"userinfo": "whois", # <-- renamed from "whois" to "userinfo"
|
"userinfo": "whois",
|
||||||
"op": "op",
|
"op": "op",
|
||||||
"deop": "deop",
|
"deop": "deop",
|
||||||
"topic": "topic",
|
"topic": "topic",
|
||||||
@@ -208,6 +254,8 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
"avatar": "avatar",
|
"avatar": "avatar",
|
||||||
"members": "members",
|
"members": "members",
|
||||||
"bans": "bans",
|
"bans": "bans",
|
||||||
|
"mkick": "mkick",
|
||||||
|
"joinrule": "joinrule",
|
||||||
"modhelp": "help",
|
"modhelp": "help",
|
||||||
"admin": "admin",
|
"admin": "admin",
|
||||||
}
|
}
|
||||||
@@ -216,7 +264,6 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
if cmd not in standalone_actions:
|
if cmd not in standalone_actions:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Permission gate (skip for help)
|
|
||||||
if cmd not in ("modhelp", "help"):
|
if cmd not in ("modhelp", "help"):
|
||||||
if not await has_mod_permission(bot, room_id, sender, config):
|
if not await has_mod_permission(bot, room_id, sender, config):
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(
|
||||||
@@ -226,7 +273,6 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
|
|
||||||
args = match.args()
|
args = match.args()
|
||||||
|
|
||||||
# Determine action and sub_args
|
|
||||||
if cmd == "admin":
|
if cmd == "admin":
|
||||||
if not args:
|
if not args:
|
||||||
await bot.api.send_text_message(room_id, "Usage: !admin <action> [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
|
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:
|
if not sub_args:
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(
|
||||||
room_id, f"Missing user. Usage: !{cmd} <@user|name> [reason...]"
|
room_id, f"Missing user. Usage: !{cmd} <@user|name> [reason...]"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# For op/deop, the last token might be a power level (number)
|
|
||||||
if action in ("op", "deop"):
|
if action in ("op", "deop"):
|
||||||
# Try to parse last token as power level
|
|
||||||
potential_pl = sub_args[-1]
|
potential_pl = sub_args[-1]
|
||||||
try:
|
try:
|
||||||
power = int(potential_pl)
|
power = int(potential_pl)
|
||||||
# Success: power level found, name is sub_args[:-1]
|
|
||||||
name_tokens = sub_args[:-1]
|
name_tokens = sub_args[:-1]
|
||||||
if not name_tokens:
|
if not name_tokens:
|
||||||
await bot.api.send_text_message(room_id, "Missing user name.")
|
await bot.api.send_text_message(room_id, "Missing user name.")
|
||||||
return
|
return
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# No numeric power, whole sub_args is the name
|
|
||||||
name_tokens = sub_args
|
name_tokens = sub_args
|
||||||
power = None
|
power = None
|
||||||
else:
|
else:
|
||||||
# kick, ban, invite, userinfo
|
name_tokens = sub_args
|
||||||
name_tokens = sub_args # entire args is the name
|
|
||||||
power = None
|
power = None
|
||||||
|
|
||||||
# Resolve the multi-word name
|
|
||||||
try:
|
try:
|
||||||
target_mxid, target_display = await _resolve_multiword(bot, room_id, name_tokens)
|
target_mxid, target_display = await _resolve_multiword(bot, room_id, name_tokens)
|
||||||
except UserResolutionError as e:
|
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))
|
await bot.api.send_text_message(room_id, "\n".join(lines))
|
||||||
return
|
return
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
# Fallback: also try the old way with just the first token (maybe they used @user)
|
|
||||||
target_str = sub_args[0]
|
target_str = sub_args[0]
|
||||||
try:
|
try:
|
||||||
target_mxid, target_display = await resolve_user_from_target(bot, room_id, target_str)
|
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))
|
await bot.api.send_text_message(room_id, str(e2))
|
||||||
return
|
return
|
||||||
|
|
||||||
# Determine reason and power level for op/deop
|
|
||||||
if action in ("op", "deop"):
|
if action in ("op", "deop"):
|
||||||
if action == "op":
|
if action == "op":
|
||||||
requested_pl = power if power is not None else 50
|
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}")
|
await bot.api.send_text_message(room_id, f"❌ Failed to set power: {e}")
|
||||||
|
|
||||||
else:
|
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 ""
|
reason = " ".join(sub_args[len(name_tokens):]) if len(sub_args) > len(name_tokens) else ""
|
||||||
|
|
||||||
if action == "kick":
|
if action == "kick":
|
||||||
@@ -345,7 +428,7 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
await bot.api.send_text_message(room_id, f"❌ Failed to invite: {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:
|
try:
|
||||||
resp = await bot.async_client.joined_members(room_id)
|
resp = await bot.async_client.joined_members(room_id)
|
||||||
member_info = None
|
member_info = None
|
||||||
@@ -385,7 +468,6 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
# TOPIC, ROOMNAME, AVATAR, MEMBERS, BANS, HELP ...
|
# TOPIC, ROOMNAME, AVATAR, MEMBERS, BANS, HELP ...
|
||||||
# (unchanged)
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
elif action == "topic":
|
elif action == "topic":
|
||||||
if not sub_args:
|
if not sub_args:
|
||||||
@@ -477,6 +559,8 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
- `!ban <@user|name> [reason]` – Ban a user
|
- `!ban <@user|name> [reason]` – Ban a user
|
||||||
- `!unban <@user:domain>` – Unban (full MXID required)
|
- `!unban <@user:domain>` – Unban (full MXID required)
|
||||||
- `!invite <@user|name>` – Invite a user
|
- `!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`)
|
- `!userinfo <@user|name>` – Show user details & power level (was `!whois`)
|
||||||
- `!op <@user|name> [pl=50]` – Promote user (max 50, moderator)
|
- `!op <@user|name> [pl=50]` – Promote user (max 50, moderator)
|
||||||
- `!deop <@user|name>` – Demote user to power level 0
|
- `!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`."
|
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
|
# Plugin metadata
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
__version__ = "1.1.1"
|
__version__ = "1.2.3"
|
||||||
__author__ = "Funguy Admin"
|
__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__ = """
|
__help__ = """
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>Admin / Moderator Commands</strong></summary>
|
<summary><strong>Admin / Moderator Commands</strong></summary>
|
||||||
<ul>
|
<ul>
|
||||||
<li><code>!kick</code>, <code>!ban</code>, <code>!unban</code>, <code>!invite</code></li>
|
<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>!op</code> (max PL 50), <code>!deop</code></li>
|
||||||
<li><code>!topic</code>, <code>!roomname</code>, <code>!avatar</code></li>
|
<li><code>!topic</code>, <code>!roomname</code>, <code>!avatar</code></li>
|
||||||
<li><code>!members</code>, <code>!bans</code></li>
|
<li><code>!members</code>, <code>!bans</code></li>
|
||||||
@@ -516,5 +647,11 @@ __help__ = """
|
|||||||
</ul>
|
</ul>
|
||||||
<p>Power level ≥ 50 required (or global admin).</p>
|
<p>Power level ≥ 50 required (or global admin).</p>
|
||||||
<p>Multi‑word display names are automatically recognized.</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>
|
</details>
|
||||||
"""
|
"""
|
||||||
|
|||||||
+1194
File diff suppressed because it is too large
Load Diff
+115
@@ -0,0 +1,115 @@
|
|||||||
|
"""
|
||||||
|
This plugin provides commands to interact with different AI models.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import simplematrixbotlib as botlib
|
||||||
|
import re
|
||||||
|
import markdown2
|
||||||
|
|
||||||
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
|
"""
|
||||||
|
Function to handle AI commands.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
room (Room): The Matrix room where the command was invoked.
|
||||||
|
message (RoomMessage): The message object containing the command.
|
||||||
|
bot (Bot): The bot object.
|
||||||
|
prefix (str): The command prefix.
|
||||||
|
config (dict): Configuration parameters.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None
|
||||||
|
"""
|
||||||
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
|
if match.is_not_from_this_bot() and match.prefix():
|
||||||
|
logging.info(f"Received command: {match.command()}")
|
||||||
|
|
||||||
|
command = match.command()
|
||||||
|
conf = load_config()
|
||||||
|
if command in conf:
|
||||||
|
await handle_ai_command(room, bot, command, match.args(), conf)
|
||||||
|
|
||||||
|
async def handle_ai_command(room, bot, command, args, config):
|
||||||
|
"""
|
||||||
|
Function to handle AI commands.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
room (Room): The Matrix room where the command was invoked.
|
||||||
|
bot (Bot): The bot object.
|
||||||
|
command (str): The name of the AI model command.
|
||||||
|
args (list): List of arguments provided with the command.
|
||||||
|
config (dict): Configuration parameters.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None
|
||||||
|
"""
|
||||||
|
if len(args) < 1:
|
||||||
|
await bot.api.send_text_message(room.room_id, f"Usage: !{command} [prompt]")
|
||||||
|
logging.info("Sent usage message to the room")
|
||||||
|
return
|
||||||
|
|
||||||
|
prompt = ' '.join(args)
|
||||||
|
|
||||||
|
# Prepare data for the API request
|
||||||
|
url = "http://127.0.0.1:5000/v1/completions"
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"prompt": f"<s>[INST]{config[command]['prompt']}{prompt}[/INST]",
|
||||||
|
"max_tokens": 4096,
|
||||||
|
"temperature": config[command]["temperature"],
|
||||||
|
"top_p": config[command]["top_p"],
|
||||||
|
"top_k": config[command]["top_k"],
|
||||||
|
"repetition_penalty": config[command]["repetition_penalty"],
|
||||||
|
"seed": -1,
|
||||||
|
"stream": False
|
||||||
|
}
|
||||||
|
|
||||||
|
# Make HTTP request to the API endpoint
|
||||||
|
try:
|
||||||
|
response = requests.post(url, headers=headers, json=data, verify=False, timeout=300)
|
||||||
|
response.raise_for_status() # Raise HTTPError for bad responses
|
||||||
|
payload = response.json()
|
||||||
|
new_text = payload['choices'][0]['text']
|
||||||
|
new_text = markdown_to_html(new_text)
|
||||||
|
|
||||||
|
if new_text.count('<p>') > 1 or new_text.count('<li>') > 1: # Check if new_text has more than one paragraph
|
||||||
|
new_text = f"<details><summary><strong>{config[command]['summary']}</strong></summary>{new_text}</details>"
|
||||||
|
await bot.api.send_markdown_message(room.room_id, new_text)
|
||||||
|
else:
|
||||||
|
await bot.api.send_markdown_message(room.room_id, new_text)
|
||||||
|
logging.info("Sent generated text to the room")
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logging.error(f"HTTP request failed for '{prompt}': {e}")
|
||||||
|
await bot.api.send_text_message(room.room_id, f"Error generating text: {e}")
|
||||||
|
|
||||||
|
def markdown_to_html(markdown_text):
|
||||||
|
"""
|
||||||
|
Convert Markdown text to HTML.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
markdown_text (str): Markdown formatted text.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: HTML formatted text.
|
||||||
|
"""
|
||||||
|
html_content = markdown2.markdown(markdown_text)
|
||||||
|
return html_content
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
"""
|
||||||
|
Load configuration from ai.json file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Configuration parameters.
|
||||||
|
"""
|
||||||
|
with open("plugins/ai.json", "r") as f:
|
||||||
|
config = json.load(f)
|
||||||
|
return config
|
||||||
|
|
||||||
|
CONFIG = load_config()
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
"""
|
||||||
|
Factoids plugin – a clone of the classic infobot / supybot Factoids plugin.
|
||||||
|
Stores and retrieves factoids via Matrix chat.
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
!fact <key> – retrieve a factoid
|
||||||
|
!fact search <query> – search factoids by key or value
|
||||||
|
!fact info <key> – show metadata for a factoid
|
||||||
|
!fact random – show a random factoid
|
||||||
|
!fact stats – show database statistics
|
||||||
|
!fact list [glob] – list factoid keys matching a glob pattern
|
||||||
|
!fact lock <key> – lock a factoid (admin only)
|
||||||
|
!fact unlock <key> – unlock a factoid (admin only)
|
||||||
|
!fact change <key> is <val> – change an existing factoid
|
||||||
|
!learn <key> is <value> – teach the bot a new factoid
|
||||||
|
!forget <key> – delete a factoid
|
||||||
|
!also <key> is <value> – append to an existing factoid
|
||||||
|
!no, <key> is <value> – replace a factoid (same as change)
|
||||||
|
|
||||||
|
In‑line query (no prefix needed):
|
||||||
|
<key>? – ask for a factoid
|
||||||
|
|
||||||
|
Special value tags:
|
||||||
|
<reply> text – replies with "text" (not "key is text")
|
||||||
|
<action> text – replies as an emote (/me)
|
||||||
|
a | b | c – picks one option at random
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import asyncio
|
||||||
|
import simplematrixbotlib as botlib
|
||||||
|
from plugins.common import code_block, collapsible_summary, html_escape
|
||||||
|
|
||||||
|
DB_PATH = "factoids.db"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Database helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
"""Ensure the factoids table exists."""
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS factoids (
|
||||||
|
factoid_key VARCHAR(64) NOT NULL DEFAULT '' PRIMARY KEY,
|
||||||
|
requested_by VARCHAR(80),
|
||||||
|
requested_time INTEGER,
|
||||||
|
requested_count SMALLINT,
|
||||||
|
created_by VARCHAR(80),
|
||||||
|
created_time INTEGER DEFAULT 0,
|
||||||
|
modified_by VARCHAR(80),
|
||||||
|
modified_time INTEGER,
|
||||||
|
locked_by VARCHAR(80),
|
||||||
|
locked_time INTEGER,
|
||||||
|
factoid_value TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def _conn():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Core operations
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _normalise_key(raw: str) -> str:
|
||||||
|
"""Lower-case, strip punctuation, collapse whitespace."""
|
||||||
|
key = raw.strip().lower()
|
||||||
|
key = re.sub(r'[?.,!]+$', '', key)
|
||||||
|
key = ' '.join(key.split())
|
||||||
|
return key
|
||||||
|
|
||||||
|
def get_factoid(key: str) -> dict | None:
|
||||||
|
"""Return a factoid row (or None) and bump its request count."""
|
||||||
|
conn = _conn()
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM factoids WHERE factoid_key = ?", (key,)
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE factoids SET requested_count = COALESCE(requested_count,0)+1, "
|
||||||
|
"requested_time = ? WHERE factoid_key = ?",
|
||||||
|
(int(time.time()), key)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
result = dict(row)
|
||||||
|
else:
|
||||||
|
result = None
|
||||||
|
conn.close()
|
||||||
|
return result
|
||||||
|
|
||||||
|
def set_factoid(key: str, value: str, created_by: str, locked_by: str = None):
|
||||||
|
"""Insert or replace a factoid."""
|
||||||
|
now = int(time.time())
|
||||||
|
conn = _conn()
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT OR REPLACE INTO factoids
|
||||||
|
(factoid_key, factoid_value, created_by, created_time, modified_by, modified_time, locked_by, locked_time)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(key, value, created_by, now, created_by, now, locked_by, now if locked_by else None)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def append_factoid(key: str, addition: str, modified_by: str) -> bool:
|
||||||
|
"""Append text to an existing factoid. Returns True if it existed."""
|
||||||
|
conn = _conn()
|
||||||
|
row = conn.execute("SELECT factoid_value FROM factoids WHERE factoid_key = ?", (key,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
conn.close()
|
||||||
|
return False
|
||||||
|
new_value = row["factoid_value"] + " or " + addition
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE factoids SET factoid_value = ?, modified_by = ?, modified_time = ? WHERE factoid_key = ?",
|
||||||
|
(new_value, modified_by, int(time.time()), key)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def delete_factoid(key: str) -> bool:
|
||||||
|
"""Delete a factoid. Returns True if it existed."""
|
||||||
|
conn = _conn()
|
||||||
|
cur = conn.execute("DELETE FROM factoids WHERE factoid_key = ?", (key,))
|
||||||
|
existed = cur.rowcount > 0
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return existed
|
||||||
|
|
||||||
|
def search_factoids(query: str, limit: int = 20) -> list[dict]:
|
||||||
|
"""Search factoids by key or value."""
|
||||||
|
conn = _conn()
|
||||||
|
like = f"%{query}%"
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM factoids WHERE factoid_key LIKE ? OR factoid_value LIKE ? LIMIT ?",
|
||||||
|
(like, like, limit)
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
def list_keys(glob_pattern: str = None, limit: int = 50) -> list[str]:
|
||||||
|
"""List factoid keys, optionally matching a glob pattern."""
|
||||||
|
conn = _conn()
|
||||||
|
if glob_pattern:
|
||||||
|
like = glob_pattern.replace("*", "%").replace("?", "_")
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT factoid_key FROM factoids WHERE factoid_key LIKE ? ORDER BY factoid_key LIMIT ?",
|
||||||
|
(like, limit)
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT factoid_key FROM factoids ORDER BY factoid_key LIMIT ?",
|
||||||
|
(limit,)
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
return [r["factoid_key"] for r in rows]
|
||||||
|
|
||||||
|
def random_factoid() -> dict | None:
|
||||||
|
"""Return a random factoid."""
|
||||||
|
conn = _conn()
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM factoids ORDER BY RANDOM() LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def get_stats() -> dict:
|
||||||
|
"""Return aggregate statistics."""
|
||||||
|
conn = _conn()
|
||||||
|
total = conn.execute("SELECT COUNT(*) AS n FROM factoids").fetchone()["n"]
|
||||||
|
top = conn.execute(
|
||||||
|
"SELECT factoid_key, requested_count FROM factoids ORDER BY COALESCE(requested_count,0) DESC LIMIT 10"
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
return {"total": total, "top": [dict(r) for r in top]}
|
||||||
|
|
||||||
|
def lock_factoid(key: str, locked_by: str) -> bool:
|
||||||
|
"""Lock a factoid. Returns True if it existed."""
|
||||||
|
conn = _conn()
|
||||||
|
cur = conn.execute(
|
||||||
|
"UPDATE factoids SET locked_by = ?, locked_time = ? WHERE factoid_key = ?",
|
||||||
|
(locked_by, int(time.time()), key)
|
||||||
|
)
|
||||||
|
existed = cur.rowcount > 0
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return existed
|
||||||
|
|
||||||
|
def unlock_factoid(key: str) -> bool:
|
||||||
|
"""Unlock a factoid. Returns True if it existed."""
|
||||||
|
conn = _conn()
|
||||||
|
cur = conn.execute(
|
||||||
|
"UPDATE factoids SET locked_by = NULL, locked_time = NULL WHERE factoid_key = ?",
|
||||||
|
(key,)
|
||||||
|
)
|
||||||
|
existed = cur.rowcount > 0
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return existed
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Value formatting
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _format_response(key: str, raw_value: str) -> str:
|
||||||
|
"""Format a factoid value for display, handling <reply>, <action>, and |."""
|
||||||
|
value = raw_value.strip()
|
||||||
|
|
||||||
|
if value.startswith("<reply>"):
|
||||||
|
return value[len("<reply>"):].strip()
|
||||||
|
|
||||||
|
if value.startswith("<action>"):
|
||||||
|
action = value[len("<action>"):].strip()
|
||||||
|
return f"* {key} {action}"
|
||||||
|
|
||||||
|
if "|" in value:
|
||||||
|
parts = [p.strip() for p in value.split("|")]
|
||||||
|
return f"{key} is {random.choice(parts)}"
|
||||||
|
|
||||||
|
return f"{key} is {value}"
|
||||||
|
|
||||||
|
def _format_info(fact: dict) -> str:
|
||||||
|
"""Format factoid metadata as code-block rows."""
|
||||||
|
rows = [
|
||||||
|
("🔑", "Key", fact["factoid_key"]),
|
||||||
|
("📝", "Value", fact["factoid_value"][:200] + ("…" if len(fact.get("factoid_value",""))>200 else "")),
|
||||||
|
]
|
||||||
|
if fact.get("created_by"):
|
||||||
|
rows.append(("👤", "Created by", fact["created_by"]))
|
||||||
|
if fact.get("created_time"):
|
||||||
|
rows.append(("📅", "Created", time.strftime("%Y-%m-%d", time.localtime(fact["created_time"]))))
|
||||||
|
if fact.get("modified_by") and fact["modified_by"] != fact.get("created_by"):
|
||||||
|
rows.append(("✏️", "Modified by", fact["modified_by"]))
|
||||||
|
if fact.get("requested_count"):
|
||||||
|
rows.append(("🔢", "Requested", f"{fact['requested_count']} times"))
|
||||||
|
if fact.get("locked_by"):
|
||||||
|
rows.append(("🔒", "Locked by", fact["locked_by"]))
|
||||||
|
sections = [{"title": "", "rows": rows}]
|
||||||
|
return code_block(f"ℹ️ Factoid Info: {fact['factoid_key']}", sections)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Command handler
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
|
init_db()
|
||||||
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
|
room_id = room.room_id
|
||||||
|
sender = str(message.sender)
|
||||||
|
body = (message.body or "").strip()
|
||||||
|
is_admin = (sender == config.admin_user)
|
||||||
|
|
||||||
|
# ---- In-line factoid query: X? (no prefix needed) ----
|
||||||
|
# Only retrieval is allowed without prefix; learning requires !learn etc.
|
||||||
|
if match.is_not_from_this_bot() and not match.prefix():
|
||||||
|
stripped = body.strip()
|
||||||
|
if stripped.endswith("?") and not stripped.startswith("!"):
|
||||||
|
key = _normalise_key(stripped[:-1])
|
||||||
|
if key:
|
||||||
|
fact = get_factoid(key)
|
||||||
|
if fact:
|
||||||
|
resp = _format_response(key, fact["factoid_value"])
|
||||||
|
await bot.api.send_markdown_message(room_id, resp)
|
||||||
|
return
|
||||||
|
# All learning now requires a ! prefix, so we ignore unprefixed messages
|
||||||
|
return
|
||||||
|
|
||||||
|
# ---- Prefixed commands ----
|
||||||
|
if not (match.is_not_from_this_bot() and match.prefix()):
|
||||||
|
return
|
||||||
|
|
||||||
|
cmd = match.command()
|
||||||
|
args = match.args()
|
||||||
|
|
||||||
|
# !fact
|
||||||
|
if cmd == "fact":
|
||||||
|
if not args:
|
||||||
|
await _send_help(room, bot)
|
||||||
|
return
|
||||||
|
|
||||||
|
sub = args[0].lower()
|
||||||
|
|
||||||
|
# !fact search <query>
|
||||||
|
if sub == "search" and len(args) >= 2:
|
||||||
|
query = " ".join(args[1:])
|
||||||
|
results = search_factoids(query)
|
||||||
|
if not results:
|
||||||
|
await bot.api.send_text_message(room_id, f"🔍 No factoids matching '{html_escape(query)}'.")
|
||||||
|
return
|
||||||
|
rows = []
|
||||||
|
for f in results:
|
||||||
|
val = f["factoid_value"][:80] + ("…" if len(f["factoid_value"]) > 80 else "")
|
||||||
|
rows.append(("📌", f["factoid_key"], val))
|
||||||
|
sections = [{"title": f"Search: {html_escape(query)}", "rows": rows}]
|
||||||
|
block = code_block(f"🔍 Factoid Search: {html_escape(query)}", sections)
|
||||||
|
output = collapsible_summary(f"🔍 Factoids matching '{html_escape(query)}'", block)
|
||||||
|
await bot.api.send_markdown_message(room_id, output)
|
||||||
|
return
|
||||||
|
|
||||||
|
# !fact info <key>
|
||||||
|
if sub == "info" and len(args) >= 2:
|
||||||
|
key = _normalise_key(" ".join(args[1:]))
|
||||||
|
fact = get_factoid(key)
|
||||||
|
if not fact:
|
||||||
|
await bot.api.send_text_message(room_id, f"❌ No factoid for '{html_escape(key)}'.")
|
||||||
|
return
|
||||||
|
await bot.api.send_markdown_message(room_id, _format_info(fact))
|
||||||
|
return
|
||||||
|
|
||||||
|
# !fact random
|
||||||
|
if sub == "random":
|
||||||
|
fact = random_factoid()
|
||||||
|
if not fact:
|
||||||
|
await bot.api.send_text_message(room_id, "📭 No factoids in the database yet.")
|
||||||
|
return
|
||||||
|
resp = _format_response(fact["factoid_key"], fact["factoid_value"])
|
||||||
|
await bot.api.send_markdown_message(room_id, resp)
|
||||||
|
return
|
||||||
|
|
||||||
|
# !fact stats
|
||||||
|
if sub == "stats":
|
||||||
|
stats = get_stats()
|
||||||
|
rows = [("📊", "Total factoids", str(stats["total"]))]
|
||||||
|
for i, t in enumerate(stats["top"], 1):
|
||||||
|
count = t.get("requested_count") or 0
|
||||||
|
rows.append(("🏅", f"#{i} {t['factoid_key']}", f"{count} requests"))
|
||||||
|
sections = [{"title": "Factoid Statistics", "rows": rows}]
|
||||||
|
block = code_block("📊 Factoid Stats", sections)
|
||||||
|
output = collapsible_summary("📊 Factoid Statistics", block)
|
||||||
|
await bot.api.send_markdown_message(room_id, output)
|
||||||
|
return
|
||||||
|
|
||||||
|
# !fact list [glob]
|
||||||
|
if sub == "list":
|
||||||
|
pattern = " ".join(args[1:]) if len(args) > 1 else None
|
||||||
|
keys = list_keys(pattern)
|
||||||
|
if not keys:
|
||||||
|
await bot.api.send_text_message(room_id, "📭 No factoids found.")
|
||||||
|
return
|
||||||
|
rows = [(f"{i}.", k, "") for i, k in enumerate(keys, 1)]
|
||||||
|
title = f"Factoid Keys ({len(keys)} total)"
|
||||||
|
sections = [{"title": title, "rows": rows}]
|
||||||
|
block = code_block(f"📋 {title}", sections)
|
||||||
|
output = collapsible_summary(title, block)
|
||||||
|
await bot.api.send_markdown_message(room_id, output)
|
||||||
|
return
|
||||||
|
|
||||||
|
# !fact lock <key>
|
||||||
|
if sub == "lock" and len(args) >= 2:
|
||||||
|
if not is_admin:
|
||||||
|
await bot.api.send_text_message(room_id, "⛔ Admin only.")
|
||||||
|
return
|
||||||
|
key = _normalise_key(" ".join(args[1:]))
|
||||||
|
if lock_factoid(key, sender):
|
||||||
|
await bot.api.send_text_message(room_id, f"🔒 Locked '{html_escape(key)}'.")
|
||||||
|
else:
|
||||||
|
await bot.api.send_text_message(room_id, f"❌ No factoid for '{html_escape(key)}'.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# !fact unlock <key>
|
||||||
|
if sub == "unlock" and len(args) >= 2:
|
||||||
|
if not is_admin:
|
||||||
|
await bot.api.send_text_message(room_id, "⛔ Admin only.")
|
||||||
|
return
|
||||||
|
key = _normalise_key(" ".join(args[1:]))
|
||||||
|
if unlock_factoid(key):
|
||||||
|
await bot.api.send_text_message(room_id, f"🔓 Unlocked '{html_escape(key)}'.")
|
||||||
|
else:
|
||||||
|
await bot.api.send_text_message(room_id, f"❌ No factoid for '{html_escape(key)}'.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# !fact change <key> is <value>
|
||||||
|
if sub == "change" and len(args) >= 2:
|
||||||
|
rest = " ".join(args[1:])
|
||||||
|
m = re.match(r'^(.+?)\s+is\s+(.+)$', rest, re.IGNORECASE)
|
||||||
|
if not m:
|
||||||
|
await bot.api.send_text_message(room_id, "Usage: !fact change <key> is <value>")
|
||||||
|
return
|
||||||
|
key = _normalise_key(m.group(1).strip())
|
||||||
|
value = m.group(2).strip()
|
||||||
|
existing = get_factoid(key)
|
||||||
|
if not existing:
|
||||||
|
await bot.api.send_text_message(room_id, f"❌ No factoid for '{html_escape(key)}'. Use !learn to create one.")
|
||||||
|
return
|
||||||
|
if existing.get("locked_by") and not is_admin:
|
||||||
|
await bot.api.send_text_message(room_id, f"🔒 '{html_escape(key)}' is locked by {existing['locked_by']}.")
|
||||||
|
return
|
||||||
|
set_factoid(key, value, sender)
|
||||||
|
await bot.api.send_text_message(room_id, f"✏️ Changed '{html_escape(key)}'.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# !fact <key> (bare retrieval)
|
||||||
|
key = _normalise_key(" ".join(args))
|
||||||
|
fact = get_factoid(key)
|
||||||
|
if not fact:
|
||||||
|
keys = list_keys(f"*{key}*")
|
||||||
|
if keys:
|
||||||
|
suggestions = ", ".join(keys[:10])
|
||||||
|
await bot.api.send_text_message(room_id, f"❌ No factoid for '{html_escape(key)}'. Did you mean: {suggestions}?")
|
||||||
|
else:
|
||||||
|
await bot.api.send_text_message(room_id, f"❌ No factoid for '{html_escape(key)}'.")
|
||||||
|
return
|
||||||
|
resp = _format_response(key, fact["factoid_value"])
|
||||||
|
await bot.api.send_markdown_message(room_id, resp)
|
||||||
|
return
|
||||||
|
|
||||||
|
# !learn <key> is <value>
|
||||||
|
if cmd == "learn":
|
||||||
|
if not args:
|
||||||
|
await bot.api.send_text_message(room_id, "Usage: !learn <key> is <value>")
|
||||||
|
return
|
||||||
|
rest = " ".join(args)
|
||||||
|
m = re.match(r'^(.+?)\s+is\s+(.+)$', rest, re.IGNORECASE)
|
||||||
|
if not m:
|
||||||
|
await bot.api.send_text_message(room_id, "Usage: !learn <key> is <value>")
|
||||||
|
return
|
||||||
|
key = _normalise_key(m.group(1).strip())
|
||||||
|
value = m.group(2).strip()
|
||||||
|
existing = get_factoid(key)
|
||||||
|
if existing and existing.get("locked_by") and not is_admin:
|
||||||
|
await bot.api.send_text_message(room_id, f"🔒 '{html_escape(key)}' is locked by {existing['locked_by']}.")
|
||||||
|
return
|
||||||
|
set_factoid(key, value, sender)
|
||||||
|
await bot.api.send_text_message(room_id, f"💡 Learned '{html_escape(key)}'.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# !forget <key>
|
||||||
|
if cmd == "forget":
|
||||||
|
if not args:
|
||||||
|
await bot.api.send_text_message(room_id, "Usage: !forget <key>")
|
||||||
|
return
|
||||||
|
key = _normalise_key(" ".join(args))
|
||||||
|
fact = get_factoid(key)
|
||||||
|
if fact and fact.get("locked_by") and not is_admin:
|
||||||
|
await bot.api.send_text_message(room_id, f"🔒 '{html_escape(key)}' is locked by {fact['locked_by']}.")
|
||||||
|
return
|
||||||
|
if delete_factoid(key):
|
||||||
|
await bot.api.send_text_message(room_id, f"🗑️ Forgot '{html_escape(key)}'.")
|
||||||
|
else:
|
||||||
|
await bot.api.send_text_message(room_id, f"❌ No factoid for '{html_escape(key)}'.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# !also <key> is <value>
|
||||||
|
if cmd == "also":
|
||||||
|
if not args:
|
||||||
|
await bot.api.send_text_message(room_id, "Usage: !also <key> is <value>")
|
||||||
|
return
|
||||||
|
rest = " ".join(args)
|
||||||
|
m = re.match(r'^(.+?)\s+is\s+(.+)$', rest, re.IGNORECASE)
|
||||||
|
if not m:
|
||||||
|
await bot.api.send_text_message(room_id, "Usage: !also <key> is <value>")
|
||||||
|
return
|
||||||
|
key = _normalise_key(m.group(1).strip())
|
||||||
|
value = m.group(2).strip()
|
||||||
|
fact = get_factoid(key)
|
||||||
|
if fact and fact.get("locked_by") and not is_admin:
|
||||||
|
await bot.api.send_text_message(room_id, f"🔒 '{html_escape(key)}' is locked by {fact['locked_by']}.")
|
||||||
|
return
|
||||||
|
if append_factoid(key, value, sender):
|
||||||
|
await bot.api.send_text_message(room_id, f"📎 Appended to '{html_escape(key)}'.")
|
||||||
|
else:
|
||||||
|
set_factoid(key, value, sender)
|
||||||
|
await bot.api.send_text_message(room_id, f"💡 Learned '{html_escape(key)}'.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# !no, <key> is <value> (same as change)
|
||||||
|
if cmd == "no":
|
||||||
|
if not args:
|
||||||
|
await bot.api.send_text_message(room_id, "Usage: !no, <key> is <value>")
|
||||||
|
return
|
||||||
|
rest = " ".join(args).lstrip(",").strip()
|
||||||
|
m = re.match(r'^(.+?)\s+is\s+(.+)$', rest, re.IGNORECASE)
|
||||||
|
if not m:
|
||||||
|
await bot.api.send_text_message(room_id, "Usage: !no, <key> is <value>")
|
||||||
|
return
|
||||||
|
key = _normalise_key(m.group(1).strip())
|
||||||
|
value = m.group(2).strip()
|
||||||
|
existing = get_factoid(key)
|
||||||
|
if not existing:
|
||||||
|
await bot.api.send_text_message(room_id, f"❌ No factoid for '{html_escape(key)}'. Use !learn to create one.")
|
||||||
|
return
|
||||||
|
if existing.get("locked_by") and not is_admin:
|
||||||
|
await bot.api.send_text_message(room_id, f"🔒 '{html_escape(key)}' is locked by {existing['locked_by']}.")
|
||||||
|
return
|
||||||
|
set_factoid(key, value, sender)
|
||||||
|
await bot.api.send_text_message(room_id, f"✏️ Changed '{html_escape(key)}'.")
|
||||||
|
return
|
||||||
|
|
||||||
|
async def _send_help(room, bot):
|
||||||
|
help_text = """
|
||||||
|
<details>
|
||||||
|
<summary><strong>📚 Factoids Plugin Help</strong></summary>
|
||||||
|
<p>
|
||||||
|
<strong>Commands:</strong><br>
|
||||||
|
<code>!fact <key></code> – retrieve a factoid<br>
|
||||||
|
<code><key>?</code> – ask for a factoid inline<br>
|
||||||
|
<code>!learn <key> is <value></code> – teach the bot<br>
|
||||||
|
<code>!forget <key></code> – delete a factoid<br>
|
||||||
|
<code>!also <key> is <value></code> – append to a factoid<br>
|
||||||
|
<code>!no, <key> is <value></code> – replace a factoid<br>
|
||||||
|
<code>!fact change <key> is <value></code> – change a factoid<br>
|
||||||
|
<code>!fact search <query></code> – search factoids<br>
|
||||||
|
<code>!fact info <key></code> – show metadata<br>
|
||||||
|
<code>!fact random</code> – random factoid<br>
|
||||||
|
<code>!fact stats</code> – statistics<br>
|
||||||
|
<code>!fact list [glob]</code> – list keys<br>
|
||||||
|
<code>!fact lock|unlock <key></code> – admin only<br>
|
||||||
|
<br>
|
||||||
|
<strong>Special values:</strong><br>
|
||||||
|
<code><reply> text</code> – replies with just "text"<br>
|
||||||
|
<code><action> text</code> – replies as /me<br>
|
||||||
|
<code>a | b | c</code> – picks one at random
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
"""
|
||||||
|
await bot.api.send_markdown_message(room.room_id, help_text)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Plugin Metadata
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
__version__ = "1.0.0"
|
||||||
|
__author__ = "Funguy Bot"
|
||||||
|
__description__ = "Factoids – infobot/supybot-style factoid storage and retrieval"
|
||||||
|
__help__ = """
|
||||||
|
<details>
|
||||||
|
<summary><strong>!fact</strong> – Factoids (infobot/supybot clone)</summary>
|
||||||
|
<ul>
|
||||||
|
<li><code>!fact <key></code> – retrieve a factoid</li>
|
||||||
|
<li><code><key>?</code> – ask for a factoid inline</li>
|
||||||
|
<li><code>!learn <key> is <value></code> – teach</li>
|
||||||
|
<li><code>!forget <key></code> – delete</li>
|
||||||
|
<li><code>!also <key> is <value></code> – append</li>
|
||||||
|
<li><code>!no, <key> is <value></code> – replace</li>
|
||||||
|
<li><code>!fact search <query></code> – search</li>
|
||||||
|
<li><code>!fact random</code> / <code>!fact stats</code> / <code>!fact list</code></li>
|
||||||
|
<li>Special tags: <code><reply></code>, <code><action></code>, pipe (<code>|</code>) for random</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
"""
|
||||||
+57
-12
@@ -9,6 +9,7 @@ Features:
|
|||||||
* View karma leaderboards (top/bottom)
|
* View karma leaderboards (top/bottom)
|
||||||
* Rate limiting to prevent spam
|
* Rate limiting to prevent spam
|
||||||
* Room-specific karma tracking
|
* Room-specific karma tracking
|
||||||
|
* Per‑target throttle (max votes per target per minute)
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
!karma - Show this help
|
!karma - Show this help
|
||||||
@@ -43,9 +44,13 @@ import time
|
|||||||
# Configuration
|
# Configuration
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# Global cooldown: one karma point per hour per voter
|
# Per‑target cooldown: one karma point per hour per user
|
||||||
COOLDOWN_SECONDS = 3600
|
COOLDOWN_SECONDS = 3600
|
||||||
|
|
||||||
|
# Per‑target throttle: max votes a target can receive per minute
|
||||||
|
PER_TARGET_THROTTLE_COUNT = 5
|
||||||
|
PER_TARGET_THROTTLE_SECONDS = 3600
|
||||||
|
|
||||||
# Database file
|
# Database file
|
||||||
DB_FILE = "karma.db"
|
DB_FILE = "karma.db"
|
||||||
|
|
||||||
@@ -56,6 +61,9 @@ display_name_cache = {}
|
|||||||
# Last time we refreshed the cache (per room)
|
# Last time we refreshed the cache (per room)
|
||||||
cache_timestamp = {}
|
cache_timestamp = {}
|
||||||
|
|
||||||
|
# Per‑target throttle tracker: (room_id, user_id) -> list of monotonic timestamps
|
||||||
|
_target_vote_times: dict[tuple[str, str], list[float]] = {}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Helper: pluralize "point" vs "points"
|
# Helper: pluralize "point" vs "points"
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -132,29 +140,25 @@ async def refresh_display_name_cache(bot, room_id):
|
|||||||
try:
|
try:
|
||||||
if hasattr(bot, 'async_client') and bot.async_client:
|
if hasattr(bot, 'async_client') and bot.async_client:
|
||||||
resp = await bot.async_client.joined_members(room_id)
|
resp = await bot.async_client.joined_members(room_id)
|
||||||
if resp.members is not None:
|
if resp.members:
|
||||||
name_map = {}
|
name_map = {}
|
||||||
for member in resp.members:
|
for member in resp.members:
|
||||||
display_name = (member.display_name or "").strip()
|
display_name = (member.display_name or "").strip()
|
||||||
if display_name:
|
if display_name:
|
||||||
name_map[display_name.lower()] = member.user_id
|
name_map[display_name.lower()] = member.user_id
|
||||||
# Also store without emojis
|
# Also store without emojis for easier matching
|
||||||
clean_name = re.sub(r'[^\w\s]', '', display_name).strip().lower()
|
clean_name = re.sub(r'[^\w\s]', '', display_name).strip().lower()
|
||||||
if clean_name and clean_name != display_name.lower():
|
if clean_name and clean_name != display_name.lower():
|
||||||
name_map[clean_name] = member.user_id
|
name_map[clean_name] = member.user_id
|
||||||
|
|
||||||
display_name_cache[room_id] = name_map
|
display_name_cache[room_id] = name_map
|
||||||
cache_timestamp[room_id] = now
|
cache_timestamp[room_id] = now
|
||||||
logging.info(f"Cached {len(name_map)} display names for room {room_id}")
|
logging.info(f"Cached {len(name_map)} display names for room {room_id}")
|
||||||
# DEBUG: show first 5 names
|
|
||||||
sample = list(name_map.items())[:5]
|
|
||||||
logging.debug(f"Sample display names: {sample}")
|
|
||||||
return
|
return
|
||||||
else:
|
|
||||||
logging.warning(f"joined_members returned None members for room {room_id}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warning(f"Could not refresh display name cache: {e}")
|
logging.warning(f"Could not refresh display name cache: {e}")
|
||||||
|
|
||||||
# init empty cache on failure
|
# If we couldn't get members, initialize empty cache
|
||||||
display_name_cache[room_id] = {}
|
display_name_cache[room_id] = {}
|
||||||
cache_timestamp[room_id] = now
|
cache_timestamp[room_id] = now
|
||||||
|
|
||||||
@@ -173,7 +177,7 @@ def resolve_display_name(room_id, display_name, bot=None):
|
|||||||
# Strip HTML tags (Matrix mention pills)
|
# Strip HTML tags (Matrix mention pills)
|
||||||
clean = re.sub(r'<[^>]+>', '', display_name).strip()
|
clean = re.sub(r'<[^>]+>', '', display_name).strip()
|
||||||
|
|
||||||
# Reject Matrix IDs outright (only if the raw input is an ID, not the cleaned one)
|
# Reject Matrix IDs outright
|
||||||
if is_matrix_id(display_name):
|
if is_matrix_id(display_name):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -181,7 +185,7 @@ def resolve_display_name(room_id, display_name, bot=None):
|
|||||||
if room_id in display_name_cache:
|
if room_id in display_name_cache:
|
||||||
name_map = display_name_cache[room_id]
|
name_map = display_name_cache[room_id]
|
||||||
|
|
||||||
# Try exact match (case‑insensitive)
|
# Try exact match (case-insensitive)
|
||||||
key = clean.lower()
|
key = clean.lower()
|
||||||
if key in name_map:
|
if key in name_map:
|
||||||
return name_map[key]
|
return name_map[key]
|
||||||
@@ -451,6 +455,28 @@ def format_karma_display(display_name, points):
|
|||||||
return f"⚖️ **{display_name}** has neutral karma (0)"
|
return f"⚖️ **{display_name}** has neutral karma (0)"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Per‑target throttle helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _is_target_throttled(room_id: str, user_id: str) -> bool:
|
||||||
|
"""Return True if the target user has received too many votes recently."""
|
||||||
|
key = (room_id, user_id)
|
||||||
|
now = time.monotonic()
|
||||||
|
times = _target_vote_times.get(key, [])
|
||||||
|
# Remove old entries
|
||||||
|
times = [t for t in times if now - t < PER_TARGET_THROTTLE_SECONDS]
|
||||||
|
_target_vote_times[key] = times
|
||||||
|
return len(times) >= PER_TARGET_THROTTLE_COUNT
|
||||||
|
|
||||||
|
|
||||||
|
def _record_target_vote(room_id: str, user_id: str):
|
||||||
|
"""Record that a vote was just cast for the target user."""
|
||||||
|
key = (room_id, user_id)
|
||||||
|
times = _target_vote_times.get(key, [])
|
||||||
|
times.append(time.monotonic())
|
||||||
|
_target_vote_times[key] = times
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Command Handlers
|
# Command Handlers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -556,6 +582,14 @@ async def process_karma_vote(room, display_name, action, voter, bot):
|
|||||||
await bot.api.send_markdown_message(room.room_id, "❌ You cannot modify your own karma!")
|
await bot.api.send_markdown_message(room.room_id, "❌ You cannot modify your own karma!")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Per‑target throttle: limit how many votes a target can receive per minute
|
||||||
|
if _is_target_throttled(room_id, user_id):
|
||||||
|
await bot.api.send_markdown_message(
|
||||||
|
room.room_id,
|
||||||
|
f"⚡ {display_name} is receiving too many votes right now. Please try again later."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
# Check cooldown
|
# Check cooldown
|
||||||
if is_on_cooldown(room_id, user_id, voter_str):
|
if is_on_cooldown(room_id, user_id, voter_str):
|
||||||
remaining = get_cooldown_remaining(room_id, user_id, voter_str)
|
remaining = get_cooldown_remaining(room_id, user_id, voter_str)
|
||||||
@@ -573,6 +607,9 @@ async def process_karma_vote(room, display_name, action, voter, bot):
|
|||||||
new_points = update_karma(room_id, user_id, change, voter_str)
|
new_points = update_karma(room_id, user_id, change, voter_str)
|
||||||
update_cooldown(room_id, user_id, voter_str)
|
update_cooldown(room_id, user_id, voter_str)
|
||||||
|
|
||||||
|
# Record target vote for throttle
|
||||||
|
_record_target_vote(room_id, user_id)
|
||||||
|
|
||||||
# Get display name for response
|
# Get display name for response
|
||||||
display_name_resolved = get_display_name_from_user_id(bot, room_id, user_id)
|
display_name_resolved = get_display_name_from_user_id(bot, room_id, user_id)
|
||||||
response = format_karma_display(display_name_resolved, new_points)
|
response = format_karma_display(display_name_resolved, new_points)
|
||||||
@@ -807,6 +844,11 @@ async def handle_inline_karma(room, message, bot):
|
|||||||
logging.debug(f"Skipping self-modification: {sender} -> {display_name}")
|
logging.debug(f"Skipping self-modification: {sender} -> {display_name}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Per‑target throttle for inline votes
|
||||||
|
if _is_target_throttled(room_id, user_id):
|
||||||
|
logging.debug(f"Inline target throttle active for {user_id}")
|
||||||
|
continue
|
||||||
|
|
||||||
# Check cooldown
|
# Check cooldown
|
||||||
if is_on_cooldown(room_id, user_id, sender):
|
if is_on_cooldown(room_id, user_id, sender):
|
||||||
logging.debug(f"Cooldown active for {sender} -> {user_id}")
|
logging.debug(f"Cooldown active for {sender} -> {user_id}")
|
||||||
@@ -817,6 +859,9 @@ async def handle_inline_karma(room, message, bot):
|
|||||||
new_points = update_karma(room_id, user_id, change, sender)
|
new_points = update_karma(room_id, user_id, change, sender)
|
||||||
update_cooldown(room_id, user_id, sender)
|
update_cooldown(room_id, user_id, sender)
|
||||||
|
|
||||||
|
# Record target vote for throttle
|
||||||
|
_record_target_vote(room_id, user_id)
|
||||||
|
|
||||||
# Format response
|
# Format response
|
||||||
display_name_resolved = get_display_name_from_user_id(bot, room_id, user_id)
|
display_name_resolved = get_display_name_from_user_id(bot, room_id, user_id)
|
||||||
arrow = "⬆️" if change > 0 else "⬇️"
|
arrow = "⬆️" if change > 0 else "⬇️"
|
||||||
@@ -855,7 +900,7 @@ def setup(bot):
|
|||||||
# Plugin Metadata
|
# Plugin Metadata
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
__version__ = "1.0.1"
|
__version__ = "1.0.2"
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "Room karma tracking system (display names only, no Matrix IDs)"
|
__description__ = "Room karma tracking system (display names only, no Matrix IDs)"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""
|
||||||
|
Plugin for generating text using Ollama's Mistral 7B Instruct model and sending it to a Matrix chat room.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from asyncio import Queue
|
||||||
|
import simplematrixbotlib as botlib
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
# Queue to store pending commands
|
||||||
|
command_queue = Queue()
|
||||||
|
|
||||||
|
API_URL = "http://localhost:11434/api/generate"
|
||||||
|
MODEL_NAME = "mistral:7b-instruct"
|
||||||
|
|
||||||
|
async def process_command(room, message, bot, prefix, config):
|
||||||
|
"""
|
||||||
|
Queue and process !text commands sequentially.
|
||||||
|
"""
|
||||||
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
|
if match.prefix() and match.command("text"):
|
||||||
|
if command_queue.empty():
|
||||||
|
await handle_command(room, message, bot, prefix, config)
|
||||||
|
else:
|
||||||
|
await command_queue.put((room, message, bot, prefix, config))
|
||||||
|
|
||||||
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
|
"""
|
||||||
|
Send the prompt to Ollama API and return the generated text.
|
||||||
|
"""
|
||||||
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
|
if not (match.prefix() and match.command("text")):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Parse optional arguments
|
||||||
|
parser = argparse.ArgumentParser(description='Generate text using Ollama API')
|
||||||
|
parser.add_argument('--max_tokens', type=int, default=512, help='Maximum tokens to generate')
|
||||||
|
parser.add_argument('--temperature', type=float, default=0.7, help='Temperature for generation')
|
||||||
|
parser.add_argument('prompt', nargs='+', help='Prompt for the model')
|
||||||
|
|
||||||
|
try:
|
||||||
|
args = parser.parse_args(message.body.split()[1:]) # Skip command itself
|
||||||
|
prompt = ' '.join(args.prompt).strip()
|
||||||
|
|
||||||
|
if not prompt:
|
||||||
|
await bot.api.send_text_message(room.room_id, "Usage: !text <your prompt here>")
|
||||||
|
return
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": MODEL_NAME,
|
||||||
|
"prompt": prompt,
|
||||||
|
"max_tokens": args.max_tokens,
|
||||||
|
"temperature": args.temperature,
|
||||||
|
"stream": False
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(API_URL, json=payload, timeout=60)
|
||||||
|
response.raise_for_status()
|
||||||
|
r = response.json()
|
||||||
|
|
||||||
|
generated_text = r.get("response", "").strip()
|
||||||
|
if not generated_text:
|
||||||
|
generated_text = "(No response from model)"
|
||||||
|
|
||||||
|
await bot.api.send_text_message(room.room_id, generated_text)
|
||||||
|
|
||||||
|
except argparse.ArgumentError as e:
|
||||||
|
await bot.api.send_text_message(room.room_id, f"Argument error: {e}")
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
await bot.api.send_text_message(room.room_id, f"Error connecting to Ollama API: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
await bot.api.send_text_message(room.room_id, f"Unexpected error: {e}")
|
||||||
|
finally:
|
||||||
|
# Process next command from the queue, if any
|
||||||
|
if not command_queue.empty():
|
||||||
|
next_command = await command_queue.get()
|
||||||
|
await handle_command(*next_command)
|
||||||
|
|
||||||
|
def print_help():
|
||||||
|
"""
|
||||||
|
Generates help text for the !text command.
|
||||||
|
"""
|
||||||
|
return """
|
||||||
|
<p>Generate text using Ollama's Mistral 7B Instruct model</p>
|
||||||
|
|
||||||
|
<p>Usage:</p>
|
||||||
|
<ul>
|
||||||
|
<li>!text <prompt> - Basic prompt for the model</li>
|
||||||
|
<li>Optional arguments:</li>
|
||||||
|
<ul>
|
||||||
|
<li>--max_tokens MAX_TOKENS - Maximum tokens to generate (default 512)</li>
|
||||||
|
<li>--temperature TEMPERATURE - Sampling temperature (default 0.7)</li>
|
||||||
|
</ul>
|
||||||
|
</ul>
|
||||||
|
"""
|
||||||
@@ -24,3 +24,5 @@ wcwidth
|
|||||||
markdown
|
markdown
|
||||||
python-cryptography-fernet-wrapper
|
python-cryptography-fernet-wrapper
|
||||||
zstandard
|
zstandard
|
||||||
|
requests
|
||||||
|
markdown2
|
||||||
|
|||||||
Reference in New Issue
Block a user