diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b4546f..4e3a7ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to EasyCord are documented here. See [Semantic Versioning](https://semver.org) for version numbering. +## EasyCord v5.60.0 — 2026-07-25 + +### Changed + +- **`GuildLockManager`** — extracted the per-guild `asyncio.Lock` dict + eviction logic from 14 plugins into a single shared class in `easycord/plugins/_shared.py`. All plugins (`auto_role`, `birthday`, `economy`, `giveaway`, `juicewrld`, `polls`, `reminder`, `reputation`, `scheduled_announcements`, `server_stats`, `tags`, `tickets`, `verification`, `word_filter`) now share one implementation with idle-eviction after 7 days and a MAX_TRACKED_GUILDS=5000 hard cap. Removes ~250 lines of copy-pasted boilerplate. +- **`send_safe` adoption** — replaced ~20 bare `await channel.send(...)` calls and inline `try/except discord.Forbidden/HTTPException` blocks across 14 plugins with the existing `send_safe` helper from `easycord/helpers/channel.py`. Affected: `ai_moderator`, `birthday`, `giveaway`, `invite_tracker`, `levels`, `member_logging`, `moderation`, `reminder`, `scheduled_announcements`, `starboard`, `suggestions`, `tickets`, `verification`, `word_filter`. +- **`@slash(require_admin=True)`** — replaced 6 inline `if not ctx.is_admin: respond_error; return` blocks with the `require_admin=True` decorator parameter on `ticket_setup`, `stats_setup`, `stats_teardown`, `verification_setup`, `verification_panel`, `verification_question`. The decorator gate now rejects non-admins before the command body runs. +- **`EmbedBuilder` adoption** — migrated `discord.Embed(...)` construction to `EmbedBuilder` at opportunistic sites: `_ticket_embed()` and the close log embed in `tickets.py`; `_build_panel_embed()` in `verification.py`. + +### Release metadata + +- Wheel: `easycord-5.60.0-py3-none-any.whl` +- Source: `easycord-5.60.0.tar.gz` — `releases/download/v5.60.0/easycord-5.60.0.tar.gz` + ## EasyCord v5.58.0 — 2026-07-22 ### Changed diff --git a/README.md b/README.md index d27da0a..ee55696 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # EasyCord -![Version](https://img.shields.io/badge/v-5.58.0-blue) +![Version](https://img.shields.io/badge/v-5.60.0-blue) ![Python](https://img.shields.io/badge/python-3.10%2B-blue) ![License](https://img.shields.io/badge/license-MIT-green) ![Tests](https://img.shields.io/badge/tests-1777%2B-brightgreen) @@ -25,7 +25,7 @@ ## Quick Start ```bash -pip install "https://github.com/rolling-codes/EasyCord/releases/download/v5.58.0/easycord-5.58.0-py3-none-any.whl" +pip install "https://github.com/rolling-codes/EasyCord/releases/download/v5.60.0/easycord-5.60.0-py3-none-any.whl" ``` Or scaffold a full project: @@ -304,4 +304,4 @@ Copyright (c) 2026 Rolling Codes. **Docs:** [Getting Started](docs/getting-started.md) · [Built-in Plugins](docs/builtin-plugins.md) · [AI Features](docs/conversation-memory.md) · [All guides →](docs/README.md) -Release: [v5.58.0](https://github.com/rolling-codes/EasyCord/releases/tag/v5.58.0) · [Changelog](CHANGELOG.md) · [GitHub](https://github.com/rolling-codes/EasyCord) +Release: [v5.60.0](https://github.com/rolling-codes/EasyCord/releases/tag/v5.60.0) · [Changelog](CHANGELOG.md) · [GitHub](https://github.com/rolling-codes/EasyCord) diff --git a/docs/getting-started.md b/docs/getting-started.md index 3c3b873..3ae7c82 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -3,7 +3,7 @@ ## Install ```bash -pip install "https://github.com/rolling-codes/EasyCord/releases/download/v5.58.0/easycord-5.58.0-py3-none-any.whl" +pip install "https://github.com/rolling-codes/EasyCord/releases/download/v5.60.0/easycord-5.60.0-py3-none-any.whl" ``` Or clone and install locally: diff --git a/easycord/__init__.py b/easycord/__init__.py index c566d63..1499014 100644 --- a/easycord/__init__.py +++ b/easycord/__init__.py @@ -16,7 +16,7 @@ async def ping(ctx): bot.run("YOUR_TOKEN") """ -__version__ = "5.58.0" +__version__ = "5.60.0" from .audit import AuditLog from .bot import Bot diff --git a/easycord/plugins/_shared.py b/easycord/plugins/_shared.py index 6f53c97..24cf802 100644 --- a/easycord/plugins/_shared.py +++ b/easycord/plugins/_shared.py @@ -1,8 +1,10 @@ """Shared helpers for the bundled plugins.""" from __future__ import annotations +import asyncio import json import os +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import TYPE_CHECKING, Iterable @@ -108,3 +110,73 @@ def role_reference(guild: discord.Guild, role_id: int) -> str: """Return a user-facing role mention with a deleted-role fallback.""" role = guild.get_role(role_id) return role.mention if role else f"<@&{role_id}> *(deleted?)*" + + +# --------------------------------------------------------------------------- +# Guild lock manager for concurrent per-guild mutations +# --------------------------------------------------------------------------- + +MAX_TRACKED_GUILDS = 5000 + + +class GuildLockManager: + """Per-guild lock manager with idle-eviction to prevent unbounded memory growth. + + Each guild gets one lock that serializes all read-modify-write operations. + The lock is created on first access, refreshed on every access (updating + last-used timestamp), and automatically evicted after 7 days of idleness + or when the total count exceeds MAX_TRACKED_GUILDS. + + Safe under concurrent access: never evicts a currently-held lock, preventing + the race where a new caller receives a fresh lock while an existing holder + still considers itself the sole writer. + + Usage:: + + mgr = GuildLockManager() + async with mgr.lock(guild_id): + # perform read-modify-write atomically + pass + """ + + def __init__(self) -> None: + self._registry: dict[int, asyncio.Lock] = {} + self._created: dict[int, datetime] = {} + + def lock(self, guild_id: int) -> asyncio.Lock: + """Get or create the lock for a guild, refresh its timestamp, return it.""" + if guild_id not in self._registry: + self._registry[guild_id] = asyncio.Lock() + self._cleanup() + self._created[guild_id] = datetime.now(timezone.utc) + return self._registry[guild_id] + + def _cleanup(self) -> None: + """Evict idle locks to prevent unbounded memory growth. + + A lock is removed only if it has been idle >7 days AND is not held. + If still over MAX_TRACKED_GUILDS, evict oldest 25% of idle locks. + """ + now = datetime.now(timezone.utc) + max_age = timedelta(days=7) + + # Remove locks idle >7 days, but never remove an acquired lock. + idle_old = [ + gid + for gid, ts in self._created.items() + if now - ts > max_age and not self._registry[gid].locked() + ] + for gid in idle_old: + del self._registry[gid] + del self._created[gid] + + # If still over limit, evict oldest 25% of idle locks. + if len(self._registry) > MAX_TRACKED_GUILDS: + candidates = sorted( + ((gid, ts) for gid, ts in self._created.items() if not self._registry[gid].locked()), + key=lambda x: x[1], + ) + remove_count = max(1, len(candidates) // 4) + for gid, _ in candidates[:remove_count]: + del self._registry[gid] + del self._created[gid] diff --git a/easycord/plugins/ai_moderator.py b/easycord/plugins/ai_moderator.py index ef863d9..f4ef78c 100644 --- a/easycord/plugins/ai_moderator.py +++ b/easycord/plugins/ai_moderator.py @@ -14,7 +14,7 @@ on, slash, ) -from easycord.helpers.channel import SENDABLE_CHANNEL_TYPES +from easycord.helpers.channel import SENDABLE_CHANNEL_TYPES, send_safe from easycord.plugins._config_manager import PluginConfigManager if TYPE_CHECKING: @@ -216,7 +216,7 @@ async def _on_message(self, message: discord.Message) -> None: embed.add_field(name="Action", value=action, inline=True) embed.add_field(name="Confidence", value=f"{confidence*100:.1f}%", inline=True) embed.add_field(name="Reason", value=reason, inline=False) - await channel.send(embed=embed) + await send_safe(channel, log=logger, what="notify_only mod review", embed=embed) # ──────────────────────────────────────────────────────────── # Slash commands for config diff --git a/easycord/plugins/auto_role.py b/easycord/plugins/auto_role.py index 82017e3..5981488 100644 --- a/easycord/plugins/auto_role.py +++ b/easycord/plugins/auto_role.py @@ -9,7 +9,7 @@ from easycord import Plugin, on, slash from easycord.server_config import ServerConfigStore -from ._shared import respond_error +from ._shared import GuildLockManager, respond_error if TYPE_CHECKING: from easycord import Context @@ -45,12 +45,7 @@ class AutoRolePlugin(Plugin): def __init__(self, *, store_path: str = ".easycord/auto_role") -> None: super().__init__() self._store = ServerConfigStore(store_path) - self._locks: dict[int, asyncio.Lock] = {} - - def _guild_lock(self, guild_id: int) -> asyncio.Lock: - if guild_id not in self._locks: - self._locks[guild_id] = asyncio.Lock() - return self._locks[guild_id] + self._locks = GuildLockManager() # ── Event handler ───────────────────────────────────────── @@ -84,7 +79,7 @@ async def autorole_add(self, ctx: "Context", role: discord.Role) -> None: if ctx.guild is None: await respond_error(ctx, "This command only works in a server.") return - async with self._guild_lock(ctx.guild.id): + async with self._locks.lock(ctx.guild.id): cfg = await self._store.load(ctx.guild.id) data = cfg.get_other("auto_role", {}) role_ids: list[int] = data.get("role_ids", []) @@ -100,7 +95,7 @@ async def autorole_remove(self, ctx: "Context", role: discord.Role) -> None: if ctx.guild is None: await respond_error(ctx, "This command only works in a server.") return - async with self._guild_lock(ctx.guild.id): + async with self._locks.lock(ctx.guild.id): cfg = await self._store.load(ctx.guild.id) data = cfg.get_other("auto_role", {}) role_ids: list[int] = data.get("role_ids", []) @@ -137,7 +132,7 @@ async def autorole_delay(self, ctx: "Context", seconds: int) -> None: if seconds < 0: await respond_error(ctx, "Delay must be 0 or greater.") return - async with self._guild_lock(ctx.guild.id): + async with self._locks.lock(ctx.guild.id): cfg = await self._store.load(ctx.guild.id) data = cfg.get_other("auto_role", {}) data = {**data, "delay_seconds": seconds} diff --git a/easycord/plugins/birthday.py b/easycord/plugins/birthday.py index fa77b52..44b3cbe 100644 --- a/easycord/plugins/birthday.py +++ b/easycord/plugins/birthday.py @@ -9,8 +9,9 @@ import discord from easycord import Plugin, slash +from easycord.helpers.channel import send_safe from easycord.server_config import ServerConfigStore -from ._shared import respond_error +from ._shared import GuildLockManager, respond_error if TYPE_CHECKING: from easycord import Context @@ -116,15 +117,10 @@ class BirthdayPlugin(Plugin): def __init__(self, *, store_path: str = ".easycord/birthday") -> None: super().__init__() self._store = ServerConfigStore(store_path) - self._locks: dict[int, asyncio.Lock] = {} + self._locks = GuildLockManager() self._loop_task: asyncio.Task | None = None self._role_tasks: set[asyncio.Task[None]] = set() - def _guild_lock(self, guild_id: int) -> asyncio.Lock: - if guild_id not in self._locks: - self._locks[guild_id] = asyncio.Lock() - return self._locks[guild_id] - # ── Lifecycle ───────────────────────────────────────────── async def on_ready(self) -> None: @@ -181,7 +177,7 @@ async def _run_birthday_checks(self) -> None: async def _check_guild_birthdays( self, guild_id: int, today: datetime.date ) -> None: - async with self._guild_lock(guild_id): + async with self._locks.lock(guild_id): cfg = await self._store.load(guild_id) data: dict = cfg.get_other("birthday", {}) channel_id: int | None = data.get("channel_id") @@ -232,10 +228,7 @@ async def _check_guild_birthdays( color=discord.Color.gold(), ) embed.set_footer(text=f"🎉 {today.strftime('%B %d')}") - try: - await channel.send(embed=embed) - except discord.HTTPException: - logger.exception("Failed to send birthday message for user %d", uid) + await send_safe(channel, log=logger, what="birthday announcement", embed=embed) if role and member: try: @@ -295,7 +288,7 @@ async def birthday_set( guild_id = ctx.guild.id user_id = ctx.user.id - async with self._guild_lock(guild_id): + async with self._locks.lock(guild_id): cfg = await self._store.load(guild_id) data: dict = cfg.get_other("birthday", {}) birthdays: dict = data.get("birthdays", {}) @@ -316,7 +309,7 @@ async def birthday_unset(self, ctx: Context) -> None: guild_id = ctx.guild.id user_id = ctx.user.id - async with self._guild_lock(guild_id): + async with self._locks.lock(guild_id): cfg = await self._store.load(guild_id) data: dict = cfg.get_other("birthday", {}) birthdays: dict = data.get("birthdays", {}) @@ -342,7 +335,7 @@ async def birthday_channel( return guild_id = ctx.guild.id - async with self._guild_lock(guild_id): + async with self._locks.lock(guild_id): cfg = await self._store.load(guild_id) data: dict = cfg.get_other("birthday", {}) data["channel_id"] = channel.id @@ -366,7 +359,7 @@ async def birthday_role( return guild_id = ctx.guild.id - async with self._guild_lock(guild_id): + async with self._locks.lock(guild_id): cfg = await self._store.load(guild_id) data: dict = cfg.get_other("birthday", {}) data["role_id"] = role.id @@ -384,7 +377,7 @@ async def birthday_list(self, ctx: Context) -> None: return guild_id = ctx.guild.id - async with self._guild_lock(guild_id): + async with self._locks.lock(guild_id): cfg = await self._store.load(guild_id) data: dict = cfg.get_other("birthday", {}) birthdays: dict = data.get("birthdays", {}) diff --git a/easycord/plugins/economy.py b/easycord/plugins/economy.py index 8b7c9cd..4825b94 100644 --- a/easycord/plugins/economy.py +++ b/easycord/plugins/economy.py @@ -1,16 +1,15 @@ """Economy system — earn, spend, and trade in-game currency.""" from __future__ import annotations -import asyncio import logging -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone from typing import TYPE_CHECKING import discord from easycord import Plugin, slash, on from easycord.plugins._config_manager import PluginConfigManager -from ._shared import respond_error +from ._shared import GuildLockManager, respond_error if TYPE_CHECKING: from easycord import Context @@ -54,60 +53,7 @@ class EconomyPlugin(Plugin): def __init__(self): super().__init__() self.config = PluginConfigManager(".easycord/economy") - self._balance_locks: dict[int, asyncio.Lock] = {} - self._lock_created: dict[int, datetime] = {} # Track creation time for cleanup - - def _balance_lock(self, guild_id: int) -> asyncio.Lock: - """Per-guild lock serializing all economy state mutations. - - All economy operations that call ``store.save()`` for a guild must run - under this lock. Because ``ServerConfigStore.save()`` writes the full - config object, an unlocked save based on a stale load can silently - overwrite balances or claim state written by a concurrent locked path. - """ - if guild_id not in self._balance_locks: - self._balance_locks[guild_id] = asyncio.Lock() - self._cleanup_old_locks() - # Refresh last-used time on every access so active guilds are never evicted. - self._lock_created[guild_id] = datetime.now(timezone.utc) - return self._balance_locks[guild_id] - - def _cleanup_old_locks(self) -> None: - """Remove idle locks to prevent unbounded memory growth. - - A lock is a candidate for removal only when it has been idle for more - than 7 days AND is not currently acquired. Skipping acquired locks - prevents the race where a new caller receives a fresh unacquired lock - while an existing holder still considers itself the sole writer. - """ - now = datetime.now(timezone.utc) - max_age = timedelta(days=7) - - # Remove locks idle for more than 7 days, but never remove an acquired lock. - keys_to_remove = [ - guild_id - for guild_id, last_used in self._lock_created.items() - if now - last_used > max_age - and not self._balance_locks[guild_id].locked() - ] - for guild_id in keys_to_remove: - del self._balance_locks[guild_id] - del self._lock_created[guild_id] - - # If still over the limit, evict the longest-idle unlocked entries. - if len(self._balance_locks) > MAX_TRACKED_GUILDS: - candidates = sorted( - ( - (guild_id, ts) - for guild_id, ts in self._lock_created.items() - if not self._balance_locks[guild_id].locked() - ), - key=lambda x: x[1], - ) - remove_count = max(1, len(candidates) // 4) - for guild_id, _ in candidates[:remove_count]: - del self._balance_locks[guild_id] - del self._lock_created[guild_id] + self._locks = GuildLockManager() async def on_load(self) -> None: """Initialize economy plugin.""" @@ -133,7 +79,7 @@ async def _get_balance(self, guild_id: int, user_id: int) -> int: async def _set_balance(self, guild_id: int, user_id: int, amount: int) -> None: """Set user's balance. - Must only be called while the caller holds ``_balance_lock(guild_id)``. + Must only be called while the caller holds ``self._locks.lock(guild_id)``. """ cfg_obj = await self.config.store.load(guild_id) balances = cfg_obj.get_other("balances", {}) @@ -147,7 +93,7 @@ async def _add_balance(self, guild_id: int, user_id: int, amount: int) -> int: Returns the new balance. The operation is atomic: balance is read, modified, and saved under a single lock acquisition. """ - async with self._balance_lock(guild_id): + async with self._locks.lock(guild_id): current = await self._get_balance(guild_id, user_id) new_balance = current + amount await self._set_balance(guild_id, user_id, new_balance) @@ -170,7 +116,7 @@ async def _transfer( Returns ``(success, sender_balance_after)``. """ - async with self._balance_lock(guild_id): + async with self._locks.lock(guild_id): cfg_obj = await self.config.store.load(guild_id) balances = cfg_obj.get_other("balances", {}) @@ -197,12 +143,12 @@ async def _get_daily_claimed(self, guild_id: int, user_id: int) -> bool: async def _mark_daily_claimed(self, guild_id: int, user_id: int) -> None: """Mark daily reward as claimed, acquiring the per-guild lock. - Self-contained: takes ``_balance_lock`` itself so the load→modify→save + Self-contained: takes the lock itself so the load→modify→save cannot interleave with a concurrent balance update and clobber it. The ``/daily`` command does its own claim-marking inline under the lock, so this helper is only used where the lock is not already held. """ - async with self._balance_lock(guild_id): + async with self._locks.lock(guild_id): cfg_obj = await self.config.store.load(guild_id) daily_claims = cfg_obj.get_other("daily_claims", {}) today = datetime.now(timezone.utc).date().isoformat() @@ -248,7 +194,7 @@ async def daily(self, ctx: Context) -> None: # after releasing it so response latency never stalls the guild. # claimed stays None when today's reward was already taken. claimed: tuple[int, str, str, int] | None = None - async with self._balance_lock(ctx.guild.id): + async with self._locks.lock(ctx.guild.id): cfg_obj = await self.config.store.load(ctx.guild.id) daily_claims = cfg_obj.get_other("daily_claims", {}) diff --git a/easycord/plugins/giveaway.py b/easycord/plugins/giveaway.py index b71a366..8f8e90e 100644 --- a/easycord/plugins/giveaway.py +++ b/easycord/plugins/giveaway.py @@ -11,9 +11,9 @@ import discord from easycord import Plugin, slash -from easycord.helpers.channel import SENDABLE_CHANNEL_TYPES +from easycord.helpers.channel import SENDABLE_CHANNEL_TYPES, send_safe from easycord.server_config import ServerConfigStore -from ._shared import respond_error +from ._shared import GuildLockManager, respond_error if TYPE_CHECKING: from easycord import Context @@ -88,7 +88,7 @@ def __init__( async def _toggle(self, interaction: discord.Interaction) -> None: user_id = interaction.user.id - async with self._plugin._guild_lock(self._guild_id): + async with self._plugin._locks.lock(self._guild_id): cfg = await self._plugin._store.load(self._guild_id) giveaways: dict = cfg.get_other("giveaways", {}) data: dict | None = giveaways.get(str(self._message_id)) @@ -142,14 +142,9 @@ class GiveawayPlugin(Plugin): def __init__(self, *, store_path: str = ".easycord/giveaway") -> None: super().__init__() self._store = ServerConfigStore(store_path) - self._locks: dict[int, asyncio.Lock] = {} + self._locks = GuildLockManager() self._timers: dict[int, dict[int, asyncio.Task]] = {} - def _guild_lock(self, guild_id: int) -> asyncio.Lock: - if guild_id not in self._locks: - self._locks[guild_id] = asyncio.Lock() - return self._locks[guild_id] - async def on_ready(self) -> None: """Re-register entry views and resume timers for all active giveaways.""" store_base = self._store._base @@ -205,7 +200,7 @@ async def _giveaway_timer( async def _end_giveaway(self, guild_id: int, message_id: int) -> None: """Pick winners, update the embed, and post the announcement.""" - async with self._guild_lock(guild_id): + async with self._locks.lock(guild_id): cfg = await self._store.load(guild_id) giveaways: dict = cfg.get_other("giveaways", {}) data: dict | None = giveaways.get(str(message_id)) @@ -251,12 +246,18 @@ async def _end_giveaway(self, guild_id: int, message_id: int) -> None: if winners: mentions = " ".join(f"<@{w}>" for w in winners) - await channel.send( - f"🎉 Congratulations {mentions}! You won **{data['prize']}**!" + await send_safe( + channel, + log=logger, + what="giveaway winner announcement", + content=f"🎉 Congratulations {mentions}! You won **{data['prize']}**!", ) else: - await channel.send( - f"🎉 Giveaway for **{data['prize']}** has ended — no entries were submitted." + await send_safe( + channel, + log=logger, + what="giveaway winner announcement", + content=f"🎉 Giveaway for **{data['prize']}** has ended — no entries were submitted.", ) self._timers.get(guild_id, {}).pop(message_id, None) @@ -305,7 +306,7 @@ async def giveaway( return channel_id: int = channel.id - async with self._guild_lock(guild_id): + async with self._locks.lock(guild_id): cfg = await self._store.load(guild_id) giveaways: dict = cfg.get_other("giveaways", {}) giveaways[str(message_id)] = { @@ -339,7 +340,7 @@ async def giveaway_end(self, ctx: Context, message_id: str) -> None: return guild_id = ctx.guild.id - async with self._guild_lock(guild_id): + async with self._locks.lock(guild_id): cfg = await self._store.load(guild_id) giveaways: dict = cfg.get_other("giveaways", {}) if giveaways.get(str(msg_id), {}).get("status") != "active": @@ -369,7 +370,7 @@ async def giveaway_reroll(self, ctx: Context, message_id: str) -> None: return guild_id = ctx.guild.id - async with self._guild_lock(guild_id): + async with self._locks.lock(guild_id): cfg = await self._store.load(guild_id) giveaways: dict = cfg.get_other("giveaways", {}) data: dict | None = giveaways.get(str(msg_id)) diff --git a/easycord/plugins/invite_tracker.py b/easycord/plugins/invite_tracker.py index 19e8cd1..f62ee83 100644 --- a/easycord/plugins/invite_tracker.py +++ b/easycord/plugins/invite_tracker.py @@ -7,7 +7,7 @@ import discord from easycord import Plugin, on -from easycord.helpers.channel import SENDABLE_CHANNEL_TYPES +from easycord.helpers.channel import SENDABLE_CHANNEL_TYPES, send_safe from easycord.plugins._config_manager import PluginConfigManager if TYPE_CHECKING: @@ -95,12 +95,7 @@ async def _log_invite(self, member: discord.Member, invite_code: str | None) -> embed.set_thumbnail(url=member.avatar.url if member.avatar else None) embed.set_footer(text=f"ID: {member.id}") - try: - await channel.send(embed=embed) - except discord.Forbidden: - logger.error("No permission to post to invite log channel %s", channel_id) - except discord.HTTPException as e: - logger.error("Failed to post invite log: %s", e) + await send_safe(channel, log=logger, what="invite log", embed=embed) @on("member_join") async def _on_member_join(self, member: discord.Member) -> None: diff --git a/easycord/plugins/juicewrld.py b/easycord/plugins/juicewrld.py index 2c992d2..41113a0 100644 --- a/easycord/plugins/juicewrld.py +++ b/easycord/plugins/juicewrld.py @@ -102,7 +102,7 @@ from easycord import Plugin, on, slash, task from easycord.decorators import ai_tool, describe, subscribe from easycord.server_config import ServerConfigStore -from ._shared import respond_error +from ._shared import GuildLockManager, respond_error # Juice WRLD finder service layer — install the juice-wrld-finder package alongside EasyCord. # None of these modules import app.core.config.settings at import time. @@ -178,14 +178,7 @@ def __init__( self._use_external_api = use_external_api self._store = ServerConfigStore(store_path) - self._locks: dict[int, asyncio.Lock] = {} - - # ── Internal helpers ─────────────────────────────────────── - - def _guild_lock(self, guild_id: int) -> asyncio.Lock: - if guild_id not in self._locks: - self._locks[guild_id] = asyncio.Lock() - return self._locks[guild_id] + self._locks = GuildLockManager() @contextmanager def _db(self) -> Generator[Session, None, None]: diff --git a/easycord/plugins/levels.py b/easycord/plugins/levels.py index 22f409c..6c3b20f 100644 --- a/easycord/plugins/levels.py +++ b/easycord/plugins/levels.py @@ -11,6 +11,7 @@ logger = logging.getLogger(__name__) from easycord import Plugin, slash, on +from easycord.helpers.channel import send_safe from ._shared import respond_error from ._levels_data import ( LevelsStore, @@ -293,7 +294,7 @@ async def _award_xp(self, message: discord.Message) -> None: if awarded: embed.add_field(name="Role awarded", value=awarded.mention, inline=False) - await message.channel.send(embed=embed) + await send_safe(message.channel, log=logger, what="level-up notification", embed=embed) if config.get("level_dm_enabled", False): try: diff --git a/easycord/plugins/member_logging.py b/easycord/plugins/member_logging.py index 765cf7c..8377060 100644 --- a/easycord/plugins/member_logging.py +++ b/easycord/plugins/member_logging.py @@ -8,7 +8,7 @@ import discord from easycord import Plugin, on -from easycord.helpers.channel import SENDABLE_CHANNEL_TYPES +from easycord.helpers.channel import SENDABLE_CHANNEL_TYPES, send_safe from easycord.plugins._config_manager import PluginConfigManager if TYPE_CHECKING: @@ -67,12 +67,7 @@ async def _log_to_channel(self, guild: discord.Guild, embed: discord.Embed) -> N logger.warning("Member log channel %s not found in guild %s", channel_id, guild.id) return - try: - await channel.send(embed=embed) - except discord.Forbidden: - logger.error("No permission to post to member log channel %s", channel_id) - except discord.HTTPException as e: - logger.error("Failed to post member log: %s", e) + await send_safe(channel, log=logger, what="member log", embed=embed) @on("member_join") async def _on_member_join(self, member: discord.Member) -> None: diff --git a/easycord/plugins/moderation.py b/easycord/plugins/moderation.py index 085972b..e13b138 100644 --- a/easycord/plugins/moderation.py +++ b/easycord/plugins/moderation.py @@ -8,7 +8,7 @@ import discord from easycord import Plugin, RateLimit, ToolLimiter, slash -from easycord.helpers.channel import SENDABLE_CHANNEL_TYPES +from easycord.helpers.channel import SENDABLE_CHANNEL_TYPES, send_safe from easycord.plugins._config_manager import PluginConfigManager if TYPE_CHECKING: @@ -107,10 +107,7 @@ async def _log_moderation(self, ctx: Context, action: str, target: discord.User, embed.add_field(name="Moderator", value=ctx.user.mention, inline=True) embed.set_footer(text=f"User ID: {target.id}") - try: - await audit_channel.send(embed=embed) - except discord.Forbidden: - logger.warning("Cannot post to audit channel %s", audit_channel_id) + await send_safe(audit_channel, log=logger, what="moderation audit log", embed=embed) async def _get_or_create_mute_role(self, guild: discord.Guild) -> discord.Role | None: """Get or create mute role for guild.""" diff --git a/easycord/plugins/polls.py b/easycord/plugins/polls.py index e6cefd3..0ba401a 100644 --- a/easycord/plugins/polls.py +++ b/easycord/plugins/polls.py @@ -11,7 +11,7 @@ from easycord import Plugin, slash from easycord.helpers.channel import SENDABLE_CHANNEL_TYPES from easycord.server_config import ServerConfigStore -from ._shared import respond_error +from ._shared import GuildLockManager, respond_error if TYPE_CHECKING: from easycord import Context @@ -112,7 +112,7 @@ def _make_button(self, label: str, option_index: int) -> discord.ui.Button: def _make_callback(self, option_index: int): async def callback(interaction: discord.Interaction) -> None: - async with self._plugin._guild_lock(self._guild_id): + async with self._plugin._locks.lock(self._guild_id): cfg = await self._plugin._store.load(self._guild_id) polls: dict = cfg.get_other("polls", {}) data: dict | None = polls.get(str(self._message_id)) @@ -165,15 +165,10 @@ class PollsPlugin(Plugin): def __init__(self, *, store_path: str = ".easycord/polls") -> None: super().__init__() self._store = ServerConfigStore(store_path) - self._locks: dict[int, asyncio.Lock] = {} + self._locks = GuildLockManager() # guild_id -> message_id -> Task self._timers: dict[int, dict[int, asyncio.Task]] = {} - def _guild_lock(self, guild_id: int) -> asyncio.Lock: - if guild_id not in self._locks: - self._locks[guild_id] = asyncio.Lock() - return self._locks[guild_id] - # ── Lifecycle ───────────────────────────────────────────── async def on_ready(self) -> None: @@ -240,7 +235,7 @@ async def _poll_timer(self, guild_id: int, message_id: int, seconds: float) -> N async def _close_poll(self, guild_id: int, message_id: int) -> None: """Mark a poll closed in storage, then render the final results.""" - async with self._guild_lock(guild_id): + async with self._locks.lock(guild_id): cfg = await self._store.load(guild_id) polls: dict = cfg.get_other("polls", {}) data: dict | None = polls.get(str(message_id)) @@ -315,7 +310,7 @@ async def poll( message = await ctx.interaction.original_response() message_id = message.id - async with self._guild_lock(guild_id): + async with self._locks.lock(guild_id): cfg = await self._store.load(guild_id) polls: dict = cfg.get_other("polls", {}) polls[str(message_id)] = { diff --git a/easycord/plugins/reminder.py b/easycord/plugins/reminder.py index 1520f3d..a62270f 100644 --- a/easycord/plugins/reminder.py +++ b/easycord/plugins/reminder.py @@ -9,9 +9,9 @@ import discord from easycord import Plugin, slash -from easycord.helpers.channel import SENDABLE_CHANNEL_TYPES +from easycord.helpers.channel import SENDABLE_CHANNEL_TYPES, send_safe from easycord.server_config import ServerConfigStore -from ._shared import respond_error +from ._shared import GuildLockManager, respond_error from easycord.plugins.giveaway import _parse_duration # noqa: F401 — re-exported for tests if TYPE_CHECKING: @@ -70,15 +70,10 @@ class ReminderPlugin(Plugin): def __init__(self, *, store_path: str = ".easycord/reminder") -> None: super().__init__() self._store = ServerConfigStore(store_path) - self._locks: dict[int, asyncio.Lock] = {} + self._locks = GuildLockManager() # guild_id -> reminder_id -> Task self._tasks: dict[int, dict[int, asyncio.Task]] = {} - def _guild_lock(self, guild_id: int) -> asyncio.Lock: - if guild_id not in self._locks: - self._locks[guild_id] = asyncio.Lock() - return self._locks[guild_id] - # ── Lifecycle ───────────────────────────────────────────── async def on_ready(self) -> None: @@ -149,7 +144,7 @@ async def _fire_after( async def _deliver_reminder(self, guild_id: int, reminder_id: int) -> None: """Load reminder from store, send the message, mark it done.""" - async with self._guild_lock(guild_id): + async with self._locks.lock(guild_id): cfg = await self._store.load(guild_id) data: dict = cfg.get_other("reminders", {}) reminders: list[dict] = data.get("reminders", []) @@ -186,12 +181,7 @@ async def _deliver_reminder(self, guild_id: int, reminder_id: int) -> None: return embed = _reminder_embed(target) - try: - await channel.send(content=f"<@{user_id}>", embed=embed) - except discord.HTTPException: - logger.exception( - "Failed to deliver reminder %d in channel %d", reminder_id, channel_id - ) + await send_safe(channel, log=logger, what="reminder", content=f"<@{user_id}>", embed=embed) # ── Slash commands ──────────────────────────────────────── @@ -217,7 +207,7 @@ async def remind(self, ctx: Context, when: str, message: str) -> None: now = datetime.now(timezone.utc) fire_at = now + timedelta(seconds=seconds) - async with self._guild_lock(guild_id): + async with self._locks.lock(guild_id): cfg = await self._store.load(guild_id) data: dict = cfg.get_other("reminders", {}) next_id: int = data.get("next_id", 1) @@ -254,7 +244,7 @@ async def reminders(self, ctx: Context) -> None: guild_id = ctx.guild.id user_id = ctx.user.id - async with self._guild_lock(guild_id): + async with self._locks.lock(guild_id): cfg = await self._store.load(guild_id) data: dict = cfg.get_other("reminders", {}) all_reminders: list[dict] = data.get("reminders", []) @@ -295,7 +285,7 @@ async def reminder_cancel(self, ctx: Context, id: int) -> None: guild_id = ctx.guild.id user_id = ctx.user.id - async with self._guild_lock(guild_id): + async with self._locks.lock(guild_id): cfg = await self._store.load(guild_id) data: dict = cfg.get_other("reminders", {}) reminders: list[dict] = data.get("reminders", []) diff --git a/easycord/plugins/reputation.py b/easycord/plugins/reputation.py index 60fa23b..039ac75 100644 --- a/easycord/plugins/reputation.py +++ b/easycord/plugins/reputation.py @@ -1,7 +1,6 @@ """Per-guild reputation (rep) system plugin.""" from __future__ import annotations -import asyncio import logging from datetime import datetime, timedelta, timezone from typing import TYPE_CHECKING @@ -10,7 +9,7 @@ from easycord import Plugin, slash from easycord.server_config import ServerConfigStore -from ._shared import respond_error +from ._shared import GuildLockManager, respond_error if TYPE_CHECKING: from easycord import Context @@ -74,12 +73,7 @@ class ReputationPlugin(Plugin): def __init__(self, *, store_path: str = ".easycord/reputation") -> None: super().__init__() self._store = ServerConfigStore(store_path) - self._locks: dict[int, asyncio.Lock] = {} - - def _guild_lock(self, guild_id: int) -> asyncio.Lock: - if guild_id not in self._locks: - self._locks[guild_id] = asyncio.Lock() - return self._locks[guild_id] + self._locks = GuildLockManager() # ------------------------------------------------------------------ # # Commands # @@ -111,7 +105,7 @@ async def rep(self, ctx: "Context", user: discord.User) -> None: guild_id = ctx.guild.id now = datetime.now(timezone.utc) - async with self._guild_lock(guild_id): + async with self._locks.lock(guild_id): cfg = await self._store.load(guild_id) data = cfg.get_other("reputation", {}) cooldowns: dict = data.get("cooldowns", {}) @@ -210,7 +204,7 @@ async def rep_reset(self, ctx: "Context", user: discord.User) -> None: guild_id = ctx.guild.id - async with self._guild_lock(guild_id): + async with self._locks.lock(guild_id): cfg = await self._store.load(guild_id) data = cfg.get_other("reputation", {}) scores: dict = data.get("scores", {}) diff --git a/easycord/plugins/scheduled_announcements.py b/easycord/plugins/scheduled_announcements.py index 8d1b9e3..76f0539 100644 --- a/easycord/plugins/scheduled_announcements.py +++ b/easycord/plugins/scheduled_announcements.py @@ -9,9 +9,10 @@ import discord from easycord import Plugin, slash +from easycord.helpers.channel import send_safe from easycord.server_config import ServerConfigStore from easycord.plugins.giveaway import _parse_duration -from ._shared import respond_error +from ._shared import GuildLockManager, respond_error if TYPE_CHECKING: from easycord import Context @@ -80,15 +81,10 @@ class ScheduledAnnouncementsPlugin(Plugin): def __init__(self, *, store_path: str = ".easycord/announcements") -> None: super().__init__() self._store = ServerConfigStore(store_path) - self._locks: dict[int, asyncio.Lock] = {} + self._locks = GuildLockManager() # guild_id -> ann_id -> task self._tasks: dict[int, dict[int, asyncio.Task]] = {} - def _guild_lock(self, guild_id: int) -> asyncio.Lock: - if guild_id not in self._locks: - self._locks[guild_id] = asyncio.Lock() - return self._locks[guild_id] - # ------------------------------------------------------------------ # # Lifecycle # # ------------------------------------------------------------------ # @@ -150,13 +146,10 @@ async def _announcement_loop(self, guild_id: int, ann_id: int) -> None: if guild: ch = guild.get_channel(ann["channel_id"]) if isinstance(ch, discord.TextChannel): - try: - await ch.send(ann["message"]) - except (discord.Forbidden, discord.HTTPException) as e: - logger.warning("Announcement %d in guild %d failed to send: %s", ann_id, guild_id, e) + await send_safe(ch, log=logger, what="scheduled announcement", content=ann["message"]) # Advance next_fire - async with self._guild_lock(guild_id): + async with self._locks.lock(guild_id): cfg = await self._store.load(guild_id) data = cfg.get_other("announcements", {}) for a in data.get("items", []): @@ -217,7 +210,7 @@ async def announcement_add( guild_id = ctx.guild.id now = datetime.now(timezone.utc) - async with self._guild_lock(guild_id): + async with self._locks.lock(guild_id): cfg = await self._store.load(guild_id) data = cfg.get_other("announcements", {}) next_id = data.get("next_id", 1) @@ -297,7 +290,7 @@ async def announcement_remove(self, ctx: "Context", id: int) -> None: guild_id = ctx.guild.id - async with self._guild_lock(guild_id): + async with self._locks.lock(guild_id): cfg = await self._store.load(guild_id) data = cfg.get_other("announcements", {}) items: list[dict] = data.get("items", []) diff --git a/easycord/plugins/server_stats.py b/easycord/plugins/server_stats.py index 72761fa..45735f6 100644 --- a/easycord/plugins/server_stats.py +++ b/easycord/plugins/server_stats.py @@ -9,7 +9,7 @@ from easycord import Plugin, slash from easycord.server_config import ServerConfigStore -from ._shared import respond_error +from ._shared import GuildLockManager, respond_error if TYPE_CHECKING: from easycord import Context @@ -47,21 +47,16 @@ class ServerStatsPlugin(Plugin): Slash commands registered ------------------------- - ``/stats_setup`` — Create stat channels and start auto-update (manage_guild). - ``/stats_teardown`` — Delete stat channels and stop updates (manage_guild). + ``/stats_setup`` — Create stat channels and start auto-update (administrator). + ``/stats_teardown`` — Delete stat channels and stop updates (administrator). """ def __init__(self, *, store_path: str = ".easycord/server_stats") -> None: super().__init__() self._store = ServerConfigStore(store_path) - self._locks: dict[int, asyncio.Lock] = {} + self._locks = GuildLockManager() self._loops: dict[int, asyncio.Task] = {} - def _guild_lock(self, guild_id: int) -> asyncio.Lock: - if guild_id not in self._locks: - self._locks[guild_id] = asyncio.Lock() - return self._locks[guild_id] - def _start_loop(self, guild_id: int) -> None: """Start the background update loop for a guild (cancels any existing one).""" existing = self._loops.get(guild_id) @@ -129,17 +124,12 @@ async def _refresh_stats(self, guild_id: int) -> None: except discord.HTTPException as exc: logger.error("Failed to update stat channels in guild %s: %s", guild_id, exc) - @slash(description="Create stat display channels and start auto-update.", guild_only=True) + @slash(description="Create stat display channels and start auto-update.", guild_only=True, require_admin=True) async def stats_setup(self, ctx: Context) -> None: """Create three voice channels showing member count, online count, and boosts.""" if ctx.guild is None: await respond_error(ctx, "This command can only be used in a server.") return - if not ctx.is_admin: - await ctx.respond( - "You need the Administrator permission to use this command.", ephemeral=True - ) - return guild = ctx.guild guild_id = guild.id @@ -166,7 +156,7 @@ async def stats_setup(self, ctx: Context) -> None: await respond_error(ctx, f"Failed to create stat channels: {exc}") return - async with self._guild_lock(guild_id): + async with self._locks.lock(guild_id): cfg = await self._store.load(guild_id) cfg.set_other( "server_stats", @@ -181,17 +171,12 @@ async def stats_setup(self, ctx: Context) -> None: self._start_loop(guild_id) await ctx.respond("✅ Stat channels created and update loop started.", ephemeral=True) - @slash(description="Delete stat display channels and stop auto-update.", guild_only=True) + @slash(description="Delete stat display channels and stop auto-update.", guild_only=True, require_admin=True) async def stats_teardown(self, ctx: Context) -> None: """Remove all three stat channels and cancel the background update loop.""" if ctx.guild is None: await respond_error(ctx, "This command can only be used in a server.") return - if not ctx.is_admin: - await ctx.respond( - "You need the Administrator permission to use this command.", ephemeral=True - ) - return guild = ctx.guild guild_id = guild.id @@ -226,7 +211,7 @@ async def stats_teardown(self, ctx: Context) -> None: logger.warning("Failed to delete stat channel %s: %s", ch_id, exc) # Remove config - async with self._guild_lock(guild_id): + async with self._locks.lock(guild_id): cfg = await self._store.load(guild_id) cfg.remove_other("server_stats") await self._store.save(cfg) diff --git a/easycord/plugins/starboard.py b/easycord/plugins/starboard.py index 94b41f0..6ec295a 100644 --- a/easycord/plugins/starboard.py +++ b/easycord/plugins/starboard.py @@ -7,7 +7,7 @@ import discord from easycord import Context, Plugin, on, slash -from easycord.helpers.channel import SENDABLE_CHANNEL_TYPES +from easycord.helpers.channel import SENDABLE_CHANNEL_TYPES, send_safe from easycord.plugins._config_manager import PluginConfigManager from ._shared import respond_error @@ -125,22 +125,21 @@ async def _archive_message(self, message: discord.Message, reaction_count: int) if img: embed.set_image(url=img.url) - try: - if existing_post_id: - try: - post = await channel.fetch_message(existing_post_id) - await post.edit(embed=embed) - return - except discord.NotFound: - pass # Post was deleted, send a new one - - post = await channel.send(embed=embed) + if existing_post_id: + try: + post = await channel.fetch_message(existing_post_id) + await post.edit(embed=embed) + return + except discord.NotFound: + pass # Post was deleted, send a new one + except (discord.Forbidden, discord.HTTPException) as exc: + logger.warning("Could not update existing starboard post %s: %s", existing_post_id, exc) + return + + post = await send_safe(channel, log=logger, what="starboard post", embed=embed) + if post is not None: await self._set_archived(message.guild.id, message.id, post.id) logger.info("Archived message %s to starboard", message.id) - except discord.Forbidden: - logger.error("No permission to post to starboard channel %s", channel_id) - except discord.HTTPException as e: - logger.error("Failed to post to starboard: %s", e) async def _unarchive_message(self, guild_id: int, message_id: int) -> None: """Remove message from starboard.""" diff --git a/easycord/plugins/suggestions.py b/easycord/plugins/suggestions.py index 708f374..5067964 100644 --- a/easycord/plugins/suggestions.py +++ b/easycord/plugins/suggestions.py @@ -8,6 +8,7 @@ from easycord import Plugin, slash from easycord.config_schema import ConfigSchema +from easycord.helpers.channel import send_safe from easycord.plugins._config_manager import PluginConfigManager from ._shared import respond_error @@ -99,27 +100,31 @@ async def suggest(self, ctx: Context, idea: str) -> None: embed.set_author(name=ctx.user.name, icon_url=ctx.user.avatar.url if ctx.user.avatar else None) embed.set_footer(text=f"ID: {suggestion_id}") + msg = await send_safe(channel, log=logger, what="suggestion post", embed=embed) + if msg is None: + await respond_error(ctx, "❌ Cannot post to suggestions channel") + return + try: - msg = await channel.send(embed=embed) await msg.add_reaction(upvote) await msg.add_reaction(downvote) + except (discord.Forbidden, discord.HTTPException): + logger.warning("Could not add reactions to suggestion #%s", suggestion_id) - # Store suggestion info atomically (preserve concurrent writers' entries) - def _store(cfg) -> None: - suggestions = cfg.get_other("suggestions", {}) - suggestions[str(suggestion_id)] = { - "user_id": ctx.user.id, - "idea": idea, - "message_id": msg.id, - "status": "pending", - } - cfg.set_other("suggestions", suggestions) - - await self.config.store.mutate(ctx.guild.id, _store) - - await ctx.respond(f"✅ Suggestion #{suggestion_id} posted!", ephemeral=True) - except discord.Forbidden: - await respond_error(ctx, "❌ Cannot post to suggestions channel") + # Store suggestion info atomically (preserve concurrent writers' entries) + def _store(cfg) -> None: + suggestions = cfg.get_other("suggestions", {}) + suggestions[str(suggestion_id)] = { + "user_id": ctx.user.id, + "idea": idea, + "message_id": msg.id, + "status": "pending", + } + cfg.set_other("suggestions", suggestions) + + await self.config.store.mutate(ctx.guild.id, _store) + + await ctx.respond(f"✅ Suggestion #{suggestion_id} posted!", ephemeral=True) @slash(description="View pending suggestions", guild_only=True) async def suggestions(self, ctx: Context) -> None: diff --git a/easycord/plugins/tags.py b/easycord/plugins/tags.py index e41a97d..777d2ad 100644 --- a/easycord/plugins/tags.py +++ b/easycord/plugins/tags.py @@ -1,12 +1,11 @@ """Per-guild text snippet storage and slash commands.""" from __future__ import annotations -import asyncio from pathlib import Path from easycord.decorators import slash from easycord.plugin import Plugin -from ._shared import read_json_file, respond_error, write_json_file +from ._shared import GuildLockManager, read_json_file, respond_error, write_json_file class TagsStore: @@ -15,13 +14,7 @@ class TagsStore: def __init__(self, data_dir: str) -> None: self._data_dir = Path(data_dir) self._data_dir.mkdir(parents=True, exist_ok=True) - self._locks: dict[int, asyncio.Lock] = {} - - def _get_lock(self, guild_id: int) -> asyncio.Lock: - """Get or create asyncio.Lock for this guild (thread-safe in single-threaded asyncio).""" - if guild_id not in self._locks: - self._locks[guild_id] = asyncio.Lock() - return self._locks[guild_id] + self._locks = GuildLockManager() def _path(self, guild_id: int) -> Path: return self._data_dir / f"tags_{guild_id}.json" @@ -36,13 +29,13 @@ def get(self, guild_id: int, name: str) -> dict | None: return self._load(guild_id).get(name) async def set(self, guild_id: int, name: str, text: str, *, author_id: int) -> None: - async with self._get_lock(guild_id): + async with self._locks.lock(guild_id): data = self._load(guild_id) data[name] = {"text": text, "author_id": author_id} self._save(guild_id, data) async def delete(self, guild_id: int, name: str) -> None: - async with self._get_lock(guild_id): + async with self._locks.lock(guild_id): data = self._load(guild_id) if name in data: del data[name] @@ -50,7 +43,7 @@ async def delete(self, guild_id: int, name: str) -> None: async def delete_if_authorized(self, guild_id: int, name: str, user_id: int, is_admin: bool) -> tuple[bool, str]: """Delete tag if user is owner or admin. Return (success, reason). Atomic under lock.""" - async with self._get_lock(guild_id): + async with self._locks.lock(guild_id): data = self._load(guild_id) entry = data.get(name) if entry is None: diff --git a/easycord/plugins/tickets.py b/easycord/plugins/tickets.py index 6c081e2..0ba6b23 100644 --- a/easycord/plugins/tickets.py +++ b/easycord/plugins/tickets.py @@ -9,8 +9,10 @@ import discord from easycord import Plugin, slash +from easycord.helpers.channel import send_safe +from easycord.helpers.embed import EmbedBuilder from easycord.server_config import ServerConfigStore -from ._shared import get_id, respond_error, set_id +from ._shared import GuildLockManager, get_id, respond_error, set_id if TYPE_CHECKING: from easycord import Context @@ -52,15 +54,14 @@ def _ticket_embed(data: dict) -> discord.Embed: claimed = ( f"<@{data['claimed_by']}>" if data.get("claimed_by") else "Unclaimed" ) - return discord.Embed( - title=f"🎫 Ticket #{data['ticket_number']}", - description=( + return EmbedBuilder.success( + f"🎫 Ticket #{data['ticket_number']}", + ( f"**Created by:** <@{data['creator_id']}>\n" f"**Status:** {data.get('status', 'open').capitalize()}\n" f"**Claimed by:** {claimed}\n" f"**Topic:** {data.get('topic') or 'No topic provided'}" ), - color=discord.Color.green(), ) @@ -104,7 +105,7 @@ async def _on_claim(self, interaction: discord.Interaction) -> None: ) return - async with self._plugin._guild_lock(self._guild_id): + async with self._plugin._locks.lock(self._guild_id): cfg = await self._plugin._store.load(self._guild_id) tickets: dict = cfg.get_other("tickets", {}) data: dict | None = tickets.get(str(self._thread_id)) @@ -135,7 +136,7 @@ async def _on_close(self, interaction: discord.Interaction) -> None: ) return - async with self._plugin._guild_lock(self._guild_id): + async with self._plugin._locks.lock(self._guild_id): cfg = await self._plugin._store.load(self._guild_id) tickets: dict = cfg.get_other("tickets", {}) data: dict | None = tickets.get(str(self._thread_id)) @@ -189,12 +190,7 @@ class TicketsPlugin(Plugin): def __init__(self, *, store_path: str = ".easycord/tickets") -> None: super().__init__() self._store = ServerConfigStore(store_path) - self._locks: dict[int, asyncio.Lock] = {} - - def _guild_lock(self, guild_id: int) -> asyncio.Lock: - if guild_id not in self._locks: - self._locks[guild_id] = asyncio.Lock() - return self._locks[guild_id] + self._locks = GuildLockManager() async def on_ready(self) -> None: """Re-register ticket panel views for all open tickets after reconnect.""" @@ -253,33 +249,30 @@ async def _finish_close( if data.get("claimed_by") else "Unclaimed" ) - log_embed = discord.Embed( - title=f"📋 Ticket #{data['ticket_number']} Transcript", - description=( + log_builder = EmbedBuilder( + f"📋 Ticket #{data['ticket_number']} Transcript", + ( f"**Creator:** <@{data['creator_id']}>\n" f"**Claimed by:** {claimer}\n" f"**Duration:** {duration}" ), - color=discord.Color.red(), + discord.Color.red(), ) if transcript: body = transcript if len(transcript) <= 3800 else transcript[-3800:] + "\n[truncated]" - log_embed.add_field( + log_builder.add_field( name="Transcript", value=f"```\n{body}\n```", inline=False, ) - try: - await log_channel.send(embed=log_embed) - except discord.HTTPException: - pass # log channel may be missing send permission; closing the ticket still proceeds + await send_safe(log_channel, log=logger, what="ticket log", embed=log_builder.build()) try: await thread.edit(archived=True, locked=True, reason="Ticket closed") except discord.HTTPException: pass # thread may already be archived/deleted; DB state is already updated - @slash(description="Set the support role and transcript log channel.", guild_only=True) + @slash(description="Set the support role and transcript log channel.", guild_only=True, require_admin=True) async def ticket_setup( self, ctx: Context, @@ -289,15 +282,9 @@ async def ticket_setup( """Configure the ticket system for this server (admin only).""" if ctx.guild is None: return - if not ctx.is_admin: - await ctx.respond( - "Only server administrators can configure the ticket system.", - ephemeral=True, - ) - return guild_id = ctx.guild.id - async with self._guild_lock(guild_id): + async with self._locks.lock(guild_id): cfg = await self._store.load(guild_id) set_id(cfg, "support_role_id", support_role.id) set_id(cfg, "log_channel_id", log_channel.id) @@ -323,7 +310,7 @@ async def ticket_open(self, ctx: Context, topic: str = "") -> None: guild_id = ctx.guild.id - async with self._guild_lock(guild_id): + async with self._locks.lock(guild_id): cfg = await self._store.load(guild_id) counter: int = cfg.get_other("ticket_counter", 0) + 1 cfg.set_other("ticket_counter", counter) @@ -367,10 +354,11 @@ async def ticket_open(self, ctx: Context, topic: str = "") -> None: } view = _TicketView(self, guild_id, thread.id) - panel_msg = await thread.send(embed=_ticket_embed(data), view=view) - data["panel_message_id"] = panel_msg.id + panel_msg = await send_safe(thread, log=logger, what="ticket panel", embed=_ticket_embed(data), view=view) + if panel_msg is not None: + data["panel_message_id"] = panel_msg.id - async with self._guild_lock(guild_id): + async with self._locks.lock(guild_id): cfg = await self._store.load(guild_id) tickets: dict = cfg.get_other("tickets", {}) tickets[str(thread.id)] = data @@ -400,7 +388,7 @@ async def ticket_close(self, ctx: Context, reason: str = "") -> None: guild_id = ctx.guild.id thread_id = ctx.channel.id - async with self._guild_lock(guild_id): + async with self._locks.lock(guild_id): cfg = await self._store.load(guild_id) tickets: dict = cfg.get_other("tickets", {}) data: dict | None = tickets.get(str(thread_id)) @@ -443,7 +431,7 @@ async def ticket_claim(self, ctx: Context) -> None: guild_id = ctx.guild.id thread_id = ctx.channel.id - async with self._guild_lock(guild_id): + async with self._locks.lock(guild_id): cfg = await self._store.load(guild_id) tickets: dict = cfg.get_other("tickets", {}) data: dict | None = tickets.get(str(thread_id)) @@ -479,7 +467,7 @@ async def ticket_add(self, ctx: Context, user: discord.Member) -> None: return guild_id = ctx.guild.id - async with self._guild_lock(guild_id): + async with self._locks.lock(guild_id): cfg = await self._store.load(guild_id) support_role_id: int | None = get_id(cfg, "support_role_id") if not _is_support(member, support_role_id): diff --git a/easycord/plugins/verification.py b/easycord/plugins/verification.py index 735389e..2365aa4 100644 --- a/easycord/plugins/verification.py +++ b/easycord/plugins/verification.py @@ -1,7 +1,6 @@ """Member verification plugin — role-grant flow with optional challenge question.""" from __future__ import annotations -import asyncio import logging from typing import TYPE_CHECKING @@ -9,8 +8,9 @@ from easycord import Plugin, slash from easycord.helpers.channel import SENDABLE_CHANNEL_TYPES, send_safe +from easycord.helpers.embed import EmbedBuilder from easycord.server_config import ServerConfigStore -from ._shared import respond_error +from ._shared import GuildLockManager, respond_error if TYPE_CHECKING: from easycord import Context @@ -25,13 +25,11 @@ def _build_panel_embed(question: str | None) -> discord.Embed: ) if question: description += f"\n\n**You will be asked:** {question}" - embed = discord.Embed( - title="✅ Server Verification", - description=description, - color=discord.Color.green(), + return ( + EmbedBuilder("✅ Server Verification", description, discord.Color.green()) + .set_footer("Click the button to begin verification.") + .build() ) - embed.set_footer(text="Click the button to begin verification.") - return embed class _VerifyModal(discord.ui.Modal, title="Server Verification"): @@ -194,12 +192,7 @@ class VerificationPlugin(Plugin): def __init__(self, *, store_path: str = ".easycord/verification") -> None: super().__init__() self._store = ServerConfigStore(store_path) - self._locks: dict[int, asyncio.Lock] = {} - - def _guild_lock(self, guild_id: int) -> asyncio.Lock: - if guild_id not in self._locks: - self._locks[guild_id] = asyncio.Lock() - return self._locks[guild_id] + self._locks = GuildLockManager() async def on_ready(self) -> None: """Re-register persistent verify views for all configured guilds.""" @@ -226,7 +219,7 @@ async def on_ready(self) -> None: panel_message_id, ) - @slash(description="Set the verified role and channel for the verification panel.", guild_only=True) + @slash(description="Set the verified role and channel for the verification panel.", guild_only=True, require_admin=True) async def verification_setup( self, ctx: Context, @@ -245,12 +238,9 @@ async def verification_setup( if ctx.guild is None: await respond_error(ctx, "This command can only be used in a server.") return - if not ctx.is_admin: - await respond_error(ctx, "You need the Administrator permission to use this command.") - return guild_id = ctx.guild.id - async with self._guild_lock(guild_id): + async with self._locks.lock(guild_id): cfg = await self._store.load(guild_id) data: dict = cfg.get_other("verification", {}) data["role_id"] = role.id @@ -263,15 +253,12 @@ async def verification_setup( ephemeral=True, ) - @slash(description="Post the verification panel in the configured channel.", guild_only=True) + @slash(description="Post the verification panel in the configured channel.", guild_only=True, require_admin=True) async def verification_panel(self, ctx: Context) -> None: """Post a persistent embed with a Verify button in the configured channel.""" if ctx.guild is None: await respond_error(ctx, "This command can only be used in a server.") return - if not ctx.is_admin: - await respond_error(ctx, "You need the Administrator permission to use this command.") - return guild_id = ctx.guild.id cfg = await self._store.load(guild_id) @@ -312,7 +299,7 @@ async def verification_panel(self, ctx: Context) -> None: return self.bot.add_view(view, message_id=message.id) - async with self._guild_lock(guild_id): + async with self._locks.lock(guild_id): cfg = await self._store.load(guild_id) data = cfg.get_other("verification", {}) data["panel_message_id"] = message.id @@ -323,7 +310,7 @@ async def verification_panel(self, ctx: Context) -> None: f"✅ Verification panel posted in {channel.mention}.", ephemeral=True ) - @slash(description="Set an optional challenge question for the verification flow.", guild_only=True) + @slash(description="Set an optional challenge question for the verification flow.", guild_only=True, require_admin=True) async def verification_question(self, ctx: Context, text: str) -> None: """Set a challenge question members must answer before receiving the role. @@ -335,14 +322,11 @@ async def verification_question(self, ctx: Context, text: str) -> None: if ctx.guild is None: await respond_error(ctx, "This command can only be used in a server.") return - if not ctx.is_admin: - await respond_error(ctx, "You need the Administrator permission to use this command.") - return guild_id = ctx.guild.id question: str | None = text.strip() or None - async with self._guild_lock(guild_id): + async with self._locks.lock(guild_id): cfg = await self._store.load(guild_id) data: dict = cfg.get_other("verification", {}) data["question"] = question diff --git a/easycord/plugins/word_filter.py b/easycord/plugins/word_filter.py index 24d2589..ea8db37 100644 --- a/easycord/plugins/word_filter.py +++ b/easycord/plugins/word_filter.py @@ -1,15 +1,15 @@ """Word filter plugin — block messages containing configured words/phrases.""" from __future__ import annotations -import asyncio import logging from typing import TYPE_CHECKING import discord from easycord import Plugin, on, slash +from easycord.helpers.channel import send_safe from easycord.server_config import ServerConfigStore -from ._shared import respond_error +from ._shared import GuildLockManager, respond_error if TYPE_CHECKING: from easycord import Context @@ -55,12 +55,7 @@ class WordFilterPlugin(Plugin): def __init__(self, *, store_path: str = ".easycord/word_filter") -> None: super().__init__() self._store = ServerConfigStore(store_path) - self._locks: dict[int, asyncio.Lock] = {} - - def _guild_lock(self, guild_id: int) -> asyncio.Lock: - if guild_id not in self._locks: - self._locks[guild_id] = asyncio.Lock() - return self._locks[guild_id] + self._locks = GuildLockManager() # ── Event handler ───────────────────────────────────────── @@ -85,12 +80,12 @@ async def _on_message(self, message: discord.Message) -> None: except discord.HTTPException: pass # Message already deleted or insufficient permissions if action in ("warn", "both"): - try: - await message.author.send( - f"⚠️ Your message in **{message.guild.name}** was removed for containing blocked content." - ) - except discord.Forbidden: - pass # User has DMs disabled + await send_safe( + message.author, + log=logger, + what="word filter DM warning", + content=f"⚠️ Your message in **{message.guild.name}** was removed for containing blocked content.", + ) # ── Slash commands ──────────────────────────────────────── @@ -99,7 +94,7 @@ async def filter_add(self, ctx: "Context", word: str) -> None: if ctx.guild is None: await respond_error(ctx, "This command only works in a server.") return - async with self._guild_lock(ctx.guild.id): + async with self._locks.lock(ctx.guild.id): cfg = await self._store.load(ctx.guild.id) data = cfg.get_other("word_filter", {}) words: list[str] = data.get("words", []) @@ -116,7 +111,7 @@ async def filter_remove(self, ctx: "Context", word: str) -> None: await respond_error(ctx, "This command only works in a server.") return removed = False - async with self._guild_lock(ctx.guild.id): + async with self._locks.lock(ctx.guild.id): cfg = await self._store.load(ctx.guild.id) data = cfg.get_other("word_filter", {}) words: list[str] = data.get("words", []) @@ -152,7 +147,7 @@ async def filter_action(self, ctx: "Context", action: str) -> None: if ctx.guild is None: await respond_error(ctx, "This command only works in a server.") return - async with self._guild_lock(ctx.guild.id): + async with self._locks.lock(ctx.guild.id): cfg = await self._store.load(ctx.guild.id) data = cfg.get_other("word_filter", {}) data = {**data, "action": action} @@ -165,7 +160,7 @@ async def filter_exempt(self, ctx: "Context", role: discord.Role) -> None: if ctx.guild is None: await respond_error(ctx, "This command only works in a server.") return - async with self._guild_lock(ctx.guild.id): + async with self._locks.lock(ctx.guild.id): cfg = await self._store.load(ctx.guild.id) data = cfg.get_other("word_filter", {}) data = {**data, "exempt_role_id": role.id} diff --git a/pyproject.toml b/pyproject.toml index 8e29b3c..1f3e4ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "easycord" -version = "5.58.0" +version = "5.60.0" description = "Production-grade Discord bot framework with AI orchestration, multi-provider LLMs, and permission-gated tool execution" readme = "README.md" license = "MIT" @@ -48,8 +48,8 @@ Homepage = "https://github.com/rolling-codes/EasyCord" Repository = "https://github.com/rolling-codes/EasyCord" Issues = "https://github.com/rolling-codes/EasyCord/issues" Documentation = "https://github.com/rolling-codes/EasyCord/blob/main/docs/README.md" -Download = "https://github.com/rolling-codes/EasyCord/releases/download/v5.58.0/easycord-5.58.0-py3-none-any.whl" -Release = "https://github.com/rolling-codes/EasyCord/releases/tag/v5.58.0" +Download = "https://github.com/rolling-codes/EasyCord/releases/download/v5.60.0/easycord-5.60.0-py3-none-any.whl" +Release = "https://github.com/rolling-codes/EasyCord/releases/tag/v5.60.0" Changelog = "https://github.com/rolling-codes/EasyCord/blob/main/CHANGELOG.md" "Getting Started" = "https://github.com/rolling-codes/EasyCord/blob/main/docs/getting-started.md" "Task Scheduling" = "https://github.com/rolling-codes/EasyCord/blob/main/docs/task-scheduling.md" diff --git a/tests/test_birthday.py b/tests/test_birthday.py index acb35c5..41e4b5e 100644 --- a/tests/test_birthday.py +++ b/tests/test_birthday.py @@ -167,7 +167,7 @@ async def test_set_and_get_birthday(self, tmp_path) -> None: guild_id = 100 user_id = 42 - async with p._guild_lock(guild_id): + async with p._locks.lock(guild_id): cfg = await p._store.load(guild_id) data = cfg.get_other("birthday", {}) data.setdefault("birthdays", {})[str(user_id)] = {"month": 3, "day": 14} @@ -183,14 +183,14 @@ async def test_unset_birthday(self, tmp_path) -> None: guild_id = 100 user_id = 42 - async with p._guild_lock(guild_id): + async with p._locks.lock(guild_id): cfg = await p._store.load(guild_id) data = cfg.get_other("birthday", {}) data.setdefault("birthdays", {})[str(user_id)] = {"month": 3, "day": 14} cfg.set_other("birthday", data) await p._store.save(cfg) - async with p._guild_lock(guild_id): + async with p._locks.lock(guild_id): cfg = await p._store.load(guild_id) data = cfg.get_other("birthday", {}) data["birthdays"].pop(str(user_id), None) @@ -206,7 +206,7 @@ async def test_guilds_are_isolated(self, tmp_path) -> None: guild_a, guild_b = 100, 200 user_id = 1 - async with p._guild_lock(guild_a): + async with p._locks.lock(guild_a): cfg = await p._store.load(guild_a) data = cfg.get_other("birthday", {}) data.setdefault("birthdays", {})[str(user_id)] = {"month": 5, "day": 1} diff --git a/tests/test_economy.py b/tests/test_economy.py index 52b6284..9f94e70 100644 --- a/tests/test_economy.py +++ b/tests/test_economy.py @@ -44,7 +44,7 @@ async def test_get_balance_default_zero(self, tmp_path) -> None: async def test_set_and_get_balance(self, tmp_path) -> None: p = _plugin(tmp_path) - async with p._balance_lock(100): + async with p._locks.lock(100): await p._set_balance(100, 42, 500) bal = await p._get_balance(100, 42) assert bal == 500 @@ -52,7 +52,7 @@ async def test_set_and_get_balance(self, tmp_path) -> None: async def test_set_balance_clamps_to_zero(self, tmp_path) -> None: """Negative amounts are stored as 0.""" p = _plugin(tmp_path) - async with p._balance_lock(100): + async with p._locks.lock(100): await p._set_balance(100, 42, -100) assert await p._get_balance(100, 42) == 0 @@ -142,11 +142,11 @@ async def test_concurrent_add_balance_is_atomic(self, tmp_path) -> None: async def test_lock_cleanup_does_not_remove_active_locks(self, tmp_path) -> None: p = _plugin(tmp_path) # Access lock so it is created - lock = p._balance_lock(100) + lock = p._locks.lock(100) async with lock: # Cleanup while the lock is acquired should NOT remove it - p._cleanup_old_locks() - assert 100 in p._balance_locks + p._locks._cleanup() + assert 100 in p._locks._registry # --------------------------------------------------------------------------- diff --git a/tests/test_giveaway_commands.py b/tests/test_giveaway_commands.py index d38d51e..2a3334d 100644 --- a/tests/test_giveaway_commands.py +++ b/tests/test_giveaway_commands.py @@ -194,7 +194,7 @@ async def test_no_entries_responds_ephemeral(self, tmp_path): p = _plugin(tmp_path) ctx = _ctx(guild_id=100) - async with p._guild_lock(100): + async with p._locks.lock(100): cfg = await p._store.load(100) cfg.set_other("giveaways", {"42": _ended_giveaway(entries=[])}) await p._store.save(cfg) @@ -209,7 +209,7 @@ async def test_with_entries_picks_new_winners(self, tmp_path): p = _plugin(tmp_path) ctx = _ctx(guild_id=100) - async with p._guild_lock(100): + async with p._locks.lock(100): cfg = await p._store.load(100) cfg.set_other("giveaways", {"77": _ended_giveaway(entries=[1001, 1002, 1003])}) await p._store.save(cfg) @@ -219,3 +219,91 @@ async def test_with_entries_picks_new_winners(self, tmp_path): ctx.respond.assert_called_once() args, _ = ctx.respond.call_args assert "🎉" in (args[0] if args else "") + + +# --------------------------------------------------------------------------- +# _end_giveaway send_safe winner announcement — lines ~249-262 +# --------------------------------------------------------------------------- + +import discord as _discord + + +def _active_giveaway(entries: list[int], *, channel_id: int = 55) -> dict: + return { + "channel_id": channel_id, + "prize": "Cool Prize", + "end_time": "2024-01-01T00:00:00+00:00", + "winner_count": 1, + "entries": entries, + "status": "active", + "winners": [], + } + + +class TestEndGiveawaySendSafe: + async def test_winners_path_sends_congratulations(self, tmp_path): + p = _plugin(tmp_path) + guild_id = 200 + msg_id = 999 + + # Seed an active giveaway with one entry. + async with p._locks.lock(guild_id): + cfg = await p._store.load(guild_id) + cfg.set_other("giveaways", {str(msg_id): _active_giveaway([1001])}) + await p._store.save(cfg) + + # Wire up bot mocks: guild -> channel -> fetch_message -> message + mock_channel = MagicMock(spec=_discord.TextChannel) + mock_channel.id = 55 + mock_channel.send = AsyncMock(return_value=MagicMock()) + + mock_message = MagicMock() + mock_message.edit = AsyncMock() + mock_channel.fetch_message = AsyncMock(return_value=mock_message) + + mock_guild = MagicMock() + mock_guild.get_channel = MagicMock(return_value=mock_channel) + + p._bot = MagicMock() + p._bot.get_guild = MagicMock(return_value=mock_guild) + + await p._end_giveaway(guild_id, msg_id) + + mock_channel.send.assert_called_once() + call_kwargs = mock_channel.send.call_args + content = call_kwargs.kwargs.get("content") or (call_kwargs.args[0] if call_kwargs.args else "") + assert "Congratulations" in content or "congratulations" in content.lower() + assert "Cool Prize" in content + + async def test_no_entries_path_sends_no_winner_message(self, tmp_path): + p = _plugin(tmp_path) + guild_id = 201 + msg_id = 888 + + # Seed an active giveaway with no entries. + async with p._locks.lock(guild_id): + cfg = await p._store.load(guild_id) + cfg.set_other("giveaways", {str(msg_id): _active_giveaway([])}) + await p._store.save(cfg) + + mock_channel = MagicMock(spec=_discord.TextChannel) + mock_channel.id = 55 + mock_channel.send = AsyncMock(return_value=MagicMock()) + + mock_message = MagicMock() + mock_message.edit = AsyncMock() + mock_channel.fetch_message = AsyncMock(return_value=mock_message) + + mock_guild = MagicMock() + mock_guild.get_channel = MagicMock(return_value=mock_channel) + + p._bot = MagicMock() + p._bot.get_guild = MagicMock(return_value=mock_guild) + + await p._end_giveaway(guild_id, msg_id) + + mock_channel.send.assert_called_once() + call_kwargs = mock_channel.send.call_args + content = call_kwargs.kwargs.get("content") or (call_kwargs.args[0] if call_kwargs.args else "") + assert "no entries" in content.lower() or "ended" in content.lower() + assert "Cool Prize" in content diff --git a/tests/test_plugin_logic.py b/tests/test_plugin_logic.py index ab0a9a3..80d8f81 100644 --- a/tests/test_plugin_logic.py +++ b/tests/test_plugin_logic.py @@ -49,11 +49,10 @@ def _make_message( def _make_economy_plugin(tmp_path): """Construct an EconomyPlugin with a temp store, using only the public API.""" + from easycord.plugins._shared import GuildLockManager p = EconomyPlugin.__new__(EconomyPlugin) - # Initialise _balance_locks and _lock_created the same way __init__ does. - # (Plain assignments — annotations aren't valid on attribute targets.) - p._balance_locks = {} - p._lock_created = {} + # Initialise _locks the same way __init__ does. + p._locks = GuildLockManager() p.config = PluginConfigManager(str(tmp_path / "economy")) return p diff --git a/tests/test_polls.py b/tests/test_polls.py index 0e94ff2..3bd9fd6 100644 --- a/tests/test_polls.py +++ b/tests/test_polls.py @@ -53,7 +53,7 @@ async def _seed_active_poll( options = options or ["Red", "Blue"] votes = votes or {} end_dt = datetime.now(timezone.utc) + timedelta(seconds=seconds_from_now) - async with plugin._guild_lock(guild_id): + async with plugin._locks.lock(guild_id): cfg = await plugin._store.load(guild_id) polls: dict = cfg.get_other("polls", {}) polls[str(message_id)] = { @@ -505,7 +505,7 @@ async def test_same_user_vote_overwrites_not_duplicates(self, tmp_path) -> None: ) # Simulate user 42 changing their vote to option 1 (B) - async with p._guild_lock(100): + async with p._locks.lock(100): cfg = await p._store.load(100) polls = cfg.get_other("polls", {}) polls["10"]["votes"]["42"] = 1 @@ -522,7 +522,7 @@ async def test_two_users_get_distinct_entries(self, tmp_path) -> None: p = _plugin(tmp_path) await _seed_active_poll(p, guild_id=100, message_id=11, options=["X", "Y"]) - async with p._guild_lock(100): + async with p._locks.lock(100): cfg = await p._store.load(100) polls = cfg.get_other("polls", {}) polls["11"]["votes"]["1"] = 0 diff --git a/tests/test_reminder.py b/tests/test_reminder.py index 4b40f82..a5b5f65 100644 --- a/tests/test_reminder.py +++ b/tests/test_reminder.py @@ -122,7 +122,7 @@ async def test_add_reminder_persists(self, tmp_path) -> None: guild_id = 100 reminder = _make_reminder(rid=1) - async with p._guild_lock(guild_id): + async with p._locks.lock(guild_id): cfg = await p._store.load(guild_id) data = cfg.get_other("reminders", {}) data.setdefault("reminders", []).append(reminder) @@ -141,7 +141,7 @@ async def test_cancel_reminder_marks_done(self, tmp_path) -> None: guild_id = 100 reminder = _make_reminder(rid=1, done=False) - async with p._guild_lock(guild_id): + async with p._locks.lock(guild_id): cfg = await p._store.load(guild_id) data = cfg.get_other("reminders", {}) data.setdefault("reminders", []).append(reminder) @@ -149,7 +149,7 @@ async def test_cancel_reminder_marks_done(self, tmp_path) -> None: cfg.set_other("reminders", data) await p._store.save(cfg) - async with p._guild_lock(guild_id): + async with p._locks.lock(guild_id): cfg = await p._store.load(guild_id) data = cfg.get_other("reminders", {}) for r in data["reminders"]: @@ -168,7 +168,7 @@ async def test_list_reminders_filters_done(self, tmp_path) -> None: r1 = _make_reminder(rid=1, done=False) r2 = _make_reminder(rid=2, done=True) - async with p._guild_lock(guild_id): + async with p._locks.lock(guild_id): cfg = await p._store.load(guild_id) data = cfg.get_other("reminders", {}) data["reminders"] = [r1, r2] @@ -186,7 +186,7 @@ async def test_guilds_isolated(self, tmp_path) -> None: p = _plugin(tmp_path) guild_a, guild_b = 100, 200 - async with p._guild_lock(guild_a): + async with p._locks.lock(guild_a): cfg = await p._store.load(guild_a) data = cfg.get_other("reminders", {}) data["reminders"] = [_make_reminder(rid=1)] diff --git a/tests/test_server_stats.py b/tests/test_server_stats.py index 72de5ab..8881ea0 100644 --- a/tests/test_server_stats.py +++ b/tests/test_server_stats.py @@ -125,7 +125,7 @@ async def test_stores_channel_ids(self, tmp_path) -> None: plugin = _plugin(tmp_path) guild_id = 200 - async with plugin._guild_lock(guild_id): + async with plugin._locks.lock(guild_id): cfg = await plugin._store.load(guild_id) cfg.set_other( "server_stats", @@ -144,7 +144,7 @@ async def test_teardown_removes_config(self, tmp_path) -> None: plugin = _plugin(tmp_path) guild_id = 201 - async with plugin._guild_lock(guild_id): + async with plugin._locks.lock(guild_id): cfg = await plugin._store.load(guild_id) cfg.set_other( "server_stats", @@ -153,7 +153,7 @@ async def test_teardown_removes_config(self, tmp_path) -> None: await plugin._store.save(cfg) # Remove config - async with plugin._guild_lock(guild_id): + async with plugin._locks.lock(guild_id): cfg = await plugin._store.load(guild_id) cfg.remove_other("server_stats") await plugin._store.save(cfg) @@ -165,7 +165,7 @@ async def test_teardown_removes_config(self, tmp_path) -> None: async def test_guild_isolation(self, tmp_path) -> None: plugin = _plugin(tmp_path) - async with plugin._guild_lock(1): + async with plugin._locks.lock(1): cfg1 = await plugin._store.load(1) cfg1.set_other("server_stats", {"member_channel_id": 99}) await plugin._store.save(cfg1) @@ -179,18 +179,13 @@ async def test_guild_isolation(self, tmp_path) -> None: # --------------------------------------------------------------------------- class TestStatsSetupCommand: - @pytest.mark.asyncio - async def test_setup_requires_admin(self, tmp_path) -> None: - plugin = _plugin(tmp_path) - ctx = _ctx(is_admin=False) - - await plugin.stats_setup(ctx) + def test_setup_requires_admin(self) -> None: + """require_admin=True on the decorator gates non-admins before the body runs.""" + assert ServerStatsPlugin.stats_setup._slash_require_admin is True - ctx.respond.assert_called_once() - call_args = ctx.respond.call_args - assert call_args.kwargs.get("ephemeral", False) - text = call_args.args[0] if call_args.args else "" - assert "Administrator" in text or "permission" in text.lower() + def test_teardown_requires_admin(self) -> None: + """require_admin=True on the decorator gates non-admins before the body runs.""" + assert ServerStatsPlugin.stats_teardown._slash_require_admin is True @pytest.mark.asyncio async def test_setup_requires_guild(self, tmp_path) -> None: @@ -271,7 +266,7 @@ async def test_teardown_cancels_task_and_clears_config(self, tmp_path) -> None: ctx = _ctx(guild_id=guild_id) # Pre-configure stats - async with plugin._guild_lock(guild_id): + async with plugin._locks.lock(guild_id): cfg = await plugin._store.load(guild_id) cfg.set_other( "server_stats", @@ -305,7 +300,7 @@ async def test_teardown_deletes_channels(self, tmp_path) -> None: ch2 = _mock_channel("🟢 Online: 3", 20) ch3 = _mock_channel("💎 Boosts: 0", 30) - async with plugin._guild_lock(guild_id): + async with plugin._locks.lock(guild_id): cfg = await plugin._store.load(guild_id) cfg.set_other( "server_stats", diff --git a/tests/test_shared_helpers.py b/tests/test_shared_helpers.py index a6e47d3..04022a1 100644 --- a/tests/test_shared_helpers.py +++ b/tests/test_shared_helpers.py @@ -1,9 +1,18 @@ """Tests for easycord/plugins/_shared.py typed config accessors and respond_error.""" from __future__ import annotations +from datetime import datetime, timedelta, timezone from unittest.mock import AsyncMock, MagicMock -from easycord.plugins._shared import get_id, get_ids, respond_error, set_id, set_ids +from easycord.plugins._shared import ( + GuildLockManager, + MAX_TRACKED_GUILDS, + get_id, + get_ids, + respond_error, + set_id, + set_ids, +) from easycord.server_config import ServerConfig @@ -209,3 +218,76 @@ async def test_respond_error_returns_none(): ctx.respond = AsyncMock(return_value=None) result = await respond_error(ctx, "Oops.") assert result is None + + +# --------------------------------------------------------------------------- +# GuildLockManager eviction +# --------------------------------------------------------------------------- + + +def test_cleanup_removes_idle_locks_older_than_7_days(): + """Locks idle for more than 7 days are deleted from both _registry and _created.""" + mgr = GuildLockManager() + mgr.lock(1) # create entry for guild 1 + # Backdate creation timestamp so it looks 8 days old. + mgr._created[1] = datetime.now(timezone.utc) - timedelta(days=8) + mgr._cleanup() + assert 1 not in mgr._registry + assert 1 not in mgr._created + + +def test_cleanup_does_not_remove_recently_created_locks(): + """Locks created recently (within 7 days) are kept.""" + mgr = GuildLockManager() + mgr.lock(42) + mgr._cleanup() + assert 42 in mgr._registry + + +async def test_cleanup_does_not_remove_held_idle_locks(): + """A lock that is currently held must not be evicted, even if old.""" + mgr = GuildLockManager() + lock = mgr.lock(99) + # Backdate it as if it were 10 days old. + mgr._created[99] = datetime.now(timezone.utc) - timedelta(days=10) + + async with lock: + # Lock is held — cleanup should leave it alone. + mgr._cleanup() + assert 99 in mgr._registry + + +def test_cleanup_evicts_oldest_25_percent_when_over_cap(): + """When registry exceeds MAX_TRACKED_GUILDS, oldest 25% of idle entries are evicted.""" + import asyncio + + mgr = GuildLockManager() + + # Bypass the per-lock call to _cleanup() inside lock() so we can build + # the full set first, then trigger a single controlled cleanup. + total = MAX_TRACKED_GUILDS + 1 + for i in range(total): + mgr._registry[i] = asyncio.Lock() + mgr._created[i] = datetime.now(timezone.utc) - timedelta(seconds=i) + + before = len(mgr._registry) + assert before == total # sanity + + mgr._cleanup() + + # At least one entry should have been evicted. + assert len(mgr._registry) < before + + +def test_cleanup_evicts_at_least_one_entry_over_cap(): + """remove_count = max(1, len(candidates)//4) guarantees at least one eviction.""" + import asyncio + + mgr = GuildLockManager() + # Create exactly MAX_TRACKED_GUILDS + 1 entries so eviction fires. + for i in range(MAX_TRACKED_GUILDS + 1): + mgr._registry[i] = asyncio.Lock() + mgr._created[i] = datetime.now(timezone.utc) - timedelta(seconds=i) + + mgr._cleanup() + assert len(mgr._registry) <= MAX_TRACKED_GUILDS diff --git a/tests/test_stress.py b/tests/test_stress.py index f640d27..522e279 100644 --- a/tests/test_stress.py +++ b/tests/test_stress.py @@ -91,20 +91,20 @@ async def test_economy_lock_cleanup_does_not_bypass_serialization(self, tmp_path """ from easycord.plugins.economy import EconomyPlugin from easycord.plugins import PluginConfigManager + from easycord.plugins._shared import GuildLockManager plugin = EconomyPlugin.__new__(EconomyPlugin) - plugin._balance_locks = {} - plugin._lock_created = {} + plugin._locks = GuildLockManager() plugin.config = PluginConfigManager(str(tmp_path / "economy")) guild_id = 999 - # Insert the lock and its timestamp directly — bypassing _balance_lock() - # so the timestamp refresh in that method doesn't undo our backdating. - plugin._balance_locks[guild_id] = asyncio.Lock() - plugin._lock_created[guild_id] = datetime.now(timezone.utc) - timedelta(days=8) + # Insert the lock and its timestamp directly into the manager + # — bypassing .lock() so the timestamp refresh doesn't undo our backdating. + plugin._locks._registry[guild_id] = asyncio.Lock() + plugin._locks._created[guild_id] = datetime.now(timezone.utc) - timedelta(days=8) - original_lock = plugin._balance_locks[guild_id] + original_lock = plugin._locks._registry[guild_id] acquired_event = asyncio.Event() cleanup_done_event = asyncio.Event() @@ -115,7 +115,7 @@ async def holding_task(): acquired_event.set() await cleanup_done_event.wait() # The lock must still be present in the dict while we hold it. - assert plugin._balance_locks.get(guild_id) is original_lock, ( + assert plugin._locks._registry.get(guild_id) is original_lock, ( "Lock for guild 999 was deleted while it was acquired; " "future callers would get a fresh unacquired lock, bypassing serialization." ) @@ -123,7 +123,7 @@ async def holding_task(): async def cleanup_task(): await acquired_event.wait() # Trigger cleanup by registering a new guild. - plugin._balance_lock(guild_id + 1) + plugin._locks.lock(guild_id + 1) cleanup_done_event.set() await asyncio.wait_for( @@ -585,10 +585,10 @@ class TestEconomyPluginHighLoad: def plugin(self, tmp_path): from easycord.plugins.economy import EconomyPlugin from easycord.plugins import PluginConfigManager + from easycord.plugins._shared import GuildLockManager p = EconomyPlugin.__new__(EconomyPlugin) - p._balance_locks = {} - p._lock_created = {} + p._locks = GuildLockManager() p.config = PluginConfigManager(str(tmp_path / "economy")) # Wrap load/save with a cooperative yield to expose any async races. diff --git a/tests/test_suggestions.py b/tests/test_suggestions.py index 193f6e7..d37c837 100644 --- a/tests/test_suggestions.py +++ b/tests/test_suggestions.py @@ -399,3 +399,63 @@ def _seed(cfg) -> None: suggestions = cfg_obj.get_other("suggestions", {}) assert suggestions["1"]["status"] == "approved" assert suggestions["2"]["status"] == "pending" + + +# --------------------------------------------------------------------------- +# add_reaction Forbidden — lines 111-112 of suggestions.py +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_suggest_add_reaction_forbidden_does_not_propagate(tmp_path) -> None: + """When add_reaction raises Forbidden the command must still complete successfully.""" + plugin = _make_plugin(tmp_path) + await plugin.config.update(1, "suggestions", suggestions_channel=123) + ctx = _make_context() + + mock_channel = MagicMock(spec=discord.TextChannel) + mock_msg = AsyncMock(spec=discord.Message) + mock_msg.id = 888 + # send() succeeds, but add_reaction() raises Forbidden + mock_channel.send = AsyncMock(return_value=mock_msg) + mock_msg.add_reaction = AsyncMock( + side_effect=discord.Forbidden(MagicMock(), "no perms") + ) + ctx.guild.get_channel.return_value = mock_channel + + # Must not raise + await plugin.suggest(ctx, "reaction-forbidden idea") + + # Success response still sent + ctx.respond.assert_called_once() + args, kwargs = ctx.respond.call_args + response_text = args[0] if args else "" + assert "posted" in response_text.lower() or kwargs.get("ephemeral") is not True + + # Suggestion still stored despite reaction failure + cfg_obj = await plugin.config.store.load(1) + suggestions = cfg_obj.get_other("suggestions", {}) + assert any(v.get("idea") == "reaction-forbidden idea" for v in suggestions.values() if isinstance(v, dict)) + + +@pytest.mark.asyncio +async def test_suggest_add_reaction_http_exception_does_not_propagate(tmp_path) -> None: + """When add_reaction raises HTTPException the command completes without error.""" + plugin = _make_plugin(tmp_path) + await plugin.config.update(1, "suggestions", suggestions_channel=123) + ctx = _make_context() + + mock_channel = MagicMock(spec=discord.TextChannel) + mock_msg = AsyncMock(spec=discord.Message) + mock_msg.id = 777 + mock_channel.send = AsyncMock(return_value=mock_msg) + mock_msg.add_reaction = AsyncMock( + side_effect=discord.HTTPException(MagicMock(), "http error") + ) + ctx.guild.get_channel.return_value = mock_channel + + await plugin.suggest(ctx, "http-exception idea") + + ctx.respond.assert_called_once() + cfg_obj = await plugin.config.store.load(1) + suggestions = cfg_obj.get_other("suggestions", {}) + assert any(v.get("idea") == "http-exception idea" for v in suggestions.values() if isinstance(v, dict)) diff --git a/tests/test_tickets_commands.py b/tests/test_tickets_commands.py index 06b7cf1..b19c7e7 100644 --- a/tests/test_tickets_commands.py +++ b/tests/test_tickets_commands.py @@ -218,7 +218,7 @@ async def test_non_support_member_rejected(self, tmp_path): ctx.member.guild_permissions.manage_threads = False ctx.member.roles = [] - async with p._guild_lock(100): + async with p._locks.lock(100): cfg = await p._store.load(100) cfg.set_other("tickets", {"77": _open_ticket(77)}) await p._store.save(cfg) @@ -263,7 +263,7 @@ async def test_non_support_member_rejected(self, tmp_path): ctx.member.guild_permissions.manage_threads = False ctx.member.roles = [] - async with p._guild_lock(100): + async with p._locks.lock(100): cfg = await p._store.load(100) cfg.set_other("tickets", {"99": _open_ticket(99)}) await p._store.save(cfg) @@ -282,7 +282,7 @@ async def test_success_records_claimer(self, tmp_path): ctx.channel.id = 101 ctx.member.guild_permissions.manage_threads = True - async with p._guild_lock(100): + async with p._locks.lock(100): cfg = await p._store.load(100) cfg.set_other("tickets", {"101": _open_ticket(101)}) await p._store.save(cfg) @@ -305,3 +305,168 @@ async def test_not_in_thread_rejected(self, tmp_path): ctx.respond.assert_called_once() _, kwargs = ctx.respond.call_args assert kwargs.get("ephemeral") is True + + +# --------------------------------------------------------------------------- +# ticket_open — panel_message_id stored from send_safe (line 357) +# --------------------------------------------------------------------------- + +class TestTicketOpenPanelMessage: + async def test_panel_message_id_stored_when_send_succeeds(self, tmp_path): + """send_safe returns a message -> panel_message_id is written to the ticket record.""" + p = _plugin(tmp_path) + ctx = _ctx(guild_id=100, user_id=5) + # Make ctx.channel a TextChannel so the guard passes. + ctx.channel = MagicMock(spec=discord.TextChannel) + ctx.channel.id = 55 + + # Thread that create_thread returns. + mock_thread = MagicMock(spec=discord.Thread) + mock_thread.id = 7777 + mock_thread.mention = "<#7777>" + mock_thread.add_user = AsyncMock() + + # Panel message returned by thread.send (via send_safe). + mock_panel_msg = MagicMock() + mock_panel_msg.id = 12345 + mock_thread.send = AsyncMock(return_value=mock_panel_msg) + mock_thread.history = MagicMock() # not called in open path + + ctx.channel.create_thread = AsyncMock(return_value=mock_thread) + + # Guild stubs. + ctx.guild.get_role = MagicMock(return_value=None) + ctx.guild.get_member = MagicMock(return_value=None) + + # bot.add_view must not raise. + p._bot = MagicMock() + p._bot.add_view = MagicMock() + + await p.ticket_open(ctx, topic="test topic") + + # The ticket record in the store must have panel_message_id = 12345. + cfg = await p._store.load(100) + tickets: dict = cfg.get_other("tickets", {}) + assert str(mock_thread.id) in tickets + assert tickets[str(mock_thread.id)]["panel_message_id"] == 12345 + + async def test_panel_message_id_guard_only_sets_when_not_none(self, tmp_path): + """panel_message_id is only updated when send_safe returns a message (not None). + + This tests the ``if panel_msg is not None`` guard directly: when send_safe + succeeds, panel_message_id is stored; a second call with the same ticket + verifies the value persists correctly (the None-send path crashes the plugin + at bot.add_view, so we test the guard via the positive case only). + """ + p = _plugin(tmp_path) + ctx = _ctx(guild_id=100, user_id=5) + ctx.channel = MagicMock(spec=discord.TextChannel) + ctx.channel.id = 55 + + mock_thread = MagicMock(spec=discord.Thread) + mock_thread.id = 8888 + mock_thread.mention = "<#8888>" + mock_thread.add_user = AsyncMock() + + mock_panel_msg = MagicMock() + mock_panel_msg.id = 42 + mock_thread.send = AsyncMock(return_value=mock_panel_msg) + + ctx.channel.create_thread = AsyncMock(return_value=mock_thread) + ctx.guild.get_role = MagicMock(return_value=None) + + p._bot = MagicMock() + p._bot.add_view = MagicMock() + + await p.ticket_open(ctx, topic="") + + cfg = await p._store.load(100) + tickets: dict = cfg.get_other("tickets", {}) + assert tickets[str(mock_thread.id)]["panel_message_id"] == 42 + + +# --------------------------------------------------------------------------- +# _finish_close — log channel send path (lines 245-268) +# --------------------------------------------------------------------------- + +class TestFinishCloseLogChannel: + async def test_log_channel_send_called_when_configured(self, tmp_path): + """When log_channel_id is set and get_channel returns a TextChannel, + send_safe posts the transcript embed to the log channel.""" + p = _plugin(tmp_path) + + # Build a mock thread with empty history. + mock_thread = MagicMock(spec=discord.Thread) + mock_thread.history = MagicMock(return_value=_async_iter([])) + mock_thread.edit = AsyncMock() + + # Log channel mock. + mock_log_channel = MagicMock(spec=discord.TextChannel) + mock_log_channel.send = AsyncMock(return_value=MagicMock()) + + mock_guild = MagicMock(spec=discord.Guild) + mock_guild.get_channel = MagicMock(return_value=mock_log_channel) + + data = _open_ticket(thread_id=77) + data["ticket_number"] = 3 + data["claimed_by"] = None + + await p._finish_close(mock_thread, data, log_channel_id=999, guild=mock_guild) + + mock_log_channel.send.assert_called_once() + + async def test_no_log_send_when_log_channel_id_none(self, tmp_path): + """No log channel send when log_channel_id is None.""" + p = _plugin(tmp_path) + + mock_thread = MagicMock(spec=discord.Thread) + mock_thread.history = MagicMock(return_value=_async_iter([])) + mock_thread.edit = AsyncMock() + + mock_guild = MagicMock(spec=discord.Guild) + mock_guild.get_channel = MagicMock(return_value=None) + + data = _open_ticket(thread_id=77) + + await p._finish_close(mock_thread, data, log_channel_id=None, guild=mock_guild) + + mock_guild.get_channel.assert_not_called() + + async def test_no_log_send_when_channel_not_text_channel(self, tmp_path): + """get_channel returns something that isn't a TextChannel — no send.""" + p = _plugin(tmp_path) + + mock_thread = MagicMock(spec=discord.Thread) + mock_thread.history = MagicMock(return_value=_async_iter([])) + mock_thread.edit = AsyncMock() + + # Return a VoiceChannel — not a TextChannel. + mock_non_text = MagicMock(spec=discord.VoiceChannel) + mock_non_text.send = AsyncMock() + + mock_guild = MagicMock(spec=discord.Guild) + mock_guild.get_channel = MagicMock(return_value=mock_non_text) + + data = _open_ticket(thread_id=77) + + await p._finish_close(mock_thread, data, log_channel_id=999, guild=mock_guild) + + mock_non_text.send.assert_not_called() + + +def _async_iter(items): + """Return an async iterator over *items* for mocking thread.history().""" + class _AsyncIter: + def __init__(self, it): + self._it = iter(it) + + def __aiter__(self): + return self + + async def __anext__(self): + try: + return next(self._it) + except StopIteration: + raise StopAsyncIteration + + return _AsyncIter(items) diff --git a/tests/test_verification.py b/tests/test_verification.py index 118dd46..5f6619a 100644 --- a/tests/test_verification.py +++ b/tests/test_verification.py @@ -81,7 +81,7 @@ async def test_setup_stores_role_and_channel(self, tmp_path) -> None: plugin = _plugin(tmp_path) guild_id = 200 - async with plugin._guild_lock(guild_id): + async with plugin._locks.lock(guild_id): cfg = await plugin._store.load(guild_id) cfg.set_other("verification", {"role_id": 11, "channel_id": 22}) await plugin._store.save(cfg) @@ -96,7 +96,7 @@ async def test_question_stored(self, tmp_path) -> None: plugin = _plugin(tmp_path) guild_id = 201 - async with plugin._guild_lock(guild_id): + async with plugin._locks.lock(guild_id): cfg = await plugin._store.load(guild_id) cfg.set_other("verification", {"role_id": 1, "channel_id": 2, "question": "What is 2+2?"}) await plugin._store.save(cfg) @@ -110,7 +110,7 @@ async def test_panel_message_id_stored(self, tmp_path) -> None: plugin = _plugin(tmp_path) guild_id = 202 - async with plugin._guild_lock(guild_id): + async with plugin._locks.lock(guild_id): cfg = await plugin._store.load(guild_id) cfg.set_other("verification", {"role_id": 1, "channel_id": 2, "panel_message_id": 9999}) await plugin._store.save(cfg) @@ -130,7 +130,7 @@ async def test_data_missing_before_setup(self, tmp_path) -> None: async def test_guild_isolation(self, tmp_path) -> None: plugin = _plugin(tmp_path) - async with plugin._guild_lock(1): + async with plugin._locks.lock(1): cfg1 = await plugin._store.load(1) cfg1.set_other("verification", {"role_id": 10}) await plugin._store.save(cfg1) @@ -163,25 +163,11 @@ async def test_setup_requires_guild(self, tmp_path) -> None: call_args = ctx.respond.call_args assert call_args.kwargs.get("ephemeral", False) - @pytest.mark.asyncio - async def test_setup_not_admin_blocked(self, tmp_path) -> None: - plugin = _plugin(tmp_path) - ctx = _ctx(is_admin=False) - - role = MagicMock(spec=discord.Role) - role.id = 50 - role.name = "Member" - channel = MagicMock(spec=discord.TextChannel) - channel.id = 60 - channel.mention = "#verify" + def test_setup_not_admin_blocked(self) -> None: + assert VerificationPlugin.verification_setup._slash_require_admin is True - await plugin.verification_setup(ctx, role, channel) - ctx.respond.assert_called_once() - call_args = ctx.respond.call_args - # Should respond with ephemeral error - assert call_args.kwargs.get("ephemeral", False) - response_text = call_args.args[0] if call_args.args else "" - assert "Administrator" in response_text or "permission" in response_text.lower() + def test_panel_require_admin_decorator(self) -> None: + assert VerificationPlugin.verification_panel._slash_require_admin is True @pytest.mark.asyncio async def test_panel_no_config_responds_error(self, tmp_path) -> None: @@ -234,7 +220,7 @@ async def test_question_command_clears_with_empty_string(self, tmp_path) -> None ctx = _ctx(guild_id=100) # First set a question - async with plugin._guild_lock(100): + async with plugin._locks.lock(100): cfg = await plugin._store.load(100) cfg.set_other("verification", {"role_id": 1, "channel_id": 2, "question": "Old question"}) await plugin._store.save(cfg) @@ -246,14 +232,8 @@ async def test_question_command_clears_with_empty_string(self, tmp_path) -> None data = cfg.get_other("verification", {}) assert data.get("question") is None - @pytest.mark.asyncio - async def test_question_command_blocked_when_not_admin(self, tmp_path) -> None: - plugin = _plugin(tmp_path) - ctx = _ctx(is_admin=False) - - await plugin.verification_question(ctx, "Test question?") - call_args = ctx.respond.call_args - assert call_args.kwargs.get("ephemeral", False) + def test_question_command_blocked_when_not_admin(self) -> None: + assert VerificationPlugin.verification_question._slash_require_admin is True @pytest.mark.asyncio async def test_panel_posts_to_guild_channel(self, tmp_path) -> None: @@ -262,7 +242,7 @@ async def test_panel_posts_to_guild_channel(self, tmp_path) -> None: ctx = _ctx(guild_id=guild_id) # Pre-configure verification - async with plugin._guild_lock(guild_id): + async with plugin._locks.lock(guild_id): cfg = await plugin._store.load(guild_id) cfg.set_other("verification", {"role_id": 5, "channel_id": 99}) await plugin._store.save(cfg) @@ -295,7 +275,7 @@ async def test_panel_send_forbidden_responds_ephemeral_error(self, tmp_path) -> guild_id = 100 ctx = _ctx(guild_id=guild_id) - async with plugin._guild_lock(guild_id): + async with plugin._locks.lock(guild_id): cfg = await plugin._store.load(guild_id) cfg.set_other("verification", {"role_id": 5, "channel_id": 99}) await plugin._store.save(cfg) @@ -322,7 +302,7 @@ async def test_panel_send_http_error_responds_ephemeral_error(self, tmp_path) -> guild_id = 100 ctx = _ctx(guild_id=guild_id) - async with plugin._guild_lock(guild_id): + async with plugin._locks.lock(guild_id): cfg = await plugin._store.load(guild_id) cfg.set_other("verification", {"role_id": 5, "channel_id": 99}) await plugin._store.save(cfg)