{"id":48646026,"url":"https://github.com/rolling-codes/easycord","last_synced_at":"2026-06-28T02:00:59.945Z","repository":{"id":350311501,"uuid":"1206281036","full_name":"rolling-codes/EasyCord","owner":"rolling-codes","description":"Python Discord bot framework — slash commands, plugins, middleware, localization, AI orchestration, and 20+ guides. Supports Claude, OpenAI, Gemini, Groq, and more. Production-ready.","archived":false,"fork":false,"pushed_at":"2026-06-27T19:16:15.000Z","size":1001,"stargazers_count":3,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-27T21:06:44.451Z","etag":null,"topics":["ai","anthropic","chatbot","components","discord","discord-bot","discord-framework","discord-py","framework","llm","modals","openai","plugins","python","slash-commands"],"latest_commit_sha":null,"homepage":"","language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/rolling-codes.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":".github/CODEOWNERS","security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null}},"created_at":"2026-04-09T18:57:44.000Z","updated_at":"2026-06-27T19:16:18.000Z","dependencies_parsed_at":"2026-04-23T01:00:29.094Z","dependency_job_id":"ff61e1a2-8034-4665-b672-7d2fc1813e68","html_url":"https://github.com/rolling-codes/EasyCord","commit_stats":null,"previous_names":["thomas999-hhd/easycord"],"tags_count":74,"template":false,"template_full_name":null,"purl":"pkg:github/rolling-codes/EasyCord","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rolling-codes%2FEasyCord","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rolling-codes%2FEasyCord/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rolling-codes%2FEasyCord/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rolling-codes%2FEasyCord/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/rolling-codes","download_url":"https://codeload.github.com/rolling-codes/EasyCord/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rolling-codes%2FEasyCord/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34874557,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-28T02:00:05.809Z","response_time":54,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["ai","anthropic","chatbot","components","discord","discord-bot","discord-framework","discord-py","framework","llm","modals","openai","plugins","python","slash-commands"],"created_at":"2026-04-10T04:00:43.187Z","updated_at":"2026-06-28T02:00:59.931Z","avatar_url":"https://github.com/rolling-codes.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"# EasyCord\n![Version](https://img.shields.io/badge/v-5.50.2-blue)\n![Python](https://img.shields.io/badge/python-3.10%2B-blue)\n![License](https://img.shields.io/badge/license-MIT-green)\n![Tests](https://img.shields.io/badge/tests-passing-brightgreen)\n\n\u003e A production-grade Discord bot framework built on discord.py. Slash commands, events, components, modals, plugins, middleware, per-guild storage, localization, and optional AI orchestration — all with decorator-based APIs and no boilerplate. **AI is optional.** A fully-featured bot needs zero AI dependencies.\n\n## Documentation\n\n**[Browse all guides →](docs/README.md)** — organized by what you want to do, with a complete index.\n\n| | |\n|---|---|\n| [Getting Started](docs/getting-started.md) | Install, write your first command, add plugins, configure storage |\n| [Interactions](docs/interactions.md) | Slash commands, context menus, components, modals, and autocomplete |\n| [Command Sync](docs/command-sync.md) | Preview, diff, and apply Discord command registration |\n| [Dynamic Component Routing](docs/components-dynamic-routing.md) | Typed URL-style routes for buttons and select menus with TTL support |\n| [Middleware Patterns](docs/middleware-patterns.md) | Built-in guards, rate limiting, logging, and custom middleware |\n| [Error Handling](docs/error-handling.md) | Per-command, plugin-scoped, and global error handler waterfall |\n| [Event Bus](docs/event-bus.md) | Async pub/sub between plugins — decouple cross-plugin communication |\n| [Lifecycle Hooks](docs/hooks.md) | `before_command`, `after_command`, `on_plugin_load`, `on_plugin_unload` |\n| [Deprecation Helpers](docs/deprecation.md) | `@deprecated` and `@version_introduced` for API lifecycle management |\n| [Testing Commands](docs/testing.md) | `PluginTestSuite`, `FakeContextBuilder`, and offline `invoke_*` helpers |\n| [Plugin Authoring](docs/plugin-authoring.md) | Build, validate, and distribute reusable plugin packages |\n| [Developer Toolkit](docs/developer-toolkit.md) | CLI scaffolding, offline testing, interaction inspection, and diagnostics |\n| [Hot-Reload Development](docs/hot-reload-development.md) | Watch plugin files and reload code without restarting the bot |\n| [Type Checking](docs/type-checking.md) | Pyright configuration and common plugin type patterns |\n| [Task Scheduling](docs/task-scheduling.md) | `@task` decorator — background tasks, intervals, error restart |\n| [Subcommand Groups](docs/subcommand-groups.md) | `SlashGroup` — subcommand namespaces and permission gates |\n| [Interactive UI](docs/context-interactive-ui.md) | `ctx.confirm()`, `ctx.paginate()`, `ctx.ask_form()`, `ctx.choose()`, `ctx.prompt()` |\n| [Conversation Memory](docs/conversation-memory.md) | Multi-turn AI context, eviction, `ctx.ai()` vs `Orchestrator` |\n| [Built-in Plugins](docs/builtin-plugins.md) | All 28 bundled plugins — commands, setup, and storage requirements |\n| [Context Reference](docs/context-reference.md) | Full `Context` API — responses, DMs, moderation, channels, members |\n| [Examples](examples/) | Working bot code |\n\n---\n\n## Installation\n\n```bash\npip install \"https://github.com/rolling-codes/EasyCord/releases/download/v5.50.2/easycord-5.50.2-py3-none-any.whl\"\n```\n\nOr clone and install locally:\n\n```bash\ngit clone https://github.com/rolling-codes/EasyCord.git\ncd EasyCord\npip install -e \".[dev]\"\n```\n\n**Requirements:** Python 3.10+. The only runtime dependency is `discord.py\u003e=2.7.1,\u003c3`.\n\n---\n\n## Quickstart\n\n```python\nfrom easycord import Bot\n\nbot = Bot()\n\n@bot.slash(description=\"Ping the bot\")\nasync def ping(ctx):\n    await ctx.respond(\"Pong!\")\n\nbot.run(\"YOUR_TOKEN\")\n```\n\nSave as `bot.py` and run it. `/ping` appears in Discord automatically.\n\n---\n\n## Start a project with the CLI\n\n```bash\neasycord new my-bot --template plugin\ncd my-bot\npip install -e \".[dev]\"\npytest\neasycord doctor bot:bot\n```\n\nThe generated project includes `bot.py`, one example plugin, `.env.example`, `pyproject.toml`, and a starter pytest file. Four templates are available:\n\n| Template | What it generates |\n|---|---|\n| `minimal` | Single `bot.py` with one slash command and one command test |\n| `plugin` | Plugin-oriented project with a memory database — **the default** |\n| `ai` | Plugin scaffold with an AI-provider placeholder command |\n| `database` | Plugin scaffold with SQLite app setup and in-memory tests |\n\nRun `easycord new --list-templates` to see all options.\n\n---\n\n## Architecture\n\n```\n+----------------+      +-------------------+      +----------------------+\n|   Discord.py   | \u003c--\u003e |  EasyCord (Bot)   | \u003c--\u003e | InteractionRegistry  |\n+----------------+      +---------+---------+      +----------------------+\n                                  |\n          +-----------+-----------+-----------+-----------+\n          |           |           |           |           |\n    +-----+-----+ +---+-------+ +-+--------+ +-+-------+ +-----------+\n    |  Plugins  | | Middleware| | Database | |  i18n   | | AI Layer  |\n    +-----------+ +-----------+ +----------+ +---------+ +-----------+\n```\n\n`InteractionRegistry` is the authoritative EasyCord inventory. `discord.app_commands.CommandTree` remains the Discord sync backend.\n\n---\n\n## Commands and Interactions\n\n### Slash commands\n\n```python\nfrom easycord import Bot, slash_command\n\nbot = Bot()\n\n@bot.slash(description=\"Add two numbers\")\nasync def add(ctx, a: int, b: int):\n    await ctx.respond(str(a + b))\n```\n\n`@slash` and `@slash_command` are identical — use either. Parameters are typed via Python annotations and rendered as Discord option types automatically.\n\n**Decorator options:**\n\n| Option | Type | Effect |\n|---|---|---|\n| `name` | `str` | Command name; defaults to the function name |\n| `description` | `str` | Shown in the Discord UI — required |\n| `guild_id` | `int` | Register to one guild (instant); `None` = global (up to 1 hour) |\n| `guild_only` | `bool` | Reject DM invocations with an ephemeral message |\n| `ephemeral` | `bool` | Force all responses from this command to be ephemeral |\n| `permissions` | `list[str]` | `discord.Permissions` attribute names the invoker must hold |\n| `cooldown` | `float` | Per-user cooldown in seconds |\n| `autocomplete` | `dict` | Live suggestion callbacks keyed to parameter names |\n| `choices` | `dict` | Fixed dropdown values keyed to parameter names |\n\n### Command guards\n\nStack reusable guards with decorators:\n\n```python\nfrom easycord import cooldown, install_type, premium_required, require_permissions, slash_command\n\n@slash_command(description=\"Clean up messages\")\n@require_permissions(\"manage_messages\")\n@cooldown(rate=2, per=30, bucket=\"guild\")\nasync def cleanup(ctx, count: int = 10):\n    await ctx.send(f\"Cleaned {count} messages.\", silent=True)\n\n@slash_command(description=\"Premium-only feature\")\n@install_type(guild=True, user=True)\n@premium_required\nasync def exclusive(ctx):\n    await ctx.respond(\"Thanks for supporting the bot!\", suppress_embeds=True)\n```\n\n- `@require_permissions(*perms)` — blocks the command if the invoker lacks any named permission.\n- `@cooldown(rate, per, bucket)` — per-user or per-guild rate limit stored in process memory.\n- `@install_type(guild, user)` — restricts where a user-installable command appears.\n- `@premium_required` — blocks the command unless the invoker has an active Discord premium entitlement.\n\n### Context menus\n\n```python\n@bot.user_command(name=\"View Profile\")\nasync def profile(ctx, member):\n    await ctx.respond(f\"{member.display_name} joined {member.guild.name}.\")\n\n@bot.message_command(name=\"Quote This\")\nasync def quote(ctx, message):\n    await ctx.respond(f'\"{message.content}\" — {message.author.display_name}')\n```\n\n### Buttons, select menus, and modals\n\n```python\nfrom easycord import component, modal\n\n@bot.component(\"approve_btn\")\nasync def on_approve(ctx):\n    await ctx.respond(\"Approved!\", ephemeral=True)\n\n@bot.modal(\"feedback_form\")\nasync def on_feedback(ctx, message: str):\n    await ctx.respond(f\"Feedback received: {message}\")\n```\n\n### Dynamic component routing\n\nRoute component interactions using typed URL-style patterns with collision checking:\n\n```python\nfrom easycord import Plugin, component\n\nclass TicketPlugin(Plugin):\n    @component(\"ticket:close:{ticket_id:int}\")\n    async def close_ticket(self, ctx, ticket_id: int):\n        await ctx.respond(f\"Closing ticket {ticket_id}.\", ephemeral=True)\n\n    @component(\"poll:vote:{poll_id:int}:{choice_id:int}\")\n    async def record_vote(self, ctx, poll_id: int, choice_id: int):\n        await ctx.respond(\"Vote recorded.\", ephemeral=True)\n\n    @component(\"wizard:{session_id:snowflake}:next\", ttl=300)\n    async def wizard_next(self, ctx, session_id: int):\n        ...\n```\n\nSupported route types: `str`, `int`, `snowflake`. Routes with `ttl=` expire without dispatching after their deadline. Routes without `ttl` are persistent and survive restarts.\n\n### Autocomplete\n\n```python\nfrom easycord import Plugin, autocomplete, slash_command\n\nclass FruitPlugin(Plugin):\n    @autocomplete(\"fruit\", command=\"pick\")\n    async def fruit_choices(self, ctx, current: str, options: dict):\n        return [name for name in [\"apple\", \"banana\", \"cherry\"] if current.lower() in name]\n\n    @slash_command(description=\"Pick a fruit\")\n    async def pick(self, ctx, fruit: str):\n        await ctx.respond(fruit)\n```\n\n### Option validators\n\nValidate slash command parameters before the handler runs:\n\n```python\nfrom easycord.validators import Duration, URL, Snowflake, Range, Regex, ChoiceSet\n```\n\n- `Duration` — parses human-readable durations like `\"2h30m\"` into seconds.\n- `URL` — validates and normalizes a URL string.\n- `Snowflake` — validates a Discord snowflake ID.\n- `Range` — enforces a numeric min/max range.\n- `Regex` — matches input against a pattern.\n- `ChoiceSet` — enforces membership in a fixed set of strings.\n\n`ValidationError` is raised on failure. Call `exc.user_message(ctx)` for the localized string.\n\n---\n\n## Plugins\n\nA plugin groups related commands, event handlers, and background tasks into a single reloadable unit with lifecycle hooks.\n\n```python\nfrom easycord import Bot, Plugin, on, slash, task\n\nclass GreetPlugin(Plugin):\n    async def on_load(self):\n        print(\"GreetPlugin loaded\")\n\n    async def on_unload(self):\n        print(\"GreetPlugin unloaded\")\n\n    @slash(description=\"Say hello\")\n    async def hello(self, ctx):\n        await ctx.respond(f\"Hello, {ctx.user.display_name}!\")\n\n    @on(\"member_join\")\n    async def welcome(self, member):\n        if channel := member.guild.system_channel:\n            await channel.send(f\"Welcome, {member.mention}!\")\n\n    @task(minutes=30)\n    async def periodic_cleanup(self):\n        ...  # runs every 30 minutes, starts on load, stops on unload\n\nbot = Bot()\nbot.add_plugin(GreetPlugin())\nbot.run(\"YOUR_TOKEN\")\n```\n\nLifecycle hooks:\n- `on_load()` — runs when the plugin is registered with `bot.add_plugin()`.\n- `on_unload()` — runs when the plugin is removed with `bot.remove_plugin()`.\n- `on_reload()` — runs after a successful hot-reload (see [hot-reload-development.md](docs/hot-reload-development.md)).\n- `on_error(ctx, exc)` — catches unhandled exceptions from any command in the plugin.\n\n### Bundled first-party plugins\n\nLoad the starter set with one call:\n\n```python\nbot = Bot(load_builtin_plugins=True)   # loads WelcomePlugin, TagsPlugin, PollsPlugin, LevelsPlugin\n```\n\nOr load them selectively:\n\n```python\nfrom easycord.plugins import (\n    LevelsPlugin,\n    ModerationPlugin,\n    PollsPlugin,\n    StarboardPlugin,\n    TagsPlugin,\n    WelcomePlugin,\n    InviteTrackerPlugin,\n    ReactionRolesPlugin,\n    MemberLoggingPlugin,\n    SuggestionsPlugin,\n    OpenClaudePlugin,\n    TranslatePlugin,\n)\n\nbot.add_plugin(LevelsPlugin(xp_per_message=15, cooldown_seconds=45))\nbot.add_plugin(ModerationPlugin())\nbot.add_plugin(PollsPlugin())\nbot.add_plugin(StarboardPlugin())\nbot.add_plugin(TagsPlugin())\nbot.add_plugin(WelcomePlugin())\nbot.add_plugin(InviteTrackerPlugin())\nbot.add_plugin(ReactionRolesPlugin())\nbot.add_plugin(MemberLoggingPlugin())\nbot.add_plugin(SuggestionsPlugin())\nbot.add_plugin(TranslatePlugin())         # /translate with Google Translate, no API key\nbot.add_plugin(OpenClaudePlugin(api_key=\"sk-ant-...\"))  # /ask backed by Claude\n```\n\n### Hot-reload during development\n\n```python\nimport os\nbot.run(os.environ[\"DISCORD_TOKEN\"], reload=os.environ.get(\"ENV\") == \"development\")\n```\n\nWhen a plugin file changes on disk, EasyCord calls `importlib.reload()`, swaps the instance, and calls `on_reload()`. Syntax errors keep the old plugin running; the next successful save retries automatically.\n\n---\n\n## Middleware\n\nMiddleware intercepts every slash command before it reaches the handler. Each layer receives `ctx` and a `proceed()` coroutine. Call `proceed()` to continue; skip it to block.\n\n```python\nbot.use(catch_errors())\nbot.use(guild_only())\nbot.use(admin_only())\nbot.use(rate_limit(limit=5, window=10.0))\nbot.use(log_middleware())\n```\n\n**Recommended order:** `catch_errors` first (outermost), then access gates, then rate limiting, then logging.\n\n### Built-in middleware\n\n| Factory | Blocks when… |\n|---|---|\n| `guild_only()` | Command is invoked in a DM |\n| `dm_only()` | Command is invoked inside a guild |\n| `admin_only(message=None)` | Invoker lacks the `administrator` permission |\n| `allowed_roles(*role_ids, message=None)` | Invoker holds none of the given role IDs |\n| `has_permission(*perms, message=None)` | Invoker lacks any of the named permissions |\n| `channel_only(*channel_ids, message=None)` | Command is invoked outside the specified channels |\n| `boost_only(message=None)` | Invoker is not currently boosting the server |\n| `rate_limit(limit=5, window=10.0)` | User exceeds `limit` calls within `window` seconds |\n| `log_middleware(level, fmt)` | Never — logs every invocation and always proceeds |\n| `catch_errors(message=None)` | Never — catches exceptions and sends an ephemeral reply |\n\n### Custom middleware\n\n```python\nfrom easycord.middleware import MiddlewareFn\n\ndef require_prefix(prefix: str) -\u003e MiddlewareFn:\n    async def handler(ctx, proceed):\n        if not ctx.user.name.startswith(prefix):\n            await ctx.respond(f\"Only users whose name starts with '{prefix}' can use this.\", ephemeral=True)\n            return\n        await proceed()\n    return handler\n\nbot.use(require_prefix(\"dev_\"))\n```\n\n---\n\n## Error Handling\n\nEasyCord walks a waterfall and stops at the first registered handler:\n\n```\n1. @command_error(\"name\")   — per-command handler on the plugin\n2. Plugin.on_error()        — plugin-scoped override\n3. @bot.on_error            — global bot-level handler\n4. Framework fallback       — re-raises for slash commands; logs for tasks and components\n```\n\n```python\nfrom easycord import Plugin, slash, command_error\n\nclass MathPlugin(Plugin):\n    @slash(description=\"Divide two numbers\")\n    async def divide(self, ctx, a: int, b: int):\n        await ctx.respond(str(a // b))\n\n    @command_error(\"divide\")\n    async def divide_error(self, ctx, exc):\n        if isinstance(exc, ZeroDivisionError):\n            await ctx.respond(\"Cannot divide by zero.\", ephemeral=True)\n        else:\n            await ctx.respond(\"Math failed. Try again.\", ephemeral=True)\n\n    async def on_error(self, ctx, exc):\n        # catches any command in this plugin not handled by @command_error\n        await ctx.respond(\"Something went wrong.\", ephemeral=True)\n\n@bot.on_error\nasync def global_handler(ctx, exc):\n    # ctx is None for task-originated errors\n    if ctx is not None:\n        await ctx.respond(\"An unexpected error occurred.\", ephemeral=True)\n```\n\nSee [error-handling.md](docs/error-handling.md) for the full guide including common exceptions and testing patterns.\n\n---\n\n## Storage\n\n### Guild-scoped key-value store\n\n```python\nfrom easycord import ServerConfigStore\n\nstore = ServerConfigStore()\nawait store.set(ctx.guild.id, \"welcome_channel\", channel_id)\nvalue = await store.get(ctx.guild.id, \"welcome_channel\")\n```\n\n### SQLite database\n\n```python\nfrom easycord import Bot, SQLiteDatabase\n\nbot = Bot(database=SQLiteDatabase(path=\"data/bot.db\"))\nawait bot.db.set(guild_id, \"key\", {\"any\": \"json_value\"})\nvalue = await bot.db.get(guild_id, \"key\", default=None)\n```\n\n### Memory database (for tests and disposable bots)\n\n```python\nbot = Bot(db_backend=\"memory\")\n# or\nfrom easycord import MemoryDatabase\nbot = Bot(database=MemoryDatabase())\n# or\nEASYCORD_DB_BACKEND=memory python bot.py\n```\n\n---\n\n## Config-driven startup\n\n```python\nfrom easycord import BotConfig\n\ncfg = BotConfig.from_env()   # reads DISCORD_TOKEN, DISCORD_GUILD_ID, db_backend, etc.\nbot = cfg.build_bot()\nbot.run(cfg.token)\n```\n\nJSON files use the same field names: `token`, `guild_id`, `db_backend`, `db_path`, `auto_sync`, `log_level`, `extra`. Config precedence: environment → file → explicit keyword overrides.\n\n---\n\n## Localization\n\n```python\nfrom easycord import Bot, LocalizationManager\n\nlocales = LocalizationManager()\nlocales.register(\"en-US\", \"locales/en.json\")\nlocales.register(\"es-ES\", \"locales/es.json\")\n\nbot = Bot(localization=locales, default_locale=\"en-US\")\n\n@bot.slash(description=\"Ping\")\nasync def ping(ctx):\n    await ctx.respond(ctx.t(\"commands.ping.response\", default=\"Pong!\"))\n```\n\nLocale resolution order: user locale → guild locale → default locale → English. Missing keys fall back gracefully at every step.\n\n**Google Translate auto-translation** (no API key required):\n\n```python\nfrom easycord import make_google_auto_translator\n\nlocales = LocalizationManager(auto_translator=make_google_auto_translator())\n```\n\nMissing-key lookups are translated on-the-fly and cached. After `bot.use_google_translate()` + `sync_commands()`, Discord shows localized command names per locale — French users see `/traduire`, German users see `/übersetzen` — all routed to the same handler.\n\n**TranslatePlugin** adds a `/translate` slash command backed by Google Translate:\n\n```python\nfrom easycord.plugins import TranslatePlugin\n\nbot.add_plugin(TranslatePlugin())\n# Members use: /translate text:\"Hello\" languages:\"English to French\"\n# or:          /translate text:\"Hola\"  (auto-detects language, translates to invoker's Discord locale)\n```\n\n---\n\n## Testing\n\nTest commands without a live Discord connection:\n\n```python\nfrom easycord.testing import (\n    FakeContext,\n    FakeContextBuilder,\n    invoke,\n    invoke_autocomplete,\n    invoke_component,\n    invoke_message_command,\n    invoke_modal,\n    invoke_user_command,\n)\n\nasync def test_ping(bot):\n    ctx = await invoke(bot, \"ping\")\n    assert ctx.last_response == \"Pong!\"\n\nasync def test_user_command(bot):\n    ctx = await invoke_user_command(bot, \"View Profile\", target_id=42)\n    ctx.assert_contains(\"profile\")\n\nasync def test_component(bot):\n    ctx = await invoke_component(bot, \"ticket:close:7\")\n    ctx.assert_content(\"Closing ticket 7.\")\n\nasync def test_modal(bot):\n    ctx = await invoke_modal(bot, \"feedback_form\", message=\"Great bot\")\n    ctx.assert_contains(\"received\")\n\nasync def test_autocomplete(bot):\n    choices = await invoke_autocomplete(bot, \"pick\", \"fruit\", \"ap\")\n    assert \"apple\" in choices\n```\n\nBuild a richer context with `FakeContextBuilder`:\n\n```python\nctx = (\n    FakeContextBuilder()\n    .with_user(42, display_name=\"Ada\")\n    .in_guild(100, name=\"Test Guild\")\n    .as_admin()\n    .with_permissions(manage_messages=True)\n    .with_roles(123456789)\n    .with_locale(\"en-US\", guild_locale=\"en-GB\")\n    .build()\n)\n```\n\n---\n\n## AI Integration (optional)\n\nEasyCord works without AI. Add AI features as plugins when needed.\n\n### Quick AI assistant\n\n```python\nfrom easycord.plugins import OpenClaudePlugin\n\nbot.add_plugin(OpenClaudePlugin(api_key=\"sk-ant-...\"))\n# Members use: /ask \"your question\"\n# Responses are rate-limited per user and truncated to Discord's 2000-character limit.\n```\n\n### 9 supported LLM providers\n\n```python\nfrom easycord.plugins import (\n    AnthropicProvider,    # Claude (claude-sonnet-4-6 default)\n    OpenAIProvider,       # GPT-4o and others\n    GeminiProvider,       # Google Gemini\n    GroqProvider,         # Groq (fast inference)\n    MistralProvider,      # Mistral AI\n    HuggingFaceProvider,  # HuggingFace Inference API\n    TogetherProvider,     # Together.ai\n    OllamaProvider,       # Local Ollama models\n    LiteLLMProvider,      # LiteLLM proxy (routes to any backend)\n)\n```\n\n### Multi-provider orchestration with fallback chains\n\n```python\nfrom easycord import Orchestrator, FallbackStrategy, RunContext\n\norchestrator = Orchestrator(\n    strategy=FallbackStrategy([\n        AnthropicProvider(),   # tried first\n        GroqProvider(),        # tried if Anthropic fails\n        OpenAIProvider(),      # tried if Groq fails\n    ]),\n    tools=bot.tool_registry,\n)\n\n@bot.slash(description=\"Ask AI with tool access\")\nasync def ask(ctx, prompt: str):\n    await ctx.defer()\n    result = await orchestrator.run(\n        RunContext(\n            messages=[{\"role\": \"user\", \"content\": prompt}],\n            ctx=ctx,\n            max_steps=5,    # max tool calls before returning a final answer\n            timeout=30.0,   # seconds per tool call\n        )\n    )\n    await ctx.respond(result.text[:2000])\n```\n\nThe orchestrator:\n- **Selects providers** by trying the best first and falling back if it fails.\n- **Detects tool calls** when the AI requests a function.\n- **Executes tools** with permission checks, timeouts, and exception handling.\n- **Loops** by feeding tool results back to the AI until it returns a final response.\n- **Enforces constraints** — admin-only, role-gated, and user-allowlisted tools are checked at each step.\n\n### AI tool registration\n\nExpose bot functions to the AI with `@ai_tool`:\n\n```python\nimport discord\nfrom easycord import Plugin, ai_tool, ToolSafety\nfrom datetime import timedelta\n\nclass ModToolsPlugin(Plugin):\n    @ai_tool(description=\"Check if a user is a member of this server\")\n    async def is_member(self, ctx, user_id: int) -\u003e str:\n        try:\n            await ctx.guild.fetch_member(user_id)\n            return \"User is a member.\"\n        except discord.NotFound:\n            return \"User is not a member.\"\n        except discord.HTTPException as exc:\n            return f\"Could not check membership: {exc}\"\n\n    @ai_tool(\n        description=\"Timeout a user from the server\",\n        safety=ToolSafety.CONTROLLED,\n        require_admin=True,\n    )\n    async def timeout_user(self, ctx, user_id: int, seconds: int = 3600) -\u003e str:\n        member = await ctx.guild.fetch_member(user_id)\n        await member.timeout(timedelta(seconds=seconds))\n        return f\"Timed out {member.name} for {seconds}s.\"\n```\n\nSafety levels:\n- `ToolSafety.SAFE` — read-only operations (queries, lookups, member info).\n- `ToolSafety.CONTROLLED` — validated write operations (moderation, role changes, database writes).\n- `ToolSafety.RESTRICTED` — never exposed to AI (admin-only or destructive operations).\n\nEach tool optionally requires `require_admin=True`, specific `allowed_roles`, or `allowed_users`.\n\nAudit tools offline before connecting to Discord:\n\n```bash\neasycord audit-tools bot:bot\neasycord audit-tools bot:bot --fail-on-warnings   # exit 1 in CI if warnings exist\n```\n\n---\n\n## Command Sync\n\nPreview what would change before syncing with Discord:\n\n```python\nplan = bot.plan_command_sync(remote_commands=[\"old_ping\"])\n# plan has: added, changed, removed, unchanged, warnings\n\nplan = await bot.sync_commands(dry_run=True, remote_commands=[\"old_ping\"])\nawait bot.sync_commands()                                        # live sync\nawait bot.sync_commands(guild_id=123456789012345678)            # guild sync\nawait bot.sync_commands(remote_commands=[\"old_ping\"], confirm_removals=True)\n```\n\nFrom the CLI:\n\n```bash\neasycord sync-plan bot:bot --remote old_ping\neasycord sync-plan bot:bot --remote old_ping --json\n```\n\n---\n\n## Inspect Registered Interactions\n\n```python\ninventory = bot.inspect_interactions()\n# returns: slash, context_menu, component, modal, autocomplete\n```\n\nEach entry includes: interaction type, name or route pattern, callback name, source plugin, guild scope, metadata, enabled state, sync state, and registration time.\n\nFrom the CLI:\n\n```bash\neasycord inspect bot:bot\neasycord inspect bot:bot --json\n```\n\n---\n\n## Built-in Embeds\n\n```python\nfrom easycord import EasyEmbed, EmbedBuilder, EmbedCard\nfrom easycord.embed_cards import InfoEmbed, SuccessEmbed, WarningEmbed, ErrorEmbed\n\n# Quick status embeds\nawait ctx.respond(embed=EasyEmbed.success(\"Operation complete!\"))\nawait ctx.respond(embed=EasyEmbed.error(\"Something went wrong.\"))\nawait ctx.respond(embed=EasyEmbed.info(\"Update available.\"))\nawait ctx.respond(embed=EasyEmbed.warning(\"Double-check this setting.\"))\n\n# Fluent builder\nembed = (\n    EmbedBuilder()\n    .title(\"Member Info\")\n    .description(\"Details about the member\")\n    .field(\"Joined\", \"2024-01-01\")\n    .field(\"Roles\", \"Moderator, Staff\")\n    .color(0x5865F2)\n    .build()\n)\n\n# Card with buttons\ncard = (\n    EmbedCard.from_embed(embed)\n    .button(\"Approve\", custom_id=\"approve\", style=\"success\")\n    .button(\"Reject\",  custom_id=\"reject\",  style=\"danger\")\n)\nawait ctx.respond(**card.to_kwargs())\n```\n\n---\n\n## Pagination\n\n```python\nfrom easycord import Paginator\n\n@bot.slash(description=\"Show all commands\")\nasync def help(ctx):\n    lines = [f\"/command{i}\" for i in range(1, 37)]\n    await Paginator.from_lines(lines, per_page=10, title=\"Commands\").send(ctx)\n\n@bot.slash(description=\"Browse results\")\nasync def browse(ctx):\n    embeds = [page_one, page_two, page_three]\n    await Paginator.from_embeds(embeds).send(ctx)\n```\n\n---\n\n## Developer Diagnostics\n\n```bash\neasycord doctor                        # check Python, discord.py, DISCORD_TOKEN\neasycord doctor bot:bot                # also check bot imports and interaction count\neasycord doctor bot:bot --json         # stable JSON output for CI\n\neasycord inspect bot:bot               # print all registered interactions\neasycord inspect bot:bot --json\n\neasycord sync-plan bot:bot             # preview command sync changes\neasycord audit-tools bot:bot           # check AI tool safety classification\n\neasycord test-template my_plugin       # generate a starter test file\neasycord test-template my_plugin -o tests/test_my_plugin.py\n\neasycord plugin create my_plugin       # scaffold a new plugin module\neasycord plugin check ./my_plugin      # validate manifest, layout, and imports\neasycord plugin discover --json        # list installed easycord.plugins entry points\n```\n\n---\n\n## Fluent Setup (alternative to manual wiring)\n\n```python\nfrom easycord import FrameworkManager\n\nbot = FrameworkManager.build_bot(\n    builtin_plugins=True,\n    guild_only=True,\n)\nbot.run(\"YOUR_TOKEN\")\n```\n\n---\n\n## Recommended Project Layout\n\n```text\nmy_bot/\n├── bot.py              # startup, BotConfig, plugin registration\n├── plugins/\n│   ├── __init__.py\n│   ├── fun.py          # one Plugin subclass per file\n│   └── moderation.py\n├── locales/\n│   ├── en-US.json\n│   └── es-ES.json\n├── tests/\n│   └── test_commands.py\n└── pyproject.toml\n```\n\n- Keep `bot.py` for startup and wiring only.\n- Put each feature in its own `Plugin`.\n- Move shared settings into `ServerConfigStore` (no database) or `SQLiteDatabase` (relational).\n- Use `db_backend=\"memory\"` in tests so test runs stay offline and produce no local files.\n\n---\n\n## Full Feature Reference\n\n**Commands and interactions:**\n- Slash commands with typed parameters, cooldowns, permission guards, and ephemeral forcing.\n- Context menu commands for right-click User and right-click Message actions.\n- Button and select menu components with static or dynamic typed-route custom IDs.\n- Modals with named field extraction.\n- Autocomplete callbacks for live suggestions as the user types.\n- Option validators: `Duration`, `URL`, `Snowflake`, `Range`, `Regex`, `ChoiceSet`.\n- Command sync planner with dry-run mode and explicit removal confirmation.\n\n**Plugins:**\n- `Plugin` base class with `on_load`, `on_unload`, `on_reload`, and `on_error` hooks.\n- `@slash`, `@on`, `@component`, `@modal`, `@task` decorators inside plugins.\n- `SlashGroup` for command namespaces with subcommands.\n- `bot.add_plugin()`, `bot.remove_plugin()`, and hot-reload via `reload=True`.\n- Plugin authoring helpers: `create_package_plugin`, `check_plugin_project`, `discover_plugins`, `load_entrypoint_plugins`.\n- Plugin manifest validation and `easycord.plugins` entry-point discovery.\n\n**Bundled plugins:**\n- `WelcomePlugin` — configurable welcome messages on member join.\n- `TagsPlugin` — per-guild text snippet store with `/tag get/set/delete/list`.\n- `PollsPlugin` — slash-command polls with reaction-based voting.\n- `LevelsPlugin` — per-guild XP, leveling, rank names, and role rewards.\n- `ModerationPlugin` — kick, ban, unban, timeout, warn, mute, unmute.\n- `StarboardPlugin` — archives messages that reach a reaction threshold.\n- `InviteTrackerPlugin` — tracks which invite brought each member.\n- `ReactionRolesPlugin` — auto-assigns roles on emoji reactions.\n- `MemberLoggingPlugin` — logs joins, leaves, nickname changes, and role changes.\n- `SuggestionsPlugin` — collects and votes on server suggestions.\n- `OpenClaudePlugin` — `/ask` command backed by Anthropic Claude.\n- `TranslatePlugin` — `/translate` backed by Google Translate, no API key required.\n\n**Middleware:**\n- `guild_only`, `dm_only`, `admin_only`, `allowed_roles`, `has_permission`, `channel_only`, `boost_only`.\n- `rate_limit` — per-user sliding-window rate limiter.\n- `log_middleware` — logs every invocation to the `easycord` logger.\n- `catch_errors` — wraps the chain in a broad except and sends an ephemeral reply.\n- Custom middleware: function-factory pattern or stateful class with `__call__`.\n\n**Storage:**\n- `ServerConfigStore` — per-guild JSON key-value store with atomic writes.\n- `SQLiteDatabase` — persistent relational storage with guild-row sync.\n- `MemoryDatabase` — in-process storage for tests and disposable bots.\n- `BotConfig` — environment/file-driven startup with config precedence rules.\n\n**Localization:**\n- `LocalizationManager` — registers locale files and resolves keys.\n- `ctx.t(key, default=...)` — resolves a translation key in the invoker's locale.\n- Locale fallback chain: user locale → guild locale → default locale → English.\n- `make_google_auto_translator()` — auto-translates missing keys on-the-fly.\n- `bot.use_google_translate()` + `sync_commands()` — localizes Discord command names per locale.\n\n**AI and orchestration:**\n- 9 `AIProvider` implementations: Anthropic, OpenAI, Gemini, Groq, Mistral, HuggingFace, Together.ai, Ollama, LiteLLM.\n- `Orchestrator` — multi-step tool execution loop with provider routing.\n- `FallbackStrategy` — tries providers in order, falls back on failure.\n- `@ai_tool` — exposes a plugin method to the AI with permission gates and safety classification.\n- `ToolSafety.SAFE / CONTROLLED / RESTRICTED` — three-tier safety model.\n- `ToolRegistry` — manages registered tools with explicit permission gates.\n- `ConversationMemory` — maintains multi-turn context across commands.\n- `easycord audit-tools` — offline safety audit before connecting to any provider.\n\n**Developer toolkit:**\n- `easycord new` — scaffolds a runnable bot project with one of four templates.\n- `easycord doctor` — checks Python version, discord.py, token, and optional bot import.\n- `easycord inspect` — prints all registered interactions grouped by type.\n- `easycord sync-plan` — diffs local commands against remote names without contacting Discord.\n- `easycord audit-tools` — checks tool safety, descriptions, schemas, and permission gates.\n- `easycord test-template` — generates a starter test file for a plugin.\n- `easycord plugin create/check/discover` — plugin scaffolding and validation.\n\n**Testing:**\n- `invoke(bot, \"name\", **kwargs)` — invokes a slash command offline.\n- `invoke_user_command`, `invoke_message_command` — invokes context menus offline.\n- `invoke_component(bot, \"custom_id\")` — invokes a component handler offline.\n- `invoke_modal(bot, \"custom_id\", **fields)` — submits a modal offline.\n- `invoke_autocomplete(bot, \"cmd\", \"param\", \"current\")` — fetches autocomplete choices offline.\n- `FakeContextBuilder` — fluent builder for richer offline handler contexts.\n- `FakeContext.make(is_admin=True)` — quick one-line fake context.\n- `ctx.assert_content(str)`, `ctx.assert_contains(str)` — inline response assertions.\n\n**Error handling:**\n- `@command_error(\"name\")` — per-command handler on the plugin.\n- `Plugin.on_error(ctx, exc)` — plugin-scoped handler for all commands in the plugin.\n- `@bot.on_error` — global fallback handler (one allowed; second call overwrites).\n- Framework fallback — re-raises for slash commands; logs for tasks and components.\n\n**Type checking:**\n- Ships `pyrightconfig.json` pre-configured for `standard` mode.\n- `SENDABLE_CHANNEL_TYPES` for narrowing channel sends without type suppression.\n- `TYPE_CHECKING` import pattern for cyclic dependency resolution.\n\n---\n\n## Why EasyCord vs. raw discord.py\n\n| Task | Raw `discord.py` | EasyCord |\n|---|---|---|\n| Slash commands | Build `CommandTree`, sync manually | `@bot.slash(description=\"...\")` |\n| Permission checks | Repeat in each command | `@require_permissions(...)` on the decorator |\n| Cooldowns | Track timestamps yourself | `@cooldown(rate=2, per=30)` |\n| Components | Wire handlers by matching string IDs | `@bot.component(\"ticket:close:{id:int}\")` |\n| Middleware | Write custom decorator chains | `bot.use(rate_limit())` |\n| Plugins | Custom `Cog` wiring | `Plugin` with lifecycle hooks |\n| Per-guild config | Hand-rolled JSON or a database | `ServerConfigStore` or `bot.db` |\n| Error handling | Catch and re-raise in each command | `@command_error`, `on_error`, `@bot.on_error` |\n| Offline tests | Mock the entire discord.py client | `invoke(bot, \"command_name\")` |\n| AI integration | discord.py + LLM SDK glue code | `Orchestrator` + `ToolRegistry` |\n| Tool calling | Manual prompt engineering | `@ai_tool` with safety classification |\n\n---\n\n## License\n\nEasyCord is released under the **MIT License**.\n\n- See `pyproject.toml` for the canonical license metadata.\n- Copyright (c) 2026 Rolling Codes.\n\nRelease: [v5.50.2](https://github.com/rolling-codes/EasyCord/releases/tag/v5.50.2) · [Changelog](CHANGELOG.md) · [GitHub](https://github.com/rolling-codes/EasyCord)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Frolling-codes%2Feasycord","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Frolling-codes%2Feasycord","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Frolling-codes%2Feasycord/lists"}