admin plugin and roomstats plugin added. cron fixed and ddg fixed
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,538 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
plugins/admin.py – Full room moderation commands.
|
||||||
|
Supports multi‑word display names, standalone commands (!op, !kick, etc.)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import simplematrixbotlib as botlib
|
||||||
|
|
||||||
|
logger = logging.getLogger("admin")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Display‑name resolution cache
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
_pending_resolution = {} # room_id → {"matches": [...], "expires": timestamp}
|
||||||
|
_name_cache = {} # room_id → {display_name.lower(): mxid}
|
||||||
|
RESOLUTION_TIMEOUT = 60
|
||||||
|
|
||||||
|
def _cleanup_resolutions():
|
||||||
|
now = time.time()
|
||||||
|
expired = [r for r, v in _pending_resolution.items() if v["expires"] < now]
|
||||||
|
for r in expired:
|
||||||
|
del _pending_resolution[r]
|
||||||
|
|
||||||
|
class UserResolutionError(Exception):
|
||||||
|
def __init__(self, matches):
|
||||||
|
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:
|
||||||
|
resp = await bot.async_client.joined_members(room_id)
|
||||||
|
if resp.members is None:
|
||||||
|
return
|
||||||
|
cache = {}
|
||||||
|
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:
|
||||||
|
cache[display] = member.user_id
|
||||||
|
_name_cache[room_id] = cache
|
||||||
|
logger.info(f"Cached {len(cache)} display names for room {room_id}")
|
||||||
|
except Exception as e:
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
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()
|
||||||
|
if candidate in cache:
|
||||||
|
mxid = cache[candidate]
|
||||||
|
if mxid is not None:
|
||||||
|
return mxid, candidate
|
||||||
|
else:
|
||||||
|
# Duplicate display name → fall through to ambiguity handling
|
||||||
|
# We'll fetch the real members for this candidate and raise UserResolutionError
|
||||||
|
resp = await bot.async_client.joined_members(room_id)
|
||||||
|
matches = []
|
||||||
|
for member in resp.members:
|
||||||
|
if (member.display_name or "").strip().lower() == candidate:
|
||||||
|
matches.append({"mxid": member.user_id, "display_name": member.display_name})
|
||||||
|
if len(matches) == 1:
|
||||||
|
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.")
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
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:
|
||||||
|
pending = _pending_resolution[room_id]
|
||||||
|
if 0 <= idx < len(pending["matches"]):
|
||||||
|
match = pending["matches"][idx]
|
||||||
|
del _pending_resolution[room_id]
|
||||||
|
return match["mxid"], match.get("display_name")
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Invalid selection {target}. Choose from the list.")
|
||||||
|
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:
|
||||||
|
if (member.display_name or "").strip().lower() == target.strip().lower():
|
||||||
|
matches.append({"mxid": member.user_id, "display_name": member.display_name})
|
||||||
|
if len(matches) == 1:
|
||||||
|
return matches[0]["mxid"], matches[0]["display_name"]
|
||||||
|
elif len(matches) > 1:
|
||||||
|
raise UserResolutionError(matches)
|
||||||
|
raise ValueError(f"No member with display name '{target}' found.")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Power level helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
async def get_power_level(bot, room_id, user_id):
|
||||||
|
try:
|
||||||
|
resp = await bot.async_client.room_get_state_event(
|
||||||
|
room_id, "m.room.power_levels"
|
||||||
|
)
|
||||||
|
if resp.content:
|
||||||
|
return resp.content.get("users", {}).get(
|
||||||
|
user_id, resp.content.get("users_default", 0)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def has_mod_permission(bot, room_id, user_id, config):
|
||||||
|
if user_id == config.admin_user:
|
||||||
|
return True
|
||||||
|
pl = await get_power_level(bot, room_id, user_id)
|
||||||
|
return pl >= 50
|
||||||
|
|
||||||
|
async def fetch_power_levels(bot, room_id):
|
||||||
|
try:
|
||||||
|
resp = await bot.async_client.room_get_state_event(
|
||||||
|
room_id, "m.room.power_levels"
|
||||||
|
)
|
||||||
|
return resp.content if resp.content else {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def set_power_level(bot, room_id, user_id, new_pl):
|
||||||
|
current = await fetch_power_levels(bot, room_id)
|
||||||
|
if not current:
|
||||||
|
raise RuntimeError("Could not retrieve power levels.")
|
||||||
|
users = current.setdefault("users", {})
|
||||||
|
users[user_id] = new_pl
|
||||||
|
await bot.async_client.room_put_state(
|
||||||
|
room_id, "m.room.power_levels", current
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_banned_users(bot, room_id):
|
||||||
|
try:
|
||||||
|
resp = await bot.async_client.room_get_state(room_id)
|
||||||
|
banned = []
|
||||||
|
for event in resp.events:
|
||||||
|
if (
|
||||||
|
event.get("type") == "m.room.member"
|
||||||
|
and event.get("content", {}).get("membership") == "ban"
|
||||||
|
):
|
||||||
|
banned.append(event["state_key"])
|
||||||
|
return banned
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to fetch bans: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 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
|
||||||
|
|
||||||
|
room_id = room.room_id
|
||||||
|
sender = message.sender
|
||||||
|
|
||||||
|
standalone_actions = {
|
||||||
|
"kick": "kick",
|
||||||
|
"ban": "ban",
|
||||||
|
"unban": "unban",
|
||||||
|
"invite": "invite",
|
||||||
|
"whois": "whois",
|
||||||
|
"op": "op",
|
||||||
|
"deop": "deop",
|
||||||
|
"topic": "topic",
|
||||||
|
"roomname": "roomname",
|
||||||
|
"avatar": "avatar",
|
||||||
|
"members": "members",
|
||||||
|
"bans": "bans",
|
||||||
|
"modhelp": "help",
|
||||||
|
"admin": "admin",
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd = match.command()
|
||||||
|
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(
|
||||||
|
room_id, "⛔ You don't have permission to use moderator commands."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
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...]")
|
||||||
|
return
|
||||||
|
action = args[0].lower()
|
||||||
|
sub_args = args[1:] if len(args) > 1 else []
|
||||||
|
else:
|
||||||
|
action = cmd
|
||||||
|
sub_args = args
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# User-targeting actions
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
if action in ("kick", "ban", "invite", "whois", "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)
|
||||||
|
# For kick/ban, everything after the name is the reason.
|
||||||
|
# Strategy: Try progressively longer multi-word names from the start of sub_args.
|
||||||
|
# For op: if the very last token is a pure integer, it's a power level; rest is the name.
|
||||||
|
# For kick/ban: everything is name except we can't know the boundary, so just try the whole thing.
|
||||||
|
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, whois
|
||||||
|
name_tokens = sub_args # entire args is the name
|
||||||
|
power = None
|
||||||
|
|
||||||
|
# Resolve the multi-word name
|
||||||
|
try:
|
||||||
|
target_mxid, target_display = await _resolve_multiword(bot, room_id, name_tokens)
|
||||||
|
except UserResolutionError as e:
|
||||||
|
lines = [
|
||||||
|
"Multiple users found. Re‑issue the command with a number:"
|
||||||
|
]
|
||||||
|
for i, m in enumerate(e.matches, 1):
|
||||||
|
lines.append(f"{i}. {m['mxid']} ({m['display_name']})")
|
||||||
|
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)
|
||||||
|
except Exception as e2:
|
||||||
|
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
|
||||||
|
if requested_pl > 50:
|
||||||
|
await bot.api.send_text_message(
|
||||||
|
room_id,
|
||||||
|
"Maximum power level for op is 50 (moderator). Setting to 50."
|
||||||
|
)
|
||||||
|
new_pl = 50
|
||||||
|
else:
|
||||||
|
new_pl = requested_pl
|
||||||
|
else:
|
||||||
|
new_pl = 0
|
||||||
|
|
||||||
|
sender_pl = await get_power_level(bot, room_id, sender)
|
||||||
|
target_pl = await get_power_level(bot, room_id, target_mxid)
|
||||||
|
if sender != config.admin_user and sender_pl <= target_pl:
|
||||||
|
await bot.api.send_text_message(
|
||||||
|
room_id,
|
||||||
|
"⛔ You can only modify users with a lower power level than yours.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await set_power_level(bot, room_id, target_mxid, new_pl)
|
||||||
|
verb = "Promoted" if action == "op" else "Demoted"
|
||||||
|
await bot.api.send_text_message(
|
||||||
|
room_id, f"✅ {verb} {target_mxid} to power level {new_pl}."
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
await bot.api.send_text_message(room_id, f"❌ Failed to set power: {e}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
# For kick/ban/invite: 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":
|
||||||
|
try:
|
||||||
|
await bot.async_client.room_kick(room_id, target_mxid, reason)
|
||||||
|
await bot.api.send_text_message(room_id, f"👢 Kicked {target_mxid}.")
|
||||||
|
except Exception as e:
|
||||||
|
await bot.api.send_text_message(room_id, f"❌ Failed to kick: {e}")
|
||||||
|
|
||||||
|
elif action == "ban":
|
||||||
|
try:
|
||||||
|
await bot.async_client.room_ban(room_id, target_mxid, reason)
|
||||||
|
await bot.api.send_text_message(room_id, f"🚫 Banned {target_mxid}.")
|
||||||
|
except Exception as e:
|
||||||
|
await bot.api.send_text_message(room_id, f"❌ Failed to ban: {e}")
|
||||||
|
|
||||||
|
elif action == "invite":
|
||||||
|
try:
|
||||||
|
await bot.async_client.room_invite(room_id, target_mxid)
|
||||||
|
await bot.api.send_text_message(room_id, f"📨 Invited {target_mxid}.")
|
||||||
|
except Exception as e:
|
||||||
|
await bot.api.send_text_message(room_id, f"❌ Failed to invite: {e}")
|
||||||
|
|
||||||
|
elif action == "whois":
|
||||||
|
try:
|
||||||
|
resp = await bot.async_client.joined_members(room_id)
|
||||||
|
member_info = None
|
||||||
|
for m in resp.members:
|
||||||
|
if m.user_id == target_mxid:
|
||||||
|
member_info = m
|
||||||
|
break
|
||||||
|
pl = await get_power_level(bot, room_id, target_mxid)
|
||||||
|
display = member_info.display_name if member_info else "Unknown"
|
||||||
|
msg = (
|
||||||
|
f"**User:** `{target_mxid}`\n"
|
||||||
|
f"**Display Name:** {display}\n"
|
||||||
|
f"**Power Level:** {pl}"
|
||||||
|
)
|
||||||
|
await bot.api.send_text_message(room_id, msg)
|
||||||
|
except Exception as e:
|
||||||
|
await bot.api.send_text_message(room_id, f"❌ Failed to get whois: {e}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# UNBAN
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
elif action == "unban":
|
||||||
|
if not sub_args:
|
||||||
|
await bot.api.send_text_message(room_id, "Usage: !unban <@user:domain>")
|
||||||
|
return
|
||||||
|
target_str = sub_args[0]
|
||||||
|
if not target_str.startswith("@"):
|
||||||
|
await bot.api.send_text_message(
|
||||||
|
room_id, "For unban, please provide the full Matrix ID (e.g., @user:domain)."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await bot.async_client.room_unban(room_id, target_str)
|
||||||
|
await bot.api.send_text_message(room_id, f"🔓 Unbanned {target_str}.")
|
||||||
|
except Exception as e:
|
||||||
|
await bot.api.send_text_message(room_id, f"❌ Failed to unban: {e}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# TOPIC
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
elif action == "topic":
|
||||||
|
if not sub_args:
|
||||||
|
try:
|
||||||
|
resp = await bot.async_client.room_get_state_event(room_id, "m.room.topic")
|
||||||
|
topic = resp.content.get("topic", "(none)") if resp.content else "(none)"
|
||||||
|
await bot.api.send_text_message(room_id, f"📝 Topic: {topic}")
|
||||||
|
except Exception:
|
||||||
|
await bot.api.send_text_message(room_id, "Could not retrieve topic.")
|
||||||
|
else:
|
||||||
|
new_topic = " ".join(sub_args)
|
||||||
|
try:
|
||||||
|
await bot.async_client.room_put_state(room_id, "m.room.topic", {"topic": new_topic})
|
||||||
|
await bot.api.send_text_message(room_id, "📝 Topic updated.")
|
||||||
|
except Exception as e:
|
||||||
|
await bot.api.send_text_message(room_id, f"❌ Failed: {e}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# ROOM NAME
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
elif action == "roomname":
|
||||||
|
if not sub_args:
|
||||||
|
try:
|
||||||
|
resp = await bot.async_client.room_get_state_event(room_id, "m.room.name")
|
||||||
|
name = resp.content.get("name", "(none)") if resp.content else "(none)"
|
||||||
|
await bot.api.send_text_message(room_id, f"🏠 Room name: {name}")
|
||||||
|
except Exception:
|
||||||
|
await bot.api.send_text_message(room_id, "Could not retrieve room name.")
|
||||||
|
else:
|
||||||
|
new_name = " ".join(sub_args)
|
||||||
|
try:
|
||||||
|
await bot.async_client.room_put_state(room_id, "m.room.name", {"name": new_name})
|
||||||
|
await bot.api.send_text_message(room_id, "🏠 Room name updated.")
|
||||||
|
except Exception as e:
|
||||||
|
await bot.api.send_text_message(room_id, f"❌ Failed: {e}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# AVATAR
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
elif action == "avatar":
|
||||||
|
if not sub_args:
|
||||||
|
try:
|
||||||
|
resp = await bot.async_client.room_get_state_event(room_id, "m.room.avatar")
|
||||||
|
url = resp.content.get("url", "not set") if resp.content else "not set"
|
||||||
|
await bot.api.send_text_message(room_id, f"🖼️ Avatar URL: {url}")
|
||||||
|
except Exception:
|
||||||
|
await bot.api.send_text_message(room_id, "Could not retrieve avatar.")
|
||||||
|
else:
|
||||||
|
mxc_url = sub_args[0]
|
||||||
|
if not mxc_url.startswith("mxc://"):
|
||||||
|
await bot.api.send_text_message(room_id, "Invalid avatar URL. Must start with mxc://.")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await bot.async_client.room_put_state(room_id, "m.room.avatar", {"url": mxc_url})
|
||||||
|
await bot.api.send_text_message(room_id, "🖼️ Avatar set.")
|
||||||
|
except Exception as e:
|
||||||
|
await bot.api.send_text_message(room_id, f"❌ Failed: {e}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# MEMBERS
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
elif action == "members":
|
||||||
|
try:
|
||||||
|
resp = await bot.async_client.joined_members(room_id)
|
||||||
|
if not resp.members:
|
||||||
|
await bot.api.send_text_message(room_id, "No members found.")
|
||||||
|
return
|
||||||
|
lines = ["**Members:**"]
|
||||||
|
for member in resp.members:
|
||||||
|
display = member.display_name or member.user_id
|
||||||
|
pl = await get_power_level(bot, room_id, member.user_id)
|
||||||
|
lines.append(f"{display} (`{member.user_id}`) [PL: {pl}]")
|
||||||
|
msg = "\n".join(lines)
|
||||||
|
while len(msg) > 4000:
|
||||||
|
part = msg[:4000]
|
||||||
|
msg = msg[4000:]
|
||||||
|
await bot.api.send_text_message(room_id, part)
|
||||||
|
if msg:
|
||||||
|
await bot.api.send_text_message(room_id, msg)
|
||||||
|
except Exception as e:
|
||||||
|
await bot.api.send_text_message(room_id, f"❌ Failed to list members: {e}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# BANS
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
elif action == "bans":
|
||||||
|
try:
|
||||||
|
banned = await get_banned_users(bot, room_id)
|
||||||
|
if not banned:
|
||||||
|
await bot.api.send_text_message(room_id, "No banned users.")
|
||||||
|
else:
|
||||||
|
ban_list = "\n".join(f"• `{u}`" for u in banned)
|
||||||
|
await bot.api.send_text_message(room_id, f"**Banned users:**\n{ban_list}")
|
||||||
|
except Exception as e:
|
||||||
|
await bot.api.send_text_message(room_id, f"❌ Failed to fetch bans: {e}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# HELP
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
elif action in ("help", "modhelp"):
|
||||||
|
help_text = """
|
||||||
|
**Moderator Commands (standalone or via !admin):**
|
||||||
|
- `!kick <@user|name> [reason]` – Kick a user
|
||||||
|
- `!ban <@user|name> [reason]` – Ban a user
|
||||||
|
- `!unban <@user:domain>` – Unban (full MXID required)
|
||||||
|
- `!invite <@user|name>` – Invite a user
|
||||||
|
- `!whois <@user|name>` – Show user details & power level
|
||||||
|
- `!op <@user|name> [pl=50]` – Promote user (max 50, moderator)
|
||||||
|
- `!deop <@user|name>` – Demote user to power level 0
|
||||||
|
- `!topic [new topic]` – Show / set room topic
|
||||||
|
- `!roomname [new name]` – Show / set room name
|
||||||
|
- `!avatar [mxc://...]` – Show / set room avatar
|
||||||
|
- `!members` – List all joined members with power levels
|
||||||
|
- `!bans` – List all banned users
|
||||||
|
- `!modhelp` – Show this help
|
||||||
|
|
||||||
|
Names may be **multi‑word**; the bot will automatically detect them.
|
||||||
|
If the name is ambiguous you'll be asked to choose from a numbered list.
|
||||||
|
"""
|
||||||
|
await bot.api.send_text_message(room_id, help_text.strip())
|
||||||
|
|
||||||
|
else:
|
||||||
|
await bot.api.send_text_message(
|
||||||
|
room_id, f"Unknown action: {action}. Use `!modhelp`."
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Plugin metadata
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
__version__ = "1.1.0"
|
||||||
|
__author__ = "Funguy Admin"
|
||||||
|
__description__ = "Full room moderation – multi‑word name support"
|
||||||
|
__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>!op</code> (max PL 50), <code>!deop</code>, <code>!whois</code></li>
|
||||||
|
<li><code>!topic</code>, <code>!roomname</code>, <code>!avatar</code></li>
|
||||||
|
<li><code>!members</code>, <code>!bans</code></li>
|
||||||
|
<li><code>!admin <action></code> also works as a parent command</li>
|
||||||
|
</ul>
|
||||||
|
<p>Power level ≥ 50 required (or global admin).</p>
|
||||||
|
<p>Multi‑word display names are automatically recognized.</p>
|
||||||
|
</details>
|
||||||
|
"""
|
||||||
+399
-64
@@ -1,81 +1,416 @@
|
|||||||
# plugins/cron.py
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
plugins/cron.py – In‑process cron scheduler (no system crontab).
|
||||||
|
Room ID is derived automatically from the command context.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from crontab import CronTab
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
import pytz
|
||||||
|
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
|
|
||||||
# Database connection and cursor
|
logger = logging.getLogger("cron")
|
||||||
conn = sqlite3.connect('cron.db')
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Create table if not exists
|
# ------------------------------------------------------------------
|
||||||
cursor.execute('''CREATE TABLE IF NOT EXISTS cron (
|
# Database
|
||||||
room_id TEXT,
|
# ------------------------------------------------------------------
|
||||||
cron_entry TEXT,
|
DB_PATH = "cron_jobs.db"
|
||||||
command TEXT
|
|
||||||
)''')
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
def init_db():
|
||||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
with sqlite3.connect(DB_PATH) as conn:
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("cron"):
|
conn.execute("""
|
||||||
args = match.args()
|
CREATE TABLE IF NOT EXISTS cron_jobs (
|
||||||
if len(args) >= 4:
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
action = args[0]
|
room_id TEXT NOT NULL,
|
||||||
room_id = args[1]
|
cron_expr TEXT NOT NULL,
|
||||||
cron_entry = ' '.join(args[2:-1])
|
command TEXT NOT NULL,
|
||||||
command = args[-1]
|
timezone TEXT DEFAULT 'UTC',
|
||||||
if action == "add":
|
enabled INTEGER DEFAULT 1,
|
||||||
add_cron(room_id, cron_entry, command)
|
added_by TEXT DEFAULT ''
|
||||||
await bot.api.send_text_message(room.room_id, f"Cron added successfully")
|
)
|
||||||
elif action == "remove":
|
""")
|
||||||
remove_cron(room_id, command)
|
conn.commit()
|
||||||
await bot.api.send_text_message(room.room_id, f"Cron removed successfully")
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Fake objects for command injection
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
class FakeRoom:
|
||||||
|
def __init__(self, room_id):
|
||||||
|
self.room_id = room_id
|
||||||
|
|
||||||
|
class FakeMessage:
|
||||||
|
def __init__(self, body):
|
||||||
|
self.sender = "@cron:system"
|
||||||
|
self.body = body
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Scheduler
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
scheduler = AsyncIOScheduler()
|
||||||
|
|
||||||
|
def load_jobs(bot):
|
||||||
|
"""Load all enabled jobs from DB into scheduler."""
|
||||||
|
with sqlite3.connect(DB_PATH) as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, room_id, cron_expr, command, timezone FROM cron_jobs WHERE enabled=1"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
job_id = str(row["id"])
|
||||||
|
trigger = CronTrigger.from_crontab(row["cron_expr"], timezone=pytz.timezone(row["timezone"]))
|
||||||
|
scheduler.add_job(
|
||||||
|
fire_job,
|
||||||
|
trigger=trigger,
|
||||||
|
args=[bot, row["room_id"], row["command"]],
|
||||||
|
id=job_id,
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
logger.info(f"Loaded cron job {job_id}: {row['cron_expr']} -> {row['command']}")
|
||||||
|
|
||||||
|
async def fire_job(bot, room_id: str, command: str):
|
||||||
|
"""Execute a scheduled command by injecting it into the bot's dispatcher."""
|
||||||
|
room = FakeRoom(room_id)
|
||||||
|
message = FakeMessage(command)
|
||||||
|
|
||||||
|
# Prefer the main bot instance (set by funguy.py) for full dispatch
|
||||||
|
if hasattr(bot, "main_bot"):
|
||||||
|
await bot.main_bot.handle_commands(room, message)
|
||||||
else:
|
else:
|
||||||
await bot.api.send_text_message(room.room_id, "Usage: !cron add|remove room_id cron_entry command")
|
# Fallback: direct plugin call
|
||||||
|
prefix = bot.config.prefix
|
||||||
def add_cron(room_id, cron_entry, command):
|
if command.startswith(prefix):
|
||||||
# Check if the cron entry already exists in the database for the given room_id and command
|
body = command[len(prefix):].strip()
|
||||||
cursor.execute('SELECT * FROM cron WHERE room_id=? AND command=? AND cron_entry=?', (room_id, command, cron_entry))
|
if " " in body:
|
||||||
existing_entry = cursor.fetchone()
|
plugin_name, _ = body.split(" ", 1)
|
||||||
if existing_entry:
|
else:
|
||||||
return # Cron entry already exists, do not add duplicate
|
plugin_name = body
|
||||||
|
|
||||||
# Insert the cron entry into the database
|
|
||||||
cursor.execute('INSERT INTO cron (room_id, cron_entry, command) VALUES (?, ?, ?)', (room_id, cron_entry, command))
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
def remove_cron(room_id, command):
|
|
||||||
cursor.execute('DELETE FROM cron WHERE room_id=? AND command=?', (room_id, command))
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
async def run_cron_jobs(bot):
|
|
||||||
cron = CronTab()
|
|
||||||
for job in cron:
|
|
||||||
cron_entry = str(job)
|
|
||||||
for row in cursor.execute('SELECT * FROM cron WHERE cron_entry=?', (cron_entry,)):
|
|
||||||
room_id, _, command = row
|
|
||||||
room = await bot.api.get_room_by_id(room_id)
|
|
||||||
if room:
|
|
||||||
plugin_name = command.split()[0].replace(prefix, '') # Extract plugin name
|
|
||||||
plugin_module = bot.plugins.get(plugin_name)
|
plugin_module = bot.plugins.get(plugin_name)
|
||||||
if plugin_module:
|
if plugin_module:
|
||||||
await plugin_module.handle_command(room, None, bot, prefix, config)
|
logger.info(f"Cron executing via {plugin_name}: {command}")
|
||||||
|
await plugin_module.handle_command(room, message, bot, prefix, bot.config)
|
||||||
|
else:
|
||||||
|
logger.warning(f"No plugin found for cron command: {command}")
|
||||||
|
await bot.api.send_text_message(room_id, f"[cron] Unknown command: {command}")
|
||||||
|
else:
|
||||||
|
# Non‑bot command: just post as plain text
|
||||||
|
await bot.api.send_text_message(room_id, command)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Plugin Metadata
|
# Setup (called by FunguyBot after bot is created)
|
||||||
# ---------------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
def setup(bot):
|
||||||
|
init_db()
|
||||||
|
load_jobs(bot)
|
||||||
|
if not scheduler.running:
|
||||||
|
scheduler.start()
|
||||||
|
logger.info("APScheduler started")
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
# ------------------------------------------------------------------
|
||||||
__author__ = "Funguy Bot"
|
# Command handler – auto‑detects room_id from the current room
|
||||||
__description__ = "Cron job scheduler"
|
# ------------------------------------------------------------------
|
||||||
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
|
if not (match.is_not_from_this_bot() and match.prefix() and match.command("cron")):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Admin only
|
||||||
|
if str(message.sender) != config.admin_user:
|
||||||
|
await bot.api.send_text_message(room.room_id, "⛔ You must be admin to use cron.")
|
||||||
|
return
|
||||||
|
|
||||||
|
args = match.args()
|
||||||
|
if not args:
|
||||||
|
await bot.api.send_text_message(room.room_id,
|
||||||
|
"📋 Usage: !cron <add|remove|list|enable|disable|clear> [arguments]")
|
||||||
|
return
|
||||||
|
|
||||||
|
action = args[0].lower()
|
||||||
|
current_room = room.room_id # ← automatically derived
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# ADD: !cron add <cron_expr> <command> [tz=Timezone]
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
if action == "add":
|
||||||
|
if len(args) < 3: # at least: add, cron_expr, command
|
||||||
|
await bot.api.send_text_message(room.room_id,
|
||||||
|
"Usage: `!cron add <cron_expr> <command> [tz=IANA]`\n"
|
||||||
|
"Example: `!cron add 0 8 * * * !weather london tz=Europe/London`")
|
||||||
|
return
|
||||||
|
|
||||||
|
cron_parts = []
|
||||||
|
command_parts = []
|
||||||
|
timezone = "UTC"
|
||||||
|
# The cron expression is everything between 'add' and the last part that
|
||||||
|
# starts with '!', or the whole remaining if no '!'. Simple heuristic:
|
||||||
|
# We'll assume the command starts with the bot's prefix (or a word that is a plugin command).
|
||||||
|
# Better: separate at the first argument that looks like a command (starts with prefix or no spaces?).
|
||||||
|
# We'll use: after "add", take all tokens until we encounter a token that is likely a command
|
||||||
|
# (i.e., starts with the prefix or contains a space? Actually let's just take everything after the add
|
||||||
|
# as command except the trailing tz=).
|
||||||
|
|
||||||
|
all_remaining = args[1:] # everything after "add"
|
||||||
|
# Find possible tz= at the end
|
||||||
|
if all_remaining and all_remaining[-1].startswith("tz="):
|
||||||
|
timezone_str = all_remaining[-1]
|
||||||
|
timezone = timezone_str.split("=", 1)[1]
|
||||||
|
all_remaining = all_remaining[:-1] # remove tz part
|
||||||
|
|
||||||
|
# Now all_remaining is the cron expression + command all in one list.
|
||||||
|
# The last element of all_remaining might be the full command if it was quoted?
|
||||||
|
# But MessageMatch.args splits by spaces, so multi-word commands are broken.
|
||||||
|
# To keep it simple, we'll require the user to wrap the command in quotes if it contains spaces.
|
||||||
|
# That is: !cron add "* * * * *" "!echo Hello World" tz=...
|
||||||
|
# However, Matrix messages typically don't preserve quotes.
|
||||||
|
# So we'll instead define: the cron expression consists of exactly 5 fields (minute, hour, dom, month, dow).
|
||||||
|
# So take the first 5 tokens after "add" as cron_expr. The rest is the command.
|
||||||
|
# If the command needs to be multiple words, they must be the remaining tokens.
|
||||||
|
if len(all_remaining) < 6:
|
||||||
|
await bot.api.send_text_message(room.room_id,
|
||||||
|
"Invalid syntax. Cron expression requires 5 fields (min hour dom month dow).\n"
|
||||||
|
"Example: `!cron add 0 8 * * * !weather london`")
|
||||||
|
return
|
||||||
|
|
||||||
|
cron_expr_tokens = all_remaining[:5]
|
||||||
|
command_tokens = all_remaining[5:]
|
||||||
|
cron_expr = " ".join(cron_expr_tokens)
|
||||||
|
command = " ".join(command_tokens) # may be multi-word, e.g., "!weather london"
|
||||||
|
|
||||||
|
# Validate timezone
|
||||||
|
if timezone not in pytz.all_timezones:
|
||||||
|
await bot.api.send_text_message(room.room_id,
|
||||||
|
f"❌ Unknown timezone: `{timezone}`. Use IANA names like UTC, Europe/London.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Validate cron expression
|
||||||
|
try:
|
||||||
|
CronTrigger.from_crontab(cron_expr, timezone=pytz.timezone(timezone))
|
||||||
|
except Exception as e:
|
||||||
|
await bot.api.send_text_message(room.room_id,
|
||||||
|
f"❌ Invalid cron expression: `{cron_expr}` – {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Store in DB using current_room
|
||||||
|
with sqlite3.connect(DB_PATH) as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO cron_jobs (room_id, cron_expr, command, timezone, added_by) VALUES (?,?,?,?,?)",
|
||||||
|
(current_room, cron_expr, command, timezone, str(message.sender))
|
||||||
|
)
|
||||||
|
job_id = cur.lastrowid
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Add to scheduler
|
||||||
|
trigger = CronTrigger.from_crontab(cron_expr, timezone=pytz.timezone(timezone))
|
||||||
|
scheduler.add_job(
|
||||||
|
fire_job,
|
||||||
|
trigger=trigger,
|
||||||
|
args=[bot, current_room, command],
|
||||||
|
id=str(job_id),
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
await bot.api.send_text_message(room.room_id,
|
||||||
|
f"✅ Cron job **#{job_id}** added in this room:\n"
|
||||||
|
f"Schedule: `{cron_expr}` ({timezone})\n"
|
||||||
|
f"Command: `{command}`")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# REMOVE: !cron remove <job_id> (only from current room)
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
elif action == "remove":
|
||||||
|
if len(args) != 2:
|
||||||
|
await bot.api.send_text_message(room.room_id, "Usage: `!cron remove <job_id>`")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
job_id = int(args[1])
|
||||||
|
except ValueError:
|
||||||
|
await bot.api.send_text_message(room.room_id, "❌ Job ID must be a number.")
|
||||||
|
return
|
||||||
|
|
||||||
|
with sqlite3.connect(DB_PATH) as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"DELETE FROM cron_jobs WHERE id=? AND room_id=?",
|
||||||
|
(job_id, current_room)
|
||||||
|
)
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
await bot.api.send_text_message(room.room_id,
|
||||||
|
f"❌ No job #{job_id} found in this room.")
|
||||||
|
return
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if scheduler.get_job(str(job_id)):
|
||||||
|
scheduler.remove_job(str(job_id))
|
||||||
|
|
||||||
|
await bot.api.send_text_message(room.room_id, f"🗑️ Job #{job_id} removed from this room.")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# LIST: !cron list [*] (default: current room, * for all rooms)
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
elif action == "list":
|
||||||
|
show_all = False
|
||||||
|
if len(args) > 1:
|
||||||
|
if args[1] == "*":
|
||||||
|
show_all = True
|
||||||
|
else:
|
||||||
|
await bot.api.send_text_message(room.room_id,
|
||||||
|
"Usage: `!cron list` (this room) or `!cron list *` (all rooms)")
|
||||||
|
return
|
||||||
|
|
||||||
|
with sqlite3.connect(DB_PATH) as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
if show_all:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, room_id, cron_expr, command, timezone, enabled FROM cron_jobs ORDER BY id"
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, cron_expr, command, timezone, enabled FROM cron_jobs WHERE room_id=? ORDER BY id",
|
||||||
|
(current_room,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
scope = "all rooms" if show_all else "this room"
|
||||||
|
await bot.api.send_text_message(room.room_id, f"📭 No cron jobs in {scope}.")
|
||||||
|
return
|
||||||
|
|
||||||
|
lines = [f"**Cron jobs in {'all rooms' if show_all else 'this room'}:**\n"]
|
||||||
|
for r in rows:
|
||||||
|
status = "🟢" if r["enabled"] else "🔴"
|
||||||
|
if show_all:
|
||||||
|
lines.append(
|
||||||
|
f"{status} **#{r['id']}** in `{r['room_id']}` → "
|
||||||
|
f"`{r['cron_expr']}` ({r['timezone']})\n"
|
||||||
|
f" Cmd: `{r['command']}`"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append(
|
||||||
|
f"{status} **#{r['id']}** → `{r['cron_expr']}` ({r['timezone']})\n"
|
||||||
|
f" Cmd: `{r['command']}`"
|
||||||
|
)
|
||||||
|
message_text = "\n".join(lines)
|
||||||
|
# Chunk if needed
|
||||||
|
while len(message_text) > 2000:
|
||||||
|
split_at = message_text.rfind("\n", 0, 2000)
|
||||||
|
if split_at == -1:
|
||||||
|
split_at = 2000
|
||||||
|
chunk = message_text[:split_at]
|
||||||
|
message_text = message_text[split_at:].lstrip()
|
||||||
|
await bot.api.send_text_message(room.room_id, chunk)
|
||||||
|
if message_text:
|
||||||
|
await bot.api.send_text_message(room.room_id, message_text)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# ENABLE: !cron enable <job_id> (only if in current room)
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
elif action == "enable":
|
||||||
|
if len(args) != 2:
|
||||||
|
await bot.api.send_text_message(room.room_id, "Usage: `!cron enable <job_id>`")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
job_id = int(args[1])
|
||||||
|
except ValueError:
|
||||||
|
await bot.api.send_text_message(room.room_id, "❌ Job ID must be a number.")
|
||||||
|
return
|
||||||
|
|
||||||
|
with sqlite3.connect(DB_PATH) as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"UPDATE cron_jobs SET enabled=1 WHERE id=? AND room_id=?",
|
||||||
|
(job_id, current_room)
|
||||||
|
)
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
await bot.api.send_text_message(room.room_id, f"❌ Job #{job_id} not found in this room.")
|
||||||
|
return
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Re‑add to scheduler
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT cron_expr, command, timezone FROM cron_jobs WHERE id=?",
|
||||||
|
(job_id,)
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
trigger = CronTrigger.from_crontab(row[0], timezone=pytz.timezone(row[2]))
|
||||||
|
scheduler.add_job(
|
||||||
|
fire_job,
|
||||||
|
trigger=trigger,
|
||||||
|
args=[bot, current_room, row[1]],
|
||||||
|
id=str(job_id),
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
await bot.api.send_text_message(room.room_id, f"✅ Job #{job_id} enabled.")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# DISABLE: !cron disable <job_id> (only if in current room)
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
elif action == "disable":
|
||||||
|
if len(args) != 2:
|
||||||
|
await bot.api.send_text_message(room.room_id, "Usage: `!cron disable <job_id>`")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
job_id = int(args[1])
|
||||||
|
except ValueError:
|
||||||
|
await bot.api.send_text_message(room.room_id, "❌ Job ID must be a number.")
|
||||||
|
return
|
||||||
|
|
||||||
|
with sqlite3.connect(DB_PATH) as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"UPDATE cron_jobs SET enabled=0 WHERE id=? AND room_id=?",
|
||||||
|
(job_id, current_room)
|
||||||
|
)
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
await bot.api.send_text_message(room.room_id, f"❌ Job #{job_id} not found in this room.")
|
||||||
|
return
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if scheduler.get_job(str(job_id)):
|
||||||
|
scheduler.remove_job(str(job_id))
|
||||||
|
|
||||||
|
await bot.api.send_text_message(room.room_id, f"⏸️ Job #{job_id} disabled.")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# CLEAR: !cron clear (all jobs in current room)
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
elif action == "clear":
|
||||||
|
with sqlite3.connect(DB_PATH) as conn:
|
||||||
|
cur = conn.execute("SELECT id FROM cron_jobs WHERE room_id=?", (current_room,))
|
||||||
|
job_ids = [str(row[0]) for row in cur.fetchall()]
|
||||||
|
conn.execute("DELETE FROM cron_jobs WHERE room_id=?", (current_room,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
for jid in job_ids:
|
||||||
|
if scheduler.get_job(jid):
|
||||||
|
scheduler.remove_job(jid)
|
||||||
|
|
||||||
|
await bot.api.send_text_message(room.room_id,
|
||||||
|
f"🧹 All cron jobs cleared from this room.")
|
||||||
|
|
||||||
|
else:
|
||||||
|
await bot.api.send_text_message(room.room_id,
|
||||||
|
"❓ Unknown action. Use: add, remove, list, enable, disable, clear.")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Plugin metadata
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
__version__ = "2.1.0"
|
||||||
|
__author__ = "Funguy Cron Team"
|
||||||
|
__description__ = "In‑process cron scheduler (room‑aware, no system crontab)"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>!cron</strong> – Schedule commands via cron syntax</summary>
|
<summary><strong>!cron</strong> – Schedule commands (room‑context aware)</summary>
|
||||||
<ul>
|
<ul>
|
||||||
<li><code>!cron add <room_id> <cron_entry> <command></code> – Add job</li>
|
<li><code>!cron add <cron_expr> <command> [tz=IANA]</code> – Add job to current room</li>
|
||||||
<li><code>!cron remove <room_id> <command></code> – Remove job</li>
|
<li><code>!cron remove <job_id></code> – Remove a job</li>
|
||||||
|
<li><code>!cron list</code> – List jobs in current room</li>
|
||||||
|
<li><code>!cron list *</code> – List jobs in all rooms (admin)</li>
|
||||||
|
<li><code>!cron enable <job_id></code> – Re‑enable a disabled job</li>
|
||||||
|
<li><code>!cron disable <job_id></code> – Disable a job</li>
|
||||||
|
<li><code>!cron clear</code> – Remove all jobs from current room</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>Admin only.</p>
|
<p>Admin only. Timezone defaults to UTC; use <code>tz=Europe/London</code> at end.</p>
|
||||||
|
<p>Cron expression: 5 fields (<em>min hour dom month dow</em>), e.g. <code>0 8 * * *</code></p>
|
||||||
</details>
|
</details>
|
||||||
"""
|
"""
|
||||||
|
|||||||
+283
-448
@@ -1,510 +1,345 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
This plugin provides DuckDuckGo search functionality using the DuckDuckGo Instant Answer API.
|
DuckDuckGo search plugin (ddgs library). Results are shown inside collapsible details boxes.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import requests
|
from html import escape
|
||||||
import json
|
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
from urllib.parse import quote, urlencode
|
from ddgs import DDGS
|
||||||
import html
|
|
||||||
|
|
||||||
DDG_API_URL = "https://api.duckduckgo.com/"
|
logger = logging.getLogger("ddg")
|
||||||
DDG_SEARCH_URL = "https://html.duckduckgo.com/html/"
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Async search wrapper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
async def _async_search(func, *args, **kwargs):
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
return await loop.run_in_executor(None, lambda: func(*args, **kwargs))
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Command handler
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
"""
|
|
||||||
Function to handle DuckDuckGo search 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)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("ddg"):
|
if not (match.is_not_from_this_bot() and match.prefix() and match.command("ddg")):
|
||||||
logging.info("Received !ddg command")
|
return
|
||||||
|
|
||||||
args = match.args()
|
args = match.args()
|
||||||
|
if not args:
|
||||||
if len(args) < 1:
|
await send_help(room, bot)
|
||||||
await show_usage(room, bot)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
subcommand = args[0].lower()
|
subcommand = args[0].lower()
|
||||||
|
|
||||||
if subcommand == "search":
|
# ---- Instant answer (default) ----
|
||||||
if len(args) < 2:
|
if subcommand in ("instant", "i"):
|
||||||
await bot.api.send_text_message(room.room_id, "Usage: !ddg search <query>")
|
query = " ".join(args[1:]) if len(args) > 1 else ""
|
||||||
return
|
if not query:
|
||||||
query = ' '.join(args[1:])
|
|
||||||
await ddg_search(room, bot, query)
|
|
||||||
|
|
||||||
elif subcommand == "instant":
|
|
||||||
if len(args) < 2:
|
|
||||||
await bot.api.send_text_message(room.room_id, "Usage: !ddg instant <query>")
|
await bot.api.send_text_message(room.room_id, "Usage: !ddg instant <query>")
|
||||||
return
|
return
|
||||||
query = ' '.join(args[1:])
|
await instant_answer(room, bot, query)
|
||||||
await ddg_instant_answer(room, bot, query)
|
|
||||||
|
|
||||||
|
# ---- Web search ----
|
||||||
|
elif subcommand == "search":
|
||||||
|
query = " ".join(args[1:]) if len(args) > 1 else ""
|
||||||
|
if not query:
|
||||||
|
await bot.api.send_text_message(room.room_id, "Usage: !ddg search <query>")
|
||||||
|
return
|
||||||
|
await web_search(room, bot, query)
|
||||||
|
|
||||||
|
# ---- Image search ----
|
||||||
elif subcommand == "image":
|
elif subcommand == "image":
|
||||||
if len(args) < 2:
|
query = " ".join(args[1:]) if len(args) > 1 else ""
|
||||||
|
if not query:
|
||||||
await bot.api.send_text_message(room.room_id, "Usage: !ddg image <query>")
|
await bot.api.send_text_message(room.room_id, "Usage: !ddg image <query>")
|
||||||
return
|
return
|
||||||
query = ' '.join(args[1:])
|
await image_search(room, bot, query)
|
||||||
await ddg_image_search(room, bot, query)
|
|
||||||
|
|
||||||
|
# ---- News search ----
|
||||||
elif subcommand == "news":
|
elif subcommand == "news":
|
||||||
if len(args) < 2:
|
query = " ".join(args[1:]) if len(args) > 1 else ""
|
||||||
|
if not query:
|
||||||
await bot.api.send_text_message(room.room_id, "Usage: !ddg news <query>")
|
await bot.api.send_text_message(room.room_id, "Usage: !ddg news <query>")
|
||||||
return
|
return
|
||||||
query = ' '.join(args[1:])
|
await news_search(room, bot, query)
|
||||||
await ddg_news_search(room, bot, query)
|
|
||||||
|
|
||||||
|
# ---- Video search ----
|
||||||
elif subcommand == "video":
|
elif subcommand == "video":
|
||||||
if len(args) < 2:
|
query = " ".join(args[1:]) if len(args) > 1 else ""
|
||||||
|
if not query:
|
||||||
await bot.api.send_text_message(room.room_id, "Usage: !ddg video <query>")
|
await bot.api.send_text_message(room.room_id, "Usage: !ddg video <query>")
|
||||||
return
|
return
|
||||||
query = ' '.join(args[1:])
|
await video_search(room, bot, query)
|
||||||
await ddg_video_search(room, bot, query)
|
|
||||||
|
|
||||||
|
# ---- Bang search ----
|
||||||
elif subcommand == "bang":
|
elif subcommand == "bang":
|
||||||
if len(args) < 2:
|
bang_query = " ".join(args[1:]) if len(args) > 1 else ""
|
||||||
await show_bang_help(room, bot)
|
if not bang_query:
|
||||||
|
await bang_help(room, bot)
|
||||||
return
|
return
|
||||||
bang_query = ' '.join(args[1:])
|
await bang_search(room, bot, bang_query)
|
||||||
await ddg_bang_search(room, bot, bang_query)
|
|
||||||
|
|
||||||
|
# ---- Definitions ----
|
||||||
elif subcommand == "define":
|
elif subcommand == "define":
|
||||||
if len(args) < 2:
|
word = " ".join(args[1:]) if len(args) > 1 else ""
|
||||||
|
if not word:
|
||||||
await bot.api.send_text_message(room.room_id, "Usage: !ddg define <word>")
|
await bot.api.send_text_message(room.room_id, "Usage: !ddg define <word>")
|
||||||
return
|
return
|
||||||
word = ' '.join(args[1:])
|
await definition(room, bot, word)
|
||||||
await ddg_definition(room, bot, word)
|
|
||||||
|
|
||||||
|
# ---- Calculator ----
|
||||||
elif subcommand == "calc":
|
elif subcommand == "calc":
|
||||||
if len(args) < 2:
|
expr = " ".join(args[1:]) if len(args) > 1 else ""
|
||||||
|
if not expr:
|
||||||
await bot.api.send_text_message(room.room_id, "Usage: !ddg calc <expression>")
|
await bot.api.send_text_message(room.room_id, "Usage: !ddg calc <expression>")
|
||||||
return
|
return
|
||||||
expression = ' '.join(args[1:])
|
await calculator(room, bot, expr)
|
||||||
await ddg_calculator(room, bot, expression)
|
|
||||||
|
|
||||||
|
# ---- Weather ----
|
||||||
elif subcommand == "weather":
|
elif subcommand == "weather":
|
||||||
location = ' '.join(args[1:]) if len(args) > 1 else ""
|
location = " ".join(args[1:]) if len(args) > 1 else ""
|
||||||
await ddg_weather(room, bot, location)
|
|
||||||
|
|
||||||
elif subcommand == "help":
|
|
||||||
await show_usage(room, bot)
|
|
||||||
|
|
||||||
else:
|
|
||||||
# Default to instant answer search
|
|
||||||
query = ' '.join(args)
|
|
||||||
await ddg_instant_answer(room, bot, query)
|
|
||||||
|
|
||||||
async def show_usage(room, bot):
|
|
||||||
"""Display DuckDuckGo command usage."""
|
|
||||||
usage = """
|
|
||||||
<strong>🦆 DuckDuckGo Search Commands</strong>
|
|
||||||
|
|
||||||
<strong>!ddg <query></strong> - Instant answer search (default)
|
|
||||||
<strong>!ddg search <query></strong> - Web search with results
|
|
||||||
<strong>!ddg instant <query></strong> - Instant answer with detailed info
|
|
||||||
<strong>!ddg image <query></strong> - Image search
|
|
||||||
<strong>!ddg news <query></strong> - News search
|
|
||||||
<strong>!ddg video <query></strong> - Video search
|
|
||||||
<strong>!ddg bang <!bang query></strong> - Use DuckDuckGo bangs
|
|
||||||
<strong>!ddg define <word></strong> - Word definitions
|
|
||||||
<strong>!ddg calc <expression></strong> - Calculator
|
|
||||||
<strong>!ddg weather [location]</strong> - Weather information
|
|
||||||
<strong>!ddg help</strong> - Show this help
|
|
||||||
|
|
||||||
<strong>Examples:</strong>
|
|
||||||
• <code>!ddg python programming</code>
|
|
||||||
• <code>!ddg search matrix protocol</code>
|
|
||||||
• <code>!ddg image cute cats</code>
|
|
||||||
• <code>!ddg bang !w matrix</code>
|
|
||||||
• <code>!ddg define serendipity</code>
|
|
||||||
• <code>!ddg calc 2+2*5</code>
|
|
||||||
• <code>!ddg weather London</code>
|
|
||||||
|
|
||||||
<strong>Popular Bangs:</strong>
|
|
||||||
• <code>!w</code> - Wikipedia
|
|
||||||
• <code>!g</code> - Google
|
|
||||||
• <code>!yt</code> - YouTube
|
|
||||||
• <code>!aw</code> - ArchWiki
|
|
||||||
• <code>!gh</code> - GitHub
|
|
||||||
• <code>!so</code> - Stack Overflow
|
|
||||||
"""
|
|
||||||
await bot.api.send_markdown_message(room.room_id, usage)
|
|
||||||
|
|
||||||
async def show_bang_help(room, bot):
|
|
||||||
"""Display DuckDuckGo bang help."""
|
|
||||||
bang_help = """
|
|
||||||
<strong>🦆 DuckDuckGo Bangs</strong>
|
|
||||||
|
|
||||||
<strong>Usage:</strong> <code>!ddg bang <!bang query></code>
|
|
||||||
|
|
||||||
<strong>Popular Bangs:</strong>
|
|
||||||
• <code>!ddg bang !w matrix</code> - Search Wikipedia
|
|
||||||
• <code>!ddg bang !g python</code> - Search Google
|
|
||||||
• <code>!ddg bang !yt music</code> - Search YouTube
|
|
||||||
• <code>!ddg bang !aw arch</code> - Search ArchWiki
|
|
||||||
• <code>!ddg bang !gh repository</code> - Search GitHub
|
|
||||||
• <code>!ddg bang !so error</code> - Search Stack Overflow
|
|
||||||
• <code>!ddg bang !amazon book</code> - Search Amazon
|
|
||||||
• <code>!ddg bang !imdb movie</code> - Search IMDb
|
|
||||||
• <code>!ddg bang !reddit topic</code> - Search Reddit
|
|
||||||
• <code>!ddg bang !tw tweet</code> - Search Twitter
|
|
||||||
|
|
||||||
<strong>More Bangs:</strong>
|
|
||||||
• <code>!ddg</code> - DuckDuckGo
|
|
||||||
• <code>!bing</code> - Bing
|
|
||||||
• <code>!ddgimages</code> - DuckDuckGo Images
|
|
||||||
• <code>!npm</code> - npm packages
|
|
||||||
• <code>!cpp</code> - C++ reference
|
|
||||||
• <code>!python</code> - Python docs
|
|
||||||
• <code>!rust</code> - Rust docs
|
|
||||||
• <code>!mdn</code> - MDN Web Docs
|
|
||||||
|
|
||||||
<em>Thousands of bangs available! See: https://duckduckgo.com/bangs</em>
|
|
||||||
"""
|
|
||||||
await bot.api.send_markdown_message(room.room_id, bang_help)
|
|
||||||
|
|
||||||
async def ddg_instant_answer(room, bot, query):
|
|
||||||
"""Get DuckDuckGo instant answer."""
|
|
||||||
try:
|
|
||||||
params = {
|
|
||||||
'q': query,
|
|
||||||
'format': 'json',
|
|
||||||
'no_html': '1',
|
|
||||||
'skip_disambig': '1',
|
|
||||||
'no_redirect': '1'
|
|
||||||
}
|
|
||||||
|
|
||||||
logging.info(f"Fetching DuckDuckGo instant answer for: {query}")
|
|
||||||
|
|
||||||
response = requests.get(DDG_API_URL, params=params, timeout=10)
|
|
||||||
|
|
||||||
if response.status_code != 200:
|
|
||||||
# If API fails, provide direct search link
|
|
||||||
search_url = f"https://duckduckgo.com/?q={quote(query)}"
|
|
||||||
await bot.api.send_markdown_message(
|
|
||||||
room.room_id,
|
|
||||||
f"<strong>🦆 DuckDuckGo: {html.escape(query)}</strong><br><br>"
|
|
||||||
f"API temporarily unavailable. <a href='{search_url}'>Search on DuckDuckGo</a>"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
output = f"<strong>🦆 DuckDuckGo: {html.escape(query)}</strong><br><br>"
|
|
||||||
|
|
||||||
# Handle different answer types
|
|
||||||
if data.get('AbstractText'):
|
|
||||||
# Wikipedia-style answer
|
|
||||||
output += f"<strong>📚 {data.get('Heading', 'Definition')}</strong><br>"
|
|
||||||
output += f"{html.escape(data['AbstractText'])}<br>"
|
|
||||||
if data.get('AbstractURL'):
|
|
||||||
output += f"<a href='{data['AbstractURL']}'>Read more on {data.get('AbstractSource', 'Wikipedia')}</a><br>"
|
|
||||||
|
|
||||||
elif data.get('Answer'):
|
|
||||||
# Direct answer
|
|
||||||
output += f"<strong>💡 Answer</strong><br>"
|
|
||||||
output += f"{html.escape(data['Answer'])}<br>"
|
|
||||||
|
|
||||||
elif data.get('Definition'):
|
|
||||||
# Definition
|
|
||||||
output += f"<strong>📖 Definition</strong><br>"
|
|
||||||
output += f"{html.escape(data['Definition'])}<br>"
|
|
||||||
if data.get('DefinitionSource'):
|
|
||||||
output += f"<em>Source: {data['DefinitionSource']}</em><br>"
|
|
||||||
|
|
||||||
elif data.get('Results'):
|
|
||||||
# List of results
|
|
||||||
output += f"<strong>🔍 Results</strong><br>"
|
|
||||||
for result in data['Results'][:3]:
|
|
||||||
output += f"• <a href='{result.get('FirstURL', '#')}'>{html.escape(result.get('Text', 'Result'))}</a><br>"
|
|
||||||
|
|
||||||
elif data.get('RelatedTopics'):
|
|
||||||
# Related topics
|
|
||||||
output += f"<strong>🔗 Related Topics</strong><br>"
|
|
||||||
for topic in data['RelatedTopics'][:3]:
|
|
||||||
if isinstance(topic, dict) and topic.get('FirstURL'):
|
|
||||||
output += f"• <a href='{topic['FirstURL']}'>{html.escape(topic.get('Text', 'Topic'))}</a><br>"
|
|
||||||
elif isinstance(topic, dict) and topic.get('Name'):
|
|
||||||
output += f"• {html.escape(topic['Name'])}<br>"
|
|
||||||
|
|
||||||
else:
|
|
||||||
# No instant answer found, show search results
|
|
||||||
output += "<strong>🔍 No instant answer found.</strong><br>"
|
|
||||||
|
|
||||||
# Add search link
|
|
||||||
search_url = f"https://duckduckgo.com/?q={quote(query)}"
|
|
||||||
output += f"<br><a href='{search_url}'>View all results on DuckDuckGo</a>"
|
|
||||||
|
|
||||||
await bot.api.send_markdown_message(room.room_id, output)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
# Fallback to direct search link
|
|
||||||
search_url = f"https://duckduckgo.com/?q={quote(query)}"
|
|
||||||
await bot.api.send_markdown_message(
|
|
||||||
room.room_id,
|
|
||||||
f"<strong>🦆 DuckDuckGo: {html.escape(query)}</strong><br><br>"
|
|
||||||
f"Error accessing API. <a href='{search_url}'>Search on DuckDuckGo</a>"
|
|
||||||
)
|
|
||||||
logging.error(f"Error in ddg_instant_answer: {e}")
|
|
||||||
|
|
||||||
async def ddg_search(room, bot, query):
|
|
||||||
"""Perform web search with multiple results."""
|
|
||||||
try:
|
|
||||||
await ddg_web_search(room, bot, query, limit=5)
|
|
||||||
except Exception as e:
|
|
||||||
await bot.api.send_text_message(room.room_id, f"Error performing search: {str(e)}")
|
|
||||||
|
|
||||||
async def ddg_web_search(room, bot, query, limit=5):
|
|
||||||
"""Perform web search and return results."""
|
|
||||||
try:
|
|
||||||
params = {
|
|
||||||
'q': query,
|
|
||||||
'format': 'json'
|
|
||||||
}
|
|
||||||
|
|
||||||
response = requests.get(DDG_API_URL, params=params, timeout=10)
|
|
||||||
|
|
||||||
if response.status_code != 200:
|
|
||||||
# Fallback to direct search
|
|
||||||
search_url = f"https://duckduckgo.com/?q={quote(query)}"
|
|
||||||
await bot.api.send_markdown_message(
|
|
||||||
room.room_id,
|
|
||||||
f"<strong>🔍 DuckDuckGo Search: {html.escape(query)}</strong><br><br>"
|
|
||||||
f"API temporarily unavailable. <a href='{search_url}'>Search on DuckDuckGo</a>"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
output = f"<strong>🔍 DuckDuckGo Search: {html.escape(query)}</strong><br><br>"
|
|
||||||
|
|
||||||
results_shown = 0
|
|
||||||
|
|
||||||
# Show instant answer if available
|
|
||||||
if data.get('AbstractText') and results_shown < limit:
|
|
||||||
output += f"<strong>💡 {data.get('Heading', 'Instant Answer')}</strong><br>"
|
|
||||||
abstract = data['AbstractText'][:200] + "..." if len(data['AbstractText']) > 200 else data['AbstractText']
|
|
||||||
output += f"{html.escape(abstract)}<br>"
|
|
||||||
if data.get('AbstractURL'):
|
|
||||||
output += f"<a href='{data['AbstractURL']}'>Read more</a><br>"
|
|
||||||
output += "<br>"
|
|
||||||
results_shown += 1
|
|
||||||
|
|
||||||
# Show web results
|
|
||||||
if data.get('Results') and results_shown < limit:
|
|
||||||
output += "<strong>🌐 Web Results</strong><br>"
|
|
||||||
for result in data['Results'][:limit - results_shown]:
|
|
||||||
output += f"• <a href='{result.get('FirstURL', '#')}'>{html.escape(result.get('Text', 'Result'))}</a><br>"
|
|
||||||
results_shown += 1
|
|
||||||
|
|
||||||
# Show related topics
|
|
||||||
if data.get('RelatedTopics') and results_shown < limit:
|
|
||||||
output += "<strong>🔗 Related Topics</strong><br>"
|
|
||||||
for topic in data['RelatedTopics'][:limit - results_shown]:
|
|
||||||
if isinstance(topic, dict) and topic.get('FirstURL'):
|
|
||||||
output += f"• <a href='{topic['FirstURL']}'>{html.escape(topic.get('Text', 'Topic'))}</a><br>"
|
|
||||||
results_shown += 1
|
|
||||||
|
|
||||||
# Add search link
|
|
||||||
search_url = f"https://duckduckgo.com/?q={quote(query)}"
|
|
||||||
output += f"<br><a href='{search_url}'>View all results on DuckDuckGo</a>"
|
|
||||||
|
|
||||||
await bot.api.send_markdown_message(room.room_id, output)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
# Fallback to direct search
|
|
||||||
search_url = f"https://duckduckgo.com/?q={quote(query)}"
|
|
||||||
await bot.api.send_markdown_message(
|
|
||||||
room.room_id,
|
|
||||||
f"<strong>🔍 DuckDuckGo Search: {html.escape(query)}</strong><br><br>"
|
|
||||||
f"Error accessing API. <a href='{search_url}'>Search on DuckDuckGo</a>"
|
|
||||||
)
|
|
||||||
logging.error(f"Error in ddg_web_search: {e}")
|
|
||||||
|
|
||||||
async def ddg_image_search(room, bot, query):
|
|
||||||
"""Perform image search."""
|
|
||||||
try:
|
|
||||||
params = {
|
|
||||||
'q': query,
|
|
||||||
'format': 'json',
|
|
||||||
'iax': 'images',
|
|
||||||
'ia': 'images'
|
|
||||||
}
|
|
||||||
|
|
||||||
response = requests.get(DDG_API_URL, params=params, timeout=10)
|
|
||||||
|
|
||||||
if response.status_code != 200:
|
|
||||||
search_url = f"https://duckduckgo.com/?q={quote(query)}&iax=images&ia=images"
|
|
||||||
await bot.api.send_markdown_message(
|
|
||||||
room.room_id,
|
|
||||||
f"<strong>🖼️ DuckDuckGo Images: {html.escape(query)}</strong><br><br>"
|
|
||||||
f"API temporarily unavailable. <a href='{search_url}'>Search images on DuckDuckGo</a>"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
output = f"<strong>🖼️ DuckDuckGo Images: {html.escape(query)}</strong><br><br>"
|
|
||||||
|
|
||||||
if data.get('Results'):
|
|
||||||
output += "<strong>📸 Image Results</strong><br>"
|
|
||||||
for image in data['Results'][:3]:
|
|
||||||
output += f"• <a href='{image.get('Image', '#')}'>{html.escape(image.get('Title', 'Image'))}</a><br>"
|
|
||||||
if image.get('Width') and image.get('Height'):
|
|
||||||
output += f" Size: {image['Width']}×{image['Height']}<br>"
|
|
||||||
else:
|
|
||||||
output += "No image results found.<br>"
|
|
||||||
|
|
||||||
# Add search link
|
|
||||||
search_url = f"https://duckduckgo.com/?q={quote(query)}&iax=images&ia=images"
|
|
||||||
output += f"<br><a href='{search_url}'>View all images on DuckDuckGo</a>"
|
|
||||||
|
|
||||||
await bot.api.send_markdown_message(room.room_id, output)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
search_url = f"https://duckduckgo.com/?q={quote(query)}&iax=images&ia=images"
|
|
||||||
await bot.api.send_markdown_message(
|
|
||||||
room.room_id,
|
|
||||||
f"<strong>🖼️ DuckDuckGo Images: {html.escape(query)}</strong><br><br>"
|
|
||||||
f"Error accessing API. <a href='{search_url}'>Search images on DuckDuckGo</a>"
|
|
||||||
)
|
|
||||||
|
|
||||||
async def ddg_news_search(room, bot, query):
|
|
||||||
"""Perform news search."""
|
|
||||||
try:
|
|
||||||
search_url = f"https://duckduckgo.com/?q={quote(query)}&iar=news"
|
|
||||||
await bot.api.send_markdown_message(
|
|
||||||
room.room_id,
|
|
||||||
f"<strong>📰 DuckDuckGo News: {html.escape(query)}</strong><br><br>"
|
|
||||||
f"<a href='{search_url}'>View news on DuckDuckGo</a>"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
await bot.api.send_text_message(room.room_id, f"Error performing news search: {str(e)}")
|
|
||||||
|
|
||||||
async def ddg_video_search(room, bot, query):
|
|
||||||
"""Perform video search."""
|
|
||||||
try:
|
|
||||||
search_url = f"https://duckduckgo.com/?q={quote(query)}&iar=videos"
|
|
||||||
await bot.api.send_markdown_message(
|
|
||||||
room.room_id,
|
|
||||||
f"<strong>🎬 DuckDuckGo Videos: {html.escape(query)}</strong><br><br>"
|
|
||||||
f"<a href='{search_url}'>View videos on DuckDuckGo</a>"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
await bot.api.send_text_message(room.room_id, f"Error performing video search: {str(e)}")
|
|
||||||
|
|
||||||
async def ddg_bang_search(room, bot, bang_query):
|
|
||||||
"""Perform search using DuckDuckGo bangs."""
|
|
||||||
try:
|
|
||||||
# Create search URL directly - this is more reliable than API for bangs
|
|
||||||
search_url = f"https://duckduckgo.com/?q={quote(bang_query)}"
|
|
||||||
|
|
||||||
# Common bangs with descriptions
|
|
||||||
bang_descriptions = {
|
|
||||||
'!w': 'Wikipedia',
|
|
||||||
'!g': 'Google',
|
|
||||||
'!yt': 'YouTube',
|
|
||||||
'!aw': 'ArchWiki',
|
|
||||||
'!gh': 'GitHub',
|
|
||||||
'!so': 'Stack Overflow',
|
|
||||||
'!amazon': 'Amazon',
|
|
||||||
'!imdb': 'IMDb',
|
|
||||||
'!reddit': 'Reddit',
|
|
||||||
'!tw': 'Twitter'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Extract bang for description
|
|
||||||
bang = bang_query.split(' ')[0] if ' ' in bang_query else bang_query
|
|
||||||
description = bang_descriptions.get(bang, 'Site-specific search')
|
|
||||||
|
|
||||||
output = f"<strong>🎯 DuckDuckGo Bang: {html.escape(bang)}</strong><br>"
|
|
||||||
output += f"<strong>Description:</strong> {description}<br>"
|
|
||||||
|
|
||||||
if ' ' in bang_query:
|
|
||||||
output += f"<strong>Query:</strong> {html.escape(bang_query.split(' ', 1)[1])}<br><br>"
|
|
||||||
|
|
||||||
output += f"<a href='{search_url}'>Search with {html.escape(bang)} on DuckDuckGo</a>"
|
|
||||||
|
|
||||||
await bot.api.send_markdown_message(room.room_id, output)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
await bot.api.send_text_message(room.room_id, f"Error with bang search: {str(e)}")
|
|
||||||
|
|
||||||
async def ddg_definition(room, bot, word):
|
|
||||||
"""Get word definition."""
|
|
||||||
try:
|
|
||||||
search_url = f"https://duckduckgo.com/?q=define+{quote(word)}"
|
|
||||||
await bot.api.send_markdown_message(
|
|
||||||
room.room_id,
|
|
||||||
f"<strong>📖 Definition: {html.escape(word)}</strong><br><br>"
|
|
||||||
f"<a href='{search_url}'>Get definition on DuckDuckGo</a>"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
await bot.api.send_text_message(room.room_id, f"Error getting definition: {str(e)}")
|
|
||||||
|
|
||||||
async def ddg_calculator(room, bot, expression):
|
|
||||||
"""Use DuckDuckGo as a calculator."""
|
|
||||||
try:
|
|
||||||
search_url = f"https://duckduckgo.com/?q={quote(expression)}"
|
|
||||||
await bot.api.send_markdown_message(
|
|
||||||
room.room_id,
|
|
||||||
f"<strong>🧮 Calculator: {html.escape(expression)}</strong><br><br>"
|
|
||||||
f"<a href='{search_url}'>Calculate on DuckDuckGo</a>"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
await bot.api.send_text_message(room.room_id, f"Error with calculator: {str(e)}")
|
|
||||||
|
|
||||||
async def ddg_weather(room, bot, location):
|
|
||||||
"""Get weather information."""
|
|
||||||
try:
|
|
||||||
if not location:
|
if not location:
|
||||||
location = "current location"
|
location = "current location"
|
||||||
|
await weather(room, bot, location)
|
||||||
|
|
||||||
search_url = f"https://duckduckgo.com/?q=weather+{quote(location)}"
|
# ---- Help ----
|
||||||
|
elif subcommand == "help":
|
||||||
|
await send_help(room, bot)
|
||||||
|
|
||||||
|
# ---- Default: treat as instant answer ----
|
||||||
|
else:
|
||||||
|
query = " ".join(args)
|
||||||
|
await instant_answer(room, bot, query)
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# Result functions (all wrapped in <details>)
|
||||||
|
# ==============================
|
||||||
|
|
||||||
|
async def instant_answer(room, bot, query):
|
||||||
|
"""Top web result wrapped in a collapsible box."""
|
||||||
|
try:
|
||||||
|
with DDGS() as ddgs:
|
||||||
|
results = await _async_search(ddgs.text, query, max_results=1)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"DDG instant answer error: {e}")
|
||||||
await bot.api.send_markdown_message(
|
await bot.api.send_markdown_message(
|
||||||
room.room_id,
|
room.room_id,
|
||||||
f"<strong>🌤️ Weather: {html.escape(location)}</strong><br><br>"
|
f"🦆 <strong>DuckDuckGo: {escape(query)}</strong><br><br>Error fetching results. Try again later."
|
||||||
f"<a href='{search_url}'>Get weather on DuckDuckGo</a>"
|
|
||||||
)
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
content = ""
|
||||||
|
if results:
|
||||||
|
r = results[0]
|
||||||
|
title = escape(r.get("title", "Result"))
|
||||||
|
body = escape(r.get("body", ""))
|
||||||
|
content = f"💡 <strong>{title}</strong><br>{body[:300]}…<br><a href='{r['href']}'>Read more</a>"
|
||||||
|
else:
|
||||||
|
search_url = f"https://duckduckgo.com/?q={escape(query)}"
|
||||||
|
content = f"No results found.<br>🔍 <a href='{search_url}'>Search on DuckDuckGo</a>"
|
||||||
|
|
||||||
|
msg = f"""<details>
|
||||||
|
<summary>🦆 DuckDuckGo: {escape(query)}</summary>
|
||||||
|
{content}
|
||||||
|
</details>"""
|
||||||
|
await bot.api.send_markdown_message(room.room_id, msg)
|
||||||
|
|
||||||
|
|
||||||
|
async def web_search(room, bot, query):
|
||||||
|
try:
|
||||||
|
with DDGS() as ddgs:
|
||||||
|
results = await _async_search(ddgs.text, query, max_results=5)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await bot.api.send_text_message(room.room_id, f"Error getting weather: {str(e)}")
|
logger.error(f"DDG web search error: {e}")
|
||||||
|
await bot.api.send_text_message(room.room_id, f"Error: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
await bot.api.send_text_message(room.room_id, f"No results for '{query}'.")
|
||||||
|
return
|
||||||
|
|
||||||
|
items = ""
|
||||||
|
for r in results:
|
||||||
|
title = escape(r.get("title", "Result"))
|
||||||
|
body = escape(r.get("body", ""))
|
||||||
|
items += f"• <a href='{r['href']}'>{title}</a><br> {body[:200]}…<br><br>"
|
||||||
|
|
||||||
|
msg = f"""<details>
|
||||||
|
<summary>🔍 Search: {escape(query)}</summary>
|
||||||
|
{items}
|
||||||
|
</details>"""
|
||||||
|
await bot.api.send_markdown_message(room.room_id, msg)
|
||||||
|
|
||||||
|
|
||||||
|
async def image_search(room, bot, query):
|
||||||
|
try:
|
||||||
|
with DDGS() as ddgs:
|
||||||
|
results = await _async_search(ddgs.images, query, max_results=3)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"DDG image error: {e}")
|
||||||
|
await bot.api.send_text_message(room.room_id, f"Error: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
await bot.api.send_text_message(room.room_id, f"No images for '{query}'.")
|
||||||
|
return
|
||||||
|
|
||||||
|
items = ""
|
||||||
|
for img in results:
|
||||||
|
title = escape(img.get("title", "Image"))
|
||||||
|
items += f"• <a href='{img['image']}'>{title}</a>"
|
||||||
|
if img.get("width") and img.get("height"):
|
||||||
|
items += f" ({img['width']}×{img['height']})"
|
||||||
|
items += "<br>"
|
||||||
|
|
||||||
|
search_url = f"https://duckduckgo.com/?q={escape(query)}&iax=images&ia=images"
|
||||||
|
items += f"<br>🔍 <a href='{search_url}'>View all images</a>"
|
||||||
|
|
||||||
|
msg = f"""<details>
|
||||||
|
<summary>🖼️ Images: {escape(query)}</summary>
|
||||||
|
{items}
|
||||||
|
</details>"""
|
||||||
|
await bot.api.send_markdown_message(room.room_id, msg)
|
||||||
|
|
||||||
|
|
||||||
|
async def news_search(room, bot, query):
|
||||||
|
try:
|
||||||
|
with DDGS() as ddgs:
|
||||||
|
results = await _async_search(ddgs.news, query, max_results=3)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"DDG news error: {e}")
|
||||||
|
await bot.api.send_text_message(room.room_id, f"Error: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
await bot.api.send_text_message(room.room_id, f"No news for '{query}'.")
|
||||||
|
return
|
||||||
|
|
||||||
|
items = ""
|
||||||
|
for n in results:
|
||||||
|
title = escape(n.get("title", "Article"))
|
||||||
|
body = escape(n.get("body", ""))
|
||||||
|
items += f"• <a href='{n['url']}'>{title}</a><br> {body[:200]}…<br><br>"
|
||||||
|
|
||||||
|
msg = f"""<details>
|
||||||
|
<summary>📰 News: {escape(query)}</summary>
|
||||||
|
{items}
|
||||||
|
</details>"""
|
||||||
|
await bot.api.send_markdown_message(room.room_id, msg)
|
||||||
|
|
||||||
|
|
||||||
|
async def video_search(room, bot, query):
|
||||||
|
try:
|
||||||
|
with DDGS() as ddgs:
|
||||||
|
results = await _async_search(ddgs.videos, query, max_results=3)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"DDG video error: {e}")
|
||||||
|
await bot.api.send_text_message(room.room_id, f"Error: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
await bot.api.send_text_message(room.room_id, f"No videos for '{query}'.")
|
||||||
|
return
|
||||||
|
|
||||||
|
items = ""
|
||||||
|
for v in results:
|
||||||
|
title = escape(v.get("title", "Video"))
|
||||||
|
items += f"• <a href='{v['content']}'>{title}</a><br>"
|
||||||
|
|
||||||
|
search_url = f"https://duckduckgo.com/?q={escape(query)}&iar=videos"
|
||||||
|
items += f"<br>🔍 <a href='{search_url}'>View all videos</a>"
|
||||||
|
|
||||||
|
msg = f"""<details>
|
||||||
|
<summary>🎬 Videos: {escape(query)}</summary>
|
||||||
|
{items}
|
||||||
|
</details>"""
|
||||||
|
await bot.api.send_markdown_message(room.room_id, msg)
|
||||||
|
|
||||||
|
|
||||||
|
async def bang_search(room, bot, bang_query):
|
||||||
|
search_url = f"https://duckduckgo.com/?q={escape(bang_query)}"
|
||||||
|
content = f"🔗 <a href='{search_url}'>Search with {escape(bang_query)} on DuckDuckGo</a>"
|
||||||
|
msg = f"""<details>
|
||||||
|
<summary>🎯 Bang: {escape(bang_query)}</summary>
|
||||||
|
{content}
|
||||||
|
</details>"""
|
||||||
|
await bot.api.send_markdown_message(room.room_id, msg)
|
||||||
|
|
||||||
|
|
||||||
|
async def definition(room, bot, word):
|
||||||
|
await instant_answer(room, bot, f"define {word}")
|
||||||
|
|
||||||
|
|
||||||
|
async def calculator(room, bot, expr):
|
||||||
|
await instant_answer(room, bot, expr)
|
||||||
|
|
||||||
|
|
||||||
|
async def weather(room, bot, location):
|
||||||
|
await instant_answer(room, bot, f"weather {location}")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Plugin Metadata
|
# Help messages (no details wrapper – kept readable)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
async def bang_help(room, bot):
|
||||||
|
msg = """
|
||||||
|
<strong>🎯 DuckDuckGo Bangs</strong><br>
|
||||||
|
Usage: <code>!ddg bang !bang query</code><br><br>
|
||||||
|
<strong>Popular bangs:</strong><br>
|
||||||
|
• <code>!w</code> – Wikipedia
|
||||||
|
• <code>!g</code> – Google
|
||||||
|
• <code>!yt</code> – YouTube
|
||||||
|
• <code>!aw</code> – ArchWiki
|
||||||
|
• <code>!gh</code> – GitHub
|
||||||
|
• <code>!so</code> – Stack Overflow
|
||||||
|
• <code>!reddit</code> – Reddit
|
||||||
|
<br>
|
||||||
|
<a href="https://duckduckgo.com/bangs">Full list here</a>
|
||||||
|
"""
|
||||||
|
await bot.api.send_markdown_message(room.room_id, msg)
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
|
||||||
|
async def send_help(room, bot):
|
||||||
|
help_msg = """
|
||||||
|
<strong>🦆 DuckDuckGo Commands</strong><br>
|
||||||
|
<code>!ddg <query></code> – Top result (collapsible)<br>
|
||||||
|
<code>!ddg search <query></code> – 5 web results<br>
|
||||||
|
<code>!ddg image <query></code> – 3 images<br>
|
||||||
|
<code>!ddg news <query></code> – 3 news articles<br>
|
||||||
|
<code>!ddg video <query></code> – 3 videos<br>
|
||||||
|
<code>!ddg bang <!bang query></code> – Bang redirect<br>
|
||||||
|
<code>!ddg define <word></code> – Definition<br>
|
||||||
|
<code>!ddg calc <expr></code> – Calculator<br>
|
||||||
|
<code>!ddg weather [city]</code> – Weather<br>
|
||||||
|
<code>!ddg help</code> – This help
|
||||||
|
"""
|
||||||
|
await bot.api.send_markdown_message(room.room_id, help_msg)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Plugin metadata
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
__version__ = "2.1.0"
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "DuckDuckGo search"
|
__description__ = "DuckDuckGo search – collapsible results (ddgs library, no API key)"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>!ddg</strong> – DuckDuckGo search and instant answers</summary>
|
<summary><strong>!ddg</strong> – DuckDuckGo search (web, images, news, etc.)</summary>
|
||||||
<ul>
|
<ul>
|
||||||
<li><code>!ddg <query></code> – Instant answer (default)</li>
|
<li><code>!ddg <query></code> – Top web result snippet (collapsible)</li>
|
||||||
<li><code>!ddg search <query></code> – Web search results</li>
|
<li><code>!ddg search <query></code> – 5 web results</li>
|
||||||
<li><code>!ddg instant <query></code> – Detailed instant answer</li>
|
<li><code>!ddg image <query></code> – 3 images</li>
|
||||||
<li><code>!ddg image <query></code> – Image search</li>
|
<li><code>!ddg news <query></code> – 3 news articles</li>
|
||||||
<li><code>!ddg news <query></code> – News search</li>
|
<li><code>!ddg video <query></code> – 3 videos</li>
|
||||||
<li><code>!ddg video <query></code> – Video search</li>
|
<li><code>!ddg bang <!bang query></code> – Bang redirect</li>
|
||||||
<li><code>!ddg bang <!bang query></code> – Use DuckDuckGo bangs</li>
|
<li><code>!ddg define <word></code> – Definition</li>
|
||||||
<li><code>!ddg define <word></code> – Word definition</li>
|
|
||||||
<li><code>!ddg calc <expression></code> – Calculator</li>
|
<li><code>!ddg calc <expression></code> – Calculator</li>
|
||||||
<li><code>!ddg weather [location]</code> – Weather information</li>
|
<li><code>!ddg weather [location]</code> – Weather</li>
|
||||||
<li><code>!ddg help</code> – Show detailed help</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<p>No API key required.</p>
|
<p>Uses <code>ddgs</code> library. No API key required.</p>
|
||||||
</details>
|
</details>
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,434 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
plugins/roomstats.py — per‑user room statistics (Limnoria‑style).
|
||||||
|
Commands: !roomstats, !rank, !stats
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import nio
|
||||||
|
import simplematrixbotlib as botlib
|
||||||
|
|
||||||
|
logger = logging.getLogger("roomstats")
|
||||||
|
|
||||||
|
DB_PATH = "roomstats.db"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Emoji / smiley regex (Unicode blocks)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
EMOJI_RE = re.compile(
|
||||||
|
"["
|
||||||
|
"\U0001F600-\U0001F64F" # Emoticons
|
||||||
|
"\U0001F300-\U0001F5FF" # Symbols & pictographs
|
||||||
|
"\U0001F680-\U0001F6FF" # Transport & map
|
||||||
|
"\U0001F1E0-\U0001F1FF" # Flags
|
||||||
|
"\U00002702-\U000027B0" # Dingbats
|
||||||
|
"\U000024C2-\U0001F251" # Misc
|
||||||
|
"]+", re.UNICODE)
|
||||||
|
|
||||||
|
def count_smileys(text):
|
||||||
|
"""Return number of emoji occurrences."""
|
||||||
|
return len(EMOJI_RE.findall(text))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Database init
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def init_db():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS user_room_stats (
|
||||||
|
room_id TEXT,
|
||||||
|
user_id TEXT,
|
||||||
|
msgs INTEGER DEFAULT 0,
|
||||||
|
chars INTEGER DEFAULT 0,
|
||||||
|
words INTEGER DEFAULT 0,
|
||||||
|
smileys INTEGER DEFAULT 0,
|
||||||
|
actions INTEGER DEFAULT 0,
|
||||||
|
joins INTEGER DEFAULT 0,
|
||||||
|
parts INTEGER DEFAULT 0,
|
||||||
|
kicks_given INTEGER DEFAULT 0,
|
||||||
|
kicked_received INTEGER DEFAULT 0,
|
||||||
|
topics_set INTEGER DEFAULT 0,
|
||||||
|
last_updated INTEGER,
|
||||||
|
PRIMARY KEY (room_id, user_id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Multi‑word user resolution helper
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
async def resolve_user_from_tokens(bot, room_id, tokens):
|
||||||
|
"""
|
||||||
|
Given a list of word tokens, find a matching display name.
|
||||||
|
Returns (mxid, display_name) or raises ValueError.
|
||||||
|
"""
|
||||||
|
# Build cache of (lowered display name → user_id) from joined members
|
||||||
|
resp = await bot.async_client.joined_members(room_id)
|
||||||
|
if resp.members is None:
|
||||||
|
raise ValueError("Could not fetch member list.")
|
||||||
|
|
||||||
|
# Create a dict: lower_display → (mxid, display_name)
|
||||||
|
# If duplicate display name, store None to signal ambiguity.
|
||||||
|
cache = {}
|
||||||
|
for member in resp.members:
|
||||||
|
display = (member.display_name or "").strip()
|
||||||
|
if not display:
|
||||||
|
continue
|
||||||
|
key = display.lower()
|
||||||
|
if key in cache:
|
||||||
|
cache[key] = None
|
||||||
|
else:
|
||||||
|
cache[key] = (member.user_id, display)
|
||||||
|
|
||||||
|
# Try progressively longer prefixes of the tokens
|
||||||
|
for end in range(len(tokens), 0, -1):
|
||||||
|
candidate = " ".join(tokens[:end]).strip().lower()
|
||||||
|
if candidate in cache:
|
||||||
|
entry = cache[candidate]
|
||||||
|
if entry is not None:
|
||||||
|
return entry # (mxid, display_name)
|
||||||
|
else:
|
||||||
|
# Ambiguous – we need to fetch and check exactly
|
||||||
|
matches = []
|
||||||
|
for member in resp.members:
|
||||||
|
if (member.display_name or "").strip().lower() == candidate:
|
||||||
|
matches.append((member.user_id, member.display_name or member.user_id))
|
||||||
|
if len(matches) == 1:
|
||||||
|
return matches[0]
|
||||||
|
elif len(matches) > 1:
|
||||||
|
raise ValueError(
|
||||||
|
f"Multiple users have display name '{candidate}'. Use an MXID instead."
|
||||||
|
)
|
||||||
|
# if none, continue
|
||||||
|
raise ValueError(f"No member found for '{' '.join(tokens)}'.")
|
||||||
|
|
||||||
|
async def resolve_user(bot, room_id, name_or_tokens):
|
||||||
|
"""
|
||||||
|
Accept either a single string (MXID or single-token display name)
|
||||||
|
or a list of tokens. Returns (mxid, display_name).
|
||||||
|
"""
|
||||||
|
if isinstance(name_or_tokens, str):
|
||||||
|
if name_or_tokens.startswith("@"):
|
||||||
|
return name_or_tokens, None
|
||||||
|
# Single token – try direct cache match or fallback to multi‑word
|
||||||
|
tokens = [name_or_tokens]
|
||||||
|
else:
|
||||||
|
tokens = name_or_tokens
|
||||||
|
|
||||||
|
return await resolve_user_from_tokens(bot, room_id, tokens)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Setup: register custom event listeners for membership & topics
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def setup(bot):
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
@bot.listener.on_custom_event(nio.RoomMemberEvent)
|
||||||
|
async def member_event(room, event):
|
||||||
|
room_id = room.room_id
|
||||||
|
membership = event.content.get("membership")
|
||||||
|
state_key = event.state_key
|
||||||
|
sender = event.sender
|
||||||
|
|
||||||
|
# Ignore the bot's own membership changes
|
||||||
|
if state_key == bot.async_client.user_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
if membership == "join":
|
||||||
|
_incr(room_id, state_key, "joins")
|
||||||
|
elif membership == "leave":
|
||||||
|
if sender != state_key: # kick
|
||||||
|
_incr(room_id, sender, "kicks_given")
|
||||||
|
_incr(room_id, state_key, "kicked_received")
|
||||||
|
else: # part
|
||||||
|
_incr(room_id, state_key, "parts")
|
||||||
|
|
||||||
|
@bot.listener.on_custom_event(nio.RoomTopicEvent)
|
||||||
|
async def topic_event(room, event):
|
||||||
|
room_id = room.room_id
|
||||||
|
sender = event.sender
|
||||||
|
_incr(room_id, sender, "topics_set")
|
||||||
|
|
||||||
|
def _incr(room_id, user_id, column):
|
||||||
|
"""Increment a stat column by 1, creating row if needed."""
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute(
|
||||||
|
"INSERT OR IGNORE INTO user_room_stats (room_id, user_id) VALUES (?, ?)",
|
||||||
|
(room_id, user_id)
|
||||||
|
)
|
||||||
|
c.execute(
|
||||||
|
f"UPDATE user_room_stats SET {column} = {column} + 1, last_updated = ? WHERE room_id = ? AND user_id = ?",
|
||||||
|
(int(time.time()), room_id, user_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Message handler – silently records stats, and handles commands
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
|
room_id = room.room_id
|
||||||
|
sender = message.sender
|
||||||
|
|
||||||
|
# ----- silently record stats for any non‑bot message -----
|
||||||
|
if sender != bot.async_client.user_id: # <-- FIXED
|
||||||
|
body = message.body or ""
|
||||||
|
words = len(body.split())
|
||||||
|
chars = len(body)
|
||||||
|
smileys = count_smileys(body)
|
||||||
|
is_action = getattr(message, "msgtype", None) == "m.emote"
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute("INSERT OR IGNORE INTO user_room_stats (room_id, user_id) VALUES (?, ?)", (room_id, sender))
|
||||||
|
c.execute(
|
||||||
|
"""UPDATE user_room_stats
|
||||||
|
SET msgs = msgs + 1,
|
||||||
|
chars = chars + ?,
|
||||||
|
words = words + ?,
|
||||||
|
smileys = smileys + ?,
|
||||||
|
actions = actions + ?,
|
||||||
|
last_updated = ?
|
||||||
|
WHERE room_id = ? AND user_id = ?""",
|
||||||
|
(chars, words, smileys, 1 if is_action else 0, int(time.time()), room_id, sender)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# ----- command matching -----
|
||||||
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
|
if not match.is_not_from_this_bot() or not match.prefix():
|
||||||
|
return
|
||||||
|
|
||||||
|
cmd = match.command()
|
||||||
|
args = match.args()
|
||||||
|
|
||||||
|
# ===============================
|
||||||
|
# !roomstats
|
||||||
|
# ===============================
|
||||||
|
if cmd == "roomstats":
|
||||||
|
await _handle_roomstats(bot, room_id)
|
||||||
|
|
||||||
|
# ===============================
|
||||||
|
# !rank <expr>
|
||||||
|
# ===============================
|
||||||
|
elif cmd == "rank":
|
||||||
|
if not args:
|
||||||
|
await bot.api.send_text_message(
|
||||||
|
room_id,
|
||||||
|
"Usage: !rank <stat>\n"
|
||||||
|
"Stats: msgs, chars, words, smileys, actions, joins, parts, "
|
||||||
|
"kicks_given, kicked_received, topics_set"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
col = args[0].lower()
|
||||||
|
await _handle_rank(bot, room_id, col)
|
||||||
|
|
||||||
|
# ===============================
|
||||||
|
# !stats [<name>]
|
||||||
|
# ===============================
|
||||||
|
elif cmd == "stats":
|
||||||
|
if args:
|
||||||
|
# Use all tokens as the display name (multi‑word)
|
||||||
|
try:
|
||||||
|
target_mxid, _ = await resolve_user_from_tokens(bot, room_id, args)
|
||||||
|
except ValueError as e:
|
||||||
|
await bot.api.send_text_message(room_id, str(e))
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
target_mxid = sender
|
||||||
|
await _handle_user_stats(bot, room_id, target_mxid, sender)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Command implementations
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
VALID_STATS = {
|
||||||
|
"msgs": "Messages",
|
||||||
|
"chars": "Characters",
|
||||||
|
"words": "Words",
|
||||||
|
"smileys": "Smileys",
|
||||||
|
"actions": "Actions",
|
||||||
|
"joins": "Joins",
|
||||||
|
"parts": "Parts",
|
||||||
|
"kicks_given": "Kicks given",
|
||||||
|
"kicked_received": "Times kicked",
|
||||||
|
"topics_set": "Topics set",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _get_aggregate(room_id):
|
||||||
|
"""Return dict of aggregate stats for a room."""
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute("""SELECT
|
||||||
|
COALESCE(SUM(msgs),0), COALESCE(SUM(chars),0),
|
||||||
|
COALESCE(SUM(words),0), COALESCE(SUM(smileys),0),
|
||||||
|
COALESCE(SUM(actions),0), COALESCE(SUM(joins),0),
|
||||||
|
COALESCE(SUM(parts),0), COALESCE(SUM(kicks_given),0),
|
||||||
|
COALESCE(SUM(kicked_received),0), COALESCE(SUM(topics_set),0)
|
||||||
|
FROM user_room_stats WHERE room_id=?""", (room_id,))
|
||||||
|
row = c.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row or all(v == 0 for v in row):
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"msgs": row[0], "chars": row[1], "words": row[2], "smileys": row[3],
|
||||||
|
"actions": row[4], "joins": row[5], "parts": row[6],
|
||||||
|
"kicks_given": row[7], "kicked_received": row[8], "topics_set": row[9]
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _handle_roomstats(bot, room_id):
|
||||||
|
agg = await _get_aggregate(room_id)
|
||||||
|
if not agg:
|
||||||
|
await bot.api.send_text_message(room_id, "No stats collected yet.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get top 10 by msgs
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute("""SELECT user_id, msgs FROM user_room_stats
|
||||||
|
WHERE room_id=? ORDER BY msgs DESC LIMIT 10""", (room_id,))
|
||||||
|
top = c.fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Resolve display names for top users
|
||||||
|
top_lines = []
|
||||||
|
resp = await bot.async_client.joined_members(room_id)
|
||||||
|
for uid, cnt in top:
|
||||||
|
disp = uid
|
||||||
|
if resp.members:
|
||||||
|
for m in resp.members:
|
||||||
|
if m.user_id == uid:
|
||||||
|
disp = m.display_name or uid
|
||||||
|
break
|
||||||
|
top_lines.append(f"<li><code>{disp}</code> — {cnt} msgs</li>")
|
||||||
|
|
||||||
|
msg = f"""<details>
|
||||||
|
<summary><strong>Room Statistics</strong></summary>
|
||||||
|
<ul>
|
||||||
|
<li>📩 Messages: {agg['msgs']}</li>
|
||||||
|
<li>🔤 Characters: {agg['chars']}</li>
|
||||||
|
<li>📝 Words: {agg['words']}</li>
|
||||||
|
<li>😀 Smileys: {agg['smileys']}</li>
|
||||||
|
<li>🎭 Actions: {agg['actions']}</li>
|
||||||
|
<li>🚪 Joins: {agg['joins']}</li>
|
||||||
|
<li>👋 Parts: {agg['parts']}</li>
|
||||||
|
<li>👢 Kicks given: {agg['kicks_given']}</li>
|
||||||
|
<li>🥾 Times kicked: {agg['kicked_received']}</li>
|
||||||
|
<li>📌 Topics set: {agg['topics_set']}</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>Top 10 by messages:</strong></p>
|
||||||
|
<ol>
|
||||||
|
{''.join(top_lines)}
|
||||||
|
</ol>
|
||||||
|
</details>"""
|
||||||
|
await bot.api.send_markdown_message(room_id, msg)
|
||||||
|
|
||||||
|
async def _handle_rank(bot, room_id, col):
|
||||||
|
# Validate column
|
||||||
|
if col not in VALID_STATS:
|
||||||
|
await bot.api.send_text_message(room_id, f"Unknown stat: {col}. Allowed: {', '.join(VALID_STATS.keys())}")
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
# Safe to use f-string because col is validated against a hardcoded set
|
||||||
|
c.execute(f"""SELECT user_id, {col} FROM user_room_stats
|
||||||
|
WHERE room_id=? AND {col} > 0 ORDER BY {col} DESC LIMIT 10""", (room_id,))
|
||||||
|
rows = c.fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
await bot.api.send_text_message(room_id, f"No users with {VALID_STATS[col]} > 0.")
|
||||||
|
return
|
||||||
|
|
||||||
|
resp = await bot.async_client.joined_members(room_id)
|
||||||
|
items = []
|
||||||
|
for i, (uid, val) in enumerate(rows, 1):
|
||||||
|
disp = uid
|
||||||
|
if resp.members:
|
||||||
|
for m in resp.members:
|
||||||
|
if m.user_id == uid:
|
||||||
|
disp = m.display_name or uid
|
||||||
|
break
|
||||||
|
items.append(f"<li>{i}. <code>{disp}</code> — {val}</li>")
|
||||||
|
|
||||||
|
msg = f"""<details>
|
||||||
|
<summary><strong>Ranking by {VALID_STATS[col]}</strong></summary>
|
||||||
|
<ol>
|
||||||
|
{''.join(items)}
|
||||||
|
</ol>
|
||||||
|
</details>"""
|
||||||
|
await bot.api.send_markdown_message(room_id, msg)
|
||||||
|
|
||||||
|
async def _handle_user_stats(bot, room_id, user_id, sender):
|
||||||
|
# Fetch stats
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute("""SELECT msgs, chars, words, smileys, actions, joins, parts,
|
||||||
|
kicks_given, kicked_received, topics_set
|
||||||
|
FROM user_room_stats WHERE room_id=? AND user_id=?""", (room_id, user_id))
|
||||||
|
row = c.fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if not row or all(v == 0 for v in row):
|
||||||
|
# No stats, maybe just joined – get display name for the message
|
||||||
|
disp = user_id
|
||||||
|
resp = await bot.async_client.joined_members(room_id)
|
||||||
|
if resp.members:
|
||||||
|
for m in resp.members:
|
||||||
|
if m.user_id == user_id:
|
||||||
|
disp = m.display_name or user_id
|
||||||
|
break
|
||||||
|
await bot.api.send_text_message(room_id, f"No stats recorded for {disp}.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get display name
|
||||||
|
disp = user_id
|
||||||
|
resp = await bot.async_client.joined_members(room_id)
|
||||||
|
if resp.members:
|
||||||
|
for m in resp.members:
|
||||||
|
if m.user_id == user_id:
|
||||||
|
disp = m.display_name or user_id
|
||||||
|
break
|
||||||
|
|
||||||
|
msg = f"""<details>
|
||||||
|
<summary><strong>Stats for {disp}</strong></summary>
|
||||||
|
<ul>
|
||||||
|
<li>📩 Messages: {row[0]}</li>
|
||||||
|
<li>🔤 Characters: {row[1]}</li>
|
||||||
|
<li>📝 Words: {row[2]}</li>
|
||||||
|
<li>😀 Smileys: {row[3]}</li>
|
||||||
|
<li>🎭 Actions: {row[4]}</li>
|
||||||
|
<li>🚪 Joins: {row[5]}</li>
|
||||||
|
<li>👋 Parts: {row[6]}</li>
|
||||||
|
<li>👢 Kicks given: {row[7]}</li>
|
||||||
|
<li>🥾 Times kicked: {row[8]}</li>
|
||||||
|
<li>📌 Topics set: {row[9]}</li>
|
||||||
|
</ul>
|
||||||
|
</details>"""
|
||||||
|
await bot.api.send_markdown_message(room_id, msg)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Plugin metadata
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
__version__ = "1.0.1"
|
||||||
|
__author__ = "Funguy Roomstats"
|
||||||
|
__description__ = "Per‑user room statistics (Limnoria‑style), with multi‑word name support"
|
||||||
|
__help__ = """
|
||||||
|
<details>
|
||||||
|
<summary><strong>Room Statistics Commands</strong></summary>
|
||||||
|
<ul>
|
||||||
|
<li><code>!roomstats</code> – Aggregate room stats + top 10 users</li>
|
||||||
|
<li><code>!rank <stat></code> – Top 10 by a specific stat (msgs, words, smileys, actions, joins, parts, kicks_given, kicked_received, topics_set)</li>
|
||||||
|
<li><code>!stats [name]</code> – Show stats for a user (supports multi‑word names)</li>
|
||||||
|
</ul>
|
||||||
|
<p>All commands work in the current room; display names are automatically resolved.</p>
|
||||||
|
</details>
|
||||||
|
"""
|
||||||
+3
-1
@@ -1,6 +1,5 @@
|
|||||||
python-dotenv
|
python-dotenv
|
||||||
requests
|
requests
|
||||||
duckduckgo_search
|
|
||||||
nio
|
nio
|
||||||
markdown2
|
markdown2
|
||||||
watchdog
|
watchdog
|
||||||
@@ -19,3 +18,6 @@ aiohttp
|
|||||||
aiosqlite
|
aiosqlite
|
||||||
pillow
|
pillow
|
||||||
omdbapi
|
omdbapi
|
||||||
|
apscheduler
|
||||||
|
pytz
|
||||||
|
ddgs
|
||||||
Binary file not shown.
Reference in New Issue
Block a user