[
  {
    "path": ".env.example",
    "content": "# Milo configuration\n# Copy this file to `.env` and replace placeholder values.\n\n# Required\nDISCORD_TOKEN=your_discord_bot_token_here\n\n# Optional: default AI provider settings\nOPENAI_API_KEY=your_openai_api_key_here\nOPENAI_API_BASE=https://api.openai.com/v1\nALLOW_USER_KEYS=true\nDEFAULT_CHAT_MODEL=gpt-4o-mini\nALLOWED_CHAT_MODELS=gpt-4o-mini,gpt-4o\n\n# Optional: Google Custom Search for `/chat --search_web`\n# These values may incur cost depending on your Google setup.\nGOOGLE_API_KEY=your_google_api_key_here\nGOOGLE_CSE_ID=your_custom_search_engine_id_here\n"
  },
  {
    "path": ".flake8",
    "content": "[flake8]\nmax-line-length = 120\nexclude = \n    .git,\n    __pycache__,\n    .venv,\n    venv,\n    database\nignore = E203,E501,W503\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yml",
    "content": "name: Bug Report\ndescription: Report a reproducible problem in Milo\ntitle: \"[Bug]: \"\nlabels:\n  - bug\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Use this form for reproducible defects. Do not report security issues here. Follow `SECURITY.md` for private reporting.\n  - type: textarea\n    id: summary\n    attributes:\n      label: Summary\n      description: What broke?\n      placeholder: A short description of the problem\n    validations:\n      required: true\n  - type: textarea\n    id: steps\n    attributes:\n      label: Steps To Reproduce\n      description: List the exact steps needed to trigger the issue\n      placeholder: |\n        1. Run ...\n        2. Use command ...\n        3. See error ...\n    validations:\n      required: true\n  - type: textarea\n    id: expected\n    attributes:\n      label: Expected Behavior\n      placeholder: What should have happened?\n    validations:\n      required: true\n  - type: textarea\n    id: actual\n    attributes:\n      label: Actual Behavior\n      placeholder: What happened instead?\n    validations:\n      required: true\n  - type: textarea\n    id: logs\n    attributes:\n      label: Logs Or Screenshots\n      description: Paste relevant logs or screenshots. Remove secrets before submitting.\n      render: shell\n  - type: textarea\n    id: environment\n    attributes:\n      label: Environment\n      description: Runtime details that may matter\n      placeholder: |\n        Python version:\n        OS:\n        discord.py version:\n        Hosting provider:\n  - type: dropdown\n    id: scope\n    attributes:\n      label: Impacted Area\n      options:\n        - Commands\n        - Economy\n        - Farming\n        - AI Chat\n        - Install / Setup\n        - Docs\n        - Other\n    validations:\n      required: true\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yml",
    "content": "name: Feature Request\ndescription: Suggest an improvement or new feature for Milo\ntitle: \"[Feature]: \"\nlabels:\n  - enhancement\nbody:\n  - type: textarea\n    id: problem\n    attributes:\n      label: Problem\n      description: What limitation or pain point are you trying to solve?\n      placeholder: The current behavior makes it hard to ...\n    validations:\n      required: true\n  - type: textarea\n    id: proposal\n    attributes:\n      label: Proposed Solution\n      description: Describe the feature you want\n      placeholder: Add a command / setting / behavior that ...\n    validations:\n      required: true\n  - type: textarea\n    id: alternatives\n    attributes:\n      label: Alternatives Considered\n      description: Any other approaches you considered?\n  - type: dropdown\n    id: breaking\n    attributes:\n      label: Breaking Change?\n      options:\n        - \"No\"\n        - \"Maybe\"\n        - \"Yes\"\n    validations:\n      required: true\n  - type: dropdown\n    id: area\n    attributes:\n      label: Area\n      options:\n        - Commands\n        - Economy\n        - Farming\n        - AI Chat\n        - Moderation\n        - Developer Experience\n        - Docs\n        - Other\n    validations:\n      required: true\n"
  },
  {
    "path": ".github/pull_request_template.md",
    "content": "## Summary\n\n- What problem does this change solve?\n\n## Changes\n\n- What changed?\n\n## Verification\n\n- How was this tested?\n\n## Risk\n\n- Any schema, config, permission, or external API impact?\n\n## Checklist\n\n- [ ] No real secrets were committed\n- [ ] Manual verification was completed\n- [ ] README or docs were updated if behavior changed\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  push:\n    branches:\n      - main\n  pull_request:\n\njobs:\n  compile:\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Set up Python\n        uses: actions/setup-python@v5\n        with:\n          python-version: \"3.11\"\n          cache: \"pip\"\n          cache-dependency-path: requirements.txt\n\n      - name: Install dependencies\n        run: |\n          python -m pip install --upgrade pip\n          pip install -r requirements.txt\n          pip install pytest pytest-asyncio\n\n      - name: Run tests\n        run: pytest\n\n      - name: Compile project\n        run: python -m compileall .\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Release\n\non:\n  push:\n    tags:\n      - \"v*\"\n\npermissions:\n  contents: write\n\njobs:\n  create-release:\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Create GitHub release\n        uses: softprops/action-gh-release@v2\n        with:\n          generate_release_notes: true\n          prerelease: ${{ contains(github.ref_name, '-') }}\n"
  },
  {
    "path": ".gitignore",
    "content": "config.json\n.env\n.env.*\n!.env.example\n__pycache__/\n*.py[cod]\n.wrangler/\n.venv/\ndatabase/*.db\n/Milo-discord-fun-bot/\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "## Changelog\n\n**v1.0.4** – 2026-03-20\n- Added bounded reminder and scheduled announcement polling, plus admin diagnostics for failed announcements.\n- Added cleanup for stale chat cooldown state and retained message logs with periodic pruning.\n- Hardened media commands against upstream API failures and clarified automod handling for thread channels.\n- Reduced economy lock contention to per-guild scope and added length guards for reminders and announcements.\n- Prevented `/help all` embed overflows and added regression tests covering the issue backlog fixes.\n\n**v1.0.3** – 2026-03-14\n- Isolated guild chat history per user and added server-side AI chat safety controls.\n- Added basic automod, warning history commands, and better moderation logging support.\n- Added scheduled announcements, welcome/goodbye previews, recurring reminders, and reminder snoozing.\n- Fixed reminder delivery loss, guild economy cooldown scope, and anti-spam isolation issues.\n\n\n**v1.0.2** – 2025-07-28\n- Improved the installation script with added support for Windows environments.\n\n\n**v1.0.1** – 2025-07-23\n- Initial public release\n"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Code of Conduct\n\n## Our Standard\n\nParticipation in this project should be respectful, direct, and constructive.\n\nExamples of expected behavior:\n\n- Give technical feedback without personal attacks\n- Assume mistakes can be corrected\n- Keep disagreement focused on code, design, and evidence\n- Respect maintainers' time and scope decisions\n\nExamples of unacceptable behavior:\n\n- Harassment, insults, or intimidation\n- Doxxing or sharing private information\n- Spam, trolling, or deliberately disruptive behavior\n- Repeatedly pressuring maintainers after a decision has been made\n\n## Enforcement\n\nProject maintainers may remove comments, close discussions, or block participation when behavior is harmful to the project or its contributors.\n\n## Reporting\n\nFor conduct issues, contact the maintainer privately through the security contact path in [SECURITY.md](./SECURITY.md) or through GitHub private contact if available.\n\n## Scope\n\nThis policy applies to repository discussions, issues, pull requests, and project-related community spaces.\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to Milo\n\nThanks for contributing. Keep changes small, reviewable, and easy to test.\n\n## Before You Start\n\n- Read [README.md](./README.md) for setup instructions.\n- Do not commit real secrets, tokens, or API keys.\n- Prefer opening an issue before large feature work or schema changes.\n\n## Development Setup\n\n1. Clone the repository.\n2. Create and activate a virtual environment.\n3. Install dependencies:\n\n```bash\npython3 -m venv .venv\nsource .venv/bin/activate\npip install -r requirements.txt\n```\n\n4. Configure secrets with `.env` or a local `config.json`.\n5. Start the bot:\n\n```bash\npython3 main.py\n```\n\nMilo targets Python 3.9+.\n\n## Project Expectations\n\n- Target Python 3.10+ when possible, while preserving current runtime compatibility.\n- Keep commands responsive. Avoid blocking I/O in command handlers.\n- Treat database updates as stateful operations. For economy and farming changes, avoid race-prone read/modify/write patterns.\n- Server-specific features must stay isolated per guild unless the behavior is explicitly global.\n- Any feature that touches secrets, external APIs, or moderation behavior should include a short note in the PR description explaining the risk.\n\n## Code Style\n\n- Follow the existing module layout under `cogs/`.\n- Use clear names and keep functions focused.\n- Add comments only when the code is not obvious from the implementation.\n- Reuse shared config and HTTP session handling instead of creating new global clients per cog.\n\n## Pull Requests\n\nOpen a pull request with:\n\n- A short summary of the problem\n- The approach you took\n- Any database or config changes\n- Manual test steps\n- Screenshots or command examples if user-facing behavior changed\n\nGood PRs are narrow. Avoid mixing refactors, feature work, and formatting-only changes in one branch.\n\n## Testing\n\nThere is no full automated test suite yet, so every PR should include manual verification.\n\nAt minimum:\n\n- Run a syntax check:\n\n```bash\npython3 -m compileall .\n```\n\n- Exercise the changed slash commands in a test server.\n- Verify that no secrets were added to tracked files.\n\n## Security\n\nDo not report security issues in public issues or pull requests. Follow [SECURITY.md](./SECURITY.md).\n"
  },
  {
    "path": "LICENSE",
    "content": "Copyright 2026 Sentinel Team and contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# Milo\n\nMilo is an open-source Discord bot for community operations, support, and lightweight moderation, built with `discord.py`, `aiosqlite`, and `aiohttp`.\n\nIt combines:\n\n- AI-assisted community help with optional web search\n- Moderation, announcements, reminders, and server utility workflows\n- Per-server progression systems and engagement features\n- Self-hosted infrastructure for small online communities\n\n## Why This Project Exists\n\nMilo is intended to be a practical bot for real online communities, not a one-command demo or a closed hosted service. The project focuses on tools that help small servers stay active, organized, and easier to support:\n\n- AI-assisted help for common community questions\n- reminders, announcements, and lightweight operations workflows\n- simple moderation and safety helpers\n- self-hosted engagement features that communities can adapt to their own needs\n\n## Project Model\n\n- License: MIT\n- Runtime target: Python 3.9+\n- Storage: SQLite\n- Secrets: environment variables first, then local `config.json`\n- Distribution: free and open source\n- Maintenance model: community-maintained and intended for self-hosted, non-closed deployments\n\n## Quick Start\n\nUse the installer script if you want the fastest local setup:\n\n### Linux / macOS\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/msgaxzzz/Milo-discord-fun-bot/main/install.sh -o install.sh\nbash install.sh\n```\n\n### Windows\n\n```powershell\npowershell -ExecutionPolicy Bypass -Command \"iwr https://raw.githubusercontent.com/msgaxzzz/Milo-discord-fun-bot/main/install.bat -OutFile install.bat\"\n.\\install.bat\n```\n\nThe installer creates a local `.venv`, installs dependencies, and generates a local `config.json`. On Windows it can fall back to downloading the repository zip if `git` is not installed.\n\nIf you prefer a manual setup, use:\n\n```bash\ngit clone https://github.com/msgaxzzz/Milo-discord-fun-bot.git\ncd Milo-discord-fun-bot\npython3 -m venv .venv\nsource .venv/bin/activate\npip install -r requirements.txt\ncp .env.example .env\npython3 main.py\n```\n\nFill in `.env` before starting the bot.\n\n## Who It Is For\n\n- open-source project servers\n- small online communities that need self-hosted tooling\n- learning groups, support communities, and volunteer-run servers\n- maintainers who want a compact `discord.py` codebase to extend\n\n## What Milo Includes\n\n- AI chat with configurable model allowlists and optional Google Custom Search\n- Chat safety controls for cooldowns, channel rules, role allowlists, and daily usage caps\n- Utility commands for persisted reminders, recurring reminders, AFK management, help, and server info\n- Community tooling for welcome messages, leave messages, scheduled announcements, and mod logs\n- Moderation tooling for warnings, invite/link filters, bad word filters, and channel whitelists\n- Economy commands with per-guild balances and leaderboards\n- Admin tools for managing server economy balances\n- Farming progression tied to the server economy\n- Games like `/guess`, `/tictactoe`, `/roll`, and `/rps`\n- Fun and media commands for polls, memes, avatars, GIF interactions, and image generation\n\n## Important Behavior\n\n- Economy and farming data are isolated per guild\n- AI chat works in servers and DMs, but server configuration commands are guild-only\n- Guild chat history is isolated per user instead of being shared by the whole channel\n- Reminders are persisted in SQLite, survive restarts, and can be recurring\n- AFK status is stored per guild and cleared on your next message in that server\n- Real secrets should never be committed to git\n\n## Configuration\n\nMilo loads config in this order:\n\n1. Environment variables from the current shell or `.env`\n2. Local `config.json`\n\nRequired:\n\n- `DISCORD_TOKEN`\n\nOptional:\n\n- `OPENAI_API_KEY`\n- `OPENAI_API_BASE`\n- `ALLOW_USER_KEYS`\n- `DEFAULT_CHAT_MODEL`\n- `ALLOWED_CHAT_MODELS`\n- `GOOGLE_API_KEY`\n- `GOOGLE_CSE_ID`\n\nSee:\n\n- [Configuration Guide](./docs/configuration.md)\n- [.env.example](./.env.example)\n\n## Documentation\n\n- [Command Reference](./docs/commands.md)\n- [Configuration Guide](./docs/configuration.md)\n- [Deployment Guide](./docs/deployment.md)\n- [Operations Notes](./docs/operations.md)\n- [FAQ](./docs/faq.md)\n- [Contributing](./CONTRIBUTING.md)\n- [Code of Conduct](./CODE_OF_CONDUCT.md)\n- [Security Policy](./SECURITY.md)\n- [Support](./SUPPORT.md)\n\n## Contributors\n\nMilo is community-maintained. See the full contributor list on GitHub:\n\n- [Contributors Graph](https://github.com/msgaxzzz/Milo-discord-fun-bot/graphs/contributors)\n- Sascha Buehrle ([`@saschabuehrle`](https://github.com/saschabuehrle)) has submitted community fixes for interaction message formatting and poll permission handling through pull requests [#39](https://github.com/msgaxzzz/Milo-discord-fun-bot/pull/39) and [#40](https://github.com/msgaxzzz/Milo-discord-fun-bot/pull/40).\n\n## Installation Scripts\n\nThe repository includes:\n\n- [install.sh](./install.sh) for Linux/macOS-style environments\n- [install.bat](./install.bat) for Windows\n\nBoth installers are intended for local setup and will generate a local `config.json`.\n\n## Manual Setup\n\n### Linux / macOS\n\n```bash\npython3 -m venv .venv\nsource .venv/bin/activate\npip install --upgrade pip\npip install -r requirements.txt\ncp .env.example .env\npython3 main.py\n```\n\n### Windows\n\n```bat\npy -3.9 -m venv .venv\n.venv\\Scripts\\activate\npython -m pip install --upgrade pip\npython -m pip install -r requirements.txt\ncopy .env.example .env\npython main.py\n```\n\n## Deployment\n\nFor a small self-hosted setup, any machine that can:\n\n- run Python 3.9+\n- keep a long-lived process online\n- write to local disk\n- access Discord and optional external APIs\n\nis enough.\n\nCommon options:\n\n- a VPS\n- a home server\n- a cloud VM\n- a container host\n\nSee [Deployment Guide](./docs/deployment.md) for process management and environment notes.\n\n## FAQ\n\nCommon questions:\n\n- Does Milo support DMs for AI chat? Yes.\n- Is the economy global across all servers? No, it is isolated per guild.\n- Do reminders survive restarts? Yes.\n- Can I schedule recurring reminders and server announcements? Yes.\n- Do I need OpenAI credentials to run the bot? Only for AI chat features.\n\nSee the full [FAQ](./docs/faq.md).\n\n## Development Notes\n\n- Slash commands are loaded from modules in `cogs/`\n- Shared HTTP access is managed centrally by the bot process\n- SQLite schema is created and migrated at startup\n- The project currently relies on manual verification rather than a full automated test suite\n\n## Security\n\n- Never commit real Discord, OpenAI, or Google API credentials\n- Use `.env` or a gitignored local `config.json`\n- Report vulnerabilities privately according to [SECURITY.md](./SECURITY.md)\n\n## Project Structure\n\n```text\nmain.py\nconfig_loader.py\ncogs/\ndocs/\n```\n\n## Roadmap\n\nNear-term improvements that would strengthen the project:\n\n- automated tests for economy, farming, reminder, and automod flows\n- richer reporting around reminder delivery failures and scheduled announcement failures\n- structured logging and better runtime error reporting\n- richer deployment examples\n- command reference generation from source metadata\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security Policy\n\n## Supported Versions\n\nOnly the latest code on `main` should be assumed to receive security fixes.\n\n## Reporting a Vulnerability\n\nDo not open a public issue for security problems.\n\nReport vulnerabilities privately with:\n\n- A clear description of the issue\n- Steps to reproduce\n- Impact\n- Any suggested fix or mitigation\n\n## What to Report\n\nExamples:\n\n- Token or API key exposure\n- Permission bypass\n- Unsafe handling of user-provided API keys\n- Remote code execution or command injection\n- Database corruption or multi-guild data leakage\n- Abuse paths in moderation features\n\n## Response Goals\n\nThe maintainer should aim to:\n\n- Acknowledge valid reports promptly\n- Reproduce and assess impact\n- Patch privately when reasonable\n- Credit the reporter if they want attribution\n\n## Secret Handling\n\n- Never commit real `DISCORD_TOKEN`, OpenAI keys, or Google API keys\n- Use `.env` or a gitignored local `config.json`\n- Rotate any credential immediately if it is exposed in git history, logs, or screenshots\n"
  },
  {
    "path": "SUPPORT.md",
    "content": "# Support\n\n## Getting Help\n\nUse the repository issue tracker for:\n\n- Reproducible bugs\n- Feature requests\n- Documentation gaps\n\nBefore opening an issue:\n\n- Read [README.md](./README.md)\n- Read [CONTRIBUTING.md](./CONTRIBUTING.md)\n- Check whether the issue is already open\n\n## What To Include\n\nGood support requests include:\n\n- What you were trying to do\n- The exact command or workflow involved\n- Your runtime environment\n- Logs or screenshots with secrets removed\n\n## Security Issues\n\nDo not request support for vulnerabilities in public issues. Use [SECURITY.md](./SECURITY.md) instead.\n\n## Scope\n\nThis repository is maintained on a best-effort basis. Setup help, bug reports, and targeted feature requests are in scope. Custom hosting, private deployment support, and urgent SLA-style support are not guaranteed.\n"
  },
  {
    "path": "cogs/chat.py",
    "content": "import json\nimport logging\nfrom collections import defaultdict\nfrom datetime import timedelta\nfrom typing import Any, Dict, List, Optional, Tuple\n\nimport discord\nfrom discord import app_commands\nfrom discord.ext import commands, tasks\n\n\nlogger = logging.getLogger(__name__)\n\nDEFAULT_API_BASE = \"https://api.openai.com/v1\"\nDEFAULT_MODEL = \"gpt-3.5-turbo\"\nMAX_CONVERSATION_HISTORY = 10\nMAX_PERSONA_LENGTH = 500\nMAX_EMBED_FIELD_LENGTH = 1024\nMAX_EMBED_DESCRIPTION_LENGTH = 4096\nCONVERSATION_TTL = timedelta(hours=6)\nCOOLDOWN_RETENTION = timedelta(days=1)\nDEFAULT_PERSONA = (\n    \"You are Milo, a friendly and helpful Discord bot. \"\n    \"You can access real-time information using the 'google_search' tool for current events or specific data. \"\n    \"Keep your answers concise and engaging.\"\n)\n\n\nclass Chat(commands.Cog):\n    def __init__(self, bot: commands.Bot):\n        self.bot = bot\n        self.conversations: Dict[Tuple[Any, ...], List[Dict[str, Any]]] = defaultdict(list)\n        self.chat_cooldowns: Dict[Tuple[int, int], discord.utils.utcnow] = {}\n        self.conversation_last_used: Dict[Tuple[Any, ...], discord.utils.utcnow] = {}\n        self.load_config()\n        self.bot.loop.create_task(self.setup_database())\n        self._prune_cooldowns.start()\n\n    def cog_unload(self):\n        self._prune_cooldowns.cancel()\n\n    def load_config(self):\n        config = getattr(self.bot, \"config\", {})\n        self.default_api_key = config.get(\"OPENAI_API_KEY\")\n        self.api_base = config.get(\"OPENAI_API_BASE\", DEFAULT_API_BASE)\n        self.allow_user_keys = config.get(\"ALLOW_USER_KEYS\", True)\n        self.default_model = config.get(\"DEFAULT_CHAT_MODEL\", DEFAULT_MODEL)\n        self.allowed_models = config.get(\"ALLOWED_CHAT_MODELS\", [DEFAULT_MODEL])\n        self.google_api_key = config.get(\"GOOGLE_API_KEY\")\n        self.google_cse_id = config.get(\"GOOGLE_CSE_ID\")\n        self.enable_web_search = bool(self.google_api_key and self.google_cse_id)\n\n    @tasks.loop(minutes=10)\n    async def _prune_cooldowns(self):\n        now = discord.utils.utcnow()\n        expired = [key for key, ts in self.chat_cooldowns.items() if (now - ts) > COOLDOWN_RETENTION]\n        for key in expired:\n            del self.chat_cooldowns[key]\n\n    async def setup_database(self):\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\n                \"\"\"\n                CREATE TABLE IF NOT EXISTS guild_configs (\n                    guild_id INTEGER PRIMARY KEY,\n                    openai_key TEXT,\n                    persona TEXT\n                )\n                \"\"\"\n            )\n            await cursor.execute(\n                \"\"\"\n                CREATE TABLE IF NOT EXISTS chat_policies (\n                    guild_id INTEGER PRIMARY KEY,\n                    enabled INTEGER NOT NULL DEFAULT 1,\n                    cooldown_seconds INTEGER NOT NULL DEFAULT 8,\n                    daily_usage_limit INTEGER,\n                    allowed_channel_ids TEXT,\n                    blocked_channel_ids TEXT,\n                    allowed_role_ids TEXT\n                )\n                \"\"\"\n            )\n            await cursor.execute(\n                \"\"\"\n                CREATE TABLE IF NOT EXISTS chat_usage (\n                    guild_id INTEGER NOT NULL,\n                    usage_date TEXT NOT NULL,\n                    usage_count INTEGER NOT NULL DEFAULT 0,\n                    PRIMARY KEY (guild_id, usage_date)\n                )\n                \"\"\"\n            )\n\n            columns = await self._table_columns(cursor, \"guild_configs\")\n            if \"persona\" not in columns:\n                await cursor.execute(\"ALTER TABLE guild_configs ADD COLUMN persona TEXT\")\n        await self.bot.db.commit()\n\n    async def _table_columns(self, cursor, table_name: str) -> List[str]:\n        await cursor.execute(f\"PRAGMA table_info({table_name})\")\n        return [row[1] for row in await cursor.fetchall()]\n\n    @property\n    def session(self):\n        return self.bot.http_session\n\n    def _context_key(self, interaction: discord.Interaction) -> Tuple[Any, ...]:\n        if interaction.guild_id:\n            return (\"guild\", interaction.guild_id, interaction.channel_id, interaction.user.id)\n        return (\"dm\", interaction.user.id)\n\n    def _serialize_ids(self, ids: List[int]) -> Optional[str]:\n        cleaned = sorted({int(item) for item in ids})\n        return json.dumps(cleaned) if cleaned else None\n\n    def _deserialize_ids(self, raw: Optional[str]) -> List[int]:\n        if not raw:\n            return []\n        try:\n            values = json.loads(raw)\n        except json.JSONDecodeError:\n            return []\n        return [int(item) for item in values if str(item).isdigit()]\n\n    async def get_guild_config(self, guild_id: Optional[int]):\n        if not guild_id:\n            return None, None\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\"SELECT openai_key, persona FROM guild_configs WHERE guild_id = ?\", (guild_id,))\n            return await cursor.fetchone()\n\n    async def set_guild_key(self, guild_id: int, key: Optional[str] = None):\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\"INSERT OR IGNORE INTO guild_configs (guild_id) VALUES (?)\", (guild_id,))\n            await cursor.execute(\"UPDATE guild_configs SET openai_key = ? WHERE guild_id = ?\", (key, guild_id))\n        await self.bot.db.commit()\n\n    async def set_guild_persona(self, guild_id: int, persona: Optional[str] = None):\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\"INSERT OR IGNORE INTO guild_configs (guild_id) VALUES (?)\", (guild_id,))\n            await cursor.execute(\"UPDATE guild_configs SET persona = ? WHERE guild_id = ?\", (persona, guild_id))\n        await self.bot.db.commit()\n\n    async def get_policy(self, guild_id: Optional[int]) -> Dict[str, Any]:\n        default_policy = {\n            \"enabled\": True,\n            \"cooldown_seconds\": 8,\n            \"daily_usage_limit\": None,\n            \"allowed_channel_ids\": [],\n            \"blocked_channel_ids\": [],\n            \"allowed_role_ids\": [],\n        }\n        if not guild_id:\n            return default_policy\n\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\n                \"\"\"\n                SELECT enabled, cooldown_seconds, daily_usage_limit, allowed_channel_ids,\n                       blocked_channel_ids, allowed_role_ids\n                FROM chat_policies\n                WHERE guild_id = ?\n                \"\"\",\n                (guild_id,),\n            )\n            row = await cursor.fetchone()\n\n        if row is None:\n            return default_policy\n\n        enabled, cooldown_seconds, daily_usage_limit, allowed_channel_ids, blocked_channel_ids, allowed_role_ids = row\n        return {\n            \"enabled\": bool(enabled),\n            \"cooldown_seconds\": (\n                default_policy[\"cooldown_seconds\"] if cooldown_seconds is None else int(cooldown_seconds)\n            ),\n            \"daily_usage_limit\": daily_usage_limit,\n            \"allowed_channel_ids\": self._deserialize_ids(allowed_channel_ids),\n            \"blocked_channel_ids\": self._deserialize_ids(blocked_channel_ids),\n            \"allowed_role_ids\": self._deserialize_ids(allowed_role_ids),\n        }\n\n    async def update_policy(self, guild_id: int, **fields):\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\"INSERT OR IGNORE INTO chat_policies (guild_id) VALUES (?)\", (guild_id,))\n            for field, value in fields.items():\n                await cursor.execute(f\"UPDATE chat_policies SET {field} = ? WHERE guild_id = ?\", (value, guild_id))\n        await self.bot.db.commit()\n\n    async def mutate_id_list(self, guild_id: int, field: str, value: int, add: bool):\n        policy = await self.get_policy(guild_id)\n        current = set(policy[field])\n        if add:\n            current.add(value)\n        else:\n            current.discard(value)\n        await self.update_policy(guild_id, **{field: self._serialize_ids(list(current))})\n        return sorted(current)\n\n    async def get_usage_count(self, guild_id: int) -> int:\n        usage_date = discord.utils.utcnow().date().isoformat()\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\n                \"SELECT usage_count FROM chat_usage WHERE guild_id = ? AND usage_date = ?\",\n                (guild_id, usage_date),\n            )\n            row = await cursor.fetchone()\n        return row[0] if row else 0\n\n    async def increment_usage(self, guild_id: int):\n        usage_date = discord.utils.utcnow().date().isoformat()\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\n                \"\"\"\n                INSERT INTO chat_usage (guild_id, usage_date, usage_count)\n                VALUES (?, ?, 1)\n                ON CONFLICT(guild_id, usage_date)\n                DO UPDATE SET usage_count = usage_count + 1\n                \"\"\",\n                (guild_id, usage_date),\n            )\n        await self.bot.db.commit()\n\n    async def validate_api_key(self, api_key: str) -> Tuple[bool, str]:\n        headers = {\"Authorization\": f\"Bearer {api_key}\"}\n        try:\n            async with self.session.get(f\"{self.api_base}/models\", headers=headers) as response:\n                if response.status == 200:\n                    return True, \"API key is valid.\"\n\n                try:\n                    error_data = await response.json()\n                    error_message = error_data.get(\"error\", {}).get(\"message\", \"Unknown API error.\")\n                except Exception:\n                    error_message = \"Unknown API error.\"\n                return False, f\"{response.status}: {error_message}\"\n        except Exception as error:\n            return False, str(error)\n\n    async def enforce_policy(self, interaction: discord.Interaction, policy: Dict[str, Any]) -> Optional[str]:\n        if not interaction.guild_id:\n            return None\n        if not policy[\"enabled\"]:\n            return \"AI chat is disabled in this server.\"\n        if interaction.channel_id in policy[\"blocked_channel_ids\"]:\n            return \"AI chat is disabled in this channel.\"\n        if policy[\"allowed_channel_ids\"] and interaction.channel_id not in policy[\"allowed_channel_ids\"]:\n            return \"AI chat is only allowed in specific channels configured by the server admins.\"\n        if policy[\"allowed_role_ids\"]:\n            member = interaction.user if isinstance(interaction.user, discord.Member) else None\n            if member is None:\n                return \"AI chat is restricted to specific roles in this server.\"\n            member_role_ids = {role.id for role in member.roles}\n            if not member_role_ids.intersection(policy[\"allowed_role_ids\"]):\n                return \"You do not have one of the roles required to use AI chat here.\"\n\n        cooldown_seconds = max(int(policy[\"cooldown_seconds\"]), 0)\n        if cooldown_seconds > 0:\n            cooldown_key = (interaction.guild_id, interaction.user.id)\n            now = discord.utils.utcnow()\n            last_used = self.chat_cooldowns.get(cooldown_key)\n            if last_used and (now - last_used).total_seconds() < cooldown_seconds:\n                remaining = cooldown_seconds - int((now - last_used).total_seconds())\n                return f\"You're on cooldown for this server. Try again in {remaining}s.\"\n\n        usage_limit = policy[\"daily_usage_limit\"]\n        if usage_limit:\n            usage_count = await self.get_usage_count(interaction.guild_id)\n            if usage_count >= usage_limit:\n                return \"This server has reached its daily AI chat usage cap.\"\n\n        return None\n\n    def define_tools(self):\n        return [\n            {\n                \"type\": \"function\",\n                \"function\": {\n                    \"name\": \"google_search\",\n                    \"description\": \"Get real-time information from the web for recent events or specific data.\",\n                    \"parameters\": {\n                        \"type\": \"object\",\n                        \"properties\": {\"query\": {\"type\": \"string\", \"description\": \"The search query.\"}},\n                        \"required\": [\"query\"],\n                    },\n                },\n            }\n        ]\n\n    async def execute_google_search(self, query: str):\n        url = \"https://www.googleapis.com/customsearch/v1\"\n        params = {\"key\": self.google_api_key, \"cx\": self.google_cse_id, \"q\": query, \"num\": 5}\n        try:\n            async with self.session.get(url, params=params) as response:\n                if response.status == 200:\n                    data = await response.json()\n                    items = data.get(\"items\", [])\n                    snippets = [item.get(\"snippet\", \"\") for item in items]\n                    return json.dumps({\"results\": snippets}) if snippets else json.dumps({\"error\": \"No results found.\"})\n\n                error_data = await response.json()\n                error_message = error_data.get(\"error\", {}).get(\"message\", \"Unknown error.\")\n                return json.dumps({\"error\": f\"Failed to fetch search results. Status: {response.status} - {error_message}\"})\n        except Exception as error:\n            return json.dumps({\"error\": f\"An error occurred during search: {error}\"})\n\n    async def model_autocomplete(self, interaction: discord.Interaction, current: str):\n        current_lower = current.lower()\n        return [\n            app_commands.Choice(name=model, value=model)\n            for model in self.allowed_models\n            if current_lower in model.lower()\n        ]\n\n    def _channel_labels(self, guild: discord.Guild, ids: List[int]) -> str:\n        if not ids:\n            return \"Not set\"\n        labels = []\n        for channel_id in ids:\n            channel = guild.get_channel(channel_id)\n            labels.append(channel.mention if channel else f\"`{channel_id}`\")\n        return \", \".join(labels)\n\n    def _role_labels(self, guild: discord.Guild, ids: List[int]) -> str:\n        if not ids:\n            return \"Not set\"\n        labels = []\n        for role_id in ids:\n            role = guild.get_role(role_id)\n            labels.append(role.mention if role else f\"`{role_id}`\")\n        return \", \".join(labels)\n\n    def _truncate_field_value(self, value: str, limit: int = MAX_EMBED_FIELD_LENGTH) -> str:\n        if len(value) <= limit:\n            return value\n        return f\"{value[: limit - 1].rstrip()}…\"\n\n    def _trim_history(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:\n        if len(messages) <= MAX_CONVERSATION_HISTORY:\n            return messages\n        return messages[0:1] + messages[-(MAX_CONVERSATION_HISTORY - 1) :]\n\n    def _prune_runtime_state(self) -> None:\n        now = discord.utils.utcnow()\n        expired_conversations = [\n            key for key, last_used in self.conversation_last_used.items() if now - last_used > CONVERSATION_TTL\n        ]\n        for key in expired_conversations:\n            self.conversation_last_used.pop(key, None)\n            self.conversations.pop(key, None)\n\n        expired_cooldowns = [\n            key for key, last_used in self.chat_cooldowns.items() if now - last_used > COOLDOWN_RETENTION\n        ]\n        for key in expired_cooldowns:\n            self.chat_cooldowns.pop(key, None)\n\n    def _set_conversation_state(self, context_key: Tuple[Any, ...], messages: List[Dict[str, Any]]) -> None:\n        if messages:\n            self.conversations[context_key] = messages\n            self.conversation_last_used[context_key] = discord.utils.utcnow()\n            return\n        self.conversations.pop(context_key, None)\n        self.conversation_last_used.pop(context_key, None)\n\n    chat_config = app_commands.Group(\n        name=\"chat-config\",\n        description=\"Configure the AI chat settings for a server.\",\n        guild_only=True,\n    )\n\n    @chat_config.command(name=\"set-key\", description=\"Set a custom OpenAI API key for this server.\")\n    @app_commands.describe(key=\"Your OpenAI API key. Use 'reset' to remove.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    async def set_key(self, interaction: discord.Interaction, key: str):\n        if not self.allow_user_keys:\n            await interaction.response.send_message(\"The bot owner has disabled custom API keys.\", ephemeral=True)\n            return\n        if key.lower() == \"reset\":\n            await self.set_guild_key(interaction.guild.id)\n            await interaction.response.send_message(\"Server API key removed.\", ephemeral=True)\n            return\n        await interaction.response.defer(ephemeral=True)\n        valid, detail = await self.validate_api_key(key)\n        if not valid:\n            await interaction.followup.send(f\"API key validation failed: {detail}\", ephemeral=True)\n            return\n        await self.set_guild_key(interaction.guild.id, key)\n        await interaction.followup.send(\"Server API key validated and saved.\", ephemeral=True)\n\n    @chat_config.command(name=\"set-persona\", description=\"Set a custom personality for the AI.\")\n    @app_commands.describe(persona=\"A description of the AI's personality. Use 'reset' to remove.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    async def set_persona(self, interaction: discord.Interaction, persona: str):\n        if len(persona) > MAX_PERSONA_LENGTH:\n            await interaction.response.send_message(\"Persona is too long (max 500 chars).\", ephemeral=True)\n            return\n        if persona.lower() == \"reset\":\n            await self.set_guild_persona(interaction.guild.id)\n            await interaction.response.send_message(\"AI persona reset to default.\", ephemeral=True)\n            return\n        await self.set_guild_persona(interaction.guild.id, persona)\n        await interaction.response.send_message(\"AI persona updated.\", ephemeral=True)\n\n    @chat_config.command(name=\"set-enabled\", description=\"Enable or disable AI chat in this server.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    async def set_enabled(self, interaction: discord.Interaction, enabled: bool):\n        await self.update_policy(interaction.guild.id, enabled=int(enabled))\n        state = \"enabled\" if enabled else \"disabled\"\n        await interaction.response.send_message(f\"AI chat is now {state} for this server.\", ephemeral=True)\n\n    @chat_config.command(name=\"set-cooldown\", description=\"Set the per-user chat cooldown for this server.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    @app_commands.describe(seconds=\"Cooldown in seconds. Use 0 to disable.\")\n    async def set_cooldown(self, interaction: discord.Interaction, seconds: app_commands.Range[int, 0, 600]):\n        await self.update_policy(interaction.guild.id, cooldown_seconds=int(seconds))\n        await interaction.response.send_message(f\"Chat cooldown set to {seconds}s.\", ephemeral=True)\n\n    @chat_config.command(name=\"set-usage-cap\", description=\"Set the per-day chat usage cap for this server.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    @app_commands.describe(limit=\"Maximum successful chat requests per day. Use 0 to remove the cap.\")\n    async def set_usage_cap(self, interaction: discord.Interaction, limit: app_commands.Range[int, 0, 5000]):\n        value = None if limit == 0 else int(limit)\n        await self.update_policy(interaction.guild.id, daily_usage_limit=value)\n        label = \"removed\" if value is None else str(value)\n        await interaction.response.send_message(f\"Daily usage cap set to {label}.\", ephemeral=True)\n\n    @chat_config.command(name=\"allow-channel\", description=\"Allow AI chat only in a specific channel.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    async def allow_channel(self, interaction: discord.Interaction, channel: discord.TextChannel):\n        channels = await self.mutate_id_list(interaction.guild.id, \"allowed_channel_ids\", channel.id, add=True)\n        await interaction.response.send_message(\n            f\"Allowed channels updated. {len(channels)} channel(s) are now allowlisted.\",\n            ephemeral=True,\n        )\n\n    @chat_config.command(name=\"block-channel\", description=\"Block AI chat in a specific channel.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    async def block_channel(self, interaction: discord.Interaction, channel: discord.TextChannel):\n        channels = await self.mutate_id_list(interaction.guild.id, \"blocked_channel_ids\", channel.id, add=True)\n        await interaction.response.send_message(\n            f\"Blocked channels updated. {len(channels)} channel(s) are now blocked.\",\n            ephemeral=True,\n        )\n\n    @chat_config.command(name=\"clear-channel-rules\", description=\"Clear channel allow/block rules for AI chat.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    async def clear_channel_rules(self, interaction: discord.Interaction):\n        await self.update_policy(interaction.guild.id, allowed_channel_ids=None, blocked_channel_ids=None)\n        await interaction.response.send_message(\"Channel rules cleared.\", ephemeral=True)\n\n    @chat_config.command(name=\"allow-role\", description=\"Restrict AI chat to members with a specific role.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    async def allow_role(self, interaction: discord.Interaction, role: discord.Role):\n        roles = await self.mutate_id_list(interaction.guild.id, \"allowed_role_ids\", role.id, add=True)\n        await interaction.response.send_message(\n            f\"Allowed roles updated. {len(roles)} role(s) are now allowlisted.\",\n            ephemeral=True,\n        )\n\n    @chat_config.command(name=\"remove-role\", description=\"Remove a role from the AI chat allowlist.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    async def remove_role(self, interaction: discord.Interaction, role: discord.Role):\n        roles = await self.mutate_id_list(interaction.guild.id, \"allowed_role_ids\", role.id, add=False)\n        await interaction.response.send_message(\n            f\"Allowed roles updated. {len(roles)} role(s) remain allowlisted.\",\n            ephemeral=True,\n        )\n\n    @chat_config.command(name=\"clear-role-rules\", description=\"Remove all role-based AI chat restrictions.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    async def clear_role_rules(self, interaction: discord.Interaction):\n        await self.update_policy(interaction.guild.id, allowed_role_ids=None)\n        await interaction.response.send_message(\"Role restrictions cleared.\", ephemeral=True)\n\n    @chat_config.command(name=\"view\", description=\"View the current chat configuration.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    async def view_config(self, interaction: discord.Interaction):\n        self._prune_runtime_state()\n        guild_config = await self.get_guild_config(interaction.guild.id) or (None, None)\n        guild_key, guild_persona = guild_config\n        policy = await self.get_policy(interaction.guild.id)\n        usage_count = await self.get_usage_count(interaction.guild.id)\n\n        embed = discord.Embed(title=f\"Chat Configuration for {interaction.guild.name}\", color=discord.Color.blue())\n        key_status = \"Not configured\"\n        if guild_key:\n            key_status = f\"`{guild_key[:5]}...{guild_key[-4:]}` (custom)\"\n        elif self.default_api_key:\n            key_status = \"Using bot default key\"\n\n        embed.add_field(name=\"Server API Key\", value=key_status, inline=False)\n        embed.add_field(\n            name=\"AI Persona\",\n            value=self._truncate_field_value(guild_persona or DEFAULT_PERSONA),\n            inline=False,\n        )\n        embed.add_field(name=\"Web Search\", value=\"Enabled\" if self.enable_web_search else \"Disabled\", inline=False)\n        embed.add_field(name=\"API Base URL\", value=self._truncate_field_value(f\"`{self.api_base}`\"), inline=False)\n        embed.add_field(\n            name=\"Allowed Models\",\n            value=self._truncate_field_value(\", \".join(f\"`{model}`\" for model in self.allowed_models)),\n            inline=False,\n        )\n        embed.add_field(name=\"Chat Enabled\", value=\"Yes\" if policy[\"enabled\"] else \"No\", inline=True)\n        embed.add_field(name=\"Cooldown\", value=f\"{policy['cooldown_seconds']}s\", inline=True)\n        embed.add_field(\n            name=\"Daily Usage Cap\",\n            value=\"Not set\" if policy[\"daily_usage_limit\"] is None else f\"{usage_count}/{policy['daily_usage_limit']}\",\n            inline=True,\n        )\n        embed.add_field(\n            name=\"Allowed Channels\",\n            value=self._truncate_field_value(self._channel_labels(interaction.guild, policy[\"allowed_channel_ids\"])),\n            inline=False,\n        )\n        embed.add_field(\n            name=\"Blocked Channels\",\n            value=self._truncate_field_value(self._channel_labels(interaction.guild, policy[\"blocked_channel_ids\"])),\n            inline=False,\n        )\n        embed.add_field(\n            name=\"Allowed Roles\",\n            value=self._truncate_field_value(self._role_labels(interaction.guild, policy[\"allowed_role_ids\"])),\n            inline=False,\n        )\n        await interaction.response.send_message(embed=embed, ephemeral=True)\n\n    @chat_config.command(name=\"test\", description=\"Validate the effective API key for this server.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    async def test_config(self, interaction: discord.Interaction):\n        guild_config = await self.get_guild_config(interaction.guild.id) or (None, None)\n        guild_key, _ = guild_config\n        api_key = guild_key or self.default_api_key\n        if not api_key:\n            await interaction.response.send_message(\"No API key is configured to test.\", ephemeral=True)\n            return\n\n        await interaction.response.defer(ephemeral=True)\n        valid, detail = await self.validate_api_key(api_key)\n        if valid:\n            await interaction.followup.send(\"API key validation succeeded.\", ephemeral=True)\n        else:\n            await interaction.followup.send(f\"API key validation failed: {detail}\", ephemeral=True)\n\n    @chat_config.command(name=\"models\", description=\"Show the configured default and allowed chat models.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    async def list_models(self, interaction: discord.Interaction):\n        embed = discord.Embed(title=\"Chat Models\", color=discord.Color.blurple())\n        embed.add_field(name=\"Default Model\", value=f\"`{self.default_model}`\", inline=False)\n        embed.add_field(\n            name=\"Allowed Models\",\n            value=self._truncate_field_value(\"\\n\".join(f\"`{model}`\" for model in self.allowed_models)),\n            inline=False,\n        )\n        await interaction.response.send_message(embed=embed, ephemeral=True)\n\n    @app_commands.command(name=\"chat\", description=\"Chat with the AI, with optional live web search.\")\n    @app_commands.describe(\n        prompt=\"What to talk about?\",\n        model=\"Choose a specific AI model.\",\n        search_web=\"Set to true to allow the AI to search the web for current info.\",\n    )\n    @app_commands.autocomplete(model=model_autocomplete)\n    async def chat(self, interaction: discord.Interaction, prompt: str, model: Optional[str] = None, search_web: bool = False):\n        self._prune_runtime_state()\n        guild_id = interaction.guild.id if interaction.guild else None\n        chosen_model = model or self.default_model\n        policy = await self.get_policy(guild_id)\n\n        if chosen_model not in self.allowed_models:\n            await interaction.response.send_message(\"That model is not allowed for this bot.\", ephemeral=True)\n            return\n        if search_web and not self.enable_web_search:\n            await interaction.response.send_message(\n                \"Web search is not configured by the bot owner.\",\n                ephemeral=True,\n            )\n            return\n\n        if interaction.guild_id:\n            policy_error = await self.enforce_policy(interaction, policy)\n            if policy_error:\n                await interaction.response.send_message(policy_error, ephemeral=True)\n                return\n\n        guild_config = await self.get_guild_config(guild_id) or (None, None)\n        api_key, persona = guild_config\n        api_key = api_key or self.default_api_key\n        if not api_key:\n            await interaction.response.send_message(\n                \"AI chat is not configured. An admin must set an API key.\",\n                ephemeral=True,\n            )\n            return\n\n        await interaction.response.defer()\n        context_key = self._context_key(interaction)\n        existing_messages = list(self.conversations.get(context_key, []))\n        if not existing_messages:\n            existing_messages = [{\"role\": \"system\", \"content\": persona or DEFAULT_PERSONA}]\n\n        original_messages = list(existing_messages)\n        working_messages = list(existing_messages)\n        working_messages.append({\"role\": \"user\", \"content\": prompt})\n        self._set_conversation_state(context_key, working_messages)\n\n        headers = {\"Authorization\": f\"Bearer {api_key}\", \"Content-Type\": \"application/json\"}\n        payload = {\"model\": chosen_model, \"messages\": working_messages}\n        if self.enable_web_search and search_web:\n            payload[\"tools\"] = self.define_tools()\n            payload[\"tool_choice\"] = \"auto\"\n\n        final_answer = None\n        try:\n            async with self.session.post(f\"{self.api_base}/chat/completions\", headers=headers, json=payload) as response:\n                if response.status != 200:\n                    error_data = await response.json()\n                    error_message = error_data.get(\"error\", {}).get(\"message\", \"An unknown API error occurred.\")\n                    self._set_conversation_state(context_key, original_messages)\n                    await interaction.followup.send(f\"API Error: {response.status} - {error_message}\", ephemeral=True)\n                    return\n\n                data = await response.json()\n                ai_message = data[\"choices\"][0][\"message\"]\n\n                if ai_message.get(\"tool_calls\") and self.enable_web_search and search_web:\n                    thinking_message = await interaction.followup.send(\"Searching the web...\", wait=True)\n                    working_messages.append(ai_message)\n                    self._set_conversation_state(context_key, working_messages)\n                    tool_call = ai_message[\"tool_calls\"][0]\n                    function_args = json.loads(tool_call[\"function\"][\"arguments\"])\n                    query = function_args.get(\"query\", \"\")\n                    tool_response = await self.execute_google_search(query)\n\n                    working_messages.append(\n                        {\n                            \"tool_call_id\": tool_call[\"id\"],\n                            \"role\": \"tool\",\n                            \"name\": tool_call[\"function\"][\"name\"],\n                            \"content\": tool_response,\n                        }\n                    )\n                    self._set_conversation_state(context_key, working_messages)\n\n                    final_payload = {\"model\": chosen_model, \"messages\": working_messages}\n                    async with self.session.post(\n                        f\"{self.api_base}/chat/completions\",\n                        headers=headers,\n                        json=final_payload,\n                    ) as final_response:\n                        if final_response.status != 200:\n                            self._set_conversation_state(context_key, original_messages)\n                            await thinking_message.edit(content=\"The model failed after web search. Please try again.\")\n                            return\n                        final_data = await final_response.json()\n                        final_answer = final_data[\"choices\"][0][\"message\"][\"content\"]\n                        await thinking_message.edit(\n                            content=self._truncate_field_value(final_answer, MAX_EMBED_DESCRIPTION_LENGTH)\n                        )\n                else:\n                    final_answer = ai_message.get(\"content\") or \"The model returned an empty response.\"\n                    await interaction.edit_original_response(content=final_answer)\n\n                working_messages.append({\"role\": \"assistant\", \"content\": final_answer})\n                self._set_conversation_state(context_key, self._trim_history(working_messages))\n\n                if interaction.guild_id:\n                    cooldown_key = (interaction.guild_id, interaction.user.id)\n                    self.chat_cooldowns[cooldown_key] = discord.utils.utcnow()\n                    await self.increment_usage(interaction.guild_id)\n\n        except Exception as error:\n            logger.exception(\"Chat processing error: %s\", error)\n            self._set_conversation_state(context_key, original_messages)\n            await interaction.followup.send(\"An unexpected error occurred. Please try again later.\", ephemeral=True)\n\n    @app_commands.command(name=\"chat-reset\", description=\"Reset your conversation history with the AI.\")\n    async def chat_reset(self, interaction: discord.Interaction):\n        self._prune_runtime_state()\n        context_key = self._context_key(interaction)\n        self._set_conversation_state(context_key, [])\n        await interaction.response.send_message(\"Your conversation history has been reset.\", ephemeral=True)\n\n\nasync def setup(bot: commands.Bot):\n    await bot.add_cog(Chat(bot))\n"
  },
  {
    "path": "cogs/community.py",
    "content": "import string\nfrom datetime import timedelta\nfrom typing import Dict, Optional\n\nimport discord\nfrom discord import app_commands\nfrom discord.ext import commands, tasks\n\n\nDEFAULT_WELCOME_MESSAGE = \"Welcome to {guild}, {member.mention}!\"\nDEFAULT_GOODBYE_MESSAGE = \"{member} left {guild}.\"\nSCHEDULE_POLL_SECONDS = 30\nSCHEDULE_BATCH_SIZE = 20\nTEMPLATE_FIELDS = {\"member\", \"member.mention\", \"guild\"}\nMAX_SCHEDULE_DELIVERY_FAILURES = 5\nMAX_ANNOUNCEMENT_LENGTH = 4000\nSCHEDULE_RETRY_BASE_SECONDS = 300\nSCHEDULE_RETRY_MAX_SECONDS = 21600\n\n\ndef parse_duration(value: str) -> Optional[int]:\n    if not value:\n        return None\n    amount = value[:-1]\n    unit = value[-1].lower()\n    if not amount.isdigit() or unit not in {\"m\", \"h\", \"d\"}:\n        return None\n    return int(amount) * {\"m\": 60, \"h\": 3600, \"d\": 86400}[unit]\n\n\nclass Community(commands.Cog):\n    def __init__(self, bot: commands.Bot):\n        self.bot = bot\n        self.schedule_loop.start()\n\n    def cog_unload(self):\n        self.schedule_loop.cancel()\n\n    async def setup_database(self):\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\n                \"\"\"\n                CREATE TABLE IF NOT EXISTS guild_settings (\n                    guild_id INTEGER PRIMARY KEY,\n                    welcome_channel_id INTEGER,\n                    goodbye_channel_id INTEGER,\n                    announcement_channel_id INTEGER,\n                    modlog_channel_id INTEGER,\n                    welcome_message TEXT,\n                    goodbye_message TEXT\n                )\n                \"\"\"\n            )\n            await cursor.execute(\n                \"\"\"\n                CREATE TABLE IF NOT EXISTS scheduled_announcements (\n                    id INTEGER PRIMARY KEY AUTOINCREMENT,\n                    guild_id INTEGER NOT NULL,\n                    channel_id INTEGER NOT NULL,\n                    author_id INTEGER NOT NULL,\n                    message TEXT NOT NULL,\n                    send_at TEXT NOT NULL,\n                    interval_seconds INTEGER\n                )\n                \"\"\"\n            )\n            await cursor.execute(\"PRAGMA table_info(scheduled_announcements)\")\n            announcement_columns = [row[1] for row in await cursor.fetchall()]\n            if \"delivery_failures\" not in announcement_columns:\n                await cursor.execute(\n                    \"ALTER TABLE scheduled_announcements ADD COLUMN delivery_failures INTEGER NOT NULL DEFAULT 0\"\n                )\n            if \"disabled\" not in announcement_columns:\n                await cursor.execute(\n                    \"ALTER TABLE scheduled_announcements ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0\"\n                )\n            if \"last_error\" not in announcement_columns:\n                await cursor.execute(\"ALTER TABLE scheduled_announcements ADD COLUMN last_error TEXT\")\n            await cursor.execute(\n                \"CREATE INDEX IF NOT EXISTS idx_scheduled_announcements_due ON scheduled_announcements(disabled, send_at)\"\n            )\n            await cursor.execute(\n                \"CREATE INDEX IF NOT EXISTS idx_scheduled_announcements_guild_send_at ON scheduled_announcements(guild_id, send_at)\"\n            )\n        await self.bot.db.commit()\n\n    async def get_settings(self, guild_id: int) -> Dict[str, Optional[int]]:\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\n                \"\"\"\n                SELECT guild_id, welcome_channel_id, goodbye_channel_id, announcement_channel_id,\n                       modlog_channel_id, welcome_message, goodbye_message\n                FROM guild_settings\n                WHERE guild_id = ?\n                \"\"\",\n                (guild_id,),\n            )\n            row = await cursor.fetchone()\n\n        if row is None:\n            return {\n                \"guild_id\": guild_id,\n                \"welcome_channel_id\": None,\n                \"goodbye_channel_id\": None,\n                \"announcement_channel_id\": None,\n                \"modlog_channel_id\": None,\n                \"welcome_message\": None,\n                \"goodbye_message\": None,\n            }\n\n        keys = [\n            \"guild_id\",\n            \"welcome_channel_id\",\n            \"goodbye_channel_id\",\n            \"announcement_channel_id\",\n            \"modlog_channel_id\",\n            \"welcome_message\",\n            \"goodbye_message\",\n        ]\n        return dict(zip(keys, row))\n\n    async def update_setting(self, guild_id: int, field: str, value):\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\"INSERT OR IGNORE INTO guild_settings (guild_id) VALUES (?)\", (guild_id,))\n            await cursor.execute(f\"UPDATE guild_settings SET {field} = ? WHERE guild_id = ?\", (value, guild_id))\n        await self.bot.db.commit()\n\n    def validate_template(self, template: str) -> Optional[str]:\n        formatter = string.Formatter()\n        try:\n            for _, field_name, _, _ in formatter.parse(template):\n                if field_name and field_name not in TEMPLATE_FIELDS:\n                    return (\n                        f\"Unsupported placeholder `{field_name}`. \"\n                        \"Use only `{member}`, `{member.mention}`, and `{guild}`.\"\n                    )\n        except ValueError as error:\n            return f\"Invalid template syntax: {error}\"\n        return None\n\n    def render_template(self, template: Optional[str], member: discord.abc.User, guild: discord.Guild, default: str) -> str:\n        text = template or default\n        return text.format(member=member, guild=guild.name)\n\n    def try_render_template(\n        self, template: Optional[str], member: discord.abc.User, guild: discord.Guild, default: str\n    ) -> tuple[Optional[str], Optional[str]]:\n        try:\n            return self.render_template(template, member, guild, default), None\n        except Exception as error:\n            return None, str(error)\n\n    def schedule_retry_delay(self, failures: int) -> timedelta:\n        seconds = min(SCHEDULE_RETRY_BASE_SECONDS * (2 ** max(failures - 1, 0)), SCHEDULE_RETRY_MAX_SECONDS)\n        return timedelta(seconds=seconds)\n\n    async def _resolve_channel(self, channel_id: Optional[int]):\n        if channel_id is None:\n            return None\n        channel = self.bot.get_channel(channel_id)\n        if channel is None:\n            try:\n                channel = await self.bot.fetch_channel(channel_id)\n            except discord.HTTPException:\n                return None\n        return channel\n\n    async def _send_to_channel(\n        self,\n        channel_id: Optional[int],\n        embed: Optional[discord.Embed] = None,\n        content: Optional[str] = None,\n    ) -> bool:\n        channel = await self._resolve_channel(channel_id)\n        if channel is None:\n            return False\n\n        try:\n            if content is not None:\n                await channel.send(content)\n            elif embed is not None:\n                await channel.send(embed=embed)\n            return True\n        except (discord.Forbidden, discord.HTTPException):\n            return False\n\n    async def _log_to_modlog(self, guild: discord.Guild, title: str, description: str, color: discord.Color):\n        settings = await self.get_settings(guild.id)\n        channel_id = settings[\"modlog_channel_id\"]\n        if channel_id is None:\n            return\n\n        embed = discord.Embed(title=title, description=description, color=color, timestamp=discord.utils.utcnow())\n        await self._send_to_channel(channel_id, embed=embed)\n\n    def _channel_value(self, channel_id: Optional[int]) -> str:\n        return f\"<#{channel_id}>\" if channel_id else \"Not set\"\n\n    async def _schedule_announcement(\n        self,\n        guild_id: int,\n        channel_id: int,\n        author_id: int,\n        message: str,\n        send_at,\n        interval_seconds: Optional[int] = None,\n    ) -> int:\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\n                \"\"\"\n                INSERT INTO scheduled_announcements (guild_id, channel_id, author_id, message, send_at, interval_seconds)\n                VALUES (?, ?, ?, ?, ?, ?)\n                \"\"\",\n                (guild_id, channel_id, author_id, message, send_at.isoformat(), interval_seconds),\n            )\n            announcement_id = cursor.lastrowid\n        await self.bot.db.commit()\n        return announcement_id\n\n    server_config = app_commands.Group(\n        name=\"server-config\",\n        description=\"Configure community features for this server.\",\n        guild_only=True,\n    )\n    announcements = app_commands.Group(\n        name=\"announcements\",\n        description=\"Manage scheduled announcements for this server.\",\n        guild_only=True,\n    )\n\n    @server_config.command(name=\"view\", description=\"View the current server community settings.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    async def view(self, interaction: discord.Interaction):\n        settings = await self.get_settings(interaction.guild_id)\n        embed = discord.Embed(title=f\"Server Config: {interaction.guild.name}\", color=discord.Color.green())\n        embed.add_field(name=\"Welcome Channel\", value=self._channel_value(settings[\"welcome_channel_id\"]), inline=False)\n        embed.add_field(name=\"Goodbye Channel\", value=self._channel_value(settings[\"goodbye_channel_id\"]), inline=False)\n        embed.add_field(\n            name=\"Announcement Channel\",\n            value=self._channel_value(settings[\"announcement_channel_id\"]),\n            inline=False,\n        )\n        embed.add_field(name=\"Mod Log Channel\", value=self._channel_value(settings[\"modlog_channel_id\"]), inline=False)\n        embed.add_field(\n            name=\"Welcome Message\",\n            value=settings[\"welcome_message\"] or DEFAULT_WELCOME_MESSAGE,\n            inline=False,\n        )\n        embed.add_field(\n            name=\"Goodbye Message\",\n            value=settings[\"goodbye_message\"] or DEFAULT_GOODBYE_MESSAGE,\n            inline=False,\n        )\n        await interaction.response.send_message(embed=embed, ephemeral=True)\n\n    @server_config.command(name=\"set-welcome-channel\", description=\"Set the welcome channel.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    async def set_welcome_channel(self, interaction: discord.Interaction, channel: discord.TextChannel):\n        await self.update_setting(interaction.guild_id, \"welcome_channel_id\", channel.id)\n        await interaction.response.send_message(f\"Welcome channel set to {channel.mention}.\", ephemeral=True)\n\n    @server_config.command(name=\"set-goodbye-channel\", description=\"Set the goodbye channel.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    async def set_goodbye_channel(self, interaction: discord.Interaction, channel: discord.TextChannel):\n        await self.update_setting(interaction.guild_id, \"goodbye_channel_id\", channel.id)\n        await interaction.response.send_message(f\"Goodbye channel set to {channel.mention}.\", ephemeral=True)\n\n    @server_config.command(name=\"set-announcement-channel\", description=\"Set the announcement channel.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    async def set_announcement_channel(self, interaction: discord.Interaction, channel: discord.TextChannel):\n        await self.update_setting(interaction.guild_id, \"announcement_channel_id\", channel.id)\n        await interaction.response.send_message(f\"Announcement channel set to {channel.mention}.\", ephemeral=True)\n\n    @server_config.command(name=\"set-modlog-channel\", description=\"Set the moderation log channel.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    async def set_modlog_channel(self, interaction: discord.Interaction, channel: discord.TextChannel):\n        await self.update_setting(interaction.guild_id, \"modlog_channel_id\", channel.id)\n        await interaction.response.send_message(f\"Mod log channel set to {channel.mention}.\", ephemeral=True)\n\n    @server_config.command(name=\"set-welcome-message\", description=\"Set the welcome message template.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    @app_commands.describe(message=\"Use {member}, {member.mention}, and {guild}. Type reset to clear.\")\n    async def set_welcome_message(self, interaction: discord.Interaction, message: str):\n        if message.lower() != \"reset\":\n            error = self.validate_template(message)\n            if error:\n                await interaction.response.send_message(error, ephemeral=True)\n                return\n        value = None if message.lower() == \"reset\" else message\n        await self.update_setting(interaction.guild_id, \"welcome_message\", value)\n        await interaction.response.send_message(\"Welcome message updated.\", ephemeral=True)\n\n    @server_config.command(name=\"set-goodbye-message\", description=\"Set the goodbye message template.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    @app_commands.describe(message=\"Use {member}, {member.mention}, and {guild}. Type reset to clear.\")\n    async def set_goodbye_message(self, interaction: discord.Interaction, message: str):\n        if message.lower() != \"reset\":\n            error = self.validate_template(message)\n            if error:\n                await interaction.response.send_message(error, ephemeral=True)\n                return\n        value = None if message.lower() == \"reset\" else message\n        await self.update_setting(interaction.guild_id, \"goodbye_message\", value)\n        await interaction.response.send_message(\"Goodbye message updated.\", ephemeral=True)\n\n    @server_config.command(name=\"preview-welcome\", description=\"Preview the welcome message template.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    async def preview_welcome(self, interaction: discord.Interaction):\n        settings = await self.get_settings(interaction.guild_id)\n        content, error = self.try_render_template(\n            settings[\"welcome_message\"], interaction.user, interaction.guild, DEFAULT_WELCOME_MESSAGE\n        )\n        if error:\n            await interaction.response.send_message(\n                \"The saved welcome template is invalid. Reset or update it before using previews.\",\n                ephemeral=True,\n            )\n            return\n        await interaction.response.send_message(content, ephemeral=True)\n\n    @server_config.command(name=\"preview-goodbye\", description=\"Preview the goodbye message template.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    async def preview_goodbye(self, interaction: discord.Interaction):\n        settings = await self.get_settings(interaction.guild_id)\n        content, error = self.try_render_template(\n            settings[\"goodbye_message\"], interaction.user, interaction.guild, DEFAULT_GOODBYE_MESSAGE\n        )\n        if error:\n            await interaction.response.send_message(\n                \"The saved goodbye template is invalid. Reset or update it before using previews.\",\n                ephemeral=True,\n            )\n            return\n        await interaction.response.send_message(content, ephemeral=True)\n\n    @server_config.command(name=\"reset-message\", description=\"Reset one message template to its default.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    @app_commands.choices(\n        target=[\n            app_commands.Choice(name=\"Welcome\", value=\"welcome_message\"),\n            app_commands.Choice(name=\"Goodbye\", value=\"goodbye_message\"),\n        ]\n    )\n    async def reset_message(self, interaction: discord.Interaction, target: app_commands.Choice[str]):\n        await self.update_setting(interaction.guild_id, target.value, None)\n        await interaction.response.send_message(f\"{target.name} message reset.\", ephemeral=True)\n\n    @server_config.command(name=\"reset-channel\", description=\"Reset one configured channel slot.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    @app_commands.choices(\n        target=[\n            app_commands.Choice(name=\"Welcome\", value=\"welcome_channel_id\"),\n            app_commands.Choice(name=\"Goodbye\", value=\"goodbye_channel_id\"),\n            app_commands.Choice(name=\"Announcement\", value=\"announcement_channel_id\"),\n            app_commands.Choice(name=\"Mod Log\", value=\"modlog_channel_id\"),\n        ]\n    )\n    async def reset_channel(self, interaction: discord.Interaction, target: app_commands.Choice[str]):\n        await self.update_setting(interaction.guild_id, target.value, None)\n        await interaction.response.send_message(f\"{target.name} channel reset.\", ephemeral=True)\n\n    @app_commands.command(name=\"announce\", description=\"Send an announcement to the configured announcement channel.\")\n    @app_commands.guild_only()\n    @app_commands.checks.has_permissions(manage_guild=True)\n    @app_commands.describe(message=\"Announcement content.\")\n    async def announce(self, interaction: discord.Interaction, message: str):\n        if len(message) > MAX_ANNOUNCEMENT_LENGTH:\n            await interaction.response.send_message(\n                f\"Announcement content is too long ({len(message)} chars). Maximum is {MAX_ANNOUNCEMENT_LENGTH}.\",\n                ephemeral=True,\n            )\n            return\n\n        settings = await self.get_settings(interaction.guild_id)\n        target_channel_id = settings[\"announcement_channel_id\"] or interaction.channel_id\n        channel = await self._resolve_channel(target_channel_id)\n        if channel is None:\n            await interaction.response.send_message(\n                \"The configured announcement channel no longer exists. Set a new one or use this command in another channel.\",\n                ephemeral=True,\n            )\n            return\n\n        embed = discord.Embed(description=message, color=discord.Color.blurple())\n        embed.set_author(name=f\"Announcement from {interaction.guild.name}\")\n        embed.set_footer(text=f\"Sent by {interaction.user.display_name}\")\n        try:\n            await channel.send(embed=embed)\n        except discord.Forbidden:\n            await interaction.response.send_message(\n                \"I do not have permission to send messages in the configured announcement channel.\",\n                ephemeral=True,\n            )\n            return\n        except discord.HTTPException:\n            await interaction.response.send_message(\n                \"I could not send the announcement right now. Please try again later.\",\n                ephemeral=True,\n            )\n            return\n\n        if target_channel_id == interaction.channel_id:\n            await interaction.response.send_message(\"Announcement sent in this channel.\", ephemeral=True)\n        else:\n            await interaction.response.send_message(f\"Announcement sent to {channel.mention}.\", ephemeral=True)\n\n    @announcements.command(name=\"schedule\", description=\"Schedule a future announcement.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    @app_commands.describe(\n        when=\"When to send it, like 10m, 2h, or 1d.\",\n        message=\"Announcement content.\",\n        repeat=\"Optional repeat interval like 1d. Leave empty for one-time.\",\n        channel=\"Optional target channel. Defaults to the configured announcement channel or current channel.\",\n    )\n    async def schedule(\n        self,\n        interaction: discord.Interaction,\n        when: str,\n        message: str,\n        repeat: Optional[str] = None,\n        channel: Optional[discord.TextChannel] = None,\n    ):\n        delay_seconds = parse_duration(when)\n        if delay_seconds is None:\n            await interaction.response.send_message(\"Invalid `when` value. Use formats like `10m`, `2h`, or `1d`.\", ephemeral=True)\n            return\n        if len(message) > MAX_ANNOUNCEMENT_LENGTH:\n            await interaction.response.send_message(\n                f\"Announcement content is too long ({len(message)} chars). Maximum is {MAX_ANNOUNCEMENT_LENGTH}.\",\n                ephemeral=True,\n            )\n            return\n        interval_seconds = parse_duration(repeat) if repeat else None\n        if repeat and interval_seconds is None:\n            await interaction.response.send_message(\"Invalid repeat interval. Use formats like `1h` or `1d`.\", ephemeral=True)\n            return\n\n        settings = await self.get_settings(interaction.guild_id)\n        target_channel_id = channel.id if channel else settings[\"announcement_channel_id\"] or interaction.channel_id\n        target_channel = await self._resolve_channel(target_channel_id)\n        if target_channel is None:\n            await interaction.response.send_message(\"The target announcement channel is unavailable.\", ephemeral=True)\n            return\n\n        send_at = discord.utils.utcnow() + timedelta(seconds=delay_seconds)\n        announcement_id = await self._schedule_announcement(\n            interaction.guild_id,\n            target_channel_id,\n            interaction.user.id,\n            message,\n            send_at,\n            interval_seconds=interval_seconds,\n        )\n        schedule_text = discord.utils.format_dt(send_at, style=\"F\")\n        repeat_text = \"\" if interval_seconds is None else f\" Repeats every `{repeat}`.\"\n        await interaction.response.send_message(\n            f\"Scheduled announcement `{announcement_id}` for {schedule_text} in {target_channel.mention}.{repeat_text}\",\n            ephemeral=True,\n        )\n\n    @announcements.command(name=\"list\", description=\"List scheduled announcements for this server.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    async def list_scheduled(self, interaction: discord.Interaction):\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\n                \"\"\"\n                SELECT id, channel_id, send_at, interval_seconds, message, delivery_failures, disabled, last_error\n                FROM scheduled_announcements\n                WHERE guild_id = ?\n                ORDER BY send_at ASC\n                LIMIT 20\n                \"\"\",\n                (interaction.guild_id,),\n            )\n            rows = await cursor.fetchall()\n\n        if not rows:\n            await interaction.response.send_message(\"No scheduled announcements for this server.\", ephemeral=True)\n            return\n\n        lines = []\n        for announcement_id, channel_id, send_at, interval_seconds, message, delivery_failures, disabled, last_error in rows:\n            schedule = discord.utils.format_dt(discord.utils.parse_time(send_at), style=\"R\")\n            repeat_text = \"\" if interval_seconds is None else f\" every `{interval_seconds}s`\"\n            status_text = \" • paused\" if disabled else \"\"\n            failure_text = \"\" if not delivery_failures else f\" • failures: {delivery_failures}\"\n            error_text = \"\" if not last_error else f\"\\nError: {last_error[:100]}\"\n            lines.append(f\"`{announcement_id}` • <#{channel_id}> • {schedule}{repeat_text}{status_text}{failure_text}\\n{message[:120]}{error_text}\")\n\n        embed = discord.Embed(title=\"Scheduled Announcements\", description=\"\\n\\n\".join(lines), color=discord.Color.blurple())\n        await interaction.response.send_message(embed=embed, ephemeral=True)\n\n    @announcements.command(name=\"diagnose\", description=\"Show details and error info for a scheduled announcement.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    @app_commands.describe(announcement_id=\"The announcement id from /announcements list.\")\n    async def diagnose_scheduled(self, interaction: discord.Interaction, announcement_id: int):\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\n                \"\"\"\n                SELECT id, channel_id, author_id, message, send_at, interval_seconds,\n                       delivery_failures, disabled, last_error\n                FROM scheduled_announcements\n                WHERE id = ? AND guild_id = ?\n                \"\"\",\n                (announcement_id, interaction.guild_id),\n            )\n            row = await cursor.fetchone()\n\n        if not row:\n            await interaction.response.send_message(\"Scheduled announcement not found.\", ephemeral=True)\n            return\n\n        ann_id, channel_id, author_id, message, send_at, interval_seconds, failures, disabled, last_error = row\n        embed = discord.Embed(\n            title=f\"Announcement `{ann_id}` Details\",\n            color=discord.Color.orange() if disabled else discord.Color.blurple(),\n        )\n        embed.add_field(name=\"Channel\", value=f\"<#{channel_id}>\", inline=True)\n        embed.add_field(name=\"Scheduled By\", value=f\"<@{author_id}>\", inline=True)\n        embed.add_field(name=\"Next Send\", value=discord.utils.format_dt(discord.utils.parse_time(send_at), style=\"F\"), inline=False)\n        if interval_seconds:\n            embed.add_field(name=\"Repeat Interval\", value=f\"{interval_seconds}s\", inline=True)\n        embed.add_field(name=\"Status\", value=\"Paused (too many failures)\" if disabled else \"Active\", inline=True)\n        embed.add_field(name=\"Delivery Failures\", value=str(failures), inline=True)\n        if last_error:\n            embed.add_field(name=\"Last Error\", value=last_error[:1024], inline=False)\n        embed.add_field(name=\"Content\", value=message[:1024], inline=False)\n        await interaction.response.send_message(embed=embed, ephemeral=True)\n\n    @announcements.command(name=\"cancel\", description=\"Cancel a scheduled announcement.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    async def cancel_scheduled(self, interaction: discord.Interaction, announcement_id: int):\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\n                \"DELETE FROM scheduled_announcements WHERE id = ? AND guild_id = ?\",\n                (announcement_id, interaction.guild_id),\n            )\n            deleted = cursor.rowcount\n        await self.bot.db.commit()\n        if deleted:\n            await interaction.response.send_message(f\"Scheduled announcement `{announcement_id}` canceled.\", ephemeral=True)\n        else:\n            await interaction.response.send_message(\"Scheduled announcement not found.\", ephemeral=True)\n\n    @tasks.loop(seconds=SCHEDULE_POLL_SECONDS)\n    async def schedule_loop(self):\n        now = discord.utils.utcnow().isoformat()\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\n                \"\"\"\n                SELECT id, guild_id, channel_id, author_id, message, send_at, interval_seconds, delivery_failures\n                FROM scheduled_announcements\n                WHERE disabled = 0 AND send_at <= ?\n                ORDER BY send_at ASC\n                LIMIT ?\n                \"\"\",\n                (now, SCHEDULE_BATCH_SIZE),\n            )\n            rows = await cursor.fetchall()\n\n        for announcement_id, guild_id, channel_id, author_id, message, send_at, interval_seconds, delivery_failures in rows:\n            guild = self.bot.get_guild(guild_id)\n            channel = await self._resolve_channel(channel_id)\n            delivered = False\n            failure_reason = None\n            if guild is not None and channel is not None:\n                embed = discord.Embed(description=message, color=discord.Color.blurple())\n                embed.set_author(name=f\"Scheduled announcement from {guild.name}\")\n                user = guild.get_member(author_id)\n                footer_name = user.display_name if user else str(author_id)\n                embed.set_footer(text=f\"Scheduled by {footer_name}\")\n                try:\n                    await channel.send(embed=embed)\n                    delivered = True\n                except (discord.Forbidden, discord.HTTPException):\n                    delivered = False\n                    failure_reason = \"failed to send the scheduled announcement in the configured channel\"\n            elif guild is None:\n                failure_reason = \"the target guild is unavailable to the bot\"\n            else:\n                failure_reason = \"the configured announcement channel is unavailable\"\n\n            async with self.bot.db.cursor() as cursor:\n                if delivered and interval_seconds:\n                    next_time = discord.utils.parse_time(send_at)\n                    while next_time <= discord.utils.utcnow():\n                        next_time += timedelta(seconds=interval_seconds)\n                    await cursor.execute(\n                        \"\"\"\n                        UPDATE scheduled_announcements\n                        SET send_at = ?, delivery_failures = 0, disabled = 0, last_error = NULL\n                        WHERE id = ?\n                        \"\"\",\n                        (next_time.isoformat(), announcement_id),\n                    )\n                elif delivered:\n                    await cursor.execute(\"DELETE FROM scheduled_announcements WHERE id = ?\", (announcement_id,))\n                else:\n                    next_failures = delivery_failures + 1\n                    if next_failures >= MAX_SCHEDULE_DELIVERY_FAILURES:\n                        await cursor.execute(\n                            \"\"\"\n                            UPDATE scheduled_announcements\n                            SET delivery_failures = ?, disabled = 1, last_error = ?\n                            WHERE id = ?\n                            \"\"\",\n                            (next_failures, failure_reason, announcement_id),\n                        )\n                        if guild is not None:\n                            await self._log_to_modlog(\n                                guild,\n                                \"Scheduled Announcement Disabled\",\n                                f\"Announcement `{announcement_id}` was paused after repeated delivery failures.\\nReason: {failure_reason}\",\n                                discord.Color.orange(),\n                            )\n                    else:\n                        retry_at = discord.utils.utcnow() + self.schedule_retry_delay(next_failures)\n                        await cursor.execute(\n                            \"\"\"\n                            UPDATE scheduled_announcements\n                            SET send_at = ?, delivery_failures = ?, last_error = ?\n                            WHERE id = ?\n                            \"\"\",\n                            (retry_at.isoformat(), next_failures, failure_reason, announcement_id),\n                        )\n            await self.bot.db.commit()\n\n    @schedule_loop.before_loop\n    async def before_schedule_loop(self):\n        await self.bot.wait_until_ready()\n        await self.setup_database()\n\n    @commands.Cog.listener()\n    async def on_member_join(self, member: discord.Member):\n        settings = await self.get_settings(member.guild.id)\n        if settings[\"welcome_channel_id\"]:\n            try:\n                text = self.render_template(settings[\"welcome_message\"], member, member.guild, DEFAULT_WELCOME_MESSAGE)\n                await self._send_to_channel(settings[\"welcome_channel_id\"], content=text)\n            except Exception:\n                await self._log_to_modlog(\n                    member.guild,\n                    \"Welcome Message Error\",\n                    \"A welcome template could not be rendered. Reset or update the welcome message template.\",\n                    discord.Color.red(),\n                )\n\n        await self._log_to_modlog(\n            member.guild,\n            \"Member Joined\",\n            f\"{member.mention} joined the server.\",\n            discord.Color.green(),\n        )\n\n    @commands.Cog.listener()\n    async def on_member_remove(self, member: discord.Member):\n        settings = await self.get_settings(member.guild.id)\n        if settings[\"goodbye_channel_id\"]:\n            try:\n                text = self.render_template(settings[\"goodbye_message\"], member, member.guild, DEFAULT_GOODBYE_MESSAGE)\n                await self._send_to_channel(settings[\"goodbye_channel_id\"], content=text)\n            except Exception:\n                await self._log_to_modlog(\n                    member.guild,\n                    \"Goodbye Message Error\",\n                    \"A goodbye template could not be rendered. Reset or update the goodbye message template.\",\n                    discord.Color.red(),\n                )\n\n        await self._log_to_modlog(\n            member.guild,\n            \"Member Left\",\n            f\"{member} left the server.\",\n            discord.Color.orange(),\n        )\n\n    @commands.Cog.listener()\n    async def on_message_delete(self, message: discord.Message):\n        if message.guild is None or message.author.bot:\n            return\n\n        content = message.content.strip()\n        if not content:\n            content = \"[no text content]\"\n        elif len(content) > 500:\n            content = f\"{content[:497]}...\"\n\n        await self._log_to_modlog(\n            message.guild,\n            \"Message Deleted\",\n            f\"Author: {message.author.mention}\\nChannel: {message.channel.mention}\\nContent: {content}\",\n            discord.Color.red(),\n        )\n\n\nasync def setup(bot: commands.Bot):\n    await bot.add_cog(Community(bot))\n"
  },
  {
    "path": "cogs/economy.py",
    "content": "import asyncio\nimport logging\nimport random\n\nimport discord\nfrom discord import app_commands\nfrom discord.ext import commands\n\nlogger = logging.getLogger(__name__)\n\n# Constants\nDEFAULT_BALANCE = 100\nDAILY_MIN = 100\nDAILY_MAX = 500\nDAILY_COOLDOWN = 86400  # 24 hours\nFREELANCE_MIN = 25\nFREELANCE_MAX = 75\nFREELANCE_COOLDOWN = 900  # 15 minutes\nREGULAR_MIN = 100\nREGULAR_MAX = 300\nREGULAR_COOLDOWN = 3600  # 1 hour\nCRIME_MIN = 500\nCRIME_MAX = 1500\nCRIME_FINE_MIN = 200\nCRIME_FINE_MAX = 750\nCRIME_SUCCESS_RATE = 0.50\nCRIME_COOLDOWN = 21600  # 6 hours\nROB_SUCCESS_RATE = 0.40\nROB_MIN_VICTIM_BALANCE = 200\nROB_COOLDOWN = 1800  # 30 minutes\nSLOTS_MIN_BET = 10\n\n\nclass Economy(commands.Cog):\n    def __init__(self, bot: commands.Bot):\n        self.bot = bot\n        self._guild_locks: dict[int, asyncio.Lock] = {}\n        self.bot.loop.create_task(self.setup_database())\n\n    def _guild_lock(self, guild_id: int) -> asyncio.Lock:\n        lock = self._guild_locks.get(guild_id)\n        if lock is None:\n            lock = asyncio.Lock()\n            self._guild_locks[guild_id] = lock\n        return lock\n\n    async def setup_database(self):\n        columns = []\n        async with self.bot.db.execute(\"PRAGMA table_info(users)\") as cursor:\n            columns = [row[1] for row in await cursor.fetchall()]\n\n        if columns and \"guild_id\" not in columns:\n            async with self.bot.db.cursor() as cursor:\n                await cursor.execute(\"ALTER TABLE users RENAME TO users_legacy\")\n                await cursor.execute(\n                    \"\"\"\n                    CREATE TABLE users (\n                        guild_id INTEGER NOT NULL,\n                        user_id INTEGER NOT NULL,\n                        balance INTEGER NOT NULL DEFAULT 100,\n                        PRIMARY KEY (guild_id, user_id)\n                    )\n                    \"\"\"\n                )\n                await cursor.execute(\n                    \"\"\"\n                    INSERT OR IGNORE INTO users (guild_id, user_id, balance)\n                    SELECT DISTINCT COALESCE(messages.guild_id, 0), legacy.user_id, legacy.balance\n                    FROM users_legacy AS legacy\n                    LEFT JOIN messages\n                        ON messages.user_id = legacy.user_id\n                       AND messages.guild_id IS NOT NULL\n                    \"\"\"\n                )\n            logger.info(\"Migrated global economy balances to per-guild records.\")\n        else:\n            async with self.bot.db.cursor() as cursor:\n                await cursor.execute(\n                    \"\"\"\n                    CREATE TABLE IF NOT EXISTS users (\n                        guild_id INTEGER NOT NULL,\n                        user_id INTEGER NOT NULL,\n                        balance INTEGER NOT NULL DEFAULT 100,\n                        PRIMARY KEY (guild_id, user_id)\n                    )\n                    \"\"\"\n                )\n\n        await self.bot.db.commit()\n\n    def _get_guild_id(self, interaction: discord.Interaction) -> int:\n        if interaction.guild_id is None:\n            raise app_commands.CheckFailure(\"This command can only be used in a server.\")\n        return interaction.guild_id\n\n    async def _get_or_create_user_unlocked(self, guild_id: int, user_id: int) -> int:\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\n                \"SELECT balance FROM users WHERE guild_id = ? AND user_id = ?\",\n                (guild_id, user_id),\n            )\n            result = await cursor.fetchone()\n            if result is None:\n                await cursor.execute(\n                    \"INSERT INTO users (guild_id, user_id, balance) VALUES (?, ?, ?)\",\n                    (guild_id, user_id, DEFAULT_BALANCE),\n                )\n                await self.bot.db.commit()\n                return DEFAULT_BALANCE\n            return result[0]\n\n    async def get_or_create_user(self, guild_id: int, user_id: int) -> int:\n        async with self._guild_lock(guild_id):\n            return await self._get_or_create_user_unlocked(guild_id, user_id)\n\n    async def change_balance(self, guild_id: int, user_id: int, delta: int) -> int:\n        async with self._guild_lock(guild_id):\n            balance = await self._get_or_create_user_unlocked(guild_id, user_id)\n            new_balance = balance + delta\n            if new_balance < 0:\n                raise ValueError(\"Balance cannot be negative.\")\n\n            async with self.bot.db.cursor() as cursor:\n                await cursor.execute(\n                    \"UPDATE users SET balance = ? WHERE guild_id = ? AND user_id = ?\",\n                    (new_balance, guild_id, user_id),\n                )\n            await self.bot.db.commit()\n            return new_balance\n\n    async def transfer_balance(self, guild_id: int, sender_id: int, receiver_id: int, amount: int) -> tuple[int, int]:\n        async with self._guild_lock(guild_id):\n            sender_balance = await self._get_or_create_user_unlocked(guild_id, sender_id)\n            if sender_balance < amount:\n                raise ValueError(\"You do not have enough coins to make this transfer.\")\n\n            receiver_balance = await self._get_or_create_user_unlocked(guild_id, receiver_id)\n            async with self.bot.db.cursor() as cursor:\n                await cursor.execute(\n                    \"UPDATE users SET balance = ? WHERE guild_id = ? AND user_id = ?\",\n                    (sender_balance - amount, guild_id, sender_id),\n                )\n                await cursor.execute(\n                    \"UPDATE users SET balance = ? WHERE guild_id = ? AND user_id = ?\",\n                    (receiver_balance + amount, guild_id, receiver_id),\n                )\n            await self.bot.db.commit()\n            return sender_balance - amount, receiver_balance + amount\n\n    async def set_balance(self, guild_id: int, user_id: int, amount: int) -> int:\n        async with self._guild_lock(guild_id):\n            await self._get_or_create_user_unlocked(guild_id, user_id)\n            async with self.bot.db.cursor() as cursor:\n                await cursor.execute(\n                    \"UPDATE users SET balance = ? WHERE guild_id = ? AND user_id = ?\",\n                    (amount, guild_id, user_id),\n                )\n            await self.bot.db.commit()\n            return amount\n\n    jobs = app_commands.Group(name=\"jobs\", description=\"Perform various jobs to earn coins.\", guild_only=True)\n    admin = app_commands.Group(name=\"economy-admin\", description=\"Administer the server economy.\", guild_only=True)\n\n    @app_commands.command(name=\"balance\", description=\"Check your or another member's coin balance.\")\n    @app_commands.guild_only()\n    @app_commands.describe(member=\"The member whose balance you want to see.\")\n    @app_commands.checks.cooldown(1, 10, key=lambda i: (i.guild_id, i.user.id))\n    async def balance(self, interaction: discord.Interaction, member: discord.Member = None):\n        guild_id = self._get_guild_id(interaction)\n        target_member = member or interaction.user\n        balance = await self.get_or_create_user(guild_id, target_member.id)\n        embed = discord.Embed(title=f\"{target_member.name}'s Balance\", color=discord.Color.green())\n        embed.add_field(name=\"Coins\", value=f\"🪙 {balance}\")\n        await interaction.response.send_message(embed=embed)\n\n    @app_commands.command(name=\"daily\", description=\"Claim your daily reward.\")\n    @app_commands.guild_only()\n    @app_commands.checks.cooldown(1, DAILY_COOLDOWN, key=lambda i: (i.guild_id, i.user.id))\n    async def daily(self, interaction: discord.Interaction):\n        guild_id = self._get_guild_id(interaction)\n        daily_amount = random.randint(DAILY_MIN, DAILY_MAX)\n        await self.change_balance(guild_id, interaction.user.id, daily_amount)\n\n        embed = discord.Embed(\n            title=\"Daily Reward!\",\n            description=f\"You have claimed your daily reward of 🪙 **{daily_amount}** coins!\",\n            color=discord.Color.gold(),\n        )\n        await interaction.response.send_message(embed=embed)\n\n    @daily.error\n    async def daily_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError):\n        if isinstance(error, app_commands.CommandOnCooldown):\n            seconds = error.retry_after\n            hours, remainder = divmod(int(seconds), 3600)\n            minutes, _ = divmod(remainder, 60)\n            await interaction.response.send_message(\n                f\"You've already claimed your daily reward. Please try again in **{hours}h {minutes}m**.\",\n                ephemeral=True,\n            )\n\n    @jobs.command(name=\"freelance\", description=\"Do a quick freelance job for some extra cash.\")\n    @app_commands.checks.cooldown(1, FREELANCE_COOLDOWN, key=lambda i: (i.guild_id, i.user.id))\n    async def jobs_freelance(self, interaction: discord.Interaction):\n        guild_id = self._get_guild_id(interaction)\n        amount = random.randint(FREELANCE_MIN, FREELANCE_MAX)\n        await self.change_balance(guild_id, interaction.user.id, amount)\n\n        messages = [\n            f\"You designed a logo for a local startup and earned 🪙 **{amount}**.\",\n            f\"You wrote a short article for a blog and got paid 🪙 **{amount}**.\",\n            f\"You helped someone with their homework and they tipped you 🪙 **{amount}**.\",\n        ]\n        await interaction.response.send_message(random.choice(messages))\n\n    @jobs_freelance.error\n    async def freelance_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError):\n        if isinstance(error, app_commands.CommandOnCooldown):\n            minutes = int(error.retry_after / 60)\n            await interaction.response.send_message(\n                f\"You need a break. You can do another freelance job in **{minutes}** minutes.\", ephemeral=True\n            )\n\n    @jobs.command(name=\"regular\", description=\"Work your regular shift for a steady income.\")\n    @app_commands.checks.cooldown(1, REGULAR_COOLDOWN, key=lambda i: (i.guild_id, i.user.id))\n    async def jobs_regular(self, interaction: discord.Interaction):\n        guild_id = self._get_guild_id(interaction)\n        amount = random.randint(REGULAR_MIN, REGULAR_MAX)\n        await self.change_balance(guild_id, interaction.user.id, amount)\n\n        messages = [\n            f\"You completed your shift as a programmer and earned 🪙 **{amount}**.\",\n            f\"You spent the day as a server janitor and got paid 🪙 **{amount}**.\",\n            f\"You delivered pizzas all afternoon and made 🪙 **{amount}**.\",\n        ]\n        await interaction.response.send_message(random.choice(messages))\n\n    @jobs_regular.error\n    async def regular_work_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError):\n        if isinstance(error, app_commands.CommandOnCooldown):\n            minutes = int(error.retry_after / 60)\n            await interaction.response.send_message(\n                f\"You're tired from your shift. You can work again in **{minutes}** minutes.\", ephemeral=True\n            )\n\n    @jobs.command(name=\"crime\", description=\"Commit a crime for a high reward, but with high risk.\")\n    @app_commands.checks.cooldown(1, CRIME_COOLDOWN, key=lambda i: (i.guild_id, i.user.id))\n    async def jobs_crime(self, interaction: discord.Interaction):\n        guild_id = self._get_guild_id(interaction)\n        balance = await self.get_or_create_user(guild_id, interaction.user.id)\n\n        if random.random() < CRIME_SUCCESS_RATE:\n            payout = random.randint(CRIME_MIN, CRIME_MAX)\n            await self.change_balance(guild_id, interaction.user.id, payout)\n            await interaction.response.send_message(\n                f\"🚨 **Success!** Your high-stakes bank heist went perfectly. You got away with 🪙 **{payout}**!\"\n            )\n        else:\n            fine = min(balance, random.randint(CRIME_FINE_MIN, CRIME_FINE_MAX))\n            await self.change_balance(guild_id, interaction.user.id, -fine)\n            await interaction.response.send_message(\n                f\"👮‍♂️ **BUSTED!** The silent alarm tripped during your operation. You were caught and fined 🪙 **{fine}**.\"\n            )\n\n    @jobs_crime.error\n    async def crime_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError):\n        if isinstance(error, app_commands.CommandOnCooldown):\n            hours = int(error.retry_after / 3600)\n            await interaction.response.send_message(\n                f\"You need to lay low for a while. You can try another crime in **{hours}** hours.\", ephemeral=True\n            )\n\n    @app_commands.command(name=\"gamble\", description=\"Gamble your coins for a chance to win big.\")\n    @app_commands.guild_only()\n    @app_commands.describe(amount=\"The amount of coins you want to gamble.\")\n    @app_commands.checks.cooldown(1, 10, key=lambda i: (i.guild_id, i.user.id))\n    async def gamble(self, interaction: discord.Interaction, amount: app_commands.Range[int, 1]):\n        guild_id = self._get_guild_id(interaction)\n        balance = await self.get_or_create_user(guild_id, interaction.user.id)\n\n        if amount > balance:\n            await interaction.response.send_message(\"You don't have enough coins to gamble that much.\", ephemeral=True)\n            return\n\n        win = random.choice([True, False, False])\n        delta = amount if win else -amount\n        new_balance = await self.change_balance(guild_id, interaction.user.id, delta)\n\n        if win:\n            await interaction.response.send_message(\n                f\"🎉 **You won!** You gambled {amount} and won {amount} coins! Your new balance is 🪙 {new_balance}.\"\n            )\n        else:\n            await interaction.response.send_message(\n                f\"💀 **You lost!** You gambled {amount} and lost it all. Your new balance is 🪙 {new_balance}.\"\n            )\n\n    @app_commands.command(name=\"leaderboard\", description=\"Shows the top 10 richest users in the server.\")\n    @app_commands.guild_only()\n    @app_commands.checks.cooldown(1, 30, key=lambda i: i.guild_id)\n    async def leaderboard(self, interaction: discord.Interaction):\n        guild_id = self._get_guild_id(interaction)\n        await interaction.response.defer()\n\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\n                \"SELECT user_id, balance FROM users WHERE guild_id = ? ORDER BY balance DESC LIMIT 10\",\n                (guild_id,),\n            )\n            top_users = await cursor.fetchall()\n\n        if not top_users:\n            await interaction.followup.send(\"The leaderboard is empty!\")\n            return\n\n        embed = discord.Embed(title=\"🏆 Server Coin Leaderboard 🏆\", color=discord.Color.gold())\n\n        leaderboard_text = \"\"\n        for rank, (user_id, balance) in enumerate(top_users, start=1):\n            member = interaction.guild.get_member(user_id)\n            if member is None:\n                user_name = f\"Unknown User (ID: {user_id})\"\n            else:\n                user_name = member.display_name\n\n            leaderboard_text += f\"{rank}. {user_name} - 🪙 {balance}\\n\"\n\n        embed.description = leaderboard_text\n        await interaction.followup.send(embed=embed)\n\n    @app_commands.command(name=\"transfer\", description=\"Transfer coins to another member.\")\n    @app_commands.guild_only()\n    @app_commands.describe(\n        member=\"The member you want to transfer coins to.\", amount=\"The amount of coins to transfer.\"\n    )\n    @app_commands.checks.cooldown(1, 15, key=lambda i: (i.guild_id, i.user.id))\n    async def transfer(\n        self, interaction: discord.Interaction, member: discord.Member, amount: app_commands.Range[int, 1]\n    ):\n        guild_id = self._get_guild_id(interaction)\n        sender_id = interaction.user.id\n        receiver_id = member.id\n\n        if sender_id == receiver_id:\n            await interaction.response.send_message(\"You cannot transfer coins to yourself.\", ephemeral=True)\n            return\n\n        if member.bot:\n            await interaction.response.send_message(\"You cannot transfer coins to bots.\", ephemeral=True)\n            return\n\n        try:\n            await self.transfer_balance(guild_id, sender_id, receiver_id, amount)\n        except ValueError as error:\n            await interaction.response.send_message(str(error), ephemeral=True)\n            return\n\n        await interaction.response.send_message(\n            f\"💸 You have successfully transferred 🪙 **{amount}** coins to {member.mention}!\"\n        )\n\n    @app_commands.command(name=\"rob\", description=\"Attempt to rob coins from another member.\")\n    @app_commands.guild_only()\n    @app_commands.describe(member=\"The member you want to rob.\")\n    @app_commands.checks.cooldown(1, ROB_COOLDOWN, key=lambda i: (i.guild_id, i.user.id))\n    async def rob(self, interaction: discord.Interaction, member: discord.Member):\n        guild_id = self._get_guild_id(interaction)\n        robber_id = interaction.user.id\n        victim_id = member.id\n\n        if robber_id == victim_id:\n            await interaction.response.send_message(\"You can't rob yourself, you silly goose!\", ephemeral=True)\n            return\n\n        if member.bot:\n            await interaction.response.send_message(\"Bots are not part of the economy.\", ephemeral=True)\n            return\n\n        async with self._guild_lock(guild_id):\n            robber_balance = await self._get_or_create_user_unlocked(guild_id, robber_id)\n            victim_balance = await self._get_or_create_user_unlocked(guild_id, victim_id)\n\n            if victim_balance < ROB_MIN_VICTIM_BALANCE:\n                self.rob.reset_cooldown(interaction)\n                await interaction.response.send_message(\n                    f\"{member.name} is too poor to be worth robbing.\", ephemeral=True\n                )\n                return\n\n            async with self.bot.db.cursor() as cursor:\n                if random.random() < ROB_SUCCESS_RATE:\n                    robbed_amount = random.randint(int(victim_balance * 0.1), int(victim_balance * 0.25))\n                    await cursor.execute(\n                        \"UPDATE users SET balance = ? WHERE guild_id = ? AND user_id = ?\",\n                        (robber_balance + robbed_amount, guild_id, robber_id),\n                    )\n                    await cursor.execute(\n                        \"UPDATE users SET balance = ? WHERE guild_id = ? AND user_id = ?\",\n                        (victim_balance - robbed_amount, guild_id, victim_id),\n                    )\n                    message = f\"🚨 Success! You discreetly robbed 🪙 **{robbed_amount}** from {member.mention}!\"\n                else:\n                    fine_amount = min(robber_balance, random.randint(int(robber_balance * 0.1), int(robber_balance * 0.2)))\n                    await cursor.execute(\n                        \"UPDATE users SET balance = ? WHERE guild_id = ? AND user_id = ?\",\n                        (robber_balance - fine_amount, guild_id, robber_id),\n                    )\n                    message = (\n                        f\"👮‍♂️ Busted! Your robbery attempt on {member.mention} failed and you were fined 🪙 \"\n                        f\"**{fine_amount}**.\"\n                    )\n\n            await self.bot.db.commit()\n\n        await interaction.response.send_message(message)\n\n    @rob.error\n    async def rob_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError):\n        if isinstance(error, app_commands.CommandOnCooldown):\n            minutes = int(error.retry_after / 60)\n            await interaction.response.send_message(\n                f\"You're on a cooldown. You can attempt another robbery in **{minutes}** minutes.\", ephemeral=True\n            )\n\n    @app_commands.command(name=\"slots\", description=\"Play the slot machine.\")\n    @app_commands.guild_only()\n    @app_commands.describe(bet=\"The amount of coins you want to bet.\")\n    @app_commands.checks.cooldown(1, 5, key=lambda i: (i.guild_id, i.user.id))\n    async def slots(self, interaction: discord.Interaction, bet: app_commands.Range[int, SLOTS_MIN_BET]):\n        guild_id = self._get_guild_id(interaction)\n        balance = await self.get_or_create_user(guild_id, interaction.user.id)\n\n        if bet > balance:\n            await interaction.response.send_message(\"You don't have enough coins to bet that much.\", ephemeral=True)\n            return\n\n        reels = [\"🍒\", \"🍊\", \"🍋\", \"🔔\", \"⭐\", \"💎\"]\n        spin = [random.choice(reels) for _ in range(3)]\n\n        result_text = f\"**[ {spin[0]} | {spin[1]} | {spin[2]} ]**\\n\\n\"\n\n        if spin[0] == spin[1] == spin[2]:\n            if spin[0] == \"💎\":\n                winnings = bet * 20\n                result_text += f\"💎 JACKPOT! 💎 You won **{winnings}** coins!\"\n            else:\n                winnings = bet * 10\n                result_text += f\"🎉 BIG WIN! 🎉 You won **{winnings}** coins!\"\n        elif spin[0] == spin[1] or spin[1] == spin[2]:\n            winnings = bet * 2\n            result_text += f\"👍 Nice! You won **{winnings}** coins!\"\n        else:\n            winnings = -bet\n            result_text += \"☠️ Aw, tough luck! You lost your bet.\"\n\n        new_balance = await self.change_balance(guild_id, interaction.user.id, winnings)\n\n        embed = discord.Embed(title=\"🎰 Slot Machine 🎰\", description=result_text, color=discord.Color.dark_magenta())\n        embed.set_footer(text=f\"Your new balance is 🪙 {new_balance}\")\n        await interaction.response.send_message(embed=embed)\n\n    @admin.command(name=\"add\", description=\"Add coins to a member.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    @app_commands.describe(member=\"The target member.\", amount=\"How many coins to add.\")\n    async def admin_add(self, interaction: discord.Interaction, member: discord.Member, amount: app_commands.Range[int, 1]):\n        guild_id = self._get_guild_id(interaction)\n        balance = await self.change_balance(guild_id, member.id, amount)\n        await interaction.response.send_message(\n            f\"Added 🪙 **{amount}** to {member.mention}. New balance: 🪙 {balance}.\",\n            ephemeral=True,\n        )\n\n    @admin.command(name=\"remove\", description=\"Remove coins from a member.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    @app_commands.describe(member=\"The target member.\", amount=\"How many coins to remove.\")\n    async def admin_remove(\n        self, interaction: discord.Interaction, member: discord.Member, amount: app_commands.Range[int, 1]\n    ):\n        guild_id = self._get_guild_id(interaction)\n        current = await self.get_or_create_user(guild_id, member.id)\n        removed = min(current, amount)\n        balance = await self.change_balance(guild_id, member.id, -removed)\n        await interaction.response.send_message(\n            f\"Removed 🪙 **{removed}** from {member.mention}. New balance: 🪙 {balance}.\",\n            ephemeral=True,\n        )\n\n    @admin.command(name=\"set\", description=\"Set a member's balance exactly.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    @app_commands.describe(member=\"The target member.\", amount=\"The exact balance to set.\")\n    async def admin_set(self, interaction: discord.Interaction, member: discord.Member, amount: app_commands.Range[int, 0]):\n        guild_id = self._get_guild_id(interaction)\n        balance = await self.set_balance(guild_id, member.id, amount)\n        await interaction.response.send_message(\n            f\"Set {member.mention}'s balance to 🪙 **{balance}**.\",\n            ephemeral=True,\n        )\n\n    @admin.command(name=\"reset-guild\", description=\"Reset the server economy for all users.\")\n    @app_commands.checks.has_permissions(administrator=True)\n    async def admin_reset_guild(self, interaction: discord.Interaction):\n        guild_id = self._get_guild_id(interaction)\n        async with self._guild_lock(guild_id):\n            async with self.bot.db.cursor() as cursor:\n                await cursor.execute(\"DELETE FROM users WHERE guild_id = ?\", (guild_id,))\n            await self.bot.db.commit()\n        await interaction.response.send_message(\"The server economy has been reset.\", ephemeral=True)\n\n\nasync def setup(bot: commands.Bot):\n    await bot.add_cog(Economy(bot))\n"
  },
  {
    "path": "cogs/farming.py",
    "content": "import logging\nimport math\nimport random\nfrom datetime import datetime, timedelta\n\nimport discord\nfrom discord import app_commands\nfrom discord.ext import commands\n\nlogger = logging.getLogger(__name__)\n\n# Constants\nPEST_EVENT_CHANCE = 0.10\nPEST_REWARD_MULTIPLIER = 0.5\nBOUNTIFUL_EVENT_CHANCE = 0.95\nBOUNTIFUL_REWARD_MULTIPLIER = 1.5\nBOUNTIFUL_XP_MULTIPLIER = 1.5\n\n\nclass Farming(commands.Cog):\n    def __init__(self, bot: commands.Bot):\n        self.bot = bot\n        self.bot.loop.create_task(self.setup_database())\n\n        self.crops = {\n            \"wheat\": {\"name\": \"Wheat 🌾\", \"cost\": 10, \"growth\": 600, \"reward\": 25, \"xp\": 5, \"level\": 1},\n            \"potato\": {\"name\": \"Potato 🥔\", \"cost\": 25, \"growth\": 1800, \"reward\": 75, \"xp\": 15, \"level\": 2},\n            \"carrot\": {\"name\": \"Carrot 🥕\", \"cost\": 50, \"growth\": 3600, \"reward\": 180, \"xp\": 30, \"level\": 5},\n            \"strawberry\": {\"name\": \"Strawberry 🍓\", \"cost\": 150, \"growth\": 7200, \"reward\": 500, \"xp\": 100, \"level\": 10},\n        }\n\n        self.land_types = {\n            1: {\"name\": \"Barren Land\", \"mod\": 1.0, \"cost\": 0},\n            2: {\"name\": \"Decent Soil\", \"mod\": 0.9, \"cost\": 1000},\n            3: {\"name\": \"Fertile Land\", \"mod\": 0.75, \"cost\": 5000},\n        }\n\n    async def setup_database(self):\n        columns = []\n        async with self.bot.db.execute(\"PRAGMA table_info(farms)\") as cursor:\n            columns = [row[1] for row in await cursor.fetchall()]\n\n        if columns and \"guild_id\" not in columns:\n            async with self.bot.db.cursor() as cursor:\n                await cursor.execute(\"ALTER TABLE farms RENAME TO farms_legacy\")\n                await cursor.execute(\n                    \"\"\"\n                    CREATE TABLE farms (\n                        guild_id INTEGER NOT NULL,\n                        user_id INTEGER NOT NULL,\n                        crop TEXT,\n                        plant_time TEXT,\n                        land_type INTEGER DEFAULT 1,\n                        level INTEGER DEFAULT 1,\n                        xp INTEGER DEFAULT 0,\n                        PRIMARY KEY (guild_id, user_id)\n                    )\n                    \"\"\"\n                )\n                await cursor.execute(\n                    \"\"\"\n                    INSERT OR IGNORE INTO farms (guild_id, user_id, crop, plant_time, land_type, level, xp)\n                    SELECT DISTINCT\n                        COALESCE(messages.guild_id, 0),\n                        legacy.user_id,\n                        legacy.crop,\n                        legacy.plant_time,\n                        legacy.land_type,\n                        legacy.level,\n                        legacy.xp\n                    FROM farms_legacy AS legacy\n                    LEFT JOIN messages\n                        ON messages.user_id = legacy.user_id\n                       AND messages.guild_id IS NOT NULL\n                    \"\"\"\n                )\n            logger.info(\"Migrated global farm data to per-guild records.\")\n        else:\n            async with self.bot.db.cursor() as cursor:\n                await cursor.execute(\n                    \"\"\"\n                    CREATE TABLE IF NOT EXISTS farms (\n                        guild_id INTEGER NOT NULL,\n                        user_id INTEGER NOT NULL,\n                        crop TEXT,\n                        plant_time TEXT,\n                        land_type INTEGER DEFAULT 1,\n                        level INTEGER DEFAULT 1,\n                        xp INTEGER DEFAULT 0,\n                        PRIMARY KEY (guild_id, user_id)\n                    )\n                    \"\"\"\n                )\n        await self.bot.db.commit()\n\n    async def get_farm_data(self, guild_id: int, user_id: int):\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\"SELECT * FROM farms WHERE guild_id = ? AND user_id = ?\", (guild_id, user_id))\n            result = await cursor.fetchone()\n            if not result:\n                await cursor.execute(\"INSERT INTO farms (guild_id, user_id) VALUES (?, ?)\", (guild_id, user_id))\n                await self.bot.db.commit()\n                await cursor.execute(\"SELECT * FROM farms WHERE guild_id = ? AND user_id = ?\", (guild_id, user_id))\n                result = await cursor.fetchone()\n\n        keys = [\"guild_id\", \"user_id\", \"crop\", \"plant_time\", \"land_type\", \"level\", \"xp\"]\n        return dict(zip(keys, result))\n\n    def get_xp_for_next_level(self, level: int):\n        return math.floor(100 * (level**1.5))\n\n    farm = app_commands.Group(name=\"farm\", description=\"Manage your farm, grow crops, and level up.\", guild_only=True)\n\n    @farm.command(name=\"profile\", description=\"View your farm profile, including level and status.\")\n    async def profile(self, interaction: discord.Interaction):\n        await interaction.response.defer()\n        farm_data = await self.get_farm_data(interaction.guild_id, interaction.user.id)\n\n        embed = discord.Embed(title=f\"{interaction.user.name}'s Farm Profile\", color=discord.Color.green())\n\n        level = farm_data[\"level\"]\n        xp = farm_data[\"xp\"]\n        xp_needed = self.get_xp_for_next_level(level)\n        progress = min(int((xp / xp_needed) * 20), 20)\n        progress_bar = \"🟩\" * progress + \"⬛\" * (20 - progress)\n\n        embed.add_field(name=\"📜 Level\", value=f\"**{level}**\", inline=True)\n        embed.add_field(name=\"🌱 XP\", value=f\"{xp} / {xp_needed}\", inline=True)\n        embed.add_field(name=\"📊 Progress\", value=f\"`{progress_bar}`\", inline=False)\n        embed.add_field(name=\"🏞️ Land\", value=self.land_types[farm_data[\"land_type\"]][\"name\"], inline=True)\n\n        if not farm_data[\"crop\"]:\n            embed.add_field(name=\"🌾 Current Crop\", value=\"Plot is empty.\", inline=True)\n        else:\n            crop_name = farm_data[\"crop\"]\n            crop_data = self.crops[crop_name]\n            plant_time = datetime.fromisoformat(farm_data[\"plant_time\"])\n            land_mod = self.land_types[farm_data[\"land_type\"]][\"mod\"]\n            harvest_time = plant_time + timedelta(seconds=crop_data[\"growth\"] * land_mod)\n\n            embed.add_field(name=\"🌾 Current Crop\", value=crop_data[\"name\"], inline=True)\n            if datetime.utcnow() >= harvest_time:\n                embed.add_field(name=\"⏰ Status\", value=\"✅ **Ready to Harvest!**\", inline=False)\n            else:\n                embed.add_field(\n                    name=\"⏰ Ready In\", value=f\"{discord.utils.format_dt(harvest_time, style='R')}\", inline=False\n                )\n\n        await interaction.followup.send(embed=embed)\n\n    @farm.command(name=\"shop\", description=\"Open the seed shop to buy new crops.\")\n    async def shop(self, interaction: discord.Interaction):\n        farm_data = await self.get_farm_data(interaction.guild_id, interaction.user.id)\n        user_level = farm_data[\"level\"]\n\n        embed = discord.Embed(\n            title=\"🌱 Seed Shop\", description=\"Select a seed to plant from the shop.\", color=discord.Color.dark_gold()\n        )\n        for crop in self.crops.values():\n            unlocked = user_level >= crop[\"level\"]\n            emoji = \"✅\" if unlocked else \"🔒\"\n            embed.add_field(\n                name=f\"{crop['name']} (Lvl {crop['level']}) {emoji}\",\n                value=f\"Cost: 🪙 {crop['cost']}\\nReward: 🪙 {crop['reward']}\\nXP: {crop['xp']}\",\n                inline=True,\n            )\n\n        await interaction.response.send_message(embed=embed)\n\n    @farm.command(name=\"plant\", description=\"Plant a crop in your farm.\")\n    @app_commands.describe(crop=\"The name of the crop to plant (e.g., wheat, potato).\")\n    async def plant(self, interaction: discord.Interaction, crop: str):\n        guild_id = interaction.guild_id\n        crop_name = crop.lower()\n        if crop_name not in self.crops:\n            await interaction.response.send_message(\n                \"That's not a valid crop! Check the `/farm shop` for options.\", ephemeral=True\n            )\n            return\n\n        farm_data = await self.get_farm_data(guild_id, interaction.user.id)\n        if farm_data[\"crop\"]:\n            await interaction.response.send_message(\"You already have a crop growing!\", ephemeral=True)\n            return\n\n        crop_data = self.crops[crop_name]\n        if farm_data[\"level\"] < crop_data[\"level\"]:\n            await interaction.response.send_message(\n                f\"You're not a high enough level! You need to be level {crop_data['level']} to plant {crop_data['name']}.\",\n                ephemeral=True,\n            )\n            return\n\n        economy_cog = self.bot.get_cog(\"Economy\")\n        if not economy_cog:\n            await interaction.response.send_message(\"The economy system is currently unavailable.\", ephemeral=True)\n            return\n\n        async with economy_cog._guild_lock(guild_id):\n            user_balance = await economy_cog._get_or_create_user_unlocked(guild_id, interaction.user.id)\n            if user_balance < crop_data[\"cost\"]:\n                await interaction.response.send_message(\n                    f\"You don't have enough coins! Planting {crop_data['name']} costs 🪙 {crop_data['cost']}.\",\n                    ephemeral=True,\n                )\n                return\n\n            async with self.bot.db.cursor() as cursor:\n                await cursor.execute(\n                    \"UPDATE users SET balance = ? WHERE guild_id = ? AND user_id = ?\",\n                    (user_balance - crop_data[\"cost\"], guild_id, interaction.user.id),\n                )\n                await cursor.execute(\n                    \"UPDATE farms SET crop = ?, plant_time = ? WHERE guild_id = ? AND user_id = ?\",\n                    (crop_name, datetime.utcnow().isoformat(), guild_id, interaction.user.id),\n                )\n            await self.bot.db.commit()\n\n        land_mod = self.land_types[farm_data[\"land_type\"]][\"mod\"]\n        growth_seconds = crop_data[\"growth\"] * land_mod\n        growth_hours = round(growth_seconds / 3600, 1)\n\n        await interaction.response.send_message(\n            f\"You've successfully planted **{crop_data['name']}** for 🪙 {crop_data['cost']}! It will be ready in {growth_hours} hours.\"\n        )\n\n    @farm.command(name=\"harvest\", description=\"Harvest your fully grown crop for coins and XP.\")\n    async def harvest(self, interaction: discord.Interaction):\n        guild_id = interaction.guild_id\n        farm_data = await self.get_farm_data(guild_id, interaction.user.id)\n        if not farm_data[\"crop\"]:\n            await interaction.response.send_message(\"You don't have anything planted right now.\", ephemeral=True)\n            return\n\n        crop_name = farm_data[\"crop\"]\n        crop_data = self.crops[crop_name]\n        plant_time = datetime.fromisoformat(farm_data[\"plant_time\"])\n        land_mod = self.land_types[farm_data[\"land_type\"]][\"mod\"]\n        harvest_time = plant_time + timedelta(seconds=crop_data[\"growth\"] * land_mod)\n\n        if datetime.utcnow() < harvest_time:\n            await interaction.response.send_message(\n                f\"Your {crop_data['name']} isn't ready yet! Come back in {discord.utils.format_dt(harvest_time, style='R')}.\",\n                ephemeral=True,\n            )\n            return\n\n        economy_cog = self.bot.get_cog(\"Economy\")\n        if not economy_cog:\n            await interaction.response.send_message(\"The economy system is currently unavailable.\", ephemeral=True)\n            return\n\n        reward = crop_data[\"reward\"]\n        xp_gain = crop_data[\"xp\"]\n        event_message = \"\"\n\n        roll = random.random()\n        if roll < PEST_EVENT_CHANCE:\n            reward = int(reward * PEST_REWARD_MULTIPLIER)\n            event_message = \"\\n\\n**Oh no! A swarm of pests ate half your harvest!** 🐛\"\n        elif roll > BOUNTIFUL_EVENT_CHANCE:\n            reward = int(reward * BOUNTIFUL_REWARD_MULTIPLIER)\n            xp_gain = int(xp_gain * BOUNTIFUL_XP_MULTIPLIER)\n            event_message = \"\\n\\n**Amazing! A bountiful harvest! You got extra coins and XP!** ✨\"\n\n        new_xp = farm_data[\"xp\"] + xp_gain\n        current_level = farm_data[\"level\"]\n        xp_needed = self.get_xp_for_next_level(current_level)\n        level_up_message = \"\"\n\n        while new_xp >= xp_needed:\n            current_level += 1\n            new_xp -= xp_needed\n            xp_needed = self.get_xp_for_next_level(current_level)\n            level_up_message += f\"\\n🎉 **LEVEL UP! You are now Farm Level {current_level}!** 🎉\"\n\n        async with economy_cog._guild_lock(guild_id):\n            user_balance = await economy_cog._get_or_create_user_unlocked(guild_id, interaction.user.id)\n            async with self.bot.db.cursor() as cursor:\n                await cursor.execute(\n                    \"UPDATE users SET balance = ? WHERE guild_id = ? AND user_id = ?\",\n                    (user_balance + reward, guild_id, interaction.user.id),\n                )\n                await cursor.execute(\n                    \"\"\"\n                    UPDATE farms\n                    SET crop = NULL, plant_time = NULL, level = ?, xp = ?\n                    WHERE guild_id = ? AND user_id = ?\n                    \"\"\",\n                    (current_level, new_xp, guild_id, interaction.user.id),\n                )\n            await self.bot.db.commit()\n\n        await interaction.response.send_message(\n            f\"You harvested your **{crop_data['name']}** and earned 🪙 **{reward}** and **{xp_gain} XP**!\"\n            f\"{event_message}{level_up_message}\"\n        )\n\n    @farm.command(name=\"upgrade\", description=\"Upgrade your farm land for faster growth times.\")\n    async def upgrade(self, interaction: discord.Interaction):\n        guild_id = interaction.guild_id\n        farm_data = await self.get_farm_data(guild_id, interaction.user.id)\n        current_land_level = farm_data[\"land_type\"]\n\n        if current_land_level >= len(self.land_types):\n            await interaction.response.send_message(\"You already have the best land available!\", ephemeral=True)\n            return\n\n        next_land_level = current_land_level + 1\n        upgrade_data = self.land_types[next_land_level]\n\n        economy_cog = self.bot.get_cog(\"Economy\")\n        if not economy_cog:\n            await interaction.response.send_message(\"The economy system is currently unavailable.\", ephemeral=True)\n            return\n\n        async with economy_cog._guild_lock(guild_id):\n            user_balance = await economy_cog._get_or_create_user_unlocked(guild_id, interaction.user.id)\n            if user_balance < upgrade_data[\"cost\"]:\n                await interaction.response.send_message(\n                    f\"You don't have enough coins! Upgrading to **{upgrade_data['name']}** costs 🪙 {upgrade_data['cost']}.\",\n                    ephemeral=True,\n                )\n                return\n\n            async with self.bot.db.cursor() as cursor:\n                await cursor.execute(\n                    \"UPDATE users SET balance = ? WHERE guild_id = ? AND user_id = ?\",\n                    (user_balance - upgrade_data[\"cost\"], guild_id, interaction.user.id),\n                )\n                await cursor.execute(\n                    \"UPDATE farms SET land_type = ? WHERE guild_id = ? AND user_id = ?\",\n                    (next_land_level, guild_id, interaction.user.id),\n                )\n            await self.bot.db.commit()\n\n        await interaction.response.send_message(\n            f\"Congratulations! You've spent 🪙 {upgrade_data['cost']} to upgrade your farm to **{upgrade_data['name']}**!\"\n            \" Your crops will now grow faster.\"\n        )\n\n\nasync def setup(bot: commands.Bot):\n    await bot.add_cog(Farming(bot))\n"
  },
  {
    "path": "cogs/fun.py",
    "content": "import discord\nfrom discord import app_commands\nfrom discord.ext import commands\nimport random\nfrom PIL import Image, ImageDraw, ImageFont\nimport io\nimport textwrap\n\n\nclass Fun(commands.Cog):\n    def __init__(self, bot: commands.Bot):\n        self.bot = bot\n        self.session = bot.http_session\n        self.jokes = [\n            \"Why don't scientists trust atoms? Because they make up everything!\",\n            \"I told my wife she should embrace her mistakes. She gave me a hug.\",\n            \"Why did the scarecrow win an award? Because he was outstanding in his field!\",\n            \"I'm reading a book on anti-gravity. It's impossible to put down!\",\n            \"What do you call a fake noodle? An Impasta!\",\n            \"Why don't skeletons fight each other? They don't have the guts.\",\n            \"What do you call cheese that isn't yours? Nacho cheese.\",\n            \"Why did the bicycle fall over? Because it was two-tired.\",\n            \"How does a penguin build its house? Igloos it together.\",\n            \"I would tell you a joke about an empty pizza box, but it's too cheesy.\",\n            \"What do you get when you cross a snowman and a vampire? Frostbite.\",\n            \"Why are ghosts such bad liars? Because you can see right through them.\",\n            \"What's orange and sounds like a parrot? A carrot.\",\n            \"I invented a new word! Plagiarism.\",\n            \"Did you hear about the mathematician who’s afraid of negative numbers? He’ll stop at nothing to avoid them.\",\n            \"Why do we tell actors to 'break a leg?' Because every play has a cast.\",\n            \"Helvetica and Times New Roman walk into a bar. 'Get out of here!' shouts the bartender. 'We don't serve your type.'\",\n            \"Yesterday I saw a guy spill all his Scrabble letters on the road. I asked him, 'What’s the word on the street?'\",\n            \"What’s the best thing about Switzerland? I don’t know, but the flag is a big plus.\",\n            \"Why did the coffee file a police report? It got mugged.\",\n            \"I'm so good at sleeping, I can do it with my eyes closed.\",\n            \"Why was the big cat disqualified from the race? Because it was a cheetah.\",\n            \"What do you call a bear with no teeth? A gummy bear.\",\n            \"I asked the librarian if the library had any books on paranoia. She whispered, 'They're right behind you!'\",\n            \"What did the zero say to the eight? Nice belt!\",\n            \"What did one wall say to the other? I'll meet you at the corner.\",\n            \"Why did the invisible man turn down the job offer? He couldn't see himself doing it.\",\n            \"I have a joke about construction, but I'm still working on it.\",\n            \"I used to play piano by ear, but now I use my hands.\",\n            \"What do you call a boomerang that won't come back? A stick.\",\n            \"Why did the golfer bring two pairs of pants? In case he got a hole in one.\",\n            \"I'm on a seafood diet. I see food, and I eat it.\",\n            \"What do you call a fish with no eyes? Fsh.\",\n            \"Parallel lines have so much in common. It’s a shame they’ll never meet.\",\n            \"My boss told me to have a good day, so I went home.\",\n            \"Why can't you hear a pterodactyl go to the bathroom? Because the 'P' is silent.\",\n            \"Why did the stadium get hot after the game? Because all the fans left.\",\n            \"What's a vampire's favorite fruit? A neck-tarine.\",\n            \"I don't trust stairs. They're always up to something.\",\n            \"Why did the scarecrow get a promotion? He was outstanding in his field.\",\n            \"What's brown and sticky? A stick.\",\n            \"Why are pirates called pirates? Because they arrrr!\",\n            \"I was wondering why the frisbee was getting bigger. Then it hit me.\",\n            \"What do you call a lazy kangaroo? Pouch potato.\",\n            \"Why was the math book sad? Because it had too many problems.\",\n            \"What did the grape do when it got stepped on? It let out a little wine.\",\n            \"Why don’t eggs tell jokes? They’d crack each other up.\",\n            \"What’s the best way to watch a fly-fishing tournament? Live stream.\",\n            \"What did the janitor say when he jumped out of the closet? 'Supplies!'\",\n            \"I'm reading a horror story in Braille. Something bad is about to happen... I can feel it.\",\n            \"What do you call an alligator in a vest? An investigator.\",\n            \"If you see a robbery at an Apple Store, does that make you an iWitness?\",\n            \"What do you call a sad strawberry? A blueberry.\",\n            \"Why should you never trust a pig with a secret? Because it's bound to squeal.\",\n            \"I got a new job as a human cannonball. They told me I'd be fired.\",\n            \"Why did the Oreo go to the dentist? Because it lost its filling.\",\n            \"How do you organize a space party? You planet.\",\n            \"What has four wheels and flies? A garbage truck.\",\n            \"What do you call a thieving alligator? A crook-o-dile.\",\n            \"I used to be a baker, but I couldn't make enough dough.\",\n            \"I have a fear of speed bumps. I'm slowly getting over it.\",\n            \"Where do you learn to make ice cream? At sundae school.\",\n            \"Why do bees have sticky hair? Because they use a honeycomb.\",\n            \"How do you make a tissue dance? You put a little boogie in it.\",\n            \"Why can’t a bicycle stand up by itself? It's two tired.\",\n            \"Why did the tomato turn red? Because it saw the salad dressing!\",\n            \"What do you call a pony with a cough? A little hoarse.\",\n            \"Why was the belt arrested? For holding up a pair of pants.\",\n            \"How do you find Will Smith in the snow? You look for the fresh prints.\",\n            \"What do you call a man with a rubber toe? Roberto.\",\n            \"Why is it annoying to eat next to basketball players? They're always dribbling.\",\n            \"What do you call a factory that makes okay products? A satisfactory.\",\n            \"I'm terrified of elevators. I'm going to start taking steps to avoid them.\",\n            \"What do you call a dog that does magic tricks? A labracadabrador.\",\n            \"What did the drummer call his twin daughters? Anna one, Anna two!\",\n            \"Why did the cow go to outer space? To see the moooon.\",\n            \"What do you call a sleeping bull? A bulldozer.\",\n            \"Why did the can crusher quit his job? It was soda pressing.\",\n            \"Why did the man get fired from the calendar factory? He took a couple of days off.\",\n            \"What's the difference between a hippo and a zippo? One is really heavy, the other is a little lighter.\",\n            \"I was going to tell a time-traveling joke, but you guys didn't like it.\",\n            \"What do you get from a pampered cow? Spoiled milk.\",\n            \"Why did the octopus beat the shark in a fight? Because it was well-armed.\",\n            \"Why was the baby strawberry crying? Because its parents were in a jam.\",\n        ]\n\n    @app_commands.command(name=\"joke\", description=\"Tells a random joke.\")\n    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)\n    async def joke(self, interaction: discord.Interaction):\n        embed = discord.Embed(\n            title=\"Here's a joke for you!\", description=random.choice(self.jokes), color=discord.Color.orange()\n        )\n        await interaction.response.send_message(embed=embed)\n\n    @app_commands.command(name=\"fact\", description=\"Get a random interesting fact.\")\n    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)\n    async def fact(self, interaction: discord.Interaction):\n        await interaction.response.defer()\n        try:\n            async with self.session.get(\"https://uselessfacts.jsph.pl/random.json?language=en\") as response:\n                if response.status == 200:\n                    data = await response.json()\n                    embed = discord.Embed(\n                        title=\"Did you know?\", description=data.get(\"text\"), color=discord.Color.cyan()\n                    )\n                    await interaction.followup.send(embed=embed)\n                else:\n                    await interaction.followup.send(\n                        \"Could not fetch a fact right now, try again later.\", ephemeral=True\n                    )\n        except Exception:\n            await interaction.followup.send(\"An error occurred while trying to get a fact.\", ephemeral=True)\n\n    @app_commands.command(name=\"avatar\", description=\"Displays a user's avatar.\")\n    @app_commands.describe(member=\"The member whose avatar you want to see.\")\n    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)\n    async def avatar(self, interaction: discord.Interaction, member: discord.Member = None):\n        target_member = member or interaction.user\n        embed = discord.Embed(title=f\"{target_member.name}'s Avatar\", color=target_member.color)\n        embed.set_image(url=target_member.display_avatar.url)\n        await interaction.response.send_message(embed=embed)\n\n    @app_commands.command(name=\"love\", description=\"Calculates the love compatibility between two members.\")\n    @app_commands.describe(member1=\"The first person.\", member2=\"The second person.\")\n    @app_commands.checks.cooldown(1, 10, key=lambda i: i.user.id)\n    async def love(self, interaction: discord.Interaction, member1: discord.Member, member2: discord.Member = None):\n        target_member2 = member2 or interaction.user\n        random.seed(member1.id + target_member2.id)\n        love_percentage = random.randint(0, 100)\n        random.seed()\n\n        emoji = \"💔\"\n        if 40 <= love_percentage < 75:\n            emoji = \"❤️\"\n        elif love_percentage >= 75:\n            emoji = \"💖\"\n\n        embed = discord.Embed(title=\"Love Calculator\", color=discord.Color.red())\n        embed.description = f\"**{member1.name}** + **{target_member2.name}** = **{love_percentage}%** {emoji}\"\n        await interaction.response.send_message(embed=embed)\n\n    @app_commands.command(name=\"emojify\", description=\"Converts your text into emojis.\")\n    @app_commands.describe(text=\"The text to convert.\")\n    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)\n    async def emojify(self, interaction: discord.Interaction, text: str):\n        if len(text) > 50:\n            await interaction.response.send_message(\n                \"Text is too long! Please keep it under 50 characters.\", ephemeral=True\n            )\n            return\n        emojis = \"\"\n        for char in text.lower():\n            if char.isalpha():\n                emojis += f\":regional_indicator_{char}: \"\n            elif char.isdigit():\n                num_map = {\n                    \"0\": \"zero\",\n                    \"1\": \"one\",\n                    \"2\": \"two\",\n                    \"3\": \"three\",\n                    \"4\": \"four\",\n                    \"5\": \"five\",\n                    \"6\": \"six\",\n                    \"7\": \"seven\",\n                    \"8\": \"eight\",\n                    \"9\": \"nine\",\n                }\n                emojis += f\":{num_map[char]}: \"\n            else:\n                emojis += \" \"\n\n        if emojis.strip():\n            await interaction.response.send_message(emojis)\n        else:\n            await interaction.response.send_message(\"Could not emojify the text.\", ephemeral=True)\n\n    @app_commands.command(name=\"poll\", description=\"Creates a poll with up to 10 options.\")\n    @app_commands.describe(\n        question=\"The question for the poll.\",\n        option1=\"The first choice.\",\n        option2=\"The second choice.\",\n        option3=\"Optional third choice.\",\n        option4=\"Optional fourth choice.\",\n        option5=\"Optional fifth choice.\",\n        option6=\"Optional sixth choice.\",\n        option7=\"Optional seventh choice.\",\n        option8=\"Optional eighth choice.\",\n        option9=\"Optional ninth choice.\",\n        option10=\"Optional tenth choice.\",\n    )\n    @app_commands.checks.cooldown(1, 15, key=lambda i: i.channel_id)\n    async def poll(\n        self,\n        interaction: discord.Interaction,\n        question: str,\n        option1: str,\n        option2: str,\n        option3: str = None,\n        option4: str = None,\n        option5: str = None,\n        option6: str = None,\n        option7: str = None,\n        option8: str = None,\n        option9: str = None,\n        option10: str = None,\n    ):\n        # Check Add Reactions permission before creating the poll\n        bot_member = interaction.guild.me if interaction.guild else None\n        if bot_member and interaction.channel:\n            perms = interaction.channel.permissions_for(bot_member)\n            if not perms.add_reactions:\n                await interaction.response.send_message(\n                    \"I need the **Add Reactions** permission in this channel to create polls.\",\n                    ephemeral=True,\n                )\n                return\n\n        options = [\n            opt\n            for opt in [option1, option2, option3, option4, option5, option6, option7, option8, option9, option10]\n            if opt is not None\n        ]\n        reactions = [\"1️⃣\", \"2️⃣\", \"3️⃣\", \"4️⃣\", \"5️⃣\", \"6️⃣\", \"7️⃣\", \"8️⃣\", \"9️⃣\", \"🔟\"]\n        description = [f\"{reactions[i]} {option}\" for i, option in enumerate(options)]\n        embed = discord.Embed(title=question, description=\"\\n\".join(description), color=discord.Color.blurple())\n        embed.set_footer(text=f\"Poll created by {interaction.user.name}\")\n        await interaction.response.send_message(embed=embed)\n        poll_message = await interaction.original_response()\n        for i in range(len(options)):\n            await poll_message.add_reaction(reactions[i])\n\n    @app_commands.command(name=\"clap\", description=\"Adds a clap emoji between each word.\")\n    @app_commands.describe(text=\"The text to clapify.\")\n    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)\n    async def clap(self, interaction: discord.Interaction, text: str):\n        if len(text.split()) < 2:\n            await interaction.response.send_message(\"You need at least two words to clap!\", ephemeral=True)\n            return\n        clapped_text = \" 👏 \".join(text.split())\n        await interaction.response.send_message(clapped_text)\n\n    @app_commands.command(name=\"tweet\", description=\"Generate an image of a fake tweet.\")\n    @app_commands.describe(text=\"The content of the tweet (max 280 chars).\")\n    @app_commands.checks.cooldown(1, 15, key=lambda i: i.user.id)\n    async def tweet(self, interaction: discord.Interaction, text: str):\n        await interaction.response.defer()\n\n        if len(text) > 280:\n            await interaction.followup.send(\"Tweet text cannot exceed 280 characters.\", ephemeral=True)\n            return\n\n        user = interaction.user\n        bg = Image.new(\"RGB\", (1000, 400), color=(21, 32, 43))\n\n        try:\n            async with self.session.get(user.display_avatar.url) as response:\n                if response.status == 200:\n                    avatar_data = await response.read()\n                    avatar = Image.open(io.BytesIO(avatar_data)).convert(\"RGBA\")\n                    avatar = avatar.resize((150, 150))\n\n                    mask = Image.new(\"L\", avatar.size, 0)\n                    draw_mask = ImageDraw.Draw(mask)\n                    draw_mask.ellipse((0, 0) + avatar.size, fill=255)\n                    bg.paste(avatar, (50, 50), mask)\n        except Exception as e:\n            print(f\"Tweet avatar error: {e}\")\n\n        draw = ImageDraw.Draw(bg)\n        try:\n            font_bold = ImageFont.truetype(\"arialbd.ttf\", 60)\n            font_regular = ImageFont.truetype(\"arial.ttf\", 50)\n            font_handle = ImageFont.truetype(\"arial.ttf\", 45)\n        except IOError:\n            print(\"Arial font not found, using default font for tweet command.\")\n            font_bold = ImageFont.load_default(size=60)\n            font_regular = ImageFont.load_default(size=50)\n            font_handle = ImageFont.load_default(size=45)\n\n        draw.text((230, 60), user.display_name, font=font_bold, fill=(255, 255, 255))\n        draw.text((230, 125), f\"@{user.name}\", font=font_handle, fill=(136, 153, 166))\n\n        wrapped_text = textwrap.fill(text, width=35)\n        draw.text((50, 230), wrapped_text, font=font_regular, fill=(255, 255, 255))\n\n        buffer = io.BytesIO()\n        bg.save(buffer, format=\"PNG\")\n        buffer.seek(0)\n\n        await interaction.followup.send(file=discord.File(buffer, \"tweet.png\"))\n\n\nasync def setup(bot: commands.Bot):\n    await bot.add_cog(Fun(bot))\n"
  },
  {
    "path": "cogs/games.py",
    "content": "import discord\nfrom discord import app_commands\nfrom discord.ext import commands\nimport random\nimport asyncio\n\n\nclass TicTacToeButton(discord.ui.Button[\"TicTacToe\"]):\n    def __init__(self, x: int, y: int):\n        super().__init__(style=discord.ButtonStyle.secondary, label=\"\\u200b\", row=y)\n        self.x = x\n        self.y = y\n\n    async def callback(self, interaction: discord.Interaction):\n        assert self.view is not None\n        view: TicTacToe = self.view\n\n        if interaction.user != view.current_player:\n            await interaction.response.send_message(\"It's not your turn!\", ephemeral=True)\n            return\n\n        state = view.board[self.y][self.x]\n        if state in (view.X, view.O):\n            return\n\n        if view.current_player == view.player1:\n            self.style = discord.ButtonStyle.danger\n            self.label = \"X\"\n            view.board[self.y][self.x] = view.X\n            view.current_player = view.player2\n            content = f\"It's {view.player2.name}'s turn (O).\"\n        else:\n            self.style = discord.ButtonStyle.success\n            self.label = \"O\"\n            view.board[self.y][self.x] = view.O\n            view.current_player = view.player1\n            content = f\"It's {view.player1.name}'s turn (X).\"\n\n        self.disabled = True\n        winner = view.check_board_winner()\n        if winner is not None:\n            if winner == view.X:\n                content = f\"🏆 {view.player1.name} wins! 🏆\"\n            elif winner == view.O:\n                content = f\"🏆 {view.player2.name} wins! 🏆\"\n            else:\n                content = \"It's a tie!\"\n\n            for child in view.children:\n                child.disabled = True\n\n            view.stop()\n\n        await interaction.response.edit_message(content=content, view=view)\n\n\nclass TicTacToe(discord.ui.View):\n    X = -1\n    O = 1\n    Tie = 2\n\n    def __init__(self, player1: discord.Member, player2: discord.Member):\n        super().__init__(timeout=180)\n        self.player1 = player1\n        self.player2 = player2\n        self.current_player = player1\n        self.board = [\n            [0, 0, 0],\n            [0, 0, 0],\n            [0, 0, 0],\n        ]\n\n        for x in range(3):\n            for y in range(3):\n                self.add_item(TicTacToeButton(x, y))\n\n    async def on_timeout(self):\n        for item in self.children:\n            item.disabled = True\n\n        message = await self.message.edit(content=\"Game timed out! No one made a move.\", view=self)\n\n    def check_board_winner(self):\n        for i in range(3):\n            if sum(self.board[i]) == 3:\n                return self.O\n            if sum(self.board[i]) == -3:\n                return self.X\n\n        for i in range(3):\n            if self.board[0][i] + self.board[1][i] + self.board[2][i] == 3:\n                return self.O\n            if self.board[0][i] + self.board[1][i] + self.board[2][i] == -3:\n                return self.X\n\n        if self.board[0][0] + self.board[1][1] + self.board[2][2] == 3:\n            return self.O\n        if self.board[0][0] + self.board[1][1] + self.board[2][2] == -3:\n            return self.X\n        if self.board[0][2] + self.board[1][1] + self.board[2][0] == 3:\n            return self.O\n        if self.board[0][2] + self.board[1][1] + self.board[2][0] == -3:\n            return self.X\n\n        if all(i != 0 for row in self.board for i in row):\n            return self.Tie\n\n        return None\n\n\nclass Games(commands.Cog):\n    def __init__(self, bot: commands.Bot):\n        self.bot = bot\n        self.active_guess_games = set()\n\n    @app_commands.command(name=\"eightball\", description=\"Ask the magic 8-ball a question.\")\n    @app_commands.describe(question=\"The question you want to ask.\")\n    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)\n    async def eightball(self, interaction: discord.Interaction, question: str):\n        responses = [\n            \"It is certain.\",\n            \"It is decidedly so.\",\n            \"Without a doubt.\",\n            \"Yes – definitely.\",\n            \"You may rely on it.\",\n            \"As I see it, yes.\",\n            \"Most likely.\",\n            \"Outlook good.\",\n            \"Yes.\",\n            \"Signs point to yes.\",\n            \"Reply hazy, try again.\",\n            \"Ask again later.\",\n            \"Better not tell you now.\",\n            \"Cannot predict now.\",\n            \"Concentrate and ask again.\",\n            \"Don't count on it.\",\n            \"My reply is no.\",\n            \"My sources say no.\",\n            \"Outlook not so good.\",\n            \"Very doubtful.\",\n        ]\n        embed = discord.Embed(title=\"🎱 Magic 8-Ball 🎱\", color=discord.Color.dark_blue())\n        embed.add_field(name=\"Question\", value=question, inline=False)\n        embed.add_field(name=\"Answer\", value=random.choice(responses), inline=False)\n        await interaction.response.send_message(embed=embed)\n\n    @app_commands.command(name=\"coinflip\", description=\"Flips a coin.\")\n    @app_commands.checks.cooldown(1, 3, key=lambda i: i.user.id)\n    async def coinflip(self, interaction: discord.Interaction):\n        result = random.choice([\"Heads\", \"Tails\"])\n        await interaction.response.send_message(f\"The coin landed on: **{result}**\")\n\n    @app_commands.command(name=\"roll\", description=\"Rolls a dice in NdN format (e.g., 2d6).\")\n    @app_commands.describe(dice=\"The dice to roll (e.g., 1d6, 2d8).\")\n    @app_commands.checks.cooldown(1, 3, key=lambda i: i.user.id)\n    async def roll(self, interaction: discord.Interaction, dice: str):\n        try:\n            rolls, limit = map(int, dice.lower().split(\"d\"))\n        except Exception:\n            await interaction.response.send_message(\"Format has to be in NdN (e.g., 1d6)!\", ephemeral=True)\n            return\n\n        if not (1 <= rolls <= 100 and 1 <= limit <= 1000):\n            await interaction.response.send_message(\n                \"Please keep rolls between 1-100 and faces between 1-1000.\", ephemeral=True\n            )\n            return\n\n        results = [random.randint(1, limit) for _ in range(rolls)]\n        total = sum(results)\n        embed = discord.Embed(title=f\"Dice Roll: {dice}\", description=f\"Total: **{total}**\", color=discord.Color.red())\n        embed.add_field(name=\"Individual Rolls\", value=\", \".join(map(str, results)), inline=False)\n        await interaction.response.send_message(embed=embed)\n\n    @app_commands.command(name=\"guess\", description=\"Starts a 'guess the number' game.\")\n    @app_commands.checks.cooldown(1, 60, key=lambda i: i.channel_id)\n    async def guess(self, interaction: discord.Interaction):\n        if interaction.channel.id in self.active_guess_games:\n            await interaction.response.send_message(\"A game is already active in this channel!\", ephemeral=True)\n            return\n\n        self.active_guess_games.add(interaction.channel.id)\n        number_to_guess = random.randint(1, 100)\n        attempts = 0\n\n        await interaction.response.send_message(\n            f\"I've picked a number between 1 and 100. You have 60 seconds to guess it!\"\n        )\n\n        def check(m):\n            return m.channel == interaction.channel and m.author == interaction.user and m.content.isdigit()\n\n        try:\n            while True:\n                guess_msg = await self.bot.wait_for(\"message\", timeout=60.0, check=check)\n                guess = int(guess_msg.content)\n                attempts += 1\n                if guess < number_to_guess:\n                    await guess_msg.reply(\"Too low! Try again.\", delete_after=5)\n                elif guess > number_to_guess:\n                    await guess_msg.reply(\"Too high! Try again.\", delete_after=5)\n                else:\n                    await guess_msg.reply(\n                        f\"🎉 You got it! The number was **{number_to_guess}**. It took you {attempts} attempts.\"\n                    )\n                    self.active_guess_games.remove(interaction.channel.id)\n                    return\n        except asyncio.TimeoutError:\n            if interaction.channel.id in self.active_guess_games:\n                await interaction.followup.send(f\"Time's up! The number was {number_to_guess}.\")\n                self.active_guess_games.remove(interaction.channel.id)\n\n    @app_commands.command(name=\"rps\", description=\"Play Rock, Paper, Scissors with the bot.\")\n    @app_commands.describe(choice=\"Your choice.\")\n    @app_commands.choices(\n        choice=[\n            app_commands.Choice(name=\"Rock\", value=\"rock\"),\n            app_commands.Choice(name=\"Paper\", value=\"paper\"),\n            app_commands.Choice(name=\"Scissors\", value=\"scissors\"),\n        ]\n    )\n    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)\n    async def rockpaperscissors(self, interaction: discord.Interaction, choice: app_commands.Choice[str]):\n        user_choice = choice.value\n        bot_choice = random.choice([\"rock\", \"paper\", \"scissors\"])\n        emoji_map = {\"rock\": \"🗿\", \"paper\": \"📄\", \"scissors\": \"✂️\"}\n        result_text = f\"You chose {emoji_map[user_choice]}, I chose {emoji_map[bot_choice]}.\\n\\n\"\n\n        if user_choice == bot_choice:\n            result_text += \"**It's a tie!**\"\n        elif (\n            (user_choice == \"rock\" and bot_choice == \"scissors\")\n            or (user_choice == \"paper\" and bot_choice == \"rock\")\n            or (user_choice == \"scissors\" and bot_choice == \"paper\")\n        ):\n            result_text += \"**You win!** 🎉\"\n        else:\n            result_text += \"**I win!** 🤖\"\n\n        await interaction.response.send_message(result_text)\n\n    @app_commands.command(name=\"tictactoe\", description=\"Play a game of Tic-Tac-Toe with another member.\")\n    @app_commands.describe(opponent=\"The member you want to challenge.\")\n    @app_commands.checks.cooldown(1, 30, key=lambda i: i.channel_id)\n    async def tictactoe(self, interaction: discord.Interaction, opponent: discord.Member):\n        if opponent == interaction.user:\n            await interaction.response.send_message(\"You can't play against yourself!\", ephemeral=True)\n            return\n        if opponent.bot:\n            await interaction.response.send_message(\n                \"You can't challenge a bot to a game of Tic-Tac-Toe!\", ephemeral=True\n            )\n            return\n\n        view = TicTacToe(interaction.user, opponent)\n        await interaction.response.send_message(\n            f\"Tic-Tac-Toe: {interaction.user.name} vs {opponent.name}\\nIt's {interaction.user.name}'s turn (X).\",\n            view=view,\n        )\n        view.message = await interaction.original_response()\n\n\nasync def setup(bot: commands.Bot):\n    await bot.add_cog(Games(bot))\n"
  },
  {
    "path": "cogs/interactions.py",
    "content": "import discord\nfrom discord import app_commands\nfrom discord.ext import commands\n\n\nclass Interactions(commands.Cog):\n    def __init__(self, bot: commands.Bot):\n        self.bot = bot\n        self.session = bot.http_session\n\n    async def get_gif(self, category: str):\n        try:\n            async with self.session.get(f\"https://api.waifu.pics/sfw/{category}\") as response:\n                if response.status == 200:\n                    data = await response.json()\n                    return data.get(\"url\")\n        except Exception as e:\n            print(f\"Could not fetch GIF for category {category}: {e}\")\n            return None\n\n    async def create_interaction_embed(\n        self,\n        interaction: discord.Interaction,\n        member: discord.Member,\n        category: str,\n        self_message: str,\n        other_message: str,\n    ):\n        await interaction.response.defer()\n        gif_url = await self.get_gif(category)\n\n        if not gif_url:\n            await interaction.followup.send(\n                f\"Sorry, couldn't get a GIF right now. But... {other_message.format(user=interaction.user.mention, target=member.mention)}\",\n                ephemeral=True,\n            )\n            return\n\n        message = (\n            self_message.format(user=interaction.user.mention, target=member.mention)\n            if member == interaction.user\n            else other_message.format(user=interaction.user.mention, target=member.mention)\n        )\n\n        embed = discord.Embed(description=message, color=discord.Color.from_rgb(255, 182, 193))  # Pink\n        embed.set_image(url=gif_url)\n\n        await interaction.followup.send(embed=embed)\n\n    @app_commands.command(name=\"hug\", description=\"Give someone a hug.\")\n    @app_commands.describe(member=\"The person you want to hug.\")\n    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)\n    async def hug(self, interaction: discord.Interaction, member: discord.Member):\n        await self.create_interaction_embed(\n            interaction,\n            member,\n            category=\"hug\",\n            self_message=\"You can't hug yourself, but I can! Here's a hug from me to you.\",\n            other_message=\"{user} gives {target} a big, warm hug!\",\n        )\n\n    @app_commands.command(name=\"pat\", description=\"Pat someone's head.\")\n    @app_commands.describe(member=\"The person you want to pat.\")\n    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)\n    async def pat(self, interaction: discord.Interaction, member: discord.Member):\n        await self.create_interaction_embed(\n            interaction,\n            member,\n            category=\"pat\",\n            self_message=\"Feeling a bit lonely? I'll pat your head for you. *pats*\",\n            other_message=\"{user} gently pats {target}'s head. Aww.\",\n        )\n\n    @app_commands.command(name=\"slap\", description=\"Slap someone.\")\n    @app_commands.describe(member=\"The person you want to slap.\")\n    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)\n    async def slap(self, interaction: discord.Interaction, member: discord.Member):\n        if member == self.bot.user:\n            await interaction.response.send_message(f\"Ouch! What did I do to deserve that, {interaction.user.mention}?\")\n            return\n\n        await self.create_interaction_embed(\n            interaction,\n            member,\n            category=\"slap\",\n            self_message=\"{user} slaps themself in confusion!\",\n            other_message=\"Oof! {user} slaps {target} right across the face!\",\n        )\n\n    @app_commands.command(name=\"kiss\", description=\"Give someone a kiss.\")\n    @app_commands.describe(member=\"The person you want to kiss.\")\n    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)\n    async def kiss(self, interaction: discord.Interaction, member: discord.Member):\n        await self.create_interaction_embed(\n            interaction,\n            member,\n            category=\"kiss\",\n            self_message=\"Blowing a kiss to yourself in the mirror, nice!\",\n            other_message=\"{user} gives {target} a sweet kiss. Mwah!\",\n        )\n\n    @app_commands.command(name=\"cuddle\", description=\"Cuddle with someone.\")\n    @app_commands.describe(member=\"The person you want to cuddle with.\")\n    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)\n    async def cuddle(self, interaction: discord.Interaction, member: discord.Member):\n        await self.create_interaction_embed(\n            interaction,\n            member,\n            category=\"cuddle\",\n            self_message=\"Cuddling with a pillow is nice, but here's a virtual one!\",\n            other_message=\"{user} snuggles up and cuddles with {target}. So cozy!\",\n        )\n\n    @app_commands.command(name=\"poke\", description=\"Poke someone.\")\n    @app_commands.describe(member=\"The person you want to poke.\")\n    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)\n    async def poke(self, interaction: discord.Interaction, member: discord.Member):\n        await self.create_interaction_embed(\n            interaction,\n            member,\n            category=\"poke\",\n            self_message=\"You poke yourself. Why?\",\n            other_message=\"Hey! {user} just poked {target}.\",\n        )\n\n\nasync def setup(bot: commands.Bot):\n    await bot.add_cog(Interactions(bot))\n"
  },
  {
    "path": "cogs/media.py",
    "content": "import logging\n\nimport discord\nfrom discord import app_commands\nfrom discord.ext import commands\n\nlogger = logging.getLogger(__name__)\n\n\nclass Media(commands.Cog):\n    def __init__(self, bot: commands.Bot):\n        self.bot = bot\n        self.session = bot.http_session\n\n    @app_commands.command(name=\"meme\", description=\"Fetches a random meme.\")\n    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)\n    async def meme(self, interaction: discord.Interaction):\n        await interaction.response.defer()\n        try:\n            async with self.session.get(\"https://meme-api.com/gimme\") as response:\n                if response.status == 200:\n                    data = await response.json()\n                    embed = discord.Embed(title=data[\"title\"], url=data[\"postLink\"], color=discord.Color.blue())\n                    embed.set_image(url=data[\"url\"])\n                    embed.set_footer(text=f\"From r/{data['subreddit']} | Upvotes: {data['ups']}\")\n                    await interaction.followup.send(embed=embed)\n                else:\n                    await interaction.followup.send(\"Could not fetch a meme, please try again.\")\n        except Exception:\n            logger.exception(\"Error fetching meme\")\n            await interaction.followup.send(\"An error occurred while fetching a meme. Please try again later.\", ephemeral=True)\n\n    @app_commands.command(name=\"cat\", description=\"Fetches a random cat picture.\")\n    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)\n    async def cat(self, interaction: discord.Interaction):\n        await interaction.response.defer()\n        try:\n            async with self.session.get(\"https://api.thecatapi.com/v1/images/search\") as response:\n                if response.status == 200:\n                    data = await response.json()\n                    embed = discord.Embed(title=\"Here's a random cat!\", color=discord.Color.purple())\n                    embed.set_image(url=data[0][\"url\"])\n                    await interaction.followup.send(embed=embed)\n                else:\n                    await interaction.followup.send(\"Could not fetch a cat picture, the cats are hiding!\")\n        except Exception:\n            logger.exception(\"Error fetching cat picture\")\n            await interaction.followup.send(\"An error occurred while fetching a cat picture. Please try again later.\", ephemeral=True)\n\n    @app_commands.command(name=\"dog\", description=\"Fetches a random dog picture.\")\n    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)\n    async def dog(self, interaction: discord.Interaction):\n        await interaction.response.defer()\n        try:\n            async with self.session.get(\"https://dog.ceo/api/breeds/image/random\") as response:\n                if response.status == 200:\n                    data = await response.json()\n                    embed = discord.Embed(title=\"Here's a random dog!\", color=discord.Color.gold())\n                    embed.set_image(url=data[\"message\"])\n                    await interaction.followup.send(embed=embed)\n                else:\n                    await interaction.followup.send(\"Could not fetch a dog picture, the dogs are playing fetch!\")\n        except Exception:\n            logger.exception(\"Error fetching dog picture\")\n            await interaction.followup.send(\"An error occurred while fetching a dog picture. Please try again later.\", ephemeral=True)\n\n\nasync def setup(bot: commands.Bot):\n    await bot.add_cog(Media(bot))\n"
  },
  {
    "path": "cogs/moderation.py",
    "content": "import json\nimport re\nfrom datetime import timedelta\nfrom typing import Dict, List, Optional\n\nimport discord\nfrom discord import app_commands\nfrom discord.ext import commands\n\n\nINVITE_RE = re.compile(r\"(discord\\.gg/|discord\\.com/invite/)\", re.IGNORECASE)\nLINK_RE = re.compile(r\"https?://\", re.IGNORECASE)\n\n\nclass Moderation(commands.Cog):\n    def __init__(self, bot: commands.Bot):\n        self.bot = bot\n        self.bot.loop.create_task(self.setup_database())\n\n    async def setup_database(self):\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\n                \"\"\"\n                CREATE TABLE IF NOT EXISTS automod_settings (\n                    guild_id INTEGER PRIMARY KEY,\n                    filter_invites INTEGER NOT NULL DEFAULT 0,\n                    filter_links INTEGER NOT NULL DEFAULT 0,\n                    bad_words TEXT,\n                    whitelist_channel_ids TEXT,\n                    action TEXT NOT NULL DEFAULT 'delete'\n                )\n                \"\"\"\n            )\n            await cursor.execute(\n                \"\"\"\n                CREATE TABLE IF NOT EXISTS moderation_warnings (\n                    id INTEGER PRIMARY KEY AUTOINCREMENT,\n                    guild_id INTEGER NOT NULL,\n                    user_id INTEGER NOT NULL,\n                    moderator_id INTEGER NOT NULL,\n                    reason TEXT NOT NULL,\n                    created_at TEXT NOT NULL\n                )\n                \"\"\"\n            )\n            await cursor.execute(\n                \"\"\"\n                CREATE INDEX IF NOT EXISTS idx_moderation_warnings_guild_user_created_at\n                ON moderation_warnings(guild_id, user_id, created_at DESC)\n                \"\"\"\n            )\n        await self.bot.db.commit()\n\n    def _serialize_ids(self, ids: List[int]) -> Optional[str]:\n        cleaned = sorted({int(item) for item in ids})\n        return json.dumps(cleaned) if cleaned else None\n\n    def _deserialize_ids(self, raw: Optional[str]) -> List[int]:\n        if not raw:\n            return []\n        try:\n            values = json.loads(raw)\n        except json.JSONDecodeError:\n            return []\n        return [int(item) for item in values if str(item).isdigit()]\n\n    async def get_automod_settings(self, guild_id: int) -> Dict[str, object]:\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\n                \"\"\"\n                SELECT filter_invites, filter_links, bad_words, whitelist_channel_ids, action\n                FROM automod_settings\n                WHERE guild_id = ?\n                \"\"\",\n                (guild_id,),\n            )\n            row = await cursor.fetchone()\n\n        if row is None:\n            return {\n                \"filter_invites\": False,\n                \"filter_links\": False,\n                \"bad_words\": [],\n                \"whitelist_channel_ids\": [],\n                \"action\": \"delete\",\n            }\n\n        filter_invites, filter_links, bad_words, whitelist_channel_ids, action = row\n        return {\n            \"filter_invites\": bool(filter_invites),\n            \"filter_links\": bool(filter_links),\n            \"bad_words\": [word for word in (bad_words or \"\").split(\",\") if word],\n            \"whitelist_channel_ids\": self._deserialize_ids(whitelist_channel_ids),\n            \"action\": action or \"delete\",\n        }\n\n    async def update_automod(self, guild_id: int, **fields):\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\"INSERT OR IGNORE INTO automod_settings (guild_id) VALUES (?)\", (guild_id,))\n            for field, value in fields.items():\n                await cursor.execute(f\"UPDATE automod_settings SET {field} = ? WHERE guild_id = ?\", (value, guild_id))\n        await self.bot.db.commit()\n\n    async def mutate_whitelist(self, guild_id: int, channel_id: int, add: bool) -> List[int]:\n        settings = await self.get_automod_settings(guild_id)\n        current = set(settings[\"whitelist_channel_ids\"])\n        if add:\n            current.add(channel_id)\n        else:\n            current.discard(channel_id)\n        await self.update_automod(guild_id, whitelist_channel_ids=self._serialize_ids(list(current)))\n        return sorted(current)\n\n    async def add_warning(self, guild_id: int, user_id: int, moderator_id: int, reason: str) -> int:\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\n                \"\"\"\n                INSERT INTO moderation_warnings (guild_id, user_id, moderator_id, reason, created_at)\n                VALUES (?, ?, ?, ?, ?)\n                \"\"\",\n                (guild_id, user_id, moderator_id, reason, discord.utils.utcnow().isoformat()),\n            )\n            warning_id = cursor.lastrowid\n        await self.bot.db.commit()\n        return warning_id\n\n    async def log_action(self, guild: discord.Guild, title: str, description: str, color: discord.Color):\n        community = self.bot.get_cog(\"Community\")\n        if community:\n            await community._log_to_modlog(guild, title, description, color)\n\n    automod = app_commands.Group(name=\"automod\", description=\"Configure basic automod rules.\", guild_only=True)\n\n    @automod.command(name=\"view\", description=\"View the current automod settings.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    async def automod_view(self, interaction: discord.Interaction):\n        settings = await self.get_automod_settings(interaction.guild_id)\n        whitelist = \", \".join(f\"<#{channel_id}>\" for channel_id in settings[\"whitelist_channel_ids\"]) or \"Not set\"\n        bad_words = \", \".join(f\"`{word}`\" for word in settings[\"bad_words\"]) or \"Not set\"\n        embed = discord.Embed(title=\"Automod Settings\", color=discord.Color.orange())\n        embed.add_field(name=\"Invite Filter\", value=\"On\" if settings[\"filter_invites\"] else \"Off\", inline=True)\n        embed.add_field(name=\"Link Filter\", value=\"On\" if settings[\"filter_links\"] else \"Off\", inline=True)\n        embed.add_field(name=\"Action\", value=settings[\"action\"], inline=True)\n        embed.add_field(name=\"Bad Words\", value=bad_words, inline=False)\n        embed.add_field(name=\"Whitelisted Channels\", value=whitelist, inline=False)\n        await interaction.response.send_message(embed=embed, ephemeral=True)\n\n    @automod.command(name=\"toggle-invites\", description=\"Turn invite filtering on or off.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    async def automod_toggle_invites(self, interaction: discord.Interaction, enabled: bool):\n        await self.update_automod(interaction.guild_id, filter_invites=int(enabled))\n        await interaction.response.send_message(f\"Invite filtering is now {'on' if enabled else 'off'}.\", ephemeral=True)\n\n    @automod.command(name=\"toggle-links\", description=\"Turn link filtering on or off.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    async def automod_toggle_links(self, interaction: discord.Interaction, enabled: bool):\n        await self.update_automod(interaction.guild_id, filter_links=int(enabled))\n        await interaction.response.send_message(f\"Link filtering is now {'on' if enabled else 'off'}.\", ephemeral=True)\n\n    @automod.command(name=\"set-action\", description=\"Choose what automod should do when it triggers.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    @app_commands.choices(\n        action=[\n            app_commands.Choice(name=\"Delete\", value=\"delete\"),\n            app_commands.Choice(name=\"Warn\", value=\"warn\"),\n            app_commands.Choice(name=\"Timeout\", value=\"timeout\"),\n        ]\n    )\n    async def automod_set_action(self, interaction: discord.Interaction, action: app_commands.Choice[str]):\n        await self.update_automod(interaction.guild_id, action=action.value)\n        await interaction.response.send_message(f\"Automod action set to `{action.value}`.\", ephemeral=True)\n\n    @automod.command(name=\"set-bad-words\", description=\"Set a comma-separated list of blocked words.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    async def automod_set_bad_words(self, interaction: discord.Interaction, words: str):\n        normalized = \",\".join(sorted({word.strip().lower() for word in words.split(\",\") if word.strip()}))\n        await self.update_automod(interaction.guild_id, bad_words=normalized or None)\n        count = 0 if not normalized else len(normalized.split(\",\"))\n        await interaction.response.send_message(f\"Stored {count} blocked word(s).\", ephemeral=True)\n\n    @automod.command(name=\"clear-bad-words\", description=\"Clear all blocked words.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    async def automod_clear_bad_words(self, interaction: discord.Interaction):\n        await self.update_automod(interaction.guild_id, bad_words=None)\n        await interaction.response.send_message(\"Blocked words cleared.\", ephemeral=True)\n\n    @automod.command(name=\"whitelist-channel\", description=\"Exclude a channel from automod checks.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    async def automod_whitelist_channel(self, interaction: discord.Interaction, channel: discord.TextChannel):\n        channels = await self.mutate_whitelist(interaction.guild_id, channel.id, add=True)\n        await interaction.response.send_message(\n            f\"Automod whitelist updated. {len(channels)} channel(s) are exempt now.\",\n            ephemeral=True,\n        )\n\n    @automod.command(name=\"remove-whitelist-channel\", description=\"Remove a channel from the automod whitelist.\")\n    @app_commands.checks.has_permissions(manage_guild=True)\n    async def automod_remove_whitelist_channel(self, interaction: discord.Interaction, channel: discord.TextChannel):\n        channels = await self.mutate_whitelist(interaction.guild_id, channel.id, add=False)\n        await interaction.response.send_message(\n            f\"Automod whitelist updated. {len(channels)} channel(s) remain exempt.\",\n            ephemeral=True,\n        )\n\n    @app_commands.command(name=\"warn\", description=\"Warn a member and log it.\")\n    @app_commands.guild_only()\n    @app_commands.checks.has_permissions(manage_messages=True)\n    async def warn(self, interaction: discord.Interaction, member: discord.Member, reason: str):\n        warning_id = await self.add_warning(interaction.guild_id, member.id, interaction.user.id, reason)\n        await self.log_action(\n            interaction.guild,\n            \"Member Warned\",\n            f\"{member.mention} was warned by {interaction.user.mention}.\\nReason: {reason}\\nWarning ID: `{warning_id}`\",\n            discord.Color.orange(),\n        )\n        await interaction.response.send_message(\n            f\"Warning `{warning_id}` recorded for {member.mention}.\",\n            ephemeral=True,\n        )\n\n    @app_commands.command(name=\"warnings\", description=\"View warning history for a member.\")\n    @app_commands.guild_only()\n    @app_commands.checks.has_permissions(manage_messages=True)\n    async def warnings(self, interaction: discord.Interaction, member: discord.Member):\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\n                \"\"\"\n                SELECT id, moderator_id, reason, created_at\n                FROM moderation_warnings\n                WHERE guild_id = ? AND user_id = ?\n                ORDER BY created_at DESC\n                LIMIT 10\n                \"\"\",\n                (interaction.guild_id, member.id),\n            )\n            rows = await cursor.fetchall()\n\n        if not rows:\n            await interaction.response.send_message(f\"{member.mention} has no warnings in this server.\", ephemeral=True)\n            return\n\n        lines = []\n        for warning_id, moderator_id, reason, created_at in rows:\n            moderator = interaction.guild.get_member(moderator_id)\n            moderator_label = moderator.mention if moderator else f\"`{moderator_id}`\"\n            lines.append(\n                f\"`{warning_id}` • {discord.utils.format_dt(discord.utils.parse_time(created_at), style='R')} • {moderator_label}\\n{reason}\"\n            )\n\n        embed = discord.Embed(title=f\"Warnings for {member}\", description=\"\\n\\n\".join(lines), color=discord.Color.orange())\n        await interaction.response.send_message(embed=embed, ephemeral=True)\n\n    @app_commands.command(name=\"clear-warning\", description=\"Delete a single warning by id.\")\n    @app_commands.guild_only()\n    @app_commands.checks.has_permissions(manage_messages=True)\n    async def clear_warning(self, interaction: discord.Interaction, warning_id: int):\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\n                \"DELETE FROM moderation_warnings WHERE id = ? AND guild_id = ?\",\n                (warning_id, interaction.guild_id),\n            )\n            deleted = cursor.rowcount\n        await self.bot.db.commit()\n        if not deleted:\n            await interaction.response.send_message(\"Warning not found.\", ephemeral=True)\n            return\n\n        await self.log_action(\n            interaction.guild,\n            \"Warning Cleared\",\n            f\"{interaction.user.mention} removed warning `{warning_id}`.\",\n            discord.Color.green(),\n        )\n        await interaction.response.send_message(f\"Warning `{warning_id}` removed.\", ephemeral=True)\n\n    async def handle_violation(self, message: discord.Message, reason: str, action: str):\n        deleted = False\n        try:\n            await message.delete()\n            deleted = True\n        except (discord.Forbidden, discord.HTTPException):\n            deleted = False\n\n        details = [f\"User: {message.author.mention}\", f\"Channel: {message.channel.mention}\", f\"Reason: {reason}\"]\n        if not deleted:\n            details.append(\"Action skipped because the original message could not be deleted\")\n            await self.log_action(message.guild, \"Automod Triggered\", \"\\n\".join(details), discord.Color.red())\n            return\n\n        if action == \"warn\":\n            warning_id = await self.add_warning(message.guild.id, message.author.id, self.bot.user.id, f\"Automod: {reason}\")\n            details.append(f\"Warning ID: `{warning_id}`\")\n            try:\n                await message.channel.send(\n                    f\"{message.author.mention}, your message was removed by automod: {reason}\",\n                    delete_after=10,\n                )\n            except (discord.Forbidden, discord.HTTPException):\n                pass\n        elif action == \"timeout\" and isinstance(message.author, discord.Member):\n            try:\n                await message.author.timeout(discord.utils.utcnow() + timedelta(minutes=10), reason=f\"Automod: {reason}\")\n                details.append(\"Action: timeout\")\n            except (discord.Forbidden, discord.HTTPException):\n                details.append(\"Action: timeout failed, message removed only\")\n                try:\n                    await message.channel.send(\n                        f\"{message.author.mention}, automod tried to time you out but lacked permission. Message removed.\",\n                        delete_after=10,\n                    )\n                except (discord.Forbidden, discord.HTTPException):\n                    pass\n\n        await self.log_action(message.guild, \"Automod Triggered\", \"\\n\".join(details), discord.Color.red())\n\n    @commands.Cog.listener(\"on_message\")\n    async def automod_listener(self, message: discord.Message):\n        if message.author.bot or message.guild is None:\n            return\n        if not isinstance(message.author, discord.Member):\n            return\n        if not isinstance(message.channel, (discord.TextChannel, discord.Thread)):\n            return\n        if message.author.guild_permissions.manage_messages:\n            return\n\n        settings = await self.get_automod_settings(message.guild.id)\n        whitelisted = settings[\"whitelist_channel_ids\"]\n        parent_id = getattr(message.channel, \"parent_id\", None)\n        if message.channel.id in whitelisted or (parent_id and parent_id in whitelisted):\n            return\n\n        lowered = message.content.lower()\n        violation = None\n        if settings[\"filter_invites\"] and INVITE_RE.search(message.content):\n            violation = \"Discord invite links are not allowed.\"\n        elif settings[\"filter_links\"] and LINK_RE.search(message.content):\n            violation = \"Links are not allowed.\"\n        else:\n            for word in settings[\"bad_words\"]:\n                if not word:\n                    continue\n                pattern = re.compile(rf\"(?<!\\w){re.escape(word)}(?!\\w)\", re.IGNORECASE)\n                if pattern.search(lowered):\n                    violation = f\"Blocked word detected: `{word}`\"\n                    break\n\n        if violation:\n            await self.handle_violation(message, violation, settings[\"action\"])\n\n\nasync def setup(bot: commands.Bot):\n    await bot.add_cog(Moderation(bot))\n"
  },
  {
    "path": "cogs/utility.py",
    "content": "import platform\nimport re\nimport sys\nimport time\nfrom datetime import timedelta\nfrom typing import Optional, Union\n\nimport discord\nfrom discord import app_commands\nfrom discord.ext import commands, tasks\n\n\nREMINDER_LIMIT_SECONDS = 30 * 24 * 60 * 60\nREMINDER_POLL_SECONDS = 30\nREMINDER_BATCH_SIZE = 20\nMAX_REMINDER_DELIVERY_FAILURES = 5\nMAX_REMINDER_TEXT_LENGTH = 1500\nREMINDER_RETRY_BASE_SECONDS = 300\nREMINDER_RETRY_MAX_SECONDS = 21600\n\n\ndef parse_duration_spec(value: str) -> Optional[int]:\n    match = re.fullmatch(r\"(\\d+)([smhd])\", value.lower())\n    if not match:\n        return None\n    quantity, unit = match.groups()\n    return int(quantity) * {\"s\": 1, \"m\": 60, \"h\": 3600, \"d\": 86400}[unit]\n\n\nclass Utility(commands.Cog):\n    def __init__(self, bot: commands.Bot):\n        self.bot = bot\n        self.afk_cache: dict[tuple[int, int], tuple[str, str]] = {}\n        if not hasattr(bot, \"start_time\"):\n            self.bot.start_time = discord.utils.utcnow()\n        self.reminder_loop.start()\n\n    def cog_unload(self):\n        self.reminder_loop.cancel()\n\n    async def setup_database(self):\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\n                \"\"\"\n                CREATE TABLE IF NOT EXISTS reminders (\n                    id INTEGER PRIMARY KEY AUTOINCREMENT,\n                    user_id INTEGER NOT NULL,\n                    channel_id INTEGER,\n                    guild_id INTEGER,\n                    remind_at TEXT NOT NULL,\n                    reason TEXT NOT NULL,\n                    recurring_seconds INTEGER,\n                    delivery_failures INTEGER NOT NULL DEFAULT 0\n                )\n                \"\"\"\n            )\n            await cursor.execute(\n                \"\"\"\n                CREATE TABLE IF NOT EXISTS afk_statuses (\n                    guild_id INTEGER NOT NULL,\n                    user_id INTEGER NOT NULL,\n                    reason TEXT NOT NULL,\n                    set_at TEXT NOT NULL,\n                    PRIMARY KEY (guild_id, user_id)\n                )\n                \"\"\"\n            )\n            await cursor.execute(\"PRAGMA table_info(reminders)\")\n            reminder_columns = [row[1] for row in await cursor.fetchall()]\n            if \"recurring_seconds\" not in reminder_columns:\n                await cursor.execute(\"ALTER TABLE reminders ADD COLUMN recurring_seconds INTEGER\")\n            if \"delivery_failures\" not in reminder_columns:\n                await cursor.execute(\"ALTER TABLE reminders ADD COLUMN delivery_failures INTEGER NOT NULL DEFAULT 0\")\n            if \"disabled\" not in reminder_columns:\n                await cursor.execute(\"ALTER TABLE reminders ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0\")\n            if \"last_error\" not in reminder_columns:\n                await cursor.execute(\"ALTER TABLE reminders ADD COLUMN last_error TEXT\")\n            await cursor.execute(\n                \"CREATE INDEX IF NOT EXISTS idx_reminders_due ON reminders(disabled, remind_at)\"\n            )\n            await cursor.execute(\n                \"CREATE INDEX IF NOT EXISTS idx_reminders_user_remind_at ON reminders(user_id, remind_at)\"\n            )\n        await self.bot.db.commit()\n\n    async def get_afk_status(self, guild_id: int, user_id: int):\n        cache_key = (guild_id, user_id)\n        if cache_key in self.afk_cache:\n            return self.afk_cache[cache_key]\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\n                \"SELECT reason, set_at FROM afk_statuses WHERE guild_id = ? AND user_id = ?\",\n                (guild_id, user_id),\n            )\n            row = await cursor.fetchone()\n        if row:\n            self.afk_cache[cache_key] = row\n        return row\n\n    async def get_many_afk_statuses(self, guild_id: int, user_ids: list[int]) -> dict[int, tuple[str, str]]:\n        statuses: dict[int, tuple[str, str]] = {}\n        missing_ids = []\n        for user_id in user_ids:\n            cache_key = (guild_id, user_id)\n            cached = self.afk_cache.get(cache_key)\n            if cached:\n                statuses[user_id] = cached\n            else:\n                missing_ids.append(user_id)\n\n        if not missing_ids:\n            return statuses\n\n        placeholders = \",\".join(\"?\" for _ in missing_ids)\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\n                f\"\"\"\n                SELECT user_id, reason, set_at\n                FROM afk_statuses\n                WHERE guild_id = ? AND user_id IN ({placeholders})\n                \"\"\",\n                [guild_id, *missing_ids],\n            )\n            rows = await cursor.fetchall()\n\n        for user_id, reason, set_at in rows:\n            status = (reason, set_at)\n            self.afk_cache[(guild_id, user_id)] = status\n            statuses[user_id] = status\n\n        return statuses\n\n    async def set_afk_status(self, guild_id: int, user_id: int, reason: str):\n        set_at = discord.utils.utcnow().isoformat()\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\n                \"\"\"\n                INSERT INTO afk_statuses (guild_id, user_id, reason, set_at)\n                VALUES (?, ?, ?, ?)\n                ON CONFLICT(guild_id, user_id)\n                DO UPDATE SET reason = excluded.reason, set_at = excluded.set_at\n                \"\"\",\n                (guild_id, user_id, reason, set_at),\n            )\n        await self.bot.db.commit()\n        self.afk_cache[(guild_id, user_id)] = (reason, set_at)\n\n    async def clear_afk_status(self, guild_id: int, user_id: int):\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\n                \"DELETE FROM afk_statuses WHERE guild_id = ? AND user_id = ?\",\n                (guild_id, user_id),\n            )\n        await self.bot.db.commit()\n        self.afk_cache.pop((guild_id, user_id), None)\n\n    async def list_user_reminders(self, user_id: int):\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\n                \"\"\"\n                SELECT id, guild_id, channel_id, remind_at, reason, recurring_seconds, delivery_failures, disabled, last_error\n                FROM reminders\n                WHERE user_id = ?\n                ORDER BY remind_at ASC\n                \"\"\",\n                (user_id,),\n            )\n            return await cursor.fetchall()\n\n    def reminder_retry_delay(self, failures: int) -> timedelta:\n        seconds = min(REMINDER_RETRY_BASE_SECONDS * (2 ** max(failures - 1, 0)), REMINDER_RETRY_MAX_SECONDS)\n        return timedelta(seconds=seconds)\n\n    async def create_reminder(\n        self,\n        user_id: int,\n        channel_id: Optional[int],\n        guild_id: Optional[int],\n        remind_at,\n        reason: str,\n        recurring_seconds: Optional[int] = None,\n    ) -> int:\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\n                \"\"\"\n                INSERT INTO reminders (user_id, channel_id, guild_id, remind_at, reason, recurring_seconds)\n                VALUES (?, ?, ?, ?, ?, ?)\n                \"\"\",\n                (user_id, channel_id, guild_id, remind_at.isoformat(), reason, recurring_seconds),\n            )\n            reminder_id = cursor.lastrowid\n        await self.bot.db.commit()\n        return reminder_id\n\n    help_group = app_commands.Group(name=\"help\", description=\"Get help with the bot's commands.\")\n    reminders_group = app_commands.Group(name=\"reminders\", description=\"Manage your active reminders.\")\n\n    @app_commands.command(name=\"ping\", description=\"Checks the bot's latency.\")\n    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)\n    async def ping(self, interaction: discord.Interaction):\n        latency = self.bot.latency * 1000\n        await interaction.response.send_message(f\"Pong! Latency: {latency:.2f}ms\")\n\n    @app_commands.command(name=\"ping_raw\", description=\"Checks the bot's detailed latency (Websocket & REST).\")\n    @app_commands.checks.cooldown(1, 10, key=lambda i: i.user.id)\n    async def ping_raw(self, interaction: discord.Interaction):\n        ws_latency = self.bot.latency * 1000\n        rest_latency = \"N/A\"\n\n        session = getattr(self.bot, \"http_session\", None)\n        if session and not session.closed:\n            started = time.perf_counter()\n            try:\n                async with session.get(\"https://discord.com/api/v10/gateway\") as response:\n                    if 200 <= response.status < 500:\n                        rest_latency = f\"{(time.perf_counter() - started) * 1000:.2f}\"\n            except Exception:\n                rest_latency = \"N/A\"\n\n        embed = discord.Embed(title=\"🏓 Pong!\", color=discord.Color.blue())\n        embed.add_field(name=\"WebSocket Latency\", value=f\"{ws_latency:.2f}ms\", inline=False)\n        embed.add_field(name=\"REST API Latency\", value=f\"{rest_latency}ms\", inline=False)\n\n        await interaction.response.send_message(embed=embed)\n\n    @app_commands.command(name=\"memberinfo\", description=\"Displays information about a member.\")\n    @app_commands.guild_only()\n    @app_commands.describe(member=\"The member to get info about.\")\n    @app_commands.checks.cooldown(1, 10, key=lambda i: i.user.id)\n    async def memberinfo(self, interaction: discord.Interaction, member: discord.Member = None):\n        target_member = member or interaction.user\n\n        embed = discord.Embed(color=target_member.color, timestamp=discord.utils.utcnow())\n        embed.set_author(name=f\"User Info - {target_member}\")\n        if target_member.display_avatar:\n            embed.set_thumbnail(url=target_member.display_avatar.url)\n        embed.set_footer(\n            text=f\"Requested by {interaction.user.name}\",\n            icon_url=interaction.user.avatar.url if interaction.user.avatar else None,\n        )\n\n        embed.add_field(name=\"ID\", value=target_member.id, inline=False)\n        embed.add_field(name=\"Display Name\", value=target_member.display_name, inline=True)\n        embed.add_field(name=\"Bot?\", value=target_member.bot, inline=True)\n        embed.add_field(name=\"Created At\", value=discord.utils.format_dt(target_member.created_at, style=\"F\"), inline=False)\n\n        if target_member.joined_at:\n            embed.add_field(name=\"Joined At\", value=discord.utils.format_dt(target_member.joined_at, style=\"F\"), inline=False)\n\n        roles = [role.mention for role in reversed(target_member.roles[1:])]\n        roles_str = \", \".join(roles) if roles else \"No roles\"\n        embed.add_field(\n            name=f\"Roles [{len(roles)}]\",\n            value=roles_str if len(roles_str) < 1024 else f\"{len(roles)} roles (too many to display)\",\n            inline=False,\n        )\n\n        await interaction.response.send_message(embed=embed)\n\n    @app_commands.command(name=\"clear\", description=\"Deletes a specified number of messages.\")\n    @app_commands.guild_only()\n    @app_commands.describe(amount=\"The number of messages to delete (1-100).\")\n    @app_commands.checks.has_permissions(manage_messages=True)\n    async def clear(self, interaction: discord.Interaction, amount: app_commands.Range[int, 1, 100]):\n        me = interaction.guild.me or interaction.guild.get_member(self.bot.user.id)\n        if me is None or not interaction.channel.permissions_for(me).manage_messages:\n            await interaction.response.send_message(\n                \"I don't have permission to manage messages in this channel.\", ephemeral=True\n            )\n            return\n\n        await interaction.response.defer(ephemeral=True)\n        deleted = await interaction.channel.purge(limit=amount)\n        await interaction.followup.send(f\"Successfully deleted {len(deleted)} messages.\", ephemeral=True)\n\n    @app_commands.command(name=\"serverinfo\", description=\"Displays detailed information about the server.\")\n    @app_commands.guild_only()\n    @app_commands.checks.cooldown(1, 10, key=lambda i: i.guild_id)\n    async def serverinfo(self, interaction: discord.Interaction):\n        guild = interaction.guild\n        embed = discord.Embed(\n            title=f\"Server Info: {guild.name}\", color=discord.Color.blue(), timestamp=discord.utils.utcnow()\n        )\n\n        if guild.icon:\n            embed.set_thumbnail(url=guild.icon.url)\n\n        owner_text = guild.owner.mention if guild.owner else f\"ID: {guild.owner_id}\"\n        embed.add_field(name=\"Owner\", value=owner_text, inline=True)\n        embed.add_field(name=\"ID\", value=guild.id, inline=True)\n        embed.add_field(name=\"Created At\", value=discord.utils.format_dt(guild.created_at, \"F\"), inline=False)\n\n        total_members = guild.member_count if guild.member_count is not None else len(guild.members)\n        bots = sum(1 for member in guild.members if member.bot) if guild.members else 0\n        humans = total_members - bots\n        embed.add_field(name=\"Members\", value=f\"Total: {total_members}\\nHumans: {humans}\\nBots: {bots}\", inline=True)\n\n        channels_total = len(guild.text_channels) + len(guild.voice_channels)\n        channels_total += len(guild.stage_channels) if hasattr(guild, \"stage_channels\") else 0\n        channels_total += len(guild.forum_channels) if hasattr(guild, \"forum_channels\") else 0\n        embed.add_field(\n            name=\"Channels\",\n            value=(\n                f\"Total: {channels_total}\\nText: {len(guild.text_channels)}\\nVoice: {len(guild.voice_channels)}\\n\"\n                f\"Stage: {len(guild.stage_channels) if hasattr(guild, 'stage_channels') else 0}\\n\"\n                f\"Forum: {len(guild.forum_channels) if hasattr(guild, 'forum_channels') else 0}\"\n            ),\n            inline=True,\n        )\n        embed.add_field(name=\"Roles\", value=len(guild.roles), inline=True)\n\n        if guild.features:\n            embed.add_field(\n                name=\"Features\",\n                value=\", \".join(feature.replace(\"_\", \" \").title() for feature in guild.features),\n                inline=False,\n            )\n\n        embed.set_footer(\n            text=f\"Requested by {interaction.user.name}\",\n            icon_url=interaction.user.avatar.url if interaction.user.avatar else None,\n        )\n        await interaction.response.send_message(embed=embed)\n\n    @app_commands.command(name=\"botinfo\", description=\"Displays information and stats about the bot.\")\n    @app_commands.checks.cooldown(1, 10, key=lambda i: i.user.id)\n    async def botinfo(self, interaction: discord.Interaction):\n        delta_uptime = discord.utils.utcnow() - self.bot.start_time\n        days = delta_uptime.days\n        hours, remainder = divmod(int(delta_uptime.total_seconds() % 86400), 3600)\n        minutes, seconds = divmod(remainder, 60)\n        uptime_str = f\"{days}d {hours}h {minutes}m {seconds}s\"\n\n        embed = discord.Embed(\n            title=f\"{self.bot.user.name} Stats\", color=discord.Color.purple(), timestamp=discord.utils.utcnow()\n        )\n        if self.bot.user.avatar:\n            embed.set_thumbnail(url=self.bot.user.avatar.url)\n\n        embed.add_field(name=\"Developer\", value=\"Sentinel Team\", inline=True)\n        embed.add_field(name=\"Servers\", value=str(len(self.bot.guilds)), inline=True)\n        embed.add_field(name=\"Total Users\", value=str(len(self.bot.users)), inline=True)\n        embed.add_field(name=\"Python Version\", value=sys.version.split(\" \")[0], inline=True)\n        embed.add_field(name=\"discord.py Version\", value=discord.__version__, inline=True)\n        embed.add_field(name=\"Uptime\", value=uptime_str, inline=True)\n\n        await interaction.response.send_message(embed=embed)\n\n    @app_commands.command(name=\"uptime\", description=\"Shows how long the bot has been online, and server info.\")\n    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)\n    async def uptime(self, interaction: discord.Interaction):\n        delta_uptime = discord.utils.utcnow() - self.bot.start_time\n        days = delta_uptime.days\n        hours, remainder = divmod(int(delta_uptime.total_seconds() % 86400), 3600)\n        minutes, seconds = divmod(remainder, 60)\n        uptime_str = f\"{days}d {hours}h {minutes}m {seconds}s\"\n\n        embed = discord.Embed(title=\"Bot Uptime & System Info\", color=discord.Color.teal())\n        embed.add_field(name=\"Bot Uptime\", value=f\"**{uptime_str}**\", inline=False)\n        embed.add_field(\n            name=\"Operating System\",\n            value=f\"{platform.system()} {platform.release()} ({platform.version()})\",\n            inline=False,\n        )\n        embed.add_field(name=\"Architecture\", value=platform.machine(), inline=False)\n\n        await interaction.response.send_message(embed=embed)\n\n    def _iter_commands(self):\n        stack = list(self.bot.tree.get_commands())\n        while stack:\n            command = stack.pop(0)\n            yield command\n            if isinstance(command, app_commands.Group):\n                stack[0:0] = command.commands\n\n    def _get_full_command_name(self, command: Union[app_commands.Command, app_commands.Group]) -> str:\n        parts = [command.name]\n        parent = command.parent\n        while parent is not None:\n            parts.append(parent.name)\n            parent = parent.parent\n        return \" \".join(reversed(parts))\n\n    def _command_support_label(self, command: Union[app_commands.Command, app_commands.Group]) -> str:\n        checks = getattr(command, \"checks\", [])\n        if any(getattr(check, \"__qualname__\", \"\").endswith(\"guild_only.<locals>.predicate\") for check in checks):\n            return \"Servers only\"\n        return \"Servers and DMs\"\n\n    def _command_permission_label(self, command: Union[app_commands.Command, app_commands.Group]) -> Optional[str]:\n        checks = getattr(command, \"checks\", [])\n        for check in checks:\n            qualname = getattr(check, \"__qualname__\", \"\")\n            if \"has_permissions\" in qualname:\n                closure = getattr(check, \"__closure__\", None) or []\n                for cell in closure:\n                    if isinstance(cell.cell_contents, dict) and cell.cell_contents:\n                        return \", \".join(name.replace(\"_\", \" \").title() for name in cell.cell_contents.keys())\n        return None\n\n    async def command_autocomplete(\n        self, interaction: discord.Interaction, current: str\n    ) -> list[app_commands.Choice[str]]:\n        current_lower = current.lower()\n        return [\n            app_commands.Choice(name=self._get_full_command_name(command), value=self._get_full_command_name(command))\n            for command in self._iter_commands()\n            if current_lower in self._get_full_command_name(command).lower()\n        ][:25]\n\n    @help_group.command(name=\"all\", description=\"Lists all available commands.\")\n    async def help_all(self, interaction: discord.Interaction):\n        embeds = []\n        embed = discord.Embed(\n            title=\"Bot Commands\",\n            description=\"Full command list grouped by cog.\",\n            color=discord.Color.blurple(),\n        )\n        embed_char_count = len(embed.title or \"\") + len(embed.description or \"\")\n        field_count = 0\n\n        for cog_name in sorted(self.bot.cogs.keys()):\n            cog = self.bot.get_cog(cog_name)\n            expanded = []\n            for command in cog.get_app_commands():\n                if isinstance(command, app_commands.Group):\n                    expanded.extend(f\"`/{self._get_full_command_name(sub)}`\" for sub in self._iter_group_commands(command))\n                else:\n                    expanded.append(f\"`/{self._get_full_command_name(command)}`\")\n\n            if not expanded:\n                continue\n\n            value = \" \".join(expanded)\n            if len(value) > 1024:\n                value = value[:1021] + \"...\"\n\n            field_chars = len(cog_name) + len(value)\n            if field_count >= 25 or embed_char_count + field_chars > 6000:\n                embeds.append(embed)\n                embed = discord.Embed(\n                    title=\"Bot Commands (continued)\",\n                    color=discord.Color.blurple(),\n                )\n                embed_char_count = len(embed.title or \"\")\n                field_count = 0\n\n            embed.add_field(name=cog_name, value=value, inline=False)\n            embed_char_count += field_chars\n            field_count += 1\n\n        embeds.append(embed)\n\n        await interaction.response.send_message(embed=embeds[0], ephemeral=True)\n        for extra in embeds[1:]:\n            await interaction.followup.send(embed=extra, ephemeral=True)\n\n    def _iter_group_commands(self, group: app_commands.Group):\n        stack = list(group.commands)\n        while stack:\n            command = stack.pop(0)\n            yield command\n            if isinstance(command, app_commands.Group):\n                stack[0:0] = command.commands\n\n    @help_group.command(name=\"command\", description=\"Get detailed help for a specific command.\")\n    @app_commands.autocomplete(command=command_autocomplete)\n    @app_commands.describe(command=\"The command you need help with.\")\n    async def help_command(self, interaction: discord.Interaction, command: str):\n        target = None\n        for item in self._iter_commands():\n            if self._get_full_command_name(item) == command:\n                target = item\n                break\n\n        if target is None:\n            await interaction.response.send_message(f\"Command `{command}` not found.\", ephemeral=True)\n            return\n\n        embed = discord.Embed(\n            title=f\"Help: `/{self._get_full_command_name(target)}`\",\n            description=target.description or \"No description provided.\",\n            color=discord.Color.blurple(),\n        )\n        embed.add_field(name=\"Availability\", value=self._command_support_label(target), inline=False)\n\n        permission_label = self._command_permission_label(target)\n        if permission_label:\n            embed.add_field(name=\"Required Permissions\", value=permission_label, inline=False)\n\n        if isinstance(target, app_commands.Group):\n            subcommands = [f\"`/{self._get_full_command_name(item)}`\" for item in self._iter_group_commands(target)]\n            if subcommands:\n                embed.add_field(name=\"Subcommands\", value=\"\\n\".join(subcommands), inline=False)\n        elif target.parameters:\n            params = [f\"`{param.name}`: {param.description or 'No description'}\" for param in target.parameters]\n            embed.add_field(name=\"Parameters\", value=\"\\n\".join(params), inline=False)\n\n        await interaction.response.send_message(embed=embed, ephemeral=True)\n\n    @app_commands.command(name=\"remindme\", description=\"Sets a reminder for you.\")\n    @app_commands.describe(time=\"When to remind (e.g., 10s, 5m, 2h, 1d).\", reason=\"What to be reminded of.\")\n    async def remindme(self, interaction: discord.Interaction, time: str, reason: str):\n        seconds = parse_duration_spec(time)\n        if seconds is None:\n            await interaction.response.send_message(\n                \"Invalid time format. Use s, m, h, or d (e.g., `10s`, `5m`, `2h`, `1d`).\",\n                ephemeral=True,\n            )\n            return\n\n        if seconds > REMINDER_LIMIT_SECONDS:\n            await interaction.response.send_message(\"You cannot set a reminder for more than 30 days.\", ephemeral=True)\n            return\n\n        if len(reason) > MAX_REMINDER_TEXT_LENGTH:\n            await interaction.response.send_message(\n                f\"Reminder text is too long ({len(reason)} chars). Maximum is {MAX_REMINDER_TEXT_LENGTH}.\",\n                ephemeral=True,\n            )\n            return\n\n        remind_at = discord.utils.utcnow() + timedelta(seconds=seconds)\n        reminder_id = await self.create_reminder(\n            interaction.user.id,\n            interaction.channel_id,\n            interaction.guild_id,\n            remind_at,\n            reason,\n        )\n\n        await interaction.response.send_message(\n            f\"Reminder `{reminder_id}` created for **{time.lower()}** from now: `{reason}`\",\n            ephemeral=True,\n        )\n\n    @reminders_group.command(name=\"recurring\", description=\"Create a recurring reminder.\")\n    @app_commands.describe(interval=\"How often to repeat, like 30m, 2h, or 1d.\", reason=\"What to be reminded of.\")\n    async def reminders_recurring(self, interaction: discord.Interaction, interval: str, reason: str):\n        seconds = parse_duration_spec(interval)\n        if seconds is None:\n            await interaction.response.send_message(\n                \"Invalid interval format. Use s, m, h, or d (e.g., `30m`, `2h`, `1d`).\",\n                ephemeral=True,\n            )\n            return\n        if seconds > REMINDER_LIMIT_SECONDS:\n            await interaction.response.send_message(\"Recurring interval cannot be more than 30 days.\", ephemeral=True)\n            return\n\n        if len(reason) > MAX_REMINDER_TEXT_LENGTH:\n            await interaction.response.send_message(\n                f\"Reminder text is too long ({len(reason)} chars). Maximum is {MAX_REMINDER_TEXT_LENGTH}.\",\n                ephemeral=True,\n            )\n            return\n\n        remind_at = discord.utils.utcnow() + timedelta(seconds=seconds)\n        reminder_id = await self.create_reminder(\n            interaction.user.id,\n            interaction.channel_id,\n            interaction.guild_id,\n            remind_at,\n            reason,\n            recurring_seconds=seconds,\n        )\n        await interaction.response.send_message(\n            f\"Recurring reminder `{reminder_id}` created every **{interval.lower()}**: `{reason}`\",\n            ephemeral=True,\n        )\n\n    @reminders_group.command(name=\"list\", description=\"List your active reminders.\")\n    async def reminders_list(self, interaction: discord.Interaction):\n        reminders = await self.list_user_reminders(interaction.user.id)\n        if not reminders:\n            await interaction.response.send_message(\"You do not have any active reminders.\", ephemeral=True)\n            return\n\n        lines = []\n        for reminder_id, guild_id, channel_id, remind_at, reason, recurring_seconds, delivery_failures, disabled, last_error in reminders[:20]:\n            if guild_id is None:\n                scope = \"DM\"\n            else:\n                guild = self.bot.get_guild(guild_id)\n                scope = guild.name if guild else f\"Guild `{guild_id}`\"\n            channel_text = f\", channel <#{channel_id}>\" if channel_id else \"\"\n            recurring_text = \"\" if not recurring_seconds else f\" • repeats every `{int(recurring_seconds)}s`\"\n            failure_text = \"\" if not delivery_failures else f\" • delivery failures: {delivery_failures}\"\n            disabled_text = \" • paused after repeated delivery failures\" if disabled else \"\"\n            error_text = \"\" if not last_error else f\"\\nLast error: {last_error[:120]}\"\n            lines.append(\n                f\"`{reminder_id}` • {discord.utils.format_dt(discord.utils.parse_time(remind_at), style='R')} • {scope}{channel_text}{recurring_text}{failure_text}{disabled_text}\\n{reason}{error_text}\"\n            )\n\n        embed = discord.Embed(title=\"Active Reminders\", description=\"\\n\\n\".join(lines), color=discord.Color.gold())\n        await interaction.response.send_message(embed=embed, ephemeral=True)\n\n    @reminders_group.command(name=\"cancel\", description=\"Cancel one reminder by id.\")\n    @app_commands.describe(reminder_id=\"The reminder id shown by /reminders list.\")\n    async def reminders_cancel(self, interaction: discord.Interaction, reminder_id: int):\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\n                \"DELETE FROM reminders WHERE id = ? AND user_id = ?\",\n                (reminder_id, interaction.user.id),\n            )\n            deleted = cursor.rowcount\n        await self.bot.db.commit()\n\n        if deleted:\n            await interaction.response.send_message(f\"Reminder `{reminder_id}` canceled.\", ephemeral=True)\n        else:\n            await interaction.response.send_message(\"Reminder not found.\", ephemeral=True)\n\n    @reminders_group.command(name=\"snooze\", description=\"Push one reminder further into the future.\")\n    @app_commands.describe(reminder_id=\"The reminder id shown by /reminders list.\", delay=\"How much longer, like 10m or 2h.\")\n    async def reminders_snooze(self, interaction: discord.Interaction, reminder_id: int, delay: str):\n        seconds = parse_duration_spec(delay)\n        if seconds is None:\n            await interaction.response.send_message(\n                \"Invalid delay format. Use s, m, h, or d (e.g., `10m`, `2h`).\",\n                ephemeral=True,\n            )\n            return\n        new_time = discord.utils.utcnow() + timedelta(seconds=seconds)\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\n                \"\"\"\n                UPDATE reminders\n                SET remind_at = ?, delivery_failures = 0, disabled = 0, last_error = NULL\n                WHERE id = ? AND user_id = ?\n                \"\"\",\n                (new_time.isoformat(), reminder_id, interaction.user.id),\n            )\n            updated = cursor.rowcount\n        await self.bot.db.commit()\n        if updated:\n            await interaction.response.send_message(\n                f\"Reminder `{reminder_id}` snoozed for `{delay.lower()}`.\",\n                ephemeral=True,\n            )\n        else:\n            await interaction.response.send_message(\"Reminder not found.\", ephemeral=True)\n\n    @reminders_group.command(name=\"clear\", description=\"Clear all of your reminders.\")\n    async def reminders_clear(self, interaction: discord.Interaction):\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\"DELETE FROM reminders WHERE user_id = ?\", (interaction.user.id,))\n            deleted = cursor.rowcount\n        await self.bot.db.commit()\n        await interaction.response.send_message(f\"Cleared {deleted} reminder(s).\", ephemeral=True)\n\n    @app_commands.command(name=\"afk\", description=\"Set or update your AFK status in this server.\")\n    @app_commands.guild_only()\n    @app_commands.describe(reason=\"The reason for your AFK status.\")\n    async def afk(self, interaction: discord.Interaction, reason: str = \"AFK\"):\n        await self.set_afk_status(interaction.guild_id, interaction.user.id, reason)\n        await interaction.response.send_message(f\"{interaction.user.mention} is now AFK: **{reason}**\")\n\n    @app_commands.command(name=\"afk-clear\", description=\"Clear your AFK status in this server.\")\n    @app_commands.guild_only()\n    async def afk_clear(self, interaction: discord.Interaction):\n        await self.clear_afk_status(interaction.guild_id, interaction.user.id)\n        await interaction.response.send_message(\"Your AFK status has been cleared.\", ephemeral=True)\n\n    @commands.Cog.listener(\"on_message\")\n    async def afk_message_listener(self, message: discord.Message):\n        if message.author.bot or not message.guild:\n            return\n\n        own_status = await self.get_afk_status(message.guild.id, message.author.id)\n        if own_status:\n            await self.clear_afk_status(message.guild.id, message.author.id)\n            await message.channel.send(f\"Welcome back {message.author.mention}! Your AFK status has been removed.\")\n\n        notified = []\n        mentioned_users = []\n        seen_user_ids = set()\n        for mentioned_user in message.mentions:\n            if mentioned_user.id in seen_user_ids:\n                continue\n            seen_user_ids.add(mentioned_user.id)\n            mentioned_users.append(mentioned_user)\n\n        mention_statuses = await self.get_many_afk_statuses(\n            message.guild.id,\n            [mentioned_user.id for mentioned_user in mentioned_users if not mentioned_user.bot],\n        )\n        for mentioned_user in mentioned_users:\n            if mentioned_user.bot:\n                continue\n            status = mention_statuses.get(mentioned_user.id)\n            if not status:\n                continue\n\n            reason, set_at = status\n            time_afk = discord.utils.utcnow() - discord.utils.parse_time(set_at)\n            days = time_afk.days\n            hours, remainder = divmod(int(time_afk.total_seconds() % 86400), 3600)\n            minutes, seconds = divmod(remainder, 60)\n            time_str = f\"{days}d {hours}h {minutes}m {seconds}s\"\n            notified.append(f\"{mentioned_user.display_name} is AFK: **{reason}** (for {time_str})\")\n\n        if notified:\n            await message.channel.send(\"\\n\".join(notified))\n\n    @tasks.loop(seconds=REMINDER_POLL_SECONDS)\n    async def reminder_loop(self):\n        now = discord.utils.utcnow().isoformat()\n        async with self.bot.db.cursor() as cursor:\n            await cursor.execute(\n                \"\"\"\n                SELECT id, user_id, channel_id, reason, remind_at, recurring_seconds, delivery_failures\n                FROM reminders\n                WHERE disabled = 0 AND remind_at <= ?\n                ORDER BY remind_at ASC\n                LIMIT ?\n                \"\"\",\n                (now, REMINDER_BATCH_SIZE),\n            )\n            reminders = await cursor.fetchall()\n\n        if not reminders:\n            return\n\n        for reminder_id, user_id, channel_id, reason, remind_at, recurring_seconds, delivery_failures in reminders:\n            user = self.bot.get_user(user_id)\n            failure_reason = \"delivery failed\"\n            if user is None:\n                try:\n                    user = await self.bot.fetch_user(user_id)\n                except discord.HTTPException:\n                    user = None\n                    failure_reason = \"unable to fetch the reminder recipient\"\n\n            delivered = False\n            if user is not None:\n                try:\n                    await user.send(f\"**Reminder:** {reason}\")\n                    delivered = True\n                except discord.Forbidden:\n                    delivered = False\n                    failure_reason = \"direct messages are disabled for the recipient\"\n                except discord.HTTPException:\n                    delivered = False\n                    failure_reason = \"failed to send the reminder through direct messages\"\n\n            if not delivered and channel_id is not None:\n                channel = self.bot.get_channel(channel_id)\n                if channel is None:\n                    try:\n                        channel = await self.bot.fetch_channel(channel_id)\n                    except discord.HTTPException:\n                        channel = None\n                        failure_reason = \"unable to access the fallback channel\"\n\n                if channel is not None:\n                    try:\n                        mention = user.mention if user else f\"<@{user_id}>\"\n                        await channel.send(f\"Hey {mention}, you had a reminder: {reason}\")\n                        delivered = True\n                    except discord.HTTPException:\n                        delivered = False\n                        failure_reason = \"failed to send the reminder in the fallback channel\"\n\n            async with self.bot.db.cursor() as cursor:\n                if delivered and recurring_seconds:\n                    next_time = discord.utils.parse_time(remind_at)\n                    while next_time <= discord.utils.utcnow():\n                        next_time += timedelta(seconds=recurring_seconds)\n                    await cursor.execute(\n                        \"\"\"\n                        UPDATE reminders\n                        SET remind_at = ?, delivery_failures = 0, disabled = 0, last_error = NULL\n                        WHERE id = ?\n                        \"\"\",\n                        (next_time.isoformat(), reminder_id),\n                    )\n                elif delivered:\n                    await cursor.execute(\"DELETE FROM reminders WHERE id = ?\", (reminder_id,))\n                else:\n                    next_failures = delivery_failures + 1\n                    if next_failures >= MAX_REMINDER_DELIVERY_FAILURES:\n                        await cursor.execute(\n                            \"\"\"\n                            UPDATE reminders\n                            SET delivery_failures = ?, disabled = 1, last_error = ?\n                            WHERE id = ?\n                            \"\"\",\n                            (next_failures, failure_reason, reminder_id),\n                        )\n                    else:\n                        retry_at = discord.utils.utcnow() + self.reminder_retry_delay(next_failures)\n                        await cursor.execute(\n                            \"\"\"\n                            UPDATE reminders\n                            SET remind_at = ?, delivery_failures = ?, last_error = ?\n                            WHERE id = ?\n                            \"\"\",\n                            (retry_at.isoformat(), next_failures, failure_reason, reminder_id),\n                        )\n        await self.bot.db.commit()\n\n    @reminder_loop.before_loop\n    async def before_reminder_loop(self):\n        await self.bot.wait_until_ready()\n        await self.setup_database()\n\n\nasync def setup(bot: commands.Bot):\n    await bot.add_cog(Utility(bot))\n"
  },
  {
    "path": "config.example.json",
    "content": "{\n  \"DISCORD_TOKEN\": \"your_discord_token_here\",\n  \"OPENAI_API_KEY\": \"your_openai_api_key_here\",\n  \"OPENAI_API_BASE\": \"https://api.openai.com/v1\",\n  \"ALLOW_USER_KEYS\": true,\n  \"DEFAULT_CHAT_MODEL\": \"gpt-4o-mini\",\n  \"ALLOWED_CHAT_MODELS\": [\n    \"gpt-4o-mini\",\n    \"gpt-4o\"\n  ],\n  \"GOOGLE_API_KEY\": \"your_google_api_key_here\",\n  \"GOOGLE_CSE_ID\": \"your_custom_search_engine_id_here\"\n}\n"
  },
  {
    "path": "config_loader.py",
    "content": "import json\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nfrom dotenv import load_dotenv\n\n\nCONFIG_PATH = Path(\"config.json\")\nDEFAULT_API_BASE = \"https://api.openai.com/v1\"\nDEFAULT_CHAT_MODEL = \"gpt-4o-mini\"\nDEFAULT_ALLOWED_MODELS = [DEFAULT_CHAT_MODEL, \"gpt-4o\"]\n\n\ndef _load_file_config() -> dict[str, Any]:\n    if not CONFIG_PATH.exists():\n        return {}\n\n    with CONFIG_PATH.open(\"r\", encoding=\"utf-8\") as handle:\n        return json.load(handle)\n\n\ndef _get_bool(name: str, default: bool, file_config: dict[str, Any]) -> bool:\n    value = os.getenv(name)\n    if value is None:\n        value = file_config.get(name, default)\n\n    if isinstance(value, bool):\n        return value\n\n    if isinstance(value, str):\n        return value.strip().lower() in {\"1\", \"true\", \"yes\", \"on\"}\n\n    return bool(value)\n\n\ndef _get_list(name: str, default: list[str], file_config: dict[str, Any]) -> list[str]:\n    value = os.getenv(name)\n    if value is None:\n        value = file_config.get(name, default)\n\n    if isinstance(value, list):\n        return [str(item).strip() for item in value if str(item).strip()]\n\n    if isinstance(value, str):\n        return [item.strip() for item in value.split(\",\") if item.strip()]\n\n    return list(default)\n\n\ndef load_runtime_config() -> dict[str, Any]:\n    load_dotenv()\n    file_config = _load_file_config()\n\n    return {\n        \"DISCORD_TOKEN\": os.getenv(\"DISCORD_TOKEN\", file_config.get(\"DISCORD_TOKEN\", \"\")),\n        \"OPENAI_API_KEY\": os.getenv(\"OPENAI_API_KEY\", file_config.get(\"OPENAI_API_KEY\", \"\")),\n        \"OPENAI_API_BASE\": os.getenv(\"OPENAI_API_BASE\", file_config.get(\"OPENAI_API_BASE\", DEFAULT_API_BASE)),\n        \"ALLOW_USER_KEYS\": _get_bool(\"ALLOW_USER_KEYS\", True, file_config),\n        \"DEFAULT_CHAT_MODEL\": os.getenv(\n            \"DEFAULT_CHAT_MODEL\", file_config.get(\"DEFAULT_CHAT_MODEL\", DEFAULT_CHAT_MODEL)\n        ),\n        \"ALLOWED_CHAT_MODELS\": _get_list(\"ALLOWED_CHAT_MODELS\", DEFAULT_ALLOWED_MODELS, file_config),\n        \"GOOGLE_API_KEY\": os.getenv(\"GOOGLE_API_KEY\", file_config.get(\"GOOGLE_API_KEY\", \"\")),\n        \"GOOGLE_CSE_ID\": os.getenv(\"GOOGLE_CSE_ID\", file_config.get(\"GOOGLE_CSE_ID\", \"\")),\n    }\n"
  },
  {
    "path": "docs/commands.md",
    "content": "# Command Reference\n\nThis document summarizes the current slash commands exposed by Milo.\n\n## AI Chat\n\n- `/chat`\n  Purpose: talk to the configured AI model, optionally with web search.\n  Works in: servers and DMs.\n  Inputs: `prompt`, optional `model`, optional `search_web`.\n\n- `/chat-reset`\n  Purpose: clear the current conversation context.\n  Works in: servers and DMs.\n\n- `/chat-config set-key`\n  Purpose: store a server-specific OpenAI API key.\n  Works in: servers only.\n  Permission: `Manage Server`.\n\n- `/chat-config set-persona`\n  Purpose: set a server-specific system persona for chat.\n  Works in: servers only.\n  Permission: `Manage Server`.\n\n- `/chat-config view`\n  Purpose: inspect the current server chat configuration.\n  Works in: servers only.\n  Permission: `Manage Server`.\n\n- `/chat-config set-enabled`\n- `/chat-config set-cooldown`\n- `/chat-config set-usage-cap`\n- `/chat-config allow-channel`\n- `/chat-config block-channel`\n- `/chat-config clear-channel-rules`\n- `/chat-config allow-role`\n- `/chat-config remove-role`\n- `/chat-config clear-role-rules`\n  Purpose: control where AI chat can be used, who can use it, and how often.\n  Works in: servers only.\n  Permission: `Manage Server`.\n\n- `/chat-config test`\n  Purpose: validate the effective API key for the current server.\n  Works in: servers only.\n  Permission: `Manage Server`.\n\n- `/chat-config models`\n  Purpose: show the default model and the allowlisted models.\n  Works in: servers only.\n  Permission: `Manage Server`.\n\n## Economy\n\nAll economy data is per guild.\n\n- `/balance`\n  Purpose: view your balance or another member's balance.\n  Works in: servers only.\n\n- `/daily`\n  Purpose: claim the daily reward.\n  Works in: servers only.\n\n- `/jobs freelance`\n- `/jobs regular`\n- `/jobs crime`\n  Purpose: earn coins through cooldown-based work commands.\n  Works in: servers only.\n\n- `/gamble`\n  Purpose: wager coins on a simple chance outcome.\n  Works in: servers only.\n\n- `/slots`\n  Purpose: use the slot machine.\n  Works in: servers only.\n\n- `/leaderboard`\n  Purpose: show the richest users in the current server.\n  Works in: servers only.\n\n- `/transfer`\n  Purpose: send coins to another member.\n  Works in: servers only.\n\n- `/rob`\n  Purpose: attempt to steal coins from another member.\n  Works in: servers only.\n\n- `/economy-admin add`\n- `/economy-admin remove`\n- `/economy-admin set`\n- `/economy-admin reset-guild`\n  Purpose: administrative balance management for the current guild.\n  Works in: servers only.\n  Permission: `Manage Server` or stronger depending on the command.\n\n## Farming\n\nAll farming progress is per guild and uses the local economy balance.\n\n- `/farm profile`\n  Purpose: inspect current farm progress, land type, and crop status.\n\n- `/farm shop`\n  Purpose: view crop unlocks, costs, rewards, and XP.\n\n- `/farm plant`\n  Purpose: plant a crop if you have enough coins and the required farm level.\n\n- `/farm harvest`\n  Purpose: collect crop rewards and farm XP.\n\n- `/farm upgrade`\n  Purpose: buy better land to reduce growth time.\n\n## Games\n\n- `/eightball`\n  Purpose: answer a question with a random eight-ball response.\n\n- `/coinflip`\n  Purpose: flip a coin.\n\n- `/roll`\n  Purpose: roll dice in `NdN` form.\n\n- `/guess`\n  Purpose: run a timed number guessing game in the current channel.\n\n- `/rps`\n  Purpose: play rock-paper-scissors against the bot.\n\n- `/tictactoe`\n  Purpose: challenge another member to a button-based tic-tac-toe game.\n\n## Fun\n\n- `/joke`\n- `/fact`\n- `/avatar`\n- `/love`\n- `/emojify`\n- `/poll`\n- `/clap`\n- `/tweet`\n\nPurpose: lightweight entertainment, formatting, and image-generation features.\n\n## Interactions\n\n- `/hug`\n- `/pat`\n- `/slap`\n- `/kiss`\n- `/cuddle`\n- `/poke`\n\nPurpose: social reaction commands backed by external GIF APIs.\n\n## Media\n\n- `/meme`\n- `/cat`\n- `/dog`\n\nPurpose: fetch random media from public APIs.\n\n## Utility\n\n- `/ping`\n- `/ping_raw`\n  Purpose: inspect websocket and basic API latency.\n\n- `/memberinfo`\n  Works in: servers only.\n  Purpose: inspect a member profile.\n\n- `/clear`\n  Works in: servers only.\n  Permission: `Manage Messages`.\n  Purpose: bulk-delete recent messages.\n\n- `/serverinfo`\n  Works in: servers only.\n  Purpose: inspect guild metadata.\n\n- `/botinfo`\n- `/uptime`\n  Purpose: inspect runtime information.\n\n- `/help all`\n- `/help command`\n  Purpose: list commands or show detail for one command.\n\n- `/remindme`\n  Purpose: create a persisted reminder.\n  Works in: servers and DMs.\n\n- `/reminders recurring`\n  Purpose: create a recurring reminder with a repeat interval.\n  Works in: servers and DMs.\n\n- `/reminders list`\n- `/reminders cancel`\n- `/reminders clear`\n- `/reminders snooze`\n  Purpose: manage your existing reminders, including snoozing one into the future.\n  Works in: servers and DMs.\n\n- `/afk`\n  Purpose: set AFK state until your next server message in that guild.\n  Works in: servers only.\n\n- `/afk-clear`\n  Purpose: clear your AFK status manually.\n  Works in: servers only.\n\n## Community\n\n- `/server-config view`\n- `/server-config set-welcome-channel`\n- `/server-config set-goodbye-channel`\n- `/server-config set-announcement-channel`\n- `/server-config set-modlog-channel`\n- `/server-config set-welcome-message`\n- `/server-config set-goodbye-message`\n- `/server-config preview-welcome`\n- `/server-config preview-goodbye`\n- `/server-config reset-message`\n- `/server-config reset-channel`\n  Purpose: manage server community automation.\n  Works in: servers only.\n  Permission: `Manage Server`.\n\n- `/announce`\n  Purpose: send an announcement to the configured announcement channel or the current channel.\n  Works in: servers only.\n  Permission: `Manage Server`.\n\n- `/announcements schedule`\n- `/announcements list`\n- `/announcements cancel`\n  Purpose: manage scheduled server announcements.\n  Works in: servers only.\n  Permission: `Manage Server`.\n\n## Moderation\n\n- `/automod view`\n- `/automod toggle-invites`\n- `/automod toggle-links`\n- `/automod set-action`\n- `/automod set-bad-words`\n- `/automod clear-bad-words`\n- `/automod whitelist-channel`\n- `/automod remove-whitelist-channel`\n  Purpose: configure invite filtering, link filtering, blocked words, channel exemptions, and automod actions.\n  Works in: servers only.\n  Permission: `Manage Server`.\n\n- `/warn`\n- `/warnings`\n- `/clear-warning`\n  Purpose: manage per-guild warning history and moderation notes.\n  Works in: servers only.\n  Permission: `Manage Messages`.\n\n## Notes\n\n- Cooldowns are enforced on many commands.\n- Some commands call third-party APIs and may fail if those services are unavailable.\n- Chat, media, and interaction commands depend on external services and configuration.\n"
  },
  {
    "path": "docs/configuration.md",
    "content": "# Configuration Guide\n\nMilo supports two config sources:\n\n1. Environment variables, including values loaded from `.env`\n2. Local `config.json`\n\nEnvironment variables take priority.\n\n## Required\n\n- `DISCORD_TOKEN`\n  Description: Discord bot token.\n  Required: yes.\n\n## AI Chat\n\n- `OPENAI_API_KEY`\n  Description: default API key used when a guild has not provided its own key.\n  Required: no.\n  Note: if omitted, `/chat` only works in guilds where admins set a server-specific key.\n\n- `OPENAI_API_BASE`\n  Description: base URL for the chat completion API.\n  Default: `https://api.openai.com/v1`\n\n- `ALLOW_USER_KEYS`\n  Description: whether guild admins may store their own API key with `/chat-config set-key`.\n  Default: `true`\n  Values: `true` or `false`\n\n- `DEFAULT_CHAT_MODEL`\n  Description: model used when the user does not specify one.\n  Default: `gpt-4o-mini`\n\n- `ALLOWED_CHAT_MODELS`\n  Description: comma-separated environment value or JSON array in `config.json`.\n  Default: `gpt-4o-mini,gpt-4o`\n  Note: `/chat` rejects models outside this allowlist.\n\n## Google Custom Search\n\n- `GOOGLE_API_KEY`\n- `GOOGLE_CSE_ID`\n\nDescription: enable optional live web search for `/chat`.\n\nImportant:\n\n- Both values must be present for web search to be enabled.\n- These services may incur cost depending on your Google account setup.\n\n## Example `.env`\n\n```env\nDISCORD_TOKEN=your_discord_bot_token_here\nOPENAI_API_KEY=your_openai_api_key_here\nOPENAI_API_BASE=https://api.openai.com/v1\nALLOW_USER_KEYS=true\nDEFAULT_CHAT_MODEL=gpt-4o-mini\nALLOWED_CHAT_MODELS=gpt-4o-mini,gpt-4o\nGOOGLE_API_KEY=your_google_api_key_here\nGOOGLE_CSE_ID=your_custom_search_engine_id_here\n```\n\n## Example `config.json`\n\n```json\n{\n  \"DISCORD_TOKEN\": \"your_discord_token_here\",\n  \"OPENAI_API_KEY\": \"your_openai_api_key_here\",\n  \"OPENAI_API_BASE\": \"https://api.openai.com/v1\",\n  \"ALLOW_USER_KEYS\": true,\n  \"DEFAULT_CHAT_MODEL\": \"gpt-4o-mini\",\n  \"ALLOWED_CHAT_MODELS\": [\"gpt-4o-mini\", \"gpt-4o\"],\n  \"GOOGLE_API_KEY\": \"your_google_api_key_here\",\n  \"GOOGLE_CSE_ID\": \"your_custom_search_engine_id_here\"\n}\n```\n\n## Secret Handling\n\n- Do not commit real values to git.\n- Prefer `.env` for local development.\n- Keep `config.json` local and gitignored if you use it for secrets.\n- Rotate any credential immediately if it is exposed.\n"
  },
  {
    "path": "docs/deployment.md",
    "content": "# Deployment Guide\n\nThis guide covers practical ways to run Milo outside a local development shell.\n\n## Requirements\n\n- Python 3.9+\n- Persistent disk access for `database/main.db`\n- Network access to Discord\n- Network access to optional upstream APIs if you enable them\n\n## Minimal Host Requirements\n\nMilo is lightweight enough for a small VPS or home server.\n\nYou need:\n\n- one persistent process\n- a writable working directory\n- a way to restart the bot after crashes or machine reboot\n\n## Recommended Layout\n\n```text\nMilo-discord-fun-bot/\n  .env\n  database/\n    main.db\n  cogs/\n  docs/\n  main.py\n```\n\n## Linux With `systemd`\n\nExample unit file:\n\n```ini\n[Unit]\nDescription=Milo Discord Bot\nAfter=network.target\n\n[Service]\nType=simple\nWorkingDirectory=/opt/Milo-discord-fun-bot\nEnvironmentFile=/opt/Milo-discord-fun-bot/.env\nExecStart=/opt/Milo-discord-fun-bot/.venv/bin/python /opt/Milo-discord-fun-bot/main.py\nRestart=always\nRestartSec=5\n\n[Install]\nWantedBy=multi-user.target\n```\n\nTypical flow:\n\n1. Create the virtual environment\n2. Install dependencies\n3. Place secrets in `.env`\n4. Create the `database/` directory if it does not exist\n5. Start and enable the service\n\n## Containers\n\nIf you containerize Milo:\n\n- mount persistent storage for `database/main.db`\n- inject secrets through environment variables\n- do not bake real keys into the image\n\n## Upgrading A Running Instance\n\n1. Stop the process\n2. Pull the latest code\n3. Reinstall dependencies if `requirements.txt` changed\n4. Start the process again\n5. Watch logs for migrations or failed cog loads\n\n## Backups\n\nBack up:\n\n- `database/main.db`\n- `.env` if used\n- local `config.json` if used\n\n## Operational Warnings\n\n- SQLite is fine for small and medium self-hosted usage, but it is not designed for horizontally scaled multi-writer deployments\n- OpenAI and Google integrations can produce external cost\n- user-supplied API keys are stored locally when that feature is enabled\n"
  },
  {
    "path": "docs/faq.md",
    "content": "# FAQ\n\n## Does Milo support AI chat in DMs?\n\nYes. `/chat` and `/chat-reset` work in DMs. Server configuration commands under `/chat-config` are guild-only.\n\n## Is the economy shared across all servers?\n\nNo. Economy balances, leaderboards, and farming progress are stored per guild.\n\n## Do reminders survive bot restarts?\n\nYes. Reminders are stored in SQLite and replayed after startup.\n\n## Can reminders repeat or be snoozed?\n\nYes. Use `/reminders recurring` for repeating reminders and `/reminders snooze` to move an existing reminder further out.\n\n## Is AI chat shared between users in the same server channel?\n\nNo. Guild chat history is isolated per user, so one member's `/chat` history does not leak into another member's conversation.\n\n## Do I need OpenAI credentials?\n\nOnly if you want AI chat features. The rest of the bot can run without OpenAI credentials.\n\n## Do I need Google API credentials?\n\nOnly if you want web-enabled search in AI chat. Both `GOOGLE_API_KEY` and `GOOGLE_CSE_ID` must be configured.\n\n## Where should I put secrets?\n\nUse `.env` or a gitignored local `config.json`. Do not commit real secrets to the repository.\n\n## What Python version should I use?\n\nPython 3.9 or newer.\n\n## What database does Milo use?\n\nSQLite, stored locally at `database/main.db`.\n\n## Can I host Milo on a VPS?\n\nYes. A small VPS or any machine that can keep a Python process online is enough for typical usage.\n\n## Can I use a Python-capable hosting provider or panel?\n\nYes, as long as the hosting environment can:\n\n- run Python 3.9 or newer\n- keep a long-running process online\n- write to local storage for SQLite\n- access Discord and any external APIs you enable\n\nExamples include VPS providers, Python app hosting platforms, Pterodactyl-style panels, and self-hosted Linux servers.\n\nThe main limitation is that Milo is a persistent Discord bot process, so it is not a fit for serverless-only platforms that cannot keep a worker running continuously.\n\n## Is this project production-ready?\n\nIt is suitable for self-hosted real usage, but it is still a small open-source bot project. It does not yet have a full automated test suite or enterprise-style operational tooling.\n"
  },
  {
    "path": "docs/operations.md",
    "content": "# Operations Notes\n\nThis document covers behavior that matters when hosting or maintaining Milo.\n\n## Runtime Requirements\n\n- Python 3.9+\n- Network access for Discord and any external APIs you enable\n- Writable filesystem access for `database/main.db`\n\n## Storage\n\nMilo uses SQLite at:\n\n```text\ndatabase/main.db\n```\n\nAt startup, the bot creates or migrates tables as needed.\n\n## Data Behavior\n\n- Economy balances are stored per guild\n- Farming progress is stored per guild\n- Chat configuration is stored per guild\n- Reminders are persisted and replayed after restart\n- Message metadata is stored for spam detection and migration support\n\n## External Services\n\nOptional services:\n\n- OpenAI-compatible chat API\n- Google Custom Search\n- Meme and animal image APIs\n- GIF APIs for social interaction commands\n\nIf any external service fails, affected commands may return an error or degraded response.\n\n## Upgrades\n\nWhen updating the bot:\n\n1. Stop the running process\n2. Pull the latest code\n3. Reinstall dependencies if needed\n4. Restart the bot\n5. Watch startup logs for schema migration or API errors\n\n## Logging\n\nLogging is configured in the main process and writes to stdout.\n\nWatch for:\n\n- configuration load failures\n- failed cog loads\n- database migration problems\n- external API errors\n\n## Backup\n\nFor a simple backup strategy, copy:\n\n- `database/main.db`\n- local `.env` if you are using it\n- local `config.json` if you are using it\n\nDo not commit these backups to the repository.\n\n## Security and Cost\n\n- OpenAI and Google integrations can create ongoing cost\n- User-provided API keys are stored in SQLite when enabled\n- Rotate leaked keys immediately\n- Keep public logs and screenshots free of secrets\n"
  },
  {
    "path": "install.bat",
    "content": "@echo off\ntitle Milo Discord Fun Bot Installer v1.3.0\n\nsetlocal enabledelayedexpansion\n\n:: ---------------- ASCII LOGO ----------------\necho  __  __ _ _       _         ____        _\necho ^|  \\/  ^| (_) ^|_ ^|___^| ^|__     ^| ^| __ )  ___ ^| ^|_^\necho ^| ^|\\/^| ^| ^| __/^|__ ^| '_ ^\\____^| ^| _ ^\\ / _ ^| ^| __^|\necho ^| ^|  ^| ^| ^| ^|^| (__^| ^| ^| ^|____^| ^|_) ^| (_) ^| ^| ^|_ \necho ^|_|  |_|_| \\__\\___|_| |_|    |____/ \\___/ \\__|\necho          Milo Discord Fun Bot Installer\necho --------------------------------------------------\n\n:: ---------------- Variables ----------------\nset \"REPO_URL=https://github.com/msgaxzzz/Milo-discord-fun-bot.git\"\nset \"ZIP_URL=https://github.com/msgaxzzz/Milo-discord-fun-bot/archive/refs/heads/main.zip\"\nset \"DIR_NAME=Milo-discord-fun-bot\"\nset \"VENV_DIR=.venv\"\n\nif exist \"main.py\" if exist \"requirements.txt\" goto use_current_dir\n\nif not exist \"%DIR_NAME%\" (\n    where git >nul 2>&1\n    if errorlevel 1 (\n        echo Git is not available. Downloading source archive instead...\n        call :download_zip_source\n        if errorlevel 1 (\n            pause\n            exit /b 1\n        )\n    ) else (\n        echo Cloning repository from %REPO_URL%...\n        git clone \"%REPO_URL%\"\n        if errorlevel 1 (\n            echo Failed to clone repository.\n            pause\n            exit /b 1\n        )\n    )\n    cd \"%DIR_NAME%\" || (\n        echo Cannot enter directory %DIR_NAME%.\n        pause\n        exit /b 1\n    )\n) else (\n    echo Directory %DIR_NAME% already exists, using it.\n    cd \"%DIR_NAME%\" || (\n        echo Cannot enter directory %DIR_NAME%.\n        pause\n        exit /b 1\n    )\n)\ngoto after_dir_setup\n\n:use_current_dir\necho Installer is running inside an existing Milo checkout.\n\n:after_dir_setup\n\n:: Find Python 3.9+\nset PYTHON_BIN=\nfor %%P in (python3.13 python3.12 python3.11 python3.10 python3.9 python) do (\n    for /f \"tokens=2 delims= \" %%V in ('%%P --version 2^>^&1') do (\n        set \"VER=%%V\"\n        for /f \"tokens=1,2 delims=.\" %%A in (\"!VER!\") do (\n            set \"MAJOR=%%A\"\n            set \"MINOR=%%B\"\n            if !MAJOR! GEQ 3 (\n                if !MINOR! GEQ 9 (\n                    set PYTHON_BIN=%%P\n                    goto foundpython\n                )\n            )\n        )\n    )\n)\n\n:foundpython\nif not defined PYTHON_BIN (\n    echo No compatible Python (>=3.9) found. Please install Python 3.9 or higher.\n    pause\n    exit /b 1\n)\necho Found Python: %PYTHON_BIN%\n\n:: Create virtual environment\nif not exist \"%VENV_DIR%\" (\n    echo Creating virtual environment in %CD%\\%VENV_DIR%...\n    %PYTHON_BIN% -m venv \"%VENV_DIR%\"\n    if errorlevel 1 (\n        echo Failed to create virtual environment. Install the Python venv component and retry.\n        pause\n        exit /b 1\n    )\n)\n\n:: Install requirements\necho Installing dependencies from requirements.txt...\n\"%CD%\\%VENV_DIR%\\Scripts\\python.exe\" -m pip install --upgrade pip\n\"%CD%\\%VENV_DIR%\\Scripts\\python.exe\" -m pip install -r requirements.txt\n\n:: Create database directory\nif not exist database (\n    mkdir database\n)\n\n:: Remove existing config.json\nif exist config.json del config.json\n\necho Configuration wizard:\n\n:input_discord\nset /p DISCORD_TOKEN=Enter Discord Bot Token (required): \nif \"%DISCORD_TOKEN%\"==\"\" (\n    echo Discord Token cannot be empty.\n    goto input_discord\n)\n\nset /p OPENAI_KEY=Enter OpenAI API Key (optional): \n\nif not \"%OPENAI_KEY%\"==\"\" (\n    set /p OPENAI_API_BASE=Enter OpenAI API Base URL (default https://api.openai.com/v1): \n    if \"%OPENAI_API_BASE%\"==\"\" set OPENAI_API_BASE=https://api.openai.com/v1\n\n    set /p ALLOW_USER_KEYS=Allow user-provided OpenAI Keys? (true/false, default true): \n    if /i not \"%ALLOW_USER_KEYS%\"==\"true\" if /i not \"%ALLOW_USER_KEYS%\"==\"false\" set ALLOW_USER_KEYS=true\n\n    set /p DEFAULT_CHAT_MODEL=Enter default chat model (default gpt-4o-mini): \n    if \"%DEFAULT_CHAT_MODEL%\"==\"\" set DEFAULT_CHAT_MODEL=gpt-4o-mini\n\n    set /p ALLOWED_CHAT_MODELS_INPUT=Enter allowed chat models, comma separated (default gpt-4o-mini,gpt-4o): \n    if \"%ALLOWED_CHAT_MODELS_INPUT%\"==\"\" (\n        set ALLOWED_CHAT_MODELS=[\"gpt-4o-mini\", \"gpt-4o\"]\n    ) else (\n        setlocal enabledelayedexpansion\n        set \"models=!ALLOWED_CHAT_MODELS_INPUT:,=\"\",\"\"!\"\n        set ALLOWED_CHAT_MODELS=[\"\"!models!\"\"]\n        endlocal & set ALLOWED_CHAT_MODELS=%ALLOWED_CHAT_MODELS%\n    )\n) else (\n    set OPENAI_API_BASE=\n    set ALLOW_USER_KEYS=false\n    set DEFAULT_CHAT_MODEL=\n    set ALLOWED_CHAT_MODELS=[]\n)\n\nset /p GOOGLE_API_KEY=Enter Google API Key (optional): \nset /p GOOGLE_CSE_ID=Enter Google CSE ID (optional): \n\n(\necho {\necho   \"DISCORD_TOKEN\": \"%DISCORD_TOKEN%\",\necho   \"OPENAI_API_KEY\": \"%OPENAI_KEY%\",\necho   \"OPENAI_API_BASE\": \"%OPENAI_API_BASE%\",\necho   \"ALLOW_USER_KEYS\": %ALLOW_USER_KEYS%,\necho   \"DEFAULT_CHAT_MODEL\": \"%DEFAULT_CHAT_MODEL%\",\necho   \"ALLOWED_CHAT_MODELS\": %ALLOWED_CHAT_MODELS%,\necho   \"GOOGLE_API_KEY\": \"%GOOGLE_API_KEY%\",\necho   \"GOOGLE_CSE_ID\": \"%GOOGLE_CSE_ID%\"\necho }\n) > config.json\n\necho config.json created successfully.\necho Milo Bot installation completed!\necho Please verify config.json and run:\necho   %CD%\\%VENV_DIR%\\Scripts\\python.exe main.py\necho Or activate the virtual environment first:\necho   %VENV_DIR%\\Scripts\\activate\necho Update log: https://github.com/msgaxzzz/Milo-discord-fun-bot/blob/main/CHANGELOG.md\n\npause\nexit /b 0\n\n:download_zip_source\nset \"ARCHIVE_FILE=%TEMP%\\milo-discord-fun-bot-main.zip\"\nset \"ARCHIVE_ROOT=%TEMP%\\milo-discord-fun-bot-install-%RANDOM%%RANDOM%\"\n\necho Downloading %ZIP_URL%...\npowershell -NoProfile -ExecutionPolicy Bypass -Command \"Invoke-WebRequest -Uri '%ZIP_URL%' -OutFile '%ARCHIVE_FILE%'\"\nif errorlevel 1 (\n    echo Failed to download the source archive.\n    exit /b 1\n)\n\necho Extracting archive...\npowershell -NoProfile -ExecutionPolicy Bypass -Command \"Expand-Archive -LiteralPath '%ARCHIVE_FILE%' -DestinationPath '%ARCHIVE_ROOT%' -Force\"\nif errorlevel 1 (\n    echo Failed to extract the source archive.\n    if exist \"%ARCHIVE_FILE%\" del /q \"%ARCHIVE_FILE%\" >nul 2>&1\n    exit /b 1\n)\n\nif not exist \"%ARCHIVE_ROOT%\\%DIR_NAME%-main\" (\n    echo The extracted archive did not contain the expected folder structure.\n    if exist \"%ARCHIVE_FILE%\" del /q \"%ARCHIVE_FILE%\" >nul 2>&1\n    if exist \"%ARCHIVE_ROOT%\" rmdir /s /q \"%ARCHIVE_ROOT%\" >nul 2>&1\n    exit /b 1\n)\n\nmove \"%ARCHIVE_ROOT%\\%DIR_NAME%-main\" \"%DIR_NAME%\" >nul\nif errorlevel 1 (\n    echo Failed to move the extracted source into %DIR_NAME%.\n    if exist \"%ARCHIVE_FILE%\" del /q \"%ARCHIVE_FILE%\" >nul 2>&1\n    if exist \"%ARCHIVE_ROOT%\" rmdir /s /q \"%ARCHIVE_ROOT%\" >nul 2>&1\n    exit /b 1\n)\n\nif exist \"%ARCHIVE_FILE%\" del /q \"%ARCHIVE_FILE%\" >nul 2>&1\nif exist \"%ARCHIVE_ROOT%\" rmdir /s /q \"%ARCHIVE_ROOT%\" >nul 2>&1\nexit /b 0\n"
  },
  {
    "path": "install.sh",
    "content": "#!/usr/bin/env bash\n\ncat << \"EOF\"\n  __  __ _ _       _         ____        _   \n |  \\/  (_) |_ ___| |__     | __ )  ___ | |_ \n | |\\/| | | __/ __| '_ \\____|  _ \\ / _ \\| __|\n | |  | | | || (__| | | |____| |_) | (_) | |_ \n |_|  |_|_|\\__\\___|_| |_|    |____/ \\___/ \\__|\n         Milo Discord Fun Bot Installer       \n--------------------------------------------------\nEOF\n\nGREEN='\\033[1;92m'\nYELLOW='\\033[1;93m'\nBLUE='\\033[1;94m'\nRED='\\033[1;91m'\nNC='\\033[0m'\n\nREPO_URL=\"https://github.com/msgaxzzz/Milo-discord-fun-bot.git\"\nDIR_NAME=\"Milo-discord-fun-bot\"\nVENV_DIR=\".venv\"\n\nif [ -f \"main.py\" ] && [ -f \"requirements.txt\" ]; then\n    echo -e \"${YELLOW}Installer is running inside an existing Milo checkout.${NC}\"\nelif [ ! -d \"$DIR_NAME\" ]; then\n    echo -e \"${BLUE}Cloning repository from $REPO_URL...${NC}\"\n    git clone \"$REPO_URL\" || { echo -e \"${RED}Failed to clone repository.${NC}\"; exit 1; }\n    cd \"$DIR_NAME\" || { echo -e \"${RED}Cannot enter directory $DIR_NAME.${NC}\"; exit 1; }\nelse\n    echo -e \"${YELLOW}Directory $DIR_NAME already exists, using it.${NC}\"\n    cd \"$DIR_NAME\" || { echo -e \"${RED}Cannot enter directory $DIR_NAME.${NC}\"; exit 1; }\nfi\n\nPROJECT_DIR=$(pwd)\n\necho -e \"${BLUE}Checking for compatible Python versions (>=3.9)...${NC}\"\nPYTHON_BIN=\"\"\n\nfor PY in python3.13 python3.12 python3.11 python3.10 python3.9 python3; do\n    if command -v $PY &> /dev/null; then\n        if $PY -c \"import sys; raise SystemExit(0 if sys.version_info >= (3, 9) else 1)\"; then\n            PYTHON_BIN=$PY\n            break\n        fi\n    fi\ndone\n\nif [ -z \"$PYTHON_BIN\" ]; then\n    echo -e \"${RED}No compatible Python (>=3.9) found. Please install Python 3.9 or higher.${NC}\"\n    exit 1\nfi\n\necho -e \"${GREEN}Found Python: $PYTHON_BIN${NC}\"\n\necho -e \"${BLUE}Creating virtual environment in ${PROJECT_DIR}/${VENV_DIR}...${NC}\"\nif [ ! -d \"$VENV_DIR\" ]; then\n    $PYTHON_BIN -m venv \"$VENV_DIR\" || {\n        echo -e \"${RED}Failed to create virtual environment. Install the Python venv package and retry.${NC}\"\n        exit 1\n    }\nfi\n\nVENV_PYTHON=\"$PROJECT_DIR/$VENV_DIR/bin/python\"\nVENV_PIP=\"$PROJECT_DIR/$VENV_DIR/bin/pip\"\n\necho -e \"${BLUE}Installing dependencies from requirements.txt...${NC}\"\n$VENV_PYTHON -m pip install --upgrade pip\n$VENV_PIP install -r requirements.txt\n\necho -e \"${BLUE}Creating database directory...${NC}\"\nmkdir -p database\n\nif [ -f config.json ]; then\n    echo -e \"${YELLOW}config.json exists, deleting to regenerate...${NC}\"\n    rm config.json\nfi\n\necho -e \"${BLUE}Configuration wizard:${NC}\"\n\nwhile true; do\n    read -p \"Enter Discord Bot Token (required): \" DISCORD_TOKEN\n    [[ -n \"$DISCORD_TOKEN\" ]] && break\n    echo \"Discord Token cannot be empty.\"\ndone\n\nread -p \"Enter OpenAI API Key (optional): \" OPENAI_KEY\n\nif [[ -n \"$OPENAI_KEY\" ]]; then\n    read -p \"Enter OpenAI API Base URL (default https://api.openai.com/v1): \" OPENAI_API_BASE\n    OPENAI_API_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1}\n\n    read -p \"Allow user-provided OpenAI Keys? (true/false, default true): \" ALLOW_USER_KEYS\n    if [[ ! \"$ALLOW_USER_KEYS\" =~ ^(true|false)$ ]]; then\n        ALLOW_USER_KEYS=true\n    fi\n\n    read -p \"Enter default chat model (default gpt-4o-mini): \" DEFAULT_CHAT_MODEL\n    DEFAULT_CHAT_MODEL=${DEFAULT_CHAT_MODEL:-gpt-4o-mini}\n\n    read -p \"Enter allowed chat models, comma separated (default gpt-4o-mini,gpt-4o): \" ALLOWED_CHAT_MODELS_INPUT\n    if [[ -z \"$ALLOWED_CHAT_MODELS_INPUT\" ]]; then\n        ALLOWED_CHAT_MODELS='[\"gpt-4o-mini\", \"gpt-4o\"]'\n    else\n        IFS=',' read -ra MODELS_ARR <<< \"$ALLOWED_CHAT_MODELS_INPUT\"\n        ALLOWED_CHAT_MODELS=$(printf '\"%s\",' \"${MODELS_ARR[@]}\")\n        ALLOWED_CHAT_MODELS=\"[${ALLOWED_CHAT_MODELS%,}]\"\n    fi\nelse\n    OPENAI_API_BASE=\"\"\n    ALLOW_USER_KEYS=false\n    DEFAULT_CHAT_MODEL=\"\"\n    ALLOWED_CHAT_MODELS=\"[]\"\nfi\n\nread -p \"Enter Google API Key (optional): \" GOOGLE_API_KEY\nread -p \"Enter Google CSE ID (optional): \" GOOGLE_CSE_ID\n\ncat > config.json <<EOF\n{\n  \"DISCORD_TOKEN\": \"$DISCORD_TOKEN\",\n  \"OPENAI_API_KEY\": \"$OPENAI_KEY\",\n  \"OPENAI_API_BASE\": \"$OPENAI_API_BASE\",\n  \"ALLOW_USER_KEYS\": $ALLOW_USER_KEYS,\n  \"DEFAULT_CHAT_MODEL\": \"$DEFAULT_CHAT_MODEL\",\n  \"ALLOWED_CHAT_MODELS\": $ALLOWED_CHAT_MODELS,\n  \"GOOGLE_API_KEY\": \"$GOOGLE_API_KEY\",\n  \"GOOGLE_CSE_ID\": \"$GOOGLE_CSE_ID\"\n}\nEOF\n\necho -e \"${GREEN}config.json created successfully.${NC}\"\necho -e \"${GREEN}Milo Bot installation completed!${NC}\"\necho -e \"${YELLOW}Please verify config.json and run:${NC}\"\necho -e \"  ${BLUE}$VENV_PYTHON main.py${NC}\"\necho -e \"${YELLOW}You can also activate the environment first:${NC}\"\necho -e \"  ${BLUE}source $VENV_DIR/bin/activate${NC}\"\necho -e \"${BLUE}Update log: https://github.com/msgaxzzz/Milo-discord-fun-bot/blob/main/CHANGELOG.md${NC}\"\n"
  },
  {
    "path": "main.py",
    "content": "import asyncio\nimport os\nimport sys\nfrom collections import defaultdict\nimport datetime\nfrom pathlib import Path\nfrom typing import Optional\nimport logging\n\n\ndef _find_local_venv_python() -> Optional[Path]:\n    project_root = Path(__file__).resolve().parent\n    candidates = [\n        project_root / \".venv\" / \"bin\" / \"python\",\n        project_root / \".venv\" / \"Scripts\" / \"python.exe\",\n    ]\n    return next((candidate for candidate in candidates if candidate.exists()), None)\n\n\ndef _maybe_reexec_into_local_venv() -> None:\n    if sys.prefix != sys.base_prefix:\n        return\n\n    venv_python = _find_local_venv_python()\n    if not venv_python:\n        return\n\n    os.execv(str(venv_python), [str(venv_python), __file__, *sys.argv[1:]])\n\n\n_maybe_reexec_into_local_venv()\n\ntry:\n    import aiohttp\n    import aiosqlite\n    import discord\n    from discord import app_commands\n    from discord.ext import commands, tasks\n\n    from config_loader import load_runtime_config\nexcept ModuleNotFoundError as exc:\n    missing_package = exc.name or \"a required package\"\n    print(\n        f\"Missing Python dependency: {missing_package}\\n\"\n        \"Create and activate a local virtual environment, then install requirements:\\n\"\n        \"  python3 -m venv .venv\\n\"\n        \"  source .venv/bin/activate\\n\"\n        \"  python -m pip install -r requirements.txt\",\n        file=sys.stderr,\n    )\n    sys.exit(1)\n\n# Configure logging\nlogging.basicConfig(level=logging.INFO, format=\"%(asctime)s - %(name)s - %(levelname)s - %(message)s\")\nlogger = logging.getLogger(__name__)\n\n# Constants\nDATABASE_PATH = \"database/main.db\"\nCOGS_FOLDER = \"cogs\"\nSPAM_THRESHOLD = 5\nSPAM_TIMEFRAME = 7  # seconds\nMESSAGE_LOG_BATCH_SIZE = 50\nMESSAGE_LOG_FLUSH_SECONDS = 2\nMESSAGE_LOG_RETENTION_DAYS = 30\n\n\nconfig = load_runtime_config()\nTOKEN = config[\"DISCORD_TOKEN\"]\n\nif not TOKEN:\n    logger.critical(\"FATAL ERROR: DISCORD_TOKEN not found in config.\")\n    sys.exit(1)\n\nintents = discord.Intents.default()\nintents.message_content = True\nintents.members = True\n\n\nclass FunBot(commands.Bot):\n    def __init__(self, runtime_config: dict):\n        super().__init__(command_prefix=\"!\", intents=intents)\n        self.config = runtime_config\n        self.db: Optional[aiosqlite.Connection] = None\n        self.http_session: Optional[aiohttp.ClientSession] = None\n        self.user_message_timestamps = defaultdict(list)\n        self.message_log_queue: asyncio.Queue[Optional[tuple[int, int, int, str]]] = asyncio.Queue()\n        self.message_log_task: Optional[asyncio.Task] = None\n        self.start_time = discord.utils.utcnow()\n\n    async def setup_hook(self):\n        \"\"\"Initialize database and load cogs.\"\"\"\n        # Ensure database directory exists\n        os.makedirs(os.path.dirname(DATABASE_PATH), exist_ok=True)\n\n        # Connect to database\n        self.db = await aiosqlite.connect(DATABASE_PATH)\n        await self.db.execute(\"PRAGMA journal_mode=WAL\")\n        await self.db.execute(\"PRAGMA foreign_keys=ON\")\n        logger.info(\"Successfully connected to the database.\")\n        self.http_session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30))\n\n        # Initialize database schema\n        async with self.db.cursor() as cursor:\n            await cursor.execute(\n                \"\"\"\n                CREATE TABLE IF NOT EXISTS messages (\n                    message_id INTEGER PRIMARY KEY,\n                    guild_id INTEGER,\n                    user_id INTEGER,\n                    timestamp TEXT\n                )\n            \"\"\"\n            )\n            await cursor.execute(\n                \"CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp)\"\n            )\n        await self.db.commit()\n        self.message_log_task = asyncio.create_task(self._message_log_worker())\n        self._prune_message_logs_task.start()\n\n        # Load all cogs\n        for filename in os.listdir(COGS_FOLDER):\n            if filename.endswith(\".py\"):\n                try:\n                    await self.load_extension(f\"{COGS_FOLDER}.{filename[:-3]}\")\n                    logger.info(f\"Loaded cog: {filename}\")\n                except Exception as e:\n                    logger.error(f\"Failed to load cog {filename}: {e}\")\n\n        # Sync slash commands\n        await self.tree.sync()\n        logger.info(\"Slash commands have been synced.\")\n\n    async def on_ready(self):\n        print(f\"Logged in as {self.user.name}\")\n        print(f\"Bot ID: {self.user.id}\")\n        print(\"------\")\n\n    async def _flush_message_logs(self, batch: list[tuple[int, int, int, str]]) -> None:\n        if not batch or not self.db:\n            return\n        async with self.db.cursor() as cursor:\n            await cursor.executemany(\n                \"INSERT OR IGNORE INTO messages (message_id, guild_id, user_id, timestamp) VALUES (?, ?, ?, ?)\",\n                batch,\n            )\n        await self.db.commit()\n\n    async def _message_log_worker(self) -> None:\n        batch: list[tuple[int, int, int, str]] = []\n        loop = asyncio.get_running_loop()\n        try:\n            while True:\n                item = await self.message_log_queue.get()\n                if item is None:\n                    break\n                batch.append(item)\n                deadline = loop.time() + MESSAGE_LOG_FLUSH_SECONDS\n                while len(batch) < MESSAGE_LOG_BATCH_SIZE:\n                    timeout = deadline - loop.time()\n                    if timeout <= 0:\n                        break\n                    try:\n                        item = await asyncio.wait_for(self.message_log_queue.get(), timeout=timeout)\n                    except asyncio.TimeoutError:\n                        break\n                    if item is None:\n                        await self._flush_message_logs(batch)\n                        return\n                    batch.append(item)\n                await self._flush_message_logs(batch)\n                batch.clear()\n        except asyncio.CancelledError:\n            if batch:\n                await self._flush_message_logs(batch)\n            raise\n\n        if batch:\n            await self._flush_message_logs(batch)\n\n    async def on_message(self, message: discord.Message):\n        \"\"\"Handle incoming messages for logging and spam detection.\"\"\"\n        if message.author.bot:\n            return\n\n        # Log message to database\n        if message.guild and self.db:\n            try:\n                await self.message_log_queue.put(\n                    (message.id, message.guild.id, message.author.id, message.created_at.isoformat())\n                )\n            except Exception as e:\n                logger.error(f\"Error logging message to database: {e}\")\n\n        # Anti-spam detection\n        now = discord.utils.utcnow()\n        spam_key = (message.guild.id if message.guild else 0, message.channel.id, message.author.id)\n        user_timestamps = self.user_message_timestamps[spam_key]\n        user_timestamps.append(now)\n\n        # Clean old timestamps\n        user_timestamps = [t for t in user_timestamps if (now - t).total_seconds() < SPAM_TIMEFRAME]\n        self.user_message_timestamps[spam_key] = user_timestamps\n\n        if len(user_timestamps) > SPAM_THRESHOLD:\n            if len(user_timestamps) == SPAM_THRESHOLD + 1:\n                try:\n                    await message.channel.send(\n                        f\"{message.author.mention}, please slow down! Your recent messages will be deleted.\",\n                        delete_after=10,\n                    )\n\n                    def is_spam_message(m):\n                        return m.author == message.author and (now - m.created_at).total_seconds() < SPAM_TIMEFRAME\n\n                    try:\n                        await message.delete()\n                    except discord.HTTPException:\n                        pass\n                    await message.channel.purge(limit=SPAM_THRESHOLD + 1, check=is_spam_message, before=message)\n                except discord.Forbidden:\n                    await message.channel.send(\n                        f\"Warning for {message.author.mention}: Spam detected, but I don't have permission to delete messages.\"\n                    )\n                except Exception as e:\n                    logger.error(f\"An error occurred during spam cleanup: {e}\")\n\n        # Process commands\n        await self.process_commands(message)\n\n    async def close(self):\n        self._prune_message_logs_task.cancel()\n        if self.message_log_task:\n            await self.message_log_queue.put(None)\n            try:\n                await self.message_log_task\n            except Exception:\n                logger.exception(\"Error while flushing queued message logs during shutdown.\")\n        if self.http_session and not self.http_session.closed:\n            await self.http_session.close()\n        if self.db:\n            await self.db.close()\n            print(\"Database connection closed.\")\n        await super().close()\n\n    @tasks.loop(hours=24)\n    async def _prune_message_logs_task(self):\n        if not self.db:\n            return\n        cutoff = (discord.utils.utcnow() - datetime.timedelta(days=MESSAGE_LOG_RETENTION_DAYS)).isoformat()\n        async with self.db.cursor() as cursor:\n            await cursor.execute(\"DELETE FROM messages WHERE timestamp < ?\", (cutoff,))\n        await self.db.commit()\n\n    @_prune_message_logs_task.before_loop\n    async def _before_prune_message_logs(self):\n        await self.wait_until_ready()\n\n\nbot = FunBot(config)\n\n\n@bot.tree.error\nasync def on_app_command_error(interaction: discord.Interaction, error: app_commands.AppCommandError):\n    if isinstance(error, app_commands.CommandOnCooldown):\n        await interaction.response.send_message(\n            f\"This command is on cooldown. Please try again in {error.retry_after:.2f}s.\", ephemeral=True\n        )\n    elif isinstance(error, app_commands.MissingPermissions):\n        await interaction.response.send_message(\n            \"You don't have the required permissions to use this command.\", ephemeral=True\n        )\n    elif isinstance(error, app_commands.CheckFailure):\n        await interaction.response.send_message(\n            \"A check failed, you might not be able to use this command.\", ephemeral=True\n        )\n    else:\n        print(f\"An unhandled app command error occurred: {error}\")\n        if not interaction.response.is_done():\n            await interaction.response.send_message(\n                \"An unexpected error occurred. Please try again later.\", ephemeral=True\n            )\n        else:\n            await interaction.followup.send(\"An unexpected error occurred. Please try again later.\", ephemeral=True)\n\n\nprint(\"Configuration loaded. Starting bot...\")\nbot.run(TOKEN)\n"
  },
  {
    "path": "requirements-dev.txt",
    "content": "# Development dependencies\n-r requirements.txt\n\n# Code quality tools\nflake8>=7.0.0\nblack>=24.0.0\nmypy>=1.8.0\npylint>=3.0.0\n"
  },
  {
    "path": "requirements.txt",
    "content": "discord.py\naiohttp\npython-dotenv\naiosqlite\nPillow"
  },
  {
    "path": "site/404.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>Milo | Page Not Found</title>\n    <meta name=\"robots\" content=\"noindex\" />\n    <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\" />\n    <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin />\n    <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap\" rel=\"stylesheet\" />\n    <link rel=\"stylesheet\" href=\"./styles.css\" />\n  </head>\n  <body>\n    <div class=\"glow glow-1\" aria-hidden=\"true\"></div>\n    <div class=\"site-shell\" style=\"display:flex;align-items:center;justify-content:center;min-height:100vh;padding:0\">\n      <div style=\"text-align:center\">\n        <p class=\"label\">404</p>\n        <h1 style=\"font-size:clamp(2rem,5vw,3rem);margin-top:12px;background:linear-gradient(to bottom right,#fff 40%,#a1a1aa);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text\">Page not found.</h1>\n        <p style=\"color:var(--text-secondary);margin-top:12px;font-size:0.95rem\">The page you're looking for doesn't exist.</p>\n        <div class=\"hero-actions\" style=\"justify-content:center;margin-top:28px\">\n          <a class=\"btn btn-primary\" href=\"./\">Back to Site</a>\n          <a class=\"btn btn-ghost\" href=\"https://github.com/msgaxzzz/Milo-discord-fun-bot\" target=\"_blank\" rel=\"noreferrer\">GitHub</a>\n        </div>\n      </div>\n    </div>\n  </body>\n</html>\n"
  },
  {
    "path": "site/_headers",
    "content": "/*\n  X-Content-Type-Options: nosniff\n  Referrer-Policy: strict-origin-when-cross-origin\n  Permissions-Policy: camera=(), geolocation=(), microphone=()\n  X-Frame-Options: DENY\n"
  },
  {
    "path": "site/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>Milo | Community Operations for Discord</title>\n    <meta\n      name=\"description\"\n      content=\"Milo is an open-source Discord bot for community support, moderation, reminders, announcements, and self-hosted operations.\"\n    />\n    <meta property=\"og:title\" content=\"Milo | Community Operations for Discord\" />\n    <meta\n      property=\"og:description\"\n      content=\"An open-source Discord bot for community support, moderation, reminders, and operations.\"\n    />\n    <meta property=\"og:type\" content=\"website\" />\n    <meta name=\"theme-color\" content=\"#09090b\" />\n    <script>\n      document.documentElement.classList.add('js');\n    </script>\n    <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\" />\n    <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin />\n    <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap\" rel=\"stylesheet\" />\n    <link rel=\"stylesheet\" href=\"./styles.css\" />\n  </head>\n  <body>\n    <div class=\"glow glow-1\" aria-hidden=\"true\"></div>\n    <div class=\"glow glow-2\" aria-hidden=\"true\"></div>\n\n    <div class=\"site-shell\">\n      <header class=\"topbar\">\n        <a class=\"brand\" href=\"#top\" aria-label=\"Milo home\">\n          <span class=\"brand-mark\">M</span>\n          <span>Milo</span>\n        </a>\n        <nav class=\"topnav\" aria-label=\"Primary\">\n          <a class=\"nav-chip\" href=\"#features\">Features</a>\n          <a class=\"nav-chip\" href=\"#start\">Quick Start</a>\n          <a class=\"nav-chip\" href=\"#modules\">Modules</a>\n          <a class=\"nav-chip\" href=\"#docs\">Docs</a>\n        </nav>\n        <div class=\"topbar-actions\">\n          <a\n            class=\"btn btn-nav\"\n            href=\"https://github.com/msgaxzzz/Milo-discord-fun-bot\"\n            target=\"_blank\"\n            rel=\"noreferrer\"\n          >\n            <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"currentColor\"><path d=\"M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z\"/></svg>\n            GitHub\n          </a>\n        </div>\n      </header>\n\n      <main id=\"top\">\n        <!-- Hero Bento -->\n        <section class=\"bento-hero\">\n          <div class=\"card card-hero animate-on-scroll\">\n            <p class=\"label\">Open-Source Discord Bot</p>\n            <h1>Support, moderation &amp; ops in one bot.</h1>\n            <p class=\"sub\">\n              Built for communities that want self-hosted tooling. Moderation, announcements, reminders,\n              AI support, and workflows in one compact codebase.\n            </p>\n            <div class=\"hero-actions\">\n              <a class=\"btn btn-primary\" href=\"https://github.com/msgaxzzz/Milo-discord-fun-bot\" target=\"_blank\" rel=\"noreferrer\">View Source</a>\n              <a class=\"btn btn-ghost\" href=\"#start\">Quick Start</a>\n            </div>\n          </div>\n          <div class=\"card card-stats animate-on-scroll\">\n            <div class=\"stat\">\n              <span class=\"stat-val\">242</span>\n              <span class=\"stat-lbl\">Stars</span>\n            </div>\n            <div class=\"stat\">\n              <span class=\"stat-val\">MIT</span>\n              <span class=\"stat-lbl\">License</span>\n            </div>\n            <div class=\"stat\">\n              <span class=\"stat-val\">3.9+</span>\n              <span class=\"stat-lbl\">Python</span>\n            </div>\n          </div>\n        </section>\n\n        <!-- Features Bento -->\n        <section class=\"bento-features\" id=\"features\">\n          <div class=\"card card-feat card-feat-ai animate-on-scroll\">\n            <span class=\"tag\">AI</span>\n            <h3>AI-Assisted Support</h3>\n            <p>Chat with model allowlists, web search, daily caps, and per-server policy controls.</p>\n          </div>\n          <div class=\"card card-feat animate-on-scroll\">\n            <span class=\"tag\">MOD</span>\n            <h3>Moderation</h3>\n            <p>Warnings, invite filters, link filters, blocked words, and mod-log workflows.</p>\n          </div>\n          <div class=\"card card-code animate-on-scroll\">\n            <p class=\"label\">Quick Start</p>\n            <pre><code>git clone https://github.com/msgaxzzz/Milo-discord-fun-bot.git\ncd Milo-discord-fun-bot\npython3 -m venv .venv && source .venv/bin/activate\npip install -r requirements.txt\npython3 main.py</code></pre>\n            <button class=\"copy-btn\" aria-label=\"Copy code\">Copy</button>\n          </div>\n          <div class=\"card card-feat animate-on-scroll\">\n            <span class=\"tag\">OPS</span>\n            <h3>Community Ops</h3>\n            <p>Welcome flows, goodbye messages, announcements, reminders, and utility commands.</p>\n          </div>\n          <div class=\"card card-feat animate-on-scroll\">\n            <span class=\"tag\">OSS</span>\n            <h3>Open Source</h3>\n            <p>MIT licensed, contributor docs included, small enough to actually extend.</p>\n          </div>\n        </section>\n\n        <!-- Setup Steps -->\n        <section class=\"section\" id=\"start\">\n          <div class=\"section-head animate-on-scroll\">\n            <p class=\"label\">Getting Started</p>\n            <h2>Three steps to running.</h2>\n          </div>\n          <div class=\"steps-grid\">\n            <article class=\"card card-step animate-on-scroll\">\n              <span class=\"step-num\">01</span>\n              <h3>Clone</h3>\n              <p>Pull the public repo. Configure secrets via <code>.env</code> or local config.</p>\n            </article>\n            <article class=\"card card-step animate-on-scroll\">\n              <span class=\"step-num\">02</span>\n              <h3>Configure</h3>\n              <p>Set your Discord token and optional API keys before starting the bot.</p>\n            </article>\n            <article class=\"card card-step animate-on-scroll\">\n              <span class=\"step-num\">03</span>\n              <h3>Extend</h3>\n              <p>Commands are split into cogs. Add or remove features independently.</p>\n            </article>\n          </div>\n        </section>\n\n        <!-- Modules -->\n        <section class=\"section\" id=\"modules\">\n          <div class=\"section-head animate-on-scroll\">\n            <p class=\"label\">Modules</p>\n            <h2>Organized by concern.</h2>\n          </div>\n          <div class=\"module-grid\">\n            <article class=\"card card-module animate-on-scroll\">\n              <h3>Community</h3>\n              <p>Welcome and goodbye flows, scheduled announcements, reminders, AFK handling.</p>\n              <ul>\n                <li>Announcements &amp; recurring reminders</li>\n                <li>Welcome and mod-log events</li>\n                <li>Server utility commands</li>\n              </ul>\n            </article>\n            <article class=\"card card-module animate-on-scroll\">\n              <h3>Moderation</h3>\n              <p>Policies for safer communities without a full enterprise panel.</p>\n              <ul>\n                <li>Warnings and warning history</li>\n                <li>Invite and link filtering</li>\n                <li>Bad-word checks &amp; whitelists</li>\n              </ul>\n            </article>\n            <article class=\"card card-module animate-on-scroll\">\n              <h3>AI Support</h3>\n              <p>AI chat, config controls, optional web search, per-server constraints.</p>\n              <ul>\n                <li>Per-guild policies &amp; cooldowns</li>\n                <li>Model allowlists &amp; usage caps</li>\n                <li>Context isolation between users</li>\n              </ul>\n            </article>\n          </div>\n        </section>\n\n        <!-- Docs -->\n        <section class=\"section\" id=\"docs\">\n          <div class=\"section-head animate-on-scroll\">\n            <p class=\"label\">Documentation</p>\n            <h2>For contributors and admins.</h2>\n          </div>\n          <div class=\"docs-grid\">\n            <a class=\"card card-doc animate-on-scroll\" href=\"https://github.com/msgaxzzz/Milo-discord-fun-bot/blob/main/README.md\" target=\"_blank\" rel=\"noreferrer\">\n              <span class=\"doc-tag\">Overview</span>\n              <h3>README</h3>\n              <p>Project summary, setup flow, features, and deployment notes.</p>\n              <span class=\"card-arrow\">&rarr;</span>\n            </a>\n            <a class=\"card card-doc animate-on-scroll\" href=\"https://github.com/msgaxzzz/Milo-discord-fun-bot/blob/main/docs/commands.md\" target=\"_blank\" rel=\"noreferrer\">\n              <span class=\"doc-tag\">Reference</span>\n              <h3>Commands</h3>\n              <p>Full command reference across AI, moderation, reminders, economy.</p>\n              <span class=\"card-arrow\">&rarr;</span>\n            </a>\n            <a class=\"card card-doc animate-on-scroll\" href=\"https://github.com/msgaxzzz/Milo-discord-fun-bot/blob/main/docs/deployment.md\" target=\"_blank\" rel=\"noreferrer\">\n              <span class=\"doc-tag\">Ops</span>\n              <h3>Deployment</h3>\n              <p>Process expectations, storage notes, hosting guidance.</p>\n              <span class=\"card-arrow\">&rarr;</span>\n            </a>\n            <a class=\"card card-doc animate-on-scroll\" href=\"https://github.com/msgaxzzz/Milo-discord-fun-bot/blob/main/CONTRIBUTING.md\" target=\"_blank\" rel=\"noreferrer\">\n              <span class=\"doc-tag\">Community</span>\n              <h3>Contributing</h3>\n              <p>Contribution flow, review expectations, collaboration guidelines.</p>\n              <span class=\"card-arrow\">&rarr;</span>\n            </a>\n          </div>\n        </section>\n\n        <!-- FAQ -->\n        <section class=\"section section-faq\">\n          <div class=\"section-head animate-on-scroll\">\n            <p class=\"label\">FAQ</p>\n            <h2>Common questions.</h2>\n          </div>\n          <div class=\"faq-list\">\n            <article class=\"faq-item animate-on-scroll\">\n              <h3>Is Milo only a fun bot?</h3>\n              <p>No. Entertainment features exist, but the core value is community support and operations.</p>\n            </article>\n            <article class=\"faq-item animate-on-scroll\">\n              <h3>Does it need a hosted backend?</h3>\n              <p>No. It runs as a self-hosted process. No SaaS dependency.</p>\n            </article>\n            <article class=\"faq-item animate-on-scroll\">\n              <h3>Who should use it?</h3>\n              <p>Open-source communities, study groups, volunteer-run servers, and maintainers who want practical tooling.</p>\n            </article>\n            <article class=\"faq-item animate-on-scroll\">\n              <h3>Can I extend it?</h3>\n              <p>Yes. Commands are organized into cogs. Add your own or modify existing ones.</p>\n            </article>\n          </div>\n        </section>\n      </main>\n\n      <footer class=\"footer\">\n        <div class=\"footer-left\">\n          <span class=\"brand-mark\">M</span>\n          <span>Milo</span>\n        </div>\n        <p class=\"footer-note\">Open-source Discord bot for community operations.</p>\n        <div class=\"footer-links\">\n          <a href=\"https://github.com/msgaxzzz/Milo-discord-fun-bot\" target=\"_blank\" rel=\"noreferrer\">GitHub</a>\n          <a href=\"https://github.com/msgaxzzz/Milo-discord-fun-bot/issues\" target=\"_blank\" rel=\"noreferrer\">Issues</a>\n        </div>\n      </footer>\n    </div>\n    <script src=\"./script.js\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "site/script.js",
    "content": "document.addEventListener('DOMContentLoaded', () => {\n  const revealTargets = document.querySelectorAll('.animate-on-scroll');\n\n  // Scroll reveal\n  if ('IntersectionObserver' in window) {\n    const observer = new IntersectionObserver((entries) => {\n      entries.forEach((entry) => {\n        if (entry.isIntersecting) {\n          entry.target.classList.add('is-visible');\n          observer.unobserve(entry.target);\n        }\n      });\n    }, { threshold: 0.08 });\n\n    revealTargets.forEach((el) => observer.observe(el));\n  } else {\n    revealTargets.forEach((el) => el.classList.add('is-visible'));\n  }\n\n  // Copy button\n  const copyBtn = document.querySelector('.copy-btn');\n  const codeBlock = document.querySelector('.card-code code');\n  const copyText = async (text) => {\n    if (navigator.clipboard && window.isSecureContext) {\n      await navigator.clipboard.writeText(text);\n      return;\n    }\n\n    const fallback = document.createElement('textarea');\n    fallback.value = text;\n    fallback.setAttribute('readonly', '');\n    fallback.style.position = 'absolute';\n    fallback.style.left = '-9999px';\n    document.body.appendChild(fallback);\n    fallback.select();\n    document.execCommand('copy');\n    fallback.remove();\n  };\n\n  const setCopyState = (label, stateClass) => {\n    copyBtn.innerText = label;\n    copyBtn.classList.toggle('copied', stateClass === 'copied');\n    copyBtn.classList.toggle('copy-failed', stateClass === 'failed');\n  };\n\n  if (copyBtn && codeBlock) {\n    copyBtn.addEventListener('click', async () => {\n      try {\n        await copyText(codeBlock.innerText);\n        setCopyState('Copied!', 'copied');\n      } catch (error) {\n        console.error('Failed to copy quick start snippet.', error);\n        setCopyState('Copy failed', 'failed');\n      }\n\n      window.setTimeout(() => {\n        setCopyState('Copy', '');\n      }, 2000);\n    });\n  }\n\n  // Scroll spy\n  const sections = document.querySelectorAll('section[id]');\n  const navLinks = document.querySelectorAll('.topnav a');\n  const updateActiveLink = () => {\n    let current = '';\n\n    sections.forEach((section) => {\n      if (window.scrollY >= section.offsetTop - 120) {\n        current = section.getAttribute('id');\n      }\n    });\n\n    navLinks.forEach((link) => {\n      link.classList.toggle('active', link.getAttribute('href') === `#${current}`);\n    });\n  };\n\n  updateActiveLink();\n  window.addEventListener('scroll', updateActiveLink, { passive: true });\n});\n"
  },
  {
    "path": "site/styles.css",
    "content": ":root {\n  --bg: #09090b;\n  --card: #131316;\n  --border: rgba(255, 255, 255, 0.08);\n  --border-hover: rgba(255, 255, 255, 0.18);\n  --text: #fafafa;\n  --text-secondary: #a1a1aa;\n  --text-muted: #52525b;\n  --accent-1: #7c3aed;\n  --accent-2: #6366f1;\n  --accent-3: #3b82f6;\n  --gradient: linear-gradient(135deg, var(--accent-1), var(--accent-2), var(--accent-3));\n  --radius: 16px;\n  --radius-sm: 10px;\n}\n\n* { box-sizing: border-box; margin: 0; padding: 0; }\n\nhtml {\n  scroll-behavior: smooth;\n  scroll-padding-top: 24px;\n}\n\nbody {\n  font-family: \"Inter\", -apple-system, BlinkMacSystemFont, sans-serif;\n  color: var(--text);\n  background: var(--bg);\n  line-height: 1.6;\n  -webkit-font-smoothing: antialiased;\n  overflow-x: hidden;\n}\n\na { color: inherit; text-decoration: none; }\n\n/* --- Background Glows --- */\n\n.glow {\n  position: fixed;\n  border-radius: 50%;\n  filter: blur(120px);\n  opacity: 0.15;\n  pointer-events: none;\n  z-index: 0;\n}\n\n.glow-1 {\n  width: 600px;\n  height: 600px;\n  top: -200px;\n  left: -100px;\n  background: var(--accent-1);\n}\n\n.glow-2 {\n  width: 500px;\n  height: 500px;\n  bottom: -100px;\n  right: -150px;\n  background: var(--accent-3);\n  opacity: 0.1;\n}\n\n/* --- Layout --- */\n\n.site-shell {\n  position: relative;\n  z-index: 1;\n  width: min(1120px, calc(100% - 32px));\n  margin: 0 auto;\n  padding: 0 0 40px;\n}\n\n/* --- Cards (shared) --- */\n\n.card {\n  background: var(--card);\n  border: 1px solid var(--border);\n  border-radius: var(--radius);\n  transition: border-color 0.3s ease, box-shadow 0.3s ease;\n}\n\n.card:hover {\n  border-color: var(--border-hover);\n}\n\n/* --- Nav --- */\n\n.topbar {\n  display: grid;\n  grid-template-columns: 1fr auto 1fr;\n  align-items: center;\n  gap: 16px;\n  padding: 20px 0;\n  position: relative;\n  z-index: 100;\n  background: rgba(9, 9, 11, 0.8);\n  backdrop-filter: blur(12px);\n  -webkit-backdrop-filter: blur(12px);\n  border-bottom: 1px solid var(--border);\n}\n\n.brand {\n  display: inline-flex;\n  align-items: center;\n  grid-column: 2;\n  justify-self: center;\n  gap: 10px;\n  font-weight: 700;\n  font-size: 1.05rem;\n}\n\n.brand-mark {\n  display: inline-grid;\n  place-items: center;\n  width: 30px;\n  height: 30px;\n  border-radius: 8px;\n  background: var(--gradient);\n  color: #fff;\n  font-weight: 800;\n  font-size: 0.8rem;\n}\n\n.topnav {\n  display: flex;\n  grid-column: 1;\n  justify-self: start;\n  gap: 28px;\n  align-items: center;\n  flex-wrap: wrap;\n}\n\n.nav-chip {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  min-height: 36px;\n  padding: 8px 14px;\n  border: 1px solid var(--border);\n  border-radius: var(--radius-sm);\n  background: var(--card);\n  color: var(--text-muted);\n  font-size: 0.88rem;\n  font-weight: 500;\n  transition: color 0.2s, border-color 0.2s, background-color 0.2s;\n}\n\n.nav-chip:hover,\n.nav-chip.active {\n  border-color: var(--border-hover);\n  background: rgba(255, 255, 255, 0.04);\n  color: var(--text);\n}\n\n.topbar-actions {\n  display: flex;\n  grid-column: 3;\n  justify-content: flex-end;\n}\n\n/* --- Buttons --- */\n\n.btn {\n  display: inline-flex;\n  align-items: center;\n  gap: 8px;\n  font-weight: 600;\n  font-size: 0.88rem;\n  border-radius: var(--radius-sm);\n  transition: all 0.2s ease;\n  cursor: pointer;\n  border: none;\n}\n\n.btn-nav {\n  padding: 8px 16px;\n  background: var(--card);\n  border: 1px solid var(--border);\n  color: var(--text-secondary);\n}\n\n.btn-nav:hover {\n  border-color: var(--border-hover);\n  color: var(--text);\n}\n\n.btn-primary {\n  padding: 12px 24px;\n  background: var(--gradient);\n  color: #fff;\n}\n\n.btn-primary:hover {\n  opacity: 0.9;\n  transform: translateY(-1px);\n}\n\n.btn-ghost {\n  padding: 12px 24px;\n  background: transparent;\n  border: 1px solid var(--border);\n  color: var(--text-secondary);\n}\n\n.btn-ghost:hover {\n  border-color: var(--border-hover);\n  color: var(--text);\n}\n\n/* --- Hero Bento --- */\n\n.bento-hero {\n  display: grid;\n  grid-template-columns: 1fr 320px;\n  gap: 16px;\n  margin-top: 32px;\n}\n\n.card-hero {\n  padding: 48px 44px;\n}\n\n.card-hero h1 {\n  margin-top: 16px;\n  font-size: clamp(2.4rem, 5vw, 3.6rem);\n  font-weight: 800;\n  line-height: 1.08;\n  letter-spacing: -0.04em;\n  background: linear-gradient(to bottom right, #fff 40%, var(--text-secondary));\n  -webkit-background-clip: text;\n  -webkit-text-fill-color: transparent;\n  background-clip: text;\n}\n\n.sub {\n  margin-top: 16px;\n  color: var(--text-secondary);\n  font-size: 1.05rem;\n  max-width: 48ch;\n  line-height: 1.7;\n}\n\n.hero-actions {\n  display: flex;\n  gap: 12px;\n  margin-top: 32px;\n}\n\n.card-stats {\n  display: flex;\n  flex-direction: column;\n  justify-content: center;\n  gap: 24px;\n  padding: 36px 32px;\n}\n\n.stat {\n  display: flex;\n  flex-direction: column;\n}\n\n.stat-val {\n  font-size: 2rem;\n  font-weight: 800;\n  letter-spacing: -0.03em;\n  background: var(--gradient);\n  -webkit-background-clip: text;\n  -webkit-text-fill-color: transparent;\n  background-clip: text;\n}\n\n.stat-lbl {\n  color: var(--text-muted);\n  font-size: 0.82rem;\n  font-weight: 500;\n  text-transform: uppercase;\n  letter-spacing: 0.08em;\n  margin-top: 2px;\n}\n\n/* --- Labels & Tags --- */\n\n.label {\n  color: var(--text-muted);\n  font-size: 0.78rem;\n  font-weight: 700;\n  letter-spacing: 0.1em;\n  text-transform: uppercase;\n}\n\n.tag {\n  display: inline-block;\n  padding: 4px 10px;\n  border-radius: 6px;\n  background: rgba(124, 58, 237, 0.12);\n  color: #a78bfa;\n  font-size: 0.72rem;\n  font-weight: 700;\n  letter-spacing: 0.06em;\n}\n\n/* --- Features Bento --- */\n\n.bento-features {\n  display: grid;\n  grid-template-columns: repeat(3, 1fr);\n  grid-template-rows: auto auto;\n  gap: 16px;\n  margin-top: 16px;\n}\n\n.card-feat {\n  padding: 28px;\n}\n\n.card-feat h3 {\n  margin-top: 14px;\n  font-size: 1.1rem;\n  font-weight: 700;\n}\n\n.card-feat p {\n  margin-top: 8px;\n  color: var(--text-secondary);\n  font-size: 0.92rem;\n}\n\n/* AI card spans 2 columns */\n.card-feat-ai {\n  grid-column: span 2;\n  position: relative;\n  overflow: hidden;\n}\n\n.card-feat-ai::before {\n  content: \"\";\n  position: absolute;\n  top: 0;\n  right: 0;\n  width: 200px;\n  height: 200px;\n  background: radial-gradient(circle, rgba(124, 58, 237, 0.15) 0%, transparent 70%);\n  pointer-events: none;\n}\n\n/* Code card spans 1 col, 2 rows */\n.card-code {\n  grid-column: 3;\n  grid-row: 1 / 3;\n  padding: 24px;\n  position: relative;\n  display: flex;\n  flex-direction: column;\n}\n\n.card-code pre {\n  margin-top: 16px;\n  overflow-x: auto;\n  font-family: \"SF Mono\", \"Fira Code\", \"Fira Mono\", monospace;\n  font-size: 0.82rem;\n  line-height: 1.9;\n  flex: 1;\n}\n\n.card-code code {\n  color: var(--text-secondary);\n}\n\n/* --- Copy Button --- */\n\n.copy-btn {\n  position: absolute;\n  top: 16px;\n  right: 16px;\n  padding: 5px 12px;\n  background: rgba(255, 255, 255, 0.06);\n  border: 1px solid var(--border);\n  border-radius: 6px;\n  color: var(--text-muted);\n  font-family: inherit;\n  font-size: 0.72rem;\n  font-weight: 600;\n  cursor: pointer;\n  transition: all 0.2s;\n}\n\n.copy-btn:hover {\n  color: var(--text);\n  border-color: var(--border-hover);\n}\n\n.copy-btn.copied {\n  color: #a78bfa;\n  border-color: rgba(124, 58, 237, 0.4);\n}\n\n/* --- Sections --- */\n\n.section {\n  margin-top: 80px;\n  scroll-margin-top: 24px;\n}\n\n.section-head {\n  margin-bottom: 32px;\n}\n\n.section-head h2 {\n  margin-top: 8px;\n  font-size: clamp(1.5rem, 3vw, 2rem);\n  font-weight: 800;\n  letter-spacing: -0.03em;\n}\n\n/* --- Steps --- */\n\n.steps-grid {\n  display: grid;\n  grid-template-columns: repeat(3, 1fr);\n  gap: 16px;\n}\n\n.card-step {\n  padding: 28px;\n  position: relative;\n}\n\n.step-num {\n  display: inline-block;\n  font-size: 2.2rem;\n  font-weight: 800;\n  letter-spacing: -0.04em;\n  background: var(--gradient);\n  -webkit-background-clip: text;\n  -webkit-text-fill-color: transparent;\n  background-clip: text;\n  opacity: 0.5;\n}\n\n.card-step h3 {\n  margin-top: 12px;\n  font-size: 1.1rem;\n  font-weight: 700;\n}\n\n.card-step p {\n  margin-top: 8px;\n  color: var(--text-secondary);\n  font-size: 0.92rem;\n}\n\n.card-step code {\n  padding: 2px 6px;\n  background: rgba(255, 255, 255, 0.06);\n  border-radius: 4px;\n  font-family: \"SF Mono\", monospace;\n  font-size: 0.85em;\n}\n\n/* --- Modules --- */\n\n.module-grid {\n  display: grid;\n  grid-template-columns: repeat(3, 1fr);\n  gap: 16px;\n}\n\n.card-module {\n  padding: 28px;\n}\n\n.card-module h3 {\n  font-size: 1.1rem;\n  font-weight: 700;\n}\n\n.card-module p {\n  margin-top: 8px;\n  color: var(--text-secondary);\n  font-size: 0.92rem;\n}\n\n.card-module ul {\n  margin-top: 16px;\n  padding-left: 16px;\n  color: var(--text-muted);\n  font-size: 0.88rem;\n  line-height: 1.9;\n}\n\n/* --- Docs --- */\n\n.docs-grid {\n  display: grid;\n  grid-template-columns: repeat(2, 1fr);\n  gap: 16px;\n}\n\n.card-doc {\n  display: block;\n  padding: 28px;\n  position: relative;\n}\n\n.card-doc:hover {\n  border-color: rgba(124, 58, 237, 0.3);\n  box-shadow: 0 0 24px -8px rgba(124, 58, 237, 0.15);\n}\n\n.doc-tag {\n  font-size: 0.72rem;\n  font-weight: 700;\n  letter-spacing: 0.08em;\n  text-transform: uppercase;\n  color: var(--text-muted);\n}\n\n.card-doc h3 {\n  margin-top: 10px;\n  font-size: 1.1rem;\n  font-weight: 700;\n}\n\n.card-doc p {\n  margin-top: 6px;\n  color: var(--text-secondary);\n  font-size: 0.88rem;\n}\n\n.card-arrow {\n  position: absolute;\n  top: 28px;\n  right: 28px;\n  color: var(--text-muted);\n  font-size: 1.2rem;\n  transition: transform 0.2s, color 0.2s;\n}\n\n.card-doc:hover .card-arrow {\n  transform: translateX(4px);\n  color: #a78bfa;\n}\n\n/* --- FAQ --- */\n\n.section-faq {\n  border-top: 1px solid var(--border);\n  padding-top: 64px;\n}\n\n.faq-list {\n  display: flex;\n  flex-direction: column;\n}\n\n.faq-item {\n  padding: 20px 0;\n  border-bottom: 1px solid var(--border);\n}\n\n.faq-item h3 {\n  font-size: 0.98rem;\n  font-weight: 600;\n}\n\n.faq-item p {\n  margin-top: 6px;\n  color: var(--text-secondary);\n  font-size: 0.92rem;\n}\n\n/* --- Footer --- */\n\n.footer {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 16px;\n  margin-top: 64px;\n  padding: 24px 0;\n  border-top: 1px solid var(--border);\n}\n\n.footer-left {\n  display: flex;\n  align-items: center;\n  gap: 10px;\n  font-weight: 700;\n  font-size: 0.95rem;\n}\n\n.footer-note {\n  color: var(--text-muted);\n  font-size: 0.85rem;\n}\n\n.footer-links {\n  display: flex;\n  gap: 20px;\n  font-size: 0.88rem;\n  color: var(--text-muted);\n}\n\n.footer-links a:hover {\n  color: var(--text);\n}\n\n/* --- Scroll Animations --- */\n\n.animate-on-scroll,\n.is-visible {\n  opacity: 1;\n  transform: translateY(0);\n}\n\n.js .animate-on-scroll {\n  opacity: 0;\n  transform: translateY(20px);\n  transition: opacity 0.7s cubic-bezier(0.16, 1, 0.3, 1), transform 0.7s cubic-bezier(0.16, 1, 0.3, 1);\n}\n\n.js .is-visible {\n  opacity: 1;\n  transform: translateY(0);\n}\n\n/* Stagger for bento grids */\n.bento-features .animate-on-scroll:nth-child(2) { transition-delay: 0.06s; }\n.bento-features .animate-on-scroll:nth-child(3) { transition-delay: 0.12s; }\n.bento-features .animate-on-scroll:nth-child(4) { transition-delay: 0.18s; }\n.bento-features .animate-on-scroll:nth-child(5) { transition-delay: 0.24s; }\n\n.steps-grid .animate-on-scroll:nth-child(2) { transition-delay: 0.08s; }\n.steps-grid .animate-on-scroll:nth-child(3) { transition-delay: 0.16s; }\n\n.module-grid .animate-on-scroll:nth-child(2) { transition-delay: 0.08s; }\n.module-grid .animate-on-scroll:nth-child(3) { transition-delay: 0.16s; }\n\n.docs-grid .animate-on-scroll:nth-child(2) { transition-delay: 0.06s; }\n.docs-grid .animate-on-scroll:nth-child(3) { transition-delay: 0.12s; }\n.docs-grid .animate-on-scroll:nth-child(4) { transition-delay: 0.18s; }\n\n/* --- Responsive --- */\n\n@media (max-width: 960px) {\n  .topbar {\n    grid-template-columns: 1fr;\n  }\n\n  .brand,\n  .topnav,\n  .topbar-actions {\n    grid-column: auto;\n    justify-self: center;\n  }\n\n  .bento-hero {\n    grid-template-columns: 1fr;\n  }\n\n  .card-stats {\n    flex-direction: row;\n    justify-content: flex-start;\n    gap: 40px;\n  }\n\n  .bento-features {\n    grid-template-columns: 1fr 1fr;\n    grid-template-rows: auto;\n  }\n\n  .card-feat-ai {\n    grid-column: span 2;\n  }\n\n  .card-code {\n    grid-column: span 2;\n    grid-row: auto;\n  }\n\n  .steps-grid,\n  .module-grid {\n    grid-template-columns: 1fr;\n  }\n\n  .docs-grid {\n    grid-template-columns: 1fr;\n  }\n}\n\n@media (max-width: 640px) {\n  .site-shell {\n    width: calc(100% - 24px);\n  }\n\n  .topbar {\n    padding: 14px 0;\n    gap: 12px;\n  }\n\n  .topnav {\n    display: flex;\n    justify-content: center;\n    flex-wrap: wrap;\n    gap: 10px;\n    width: 100%;\n  }\n\n  .nav-chip {\n    width: auto;\n    min-height: 40px;\n    padding: 9px 12px;\n  }\n\n  .topbar-actions {\n    width: 100%;\n    justify-content: center;\n  }\n\n  .btn-nav {\n    width: min(100%, 220px);\n    justify-content: center;\n  }\n\n  .bento-features {\n    grid-template-columns: 1fr;\n  }\n\n  .card-feat-ai {\n    grid-column: auto;\n  }\n\n  .card-code {\n    grid-column: auto;\n  }\n\n  .card-hero {\n    padding: 32px 24px;\n  }\n\n  .card-hero h1 {\n    font-size: clamp(2rem, 11vw, 2.85rem);\n  }\n\n  .sub {\n    font-size: 0.98rem;\n    line-height: 1.65;\n  }\n\n  .hero-actions {\n    flex-direction: column;\n  }\n\n  .btn-primary,\n  .btn-ghost,\n  .btn-nav {\n    justify-content: center;\n  }\n\n  .card-stats {\n    display: grid;\n    grid-template-columns: repeat(3, minmax(0, 1fr));\n    gap: 12px;\n    padding: 22px 18px;\n  }\n\n  .stat {\n    text-align: center;\n  }\n\n  .stat-val {\n    font-size: 1.6rem;\n  }\n\n  .card-feat,\n  .card-code,\n  .card-step,\n  .card-module,\n  .card-doc {\n    padding: 22px 20px;\n  }\n\n  .card-code pre {\n    margin-top: 18px;\n    font-size: 0.76rem;\n    line-height: 1.75;\n  }\n\n  .copy-btn {\n    top: 12px;\n    right: 12px;\n  }\n\n  .footer {\n    flex-direction: column;\n    align-items: flex-start;\n    gap: 12px;\n  }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  * {\n    animation-duration: 0.01ms !important;\n    transition-duration: 0.01ms !important;\n    scroll-behavior: auto !important;\n  }\n\n  .animate-on-scroll {\n    opacity: 1 !important;\n    transform: none !important;\n  }\n}\n"
  },
  {
    "path": "tests/conftest.py",
    "content": "import sys\nfrom pathlib import Path\n\n\nPROJECT_ROOT = Path(__file__).resolve().parents[1]\nif str(PROJECT_ROOT) not in sys.path:\n    sys.path.insert(0, str(PROJECT_ROOT))\n"
  },
  {
    "path": "tests/test_config_loader.py",
    "content": "import json\n\nfrom config_loader import load_runtime_config\n\n\ndef test_load_runtime_config_prefers_env_over_file(monkeypatch, tmp_path):\n    monkeypatch.chdir(tmp_path)\n    monkeypatch.setenv(\"DISCORD_TOKEN\", \"env-token\")\n    monkeypatch.setenv(\"ALLOW_USER_KEYS\", \"false\")\n    monkeypatch.setenv(\"ALLOWED_CHAT_MODELS\", \"gpt-4o-mini,gpt-4o\")\n\n    (tmp_path / \"config.json\").write_text(\n        json.dumps(\n            {\n                \"DISCORD_TOKEN\": \"file-token\",\n                \"ALLOW_USER_KEYS\": True,\n                \"ALLOWED_CHAT_MODELS\": [\"file-model\"],\n            }\n        ),\n        encoding=\"utf-8\",\n    )\n\n    config = load_runtime_config()\n\n    assert config[\"DISCORD_TOKEN\"] == \"env-token\"\n    assert config[\"ALLOW_USER_KEYS\"] is False\n    assert config[\"ALLOWED_CHAT_MODELS\"] == [\"gpt-4o-mini\", \"gpt-4o\"]\n\n\ndef test_load_runtime_config_falls_back_to_config_json(monkeypatch, tmp_path):\n    monkeypatch.chdir(tmp_path)\n    for key in (\n        \"DISCORD_TOKEN\",\n        \"OPENAI_API_KEY\",\n        \"OPENAI_API_BASE\",\n        \"ALLOW_USER_KEYS\",\n        \"DEFAULT_CHAT_MODEL\",\n        \"ALLOWED_CHAT_MODELS\",\n        \"GOOGLE_API_KEY\",\n        \"GOOGLE_CSE_ID\",\n    ):\n        monkeypatch.delenv(key, raising=False)\n\n    (tmp_path / \"config.json\").write_text(\n        json.dumps(\n            {\n                \"DISCORD_TOKEN\": \"file-token\",\n                \"OPENAI_API_KEY\": \"openai-key\",\n                \"OPENAI_API_BASE\": \"https://example.invalid/v1\",\n                \"ALLOW_USER_KEYS\": False,\n                \"DEFAULT_CHAT_MODEL\": \"gpt-test\",\n                \"ALLOWED_CHAT_MODELS\": [\"gpt-test\", \"gpt-other\"],\n                \"GOOGLE_API_KEY\": \"google-key\",\n                \"GOOGLE_CSE_ID\": \"search-id\",\n            }\n        ),\n        encoding=\"utf-8\",\n    )\n\n    config = load_runtime_config()\n\n    assert config[\"DISCORD_TOKEN\"] == \"file-token\"\n    assert config[\"OPENAI_API_KEY\"] == \"openai-key\"\n    assert config[\"OPENAI_API_BASE\"] == \"https://example.invalid/v1\"\n    assert config[\"ALLOW_USER_KEYS\"] is False\n    assert config[\"DEFAULT_CHAT_MODEL\"] == \"gpt-test\"\n    assert config[\"ALLOWED_CHAT_MODELS\"] == [\"gpt-test\", \"gpt-other\"]\n    assert config[\"GOOGLE_API_KEY\"] == \"google-key\"\n    assert config[\"GOOGLE_CSE_ID\"] == \"search-id\"\n"
  },
  {
    "path": "tests/test_duration_parsing.py",
    "content": "from cogs.community import parse_duration\nfrom cogs.utility import parse_duration_spec\n\n\ndef test_parse_duration_spec_supports_seconds_minutes_hours_days():\n    assert parse_duration_spec(\"15s\") == 15\n    assert parse_duration_spec(\"2m\") == 120\n    assert parse_duration_spec(\"3h\") == 10800\n    assert parse_duration_spec(\"4d\") == 345600\n\n\ndef test_parse_duration_spec_rejects_invalid_values():\n    assert parse_duration_spec(\"10\") is None\n    assert parse_duration_spec(\"abc\") is None\n    assert parse_duration_spec(\"5w\") is None\n\n\ndef test_parse_duration_supports_minutes_hours_days():\n    assert parse_duration(\"15m\") == 900\n    assert parse_duration(\"2h\") == 7200\n    assert parse_duration(\"3d\") == 259200\n\n\ndef test_parse_duration_rejects_invalid_values():\n    assert parse_duration(\"\") is None\n    assert parse_duration(\"30\") is None\n    assert parse_duration(\"abc\") is None\n    assert parse_duration(\"7s\") is None\n"
  },
  {
    "path": "tests/test_issue_fixes.py",
    "content": "from datetime import timedelta\nfrom types import SimpleNamespace\nfrom unittest.mock import AsyncMock\n\nimport discord\nimport pytest\n\nfrom cogs.chat import COOLDOWN_RETENTION, Chat\nfrom cogs.community import Community, SCHEDULE_BATCH_SIZE\nfrom cogs.economy import Economy\nfrom cogs.utility import REMINDER_BATCH_SIZE, Utility\n\n\nclass FakeCursor:\n    def __init__(self, rows=None):\n        self.rows = rows or []\n        self.executed = []\n\n    async def __aenter__(self):\n        return self\n\n    async def __aexit__(self, exc_type, exc, tb):\n        return False\n\n    async def execute(self, query, params=()):\n        self.executed.append((query, params))\n\n    async def fetchall(self):\n        return list(self.rows)\n\n\nclass FakeDB:\n    def __init__(self, rows=None):\n        self.rows = rows or []\n        self.cursor_instances = []\n        self.commit_calls = 0\n\n    def cursor(self):\n        cursor = FakeCursor(self.rows)\n        self.cursor_instances.append(cursor)\n        return cursor\n\n    async def commit(self):\n        self.commit_calls += 1\n\n\n@pytest.mark.asyncio\nasync def test_afk_listener_deduplicates_repeated_mentions():\n    utility = Utility.__new__(Utility)\n    utility.bot = SimpleNamespace()\n    utility.afk_cache = {}\n    utility.get_afk_status = AsyncMock(return_value=None)\n    utility.clear_afk_status = AsyncMock()\n    set_at = (discord.utils.utcnow() - timedelta(minutes=5)).isoformat()\n    utility.get_many_afk_statuses = AsyncMock(return_value={42: (\"Lunch\", set_at)})\n\n    mentioned_user = SimpleNamespace(id=42, bot=False, display_name=\"Casey\")\n    channel = SimpleNamespace(send=AsyncMock())\n    message = SimpleNamespace(\n        author=SimpleNamespace(id=100, bot=False),\n        guild=SimpleNamespace(id=555),\n        channel=channel,\n        mentions=[mentioned_user, mentioned_user],\n    )\n\n    await Utility.afk_message_listener(utility, message)\n\n    utility.get_many_afk_statuses.assert_awaited_once_with(555, [42])\n    channel.send.assert_awaited_once()\n    sent_text = channel.send.await_args.args[0]\n    assert sent_text.count(\"Casey is AFK\") == 1\n\n\n@pytest.mark.asyncio\nasync def test_reminder_loop_queries_due_work_in_bounded_batches():\n    utility = Utility.__new__(Utility)\n    utility.bot = SimpleNamespace(db=FakeDB(), get_user=lambda user_id: None)\n\n    await Utility.reminder_loop.coro(utility)\n\n    cursor = utility.bot.db.cursor_instances[0]\n    assert cursor.executed[0][1][1] == REMINDER_BATCH_SIZE\n\n\n@pytest.mark.asyncio\nasync def test_schedule_loop_queries_due_work_in_bounded_batches():\n    community = Community.__new__(Community)\n    community.bot = SimpleNamespace(db=FakeDB())\n\n    await Community.schedule_loop.coro(community)\n\n    cursor = community.bot.db.cursor_instances[0]\n    assert cursor.executed[0][1][1] == SCHEDULE_BATCH_SIZE\n\n\n@pytest.mark.asyncio\nasync def test_chat_prune_cooldowns_removes_only_expired_entries():\n    chat = Chat.__new__(Chat)\n    now = discord.utils.utcnow()\n    expired_key = (1, 1)\n    fresh_key = (1, 2)\n    chat.chat_cooldowns = {\n        expired_key: now - COOLDOWN_RETENTION - timedelta(minutes=1),\n        fresh_key: now - timedelta(minutes=5),\n    }\n\n    await Chat._prune_cooldowns.coro(chat)\n\n    assert expired_key not in chat.chat_cooldowns\n    assert fresh_key in chat.chat_cooldowns\n\n\ndef test_economy_guild_lock_is_scoped_per_guild():\n    economy = Economy.__new__(Economy)\n    economy._guild_locks = {}\n\n    guild_one_lock = economy._guild_lock(1)\n    same_guild_lock = economy._guild_lock(1)\n    other_guild_lock = economy._guild_lock(2)\n\n    assert guild_one_lock is same_guild_lock\n    assert guild_one_lock is not other_guild_lock\n"
  },
  {
    "path": "tests/test_retry_delays.py",
    "content": "from datetime import timedelta\n\nfrom cogs.community import Community, SCHEDULE_RETRY_MAX_SECONDS\nfrom cogs.utility import REMINDER_RETRY_MAX_SECONDS, Utility\n\n\ndef test_reminder_retry_delay_grows_and_caps():\n    utility = Utility.__new__(Utility)\n\n    assert utility.reminder_retry_delay(1) == timedelta(minutes=5)\n    assert utility.reminder_retry_delay(2) == timedelta(minutes=10)\n    assert utility.reminder_retry_delay(3) == timedelta(minutes=20)\n    assert utility.reminder_retry_delay(99) == timedelta(seconds=REMINDER_RETRY_MAX_SECONDS)\n\n\ndef test_schedule_retry_delay_grows_and_caps():\n    community = Community.__new__(Community)\n\n    assert community.schedule_retry_delay(1) == timedelta(minutes=5)\n    assert community.schedule_retry_delay(2) == timedelta(minutes=10)\n    assert community.schedule_retry_delay(3) == timedelta(minutes=20)\n    assert community.schedule_retry_delay(99) == timedelta(seconds=SCHEDULE_RETRY_MAX_SECONDS)\n"
  }
]