Skip to content
Merged
60 changes: 60 additions & 0 deletions easycord/plugins/_shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
rolling-codes marked this conversation as resolved.


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)
Expand Down
13 changes: 7 additions & 6 deletions easycord/plugins/auto_role.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from easycord import Plugin, on, slash
from easycord.server_config import ServerConfigStore
from ._shared import respond_error
Comment thread
rolling-codes marked this conversation as resolved.

if TYPE_CHECKING:
from easycord import Context
Expand Down Expand Up @@ -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.")
Comment thread
rolling-codes marked this conversation as resolved.
return
async with self._guild_lock(ctx.guild.id):
cfg = await self._store.load(ctx.guild.id)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down
5 changes: 3 additions & 2 deletions easycord/plugins/birthday.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from easycord import Plugin, slash
from easycord.server_config import ServerConfigStore
from ._shared import respond_error
Comment thread
rolling-codes marked this conversation as resolved.

if TYPE_CHECKING:
from easycord import Context
Expand Down Expand Up @@ -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.")
Comment thread
rolling-codes marked this conversation as resolved.
return
Comment on lines +324 to 325

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "== graph availability =="
if [ -f graphify-out/graph.json ]; then
  wc -l graphify-out/graph.json
  python3 - <<'PY'
import json
from pathlib import Path
p=Path("graphify-out/graph.json")
data=json.loads(p.read_text())
for k in ("nodes","edges"):
    print(k, len(data.get(k, [])))
PY
else
  echo "missing graphify-out/graph.json"
fi

echo
echo "== locate birthday.py/reminder.py/reputation.py =="
fd -a '^(birthday|reminder|reputation)\.py$' . | sed 's#^\./##'

echo
echo "== inspect birthday.py context/lock/respond_error =="
if [ -f easycord/plugins/birthday.py ]; then
  wc -l easycord/plugins/birthday.py
  rg -n "(_guild_lock|respond_error|mutate|discord|ctx\.*send|get_member|Interaction|on/)?" easycord/plugins/birthday.py || true
  sed -n '280,345p' easycord/plugins/birthday.py || true
fi

echo
echo "== inspect reminder.py context/lock/respond_error =="
if [ -f easycord/plugins/reminder.py ]; then
  wc -l easycord/plugins/reminder.py
  rg -n "(_guild_lock|respond_error|mutate|discord|ctx\.*send|get_member|Interaction|on/)" easycord/plugins/reminder.py || true
  sed -n '280,325p' easycord/plugins/reminder.py || true
fi

echo
echo "== inspect reputation.py context/lock/respond_error =="
if [ -f easycord/plugins/reputation.py ]; with
  wc -l easycord/plugins/reputation.py
  rg -n "(_guild_lock|respond_error|mutate|discord|ctx\.*send|get_member|Interaction|on/)" easycord/plugins/reputation.py || true
  sed -n '90,135p' easycord/plugins/reputation.py || true
fi

echo
echo "== definitions of respond_error and guild lock helpers =="
rg -n "def respond_error|class .*Guild|_guild_lock|ServerConfigStore\.mutate|per-guild|guild_lock|async with|with .*lock|lock_context" easycord -S || true

Repository: rolling-codes/EasyCord

Length of output: 22232


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "== graph availability =="
if [ -f graphify-out/graph.json ]; then
  wc -l graphify-out/graph.json
  python3 - <<'PY'
import json
from pathlib import Path
p=Path("graphify-out/graph.json")
data=json.loads(p.read_text())
for k in ("nodes","edges"):
    print(k, len(data.get(k, [])))
PY
else
  echo "missing graphify-out/graph.json"
fi

echo
echo "== locate birthday.py/reminder.py/reputation.py =="
fd -a '^(birthday|reminder|reputation)\.py$' . | sed 's#^\./##'

echo
echo "== inspect birthday.py context/lock/respond_error =="
if [ -f easycord/plugins/birthday.py ]; then
  wc -l easycord/plugins/birthday.py
  rg -n "(_guild_lock|respond_error|mutate|discord|ctx\.*send|get_member|Interaction|on/?) " easycord/plugins/birthday.py || true
  sed -n '280,345p' easycord/plugins/birthday.py || true
fi

echo
echo "== inspect reminder.py context/lock/respond_error =="
if [ -f easycord/plugins/reminder.py ]; then
  wc -l easycord/plugins/reminder.py
  rg -n "(_guild_lock|respond_error|mutate|discord|ctx\.*send|get_member|Interaction|on/?)" easycord/plugins/reminder.py || true
  sed -n '280,330p' easycord/plugins/reminder.py || true
fi

echo
echo "== inspect reputation.py context/lock/respond_error =="
if [ -f easycord/plugins/reputation.py ]; then
  wc -l easycord/plugins/reputation.py
  rg -n "(_guild_lock|respond_error|mutate|discord|ctx\.*send|get_member|Interaction|on/?)" easycord/plugins/reputation.py || true
  sed -n '90,145p' easycord/plugins/reputation.py || true
fi

echo
echo "== definitions of respond_error and guild lock helpers =="
rg -n "def respond_error|class .*Guild|_guild_lock|ServerConfigStore\.mutate|per-guild|guild_lock|async with|with .*lock|lock_context" easycord -S || true

Repository: rolling-codes/EasyCord

Length of output: 25093


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "== inspect easycord/_shared.py respond_error =="
wc -l easycord/_shared.py
sed -n '1,80p' easycord/_shared.py

echo
echo "== inspect easycord/interaction helpers that define respond_error =="
rg -n "def respond_error|async def respond_error|ctx\.respond" easycord -g '*.py' | head -80

Repository: rolling-codes/EasyCord

Length of output: 259


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "== shared module candidates =="
fd -a 'shared\.py$|_shared\.py$' easycord plugins . 2>/dev/null | sed 's#^\./##' | sort | head -80

echo
echo "== inspect respondent_error definitions =="
rg -n "async def respond_error|def respond_error|respond_error =" easycord -g '*.py' -S

echo
echo "== inspect birthday/reminders/reputation relevant slices =="
sed -n '290,330p' easycord/plugins/birthday.py
sed -n '296,316p' easycord/plugins/reminder.py
sed -n '112,126p' easycord/plugins/reputation.py

Repository: rolling-codes/EasyCord

Length of output: 3510


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "== inspect easycord/plugins/_shared.py respond_error =="
sed -n '1,70p' easycord/plugins/_shared.py

echo
echo "== inspect interaction context respond_error wrapper =="
sed -n '1,80p' easycord/helpers/context.py

Repository: rolling-codes/EasyCord

Length of output: 5431


Move respond_error outside the per-guild configuration locks.

  • easycord/plugins/birthday.py#L324-L325: respond_error(ctx, ...) calls ctx.respond(...), so defer the missing-birthday response until after the load-check branch of _guild_lock exits.
  • easycord/plugins/reminder.py#L310-L311: defer the missing-reminder response until after config load/check before saving.
  • easycord/plugins/reputation.py#L120-L121: defer the cooldown response until after config load/check before saving.

As per the per-guild config guideline, Discord/network I/O must not occur while the per-guild configuration lock is held.

📍 Affects 3 files
  • easycord/plugins/birthday.py#L324-L325 (this comment)
  • easycord/plugins/reminder.py#L310-L311
  • easycord/plugins/reputation.py#L120-L121
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@easycord/plugins/birthday.py` around lines 324 - 325, Move the respond_error
calls outside the per-guild configuration lock: in easycord/plugins/birthday.py
lines 324-325, easycord/plugins/reminder.py lines 310-311, and
easycord/plugins/reputation.py lines 120-121, record the missing-birthday,
missing-reminder, or cooldown result during the _guild_lock load/check flow,
then perform the response after the lock exits and before continuing or
returning.

Source: Coding guidelines

birthdays.pop(str(user_id), None)
data["birthdays"] = birthdays
Expand Down Expand Up @@ -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] = []
Expand Down
5 changes: 3 additions & 2 deletions easycord/plugins/economy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
9 changes: 5 additions & 4 deletions easycord/plugins/giveaway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Comment thread
rolling-codes marked this conversation as resolved.
channel_id: int = channel.id

Expand Down Expand Up @@ -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.")
27 changes: 12 additions & 15 deletions easycord/plugins/juicewrld.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -432,15 +433,15 @@ 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]

# 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}`",
Expand Down Expand Up @@ -478,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 ctx.respond(f"No songs found matching `{query}` in either source.", ephemeral=True)
return

embed = discord.Embed(
title=f"Results for `{query}`",
color=discord.Color.green(),
Expand Down Expand Up @@ -535,7 +532,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:
Expand Down Expand Up @@ -563,7 +560,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())
Expand Down Expand Up @@ -618,7 +615,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]
Expand All @@ -628,7 +625,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:
Expand All @@ -651,7 +648,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(
Expand Down Expand Up @@ -689,7 +686,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:
Expand All @@ -709,7 +706,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())
Expand Down Expand Up @@ -756,7 +753,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(
Expand Down Expand Up @@ -788,7 +785,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(
Expand Down
Loading
Loading