From 619ff92465c1e6873030d6176ce731e3526b422f Mon Sep 17 00:00:00 2001 From: tee Date: Sat, 25 Jul 2026 10:52:08 -0400 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20add=20Phase=201=20shared=20helpers?= =?UTF-8?q?=20=E2=80=94=20respond=5Ferror=20and=20typed=20config=20accesso?= =?UTF-8?q?rs?= 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 447b1b0ae7973397a76df282a9a9b6d6a31d1a74 Mon Sep 17 00:00:00 2001 From: rolling-codes <135290897+rolling-codes@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:22:46 -0400 Subject: [PATCH 7/8] Potential fix for pull request finding 'CodeQL / Unreachable code' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- easycord/plugins/juicewrld.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/easycord/plugins/juicewrld.py b/easycord/plugins/juicewrld.py index eec7248..2c992d2 100644 --- a/easycord/plugins/juicewrld.py +++ b/easycord/plugins/juicewrld.py @@ -479,10 +479,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(), From 3e23343970b4cc7a84df9bf5d7454b7c00b89257 Mon Sep 17 00:00:00 2001 From: rolling-codes <135290897+rolling-codes@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:22:56 -0400 Subject: [PATCH 8/8] Potential fix for pull request finding 'CodeQL / Unused import' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- tests/test_shared_helpers.py | 2 -- 1 file changed, 2 deletions(-) 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