Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
619ff92
feat: add Phase 1 shared helpers — respond_error and typed config acc…
tee1339 Jul 25, 2026
2eab52a
refactor(plugins): adopt respond_error in auto_role, reminder, reputa…
tee1339 Jul 25, 2026
bca4124
refactor(plugins): adopt respond_error in 12 more plugins
tee1339 Jul 25, 2026
8635da0
refactor(plugins): adopt respond_error in openclaw and juicewrld
tee1339 Jul 25, 2026
f9b9672
refactor(tickets): adopt typed config accessors for support_role_id a…
tee1339 Jul 25, 2026
003898f
refactor(plugins): adopt respond_error in tags and welcome
tee1339 Jul 25, 2026
87454f4
refactor(shared): extract GuildLockManager for per-guild lock dedupli…
tee1339 Jul 25, 2026
6544ee8
refactor(auto_role): adopt GuildLockManager for per-guild lock dedupl…
tee1339 Jul 25, 2026
c0c1fff
refactor(birthday): adopt GuildLockManager for per-guild lock dedupli…
tee1339 Jul 25, 2026
3876dce
refactor(giveaway): adopt GuildLockManager for per-guild lock dedupli…
tee1339 Jul 25, 2026
55c8b7e
refactor(juicewrld): adopt GuildLockManager for per-guild lock dedupl…
tee1339 Jul 25, 2026
2d2c337
refactor(polls): adopt GuildLockManager for per-guild lock deduplication
tee1339 Jul 25, 2026
2e51f3c
refactor(reminder): adopt GuildLockManager for per-guild lock dedupli…
tee1339 Jul 25, 2026
f0ea0e0
refactor(reputation): adopt GuildLockManager for per-guild lock dedup…
tee1339 Jul 25, 2026
2643677
refactor(scheduled_announcements): adopt GuildLockManager for per-gui…
tee1339 Jul 25, 2026
4302ef9
refactor(server_stats): adopt GuildLockManager for per-guild lock ded…
tee1339 Jul 25, 2026
b5f90ed
refactor(tickets): adopt GuildLockManager for per-guild lock deduplic…
tee1339 Jul 25, 2026
4bd7377
refactor(verification): adopt GuildLockManager for per-guild lock ded…
tee1339 Jul 25, 2026
e9c6620
refactor(word_filter): adopt GuildLockManager for per-guild lock dedu…
tee1339 Jul 25, 2026
650d5c6
refactor(tags): adopt GuildLockManager for per-guild lock deduplication
tee1339 Jul 25, 2026
61d86d1
refactor(ai_moderator,moderation): adopt send_safe on notify/audit se…
tee1339 Jul 25, 2026
cb26537
refactor(birthday,invite_tracker,member_logging,scheduled_announcemen…
tee1339 Jul 25, 2026
e4c4d03
refactor(giveaway,levels,starboard,suggestions): adopt send_safe
tee1339 Jul 25, 2026
ac18262
refactor(reminder,word_filter): adopt send_safe
tee1339 Jul 25, 2026
45c510f
refactor(tickets): adopt send_safe, require_admin=True, EmbedBuilder
tee1339 Jul 25, 2026
93a7979
refactor(server_stats): require_admin=True on stats_setup and stats_t…
tee1339 Jul 25, 2026
446e48b
refactor(verification): require_admin=True on 3 commands, EmbedBuilde…
tee1339 Jul 25, 2026
7af094a
chore: bump version to 5.60.0, add CHANGELOG entry for DRY refactor p…
tee1339 Jul 25, 2026
6fb99cc
chore: resolve merge conflicts with main (Phase 1 already on branch)
tee1339 Jul 25, 2026
68a890a
fix(GuildLockManager): rename internal _locks to _registry to fix Pyl…
tee1339 Jul 25, 2026
cc53555
fix: address PR review findings (unreachable code, unguarded reaction…
tee1339 Jul 25, 2026
fd1cf29
test: cover GuildLockManager eviction, send_safe error paths, ticket …
tee1339 Jul 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

All notable changes to EasyCord are documented here. See [Semantic Versioning](https://semver.org) for version numbering.

## EasyCord v5.60.0 — 2026-07-25

### Changed

- **`GuildLockManager`** — extracted the per-guild `asyncio.Lock` dict + eviction logic from 14 plugins into a single shared class in `easycord/plugins/_shared.py`. All plugins (`auto_role`, `birthday`, `economy`, `giveaway`, `juicewrld`, `polls`, `reminder`, `reputation`, `scheduled_announcements`, `server_stats`, `tags`, `tickets`, `verification`, `word_filter`) now share one implementation with idle-eviction after 7 days and a MAX_TRACKED_GUILDS=5000 hard cap. Removes ~250 lines of copy-pasted boilerplate.
- **`send_safe` adoption** — replaced ~20 bare `await channel.send(...)` calls and inline `try/except discord.Forbidden/HTTPException` blocks across 14 plugins with the existing `send_safe` helper from `easycord/helpers/channel.py`. Affected: `ai_moderator`, `birthday`, `giveaway`, `invite_tracker`, `levels`, `member_logging`, `moderation`, `reminder`, `scheduled_announcements`, `starboard`, `suggestions`, `tickets`, `verification`, `word_filter`.
- **`@slash(require_admin=True)`** — replaced 6 inline `if not ctx.is_admin: respond_error; return` blocks with the `require_admin=True` decorator parameter on `ticket_setup`, `stats_setup`, `stats_teardown`, `verification_setup`, `verification_panel`, `verification_question`. The decorator gate now rejects non-admins before the command body runs.
- **`EmbedBuilder` adoption** — migrated `discord.Embed(...)` construction to `EmbedBuilder` at opportunistic sites: `_ticket_embed()` and the close log embed in `tickets.py`; `_build_panel_embed()` in `verification.py`.

### Release metadata

- Wheel: `easycord-5.60.0-py3-none-any.whl`
- Source: `easycord-5.60.0.tar.gz` — `releases/download/v5.60.0/easycord-5.60.0.tar.gz`

## EasyCord v5.58.0 — 2026-07-22

### Changed
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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)
2 changes: 1 addition & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion easycord/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions easycord/plugins/_shared.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -108,3 +110,73 @@ def role_reference(guild: discord.Guild, role_id: int) -> str:
"""Return a user-facing role mention with a deleted-role fallback."""
role = guild.get_role(role_id)
return role.mention if role else f"<@&{role_id}> *(deleted?)*"


# ---------------------------------------------------------------------------
# Guild lock manager for concurrent per-guild mutations
# ---------------------------------------------------------------------------

MAX_TRACKED_GUILDS = 5000


class GuildLockManager:
"""Per-guild lock manager with idle-eviction to prevent unbounded memory growth.

Each guild gets one lock that serializes all read-modify-write operations.
The lock is created on first access, refreshed on every access (updating
last-used timestamp), and automatically evicted after 7 days of idleness
or when the total count exceeds MAX_TRACKED_GUILDS.

Safe under concurrent access: never evicts a currently-held lock, preventing
the race where a new caller receives a fresh lock while an existing holder
still considers itself the sole writer.

Usage::

mgr = GuildLockManager()
async with mgr.lock(guild_id):
# perform read-modify-write atomically
pass
"""

def __init__(self) -> None:
self._registry: dict[int, asyncio.Lock] = {}
self._created: dict[int, datetime] = {}

def lock(self, guild_id: int) -> asyncio.Lock:
"""Get or create the lock for a guild, refresh its timestamp, return it."""
if guild_id not in self._registry:
self._registry[guild_id] = asyncio.Lock()
self._cleanup()
self._created[guild_id] = datetime.now(timezone.utc)
return self._registry[guild_id]

def _cleanup(self) -> None:
"""Evict idle locks to prevent unbounded memory growth.

A lock is removed only if it has been idle >7 days AND is not held.
If still over MAX_TRACKED_GUILDS, evict oldest 25% of idle locks.
"""
now = datetime.now(timezone.utc)
max_age = timedelta(days=7)

# Remove locks idle >7 days, but never remove an acquired lock.
idle_old = [
gid
for gid, ts in self._created.items()
if now - ts > max_age and not self._registry[gid].locked()
]
for gid in idle_old:
del self._registry[gid]
del self._created[gid]

# If still over limit, evict oldest 25% of idle locks.
if len(self._registry) > MAX_TRACKED_GUILDS:
candidates = sorted(
((gid, ts) for gid, ts in self._created.items() if not self._registry[gid].locked()),
key=lambda x: x[1],
)
remove_count = max(1, len(candidates) // 4)
for gid, _ in candidates[:remove_count]:
del self._registry[gid]
del self._created[gid]
4 changes: 2 additions & 2 deletions easycord/plugins/ai_moderator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
15 changes: 5 additions & 10 deletions easycord/plugins/auto_role.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ─────────────────────────────────────────

Expand Down Expand Up @@ -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", [])
Expand All @@ -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", [])
Expand Down Expand Up @@ -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}
Expand Down
27 changes: 10 additions & 17 deletions easycord/plugins/birthday.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
import discord

from easycord import Plugin, slash
from easycord.helpers.channel import send_safe
from easycord.server_config import ServerConfigStore
from ._shared import respond_error
from ._shared import GuildLockManager, respond_error

if TYPE_CHECKING:
from easycord import Context
Expand Down Expand Up @@ -116,15 +117,10 @@ class BirthdayPlugin(Plugin):
def __init__(self, *, store_path: str = ".easycord/birthday") -> None:
super().__init__()
self._store = ServerConfigStore(store_path)
self._locks: dict[int, asyncio.Lock] = {}
self._locks = GuildLockManager()
self._loop_task: asyncio.Task | None = None
self._role_tasks: set[asyncio.Task[None]] = set()

def _guild_lock(self, guild_id: int) -> asyncio.Lock:
if guild_id not in self._locks:
self._locks[guild_id] = asyncio.Lock()
return self._locks[guild_id]

# ── Lifecycle ─────────────────────────────────────────────

async def on_ready(self) -> None:
Expand Down Expand Up @@ -181,7 +177,7 @@ async def _run_birthday_checks(self) -> None:
async def _check_guild_birthdays(
self, guild_id: int, today: datetime.date
) -> None:
async with self._guild_lock(guild_id):
async with self._locks.lock(guild_id):
cfg = await self._store.load(guild_id)
data: dict = cfg.get_other("birthday", {})
channel_id: int | None = data.get("channel_id")
Expand Down Expand Up @@ -232,10 +228,7 @@ async def _check_guild_birthdays(
color=discord.Color.gold(),
)
embed.set_footer(text=f"🎉 {today.strftime('%B %d')}")
try:
await channel.send(embed=embed)
except discord.HTTPException:
logger.exception("Failed to send birthday message for user %d", uid)
await send_safe(channel, log=logger, what="birthday announcement", embed=embed)

if role and member:
try:
Expand Down Expand Up @@ -295,7 +288,7 @@ async def birthday_set(
guild_id = ctx.guild.id
user_id = ctx.user.id

async with self._guild_lock(guild_id):
async with self._locks.lock(guild_id):
cfg = await self._store.load(guild_id)
data: dict = cfg.get_other("birthday", {})
birthdays: dict = data.get("birthdays", {})
Expand All @@ -316,7 +309,7 @@ async def birthday_unset(self, ctx: Context) -> None:
guild_id = ctx.guild.id
user_id = ctx.user.id

async with self._guild_lock(guild_id):
async with self._locks.lock(guild_id):
cfg = await self._store.load(guild_id)
data: dict = cfg.get_other("birthday", {})
birthdays: dict = data.get("birthdays", {})
Expand All @@ -342,7 +335,7 @@ async def birthday_channel(
return
guild_id = ctx.guild.id

async with self._guild_lock(guild_id):
async with self._locks.lock(guild_id):
cfg = await self._store.load(guild_id)
data: dict = cfg.get_other("birthday", {})
data["channel_id"] = channel.id
Expand All @@ -366,7 +359,7 @@ async def birthday_role(
return
guild_id = ctx.guild.id

async with self._guild_lock(guild_id):
async with self._locks.lock(guild_id):
cfg = await self._store.load(guild_id)
data: dict = cfg.get_other("birthday", {})
data["role_id"] = role.id
Expand All @@ -384,7 +377,7 @@ async def birthday_list(self, ctx: Context) -> None:
return
guild_id = ctx.guild.id

async with self._guild_lock(guild_id):
async with self._locks.lock(guild_id):
cfg = await self._store.load(guild_id)
data: dict = cfg.get_other("birthday", {})
birthdays: dict = data.get("birthdays", {})
Expand Down
Loading
Loading