From 619ff92465c1e6873030d6176ce731e3526b422f Mon Sep 17 00:00:00 2001 From: tee Date: Sat, 25 Jul 2026 10:52:08 -0400 Subject: [PATCH 01/31] =?UTF-8?q?feat:=20add=20Phase=201=20shared=20helper?= =?UTF-8?q?s=20=E2=80=94=20respond=5Ferror=20and=20typed=20config=20access?= =?UTF-8?q?ors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add five pure helpers to easycord/plugins/_shared.py: - respond_error(ctx, message): collapses the 107-site ephemeral error pattern - get_id / set_id: str↔int coercion over ServerConfig.get_other/set_other; None deletes - get_ids / set_ids: list variant with per-item coercion; missing key returns [] All are pure over ServerConfig (no I/O, no lock) so they slot inside existing mutate/guild_lock spans without changing concurrency semantics. Tests: 29 new cases in test_shared_helpers.py covering coercion, defaults, None-delete, non-coercible skipping, and respond ephemeral flag. --- easycord/plugins/_shared.py | 60 ++++++++++ tests/test_shared_helpers.py | 213 +++++++++++++++++++++++++++++++++++ 2 files changed, 273 insertions(+) create mode 100644 tests/test_shared_helpers.py diff --git a/easycord/plugins/_shared.py b/easycord/plugins/_shared.py index 443353f..6f53c97 100644 --- a/easycord/plugins/_shared.py +++ b/easycord/plugins/_shared.py @@ -4,15 +4,75 @@ import json import os from pathlib import Path +from typing import TYPE_CHECKING, Iterable import discord +if TYPE_CHECKING: + from easycord.server_config import ServerConfig + def require_guild(ctx: object) -> discord.Guild | None: """Return the invoking guild, or ``None`` when the command ran in DMs.""" return getattr(ctx, "guild", None) +async def respond_error(ctx: object, message: str) -> None: + """Send *message* as an ephemeral error reply. + + Collapses the ubiquitous ``await ctx.respond(msg, ephemeral=True)`` pattern + used for validation failures and permission denials across the plugins. + """ + await ctx.respond(message, ephemeral=True) # type: ignore[attr-defined] + + +# --------------------------------------------------------------------------- +# Typed config accessors +# +# Discord snowflake IDs are frequently persisted as JSON strings, so reads +# normalize back to ``int``. These are pure over a ``ServerConfig`` (no store, +# no lock, no I/O), so they slot inside an existing ``mutate``/lock span +# without changing locking semantics. +# --------------------------------------------------------------------------- + +def get_id(cfg: "ServerConfig", key: str) -> int | None: + """Return the snowflake stored under *key* as an ``int``, or ``None``.""" + value = cfg.get_other(key) + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def set_id(cfg: "ServerConfig", key: str, value: int | None) -> None: + """Store a snowflake under *key*. ``None`` removes the key entirely.""" + if value is None: + cfg.remove_other(key) + return + cfg.set_other(key, int(value)) + + +def get_ids(cfg: "ServerConfig", key: str) -> list[int]: + """Return the list of snowflakes under *key* as ``int``s (missing -> []).""" + raw = cfg.get_other(key) + if not isinstance(raw, (list, tuple)): + return [] + out: list[int] = [] + for item in raw: + try: + out.append(int(item)) + except (TypeError, ValueError): + continue + return out + + +def set_ids(cfg: "ServerConfig", key: str, values: Iterable[int]) -> None: + """Store an iterable of snowflakes under *key* as a list of ``int``s.""" + cfg.set_other(key, [int(v) for v in values]) + + def format_template(template: str, **values: str) -> str: """Render a simple placeholder template.""" return template.format(**values) diff --git a/tests/test_shared_helpers.py b/tests/test_shared_helpers.py new file mode 100644 index 0000000..59c458e --- /dev/null +++ b/tests/test_shared_helpers.py @@ -0,0 +1,213 @@ +"""Tests for easycord/plugins/_shared.py typed config accessors and respond_error.""" +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from easycord.plugins._shared import get_id, get_ids, respond_error, set_id, set_ids +from easycord.server_config import ServerConfig + + +def _cfg() -> ServerConfig: + return ServerConfig(guild_id=1) + + +# --------------------------------------------------------------------------- +# get_id +# --------------------------------------------------------------------------- + +def test_get_id_returns_int_for_stored_int(): + cfg = _cfg() + cfg.set_other("channel", 555) + assert get_id(cfg, "channel") == 555 + + +def test_get_id_coerces_string_to_int(): + cfg = _cfg() + cfg.set_other("channel", "777") + assert get_id(cfg, "channel") == 777 + + +def test_get_id_returns_none_for_missing_key(): + assert get_id(_cfg(), "missing") is None + + +def test_get_id_returns_none_for_none_value(): + cfg = _cfg() + cfg.set_other("channel", None) + assert get_id(cfg, "channel") is None + + +def test_get_id_returns_none_for_non_coercible_value(): + cfg = _cfg() + cfg.set_other("channel", "not-a-number") + assert get_id(cfg, "channel") is None + + +def test_get_id_returns_none_for_float_string(): + cfg = _cfg() + cfg.set_other("channel", "3.14") + assert get_id(cfg, "channel") is None + + +def test_get_id_returns_int_type(): + cfg = _cfg() + cfg.set_other("role", "123") + result = get_id(cfg, "role") + assert isinstance(result, int) + + +# --------------------------------------------------------------------------- +# set_id +# --------------------------------------------------------------------------- + +def test_set_id_stores_int(): + cfg = _cfg() + set_id(cfg, "role", 123) + assert cfg.get_other("role") == 123 + + +def test_set_id_value_is_int_type(): + cfg = _cfg() + set_id(cfg, "role", 456) + assert isinstance(cfg.get_other("role"), int) + + +def test_set_id_none_removes_key(): + cfg = _cfg() + cfg.set_other("role", 123) + set_id(cfg, "role", None) + assert not cfg.has_other("role") + + +def test_set_id_none_on_missing_key_is_noop(): + cfg = _cfg() + set_id(cfg, "never_set", None) + assert not cfg.has_other("never_set") + + +def test_set_id_get_id_round_trip(): + cfg = _cfg() + set_id(cfg, "log_channel", 888) + assert get_id(cfg, "log_channel") == 888 + + +def test_set_id_overwrite(): + cfg = _cfg() + set_id(cfg, "channel", 111) + set_id(cfg, "channel", 222) + assert get_id(cfg, "channel") == 222 + + +# --------------------------------------------------------------------------- +# get_ids +# --------------------------------------------------------------------------- + +def test_get_ids_returns_list_of_ints(): + cfg = _cfg() + cfg.set_other("roles", [1, 2, 3]) + assert get_ids(cfg, "roles") == [1, 2, 3] + + +def test_get_ids_coerces_string_items(): + cfg = _cfg() + cfg.set_other("roles", ["10", "20", "30"]) + assert get_ids(cfg, "roles") == [10, 20, 30] + + +def test_get_ids_returns_empty_for_missing_key(): + assert get_ids(_cfg(), "missing") == [] + + +def test_get_ids_returns_empty_for_none_value(): + cfg = _cfg() + cfg.set_other("roles", None) + assert get_ids(cfg, "roles") == [] + + +def test_get_ids_returns_empty_for_non_list_value(): + cfg = _cfg() + cfg.set_other("roles", "not-a-list") + assert get_ids(cfg, "roles") == [] + + +def test_get_ids_skips_non_coercible_items(): + cfg = _cfg() + cfg.set_other("roles", [1, "bad", 3]) + assert get_ids(cfg, "roles") == [1, 3] + + +def test_get_ids_accepts_tuple(): + cfg = _cfg() + cfg.set_other("roles", (10, 20)) + assert get_ids(cfg, "roles") == [10, 20] + + +def test_get_ids_result_is_list(): + cfg = _cfg() + cfg.set_other("roles", [1]) + assert isinstance(get_ids(cfg, "roles"), list) + + +# --------------------------------------------------------------------------- +# set_ids +# --------------------------------------------------------------------------- + +def test_set_ids_stores_list(): + cfg = _cfg() + set_ids(cfg, "roles", [100, 200]) + assert cfg.get_other("roles") == [100, 200] + + +def test_set_ids_round_trip_with_get_ids(): + cfg = _cfg() + set_ids(cfg, "roles", [11, 22, 33]) + assert get_ids(cfg, "roles") == [11, 22, 33] + + +def test_set_ids_stores_ints(): + cfg = _cfg() + set_ids(cfg, "roles", [1, 2, 3]) + assert all(isinstance(v, int) for v in cfg.get_other("roles")) + + +def test_set_ids_empty_list(): + cfg = _cfg() + set_ids(cfg, "roles", []) + assert cfg.get_other("roles") == [] + + +# --------------------------------------------------------------------------- +# respond_error +# --------------------------------------------------------------------------- + +async def test_respond_error_calls_respond_ephemeral(): + ctx = MagicMock() + ctx.respond = AsyncMock() + await respond_error(ctx, "Something went wrong.") + ctx.respond.assert_called_once_with("Something went wrong.", ephemeral=True) + + +async def test_respond_error_passes_message_verbatim(): + ctx = MagicMock() + ctx.respond = AsyncMock() + await respond_error(ctx, "Access denied.") + args, kwargs = ctx.respond.call_args + assert args[0] == "Access denied." + assert kwargs.get("ephemeral") is True + + +async def test_respond_error_sets_ephemeral_true(): + ctx = MagicMock() + ctx.respond = AsyncMock() + await respond_error(ctx, "Error.") + _, kwargs = ctx.respond.call_args + assert kwargs["ephemeral"] is True + + +async def test_respond_error_returns_none(): + ctx = MagicMock() + ctx.respond = AsyncMock(return_value=None) + result = await respond_error(ctx, "Oops.") + assert result is None From 2eab52a4bc11cc9d9f7a5240b3eb635cb03d62c0 Mon Sep 17 00:00:00 2001 From: tee Date: Sat, 25 Jul 2026 10:55:19 -0400 Subject: [PATCH 02/31] refactor(plugins): adopt respond_error in auto_role, reminder, reputation Replace bare ctx.respond(msg, ephemeral=True) error/guard calls with respond_error() from _shared. Success embeds and confirmations stay explicit. --- easycord/plugins/auto_role.py | 13 +++++++------ easycord/plugins/reminder.py | 11 +++++------ easycord/plugins/reputation.py | 17 ++++++----------- 3 files changed, 18 insertions(+), 23 deletions(-) diff --git a/easycord/plugins/auto_role.py b/easycord/plugins/auto_role.py index 2ae0a3c..82017e3 100644 --- a/easycord/plugins/auto_role.py +++ b/easycord/plugins/auto_role.py @@ -9,6 +9,7 @@ from easycord import Plugin, on, slash from easycord.server_config import ServerConfigStore +from ._shared import respond_error if TYPE_CHECKING: from easycord import Context @@ -81,7 +82,7 @@ async def _on_member_join(self, member: discord.Member) -> None: @slash(description="Add a role to the auto-assign list for new members.", permissions=["manage_guild"]) async def autorole_add(self, ctx: "Context", role: discord.Role) -> None: if ctx.guild is None: - await ctx.respond("This command only works in a server.", ephemeral=True) + await respond_error(ctx, "This command only works in a server.") return async with self._guild_lock(ctx.guild.id): cfg = await self._store.load(ctx.guild.id) @@ -97,7 +98,7 @@ async def autorole_add(self, ctx: "Context", role: discord.Role) -> None: @slash(description="Remove a role from the auto-assign list.", permissions=["manage_guild"]) async def autorole_remove(self, ctx: "Context", role: discord.Role) -> None: if ctx.guild is None: - await ctx.respond("This command only works in a server.", ephemeral=True) + await respond_error(ctx, "This command only works in a server.") return async with self._guild_lock(ctx.guild.id): cfg = await self._store.load(ctx.guild.id) @@ -112,14 +113,14 @@ async def autorole_remove(self, ctx: "Context", role: discord.Role) -> None: @slash(description="Show currently configured auto-roles.", permissions=["manage_guild"]) async def autorole_list(self, ctx: "Context") -> None: if ctx.guild is None: - await ctx.respond("This command only works in a server.", ephemeral=True) + await respond_error(ctx, "This command only works in a server.") return cfg = await self._store.load(ctx.guild.id) data = cfg.get_other("auto_role", {}) role_ids: list[int] = data.get("role_ids", []) delay: int = data.get("delay_seconds", 0) if not role_ids: - await ctx.respond("No auto-roles configured.", ephemeral=True) + await respond_error(ctx, "No auto-roles configured.") return role_mentions = [f"<@&{rid}>" for rid in role_ids] role_list = "\n".join(f"• {m}" for m in role_mentions) @@ -131,10 +132,10 @@ async def autorole_list(self, ctx: "Context") -> None: @slash(description="Set delay in seconds before assigning auto-roles (0 = immediate).", permissions=["manage_guild"]) async def autorole_delay(self, ctx: "Context", seconds: int) -> None: if ctx.guild is None: - await ctx.respond("This command only works in a server.", ephemeral=True) + await respond_error(ctx, "This command only works in a server.") return if seconds < 0: - await ctx.respond("Delay must be 0 or greater.", ephemeral=True) + await respond_error(ctx, "Delay must be 0 or greater.") return async with self._guild_lock(ctx.guild.id): cfg = await self._store.load(ctx.guild.id) diff --git a/easycord/plugins/reminder.py b/easycord/plugins/reminder.py index 93abfe1..1520f3d 100644 --- a/easycord/plugins/reminder.py +++ b/easycord/plugins/reminder.py @@ -11,6 +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 easycord.plugins.giveaway import _parse_duration # noqa: F401 — re-exported for tests if TYPE_CHECKING: @@ -202,14 +203,14 @@ async def remind(self, ctx: Context, when: str, message: str) -> None: try: seconds = _parse_duration(when) except ValueError as exc: - await ctx.respond(str(exc), ephemeral=True) + await respond_error(ctx, str(exc)) return guild_id = ctx.guild.id user_id = ctx.user.id channel = ctx.channel if not isinstance(channel, SENDABLE_CHANNEL_TYPES): - await ctx.respond("This command must be used in a channel.", ephemeral=True) + await respond_error(ctx, "This command must be used in a channel.") return channel_id: int = channel.id @@ -264,7 +265,7 @@ async def reminders(self, ctx: Context) -> None: ] if not pending: - await ctx.respond("You have no pending reminders.", ephemeral=True) + await respond_error(ctx, "You have no pending reminders.") return lines: list[str] = [] @@ -306,9 +307,7 @@ async def reminder_cancel(self, ctx: Context, id: int) -> None: break if target is None: - await ctx.respond( - f"No pending reminder with ID {id} found.", ephemeral=True - ) + await respond_error(ctx, f"No pending reminder with ID {id} found.") return target["done"] = True diff --git a/easycord/plugins/reputation.py b/easycord/plugins/reputation.py index 853c99e..60fa23b 100644 --- a/easycord/plugins/reputation.py +++ b/easycord/plugins/reputation.py @@ -10,6 +10,7 @@ from easycord import Plugin, slash from easycord.server_config import ServerConfigStore +from ._shared import respond_error if TYPE_CHECKING: from easycord import Context @@ -100,11 +101,11 @@ async def rep(self, ctx: "Context", user: discord.User) -> None: target_id = user.id if target_id == giver_id: - await ctx.respond("You cannot give rep to yourself.", ephemeral=True) + await respond_error(ctx, "You cannot give rep to yourself.") return if user.bot: - await ctx.respond("You cannot give rep to a bot.", ephemeral=True) + await respond_error(ctx, "You cannot give rep to a bot.") return guild_id = ctx.guild.id @@ -116,10 +117,7 @@ async def rep(self, ctx: "Context", user: discord.User) -> None: cooldowns: dict = data.get("cooldowns", {}) if _is_on_cooldown(cooldowns.get(str(giver_id)), now): - await ctx.respond( - "You already gave rep in the last 24 hours. Try again later.", - ephemeral=True, - ) + await respond_error(ctx, "You already gave rep in the last 24 hours. Try again later.") return scores: dict = data.get("scores", {}) @@ -170,7 +168,7 @@ async def rep_top(self, ctx: "Context") -> None: scores: dict = data.get("scores", {}) if not scores: - await ctx.respond("No rep has been given yet.", ephemeral=True) + await respond_error(ctx, "No rep has been given yet.") return entries = _top_entries(scores) @@ -204,10 +202,7 @@ async def rep_reset(self, ctx: "Context", user: discord.User) -> None: if not getattr(ctx, "is_admin", False) and not ( ctx.member and ctx.member.guild_permissions.manage_guild ): - await ctx.respond( - "You need the **Manage Guild** permission to reset rep.", - ephemeral=True, - ) + await respond_error(ctx, "You need the **Manage Guild** permission to reset rep.") return if ctx.guild is None: From bca4124bed33d30ef311b4095334be8c7cdb8ffa Mon Sep 17 00:00:00 2001 From: tee Date: Sat, 25 Jul 2026 11:05:09 -0400 Subject: [PATCH 03/31] refactor(plugins): adopt respond_error in 12 more plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit economy, starboard, polls, birthday, verification, word_filter, suggestions, tickets, server_stats, levels, giveaway, scheduled_announcements — error/guard paths now use respond_error() from _shared. Success confirms and embeds stay explicit. --- easycord/plugins/birthday.py | 5 +++-- easycord/plugins/economy.py | 5 +++-- easycord/plugins/giveaway.py | 9 ++++---- easycord/plugins/levels.py | 23 ++++++++------------- easycord/plugins/polls.py | 7 ++++--- easycord/plugins/scheduled_announcements.py | 5 +++-- easycord/plugins/server_stats.py | 7 ++++--- easycord/plugins/starboard.py | 3 ++- easycord/plugins/suggestions.py | 15 +++++++------- easycord/plugins/tickets.py | 9 ++++---- easycord/plugins/verification.py | 13 ++++++------ easycord/plugins/word_filter.py | 17 ++++++++------- 12 files changed, 62 insertions(+), 56 deletions(-) diff --git a/easycord/plugins/birthday.py b/easycord/plugins/birthday.py index 150eeec..fa77b52 100644 --- a/easycord/plugins/birthday.py +++ b/easycord/plugins/birthday.py @@ -10,6 +10,7 @@ from easycord import Plugin, slash from easycord.server_config import ServerConfigStore +from ._shared import respond_error if TYPE_CHECKING: from easycord import Context @@ -320,7 +321,7 @@ async def birthday_unset(self, ctx: Context) -> None: data: dict = cfg.get_other("birthday", {}) birthdays: dict = data.get("birthdays", {}) if str(user_id) not in birthdays: - await ctx.respond("You don't have a birthday registered.", ephemeral=True) + await respond_error(ctx, "You don't have a birthday registered.") return birthdays.pop(str(user_id), None) data["birthdays"] = birthdays @@ -392,7 +393,7 @@ async def birthday_list(self, ctx: Context) -> None: sorted_entries = _sort_upcoming(birthdays, today) if not sorted_entries: - await ctx.respond("No birthdays have been registered yet.", ephemeral=True) + await respond_error(ctx, "No birthdays have been registered yet.") return lines: list[str] = [] diff --git a/easycord/plugins/economy.py b/easycord/plugins/economy.py index 6acde1f..8b7c9cd 100644 --- a/easycord/plugins/economy.py +++ b/easycord/plugins/economy.py @@ -10,6 +10,7 @@ from easycord import Plugin, slash, on from easycord.plugins._config_manager import PluginConfigManager +from ._shared import respond_error if TYPE_CHECKING: from easycord import Context @@ -319,11 +320,11 @@ async def transfer(self, ctx: Context, user: discord.User, amount: int) -> None: """Send currency to another user.""" assert ctx.guild is not None # guaranteed by guild_only=True if amount <= 0: - await ctx.respond("❌ Amount must be positive", ephemeral=True) + await respond_error(ctx, "❌ Amount must be positive") return if user.id == ctx.user.id: - await ctx.respond("❌ Can't transfer to yourself", ephemeral=True) + await respond_error(ctx, "❌ Can't transfer to yourself") return ok, sender_balance_after = await self._transfer( diff --git a/easycord/plugins/giveaway.py b/easycord/plugins/giveaway.py index 5df2e15..b71a366 100644 --- a/easycord/plugins/giveaway.py +++ b/easycord/plugins/giveaway.py @@ -13,6 +13,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 if TYPE_CHECKING: from easycord import Context @@ -282,10 +283,10 @@ async def giveaway( try: seconds = _parse_duration(duration) except ValueError as exc: - await ctx.respond(str(exc), ephemeral=True) + await respond_error(ctx, str(exc)) return if winners < 1: - await ctx.respond("Winner count must be at least 1.", ephemeral=True) + await respond_error(ctx, "Winner count must be at least 1.") return if ctx.guild is None: return @@ -300,7 +301,7 @@ async def giveaway( guild_id = ctx.guild.id channel = ctx.channel if not isinstance(channel, SENDABLE_CHANNEL_TYPES): - await ctx.respond("This command must be used in a channel.", ephemeral=True) + await respond_error(ctx, "This command must be used in a channel.") return channel_id: int = channel.id @@ -387,4 +388,4 @@ async def giveaway_reroll(self, ctx: Context, message_id: str) -> None: mentions = " ".join(f"<@{w}>" for w in new_winners) await ctx.respond(f"🎉 New winners: {mentions}!") else: - await ctx.respond("No entries to pick from.", ephemeral=True) + await respond_error(ctx, "No entries to pick from.") diff --git a/easycord/plugins/levels.py b/easycord/plugins/levels.py index 88ef9ec..22f409c 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 ._shared import respond_error from ._levels_data import ( LevelsStore, progress_bar, @@ -324,7 +325,7 @@ async def leaderboard(self, ctx) -> None: self._lb_cache[ctx.guild.id] = (data, now) if not data: - await ctx.respond(ctx.t("levels.no_xp_yet", default="No one has earned XP yet!"), ephemeral=True) + await respond_error(ctx, ctx.t("levels.no_xp_yet", default="No one has earned XP yet!")) return config = self._store.read_config(ctx.guild.id) @@ -333,7 +334,7 @@ async def leaderboard(self, ctx) -> None: @slash(description="Award XP to a member.", permissions=["manage_guild"], guild_only=True) async def give_xp(self, ctx, member: discord.Member, amount: int) -> None: if amount <= 0: - await ctx.respond(ctx.t("levels.amount_positive", default="Amount must be a positive number."), ephemeral=True) + await respond_error(ctx, ctx.t("levels.amount_positive", default="Amount must be a positive number.")) return xp, level, leveled_up = await self._store.add_xp(ctx.guild.id, member.id, amount) self._invalidate_lb_cache(ctx.guild.id) @@ -358,10 +359,10 @@ async def reset_xp(self, ctx, member: discord.Member) -> None: async def set_xp_multiplier(self, ctx, multiplier: float, duration_minutes: int = 60) -> None: assert ctx.guild is not None if multiplier <= 0: - await ctx.respond("❌ Multiplier must be greater than zero.", ephemeral=True) + await respond_error(ctx, "❌ Multiplier must be greater than zero.") return if duration_minutes < 1: - await ctx.respond("❌ Duration must be at least 1 minute.", ephemeral=True) + await respond_error(ctx, "❌ Duration must be at least 1 minute.") return expires_at = time.time() + duration_minutes * 60 @@ -395,7 +396,7 @@ def _toggle(cfg: dict) -> None: @slash(description="Name a rank for a specific level.", permissions=["manage_guild"], guild_only=True) async def set_rank(self, ctx, level: int, name: str) -> None: if not _positive_level(level): - await ctx.respond(ctx.t("levels.level_min", default="Level must be at least 1."), ephemeral=True) + await respond_error(ctx, ctx.t("levels.level_min", default="Level must be at least 1.")) return await self._set_config_value(ctx.guild.id, "ranks", level, name) @@ -408,10 +409,7 @@ async def set_rank(self, ctx, level: int, name: str) -> None: async def remove_rank(self, ctx, level: int) -> None: removed = await self._remove_config_value(ctx.guild.id, "ranks", level) if removed is None: - await ctx.respond( - ctx.t("levels.no_rank_at_level", default="No rank is configured at level {level}.", level=level), - ephemeral=True, - ) + await respond_error(ctx, ctx.t("levels.no_rank_at_level", default="No rank is configured at level {level}.", level=level)) return await ctx.respond( ctx.t("levels.rank_removed", default="Removed rank **{removed}** from Level {level}.", removed=removed, level=level), @@ -421,7 +419,7 @@ async def remove_rank(self, ctx, level: int) -> None: @slash(description="Assign a role reward when a member reaches a level.", permissions=["manage_guild"], guild_only=True) async def set_level_role(self, ctx, level: int, role: discord.Role) -> None: if not _positive_level(level): - await ctx.respond(ctx.t("levels.level_min", default="Level must be at least 1."), ephemeral=True) + await respond_error(ctx, ctx.t("levels.level_min", default="Level must be at least 1.")) return await self._set_config_value(ctx.guild.id, "role_rewards", level, role.id) @@ -434,9 +432,6 @@ async def set_level_role(self, ctx, level: int, role: discord.Role) -> None: async def ranks(self, ctx) -> None: config = self._store.read_config(ctx.guild.id) if not (config.get("ranks") or config.get("role_rewards")): - await ctx.respond( - ctx.t("levels.no_ranks_configured", default="No ranks or role rewards configured yet.\nUse `/set_rank` or `/set_level_role` to add some."), - ephemeral=True, - ) + await respond_error(ctx, ctx.t("levels.no_ranks_configured", default="No ranks or role rewards configured yet.\nUse `/set_rank` or `/set_level_role` to add some.")) return await ctx.respond(embed=self._build_ranks_embed(ctx, config)) diff --git a/easycord/plugins/polls.py b/easycord/plugins/polls.py index 412fb06..e6cefd3 100644 --- a/easycord/plugins/polls.py +++ b/easycord/plugins/polls.py @@ -11,6 +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 if TYPE_CHECKING: from easycord import Context @@ -293,16 +294,16 @@ async def poll( ) -> None: options = _poll_options(option1, option2, option3, option4, option5) if len(options) < 2: - await ctx.respond(ctx.t("polls.min_options", default="A poll needs at least 2 options."), ephemeral=True) + await respond_error(ctx, ctx.t("polls.min_options", default="A poll needs at least 2 options.")) return if not _is_valid_duration(duration): - await ctx.respond(ctx.t("polls.min_duration", default="Duration must be at least 5 seconds."), ephemeral=True) + await respond_error(ctx, ctx.t("polls.min_duration", default="Duration must be at least 5 seconds.")) return if ctx.guild is None: return channel = ctx.channel if not isinstance(channel, SENDABLE_CHANNEL_TYPES): - await ctx.respond(ctx.t("polls.channel_required", default="This command must be used in a channel."), ephemeral=True) + await respond_error(ctx, ctx.t("polls.channel_required", default="This command must be used in a channel.")) return guild_id = ctx.guild.id diff --git a/easycord/plugins/scheduled_announcements.py b/easycord/plugins/scheduled_announcements.py index d475923..8d1b9e3 100644 --- a/easycord/plugins/scheduled_announcements.py +++ b/easycord/plugins/scheduled_announcements.py @@ -11,6 +11,7 @@ from easycord import Plugin, slash from easycord.server_config import ServerConfigStore from easycord.plugins.giveaway import _parse_duration +from ._shared import respond_error if TYPE_CHECKING: from easycord import Context @@ -207,7 +208,7 @@ async def announcement_add( try: interval_seconds = _parse_duration(interval) except ValueError as exc: - await ctx.respond(str(exc), ephemeral=True) + await respond_error(ctx, str(exc)) return if ctx.guild is None: @@ -264,7 +265,7 @@ async def announcement_list(self, ctx: "Context") -> None: items: list[dict] = data.get("items", []) if not items: - await ctx.respond("No announcements scheduled.", ephemeral=True) + await respond_error(ctx, "No announcements scheduled.") return embeds = [_announcement_embed(ann) for ann in items] diff --git a/easycord/plugins/server_stats.py b/easycord/plugins/server_stats.py index c444a4d..72761fa 100644 --- a/easycord/plugins/server_stats.py +++ b/easycord/plugins/server_stats.py @@ -9,6 +9,7 @@ from easycord import Plugin, slash from easycord.server_config import ServerConfigStore +from ._shared import respond_error if TYPE_CHECKING: from easycord import Context @@ -132,7 +133,7 @@ async def _refresh_stats(self, guild_id: int) -> None: 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 ctx.respond("This command can only be used in a server.", ephemeral=True) + await respond_error(ctx, "This command can only be used in a server.") return if not ctx.is_admin: await ctx.respond( @@ -162,7 +163,7 @@ async def stats_setup(self, ctx: Context) -> None: ) return except discord.HTTPException as exc: - await ctx.respond(f"Failed to create stat channels: {exc}", ephemeral=True) + await respond_error(ctx, f"Failed to create stat channels: {exc}") return async with self._guild_lock(guild_id): @@ -184,7 +185,7 @@ async def stats_setup(self, ctx: Context) -> None: 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 ctx.respond("This command can only be used in a server.", ephemeral=True) + await respond_error(ctx, "This command can only be used in a server.") return if not ctx.is_admin: await ctx.respond( diff --git a/easycord/plugins/starboard.py b/easycord/plugins/starboard.py index 71156b1..94b41f0 100644 --- a/easycord/plugins/starboard.py +++ b/easycord/plugins/starboard.py @@ -9,6 +9,7 @@ from easycord import Context, Plugin, on, slash from easycord.helpers.channel import SENDABLE_CHANNEL_TYPES from easycord.plugins._config_manager import PluginConfigManager +from ._shared import respond_error if TYPE_CHECKING: pass @@ -275,7 +276,7 @@ async def starboard_emoji(self, ctx: Context, emoji: str) -> None: async def starboard_threshold(self, ctx: Context, threshold: int) -> None: assert ctx.guild is not None # guaranteed by guild_only=True if threshold < 1: - await ctx.respond("Threshold must be at least 1.", ephemeral=True) + await respond_error(ctx, "Threshold must be at least 1.") return await self._update_config(ctx.guild.id, threshold=threshold) await ctx.respond(f"Starboard threshold set to {threshold}.", ephemeral=True) diff --git a/easycord/plugins/suggestions.py b/easycord/plugins/suggestions.py index baf2f70..708f374 100644 --- a/easycord/plugins/suggestions.py +++ b/easycord/plugins/suggestions.py @@ -9,6 +9,7 @@ from easycord import Plugin, slash from easycord.config_schema import ConfigSchema from easycord.plugins._config_manager import PluginConfigManager +from ._shared import respond_error if TYPE_CHECKING: from easycord import Context @@ -78,12 +79,12 @@ async def suggest(self, ctx: Context, idea: str) -> None: channel_id = cfg.get("suggestions_channel") if not channel_id: - await ctx.respond("❌ Suggestions channel not configured", ephemeral=True) + await respond_error(ctx, "❌ Suggestions channel not configured") return channel = ctx.guild.get_channel(channel_id) if not isinstance(channel, (discord.TextChannel, discord.Thread)): - await ctx.respond("❌ Suggestions channel not found", ephemeral=True) + await respond_error(ctx, "❌ Suggestions channel not found") return suggestion_id = await self._get_next_id(ctx.guild.id) @@ -118,7 +119,7 @@ def _store(cfg) -> None: await ctx.respond(f"✅ Suggestion #{suggestion_id} posted!", ephemeral=True) except discord.Forbidden: - await ctx.respond("❌ Cannot post to suggestions channel", ephemeral=True) + await respond_error(ctx, "❌ Cannot post to suggestions channel") @slash(description="View pending suggestions", guild_only=True) async def suggestions(self, ctx: Context) -> None: @@ -156,7 +157,7 @@ async def suggestion_approve(self, ctx: Context, suggestion_id: int) -> None: assert ctx.guild is not None # guaranteed by guild_only=True assert isinstance(ctx.user, discord.Member) # guild_only ⇒ invoker is a Member if not ctx.user.guild_permissions.manage_guild: - await ctx.respond("❌ You lack `manage_guild` permission", ephemeral=True) + await respond_error(ctx, "❌ You lack `manage_guild` permission") return def _apply(cfg) -> bool: @@ -169,7 +170,7 @@ def _apply(cfg) -> bool: return True if not await self.config.store.mutate(ctx.guild.id, _apply): - await ctx.respond("❌ Suggestion not found", ephemeral=True) + await respond_error(ctx, "❌ Suggestion not found") return await ctx.respond(f"✅ Suggestion #{suggestion_id} approved") @@ -180,7 +181,7 @@ async def suggestion_reject(self, ctx: Context, suggestion_id: int) -> None: assert ctx.guild is not None # guaranteed by guild_only=True assert isinstance(ctx.user, discord.Member) # guild_only ⇒ invoker is a Member if not ctx.user.guild_permissions.manage_guild: - await ctx.respond("❌ You lack `manage_guild` permission", ephemeral=True) + await respond_error(ctx, "❌ You lack `manage_guild` permission") return def _apply(cfg) -> bool: @@ -193,7 +194,7 @@ def _apply(cfg) -> bool: return True if not await self.config.store.mutate(ctx.guild.id, _apply): - await ctx.respond("❌ Suggestion not found", ephemeral=True) + await respond_error(ctx, "❌ Suggestion not found") return await ctx.respond(f"✅ Suggestion #{suggestion_id} rejected") diff --git a/easycord/plugins/tickets.py b/easycord/plugins/tickets.py index 9f1a992..049c0e8 100644 --- a/easycord/plugins/tickets.py +++ b/easycord/plugins/tickets.py @@ -10,6 +10,7 @@ from easycord import Plugin, slash from easycord.server_config import ServerConfigStore +from ._shared import respond_error if TYPE_CHECKING: from easycord import Context @@ -341,7 +342,7 @@ async def ticket_open(self, ctx: Context, topic: str = "") -> None: reason=f"Ticket #{ticket_number} opened by {ctx.user}", ) except discord.HTTPException as exc: - await ctx.respond(f"Failed to create ticket: {exc}", ephemeral=True) + await respond_error(ctx, f"Failed to create ticket: {exc}") return await thread.add_user(ctx.user) @@ -404,7 +405,7 @@ async def ticket_close(self, ctx: Context, reason: str = "") -> None: tickets: dict = cfg.get_other("tickets", {}) data: dict | None = tickets.get(str(thread_id)) if not data or data.get("status") != "open": - await ctx.respond("This is not an open ticket.", ephemeral=True) + await respond_error(ctx, "This is not an open ticket.") return support_role_id: int | None = cfg.get_other("support_role_id") log_channel_id: int | None = cfg.get_other("log_channel_id") @@ -447,7 +448,7 @@ async def ticket_claim(self, ctx: Context) -> None: tickets: dict = cfg.get_other("tickets", {}) data: dict | None = tickets.get(str(thread_id)) if not data or data.get("status") != "open": - await ctx.respond("This is not an open ticket.", ephemeral=True) + await respond_error(ctx, "This is not an open ticket.") return support_role_id: int | None = cfg.get_other("support_role_id") if not _is_support(member, support_role_id): @@ -492,4 +493,4 @@ async def ticket_add(self, ctx: Context, user: discord.Member) -> None: await ctx.channel.add_user(user) await ctx.respond(f"Added {user.mention} to the ticket.") except discord.HTTPException as exc: - await ctx.respond(f"Failed to add user: {exc}", ephemeral=True) + await respond_error(ctx, f"Failed to add user: {exc}") diff --git a/easycord/plugins/verification.py b/easycord/plugins/verification.py index 68f62aa..735389e 100644 --- a/easycord/plugins/verification.py +++ b/easycord/plugins/verification.py @@ -10,6 +10,7 @@ from easycord import Plugin, slash from easycord.helpers.channel import SENDABLE_CHANNEL_TYPES, send_safe from easycord.server_config import ServerConfigStore +from ._shared import respond_error if TYPE_CHECKING: from easycord import Context @@ -242,10 +243,10 @@ async def verification_setup( The channel where the verification panel will be posted. """ if ctx.guild is None: - await ctx.respond("This command can only be used in a server.", ephemeral=True) + 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) + await respond_error(ctx, "You need the Administrator permission to use this command.") return guild_id = ctx.guild.id @@ -266,10 +267,10 @@ async def verification_setup( 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 ctx.respond("This command can only be used in a server.", ephemeral=True) + 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) + await respond_error(ctx, "You need the Administrator permission to use this command.") return guild_id = ctx.guild.id @@ -332,10 +333,10 @@ async def verification_question(self, ctx: Context, text: str) -> None: The question text. Pass an empty string to clear the question. """ if ctx.guild is None: - await ctx.respond("This command can only be used in a server.", ephemeral=True) + 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) + await respond_error(ctx, "You need the Administrator permission to use this command.") return guild_id = ctx.guild.id diff --git a/easycord/plugins/word_filter.py b/easycord/plugins/word_filter.py index 25ebfe0..24d2589 100644 --- a/easycord/plugins/word_filter.py +++ b/easycord/plugins/word_filter.py @@ -9,6 +9,7 @@ from easycord import Plugin, on, slash from easycord.server_config import ServerConfigStore +from ._shared import respond_error if TYPE_CHECKING: from easycord import Context @@ -96,7 +97,7 @@ async def _on_message(self, message: discord.Message) -> None: @slash(description="Add a word or phrase to the guild blocklist.", permissions=["manage_guild"]) async def filter_add(self, ctx: "Context", word: str) -> None: if ctx.guild is None: - await ctx.respond("This command only works in a server.", ephemeral=True) + await respond_error(ctx, "This command only works in a server.") return async with self._guild_lock(ctx.guild.id): cfg = await self._store.load(ctx.guild.id) @@ -112,7 +113,7 @@ async def filter_add(self, ctx: "Context", word: str) -> None: @slash(description="Remove a word or phrase from the blocklist.", permissions=["manage_guild"]) async def filter_remove(self, ctx: "Context", word: str) -> None: if ctx.guild is None: - await ctx.respond("This command only works in a server.", ephemeral=True) + await respond_error(ctx, "This command only works in a server.") return removed = False async with self._guild_lock(ctx.guild.id): @@ -125,20 +126,20 @@ async def filter_remove(self, ctx: "Context", word: str) -> None: cfg.set_other("word_filter", data) await self._store.save(cfg) if not removed: - await ctx.respond(f"**{word}** was not in the blocklist.", ephemeral=True) + await respond_error(ctx, f"**{word}** was not in the blocklist.") else: await ctx.respond(f"Removed **{word}** from the blocklist.", ephemeral=True) @slash(description="Show all blocked words for this server.", permissions=["manage_guild"]) async def filter_list(self, ctx: "Context") -> None: if ctx.guild is None: - await ctx.respond("This command only works in a server.", ephemeral=True) + await respond_error(ctx, "This command only works in a server.") return cfg = await self._store.load(ctx.guild.id) data = cfg.get_other("word_filter", {}) words: list[str] = data.get("words", []) if not words: - await ctx.respond("No blocked words configured.", ephemeral=True) + await respond_error(ctx, "No blocked words configured.") return word_list = "\n".join(f"• {w}" for w in words) await ctx.respond(f"**Blocked words:**\n{word_list}", ephemeral=True) @@ -146,10 +147,10 @@ async def filter_list(self, ctx: "Context") -> None: @slash(description="Set the filter action: 'delete', 'warn', or 'both'.", permissions=["manage_guild"], bot_permissions=["manage_messages"]) async def filter_action(self, ctx: "Context", action: str) -> None: if action not in ("delete", "warn", "both"): - await ctx.respond("Action must be 'delete', 'warn', or 'both'.", ephemeral=True) + await respond_error(ctx, "Action must be 'delete', 'warn', or 'both'.") return if ctx.guild is None: - await ctx.respond("This command only works in a server.", ephemeral=True) + await respond_error(ctx, "This command only works in a server.") return async with self._guild_lock(ctx.guild.id): cfg = await self._store.load(ctx.guild.id) @@ -162,7 +163,7 @@ async def filter_action(self, ctx: "Context", action: str) -> None: @slash(description="Exempt a role from word filtering.", permissions=["manage_guild"]) async def filter_exempt(self, ctx: "Context", role: discord.Role) -> None: if ctx.guild is None: - await ctx.respond("This command only works in a server.", ephemeral=True) + await respond_error(ctx, "This command only works in a server.") return async with self._guild_lock(ctx.guild.id): cfg = await self._store.load(ctx.guild.id) From 8635da064af66541e94122eb31d902ae6f8e2e2b Mon Sep 17 00:00:00 2001 From: tee Date: Sat, 25 Jul 2026 11:09:12 -0400 Subject: [PATCH 04/31] refactor(plugins): adopt respond_error in openclaw and juicewrld Replace error/guard ephemeral responds. Success and info responses (status display, history list, cancellation confirm) stay explicit. --- easycord/plugins/juicewrld.py | 25 +++++++++++++------------ easycord/plugins/openclaw.py | 13 +++++++------ 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/easycord/plugins/juicewrld.py b/easycord/plugins/juicewrld.py index 07abafd..eec7248 100644 --- a/easycord/plugins/juicewrld.py +++ b/easycord/plugins/juicewrld.py @@ -102,6 +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 # 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. @@ -432,7 +433,7 @@ def _local_search() -> list: ) except Exception as exc: logger.error("jw_search: %s", exc) - await ctx.respond("Search failed — please try again.", ephemeral=True) + await respond_error(ctx, "Search failed — please try again.") return api_titles = [r.get("title", "") for r in api_results] @@ -440,7 +441,7 @@ def _local_search() -> list: # Simple path — no API configured if not api_results: if not local_results: - await ctx.respond(f"No songs found matching `{query}`.", ephemeral=True) + await respond_error(ctx, f"No songs found matching `{query}`.") return embed = discord.Embed( title=f"Results for `{query}`", @@ -479,7 +480,7 @@ def _local_search() -> list: ] if not local_results and not api_results: - await ctx.respond(f"No songs found matching `{query}` in either source.", ephemeral=True) + await respond_error(ctx, f"No songs found matching `{query}` in either source.") return embed = discord.Embed( @@ -535,7 +536,7 @@ async def jw_song(self, ctx: "Context", song_id: int) -> None: safe_api = self._safe_url(getattr(song, "api_download_url", None)) if song else None except Exception as exc: logger.error("jw_song: %s", exc) - await ctx.respond("Failed to fetch song details.", ephemeral=True) + await respond_error(ctx, "Failed to fetch song details.") return if not song: @@ -563,7 +564,7 @@ async def jw_song(self, ctx: "Context", song_id: int) -> None: user_id=ctx.user.id, ) else: - await ctx.respond(f"No song found with ID `{song_id}`.", ephemeral=True) + await respond_error(ctx, f"No song found with ID `{song_id}`.") return embed = discord.Embed(title=song.title, color=discord.Color.blue()) @@ -618,7 +619,7 @@ def _local_era_query(): ) except Exception as exc: logger.error("jw_era: %s", exc) - await ctx.respond("Era lookup failed.", ephemeral=True) + await respond_error(ctx, "Era lookup failed.") return local_titles = [s.title for s in local_songs] @@ -628,7 +629,7 @@ def _local_era_query(): ] if not era_obj and not api_only: - await ctx.respond(f"Era `{era_name}` not found.", ephemeral=True) + await respond_error(ctx, f"Era `{era_name}` not found.") return if not era_obj: @@ -651,7 +652,7 @@ def _local_era_query(): return if not local_songs and not api_only: - await ctx.respond(f"No songs in era `{era_obj.name}`.", ephemeral=True) + await respond_error(ctx, f"No songs in era `{era_obj.name}`.") return embed = discord.Embed( @@ -689,7 +690,7 @@ async def jw_random(self, ctx: "Context") -> None: resolved = self._resolve_song_url(song, db) if song else None except Exception as exc: logger.error("jw_random: %s", exc) - await ctx.respond("Failed to get a random song.", ephemeral=True) + await respond_error(ctx, "Failed to get a random song.") return if not song: @@ -709,7 +710,7 @@ async def jw_random(self, ctx: "Context") -> None: embed.set_footer(text="Source: Juice WRLD API • Not yet in local catalog") await ctx.respond(embed=embed) else: - await ctx.respond("No songs in the catalog yet.", ephemeral=True) + await respond_error(ctx, "No songs in the catalog yet.") return embed = discord.Embed(title=f"🎲 {song.title}", color=discord.Color.gold()) @@ -756,7 +757,7 @@ async def jw_add_song( ) except Exception as exc: logger.error("jw_add_song: %s", exc) - await ctx.respond("Failed to add song — check logs for details.", ephemeral=True) + await respond_error(ctx, "Failed to add song — check logs for details.") return await ctx.respond( @@ -788,7 +789,7 @@ async def jw_reindex(self, ctx: "Context") -> None: return except Exception as exc: logger.error("jw_reindex: %s", exc) - await ctx.respond("Reindex failed — check logs for details.", ephemeral=True) + await respond_error(ctx, "Reindex failed — check logs for details.") return await ctx.respond( diff --git a/easycord/plugins/openclaw.py b/easycord/plugins/openclaw.py index d781b80..0b0748b 100644 --- a/easycord/plugins/openclaw.py +++ b/easycord/plugins/openclaw.py @@ -12,6 +12,7 @@ from easycord.orchestrator import Orchestrator, RunContext from easycord.plugins._config_manager import PluginConfigManager from easycord.tools import ToolRegistry +from ._shared import respond_error if TYPE_CHECKING: from easycord import Context @@ -115,7 +116,7 @@ async def openclaw_task(self, ctx: Context, task: str) -> None: if self.client is not None else "Provide an EasyCord Orchestrator to OpenClawPlugin." ) - await ctx.respond(f"OpenClaw is not configured. {detail}", ephemeral=True) + await respond_error(ctx, f"OpenClaw is not configured. {detail}") return guild_id = ctx.guild.id @@ -155,7 +156,7 @@ async def openclaw_status(self, ctx: Context) -> None: assert ctx.guild is not None # guaranteed by guild_only=True task = self._active.get(ctx.guild.id) if not task: - await ctx.respond("No OpenClaw task is running for this server.", ephemeral=True) + await respond_error(ctx, "No OpenClaw task is running for this server.") return await ctx.respond(self._truncate(self._format_status(task)), ephemeral=True) @@ -168,7 +169,7 @@ async def openclaw_stop(self, ctx: Context) -> None: guild_id = ctx.guild.id task = self._active.get(guild_id) if not task: - await ctx.respond("No OpenClaw task is running for this server.", ephemeral=True) + await respond_error(ctx, "No OpenClaw task is running for this server.") return task.cancelled = True @@ -191,7 +192,7 @@ async def openclaw_history(self, ctx: Context) -> None: assert ctx.guild is not None # guaranteed by guild_only=True history = await self._load_history(ctx.guild.id) if not history: - await ctx.respond("No OpenClaw task history for this server.", ephemeral=True) + await respond_error(ctx, "No OpenClaw task history for this server.") return lines = ["Recent OpenClaw tasks:"] for item in history[-5:]: @@ -263,7 +264,7 @@ async def _run_task(self, ctx: Context, task: OpenClawTask) -> None: async def _authorize(self, ctx: Context) -> bool: if not ctx.guild: - await ctx.respond("OpenClaw can only run inside a server.", ephemeral=True) + await respond_error(ctx, "OpenClaw can only run inside a server.") return False if self.require_admin: allowed = bool(getattr(ctx, "is_admin", False)) @@ -275,7 +276,7 @@ async def _authorize(self, ctx: Context) -> bool: or getattr(perms, "moderate_members", False) ) if not allowed: - await ctx.respond("OpenClaw requires admin or moderator permission.", ephemeral=True) + await respond_error(ctx, "OpenClaw requires admin or moderator permission.") return False return True From f9b96720e0b96ace0581ba15a82869371e65de17 Mon Sep 17 00:00:00 2001 From: tee Date: Sat, 25 Jul 2026 11:16:28 -0400 Subject: [PATCH 05/31] refactor(tickets): adopt typed config accessors for support_role_id and log_channel_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace bare cfg.get_other("support_role_id"/"log_channel_id") and cfg.set_other(...) calls with get_id/set_id from _shared. Adds str→int coercion and None-removal semantics that raw get_other/set_other lack. --- easycord/plugins/tickets.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/easycord/plugins/tickets.py b/easycord/plugins/tickets.py index 049c0e8..6c081e2 100644 --- a/easycord/plugins/tickets.py +++ b/easycord/plugins/tickets.py @@ -10,7 +10,7 @@ from easycord import Plugin, slash from easycord.server_config import ServerConfigStore -from ._shared import respond_error +from ._shared import get_id, respond_error, set_id if TYPE_CHECKING: from easycord import Context @@ -113,7 +113,7 @@ async def _on_claim(self, interaction: discord.Interaction) -> None: "This ticket is no longer open.", ephemeral=True ) return - support_role_id: int | None = cfg.get_other("support_role_id") + support_role_id: int | None = get_id(cfg, "support_role_id") if not _is_support(member, support_role_id): await interaction.response.send_message( "Only support team members can claim tickets.", ephemeral=True @@ -144,8 +144,8 @@ async def _on_close(self, interaction: discord.Interaction) -> None: "This ticket is already closed.", ephemeral=True ) return - support_role_id: int | None = cfg.get_other("support_role_id") - log_channel_id: int | None = cfg.get_other("log_channel_id") + support_role_id: int | None = get_id(cfg, "support_role_id") + log_channel_id: int | None = get_id(cfg, "log_channel_id") if not _is_support(member, support_role_id): await interaction.response.send_message( "Only support team members can close tickets.", ephemeral=True @@ -299,8 +299,8 @@ async def ticket_setup( guild_id = ctx.guild.id async with self._guild_lock(guild_id): cfg = await self._store.load(guild_id) - cfg.set_other("support_role_id", support_role.id) - cfg.set_other("log_channel_id", log_channel.id) + set_id(cfg, "support_role_id", support_role.id) + set_id(cfg, "log_channel_id", log_channel.id) await self._store.save(cfg) await ctx.respond( @@ -327,7 +327,7 @@ async def ticket_open(self, ctx: Context, topic: str = "") -> None: cfg = await self._store.load(guild_id) counter: int = cfg.get_other("ticket_counter", 0) + 1 cfg.set_other("ticket_counter", counter) - support_role_id: int | None = cfg.get_other("support_role_id") + support_role_id: int | None = get_id(cfg, "support_role_id") await self._store.save(cfg) ticket_number = counter @@ -407,8 +407,8 @@ async def ticket_close(self, ctx: Context, reason: str = "") -> None: if not data or data.get("status") != "open": await respond_error(ctx, "This is not an open ticket.") return - support_role_id: int | None = cfg.get_other("support_role_id") - log_channel_id: int | None = cfg.get_other("log_channel_id") + support_role_id: int | None = get_id(cfg, "support_role_id") + log_channel_id: int | None = get_id(cfg, "log_channel_id") if not _is_support(member, support_role_id): await ctx.respond( "Only support team members can close tickets.", ephemeral=True @@ -450,7 +450,7 @@ async def ticket_claim(self, ctx: Context) -> None: if not data or data.get("status") != "open": await respond_error(ctx, "This is not an open ticket.") return - support_role_id: int | None = cfg.get_other("support_role_id") + support_role_id: int | None = get_id(cfg, "support_role_id") if not _is_support(member, support_role_id): await ctx.respond( "Only support team members can claim tickets.", ephemeral=True @@ -481,7 +481,7 @@ async def ticket_add(self, ctx: Context, user: discord.Member) -> None: guild_id = ctx.guild.id async with self._guild_lock(guild_id): cfg = await self._store.load(guild_id) - support_role_id: int | None = cfg.get_other("support_role_id") + support_role_id: int | None = get_id(cfg, "support_role_id") if not _is_support(member, support_role_id): await ctx.respond( "Only support team members can add users to tickets.", From 003898fcf88808a7feae75e0b113c2d2df9bf1a2 Mon Sep 17 00:00:00 2001 From: tee Date: Sat, 25 Jul 2026 11:18:51 -0400 Subject: [PATCH 06/31] refactor(plugins): adopt respond_error in tags and welcome tags: not-found, unauthorized, and empty-list paths. welcome: five guild-guard paths. Success responses stay explicit. --- easycord/plugins/tags.py | 8 ++++---- easycord/plugins/welcome.py | 11 ++++++----- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/easycord/plugins/tags.py b/easycord/plugins/tags.py index 5e86990..e41a97d 100644 --- a/easycord/plugins/tags.py +++ b/easycord/plugins/tags.py @@ -6,7 +6,7 @@ from easycord.decorators import slash from easycord.plugin import Plugin -from ._shared import read_json_file, write_json_file +from ._shared import read_json_file, respond_error, write_json_file class TagsStore: @@ -76,7 +76,7 @@ def __init__(self, *, data_dir: str = "tags_data") -> None: async def get(self, ctx, name: str) -> None: entry = self._store.get(ctx.guild_id, name) if entry is None: - await ctx.respond(ctx.t("tags.not_found", default="Tag `{name}` not found.", name=name), ephemeral=True) + await respond_error(ctx, ctx.t("tags.not_found", default="Tag `{name}` not found.", name=name)) return await ctx.respond(entry["text"]) @@ -93,7 +93,7 @@ async def delete(self, ctx, name: str) -> None: msg = ctx.t("tags.not_found", default="Tag `{name}` not found.", name=name) else: # unauthorized msg = ctx.t("tags.cannot_delete", default="You can only delete your own tags (or be an admin).") - await ctx.respond(msg, ephemeral=True) + await respond_error(ctx, msg) else: await ctx.respond(ctx.t("tags.deleted", default="Tag `{name}` deleted.", name=name), ephemeral=True) @@ -101,7 +101,7 @@ async def delete(self, ctx, name: str) -> None: async def list(self, ctx) -> None: names = self._store.list_names(ctx.guild_id) if not names: - await ctx.respond(ctx.t("tags.empty", default="No tags yet."), ephemeral=True) + await respond_error(ctx, ctx.t("tags.empty", default="No tags yet.")) return header = ctx.t("tags.header", default="**Tags in this server:**") diff --git a/easycord/plugins/welcome.py b/easycord/plugins/welcome.py index 2b79b0a..129e537 100644 --- a/easycord/plugins/welcome.py +++ b/easycord/plugins/welcome.py @@ -13,6 +13,7 @@ format_template, read_json_file, require_guild, + respond_error, role_reference, write_json_file, ) @@ -142,7 +143,7 @@ async def set_welcome_channel(self, ctx, channel: discord.TextChannel) -> None: async def set_goodbye_channel(self, ctx, channel: discord.TextChannel) -> None: guild = require_guild(ctx) if guild is None: - await ctx.respond("This command only works in a server.", ephemeral=True) + await respond_error(ctx, "This command only works in a server.") return self._update(guild.id, goodbye_channel=channel.id) await ctx.respond(f"Goodbye messages will be posted in {channel.mention}.", ephemeral=True) @@ -151,7 +152,7 @@ async def set_goodbye_channel(self, ctx, channel: discord.TextChannel) -> None: async def set_auto_role(self, ctx, role: discord.Role) -> None: guild = require_guild(ctx) if guild is None: - await ctx.respond("This command only works in a server.", ephemeral=True) + await respond_error(ctx, "This command only works in a server.") return self._update(guild.id, auto_role=role.id) await ctx.respond(f"New members will automatically receive {role.mention}.", ephemeral=True) @@ -160,7 +161,7 @@ async def set_auto_role(self, ctx, role: discord.Role) -> None: async def set_welcome_message(self, ctx, message: str) -> None: guild = require_guild(ctx) if guild is None: - await ctx.respond("This command only works in a server.", ephemeral=True) + await respond_error(ctx, "This command only works in a server.") return try: preview = format_template(message, user=ctx.user.mention, server=guild.name) @@ -177,7 +178,7 @@ async def set_welcome_message(self, ctx, message: str) -> None: async def set_goodbye_message(self, ctx, message: str) -> None: guild = require_guild(ctx) if guild is None: - await ctx.respond("This command only works in a server.", ephemeral=True) + await respond_error(ctx, "This command only works in a server.") return try: preview = format_template(message, user=str(ctx.user), server=guild.name) @@ -194,7 +195,7 @@ async def set_goodbye_message(self, ctx, message: str) -> None: async def welcome_config(self, ctx) -> None: guild = require_guild(ctx) if guild is None: - await ctx.respond("This command only works in a server.", ephemeral=True) + await respond_error(ctx, "This command only works in a server.") return cfg = self._read_config(guild.id) From 87454f442bb2d77c29bfe0a3ea32bfbb8259297f Mon Sep 17 00:00:00 2001 From: tee Date: Sat, 25 Jul 2026 11:33:02 -0400 Subject: [PATCH 07/31] refactor(shared): extract GuildLockManager for per-guild lock deduplication Move the per-guild lock pattern from EconomyPlugin to _shared.py as GuildLockManager. Encapsulates idle-eviction, hard cap, and timestamp refresh. All 1,811 tests passing. Ready for rollout to 13 other plugins. --- easycord/plugins/_shared.py | 72 +++++++++++++++++++++++++++++++++++++ easycord/plugins/economy.py | 72 +++++-------------------------------- tests/test_economy.py | 10 +++--- tests/test_plugin_logic.py | 7 ++-- tests/test_stress.py | 22 ++++++------ 5 files changed, 100 insertions(+), 83 deletions(-) diff --git a/easycord/plugins/_shared.py b/easycord/plugins/_shared.py index 6f53c97..e1afd71 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._locks: 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._locks: + self._locks[guild_id] = asyncio.Lock() + self._cleanup() + self._created[guild_id] = datetime.now(timezone.utc) + return self._locks[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._locks[gid].locked() + ] + for gid in idle_old: + del self._locks[gid] + del self._created[gid] + + # If still over limit, evict oldest 25% of idle locks. + if len(self._locks) > MAX_TRACKED_GUILDS: + candidates = sorted( + ((gid, ts) for gid, ts in self._created.items() if not self._locks[gid].locked()), + key=lambda x: x[1], + ) + remove_count = max(1, len(candidates) // 4) + for gid, _ in candidates[:remove_count]: + del self._locks[gid] + del self._created[gid] 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/tests/test_economy.py b/tests/test_economy.py index 52b6284..cdcfed5 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._locks # --------------------------------------------------------------------------- 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_stress.py b/tests/test_stress.py index f640d27..87396b7 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._locks[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._locks[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._locks.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. From 6544ee8581fc2598219c432d976a4102ddafe56f Mon Sep 17 00:00:00 2001 From: tee Date: Sat, 25 Jul 2026 11:38:33 -0400 Subject: [PATCH 08/31] refactor(auto_role): adopt GuildLockManager for per-guild lock deduplication Replace manual _guild_lock pattern with shared GuildLockManager. Eliminates per-guild Lock dict initialization and cleanup logic. Co-Authored-By: Claude Opus 4.8 --- easycord/plugins/auto_role.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) 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} From c0c1fff45804c4f42295e0151776c1e951e116a7 Mon Sep 17 00:00:00 2001 From: tee Date: Sat, 25 Jul 2026 11:38:39 -0400 Subject: [PATCH 09/31] refactor(birthday): adopt GuildLockManager for per-guild lock deduplication Replace manual _guild_lock pattern with shared GuildLockManager. Update test fixtures to use new API. Co-Authored-By: Claude Opus 4.8 --- easycord/plugins/birthday.py | 21 ++++++++------------- tests/test_birthday.py | 8 ++++---- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/easycord/plugins/birthday.py b/easycord/plugins/birthday.py index fa77b52..d8ea5b4 100644 --- a/easycord/plugins/birthday.py +++ b/easycord/plugins/birthday.py @@ -10,7 +10,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 @@ -116,15 +116,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 +176,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") @@ -295,7 +290,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 +311,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 +337,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 +361,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 +379,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/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} From 3876dce654ff44adf417d48c121a40decd3b3528 Mon Sep 17 00:00:00 2001 From: tee Date: Sat, 25 Jul 2026 11:38:48 -0400 Subject: [PATCH 10/31] refactor(giveaway): adopt GuildLockManager for per-guild lock deduplication Replace manual _guild_lock pattern with shared GuildLockManager. Update test fixtures to use new API. Co-Authored-By: Claude Opus 4.8 --- easycord/plugins/giveaway.py | 19 +++++++------------ tests/test_giveaway_commands.py | 4 ++-- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/easycord/plugins/giveaway.py b/easycord/plugins/giveaway.py index b71a366..a68d74b 100644 --- a/easycord/plugins/giveaway.py +++ b/easycord/plugins/giveaway.py @@ -13,7 +13,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 @@ -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)) @@ -305,7 +300,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 +334,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 +364,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/tests/test_giveaway_commands.py b/tests/test_giveaway_commands.py index d38d51e..f41f196 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) From 55c8b7e785ed8ba90b45f37cb0ebc227829f3227 Mon Sep 17 00:00:00 2001 From: tee Date: Sat, 25 Jul 2026 11:42:20 -0400 Subject: [PATCH 11/31] refactor(juicewrld): adopt GuildLockManager for per-guild lock deduplication Replace manual _guild_lock pattern with shared GuildLockManager. Eliminates per-guild Lock dict initialization and cleanup logic. Co-Authored-By: Claude Opus 4.8 --- easycord/plugins/juicewrld.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/easycord/plugins/juicewrld.py b/easycord/plugins/juicewrld.py index eec7248..6b0e14e 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]: From 2d2c33724e4d7fbcd418787967e6a6bb10d602fc Mon Sep 17 00:00:00 2001 From: tee Date: Sat, 25 Jul 2026 11:42:33 -0400 Subject: [PATCH 12/31] refactor(polls): adopt GuildLockManager for per-guild lock deduplication Replace manual _guild_lock pattern with shared GuildLockManager. Update test fixtures to use new API. Co-Authored-By: Claude Opus 4.8 --- easycord/plugins/polls.py | 15 +++++---------- tests/test_polls.py | 6 +++--- 2 files changed, 8 insertions(+), 13 deletions(-) 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/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 From 2e51f3c0d97305322ca44e61fd7da6116e8ce068 Mon Sep 17 00:00:00 2001 From: tee Date: Sat, 25 Jul 2026 11:42:42 -0400 Subject: [PATCH 13/31] refactor(reminder): adopt GuildLockManager for per-guild lock deduplication Replace manual _guild_lock pattern with shared GuildLockManager. Update test fixtures to use new API. Co-Authored-By: Claude Opus 4.8 --- easycord/plugins/reminder.py | 17 ++++++----------- tests/test_reminder.py | 10 +++++----- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/easycord/plugins/reminder.py b/easycord/plugins/reminder.py index 1520f3d..d07c059 100644 --- a/easycord/plugins/reminder.py +++ b/easycord/plugins/reminder.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 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", []) @@ -217,7 +212,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 +249,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 +290,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/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)] From f0ea0e0ff0783c923034d46e72e985c4f32a8f56 Mon Sep 17 00:00:00 2001 From: tee Date: Sat, 25 Jul 2026 11:50:25 -0400 Subject: [PATCH 14/31] refactor(reputation): adopt GuildLockManager for per-guild lock deduplication Replace manual _guild_lock pattern with shared GuildLockManager. Remove now-unused asyncio import. --- easycord/plugins/reputation.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) 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", {}) From 26436779e4995dc32ee85a47994ef3336641eb1d Mon Sep 17 00:00:00 2001 From: tee Date: Sat, 25 Jul 2026 11:50:33 -0400 Subject: [PATCH 15/31] refactor(scheduled_announcements): adopt GuildLockManager for per-guild lock deduplication Replace manual _guild_lock pattern with shared GuildLockManager. --- easycord/plugins/scheduled_announcements.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/easycord/plugins/scheduled_announcements.py b/easycord/plugins/scheduled_announcements.py index 8d1b9e3..f7b34e8 100644 --- a/easycord/plugins/scheduled_announcements.py +++ b/easycord/plugins/scheduled_announcements.py @@ -11,7 +11,7 @@ from easycord import Plugin, slash 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 +80,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 # # ------------------------------------------------------------------ # @@ -156,7 +151,7 @@ async def _announcement_loop(self, guild_id: int, ann_id: int) -> None: logger.warning("Announcement %d in guild %d failed to send: %s", ann_id, guild_id, e) # 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 +212,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 +292,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", []) From 4302ef916bdf16df17ac35f5f21dcb341432a412 Mon Sep 17 00:00:00 2001 From: tee Date: Sat, 25 Jul 2026 11:50:40 -0400 Subject: [PATCH 16/31] refactor(server_stats): adopt GuildLockManager for per-guild lock deduplication Replace manual _guild_lock pattern with shared GuildLockManager. Update test fixtures to use new API. --- easycord/plugins/server_stats.py | 13 ++++--------- tests/test_server_stats.py | 12 ++++++------ 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/easycord/plugins/server_stats.py b/easycord/plugins/server_stats.py index 72761fa..a51bd89 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 @@ -54,14 +54,9 @@ class ServerStatsPlugin(Plugin): 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) @@ -166,7 +161,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", @@ -226,7 +221,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/tests/test_server_stats.py b/tests/test_server_stats.py index 72de5ab..0f983ef 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) @@ -271,7 +271,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 +305,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", From b5f90eda9cf534faece7836476fea150d5ff3d7a Mon Sep 17 00:00:00 2001 From: tee Date: Sat, 25 Jul 2026 11:50:48 -0400 Subject: [PATCH 17/31] refactor(tickets): adopt GuildLockManager for per-guild lock deduplication Replace manual _guild_lock pattern with shared GuildLockManager in both the plugin and the embedded view class. Update test fixtures to use new API. --- easycord/plugins/tickets.py | 25 ++++++++++--------------- tests/test_tickets_commands.py | 6 +++--- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/easycord/plugins/tickets.py b/easycord/plugins/tickets.py index 6c081e2..c2c5565 100644 --- a/easycord/plugins/tickets.py +++ b/easycord/plugins/tickets.py @@ -10,7 +10,7 @@ from easycord import Plugin, slash 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 @@ -104,7 +104,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 +135,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 +189,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.""" @@ -297,7 +292,7 @@ async def ticket_setup( 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 +318,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) @@ -370,7 +365,7 @@ async def ticket_open(self, ctx: Context, topic: str = "") -> None: panel_msg = await thread.send(embed=_ticket_embed(data), view=view) 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 +395,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 +438,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 +474,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/tests/test_tickets_commands.py b/tests/test_tickets_commands.py index 06b7cf1..50a6ffc 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) From 4bd73772b267af55c3bfac61b9444033c0981ad8 Mon Sep 17 00:00:00 2001 From: tee Date: Sat, 25 Jul 2026 11:50:55 -0400 Subject: [PATCH 18/31] refactor(verification): adopt GuildLockManager for per-guild lock deduplication Replace manual _guild_lock pattern with shared GuildLockManager. Remove now-unused asyncio import. Update test fixtures to use new API. --- easycord/plugins/verification.py | 16 +++++----------- tests/test_verification.py | 16 ++++++++-------- 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/easycord/plugins/verification.py b/easycord/plugins/verification.py index 735389e..5a7d884 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 @@ -10,7 +9,7 @@ from easycord import Plugin, slash 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 @@ -194,12 +193,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.""" @@ -250,7 +244,7 @@ async def verification_setup( 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 @@ -312,7 +306,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 @@ -342,7 +336,7 @@ async def verification_question(self, ctx: Context, text: str) -> None: 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/tests/test_verification.py b/tests/test_verification.py index 118dd46..7b7951e 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) @@ -234,7 +234,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) @@ -262,7 +262,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 +295,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 +322,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) From e9c66209de0bbc07bccbbbe355279064740518bb Mon Sep 17 00:00:00 2001 From: tee Date: Sat, 25 Jul 2026 11:51:01 -0400 Subject: [PATCH 19/31] refactor(word_filter): adopt GuildLockManager for per-guild lock deduplication Replace manual _guild_lock pattern with shared GuildLockManager. Remove now-unused asyncio import. --- easycord/plugins/word_filter.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/easycord/plugins/word_filter.py b/easycord/plugins/word_filter.py index 24d2589..2726f48 100644 --- a/easycord/plugins/word_filter.py +++ b/easycord/plugins/word_filter.py @@ -1,7 +1,6 @@ """Word filter plugin — block messages containing configured words/phrases.""" from __future__ import annotations -import asyncio import logging from typing import TYPE_CHECKING @@ -9,7 +8,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 @@ -55,12 +54,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 ───────────────────────────────────────── @@ -99,7 +93,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 +110,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 +146,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 +159,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} From 650d5c657c82af1bf67fdfcd3167843af93b1bdc Mon Sep 17 00:00:00 2001 From: tee Date: Sat, 25 Jul 2026 11:51:14 -0400 Subject: [PATCH 20/31] refactor(tags): adopt GuildLockManager for per-guild lock deduplication Replace manual _get_lock pattern on TagsStore with shared GuildLockManager. Remove now-unused asyncio import. --- easycord/plugins/tags.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) 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: From 61d86d13100496d9188ee9e7519399f2f42366da Mon Sep 17 00:00:00 2001 From: tee Date: Sat, 25 Jul 2026 12:01:42 -0400 Subject: [PATCH 21/31] refactor(ai_moderator,moderation): adopt send_safe on notify/audit send legs --- easycord/plugins/ai_moderator.py | 4 ++-- easycord/plugins/moderation.py | 7 ++----- 2 files changed, 4 insertions(+), 7 deletions(-) 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/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.""" From cb26537625fb27df341b646b0ce780daaeca0c82 Mon Sep 17 00:00:00 2001 From: tee Date: Sat, 25 Jul 2026 12:05:05 -0400 Subject: [PATCH 22/31] refactor(birthday,invite_tracker,member_logging,scheduled_announcements): adopt send_safe --- easycord/plugins/birthday.py | 6 ++---- easycord/plugins/invite_tracker.py | 9 ++------- easycord/plugins/member_logging.py | 9 ++------- easycord/plugins/scheduled_announcements.py | 6 ++---- 4 files changed, 8 insertions(+), 22 deletions(-) diff --git a/easycord/plugins/birthday.py b/easycord/plugins/birthday.py index d8ea5b4..44b3cbe 100644 --- a/easycord/plugins/birthday.py +++ b/easycord/plugins/birthday.py @@ -9,6 +9,7 @@ import discord from easycord import Plugin, slash +from easycord.helpers.channel import send_safe from easycord.server_config import ServerConfigStore from ._shared import GuildLockManager, respond_error @@ -227,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: 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/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/scheduled_announcements.py b/easycord/plugins/scheduled_announcements.py index f7b34e8..76f0539 100644 --- a/easycord/plugins/scheduled_announcements.py +++ b/easycord/plugins/scheduled_announcements.py @@ -9,6 +9,7 @@ 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 GuildLockManager, respond_error @@ -145,10 +146,7 @@ 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._locks.lock(guild_id): From e4c4d03aa7fe4250dc68a53b609dce8b0ca6ada9 Mon Sep 17 00:00:00 2001 From: tee Date: Sat, 25 Jul 2026 12:08:24 -0400 Subject: [PATCH 23/31] refactor(giveaway,levels,starboard,suggestions): adopt send_safe --- easycord/plugins/giveaway.py | 16 +++++++++---- easycord/plugins/levels.py | 3 ++- easycord/plugins/starboard.py | 26 +++++++++----------- easycord/plugins/suggestions.py | 42 +++++++++++++++++---------------- 4 files changed, 46 insertions(+), 41 deletions(-) diff --git a/easycord/plugins/giveaway.py b/easycord/plugins/giveaway.py index a68d74b..8f8e90e 100644 --- a/easycord/plugins/giveaway.py +++ b/easycord/plugins/giveaway.py @@ -11,7 +11,7 @@ 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 GuildLockManager, respond_error @@ -246,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) 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/starboard.py b/easycord/plugins/starboard.py index 94b41f0..9f14102 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,18 @@ 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 + + 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..9e2bcd1 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,28 @@ 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}") - try: - msg = await channel.send(embed=embed) - await msg.add_reaction(upvote) - await msg.add_reaction(downvote) - - # 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: + 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 + + await msg.add_reaction(upvote) + await msg.add_reaction(downvote) + + # 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: From ac18262727ffadc0fcd82d6aff6cb0aada875fe1 Mon Sep 17 00:00:00 2001 From: tee Date: Sat, 25 Jul 2026 12:10:43 -0400 Subject: [PATCH 24/31] refactor(reminder,word_filter): adopt send_safe --- easycord/plugins/reminder.py | 9 ++------- easycord/plugins/word_filter.py | 13 +++++++------ 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/easycord/plugins/reminder.py b/easycord/plugins/reminder.py index d07c059..a62270f 100644 --- a/easycord/plugins/reminder.py +++ b/easycord/plugins/reminder.py @@ -9,7 +9,7 @@ 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 GuildLockManager, respond_error from easycord.plugins.giveaway import _parse_duration # noqa: F401 — re-exported for tests @@ -181,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 ──────────────────────────────────────── diff --git a/easycord/plugins/word_filter.py b/easycord/plugins/word_filter.py index 2726f48..ea8db37 100644 --- a/easycord/plugins/word_filter.py +++ b/easycord/plugins/word_filter.py @@ -7,6 +7,7 @@ import discord from easycord import Plugin, on, slash +from easycord.helpers.channel import send_safe from easycord.server_config import ServerConfigStore from ._shared import GuildLockManager, respond_error @@ -79,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 ──────────────────────────────────────── From 45c510f52c100dab53fc6d72f6b2b988b3cc257d Mon Sep 17 00:00:00 2001 From: tee Date: Sat, 25 Jul 2026 12:14:21 -0400 Subject: [PATCH 25/31] refactor(tickets): adopt send_safe, require_admin=True, EmbedBuilder --- easycord/plugins/tickets.py | 37 +++++++++++++++---------------------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/easycord/plugins/tickets.py b/easycord/plugins/tickets.py index c2c5565..0ba6b23 100644 --- a/easycord/plugins/tickets.py +++ b/easycord/plugins/tickets.py @@ -9,6 +9,8 @@ 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 GuildLockManager, get_id, respond_error, set_id @@ -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(), ) @@ -248,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, @@ -284,12 +282,6 @@ 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._locks.lock(guild_id): @@ -362,8 +354,9 @@ 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._locks.lock(guild_id): cfg = await self._store.load(guild_id) From 93a797910518de2f5de30ee96cb080d835586464 Mon Sep 17 00:00:00 2001 From: tee Date: Sat, 25 Jul 2026 12:17:14 -0400 Subject: [PATCH 26/31] refactor(server_stats): require_admin=True on stats_setup and stats_teardown --- easycord/plugins/server_stats.py | 14 ++------------ tests/test_server_stats.py | 17 ++++++----------- 2 files changed, 8 insertions(+), 23 deletions(-) diff --git a/easycord/plugins/server_stats.py b/easycord/plugins/server_stats.py index a51bd89..7fe1588 100644 --- a/easycord/plugins/server_stats.py +++ b/easycord/plugins/server_stats.py @@ -124,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 @@ -176,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 diff --git a/tests/test_server_stats.py b/tests/test_server_stats.py index 0f983ef..8881ea0 100644 --- a/tests/test_server_stats.py +++ b/tests/test_server_stats.py @@ -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: From 446e48bec4c6f6ca7cf7bae00d5a552ea4c5b5b2 Mon Sep 17 00:00:00 2001 From: tee Date: Sat, 25 Jul 2026 12:19:58 -0400 Subject: [PATCH 27/31] refactor(verification): require_admin=True on 3 commands, EmbedBuilder for panel embed --- easycord/plugins/verification.py | 26 ++++++++------------------ tests/test_verification.py | 32 ++++++-------------------------- 2 files changed, 14 insertions(+), 44 deletions(-) diff --git a/easycord/plugins/verification.py b/easycord/plugins/verification.py index 5a7d884..2365aa4 100644 --- a/easycord/plugins/verification.py +++ b/easycord/plugins/verification.py @@ -8,6 +8,7 @@ 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 GuildLockManager, respond_error @@ -24,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"): @@ -220,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, @@ -239,9 +238,6 @@ 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._locks.lock(guild_id): @@ -257,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) @@ -317,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. @@ -329,9 +322,6 @@ 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 diff --git a/tests/test_verification.py b/tests/test_verification.py index 7b7951e..5f6619a 100644 --- a/tests/test_verification.py +++ b/tests/test_verification.py @@ -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: @@ -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: From 7af094a5b163776de3624b958f0f9899f99f14a1 Mon Sep 17 00:00:00 2001 From: tee Date: Sat, 25 Jul 2026 12:49:07 -0400 Subject: [PATCH 28/31] chore: bump version to 5.60.0, add CHANGELOG entry for DRY refactor phases 2-4 --- CHANGELOG.md | 14 ++++++++++++++ README.md | 6 +++--- docs/getting-started.md | 2 +- easycord/__init__.py | 2 +- pyproject.toml | 6 +++--- 5 files changed, 22 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b4546f..97e9d4b 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 13 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 12 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/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" From 68a890ae3a21abfec90c3fa0c96025590b59a7ad Mon Sep 17 00:00:00 2001 From: tee Date: Sat, 25 Jul 2026 17:00:16 -0400 Subject: [PATCH 29/31] fix(GuildLockManager): rename internal _locks to _registry to fix Pylance attribute collision --- easycord/plugins/_shared.py | 18 +++++++++--------- tests/test_economy.py | 2 +- tests/test_stress.py | 6 +++--- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/easycord/plugins/_shared.py b/easycord/plugins/_shared.py index e1afd71..24cf802 100644 --- a/easycord/plugins/_shared.py +++ b/easycord/plugins/_shared.py @@ -140,16 +140,16 @@ class GuildLockManager: """ def __init__(self) -> None: - self._locks: dict[int, asyncio.Lock] = {} + 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._locks: - self._locks[guild_id] = asyncio.Lock() + 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._locks[guild_id] + return self._registry[guild_id] def _cleanup(self) -> None: """Evict idle locks to prevent unbounded memory growth. @@ -164,19 +164,19 @@ def _cleanup(self) -> None: idle_old = [ gid for gid, ts in self._created.items() - if now - ts > max_age and not self._locks[gid].locked() + if now - ts > max_age and not self._registry[gid].locked() ] for gid in idle_old: - del self._locks[gid] + del self._registry[gid] del self._created[gid] # If still over limit, evict oldest 25% of idle locks. - if len(self._locks) > MAX_TRACKED_GUILDS: + if len(self._registry) > MAX_TRACKED_GUILDS: candidates = sorted( - ((gid, ts) for gid, ts in self._created.items() if not self._locks[gid].locked()), + ((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._locks[gid] + del self._registry[gid] del self._created[gid] diff --git a/tests/test_economy.py b/tests/test_economy.py index cdcfed5..9f94e70 100644 --- a/tests/test_economy.py +++ b/tests/test_economy.py @@ -146,7 +146,7 @@ async def test_lock_cleanup_does_not_remove_active_locks(self, tmp_path) -> None async with lock: # Cleanup while the lock is acquired should NOT remove it p._locks._cleanup() - assert 100 in p._locks._locks + assert 100 in p._locks._registry # --------------------------------------------------------------------------- diff --git a/tests/test_stress.py b/tests/test_stress.py index 87396b7..522e279 100644 --- a/tests/test_stress.py +++ b/tests/test_stress.py @@ -101,10 +101,10 @@ async def test_economy_lock_cleanup_does_not_bypass_serialization(self, tmp_path # Insert the lock and its timestamp directly into the manager # — bypassing .lock() so the timestamp refresh doesn't undo our backdating. - plugin._locks._locks[guild_id] = asyncio.Lock() + plugin._locks._registry[guild_id] = asyncio.Lock() plugin._locks._created[guild_id] = datetime.now(timezone.utc) - timedelta(days=8) - original_lock = plugin._locks._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._locks._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." ) From cc53555769458792fcd566a1fd8d442e39f25858 Mon Sep 17 00:00:00 2001 From: tee Date: Sat, 25 Jul 2026 17:07:21 -0400 Subject: [PATCH 30/31] fix: address PR review findings (unreachable code, unguarded reactions, stale docstring, unused import) --- CHANGELOG.md | 4 ++-- easycord/plugins/juicewrld.py | 4 ---- easycord/plugins/server_stats.py | 4 ++-- easycord/plugins/starboard.py | 5 ++++- easycord/plugins/suggestions.py | 7 +++++-- tests/test_shared_helpers.py | 2 -- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97e9d4b..4e3a7ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,8 @@ All notable changes to EasyCord are documented here. See [Semantic Versioning](h ### Changed -- **`GuildLockManager`** — extracted the per-guild `asyncio.Lock` dict + eviction logic from 13 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 12 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`. +- **`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`. diff --git a/easycord/plugins/juicewrld.py b/easycord/plugins/juicewrld.py index 6b0e14e..41113a0 100644 --- a/easycord/plugins/juicewrld.py +++ b/easycord/plugins/juicewrld.py @@ -472,10 +472,6 @@ def _local_search() -> list: if not any(self._titles_match(r.get("title", ""), lt) for lt in local_titles) ] - if not local_results and not api_results: - await respond_error(ctx, f"No songs found matching `{query}` in either source.") - return - embed = discord.Embed( title=f"Results for `{query}`", color=discord.Color.green(), diff --git a/easycord/plugins/server_stats.py b/easycord/plugins/server_stats.py index 7fe1588..45735f6 100644 --- a/easycord/plugins/server_stats.py +++ b/easycord/plugins/server_stats.py @@ -47,8 +47,8 @@ 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: diff --git a/easycord/plugins/starboard.py b/easycord/plugins/starboard.py index 9f14102..6ec295a 100644 --- a/easycord/plugins/starboard.py +++ b/easycord/plugins/starboard.py @@ -131,7 +131,10 @@ async def _archive_message(self, message: discord.Message, reaction_count: int) await post.edit(embed=embed) return except discord.NotFound: - pass # Post was deleted, send a new one + 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: diff --git a/easycord/plugins/suggestions.py b/easycord/plugins/suggestions.py index 9e2bcd1..5067964 100644 --- a/easycord/plugins/suggestions.py +++ b/easycord/plugins/suggestions.py @@ -105,8 +105,11 @@ async def suggest(self, ctx: Context, idea: str) -> None: await respond_error(ctx, "❌ Cannot post to suggestions channel") return - await msg.add_reaction(upvote) - await msg.add_reaction(downvote) + try: + 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: diff --git a/tests/test_shared_helpers.py b/tests/test_shared_helpers.py index 59c458e..a6e47d3 100644 --- a/tests/test_shared_helpers.py +++ b/tests/test_shared_helpers.py @@ -3,8 +3,6 @@ from unittest.mock import AsyncMock, MagicMock -import pytest - from easycord.plugins._shared import get_id, get_ids, respond_error, set_id, set_ids from easycord.server_config import ServerConfig From fd1cf29dc2fd2575db1bb57f17c6ef7f9b0da6be Mon Sep 17 00:00:00 2001 From: tee Date: Sat, 25 Jul 2026 17:19:53 -0400 Subject: [PATCH 31/31] test: cover GuildLockManager eviction, send_safe error paths, ticket panel and log sends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_shared_helpers.py: 5 new tests for GuildLockManager._cleanup() — idle >7 day eviction (lines 170-171), held-lock immunity, recent-lock preservation, and the MAX_TRACKED_GUILDS cap eviction (lines 175-182) - test_suggestions.py: 2 new tests for add_reaction Forbidden/HTTPException handler (lines 111-112) — confirms command completes and suggestion is stored despite failure - test_giveaway_commands.py: 2 new tests for _end_giveaway send_safe winner announcement (lines 249-262) — winners path and no-entries path - test_tickets_commands.py: 4 new tests — panel_message_id guard from send_safe (line 357), log channel send in _finish_close (lines 245-268), no-send when log_channel_id is None, no-send when channel is not a TextChannel --- tests/test_giveaway_commands.py | 88 +++++++++++++++++ tests/test_shared_helpers.py | 84 +++++++++++++++- tests/test_suggestions.py | 60 ++++++++++++ tests/test_tickets_commands.py | 165 ++++++++++++++++++++++++++++++++ 4 files changed, 396 insertions(+), 1 deletion(-) diff --git a/tests/test_giveaway_commands.py b/tests/test_giveaway_commands.py index f41f196..2a3334d 100644 --- a/tests/test_giveaway_commands.py +++ b/tests/test_giveaway_commands.py @@ -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_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_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 50a6ffc..b19c7e7 100644 --- a/tests/test_tickets_commands.py +++ b/tests/test_tickets_commands.py @@ -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)