main 3e9d328496f5 cached
47 files
286.9 KB
66.7k tokens
273 symbols
1 requests
Download .txt
Showing preview only (302K chars total). Download the full file or copy to clipboard to get everything.
Repository: msgaxzzz/Milo-discord-fun-bot
Branch: main
Commit: 3e9d328496f5
Files: 47
Total size: 286.9 KB

Directory structure:
gitextract_ia4j90bs/

├── .env.example
├── .flake8
├── .github/
│   ├── ISSUE_TEMPLATE/
│   │   ├── bug_report.yml
│   │   └── feature_request.yml
│   ├── pull_request_template.md
│   └── workflows/
│       ├── ci.yml
│       └── release.yml
├── .gitignore
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── SECURITY.md
├── SUPPORT.md
├── cogs/
│   ├── chat.py
│   ├── community.py
│   ├── economy.py
│   ├── farming.py
│   ├── fun.py
│   ├── games.py
│   ├── interactions.py
│   ├── media.py
│   ├── moderation.py
│   └── utility.py
├── config.example.json
├── config_loader.py
├── docs/
│   ├── commands.md
│   ├── configuration.md
│   ├── deployment.md
│   ├── faq.md
│   └── operations.md
├── install.bat
├── install.sh
├── main.py
├── requirements-dev.txt
├── requirements.txt
├── site/
│   ├── 404.html
│   ├── _headers
│   ├── index.html
│   ├── script.js
│   └── styles.css
└── tests/
    ├── conftest.py
    ├── test_config_loader.py
    ├── test_duration_parsing.py
    ├── test_issue_fixes.py
    └── test_retry_delays.py

================================================
FILE CONTENTS
================================================

================================================
FILE: .env.example
================================================
# Milo configuration
# Copy this file to `.env` and replace placeholder values.

# Required
DISCORD_TOKEN=your_discord_bot_token_here

# Optional: default AI provider settings
OPENAI_API_KEY=your_openai_api_key_here
OPENAI_API_BASE=https://api.openai.com/v1
ALLOW_USER_KEYS=true
DEFAULT_CHAT_MODEL=gpt-4o-mini
ALLOWED_CHAT_MODELS=gpt-4o-mini,gpt-4o

# Optional: Google Custom Search for `/chat --search_web`
# These values may incur cost depending on your Google setup.
GOOGLE_API_KEY=your_google_api_key_here
GOOGLE_CSE_ID=your_custom_search_engine_id_here


================================================
FILE: .flake8
================================================
[flake8]
max-line-length = 120
exclude = 
    .git,
    __pycache__,
    .venv,
    venv,
    database
ignore = E203,E501,W503


================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.yml
================================================
name: Bug Report
description: Report a reproducible problem in Milo
title: "[Bug]: "
labels:
  - bug
body:
  - type: markdown
    attributes:
      value: |
        Use this form for reproducible defects. Do not report security issues here. Follow `SECURITY.md` for private reporting.
  - type: textarea
    id: summary
    attributes:
      label: Summary
      description: What broke?
      placeholder: A short description of the problem
    validations:
      required: true
  - type: textarea
    id: steps
    attributes:
      label: Steps To Reproduce
      description: List the exact steps needed to trigger the issue
      placeholder: |
        1. Run ...
        2. Use command ...
        3. See error ...
    validations:
      required: true
  - type: textarea
    id: expected
    attributes:
      label: Expected Behavior
      placeholder: What should have happened?
    validations:
      required: true
  - type: textarea
    id: actual
    attributes:
      label: Actual Behavior
      placeholder: What happened instead?
    validations:
      required: true
  - type: textarea
    id: logs
    attributes:
      label: Logs Or Screenshots
      description: Paste relevant logs or screenshots. Remove secrets before submitting.
      render: shell
  - type: textarea
    id: environment
    attributes:
      label: Environment
      description: Runtime details that may matter
      placeholder: |
        Python version:
        OS:
        discord.py version:
        Hosting provider:
  - type: dropdown
    id: scope
    attributes:
      label: Impacted Area
      options:
        - Commands
        - Economy
        - Farming
        - AI Chat
        - Install / Setup
        - Docs
        - Other
    validations:
      required: true


================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.yml
================================================
name: Feature Request
description: Suggest an improvement or new feature for Milo
title: "[Feature]: "
labels:
  - enhancement
body:
  - type: textarea
    id: problem
    attributes:
      label: Problem
      description: What limitation or pain point are you trying to solve?
      placeholder: The current behavior makes it hard to ...
    validations:
      required: true
  - type: textarea
    id: proposal
    attributes:
      label: Proposed Solution
      description: Describe the feature you want
      placeholder: Add a command / setting / behavior that ...
    validations:
      required: true
  - type: textarea
    id: alternatives
    attributes:
      label: Alternatives Considered
      description: Any other approaches you considered?
  - type: dropdown
    id: breaking
    attributes:
      label: Breaking Change?
      options:
        - "No"
        - "Maybe"
        - "Yes"
    validations:
      required: true
  - type: dropdown
    id: area
    attributes:
      label: Area
      options:
        - Commands
        - Economy
        - Farming
        - AI Chat
        - Moderation
        - Developer Experience
        - Docs
        - Other
    validations:
      required: true


================================================
FILE: .github/pull_request_template.md
================================================
## Summary

- What problem does this change solve?

## Changes

- What changed?

## Verification

- How was this tested?

## Risk

- Any schema, config, permission, or external API impact?

## Checklist

- [ ] No real secrets were committed
- [ ] Manual verification was completed
- [ ] README or docs were updated if behavior changed


================================================
FILE: .github/workflows/ci.yml
================================================
name: CI

on:
  push:
    branches:
      - main
  pull_request:

jobs:
  compile:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"
          cache: "pip"
          cache-dependency-path: requirements.txt

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt
          pip install pytest pytest-asyncio

      - name: Run tests
        run: pytest

      - name: Compile project
        run: python -m compileall .


================================================
FILE: .github/workflows/release.yml
================================================
name: Release

on:
  push:
    tags:
      - "v*"

permissions:
  contents: write

jobs:
  create-release:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Create GitHub release
        uses: softprops/action-gh-release@v2
        with:
          generate_release_notes: true
          prerelease: ${{ contains(github.ref_name, '-') }}


================================================
FILE: .gitignore
================================================
config.json
.env
.env.*
!.env.example
__pycache__/
*.py[cod]
.wrangler/
.venv/
database/*.db
/Milo-discord-fun-bot/


================================================
FILE: CHANGELOG.md
================================================
## Changelog

**v1.0.4** – 2026-03-20
- Added bounded reminder and scheduled announcement polling, plus admin diagnostics for failed announcements.
- Added cleanup for stale chat cooldown state and retained message logs with periodic pruning.
- Hardened media commands against upstream API failures and clarified automod handling for thread channels.
- Reduced economy lock contention to per-guild scope and added length guards for reminders and announcements.
- Prevented `/help all` embed overflows and added regression tests covering the issue backlog fixes.

**v1.0.3** – 2026-03-14
- Isolated guild chat history per user and added server-side AI chat safety controls.
- Added basic automod, warning history commands, and better moderation logging support.
- Added scheduled announcements, welcome/goodbye previews, recurring reminders, and reminder snoozing.
- Fixed reminder delivery loss, guild economy cooldown scope, and anti-spam isolation issues.


**v1.0.2** – 2025-07-28
- Improved the installation script with added support for Windows environments.


**v1.0.1** – 2025-07-23
- Initial public release


================================================
FILE: CODE_OF_CONDUCT.md
================================================
# Code of Conduct

## Our Standard

Participation in this project should be respectful, direct, and constructive.

Examples of expected behavior:

- Give technical feedback without personal attacks
- Assume mistakes can be corrected
- Keep disagreement focused on code, design, and evidence
- Respect maintainers' time and scope decisions

Examples of unacceptable behavior:

- Harassment, insults, or intimidation
- Doxxing or sharing private information
- Spam, trolling, or deliberately disruptive behavior
- Repeatedly pressuring maintainers after a decision has been made

## Enforcement

Project maintainers may remove comments, close discussions, or block participation when behavior is harmful to the project or its contributors.

## Reporting

For conduct issues, contact the maintainer privately through the security contact path in [SECURITY.md](./SECURITY.md) or through GitHub private contact if available.

## Scope

This policy applies to repository discussions, issues, pull requests, and project-related community spaces.


================================================
FILE: CONTRIBUTING.md
================================================
# Contributing to Milo

Thanks for contributing. Keep changes small, reviewable, and easy to test.

## Before You Start

- Read [README.md](./README.md) for setup instructions.
- Do not commit real secrets, tokens, or API keys.
- Prefer opening an issue before large feature work or schema changes.

## Development Setup

1. Clone the repository.
2. Create and activate a virtual environment.
3. Install dependencies:

```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```

4. Configure secrets with `.env` or a local `config.json`.
5. Start the bot:

```bash
python3 main.py
```

Milo targets Python 3.9+.

## Project Expectations

- Target Python 3.10+ when possible, while preserving current runtime compatibility.
- Keep commands responsive. Avoid blocking I/O in command handlers.
- Treat database updates as stateful operations. For economy and farming changes, avoid race-prone read/modify/write patterns.
- Server-specific features must stay isolated per guild unless the behavior is explicitly global.
- Any feature that touches secrets, external APIs, or moderation behavior should include a short note in the PR description explaining the risk.

## Code Style

- Follow the existing module layout under `cogs/`.
- Use clear names and keep functions focused.
- Add comments only when the code is not obvious from the implementation.
- Reuse shared config and HTTP session handling instead of creating new global clients per cog.

## Pull Requests

Open a pull request with:

- A short summary of the problem
- The approach you took
- Any database or config changes
- Manual test steps
- Screenshots or command examples if user-facing behavior changed

Good PRs are narrow. Avoid mixing refactors, feature work, and formatting-only changes in one branch.

## Testing

There is no full automated test suite yet, so every PR should include manual verification.

At minimum:

- Run a syntax check:

```bash
python3 -m compileall .
```

- Exercise the changed slash commands in a test server.
- Verify that no secrets were added to tracked files.

## Security

Do not report security issues in public issues or pull requests. Follow [SECURITY.md](./SECURITY.md).


================================================
FILE: LICENSE
================================================
Copyright 2026 Sentinel Team and contributors

Permission 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:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE 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.


================================================
FILE: README.md
================================================
# Milo

Milo is an open-source Discord bot for community operations, support, and lightweight moderation, built with `discord.py`, `aiosqlite`, and `aiohttp`.

It combines:

- AI-assisted community help with optional web search
- Moderation, announcements, reminders, and server utility workflows
- Per-server progression systems and engagement features
- Self-hosted infrastructure for small online communities

## Why This Project Exists

Milo 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:

- AI-assisted help for common community questions
- reminders, announcements, and lightweight operations workflows
- simple moderation and safety helpers
- self-hosted engagement features that communities can adapt to their own needs

## Project Model

- License: MIT
- Runtime target: Python 3.9+
- Storage: SQLite
- Secrets: environment variables first, then local `config.json`
- Distribution: free and open source
- Maintenance model: community-maintained and intended for self-hosted, non-closed deployments

## Quick Start

Use the installer script if you want the fastest local setup:

### Linux / macOS

```bash
curl -fsSL https://raw.githubusercontent.com/msgaxzzz/Milo-discord-fun-bot/main/install.sh -o install.sh
bash install.sh
```

### Windows

```powershell
powershell -ExecutionPolicy Bypass -Command "iwr https://raw.githubusercontent.com/msgaxzzz/Milo-discord-fun-bot/main/install.bat -OutFile install.bat"
.\install.bat
```

The 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.

If you prefer a manual setup, use:

```bash
git clone https://github.com/msgaxzzz/Milo-discord-fun-bot.git
cd Milo-discord-fun-bot
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
python3 main.py
```

Fill in `.env` before starting the bot.

## Who It Is For

- open-source project servers
- small online communities that need self-hosted tooling
- learning groups, support communities, and volunteer-run servers
- maintainers who want a compact `discord.py` codebase to extend

## What Milo Includes

- AI chat with configurable model allowlists and optional Google Custom Search
- Chat safety controls for cooldowns, channel rules, role allowlists, and daily usage caps
- Utility commands for persisted reminders, recurring reminders, AFK management, help, and server info
- Community tooling for welcome messages, leave messages, scheduled announcements, and mod logs
- Moderation tooling for warnings, invite/link filters, bad word filters, and channel whitelists
- Economy commands with per-guild balances and leaderboards
- Admin tools for managing server economy balances
- Farming progression tied to the server economy
- Games like `/guess`, `/tictactoe`, `/roll`, and `/rps`
- Fun and media commands for polls, memes, avatars, GIF interactions, and image generation

## Important Behavior

- Economy and farming data are isolated per guild
- AI chat works in servers and DMs, but server configuration commands are guild-only
- Guild chat history is isolated per user instead of being shared by the whole channel
- Reminders are persisted in SQLite, survive restarts, and can be recurring
- AFK status is stored per guild and cleared on your next message in that server
- Real secrets should never be committed to git

## Configuration

Milo loads config in this order:

1. Environment variables from the current shell or `.env`
2. Local `config.json`

Required:

- `DISCORD_TOKEN`

Optional:

- `OPENAI_API_KEY`
- `OPENAI_API_BASE`
- `ALLOW_USER_KEYS`
- `DEFAULT_CHAT_MODEL`
- `ALLOWED_CHAT_MODELS`
- `GOOGLE_API_KEY`
- `GOOGLE_CSE_ID`

See:

- [Configuration Guide](./docs/configuration.md)
- [.env.example](./.env.example)

## Documentation

- [Command Reference](./docs/commands.md)
- [Configuration Guide](./docs/configuration.md)
- [Deployment Guide](./docs/deployment.md)
- [Operations Notes](./docs/operations.md)
- [FAQ](./docs/faq.md)
- [Contributing](./CONTRIBUTING.md)
- [Code of Conduct](./CODE_OF_CONDUCT.md)
- [Security Policy](./SECURITY.md)
- [Support](./SUPPORT.md)

## Contributors

Milo is community-maintained. See the full contributor list on GitHub:

- [Contributors Graph](https://github.com/msgaxzzz/Milo-discord-fun-bot/graphs/contributors)
- 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).

## Installation Scripts

The repository includes:

- [install.sh](./install.sh) for Linux/macOS-style environments
- [install.bat](./install.bat) for Windows

Both installers are intended for local setup and will generate a local `config.json`.

## Manual Setup

### Linux / macOS

```bash
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
cp .env.example .env
python3 main.py
```

### Windows

```bat
py -3.9 -m venv .venv
.venv\Scripts\activate
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
copy .env.example .env
python main.py
```

## Deployment

For a small self-hosted setup, any machine that can:

- run Python 3.9+
- keep a long-lived process online
- write to local disk
- access Discord and optional external APIs

is enough.

Common options:

- a VPS
- a home server
- a cloud VM
- a container host

See [Deployment Guide](./docs/deployment.md) for process management and environment notes.

## FAQ

Common questions:

- Does Milo support DMs for AI chat? Yes.
- Is the economy global across all servers? No, it is isolated per guild.
- Do reminders survive restarts? Yes.
- Can I schedule recurring reminders and server announcements? Yes.
- Do I need OpenAI credentials to run the bot? Only for AI chat features.

See the full [FAQ](./docs/faq.md).

## Development Notes

- Slash commands are loaded from modules in `cogs/`
- Shared HTTP access is managed centrally by the bot process
- SQLite schema is created and migrated at startup
- The project currently relies on manual verification rather than a full automated test suite

## Security

- Never commit real Discord, OpenAI, or Google API credentials
- Use `.env` or a gitignored local `config.json`
- Report vulnerabilities privately according to [SECURITY.md](./SECURITY.md)

## Project Structure

```text
main.py
config_loader.py
cogs/
docs/
```

## Roadmap

Near-term improvements that would strengthen the project:

- automated tests for economy, farming, reminder, and automod flows
- richer reporting around reminder delivery failures and scheduled announcement failures
- structured logging and better runtime error reporting
- richer deployment examples
- command reference generation from source metadata


================================================
FILE: SECURITY.md
================================================
# Security Policy

## Supported Versions

Only the latest code on `main` should be assumed to receive security fixes.

## Reporting a Vulnerability

Do not open a public issue for security problems.

Report vulnerabilities privately with:

- A clear description of the issue
- Steps to reproduce
- Impact
- Any suggested fix or mitigation

## What to Report

Examples:

- Token or API key exposure
- Permission bypass
- Unsafe handling of user-provided API keys
- Remote code execution or command injection
- Database corruption or multi-guild data leakage
- Abuse paths in moderation features

## Response Goals

The maintainer should aim to:

- Acknowledge valid reports promptly
- Reproduce and assess impact
- Patch privately when reasonable
- Credit the reporter if they want attribution

## Secret Handling

- Never commit real `DISCORD_TOKEN`, OpenAI keys, or Google API keys
- Use `.env` or a gitignored local `config.json`
- Rotate any credential immediately if it is exposed in git history, logs, or screenshots


================================================
FILE: SUPPORT.md
================================================
# Support

## Getting Help

Use the repository issue tracker for:

- Reproducible bugs
- Feature requests
- Documentation gaps

Before opening an issue:

- Read [README.md](./README.md)
- Read [CONTRIBUTING.md](./CONTRIBUTING.md)
- Check whether the issue is already open

## What To Include

Good support requests include:

- What you were trying to do
- The exact command or workflow involved
- Your runtime environment
- Logs or screenshots with secrets removed

## Security Issues

Do not request support for vulnerabilities in public issues. Use [SECURITY.md](./SECURITY.md) instead.

## Scope

This 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.


================================================
FILE: cogs/chat.py
================================================
import json
import logging
from collections import defaultdict
from datetime import timedelta
from typing import Any, Dict, List, Optional, Tuple

import discord
from discord import app_commands
from discord.ext import commands, tasks


logger = logging.getLogger(__name__)

DEFAULT_API_BASE = "https://api.openai.com/v1"
DEFAULT_MODEL = "gpt-3.5-turbo"
MAX_CONVERSATION_HISTORY = 10
MAX_PERSONA_LENGTH = 500
MAX_EMBED_FIELD_LENGTH = 1024
MAX_EMBED_DESCRIPTION_LENGTH = 4096
CONVERSATION_TTL = timedelta(hours=6)
COOLDOWN_RETENTION = timedelta(days=1)
DEFAULT_PERSONA = (
    "You are Milo, a friendly and helpful Discord bot. "
    "You can access real-time information using the 'google_search' tool for current events or specific data. "
    "Keep your answers concise and engaging."
)


class Chat(commands.Cog):
    def __init__(self, bot: commands.Bot):
        self.bot = bot
        self.conversations: Dict[Tuple[Any, ...], List[Dict[str, Any]]] = defaultdict(list)
        self.chat_cooldowns: Dict[Tuple[int, int], discord.utils.utcnow] = {}
        self.conversation_last_used: Dict[Tuple[Any, ...], discord.utils.utcnow] = {}
        self.load_config()
        self.bot.loop.create_task(self.setup_database())
        self._prune_cooldowns.start()

    def cog_unload(self):
        self._prune_cooldowns.cancel()

    def load_config(self):
        config = getattr(self.bot, "config", {})
        self.default_api_key = config.get("OPENAI_API_KEY")
        self.api_base = config.get("OPENAI_API_BASE", DEFAULT_API_BASE)
        self.allow_user_keys = config.get("ALLOW_USER_KEYS", True)
        self.default_model = config.get("DEFAULT_CHAT_MODEL", DEFAULT_MODEL)
        self.allowed_models = config.get("ALLOWED_CHAT_MODELS", [DEFAULT_MODEL])
        self.google_api_key = config.get("GOOGLE_API_KEY")
        self.google_cse_id = config.get("GOOGLE_CSE_ID")
        self.enable_web_search = bool(self.google_api_key and self.google_cse_id)

    @tasks.loop(minutes=10)
    async def _prune_cooldowns(self):
        now = discord.utils.utcnow()
        expired = [key for key, ts in self.chat_cooldowns.items() if (now - ts) > COOLDOWN_RETENTION]
        for key in expired:
            del self.chat_cooldowns[key]

    async def setup_database(self):
        async with self.bot.db.cursor() as cursor:
            await cursor.execute(
                """
                CREATE TABLE IF NOT EXISTS guild_configs (
                    guild_id INTEGER PRIMARY KEY,
                    openai_key TEXT,
                    persona TEXT
                )
                """
            )
            await cursor.execute(
                """
                CREATE TABLE IF NOT EXISTS chat_policies (
                    guild_id INTEGER PRIMARY KEY,
                    enabled INTEGER NOT NULL DEFAULT 1,
                    cooldown_seconds INTEGER NOT NULL DEFAULT 8,
                    daily_usage_limit INTEGER,
                    allowed_channel_ids TEXT,
                    blocked_channel_ids TEXT,
                    allowed_role_ids TEXT
                )
                """
            )
            await cursor.execute(
                """
                CREATE TABLE IF NOT EXISTS chat_usage (
                    guild_id INTEGER NOT NULL,
                    usage_date TEXT NOT NULL,
                    usage_count INTEGER NOT NULL DEFAULT 0,
                    PRIMARY KEY (guild_id, usage_date)
                )
                """
            )

            columns = await self._table_columns(cursor, "guild_configs")
            if "persona" not in columns:
                await cursor.execute("ALTER TABLE guild_configs ADD COLUMN persona TEXT")
        await self.bot.db.commit()

    async def _table_columns(self, cursor, table_name: str) -> List[str]:
        await cursor.execute(f"PRAGMA table_info({table_name})")
        return [row[1] for row in await cursor.fetchall()]

    @property
    def session(self):
        return self.bot.http_session

    def _context_key(self, interaction: discord.Interaction) -> Tuple[Any, ...]:
        if interaction.guild_id:
            return ("guild", interaction.guild_id, interaction.channel_id, interaction.user.id)
        return ("dm", interaction.user.id)

    def _serialize_ids(self, ids: List[int]) -> Optional[str]:
        cleaned = sorted({int(item) for item in ids})
        return json.dumps(cleaned) if cleaned else None

    def _deserialize_ids(self, raw: Optional[str]) -> List[int]:
        if not raw:
            return []
        try:
            values = json.loads(raw)
        except json.JSONDecodeError:
            return []
        return [int(item) for item in values if str(item).isdigit()]

    async def get_guild_config(self, guild_id: Optional[int]):
        if not guild_id:
            return None, None
        async with self.bot.db.cursor() as cursor:
            await cursor.execute("SELECT openai_key, persona FROM guild_configs WHERE guild_id = ?", (guild_id,))
            return await cursor.fetchone()

    async def set_guild_key(self, guild_id: int, key: Optional[str] = None):
        async with self.bot.db.cursor() as cursor:
            await cursor.execute("INSERT OR IGNORE INTO guild_configs (guild_id) VALUES (?)", (guild_id,))
            await cursor.execute("UPDATE guild_configs SET openai_key = ? WHERE guild_id = ?", (key, guild_id))
        await self.bot.db.commit()

    async def set_guild_persona(self, guild_id: int, persona: Optional[str] = None):
        async with self.bot.db.cursor() as cursor:
            await cursor.execute("INSERT OR IGNORE INTO guild_configs (guild_id) VALUES (?)", (guild_id,))
            await cursor.execute("UPDATE guild_configs SET persona = ? WHERE guild_id = ?", (persona, guild_id))
        await self.bot.db.commit()

    async def get_policy(self, guild_id: Optional[int]) -> Dict[str, Any]:
        default_policy = {
            "enabled": True,
            "cooldown_seconds": 8,
            "daily_usage_limit": None,
            "allowed_channel_ids": [],
            "blocked_channel_ids": [],
            "allowed_role_ids": [],
        }
        if not guild_id:
            return default_policy

        async with self.bot.db.cursor() as cursor:
            await cursor.execute(
                """
                SELECT enabled, cooldown_seconds, daily_usage_limit, allowed_channel_ids,
                       blocked_channel_ids, allowed_role_ids
                FROM chat_policies
                WHERE guild_id = ?
                """,
                (guild_id,),
            )
            row = await cursor.fetchone()

        if row is None:
            return default_policy

        enabled, cooldown_seconds, daily_usage_limit, allowed_channel_ids, blocked_channel_ids, allowed_role_ids = row
        return {
            "enabled": bool(enabled),
            "cooldown_seconds": (
                default_policy["cooldown_seconds"] if cooldown_seconds is None else int(cooldown_seconds)
            ),
            "daily_usage_limit": daily_usage_limit,
            "allowed_channel_ids": self._deserialize_ids(allowed_channel_ids),
            "blocked_channel_ids": self._deserialize_ids(blocked_channel_ids),
            "allowed_role_ids": self._deserialize_ids(allowed_role_ids),
        }

    async def update_policy(self, guild_id: int, **fields):
        async with self.bot.db.cursor() as cursor:
            await cursor.execute("INSERT OR IGNORE INTO chat_policies (guild_id) VALUES (?)", (guild_id,))
            for field, value in fields.items():
                await cursor.execute(f"UPDATE chat_policies SET {field} = ? WHERE guild_id = ?", (value, guild_id))
        await self.bot.db.commit()

    async def mutate_id_list(self, guild_id: int, field: str, value: int, add: bool):
        policy = await self.get_policy(guild_id)
        current = set(policy[field])
        if add:
            current.add(value)
        else:
            current.discard(value)
        await self.update_policy(guild_id, **{field: self._serialize_ids(list(current))})
        return sorted(current)

    async def get_usage_count(self, guild_id: int) -> int:
        usage_date = discord.utils.utcnow().date().isoformat()
        async with self.bot.db.cursor() as cursor:
            await cursor.execute(
                "SELECT usage_count FROM chat_usage WHERE guild_id = ? AND usage_date = ?",
                (guild_id, usage_date),
            )
            row = await cursor.fetchone()
        return row[0] if row else 0

    async def increment_usage(self, guild_id: int):
        usage_date = discord.utils.utcnow().date().isoformat()
        async with self.bot.db.cursor() as cursor:
            await cursor.execute(
                """
                INSERT INTO chat_usage (guild_id, usage_date, usage_count)
                VALUES (?, ?, 1)
                ON CONFLICT(guild_id, usage_date)
                DO UPDATE SET usage_count = usage_count + 1
                """,
                (guild_id, usage_date),
            )
        await self.bot.db.commit()

    async def validate_api_key(self, api_key: str) -> Tuple[bool, str]:
        headers = {"Authorization": f"Bearer {api_key}"}
        try:
            async with self.session.get(f"{self.api_base}/models", headers=headers) as response:
                if response.status == 200:
                    return True, "API key is valid."

                try:
                    error_data = await response.json()
                    error_message = error_data.get("error", {}).get("message", "Unknown API error.")
                except Exception:
                    error_message = "Unknown API error."
                return False, f"{response.status}: {error_message}"
        except Exception as error:
            return False, str(error)

    async def enforce_policy(self, interaction: discord.Interaction, policy: Dict[str, Any]) -> Optional[str]:
        if not interaction.guild_id:
            return None
        if not policy["enabled"]:
            return "AI chat is disabled in this server."
        if interaction.channel_id in policy["blocked_channel_ids"]:
            return "AI chat is disabled in this channel."
        if policy["allowed_channel_ids"] and interaction.channel_id not in policy["allowed_channel_ids"]:
            return "AI chat is only allowed in specific channels configured by the server admins."
        if policy["allowed_role_ids"]:
            member = interaction.user if isinstance(interaction.user, discord.Member) else None
            if member is None:
                return "AI chat is restricted to specific roles in this server."
            member_role_ids = {role.id for role in member.roles}
            if not member_role_ids.intersection(policy["allowed_role_ids"]):
                return "You do not have one of the roles required to use AI chat here."

        cooldown_seconds = max(int(policy["cooldown_seconds"]), 0)
        if cooldown_seconds > 0:
            cooldown_key = (interaction.guild_id, interaction.user.id)
            now = discord.utils.utcnow()
            last_used = self.chat_cooldowns.get(cooldown_key)
            if last_used and (now - last_used).total_seconds() < cooldown_seconds:
                remaining = cooldown_seconds - int((now - last_used).total_seconds())
                return f"You're on cooldown for this server. Try again in {remaining}s."

        usage_limit = policy["daily_usage_limit"]
        if usage_limit:
            usage_count = await self.get_usage_count(interaction.guild_id)
            if usage_count >= usage_limit:
                return "This server has reached its daily AI chat usage cap."

        return None

    def define_tools(self):
        return [
            {
                "type": "function",
                "function": {
                    "name": "google_search",
                    "description": "Get real-time information from the web for recent events or specific data.",
                    "parameters": {
                        "type": "object",
                        "properties": {"query": {"type": "string", "description": "The search query."}},
                        "required": ["query"],
                    },
                },
            }
        ]

    async def execute_google_search(self, query: str):
        url = "https://www.googleapis.com/customsearch/v1"
        params = {"key": self.google_api_key, "cx": self.google_cse_id, "q": query, "num": 5}
        try:
            async with self.session.get(url, params=params) as response:
                if response.status == 200:
                    data = await response.json()
                    items = data.get("items", [])
                    snippets = [item.get("snippet", "") for item in items]
                    return json.dumps({"results": snippets}) if snippets else json.dumps({"error": "No results found."})

                error_data = await response.json()
                error_message = error_data.get("error", {}).get("message", "Unknown error.")
                return json.dumps({"error": f"Failed to fetch search results. Status: {response.status} - {error_message}"})
        except Exception as error:
            return json.dumps({"error": f"An error occurred during search: {error}"})

    async def model_autocomplete(self, interaction: discord.Interaction, current: str):
        current_lower = current.lower()
        return [
            app_commands.Choice(name=model, value=model)
            for model in self.allowed_models
            if current_lower in model.lower()
        ]

    def _channel_labels(self, guild: discord.Guild, ids: List[int]) -> str:
        if not ids:
            return "Not set"
        labels = []
        for channel_id in ids:
            channel = guild.get_channel(channel_id)
            labels.append(channel.mention if channel else f"`{channel_id}`")
        return ", ".join(labels)

    def _role_labels(self, guild: discord.Guild, ids: List[int]) -> str:
        if not ids:
            return "Not set"
        labels = []
        for role_id in ids:
            role = guild.get_role(role_id)
            labels.append(role.mention if role else f"`{role_id}`")
        return ", ".join(labels)

    def _truncate_field_value(self, value: str, limit: int = MAX_EMBED_FIELD_LENGTH) -> str:
        if len(value) <= limit:
            return value
        return f"{value[: limit - 1].rstrip()}…"

    def _trim_history(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
        if len(messages) <= MAX_CONVERSATION_HISTORY:
            return messages
        return messages[0:1] + messages[-(MAX_CONVERSATION_HISTORY - 1) :]

    def _prune_runtime_state(self) -> None:
        now = discord.utils.utcnow()
        expired_conversations = [
            key for key, last_used in self.conversation_last_used.items() if now - last_used > CONVERSATION_TTL
        ]
        for key in expired_conversations:
            self.conversation_last_used.pop(key, None)
            self.conversations.pop(key, None)

        expired_cooldowns = [
            key for key, last_used in self.chat_cooldowns.items() if now - last_used > COOLDOWN_RETENTION
        ]
        for key in expired_cooldowns:
            self.chat_cooldowns.pop(key, None)

    def _set_conversation_state(self, context_key: Tuple[Any, ...], messages: List[Dict[str, Any]]) -> None:
        if messages:
            self.conversations[context_key] = messages
            self.conversation_last_used[context_key] = discord.utils.utcnow()
            return
        self.conversations.pop(context_key, None)
        self.conversation_last_used.pop(context_key, None)

    chat_config = app_commands.Group(
        name="chat-config",
        description="Configure the AI chat settings for a server.",
        guild_only=True,
    )

    @chat_config.command(name="set-key", description="Set a custom OpenAI API key for this server.")
    @app_commands.describe(key="Your OpenAI API key. Use 'reset' to remove.")
    @app_commands.checks.has_permissions(manage_guild=True)
    async def set_key(self, interaction: discord.Interaction, key: str):
        if not self.allow_user_keys:
            await interaction.response.send_message("The bot owner has disabled custom API keys.", ephemeral=True)
            return
        if key.lower() == "reset":
            await self.set_guild_key(interaction.guild.id)
            await interaction.response.send_message("Server API key removed.", ephemeral=True)
            return
        await interaction.response.defer(ephemeral=True)
        valid, detail = await self.validate_api_key(key)
        if not valid:
            await interaction.followup.send(f"API key validation failed: {detail}", ephemeral=True)
            return
        await self.set_guild_key(interaction.guild.id, key)
        await interaction.followup.send("Server API key validated and saved.", ephemeral=True)

    @chat_config.command(name="set-persona", description="Set a custom personality for the AI.")
    @app_commands.describe(persona="A description of the AI's personality. Use 'reset' to remove.")
    @app_commands.checks.has_permissions(manage_guild=True)
    async def set_persona(self, interaction: discord.Interaction, persona: str):
        if len(persona) > MAX_PERSONA_LENGTH:
            await interaction.response.send_message("Persona is too long (max 500 chars).", ephemeral=True)
            return
        if persona.lower() == "reset":
            await self.set_guild_persona(interaction.guild.id)
            await interaction.response.send_message("AI persona reset to default.", ephemeral=True)
            return
        await self.set_guild_persona(interaction.guild.id, persona)
        await interaction.response.send_message("AI persona updated.", ephemeral=True)

    @chat_config.command(name="set-enabled", description="Enable or disable AI chat in this server.")
    @app_commands.checks.has_permissions(manage_guild=True)
    async def set_enabled(self, interaction: discord.Interaction, enabled: bool):
        await self.update_policy(interaction.guild.id, enabled=int(enabled))
        state = "enabled" if enabled else "disabled"
        await interaction.response.send_message(f"AI chat is now {state} for this server.", ephemeral=True)

    @chat_config.command(name="set-cooldown", description="Set the per-user chat cooldown for this server.")
    @app_commands.checks.has_permissions(manage_guild=True)
    @app_commands.describe(seconds="Cooldown in seconds. Use 0 to disable.")
    async def set_cooldown(self, interaction: discord.Interaction, seconds: app_commands.Range[int, 0, 600]):
        await self.update_policy(interaction.guild.id, cooldown_seconds=int(seconds))
        await interaction.response.send_message(f"Chat cooldown set to {seconds}s.", ephemeral=True)

    @chat_config.command(name="set-usage-cap", description="Set the per-day chat usage cap for this server.")
    @app_commands.checks.has_permissions(manage_guild=True)
    @app_commands.describe(limit="Maximum successful chat requests per day. Use 0 to remove the cap.")
    async def set_usage_cap(self, interaction: discord.Interaction, limit: app_commands.Range[int, 0, 5000]):
        value = None if limit == 0 else int(limit)
        await self.update_policy(interaction.guild.id, daily_usage_limit=value)
        label = "removed" if value is None else str(value)
        await interaction.response.send_message(f"Daily usage cap set to {label}.", ephemeral=True)

    @chat_config.command(name="allow-channel", description="Allow AI chat only in a specific channel.")
    @app_commands.checks.has_permissions(manage_guild=True)
    async def allow_channel(self, interaction: discord.Interaction, channel: discord.TextChannel):
        channels = await self.mutate_id_list(interaction.guild.id, "allowed_channel_ids", channel.id, add=True)
        await interaction.response.send_message(
            f"Allowed channels updated. {len(channels)} channel(s) are now allowlisted.",
            ephemeral=True,
        )

    @chat_config.command(name="block-channel", description="Block AI chat in a specific channel.")
    @app_commands.checks.has_permissions(manage_guild=True)
    async def block_channel(self, interaction: discord.Interaction, channel: discord.TextChannel):
        channels = await self.mutate_id_list(interaction.guild.id, "blocked_channel_ids", channel.id, add=True)
        await interaction.response.send_message(
            f"Blocked channels updated. {len(channels)} channel(s) are now blocked.",
            ephemeral=True,
        )

    @chat_config.command(name="clear-channel-rules", description="Clear channel allow/block rules for AI chat.")
    @app_commands.checks.has_permissions(manage_guild=True)
    async def clear_channel_rules(self, interaction: discord.Interaction):
        await self.update_policy(interaction.guild.id, allowed_channel_ids=None, blocked_channel_ids=None)
        await interaction.response.send_message("Channel rules cleared.", ephemeral=True)

    @chat_config.command(name="allow-role", description="Restrict AI chat to members with a specific role.")
    @app_commands.checks.has_permissions(manage_guild=True)
    async def allow_role(self, interaction: discord.Interaction, role: discord.Role):
        roles = await self.mutate_id_list(interaction.guild.id, "allowed_role_ids", role.id, add=True)
        await interaction.response.send_message(
            f"Allowed roles updated. {len(roles)} role(s) are now allowlisted.",
            ephemeral=True,
        )

    @chat_config.command(name="remove-role", description="Remove a role from the AI chat allowlist.")
    @app_commands.checks.has_permissions(manage_guild=True)
    async def remove_role(self, interaction: discord.Interaction, role: discord.Role):
        roles = await self.mutate_id_list(interaction.guild.id, "allowed_role_ids", role.id, add=False)
        await interaction.response.send_message(
            f"Allowed roles updated. {len(roles)} role(s) remain allowlisted.",
            ephemeral=True,
        )

    @chat_config.command(name="clear-role-rules", description="Remove all role-based AI chat restrictions.")
    @app_commands.checks.has_permissions(manage_guild=True)
    async def clear_role_rules(self, interaction: discord.Interaction):
        await self.update_policy(interaction.guild.id, allowed_role_ids=None)
        await interaction.response.send_message("Role restrictions cleared.", ephemeral=True)

    @chat_config.command(name="view", description="View the current chat configuration.")
    @app_commands.checks.has_permissions(manage_guild=True)
    async def view_config(self, interaction: discord.Interaction):
        self._prune_runtime_state()
        guild_config = await self.get_guild_config(interaction.guild.id) or (None, None)
        guild_key, guild_persona = guild_config
        policy = await self.get_policy(interaction.guild.id)
        usage_count = await self.get_usage_count(interaction.guild.id)

        embed = discord.Embed(title=f"Chat Configuration for {interaction.guild.name}", color=discord.Color.blue())
        key_status = "Not configured"
        if guild_key:
            key_status = f"`{guild_key[:5]}...{guild_key[-4:]}` (custom)"
        elif self.default_api_key:
            key_status = "Using bot default key"

        embed.add_field(name="Server API Key", value=key_status, inline=False)
        embed.add_field(
            name="AI Persona",
            value=self._truncate_field_value(guild_persona or DEFAULT_PERSONA),
            inline=False,
        )
        embed.add_field(name="Web Search", value="Enabled" if self.enable_web_search else "Disabled", inline=False)
        embed.add_field(name="API Base URL", value=self._truncate_field_value(f"`{self.api_base}`"), inline=False)
        embed.add_field(
            name="Allowed Models",
            value=self._truncate_field_value(", ".join(f"`{model}`" for model in self.allowed_models)),
            inline=False,
        )
        embed.add_field(name="Chat Enabled", value="Yes" if policy["enabled"] else "No", inline=True)
        embed.add_field(name="Cooldown", value=f"{policy['cooldown_seconds']}s", inline=True)
        embed.add_field(
            name="Daily Usage Cap",
            value="Not set" if policy["daily_usage_limit"] is None else f"{usage_count}/{policy['daily_usage_limit']}",
            inline=True,
        )
        embed.add_field(
            name="Allowed Channels",
            value=self._truncate_field_value(self._channel_labels(interaction.guild, policy["allowed_channel_ids"])),
            inline=False,
        )
        embed.add_field(
            name="Blocked Channels",
            value=self._truncate_field_value(self._channel_labels(interaction.guild, policy["blocked_channel_ids"])),
            inline=False,
        )
        embed.add_field(
            name="Allowed Roles",
            value=self._truncate_field_value(self._role_labels(interaction.guild, policy["allowed_role_ids"])),
            inline=False,
        )
        await interaction.response.send_message(embed=embed, ephemeral=True)

    @chat_config.command(name="test", description="Validate the effective API key for this server.")
    @app_commands.checks.has_permissions(manage_guild=True)
    async def test_config(self, interaction: discord.Interaction):
        guild_config = await self.get_guild_config(interaction.guild.id) or (None, None)
        guild_key, _ = guild_config
        api_key = guild_key or self.default_api_key
        if not api_key:
            await interaction.response.send_message("No API key is configured to test.", ephemeral=True)
            return

        await interaction.response.defer(ephemeral=True)
        valid, detail = await self.validate_api_key(api_key)
        if valid:
            await interaction.followup.send("API key validation succeeded.", ephemeral=True)
        else:
            await interaction.followup.send(f"API key validation failed: {detail}", ephemeral=True)

    @chat_config.command(name="models", description="Show the configured default and allowed chat models.")
    @app_commands.checks.has_permissions(manage_guild=True)
    async def list_models(self, interaction: discord.Interaction):
        embed = discord.Embed(title="Chat Models", color=discord.Color.blurple())
        embed.add_field(name="Default Model", value=f"`{self.default_model}`", inline=False)
        embed.add_field(
            name="Allowed Models",
            value=self._truncate_field_value("\n".join(f"`{model}`" for model in self.allowed_models)),
            inline=False,
        )
        await interaction.response.send_message(embed=embed, ephemeral=True)

    @app_commands.command(name="chat", description="Chat with the AI, with optional live web search.")
    @app_commands.describe(
        prompt="What to talk about?",
        model="Choose a specific AI model.",
        search_web="Set to true to allow the AI to search the web for current info.",
    )
    @app_commands.autocomplete(model=model_autocomplete)
    async def chat(self, interaction: discord.Interaction, prompt: str, model: Optional[str] = None, search_web: bool = False):
        self._prune_runtime_state()
        guild_id = interaction.guild.id if interaction.guild else None
        chosen_model = model or self.default_model
        policy = await self.get_policy(guild_id)

        if chosen_model not in self.allowed_models:
            await interaction.response.send_message("That model is not allowed for this bot.", ephemeral=True)
            return
        if search_web and not self.enable_web_search:
            await interaction.response.send_message(
                "Web search is not configured by the bot owner.",
                ephemeral=True,
            )
            return

        if interaction.guild_id:
            policy_error = await self.enforce_policy(interaction, policy)
            if policy_error:
                await interaction.response.send_message(policy_error, ephemeral=True)
                return

        guild_config = await self.get_guild_config(guild_id) or (None, None)
        api_key, persona = guild_config
        api_key = api_key or self.default_api_key
        if not api_key:
            await interaction.response.send_message(
                "AI chat is not configured. An admin must set an API key.",
                ephemeral=True,
            )
            return

        await interaction.response.defer()
        context_key = self._context_key(interaction)
        existing_messages = list(self.conversations.get(context_key, []))
        if not existing_messages:
            existing_messages = [{"role": "system", "content": persona or DEFAULT_PERSONA}]

        original_messages = list(existing_messages)
        working_messages = list(existing_messages)
        working_messages.append({"role": "user", "content": prompt})
        self._set_conversation_state(context_key, working_messages)

        headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
        payload = {"model": chosen_model, "messages": working_messages}
        if self.enable_web_search and search_web:
            payload["tools"] = self.define_tools()
            payload["tool_choice"] = "auto"

        final_answer = None
        try:
            async with self.session.post(f"{self.api_base}/chat/completions", headers=headers, json=payload) as response:
                if response.status != 200:
                    error_data = await response.json()
                    error_message = error_data.get("error", {}).get("message", "An unknown API error occurred.")
                    self._set_conversation_state(context_key, original_messages)
                    await interaction.followup.send(f"API Error: {response.status} - {error_message}", ephemeral=True)
                    return

                data = await response.json()
                ai_message = data["choices"][0]["message"]

                if ai_message.get("tool_calls") and self.enable_web_search and search_web:
                    thinking_message = await interaction.followup.send("Searching the web...", wait=True)
                    working_messages.append(ai_message)
                    self._set_conversation_state(context_key, working_messages)
                    tool_call = ai_message["tool_calls"][0]
                    function_args = json.loads(tool_call["function"]["arguments"])
                    query = function_args.get("query", "")
                    tool_response = await self.execute_google_search(query)

                    working_messages.append(
                        {
                            "tool_call_id": tool_call["id"],
                            "role": "tool",
                            "name": tool_call["function"]["name"],
                            "content": tool_response,
                        }
                    )
                    self._set_conversation_state(context_key, working_messages)

                    final_payload = {"model": chosen_model, "messages": working_messages}
                    async with self.session.post(
                        f"{self.api_base}/chat/completions",
                        headers=headers,
                        json=final_payload,
                    ) as final_response:
                        if final_response.status != 200:
                            self._set_conversation_state(context_key, original_messages)
                            await thinking_message.edit(content="The model failed after web search. Please try again.")
                            return
                        final_data = await final_response.json()
                        final_answer = final_data["choices"][0]["message"]["content"]
                        await thinking_message.edit(
                            content=self._truncate_field_value(final_answer, MAX_EMBED_DESCRIPTION_LENGTH)
                        )
                else:
                    final_answer = ai_message.get("content") or "The model returned an empty response."
                    await interaction.edit_original_response(content=final_answer)

                working_messages.append({"role": "assistant", "content": final_answer})
                self._set_conversation_state(context_key, self._trim_history(working_messages))

                if interaction.guild_id:
                    cooldown_key = (interaction.guild_id, interaction.user.id)
                    self.chat_cooldowns[cooldown_key] = discord.utils.utcnow()
                    await self.increment_usage(interaction.guild_id)

        except Exception as error:
            logger.exception("Chat processing error: %s", error)
            self._set_conversation_state(context_key, original_messages)
            await interaction.followup.send("An unexpected error occurred. Please try again later.", ephemeral=True)

    @app_commands.command(name="chat-reset", description="Reset your conversation history with the AI.")
    async def chat_reset(self, interaction: discord.Interaction):
        self._prune_runtime_state()
        context_key = self._context_key(interaction)
        self._set_conversation_state(context_key, [])
        await interaction.response.send_message("Your conversation history has been reset.", ephemeral=True)


async def setup(bot: commands.Bot):
    await bot.add_cog(Chat(bot))


================================================
FILE: cogs/community.py
================================================
import string
from datetime import timedelta
from typing import Dict, Optional

import discord
from discord import app_commands
from discord.ext import commands, tasks


DEFAULT_WELCOME_MESSAGE = "Welcome to {guild}, {member.mention}!"
DEFAULT_GOODBYE_MESSAGE = "{member} left {guild}."
SCHEDULE_POLL_SECONDS = 30
SCHEDULE_BATCH_SIZE = 20
TEMPLATE_FIELDS = {"member", "member.mention", "guild"}
MAX_SCHEDULE_DELIVERY_FAILURES = 5
MAX_ANNOUNCEMENT_LENGTH = 4000
SCHEDULE_RETRY_BASE_SECONDS = 300
SCHEDULE_RETRY_MAX_SECONDS = 21600


def parse_duration(value: str) -> Optional[int]:
    if not value:
        return None
    amount = value[:-1]
    unit = value[-1].lower()
    if not amount.isdigit() or unit not in {"m", "h", "d"}:
        return None
    return int(amount) * {"m": 60, "h": 3600, "d": 86400}[unit]


class Community(commands.Cog):
    def __init__(self, bot: commands.Bot):
        self.bot = bot
        self.schedule_loop.start()

    def cog_unload(self):
        self.schedule_loop.cancel()

    async def setup_database(self):
        async with self.bot.db.cursor() as cursor:
            await cursor.execute(
                """
                CREATE TABLE IF NOT EXISTS guild_settings (
                    guild_id INTEGER PRIMARY KEY,
                    welcome_channel_id INTEGER,
                    goodbye_channel_id INTEGER,
                    announcement_channel_id INTEGER,
                    modlog_channel_id INTEGER,
                    welcome_message TEXT,
                    goodbye_message TEXT
                )
                """
            )
            await cursor.execute(
                """
                CREATE TABLE IF NOT EXISTS scheduled_announcements (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    guild_id INTEGER NOT NULL,
                    channel_id INTEGER NOT NULL,
                    author_id INTEGER NOT NULL,
                    message TEXT NOT NULL,
                    send_at TEXT NOT NULL,
                    interval_seconds INTEGER
                )
                """
            )
            await cursor.execute("PRAGMA table_info(scheduled_announcements)")
            announcement_columns = [row[1] for row in await cursor.fetchall()]
            if "delivery_failures" not in announcement_columns:
                await cursor.execute(
                    "ALTER TABLE scheduled_announcements ADD COLUMN delivery_failures INTEGER NOT NULL DEFAULT 0"
                )
            if "disabled" not in announcement_columns:
                await cursor.execute(
                    "ALTER TABLE scheduled_announcements ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0"
                )
            if "last_error" not in announcement_columns:
                await cursor.execute("ALTER TABLE scheduled_announcements ADD COLUMN last_error TEXT")
            await cursor.execute(
                "CREATE INDEX IF NOT EXISTS idx_scheduled_announcements_due ON scheduled_announcements(disabled, send_at)"
            )
            await cursor.execute(
                "CREATE INDEX IF NOT EXISTS idx_scheduled_announcements_guild_send_at ON scheduled_announcements(guild_id, send_at)"
            )
        await self.bot.db.commit()

    async def get_settings(self, guild_id: int) -> Dict[str, Optional[int]]:
        async with self.bot.db.cursor() as cursor:
            await cursor.execute(
                """
                SELECT guild_id, welcome_channel_id, goodbye_channel_id, announcement_channel_id,
                       modlog_channel_id, welcome_message, goodbye_message
                FROM guild_settings
                WHERE guild_id = ?
                """,
                (guild_id,),
            )
            row = await cursor.fetchone()

        if row is None:
            return {
                "guild_id": guild_id,
                "welcome_channel_id": None,
                "goodbye_channel_id": None,
                "announcement_channel_id": None,
                "modlog_channel_id": None,
                "welcome_message": None,
                "goodbye_message": None,
            }

        keys = [
            "guild_id",
            "welcome_channel_id",
            "goodbye_channel_id",
            "announcement_channel_id",
            "modlog_channel_id",
            "welcome_message",
            "goodbye_message",
        ]
        return dict(zip(keys, row))

    async def update_setting(self, guild_id: int, field: str, value):
        async with self.bot.db.cursor() as cursor:
            await cursor.execute("INSERT OR IGNORE INTO guild_settings (guild_id) VALUES (?)", (guild_id,))
            await cursor.execute(f"UPDATE guild_settings SET {field} = ? WHERE guild_id = ?", (value, guild_id))
        await self.bot.db.commit()

    def validate_template(self, template: str) -> Optional[str]:
        formatter = string.Formatter()
        try:
            for _, field_name, _, _ in formatter.parse(template):
                if field_name and field_name not in TEMPLATE_FIELDS:
                    return (
                        f"Unsupported placeholder `{field_name}`. "
                        "Use only `{member}`, `{member.mention}`, and `{guild}`."
                    )
        except ValueError as error:
            return f"Invalid template syntax: {error}"
        return None

    def render_template(self, template: Optional[str], member: discord.abc.User, guild: discord.Guild, default: str) -> str:
        text = template or default
        return text.format(member=member, guild=guild.name)

    def try_render_template(
        self, template: Optional[str], member: discord.abc.User, guild: discord.Guild, default: str
    ) -> tuple[Optional[str], Optional[str]]:
        try:
            return self.render_template(template, member, guild, default), None
        except Exception as error:
            return None, str(error)

    def schedule_retry_delay(self, failures: int) -> timedelta:
        seconds = min(SCHEDULE_RETRY_BASE_SECONDS * (2 ** max(failures - 1, 0)), SCHEDULE_RETRY_MAX_SECONDS)
        return timedelta(seconds=seconds)

    async def _resolve_channel(self, channel_id: Optional[int]):
        if channel_id is None:
            return None
        channel = self.bot.get_channel(channel_id)
        if channel is None:
            try:
                channel = await self.bot.fetch_channel(channel_id)
            except discord.HTTPException:
                return None
        return channel

    async def _send_to_channel(
        self,
        channel_id: Optional[int],
        embed: Optional[discord.Embed] = None,
        content: Optional[str] = None,
    ) -> bool:
        channel = await self._resolve_channel(channel_id)
        if channel is None:
            return False

        try:
            if content is not None:
                await channel.send(content)
            elif embed is not None:
                await channel.send(embed=embed)
            return True
        except (discord.Forbidden, discord.HTTPException):
            return False

    async def _log_to_modlog(self, guild: discord.Guild, title: str, description: str, color: discord.Color):
        settings = await self.get_settings(guild.id)
        channel_id = settings["modlog_channel_id"]
        if channel_id is None:
            return

        embed = discord.Embed(title=title, description=description, color=color, timestamp=discord.utils.utcnow())
        await self._send_to_channel(channel_id, embed=embed)

    def _channel_value(self, channel_id: Optional[int]) -> str:
        return f"<#{channel_id}>" if channel_id else "Not set"

    async def _schedule_announcement(
        self,
        guild_id: int,
        channel_id: int,
        author_id: int,
        message: str,
        send_at,
        interval_seconds: Optional[int] = None,
    ) -> int:
        async with self.bot.db.cursor() as cursor:
            await cursor.execute(
                """
                INSERT INTO scheduled_announcements (guild_id, channel_id, author_id, message, send_at, interval_seconds)
                VALUES (?, ?, ?, ?, ?, ?)
                """,
                (guild_id, channel_id, author_id, message, send_at.isoformat(), interval_seconds),
            )
            announcement_id = cursor.lastrowid
        await self.bot.db.commit()
        return announcement_id

    server_config = app_commands.Group(
        name="server-config",
        description="Configure community features for this server.",
        guild_only=True,
    )
    announcements = app_commands.Group(
        name="announcements",
        description="Manage scheduled announcements for this server.",
        guild_only=True,
    )

    @server_config.command(name="view", description="View the current server community settings.")
    @app_commands.checks.has_permissions(manage_guild=True)
    async def view(self, interaction: discord.Interaction):
        settings = await self.get_settings(interaction.guild_id)
        embed = discord.Embed(title=f"Server Config: {interaction.guild.name}", color=discord.Color.green())
        embed.add_field(name="Welcome Channel", value=self._channel_value(settings["welcome_channel_id"]), inline=False)
        embed.add_field(name="Goodbye Channel", value=self._channel_value(settings["goodbye_channel_id"]), inline=False)
        embed.add_field(
            name="Announcement Channel",
            value=self._channel_value(settings["announcement_channel_id"]),
            inline=False,
        )
        embed.add_field(name="Mod Log Channel", value=self._channel_value(settings["modlog_channel_id"]), inline=False)
        embed.add_field(
            name="Welcome Message",
            value=settings["welcome_message"] or DEFAULT_WELCOME_MESSAGE,
            inline=False,
        )
        embed.add_field(
            name="Goodbye Message",
            value=settings["goodbye_message"] or DEFAULT_GOODBYE_MESSAGE,
            inline=False,
        )
        await interaction.response.send_message(embed=embed, ephemeral=True)

    @server_config.command(name="set-welcome-channel", description="Set the welcome channel.")
    @app_commands.checks.has_permissions(manage_guild=True)
    async def set_welcome_channel(self, interaction: discord.Interaction, channel: discord.TextChannel):
        await self.update_setting(interaction.guild_id, "welcome_channel_id", channel.id)
        await interaction.response.send_message(f"Welcome channel set to {channel.mention}.", ephemeral=True)

    @server_config.command(name="set-goodbye-channel", description="Set the goodbye channel.")
    @app_commands.checks.has_permissions(manage_guild=True)
    async def set_goodbye_channel(self, interaction: discord.Interaction, channel: discord.TextChannel):
        await self.update_setting(interaction.guild_id, "goodbye_channel_id", channel.id)
        await interaction.response.send_message(f"Goodbye channel set to {channel.mention}.", ephemeral=True)

    @server_config.command(name="set-announcement-channel", description="Set the announcement channel.")
    @app_commands.checks.has_permissions(manage_guild=True)
    async def set_announcement_channel(self, interaction: discord.Interaction, channel: discord.TextChannel):
        await self.update_setting(interaction.guild_id, "announcement_channel_id", channel.id)
        await interaction.response.send_message(f"Announcement channel set to {channel.mention}.", ephemeral=True)

    @server_config.command(name="set-modlog-channel", description="Set the moderation log channel.")
    @app_commands.checks.has_permissions(manage_guild=True)
    async def set_modlog_channel(self, interaction: discord.Interaction, channel: discord.TextChannel):
        await self.update_setting(interaction.guild_id, "modlog_channel_id", channel.id)
        await interaction.response.send_message(f"Mod log channel set to {channel.mention}.", ephemeral=True)

    @server_config.command(name="set-welcome-message", description="Set the welcome message template.")
    @app_commands.checks.has_permissions(manage_guild=True)
    @app_commands.describe(message="Use {member}, {member.mention}, and {guild}. Type reset to clear.")
    async def set_welcome_message(self, interaction: discord.Interaction, message: str):
        if message.lower() != "reset":
            error = self.validate_template(message)
            if error:
                await interaction.response.send_message(error, ephemeral=True)
                return
        value = None if message.lower() == "reset" else message
        await self.update_setting(interaction.guild_id, "welcome_message", value)
        await interaction.response.send_message("Welcome message updated.", ephemeral=True)

    @server_config.command(name="set-goodbye-message", description="Set the goodbye message template.")
    @app_commands.checks.has_permissions(manage_guild=True)
    @app_commands.describe(message="Use {member}, {member.mention}, and {guild}. Type reset to clear.")
    async def set_goodbye_message(self, interaction: discord.Interaction, message: str):
        if message.lower() != "reset":
            error = self.validate_template(message)
            if error:
                await interaction.response.send_message(error, ephemeral=True)
                return
        value = None if message.lower() == "reset" else message
        await self.update_setting(interaction.guild_id, "goodbye_message", value)
        await interaction.response.send_message("Goodbye message updated.", ephemeral=True)

    @server_config.command(name="preview-welcome", description="Preview the welcome message template.")
    @app_commands.checks.has_permissions(manage_guild=True)
    async def preview_welcome(self, interaction: discord.Interaction):
        settings = await self.get_settings(interaction.guild_id)
        content, error = self.try_render_template(
            settings["welcome_message"], interaction.user, interaction.guild, DEFAULT_WELCOME_MESSAGE
        )
        if error:
            await interaction.response.send_message(
                "The saved welcome template is invalid. Reset or update it before using previews.",
                ephemeral=True,
            )
            return
        await interaction.response.send_message(content, ephemeral=True)

    @server_config.command(name="preview-goodbye", description="Preview the goodbye message template.")
    @app_commands.checks.has_permissions(manage_guild=True)
    async def preview_goodbye(self, interaction: discord.Interaction):
        settings = await self.get_settings(interaction.guild_id)
        content, error = self.try_render_template(
            settings["goodbye_message"], interaction.user, interaction.guild, DEFAULT_GOODBYE_MESSAGE
        )
        if error:
            await interaction.response.send_message(
                "The saved goodbye template is invalid. Reset or update it before using previews.",
                ephemeral=True,
            )
            return
        await interaction.response.send_message(content, ephemeral=True)

    @server_config.command(name="reset-message", description="Reset one message template to its default.")
    @app_commands.checks.has_permissions(manage_guild=True)
    @app_commands.choices(
        target=[
            app_commands.Choice(name="Welcome", value="welcome_message"),
            app_commands.Choice(name="Goodbye", value="goodbye_message"),
        ]
    )
    async def reset_message(self, interaction: discord.Interaction, target: app_commands.Choice[str]):
        await self.update_setting(interaction.guild_id, target.value, None)
        await interaction.response.send_message(f"{target.name} message reset.", ephemeral=True)

    @server_config.command(name="reset-channel", description="Reset one configured channel slot.")
    @app_commands.checks.has_permissions(manage_guild=True)
    @app_commands.choices(
        target=[
            app_commands.Choice(name="Welcome", value="welcome_channel_id"),
            app_commands.Choice(name="Goodbye", value="goodbye_channel_id"),
            app_commands.Choice(name="Announcement", value="announcement_channel_id"),
            app_commands.Choice(name="Mod Log", value="modlog_channel_id"),
        ]
    )
    async def reset_channel(self, interaction: discord.Interaction, target: app_commands.Choice[str]):
        await self.update_setting(interaction.guild_id, target.value, None)
        await interaction.response.send_message(f"{target.name} channel reset.", ephemeral=True)

    @app_commands.command(name="announce", description="Send an announcement to the configured announcement channel.")
    @app_commands.guild_only()
    @app_commands.checks.has_permissions(manage_guild=True)
    @app_commands.describe(message="Announcement content.")
    async def announce(self, interaction: discord.Interaction, message: str):
        if len(message) > MAX_ANNOUNCEMENT_LENGTH:
            await interaction.response.send_message(
                f"Announcement content is too long ({len(message)} chars). Maximum is {MAX_ANNOUNCEMENT_LENGTH}.",
                ephemeral=True,
            )
            return

        settings = await self.get_settings(interaction.guild_id)
        target_channel_id = settings["announcement_channel_id"] or interaction.channel_id
        channel = await self._resolve_channel(target_channel_id)
        if channel is None:
            await interaction.response.send_message(
                "The configured announcement channel no longer exists. Set a new one or use this command in another channel.",
                ephemeral=True,
            )
            return

        embed = discord.Embed(description=message, color=discord.Color.blurple())
        embed.set_author(name=f"Announcement from {interaction.guild.name}")
        embed.set_footer(text=f"Sent by {interaction.user.display_name}")
        try:
            await channel.send(embed=embed)
        except discord.Forbidden:
            await interaction.response.send_message(
                "I do not have permission to send messages in the configured announcement channel.",
                ephemeral=True,
            )
            return
        except discord.HTTPException:
            await interaction.response.send_message(
                "I could not send the announcement right now. Please try again later.",
                ephemeral=True,
            )
            return

        if target_channel_id == interaction.channel_id:
            await interaction.response.send_message("Announcement sent in this channel.", ephemeral=True)
        else:
            await interaction.response.send_message(f"Announcement sent to {channel.mention}.", ephemeral=True)

    @announcements.command(name="schedule", description="Schedule a future announcement.")
    @app_commands.checks.has_permissions(manage_guild=True)
    @app_commands.describe(
        when="When to send it, like 10m, 2h, or 1d.",
        message="Announcement content.",
        repeat="Optional repeat interval like 1d. Leave empty for one-time.",
        channel="Optional target channel. Defaults to the configured announcement channel or current channel.",
    )
    async def schedule(
        self,
        interaction: discord.Interaction,
        when: str,
        message: str,
        repeat: Optional[str] = None,
        channel: Optional[discord.TextChannel] = None,
    ):
        delay_seconds = parse_duration(when)
        if delay_seconds is None:
            await interaction.response.send_message("Invalid `when` value. Use formats like `10m`, `2h`, or `1d`.", ephemeral=True)
            return
        if len(message) > MAX_ANNOUNCEMENT_LENGTH:
            await interaction.response.send_message(
                f"Announcement content is too long ({len(message)} chars). Maximum is {MAX_ANNOUNCEMENT_LENGTH}.",
                ephemeral=True,
            )
            return
        interval_seconds = parse_duration(repeat) if repeat else None
        if repeat and interval_seconds is None:
            await interaction.response.send_message("Invalid repeat interval. Use formats like `1h` or `1d`.", ephemeral=True)
            return

        settings = await self.get_settings(interaction.guild_id)
        target_channel_id = channel.id if channel else settings["announcement_channel_id"] or interaction.channel_id
        target_channel = await self._resolve_channel(target_channel_id)
        if target_channel is None:
            await interaction.response.send_message("The target announcement channel is unavailable.", ephemeral=True)
            return

        send_at = discord.utils.utcnow() + timedelta(seconds=delay_seconds)
        announcement_id = await self._schedule_announcement(
            interaction.guild_id,
            target_channel_id,
            interaction.user.id,
            message,
            send_at,
            interval_seconds=interval_seconds,
        )
        schedule_text = discord.utils.format_dt(send_at, style="F")
        repeat_text = "" if interval_seconds is None else f" Repeats every `{repeat}`."
        await interaction.response.send_message(
            f"Scheduled announcement `{announcement_id}` for {schedule_text} in {target_channel.mention}.{repeat_text}",
            ephemeral=True,
        )

    @announcements.command(name="list", description="List scheduled announcements for this server.")
    @app_commands.checks.has_permissions(manage_guild=True)
    async def list_scheduled(self, interaction: discord.Interaction):
        async with self.bot.db.cursor() as cursor:
            await cursor.execute(
                """
                SELECT id, channel_id, send_at, interval_seconds, message, delivery_failures, disabled, last_error
                FROM scheduled_announcements
                WHERE guild_id = ?
                ORDER BY send_at ASC
                LIMIT 20
                """,
                (interaction.guild_id,),
            )
            rows = await cursor.fetchall()

        if not rows:
            await interaction.response.send_message("No scheduled announcements for this server.", ephemeral=True)
            return

        lines = []
        for announcement_id, channel_id, send_at, interval_seconds, message, delivery_failures, disabled, last_error in rows:
            schedule = discord.utils.format_dt(discord.utils.parse_time(send_at), style="R")
            repeat_text = "" if interval_seconds is None else f" every `{interval_seconds}s`"
            status_text = " • paused" if disabled else ""
            failure_text = "" if not delivery_failures else f" • failures: {delivery_failures}"
            error_text = "" if not last_error else f"\nError: {last_error[:100]}"
            lines.append(f"`{announcement_id}` • <#{channel_id}> • {schedule}{repeat_text}{status_text}{failure_text}\n{message[:120]}{error_text}")

        embed = discord.Embed(title="Scheduled Announcements", description="\n\n".join(lines), color=discord.Color.blurple())
        await interaction.response.send_message(embed=embed, ephemeral=True)

    @announcements.command(name="diagnose", description="Show details and error info for a scheduled announcement.")
    @app_commands.checks.has_permissions(manage_guild=True)
    @app_commands.describe(announcement_id="The announcement id from /announcements list.")
    async def diagnose_scheduled(self, interaction: discord.Interaction, announcement_id: int):
        async with self.bot.db.cursor() as cursor:
            await cursor.execute(
                """
                SELECT id, channel_id, author_id, message, send_at, interval_seconds,
                       delivery_failures, disabled, last_error
                FROM scheduled_announcements
                WHERE id = ? AND guild_id = ?
                """,
                (announcement_id, interaction.guild_id),
            )
            row = await cursor.fetchone()

        if not row:
            await interaction.response.send_message("Scheduled announcement not found.", ephemeral=True)
            return

        ann_id, channel_id, author_id, message, send_at, interval_seconds, failures, disabled, last_error = row
        embed = discord.Embed(
            title=f"Announcement `{ann_id}` Details",
            color=discord.Color.orange() if disabled else discord.Color.blurple(),
        )
        embed.add_field(name="Channel", value=f"<#{channel_id}>", inline=True)
        embed.add_field(name="Scheduled By", value=f"<@{author_id}>", inline=True)
        embed.add_field(name="Next Send", value=discord.utils.format_dt(discord.utils.parse_time(send_at), style="F"), inline=False)
        if interval_seconds:
            embed.add_field(name="Repeat Interval", value=f"{interval_seconds}s", inline=True)
        embed.add_field(name="Status", value="Paused (too many failures)" if disabled else "Active", inline=True)
        embed.add_field(name="Delivery Failures", value=str(failures), inline=True)
        if last_error:
            embed.add_field(name="Last Error", value=last_error[:1024], inline=False)
        embed.add_field(name="Content", value=message[:1024], inline=False)
        await interaction.response.send_message(embed=embed, ephemeral=True)

    @announcements.command(name="cancel", description="Cancel a scheduled announcement.")
    @app_commands.checks.has_permissions(manage_guild=True)
    async def cancel_scheduled(self, interaction: discord.Interaction, announcement_id: int):
        async with self.bot.db.cursor() as cursor:
            await cursor.execute(
                "DELETE FROM scheduled_announcements WHERE id = ? AND guild_id = ?",
                (announcement_id, interaction.guild_id),
            )
            deleted = cursor.rowcount
        await self.bot.db.commit()
        if deleted:
            await interaction.response.send_message(f"Scheduled announcement `{announcement_id}` canceled.", ephemeral=True)
        else:
            await interaction.response.send_message("Scheduled announcement not found.", ephemeral=True)

    @tasks.loop(seconds=SCHEDULE_POLL_SECONDS)
    async def schedule_loop(self):
        now = discord.utils.utcnow().isoformat()
        async with self.bot.db.cursor() as cursor:
            await cursor.execute(
                """
                SELECT id, guild_id, channel_id, author_id, message, send_at, interval_seconds, delivery_failures
                FROM scheduled_announcements
                WHERE disabled = 0 AND send_at <= ?
                ORDER BY send_at ASC
                LIMIT ?
                """,
                (now, SCHEDULE_BATCH_SIZE),
            )
            rows = await cursor.fetchall()

        for announcement_id, guild_id, channel_id, author_id, message, send_at, interval_seconds, delivery_failures in rows:
            guild = self.bot.get_guild(guild_id)
            channel = await self._resolve_channel(channel_id)
            delivered = False
            failure_reason = None
            if guild is not None and channel is not None:
                embed = discord.Embed(description=message, color=discord.Color.blurple())
                embed.set_author(name=f"Scheduled announcement from {guild.name}")
                user = guild.get_member(author_id)
                footer_name = user.display_name if user else str(author_id)
                embed.set_footer(text=f"Scheduled by {footer_name}")
                try:
                    await channel.send(embed=embed)
                    delivered = True
                except (discord.Forbidden, discord.HTTPException):
                    delivered = False
                    failure_reason = "failed to send the scheduled announcement in the configured channel"
            elif guild is None:
                failure_reason = "the target guild is unavailable to the bot"
            else:
                failure_reason = "the configured announcement channel is unavailable"

            async with self.bot.db.cursor() as cursor:
                if delivered and interval_seconds:
                    next_time = discord.utils.parse_time(send_at)
                    while next_time <= discord.utils.utcnow():
                        next_time += timedelta(seconds=interval_seconds)
                    await cursor.execute(
                        """
                        UPDATE scheduled_announcements
                        SET send_at = ?, delivery_failures = 0, disabled = 0, last_error = NULL
                        WHERE id = ?
                        """,
                        (next_time.isoformat(), announcement_id),
                    )
                elif delivered:
                    await cursor.execute("DELETE FROM scheduled_announcements WHERE id = ?", (announcement_id,))
                else:
                    next_failures = delivery_failures + 1
                    if next_failures >= MAX_SCHEDULE_DELIVERY_FAILURES:
                        await cursor.execute(
                            """
                            UPDATE scheduled_announcements
                            SET delivery_failures = ?, disabled = 1, last_error = ?
                            WHERE id = ?
                            """,
                            (next_failures, failure_reason, announcement_id),
                        )
                        if guild is not None:
                            await self._log_to_modlog(
                                guild,
                                "Scheduled Announcement Disabled",
                                f"Announcement `{announcement_id}` was paused after repeated delivery failures.\nReason: {failure_reason}",
                                discord.Color.orange(),
                            )
                    else:
                        retry_at = discord.utils.utcnow() + self.schedule_retry_delay(next_failures)
                        await cursor.execute(
                            """
                            UPDATE scheduled_announcements
                            SET send_at = ?, delivery_failures = ?, last_error = ?
                            WHERE id = ?
                            """,
                            (retry_at.isoformat(), next_failures, failure_reason, announcement_id),
                        )
            await self.bot.db.commit()

    @schedule_loop.before_loop
    async def before_schedule_loop(self):
        await self.bot.wait_until_ready()
        await self.setup_database()

    @commands.Cog.listener()
    async def on_member_join(self, member: discord.Member):
        settings = await self.get_settings(member.guild.id)
        if settings["welcome_channel_id"]:
            try:
                text = self.render_template(settings["welcome_message"], member, member.guild, DEFAULT_WELCOME_MESSAGE)
                await self._send_to_channel(settings["welcome_channel_id"], content=text)
            except Exception:
                await self._log_to_modlog(
                    member.guild,
                    "Welcome Message Error",
                    "A welcome template could not be rendered. Reset or update the welcome message template.",
                    discord.Color.red(),
                )

        await self._log_to_modlog(
            member.guild,
            "Member Joined",
            f"{member.mention} joined the server.",
            discord.Color.green(),
        )

    @commands.Cog.listener()
    async def on_member_remove(self, member: discord.Member):
        settings = await self.get_settings(member.guild.id)
        if settings["goodbye_channel_id"]:
            try:
                text = self.render_template(settings["goodbye_message"], member, member.guild, DEFAULT_GOODBYE_MESSAGE)
                await self._send_to_channel(settings["goodbye_channel_id"], content=text)
            except Exception:
                await self._log_to_modlog(
                    member.guild,
                    "Goodbye Message Error",
                    "A goodbye template could not be rendered. Reset or update the goodbye message template.",
                    discord.Color.red(),
                )

        await self._log_to_modlog(
            member.guild,
            "Member Left",
            f"{member} left the server.",
            discord.Color.orange(),
        )

    @commands.Cog.listener()
    async def on_message_delete(self, message: discord.Message):
        if message.guild is None or message.author.bot:
            return

        content = message.content.strip()
        if not content:
            content = "[no text content]"
        elif len(content) > 500:
            content = f"{content[:497]}..."

        await self._log_to_modlog(
            message.guild,
            "Message Deleted",
            f"Author: {message.author.mention}\nChannel: {message.channel.mention}\nContent: {content}",
            discord.Color.red(),
        )


async def setup(bot: commands.Bot):
    await bot.add_cog(Community(bot))


================================================
FILE: cogs/economy.py
================================================
import asyncio
import logging
import random

import discord
from discord import app_commands
from discord.ext import commands

logger = logging.getLogger(__name__)

# Constants
DEFAULT_BALANCE = 100
DAILY_MIN = 100
DAILY_MAX = 500
DAILY_COOLDOWN = 86400  # 24 hours
FREELANCE_MIN = 25
FREELANCE_MAX = 75
FREELANCE_COOLDOWN = 900  # 15 minutes
REGULAR_MIN = 100
REGULAR_MAX = 300
REGULAR_COOLDOWN = 3600  # 1 hour
CRIME_MIN = 500
CRIME_MAX = 1500
CRIME_FINE_MIN = 200
CRIME_FINE_MAX = 750
CRIME_SUCCESS_RATE = 0.50
CRIME_COOLDOWN = 21600  # 6 hours
ROB_SUCCESS_RATE = 0.40
ROB_MIN_VICTIM_BALANCE = 200
ROB_COOLDOWN = 1800  # 30 minutes
SLOTS_MIN_BET = 10


class Economy(commands.Cog):
    def __init__(self, bot: commands.Bot):
        self.bot = bot
        self._guild_locks: dict[int, asyncio.Lock] = {}
        self.bot.loop.create_task(self.setup_database())

    def _guild_lock(self, guild_id: int) -> asyncio.Lock:
        lock = self._guild_locks.get(guild_id)
        if lock is None:
            lock = asyncio.Lock()
            self._guild_locks[guild_id] = lock
        return lock

    async def setup_database(self):
        columns = []
        async with self.bot.db.execute("PRAGMA table_info(users)") as cursor:
            columns = [row[1] for row in await cursor.fetchall()]

        if columns and "guild_id" not in columns:
            async with self.bot.db.cursor() as cursor:
                await cursor.execute("ALTER TABLE users RENAME TO users_legacy")
                await cursor.execute(
                    """
                    CREATE TABLE users (
                        guild_id INTEGER NOT NULL,
                        user_id INTEGER NOT NULL,
                        balance INTEGER NOT NULL DEFAULT 100,
                        PRIMARY KEY (guild_id, user_id)
                    )
                    """
                )
                await cursor.execute(
                    """
                    INSERT OR IGNORE INTO users (guild_id, user_id, balance)
                    SELECT DISTINCT COALESCE(messages.guild_id, 0), legacy.user_id, legacy.balance
                    FROM users_legacy AS legacy
                    LEFT JOIN messages
                        ON messages.user_id = legacy.user_id
                       AND messages.guild_id IS NOT NULL
                    """
                )
            logger.info("Migrated global economy balances to per-guild records.")
        else:
            async with self.bot.db.cursor() as cursor:
                await cursor.execute(
                    """
                    CREATE TABLE IF NOT EXISTS users (
                        guild_id INTEGER NOT NULL,
                        user_id INTEGER NOT NULL,
                        balance INTEGER NOT NULL DEFAULT 100,
                        PRIMARY KEY (guild_id, user_id)
                    )
                    """
                )

        await self.bot.db.commit()

    def _get_guild_id(self, interaction: discord.Interaction) -> int:
        if interaction.guild_id is None:
            raise app_commands.CheckFailure("This command can only be used in a server.")
        return interaction.guild_id

    async def _get_or_create_user_unlocked(self, guild_id: int, user_id: int) -> int:
        async with self.bot.db.cursor() as cursor:
            await cursor.execute(
                "SELECT balance FROM users WHERE guild_id = ? AND user_id = ?",
                (guild_id, user_id),
            )
            result = await cursor.fetchone()
            if result is None:
                await cursor.execute(
                    "INSERT INTO users (guild_id, user_id, balance) VALUES (?, ?, ?)",
                    (guild_id, user_id, DEFAULT_BALANCE),
                )
                await self.bot.db.commit()
                return DEFAULT_BALANCE
            return result[0]

    async def get_or_create_user(self, guild_id: int, user_id: int) -> int:
        async with self._guild_lock(guild_id):
            return await self._get_or_create_user_unlocked(guild_id, user_id)

    async def change_balance(self, guild_id: int, user_id: int, delta: int) -> int:
        async with self._guild_lock(guild_id):
            balance = await self._get_or_create_user_unlocked(guild_id, user_id)
            new_balance = balance + delta
            if new_balance < 0:
                raise ValueError("Balance cannot be negative.")

            async with self.bot.db.cursor() as cursor:
                await cursor.execute(
                    "UPDATE users SET balance = ? WHERE guild_id = ? AND user_id = ?",
                    (new_balance, guild_id, user_id),
                )
            await self.bot.db.commit()
            return new_balance

    async def transfer_balance(self, guild_id: int, sender_id: int, receiver_id: int, amount: int) -> tuple[int, int]:
        async with self._guild_lock(guild_id):
            sender_balance = await self._get_or_create_user_unlocked(guild_id, sender_id)
            if sender_balance < amount:
                raise ValueError("You do not have enough coins to make this transfer.")

            receiver_balance = await self._get_or_create_user_unlocked(guild_id, receiver_id)
            async with self.bot.db.cursor() as cursor:
                await cursor.execute(
                    "UPDATE users SET balance = ? WHERE guild_id = ? AND user_id = ?",
                    (sender_balance - amount, guild_id, sender_id),
                )
                await cursor.execute(
                    "UPDATE users SET balance = ? WHERE guild_id = ? AND user_id = ?",
                    (receiver_balance + amount, guild_id, receiver_id),
                )
            await self.bot.db.commit()
            return sender_balance - amount, receiver_balance + amount

    async def set_balance(self, guild_id: int, user_id: int, amount: int) -> int:
        async with self._guild_lock(guild_id):
            await self._get_or_create_user_unlocked(guild_id, user_id)
            async with self.bot.db.cursor() as cursor:
                await cursor.execute(
                    "UPDATE users SET balance = ? WHERE guild_id = ? AND user_id = ?",
                    (amount, guild_id, user_id),
                )
            await self.bot.db.commit()
            return amount

    jobs = app_commands.Group(name="jobs", description="Perform various jobs to earn coins.", guild_only=True)
    admin = app_commands.Group(name="economy-admin", description="Administer the server economy.", guild_only=True)

    @app_commands.command(name="balance", description="Check your or another member's coin balance.")
    @app_commands.guild_only()
    @app_commands.describe(member="The member whose balance you want to see.")
    @app_commands.checks.cooldown(1, 10, key=lambda i: (i.guild_id, i.user.id))
    async def balance(self, interaction: discord.Interaction, member: discord.Member = None):
        guild_id = self._get_guild_id(interaction)
        target_member = member or interaction.user
        balance = await self.get_or_create_user(guild_id, target_member.id)
        embed = discord.Embed(title=f"{target_member.name}'s Balance", color=discord.Color.green())
        embed.add_field(name="Coins", value=f"🪙 {balance}")
        await interaction.response.send_message(embed=embed)

    @app_commands.command(name="daily", description="Claim your daily reward.")
    @app_commands.guild_only()
    @app_commands.checks.cooldown(1, DAILY_COOLDOWN, key=lambda i: (i.guild_id, i.user.id))
    async def daily(self, interaction: discord.Interaction):
        guild_id = self._get_guild_id(interaction)
        daily_amount = random.randint(DAILY_MIN, DAILY_MAX)
        await self.change_balance(guild_id, interaction.user.id, daily_amount)

        embed = discord.Embed(
            title="Daily Reward!",
            description=f"You have claimed your daily reward of 🪙 **{daily_amount}** coins!",
            color=discord.Color.gold(),
        )
        await interaction.response.send_message(embed=embed)

    @daily.error
    async def daily_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError):
        if isinstance(error, app_commands.CommandOnCooldown):
            seconds = error.retry_after
            hours, remainder = divmod(int(seconds), 3600)
            minutes, _ = divmod(remainder, 60)
            await interaction.response.send_message(
                f"You've already claimed your daily reward. Please try again in **{hours}h {minutes}m**.",
                ephemeral=True,
            )

    @jobs.command(name="freelance", description="Do a quick freelance job for some extra cash.")
    @app_commands.checks.cooldown(1, FREELANCE_COOLDOWN, key=lambda i: (i.guild_id, i.user.id))
    async def jobs_freelance(self, interaction: discord.Interaction):
        guild_id = self._get_guild_id(interaction)
        amount = random.randint(FREELANCE_MIN, FREELANCE_MAX)
        await self.change_balance(guild_id, interaction.user.id, amount)

        messages = [
            f"You designed a logo for a local startup and earned 🪙 **{amount}**.",
            f"You wrote a short article for a blog and got paid 🪙 **{amount}**.",
            f"You helped someone with their homework and they tipped you 🪙 **{amount}**.",
        ]
        await interaction.response.send_message(random.choice(messages))

    @jobs_freelance.error
    async def freelance_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError):
        if isinstance(error, app_commands.CommandOnCooldown):
            minutes = int(error.retry_after / 60)
            await interaction.response.send_message(
                f"You need a break. You can do another freelance job in **{minutes}** minutes.", ephemeral=True
            )

    @jobs.command(name="regular", description="Work your regular shift for a steady income.")
    @app_commands.checks.cooldown(1, REGULAR_COOLDOWN, key=lambda i: (i.guild_id, i.user.id))
    async def jobs_regular(self, interaction: discord.Interaction):
        guild_id = self._get_guild_id(interaction)
        amount = random.randint(REGULAR_MIN, REGULAR_MAX)
        await self.change_balance(guild_id, interaction.user.id, amount)

        messages = [
            f"You completed your shift as a programmer and earned 🪙 **{amount}**.",
            f"You spent the day as a server janitor and got paid 🪙 **{amount}**.",
            f"You delivered pizzas all afternoon and made 🪙 **{amount}**.",
        ]
        await interaction.response.send_message(random.choice(messages))

    @jobs_regular.error
    async def regular_work_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError):
        if isinstance(error, app_commands.CommandOnCooldown):
            minutes = int(error.retry_after / 60)
            await interaction.response.send_message(
                f"You're tired from your shift. You can work again in **{minutes}** minutes.", ephemeral=True
            )

    @jobs.command(name="crime", description="Commit a crime for a high reward, but with high risk.")
    @app_commands.checks.cooldown(1, CRIME_COOLDOWN, key=lambda i: (i.guild_id, i.user.id))
    async def jobs_crime(self, interaction: discord.Interaction):
        guild_id = self._get_guild_id(interaction)
        balance = await self.get_or_create_user(guild_id, interaction.user.id)

        if random.random() < CRIME_SUCCESS_RATE:
            payout = random.randint(CRIME_MIN, CRIME_MAX)
            await self.change_balance(guild_id, interaction.user.id, payout)
            await interaction.response.send_message(
                f"🚨 **Success!** Your high-stakes bank heist went perfectly. You got away with 🪙 **{payout}**!"
            )
        else:
            fine = min(balance, random.randint(CRIME_FINE_MIN, CRIME_FINE_MAX))
            await self.change_balance(guild_id, interaction.user.id, -fine)
            await interaction.response.send_message(
                f"👮‍♂️ **BUSTED!** The silent alarm tripped during your operation. You were caught and fined 🪙 **{fine}**."
            )

    @jobs_crime.error
    async def crime_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError):
        if isinstance(error, app_commands.CommandOnCooldown):
            hours = int(error.retry_after / 3600)
            await interaction.response.send_message(
                f"You need to lay low for a while. You can try another crime in **{hours}** hours.", ephemeral=True
            )

    @app_commands.command(name="gamble", description="Gamble your coins for a chance to win big.")
    @app_commands.guild_only()
    @app_commands.describe(amount="The amount of coins you want to gamble.")
    @app_commands.checks.cooldown(1, 10, key=lambda i: (i.guild_id, i.user.id))
    async def gamble(self, interaction: discord.Interaction, amount: app_commands.Range[int, 1]):
        guild_id = self._get_guild_id(interaction)
        balance = await self.get_or_create_user(guild_id, interaction.user.id)

        if amount > balance:
            await interaction.response.send_message("You don't have enough coins to gamble that much.", ephemeral=True)
            return

        win = random.choice([True, False, False])
        delta = amount if win else -amount
        new_balance = await self.change_balance(guild_id, interaction.user.id, delta)

        if win:
            await interaction.response.send_message(
                f"🎉 **You won!** You gambled {amount} and won {amount} coins! Your new balance is 🪙 {new_balance}."
            )
        else:
            await interaction.response.send_message(
                f"💀 **You lost!** You gambled {amount} and lost it all. Your new balance is 🪙 {new_balance}."
            )

    @app_commands.command(name="leaderboard", description="Shows the top 10 richest users in the server.")
    @app_commands.guild_only()
    @app_commands.checks.cooldown(1, 30, key=lambda i: i.guild_id)
    async def leaderboard(self, interaction: discord.Interaction):
        guild_id = self._get_guild_id(interaction)
        await interaction.response.defer()

        async with self.bot.db.cursor() as cursor:
            await cursor.execute(
                "SELECT user_id, balance FROM users WHERE guild_id = ? ORDER BY balance DESC LIMIT 10",
                (guild_id,),
            )
            top_users = await cursor.fetchall()

        if not top_users:
            await interaction.followup.send("The leaderboard is empty!")
            return

        embed = discord.Embed(title="🏆 Server Coin Leaderboard 🏆", color=discord.Color.gold())

        leaderboard_text = ""
        for rank, (user_id, balance) in enumerate(top_users, start=1):
            member = interaction.guild.get_member(user_id)
            if member is None:
                user_name = f"Unknown User (ID: {user_id})"
            else:
                user_name = member.display_name

            leaderboard_text += f"{rank}. {user_name} - 🪙 {balance}\n"

        embed.description = leaderboard_text
        await interaction.followup.send(embed=embed)

    @app_commands.command(name="transfer", description="Transfer coins to another member.")
    @app_commands.guild_only()
    @app_commands.describe(
        member="The member you want to transfer coins to.", amount="The amount of coins to transfer."
    )
    @app_commands.checks.cooldown(1, 15, key=lambda i: (i.guild_id, i.user.id))
    async def transfer(
        self, interaction: discord.Interaction, member: discord.Member, amount: app_commands.Range[int, 1]
    ):
        guild_id = self._get_guild_id(interaction)
        sender_id = interaction.user.id
        receiver_id = member.id

        if sender_id == receiver_id:
            await interaction.response.send_message("You cannot transfer coins to yourself.", ephemeral=True)
            return

        if member.bot:
            await interaction.response.send_message("You cannot transfer coins to bots.", ephemeral=True)
            return

        try:
            await self.transfer_balance(guild_id, sender_id, receiver_id, amount)
        except ValueError as error:
            await interaction.response.send_message(str(error), ephemeral=True)
            return

        await interaction.response.send_message(
            f"💸 You have successfully transferred 🪙 **{amount}** coins to {member.mention}!"
        )

    @app_commands.command(name="rob", description="Attempt to rob coins from another member.")
    @app_commands.guild_only()
    @app_commands.describe(member="The member you want to rob.")
    @app_commands.checks.cooldown(1, ROB_COOLDOWN, key=lambda i: (i.guild_id, i.user.id))
    async def rob(self, interaction: discord.Interaction, member: discord.Member):
        guild_id = self._get_guild_id(interaction)
        robber_id = interaction.user.id
        victim_id = member.id

        if robber_id == victim_id:
            await interaction.response.send_message("You can't rob yourself, you silly goose!", ephemeral=True)
            return

        if member.bot:
            await interaction.response.send_message("Bots are not part of the economy.", ephemeral=True)
            return

        async with self._guild_lock(guild_id):
            robber_balance = await self._get_or_create_user_unlocked(guild_id, robber_id)
            victim_balance = await self._get_or_create_user_unlocked(guild_id, victim_id)

            if victim_balance < ROB_MIN_VICTIM_BALANCE:
                self.rob.reset_cooldown(interaction)
                await interaction.response.send_message(
                    f"{member.name} is too poor to be worth robbing.", ephemeral=True
                )
                return

            async with self.bot.db.cursor() as cursor:
                if random.random() < ROB_SUCCESS_RATE:
                    robbed_amount = random.randint(int(victim_balance * 0.1), int(victim_balance * 0.25))
                    await cursor.execute(
                        "UPDATE users SET balance = ? WHERE guild_id = ? AND user_id = ?",
                        (robber_balance + robbed_amount, guild_id, robber_id),
                    )
                    await cursor.execute(
                        "UPDATE users SET balance = ? WHERE guild_id = ? AND user_id = ?",
                        (victim_balance - robbed_amount, guild_id, victim_id),
                    )
                    message = f"🚨 Success! You discreetly robbed 🪙 **{robbed_amount}** from {member.mention}!"
                else:
                    fine_amount = min(robber_balance, random.randint(int(robber_balance * 0.1), int(robber_balance * 0.2)))
                    await cursor.execute(
                        "UPDATE users SET balance = ? WHERE guild_id = ? AND user_id = ?",
                        (robber_balance - fine_amount, guild_id, robber_id),
                    )
                    message = (
                        f"👮‍♂️ Busted! Your robbery attempt on {member.mention} failed and you were fined 🪙 "
                        f"**{fine_amount}**."
                    )

            await self.bot.db.commit()

        await interaction.response.send_message(message)

    @rob.error
    async def rob_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError):
        if isinstance(error, app_commands.CommandOnCooldown):
            minutes = int(error.retry_after / 60)
            await interaction.response.send_message(
                f"You're on a cooldown. You can attempt another robbery in **{minutes}** minutes.", ephemeral=True
            )

    @app_commands.command(name="slots", description="Play the slot machine.")
    @app_commands.guild_only()
    @app_commands.describe(bet="The amount of coins you want to bet.")
    @app_commands.checks.cooldown(1, 5, key=lambda i: (i.guild_id, i.user.id))
    async def slots(self, interaction: discord.Interaction, bet: app_commands.Range[int, SLOTS_MIN_BET]):
        guild_id = self._get_guild_id(interaction)
        balance = await self.get_or_create_user(guild_id, interaction.user.id)

        if bet > balance:
            await interaction.response.send_message("You don't have enough coins to bet that much.", ephemeral=True)
            return

        reels = ["🍒", "🍊", "🍋", "🔔", "⭐", "💎"]
        spin = [random.choice(reels) for _ in range(3)]

        result_text = f"**[ {spin[0]} | {spin[1]} | {spin[2]} ]**\n\n"

        if spin[0] == spin[1] == spin[2]:
            if spin[0] == "💎":
                winnings = bet * 20
                result_text += f"💎 JACKPOT! 💎 You won **{winnings}** coins!"
            else:
                winnings = bet * 10
                result_text += f"🎉 BIG WIN! 🎉 You won **{winnings}** coins!"
        elif spin[0] == spin[1] or spin[1] == spin[2]:
            winnings = bet * 2
            result_text += f"👍 Nice! You won **{winnings}** coins!"
        else:
            winnings = -bet
            result_text += "☠️ Aw, tough luck! You lost your bet."

        new_balance = await self.change_balance(guild_id, interaction.user.id, winnings)

        embed = discord.Embed(title="🎰 Slot Machine 🎰", description=result_text, color=discord.Color.dark_magenta())
        embed.set_footer(text=f"Your new balance is 🪙 {new_balance}")
        await interaction.response.send_message(embed=embed)

    @admin.command(name="add", description="Add coins to a member.")
    @app_commands.checks.has_permissions(manage_guild=True)
    @app_commands.describe(member="The target member.", amount="How many coins to add.")
    async def admin_add(self, interaction: discord.Interaction, member: discord.Member, amount: app_commands.Range[int, 1]):
        guild_id = self._get_guild_id(interaction)
        balance = await self.change_balance(guild_id, member.id, amount)
        await interaction.response.send_message(
            f"Added 🪙 **{amount}** to {member.mention}. New balance: 🪙 {balance}.",
            ephemeral=True,
        )

    @admin.command(name="remove", description="Remove coins from a member.")
    @app_commands.checks.has_permissions(manage_guild=True)
    @app_commands.describe(member="The target member.", amount="How many coins to remove.")
    async def admin_remove(
        self, interaction: discord.Interaction, member: discord.Member, amount: app_commands.Range[int, 1]
    ):
        guild_id = self._get_guild_id(interaction)
        current = await self.get_or_create_user(guild_id, member.id)
        removed = min(current, amount)
        balance = await self.change_balance(guild_id, member.id, -removed)
        await interaction.response.send_message(
            f"Removed 🪙 **{removed}** from {member.mention}. New balance: 🪙 {balance}.",
            ephemeral=True,
        )

    @admin.command(name="set", description="Set a member's balance exactly.")
    @app_commands.checks.has_permissions(manage_guild=True)
    @app_commands.describe(member="The target member.", amount="The exact balance to set.")
    async def admin_set(self, interaction: discord.Interaction, member: discord.Member, amount: app_commands.Range[int, 0]):
        guild_id = self._get_guild_id(interaction)
        balance = await self.set_balance(guild_id, member.id, amount)
        await interaction.response.send_message(
            f"Set {member.mention}'s balance to 🪙 **{balance}**.",
            ephemeral=True,
        )

    @admin.command(name="reset-guild", description="Reset the server economy for all users.")
    @app_commands.checks.has_permissions(administrator=True)
    async def admin_reset_guild(self, interaction: discord.Interaction):
        guild_id = self._get_guild_id(interaction)
        async with self._guild_lock(guild_id):
            async with self.bot.db.cursor() as cursor:
                await cursor.execute("DELETE FROM users WHERE guild_id = ?", (guild_id,))
            await self.bot.db.commit()
        await interaction.response.send_message("The server economy has been reset.", ephemeral=True)


async def setup(bot: commands.Bot):
    await bot.add_cog(Economy(bot))


================================================
FILE: cogs/farming.py
================================================
import logging
import math
import random
from datetime import datetime, timedelta

import discord
from discord import app_commands
from discord.ext import commands

logger = logging.getLogger(__name__)

# Constants
PEST_EVENT_CHANCE = 0.10
PEST_REWARD_MULTIPLIER = 0.5
BOUNTIFUL_EVENT_CHANCE = 0.95
BOUNTIFUL_REWARD_MULTIPLIER = 1.5
BOUNTIFUL_XP_MULTIPLIER = 1.5


class Farming(commands.Cog):
    def __init__(self, bot: commands.Bot):
        self.bot = bot
        self.bot.loop.create_task(self.setup_database())

        self.crops = {
            "wheat": {"name": "Wheat 🌾", "cost": 10, "growth": 600, "reward": 25, "xp": 5, "level": 1},
            "potato": {"name": "Potato 🥔", "cost": 25, "growth": 1800, "reward": 75, "xp": 15, "level": 2},
            "carrot": {"name": "Carrot 🥕", "cost": 50, "growth": 3600, "reward": 180, "xp": 30, "level": 5},
            "strawberry": {"name": "Strawberry 🍓", "cost": 150, "growth": 7200, "reward": 500, "xp": 100, "level": 10},
        }

        self.land_types = {
            1: {"name": "Barren Land", "mod": 1.0, "cost": 0},
            2: {"name": "Decent Soil", "mod": 0.9, "cost": 1000},
            3: {"name": "Fertile Land", "mod": 0.75, "cost": 5000},
        }

    async def setup_database(self):
        columns = []
        async with self.bot.db.execute("PRAGMA table_info(farms)") as cursor:
            columns = [row[1] for row in await cursor.fetchall()]

        if columns and "guild_id" not in columns:
            async with self.bot.db.cursor() as cursor:
                await cursor.execute("ALTER TABLE farms RENAME TO farms_legacy")
                await cursor.execute(
                    """
                    CREATE TABLE farms (
                        guild_id INTEGER NOT NULL,
                        user_id INTEGER NOT NULL,
                        crop TEXT,
                        plant_time TEXT,
                        land_type INTEGER DEFAULT 1,
                        level INTEGER DEFAULT 1,
                        xp INTEGER DEFAULT 0,
                        PRIMARY KEY (guild_id, user_id)
                    )
                    """
                )
                await cursor.execute(
                    """
                    INSERT OR IGNORE INTO farms (guild_id, user_id, crop, plant_time, land_type, level, xp)
                    SELECT DISTINCT
                        COALESCE(messages.guild_id, 0),
                        legacy.user_id,
                        legacy.crop,
                        legacy.plant_time,
                        legacy.land_type,
                        legacy.level,
                        legacy.xp
                    FROM farms_legacy AS legacy
                    LEFT JOIN messages
                        ON messages.user_id = legacy.user_id
                       AND messages.guild_id IS NOT NULL
                    """
                )
            logger.info("Migrated global farm data to per-guild records.")
        else:
            async with self.bot.db.cursor() as cursor:
                await cursor.execute(
                    """
                    CREATE TABLE IF NOT EXISTS farms (
                        guild_id INTEGER NOT NULL,
                        user_id INTEGER NOT NULL,
                        crop TEXT,
                        plant_time TEXT,
                        land_type INTEGER DEFAULT 1,
                        level INTEGER DEFAULT 1,
                        xp INTEGER DEFAULT 0,
                        PRIMARY KEY (guild_id, user_id)
                    )
                    """
                )
        await self.bot.db.commit()

    async def get_farm_data(self, guild_id: int, user_id: int):
        async with self.bot.db.cursor() as cursor:
            await cursor.execute("SELECT * FROM farms WHERE guild_id = ? AND user_id = ?", (guild_id, user_id))
            result = await cursor.fetchone()
            if not result:
                await cursor.execute("INSERT INTO farms (guild_id, user_id) VALUES (?, ?)", (guild_id, user_id))
                await self.bot.db.commit()
                await cursor.execute("SELECT * FROM farms WHERE guild_id = ? AND user_id = ?", (guild_id, user_id))
                result = await cursor.fetchone()

        keys = ["guild_id", "user_id", "crop", "plant_time", "land_type", "level", "xp"]
        return dict(zip(keys, result))

    def get_xp_for_next_level(self, level: int):
        return math.floor(100 * (level**1.5))

    farm = app_commands.Group(name="farm", description="Manage your farm, grow crops, and level up.", guild_only=True)

    @farm.command(name="profile", description="View your farm profile, including level and status.")
    async def profile(self, interaction: discord.Interaction):
        await interaction.response.defer()
        farm_data = await self.get_farm_data(interaction.guild_id, interaction.user.id)

        embed = discord.Embed(title=f"{interaction.user.name}'s Farm Profile", color=discord.Color.green())

        level = farm_data["level"]
        xp = farm_data["xp"]
        xp_needed = self.get_xp_for_next_level(level)
        progress = min(int((xp / xp_needed) * 20), 20)
        progress_bar = "🟩" * progress + "⬛" * (20 - progress)

        embed.add_field(name="📜 Level", value=f"**{level}**", inline=True)
        embed.add_field(name="🌱 XP", value=f"{xp} / {xp_needed}", inline=True)
        embed.add_field(name="📊 Progress", value=f"`{progress_bar}`", inline=False)
        embed.add_field(name="🏞️ Land", value=self.land_types[farm_data["land_type"]]["name"], inline=True)

        if not farm_data["crop"]:
            embed.add_field(name="🌾 Current Crop", value="Plot is empty.", inline=True)
        else:
            crop_name = farm_data["crop"]
            crop_data = self.crops[crop_name]
            plant_time = datetime.fromisoformat(farm_data["plant_time"])
            land_mod = self.land_types[farm_data["land_type"]]["mod"]
            harvest_time = plant_time + timedelta(seconds=crop_data["growth"] * land_mod)

            embed.add_field(name="🌾 Current Crop", value=crop_data["name"], inline=True)
            if datetime.utcnow() >= harvest_time:
                embed.add_field(name="⏰ Status", value="✅ **Ready to Harvest!**", inline=False)
            else:
                embed.add_field(
                    name="⏰ Ready In", value=f"{discord.utils.format_dt(harvest_time, style='R')}", inline=False
                )

        await interaction.followup.send(embed=embed)

    @farm.command(name="shop", description="Open the seed shop to buy new crops.")
    async def shop(self, interaction: discord.Interaction):
        farm_data = await self.get_farm_data(interaction.guild_id, interaction.user.id)
        user_level = farm_data["level"]

        embed = discord.Embed(
            title="🌱 Seed Shop", description="Select a seed to plant from the shop.", color=discord.Color.dark_gold()
        )
        for crop in self.crops.values():
            unlocked = user_level >= crop["level"]
            emoji = "✅" if unlocked else "🔒"
            embed.add_field(
                name=f"{crop['name']} (Lvl {crop['level']}) {emoji}",
                value=f"Cost: 🪙 {crop['cost']}\nReward: 🪙 {crop['reward']}\nXP: {crop['xp']}",
                inline=True,
            )

        await interaction.response.send_message(embed=embed)

    @farm.command(name="plant", description="Plant a crop in your farm.")
    @app_commands.describe(crop="The name of the crop to plant (e.g., wheat, potato).")
    async def plant(self, interaction: discord.Interaction, crop: str):
        guild_id = interaction.guild_id
        crop_name = crop.lower()
        if crop_name not in self.crops:
            await interaction.response.send_message(
                "That's not a valid crop! Check the `/farm shop` for options.", ephemeral=True
            )
            return

        farm_data = await self.get_farm_data(guild_id, interaction.user.id)
        if farm_data["crop"]:
            await interaction.response.send_message("You already have a crop growing!", ephemeral=True)
            return

        crop_data = self.crops[crop_name]
        if farm_data["level"] < crop_data["level"]:
            await interaction.response.send_message(
                f"You're not a high enough level! You need to be level {crop_data['level']} to plant {crop_data['name']}.",
                ephemeral=True,
            )
            return

        economy_cog = self.bot.get_cog("Economy")
        if not economy_cog:
            await interaction.response.send_message("The economy system is currently unavailable.", ephemeral=True)
            return

        async with economy_cog._guild_lock(guild_id):
            user_balance = await economy_cog._get_or_create_user_unlocked(guild_id, interaction.user.id)
            if user_balance < crop_data["cost"]:
                await interaction.response.send_message(
                    f"You don't have enough coins! Planting {crop_data['name']} costs 🪙 {crop_data['cost']}.",
                    ephemeral=True,
                )
                return

            async with self.bot.db.cursor() as cursor:
                await cursor.execute(
                    "UPDATE users SET balance = ? WHERE guild_id = ? AND user_id = ?",
                    (user_balance - crop_data["cost"], guild_id, interaction.user.id),
                )
                await cursor.execute(
                    "UPDATE farms SET crop = ?, plant_time = ? WHERE guild_id = ? AND user_id = ?",
                    (crop_name, datetime.utcnow().isoformat(), guild_id, interaction.user.id),
                )
            await self.bot.db.commit()

        land_mod = self.land_types[farm_data["land_type"]]["mod"]
        growth_seconds = crop_data["growth"] * land_mod
        growth_hours = round(growth_seconds / 3600, 1)

        await interaction.response.send_message(
            f"You've successfully planted **{crop_data['name']}** for 🪙 {crop_data['cost']}! It will be ready in {growth_hours} hours."
        )

    @farm.command(name="harvest", description="Harvest your fully grown crop for coins and XP.")
    async def harvest(self, interaction: discord.Interaction):
        guild_id = interaction.guild_id
        farm_data = await self.get_farm_data(guild_id, interaction.user.id)
        if not farm_data["crop"]:
            await interaction.response.send_message("You don't have anything planted right now.", ephemeral=True)
            return

        crop_name = farm_data["crop"]
        crop_data = self.crops[crop_name]
        plant_time = datetime.fromisoformat(farm_data["plant_time"])
        land_mod = self.land_types[farm_data["land_type"]]["mod"]
        harvest_time = plant_time + timedelta(seconds=crop_data["growth"] * land_mod)

        if datetime.utcnow() < harvest_time:
            await interaction.response.send_message(
                f"Your {crop_data['name']} isn't ready yet! Come back in {discord.utils.format_dt(harvest_time, style='R')}.",
                ephemeral=True,
            )
            return

        economy_cog = self.bot.get_cog("Economy")
        if not economy_cog:
            await interaction.response.send_message("The economy system is currently unavailable.", ephemeral=True)
            return

        reward = crop_data["reward"]
        xp_gain = crop_data["xp"]
        event_message = ""

        roll = random.random()
        if roll < PEST_EVENT_CHANCE:
            reward = int(reward * PEST_REWARD_MULTIPLIER)
            event_message = "\n\n**Oh no! A swarm of pests ate half your harvest!** 🐛"
        elif roll > BOUNTIFUL_EVENT_CHANCE:
            reward = int(reward * BOUNTIFUL_REWARD_MULTIPLIER)
            xp_gain = int(xp_gain * BOUNTIFUL_XP_MULTIPLIER)
            event_message = "\n\n**Amazing! A bountiful harvest! You got extra coins and XP!** ✨"

        new_xp = farm_data["xp"] + xp_gain
        current_level = farm_data["level"]
        xp_needed = self.get_xp_for_next_level(current_level)
        level_up_message = ""

        while new_xp >= xp_needed:
            current_level += 1
            new_xp -= xp_needed
            xp_needed = self.get_xp_for_next_level(current_level)
            level_up_message += f"\n🎉 **LEVEL UP! You are now Farm Level {current_level}!** 🎉"

        async with economy_cog._guild_lock(guild_id):
            user_balance = await economy_cog._get_or_create_user_unlocked(guild_id, interaction.user.id)
            async with self.bot.db.cursor() as cursor:
                await cursor.execute(
                    "UPDATE users SET balance = ? WHERE guild_id = ? AND user_id = ?",
                    (user_balance + reward, guild_id, interaction.user.id),
                )
                await cursor.execute(
                    """
                    UPDATE farms
                    SET crop = NULL, plant_time = NULL, level = ?, xp = ?
                    WHERE guild_id = ? AND user_id = ?
                    """,
                    (current_level, new_xp, guild_id, interaction.user.id),
                )
            await self.bot.db.commit()

        await interaction.response.send_message(
            f"You harvested your **{crop_data['name']}** and earned 🪙 **{reward}** and **{xp_gain} XP**!"
            f"{event_message}{level_up_message}"
        )

    @farm.command(name="upgrade", description="Upgrade your farm land for faster growth times.")
    async def upgrade(self, interaction: discord.Interaction):
        guild_id = interaction.guild_id
        farm_data = await self.get_farm_data(guild_id, interaction.user.id)
        current_land_level = farm_data["land_type"]

        if current_land_level >= len(self.land_types):
            await interaction.response.send_message("You already have the best land available!", ephemeral=True)
            return

        next_land_level = current_land_level + 1
        upgrade_data = self.land_types[next_land_level]

        economy_cog = self.bot.get_cog("Economy")
        if not economy_cog:
            await interaction.response.send_message("The economy system is currently unavailable.", ephemeral=True)
            return

        async with economy_cog._guild_lock(guild_id):
            user_balance = await economy_cog._get_or_create_user_unlocked(guild_id, interaction.user.id)
            if user_balance < upgrade_data["cost"]:
                await interaction.response.send_message(
                    f"You don't have enough coins! Upgrading to **{upgrade_data['name']}** costs 🪙 {upgrade_data['cost']}.",
                    ephemeral=True,
                )
                return

            async with self.bot.db.cursor() as cursor:
                await cursor.execute(
                    "UPDATE users SET balance = ? WHERE guild_id = ? AND user_id = ?",
                    (user_balance - upgrade_data["cost"], guild_id, interaction.user.id),
                )
                await cursor.execute(
                    "UPDATE farms SET land_type = ? WHERE guild_id = ? AND user_id = ?",
                    (next_land_level, guild_id, interaction.user.id),
                )
            await self.bot.db.commit()

        await interaction.response.send_message(
            f"Congratulations! You've spent 🪙 {upgrade_data['cost']} to upgrade your farm to **{upgrade_data['name']}**!"
            " Your crops will now grow faster."
        )


async def setup(bot: commands.Bot):
    await bot.add_cog(Farming(bot))


================================================
FILE: cogs/fun.py
================================================
import discord
from discord import app_commands
from discord.ext import commands
import random
from PIL import Image, ImageDraw, ImageFont
import io
import textwrap


class Fun(commands.Cog):
    def __init__(self, bot: commands.Bot):
        self.bot = bot
        self.session = bot.http_session
        self.jokes = [
            "Why don't scientists trust atoms? Because they make up everything!",
            "I told my wife she should embrace her mistakes. She gave me a hug.",
            "Why did the scarecrow win an award? Because he was outstanding in his field!",
            "I'm reading a book on anti-gravity. It's impossible to put down!",
            "What do you call a fake noodle? An Impasta!",
            "Why don't skeletons fight each other? They don't have the guts.",
            "What do you call cheese that isn't yours? Nacho cheese.",
            "Why did the bicycle fall over? Because it was two-tired.",
            "How does a penguin build its house? Igloos it together.",
            "I would tell you a joke about an empty pizza box, but it's too cheesy.",
            "What do you get when you cross a snowman and a vampire? Frostbite.",
            "Why are ghosts such bad liars? Because you can see right through them.",
            "What's orange and sounds like a parrot? A carrot.",
            "I invented a new word! Plagiarism.",
            "Did you hear about the mathematician who’s afraid of negative numbers? He’ll stop at nothing to avoid them.",
            "Why do we tell actors to 'break a leg?' Because every play has a cast.",
            "Helvetica and Times New Roman walk into a bar. 'Get out of here!' shouts the bartender. 'We don't serve your type.'",
            "Yesterday I saw a guy spill all his Scrabble letters on the road. I asked him, 'What’s the word on the street?'",
            "What’s the best thing about Switzerland? I don’t know, but the flag is a big plus.",
            "Why did the coffee file a police report? It got mugged.",
            "I'm so good at sleeping, I can do it with my eyes closed.",
            "Why was the big cat disqualified from the race? Because it was a cheetah.",
            "What do you call a bear with no teeth? A gummy bear.",
            "I asked the librarian if the library had any books on paranoia. She whispered, 'They're right behind you!'",
            "What did the zero say to the eight? Nice belt!",
            "What did one wall say to the other? I'll meet you at the corner.",
            "Why did the invisible man turn down the job offer? He couldn't see himself doing it.",
            "I have a joke about construction, but I'm still working on it.",
            "I used to play piano by ear, but now I use my hands.",
            "What do you call a boomerang that won't come back? A stick.",
            "Why did the golfer bring two pairs of pants? In case he got a hole in one.",
            "I'm on a seafood diet. I see food, and I eat it.",
            "What do you call a fish with no eyes? Fsh.",
            "Parallel lines have so much in common. It’s a shame they’ll never meet.",
            "My boss told me to have a good day, so I went home.",
            "Why can't you hear a pterodactyl go to the bathroom? Because the 'P' is silent.",
            "Why did the stadium get hot after the game? Because all the fans left.",
            "What's a vampire's favorite fruit? A neck-tarine.",
            "I don't trust stairs. They're always up to something.",
            "Why did the scarecrow get a promotion? He was outstanding in his field.",
            "What's brown and sticky? A stick.",
            "Why are pirates called pirates? Because they arrrr!",
            "I was wondering why the frisbee was getting bigger. Then it hit me.",
            "What do you call a lazy kangaroo? Pouch potato.",
            "Why was the math book sad? Because it had too many problems.",
            "What did the grape do when it got stepped on? It let out a little wine.",
            "Why don’t eggs tell jokes? They’d crack each other up.",
            "What’s the best way to watch a fly-fishing tournament? Live stream.",
            "What did the janitor say when he jumped out of the closet? 'Supplies!'",
            "I'm reading a horror story in Braille. Something bad is about to happen... I can feel it.",
            "What do you call an alligator in a vest? An investigator.",
            "If you see a robbery at an Apple Store, does that make you an iWitness?",
            "What do you call a sad strawberry? A blueberry.",
            "Why should you never trust a pig with a secret? Because it's bound to squeal.",
            "I got a new job as a human cannonball. They told me I'd be fired.",
            "Why did the Oreo go to the dentist? Because it lost its filling.",
            "How do you organize a space party? You planet.",
            "What has four wheels and flies? A garbage truck.",
            "What do you call a thieving alligator? A crook-o-dile.",
            "I used to be a baker, but I couldn't make enough dough.",
            "I have a fear of speed bumps. I'm slowly getting over it.",
            "Where do you learn to make ice cream? At sundae school.",
            "Why do bees have sticky hair? Because they use a honeycomb.",
            "How do you make a tissue dance? You put a little boogie in it.",
            "Why can’t a bicycle stand up by itself? It's two tired.",
            "Why did the tomato turn red? Because it saw the salad dressing!",
            "What do you call a pony with a cough? A little hoarse.",
            "Why was the belt arrested? For holding up a pair of pants.",
            "How do you find Will Smith in the snow? You look for the fresh prints.",
            "What do you call a man with a rubber toe? Roberto.",
            "Why is it annoying to eat next to basketball players? They're always dribbling.",
            "What do you call a factory that makes okay products? A satisfactory.",
            "I'm terrified of elevators. I'm going to start taking steps to avoid them.",
            "What do you call a dog that does magic tricks? A labracadabrador.",
            "What did the drummer call his twin daughters? Anna one, Anna two!",
            "Why did the cow go to outer space? To see the moooon.",
            "What do you call a sleeping bull? A bulldozer.",
            "Why did the can crusher quit his job? It was soda pressing.",
            "Why did the man get fired from the calendar factory? He took a couple of days off.",
            "What's the difference between a hippo and a zippo? One is really heavy, the other is a little lighter.",
            "I was going to tell a time-traveling joke, but you guys didn't like it.",
            "What do you get from a pampered cow? Spoiled milk.",
            "Why did the octopus beat the shark in a fight? Because it was well-armed.",
            "Why was the baby strawberry crying? Because its parents were in a jam.",
        ]

    @app_commands.command(name="joke", description="Tells a random joke.")
    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)
    async def joke(self, interaction: discord.Interaction):
        embed = discord.Embed(
            title="Here's a joke for you!", description=random.choice(self.jokes), color=discord.Color.orange()
        )
        await interaction.response.send_message(embed=embed)

    @app_commands.command(name="fact", description="Get a random interesting fact.")
    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)
    async def fact(self, interaction: discord.Interaction):
        await interaction.response.defer()
        try:
            async with self.session.get("https://uselessfacts.jsph.pl/random.json?language=en") as response:
                if response.status == 200:
                    data = await response.json()
                    embed = discord.Embed(
                        title="Did you know?", description=data.get("text"), color=discord.Color.cyan()
                    )
                    await interaction.followup.send(embed=embed)
                else:
                    await interaction.followup.send(
                        "Could not fetch a fact right now, try again later.", ephemeral=True
                    )
        except Exception:
            await interaction.followup.send("An error occurred while trying to get a fact.", ephemeral=True)

    @app_commands.command(name="avatar", description="Displays a user's avatar.")
    @app_commands.describe(member="The member whose avatar you want to see.")
    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)
    async def avatar(self, interaction: discord.Interaction, member: discord.Member = None):
        target_member = member or interaction.user
        embed = discord.Embed(title=f"{target_member.name}'s Avatar", color=target_member.color)
        embed.set_image(url=target_member.display_avatar.url)
        await interaction.response.send_message(embed=embed)

    @app_commands.command(name="love", description="Calculates the love compatibility between two members.")
    @app_commands.describe(member1="The first person.", member2="The second person.")
    @app_commands.checks.cooldown(1, 10, key=lambda i: i.user.id)
    async def love(self, interaction: discord.Interaction, member1: discord.Member, member2: discord.Member = None):
        target_member2 = member2 or interaction.user
        random.seed(member1.id + target_member2.id)
        love_percentage = random.randint(0, 100)
        random.seed()

        emoji = "💔"
        if 40 <= love_percentage < 75:
            emoji = "❤️"
        elif love_percentage >= 75:
            emoji = "💖"

        embed = discord.Embed(title="Love Calculator", color=discord.Color.red())
        embed.description = f"**{member1.name}** + **{target_member2.name}** = **{love_percentage}%** {emoji}"
        await interaction.response.send_message(embed=embed)

    @app_commands.command(name="emojify", description="Converts your text into emojis.")
    @app_commands.describe(text="The text to convert.")
    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)
    async def emojify(self, interaction: discord.Interaction, text: str):
        if len(text) > 50:
            await interaction.response.send_message(
                "Text is too long! Please keep it under 50 characters.", ephemeral=True
            )
            return
        emojis = ""
        for char in text.lower():
            if char.isalpha():
                emojis += f":regional_indicator_{char}: "
            elif char.isdigit():
                num_map = {
                    "0": "zero",
                    "1": "one",
                    "2": "two",
                    "3": "three",
                    "4": "four",
                    "5": "five",
                    "6": "six",
                    "7": "seven",
                    "8": "eight",
                    "9": "nine",
                }
                emojis += f":{num_map[char]}: "
            else:
                emojis += " "

        if emojis.strip():
            await interaction.response.send_message(emojis)
        else:
            await interaction.response.send_message("Could not emojify the text.", ephemeral=True)

    @app_commands.command(name="poll", description="Creates a poll with up to 10 options.")
    @app_commands.describe(
        question="The question for the poll.",
        option1="The first choice.",
        option2="The second choice.",
        option3="Optional third choice.",
        option4="Optional fourth choice.",
        option5="Optional fifth choice.",
        option6="Optional sixth choice.",
        option7="Optional seventh choice.",
        option8="Optional eighth choice.",
        option9="Optional ninth choice.",
        option10="Optional tenth choice.",
    )
    @app_commands.checks.cooldown(1, 15, key=lambda i: i.channel_id)
    async def poll(
        self,
        interaction: discord.Interaction,
        question: str,
        option1: str,
        option2: str,
        option3: str = None,
        option4: str = None,
        option5: str = None,
        option6: str = None,
        option7: str = None,
        option8: str = None,
        option9: str = None,
        option10: str = None,
    ):
        # Check Add Reactions permission before creating the poll
        bot_member = interaction.guild.me if interaction.guild else None
        if bot_member and interaction.channel:
            perms = interaction.channel.permissions_for(bot_member)
            if not perms.add_reactions:
                await interaction.response.send_message(
                    "I need the **Add Reactions** permission in this channel to create polls.",
                    ephemeral=True,
                )
                return

        options = [
            opt
            for opt in [option1, option2, option3, option4, option5, option6, option7, option8, option9, option10]
            if opt is not None
        ]
        reactions = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟"]
        description = [f"{reactions[i]} {option}" for i, option in enumerate(options)]
        embed = discord.Embed(title=question, description="\n".join(description), color=discord.Color.blurple())
        embed.set_footer(text=f"Poll created by {interaction.user.name}")
        await interaction.response.send_message(embed=embed)
        poll_message = await interaction.original_response()
        for i in range(len(options)):
            await poll_message.add_reaction(reactions[i])

    @app_commands.command(name="clap", description="Adds a clap emoji between each word.")
    @app_commands.describe(text="The text to clapify.")
    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)
    async def clap(self, interaction: discord.Interaction, text: str):
        if len(text.split()) < 2:
            await interaction.response.send_message("You need at least two words to clap!", ephemeral=True)
            return
        clapped_text = " 👏 ".join(text.split())
        await interaction.response.send_message(clapped_text)

    @app_commands.command(name="tweet", description="Generate an image of a fake tweet.")
    @app_commands.describe(text="The content of the tweet (max 280 chars).")
    @app_commands.checks.cooldown(1, 15, key=lambda i: i.user.id)
    async def tweet(self, interaction: discord.Interaction, text: str):
        await interaction.response.defer()

        if len(text) > 280:
            await interaction.followup.send("Tweet text cannot exceed 280 characters.", ephemeral=True)
            return

        user = interaction.user
        bg = Image.new("RGB", (1000, 400), color=(21, 32, 43))

        try:
            async with self.session.get(user.display_avatar.url) as response:
                if response.status == 200:
                    avatar_data = await response.read()
                    avatar = Image.open(io.BytesIO(avatar_data)).convert("RGBA")
                    avatar = avatar.resize((150, 150))

                    mask = Image.new("L", avatar.size, 0)
                    draw_mask = ImageDraw.Draw(mask)
                    draw_mask.ellipse((0, 0) + avatar.size, fill=255)
                    bg.paste(avatar, (50, 50), mask)
        except Exception as e:
            print(f"Tweet avatar error: {e}")

        draw = ImageDraw.Draw(bg)
        try:
            font_bold = ImageFont.truetype("arialbd.ttf", 60)
            font_regular = ImageFont.truetype("arial.ttf", 50)
            font_handle = ImageFont.truetype("arial.ttf", 45)
        except IOError:
            print("Arial font not found, using default font for tweet command.")
            font_bold = ImageFont.load_default(size=60)
            font_regular = ImageFont.load_default(size=50)
            font_handle = ImageFont.load_default(size=45)

        draw.text((230, 60), user.display_name, font=font_bold, fill=(255, 255, 255))
        draw.text((230, 125), f"@{user.name}", font=font_handle, fill=(136, 153, 166))

        wrapped_text = textwrap.fill(text, width=35)
        draw.text((50, 230), wrapped_text, font=font_regular, fill=(255, 255, 255))

        buffer = io.BytesIO()
        bg.save(buffer, format="PNG")
        buffer.seek(0)

        await interaction.followup.send(file=discord.File(buffer, "tweet.png"))


async def setup(bot: commands.Bot):
    await bot.add_cog(Fun(bot))


================================================
FILE: cogs/games.py
================================================
import discord
from discord import app_commands
from discord.ext import commands
import random
import asyncio


class TicTacToeButton(discord.ui.Button["TicTacToe"]):
    def __init__(self, x: int, y: int):
        super().__init__(style=discord.ButtonStyle.secondary, label="\u200b", row=y)
        self.x = x
        self.y = y

    async def callback(self, interaction: discord.Interaction):
        assert self.view is not None
        view: TicTacToe = self.view

        if interaction.user != view.current_player:
            await interaction.response.send_message("It's not your turn!", ephemeral=True)
            return

        state = view.board[self.y][self.x]
        if state in (view.X, view.O):
            return

        if view.current_player == view.player1:
            self.style = discord.ButtonStyle.danger
            self.label = "X"
            view.board[self.y][self.x] = view.X
            view.current_player = view.player2
            content = f"It's {view.player2.name}'s turn (O)."
        else:
            self.style = discord.ButtonStyle.success
            self.label = "O"
            view.board[self.y][self.x] = view.O
            view.current_player = view.player1
            content = f"It's {view.player1.name}'s turn (X)."

        self.disabled = True
        winner = view.check_board_winner()
        if winner is not None:
            if winner == view.X:
                content = f"🏆 {view.player1.name} wins! 🏆"
            elif winner == view.O:
                content = f"🏆 {view.player2.name} wins! 🏆"
            else:
                content = "It's a tie!"

            for child in view.children:
                child.disabled = True

            view.stop()

        await interaction.response.edit_message(content=content, view=view)


class TicTacToe(discord.ui.View):
    X = -1
    O = 1
    Tie = 2

    def __init__(self, player1: discord.Member, player2: discord.Member):
        super().__init__(timeout=180)
        self.player1 = player1
        self.player2 = player2
        self.current_player = player1
        self.board = [
            [0, 0, 0],
            [0, 0, 0],
            [0, 0, 0],
        ]

        for x in range(3):
            for y in range(3):
                self.add_item(TicTacToeButton(x, y))

    async def on_timeout(self):
        for item in self.children:
            item.disabled = True

        message = await self.message.edit(content="Game timed out! No one made a move.", view=self)

    def check_board_winner(self):
        for i in range(3):
            if sum(self.board[i]) == 3:
                return self.O
            if sum(self.board[i]) == -3:
                return self.X

        for i in range(3):
            if self.board[0][i] + self.board[1][i] + self.board[2][i] == 3:
                return self.O
            if self.board[0][i] + self.board[1][i] + self.board[2][i] == -3:
                return self.X

        if self.board[0][0] + self.board[1][1] + self.board[2][2] == 3:
            return self.O
        if self.board[0][0] + self.board[1][1] + self.board[2][2] == -3:
            return self.X
        if self.board[0][2] + self.board[1][1] + self.board[2][0] == 3:
            return self.O
        if self.board[0][2] + self.board[1][1] + self.board[2][0] == -3:
            return self.X

        if all(i != 0 for row in self.board for i in row):
            return self.Tie

        return None


class Games(commands.Cog):
    def __init__(self, bot: commands.Bot):
        self.bot = bot
        self.active_guess_games = set()

    @app_commands.command(name="eightball", description="Ask the magic 8-ball a question.")
    @app_commands.describe(question="The question you want to ask.")
    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)
    async def eightball(self, interaction: discord.Interaction, question: str):
        responses = [
            "It is certain.",
            "It is decidedly so.",
            "Without a doubt.",
            "Yes – definitely.",
            "You may rely on it.",
            "As I see it, yes.",
            "Most likely.",
            "Outlook good.",
            "Yes.",
            "Signs point to yes.",
            "Reply hazy, try again.",
            "Ask again later.",
            "Better not tell you now.",
            "Cannot predict now.",
            "Concentrate and ask again.",
            "Don't count on it.",
            "My reply is no.",
            "My sources say no.",
            "Outlook not so good.",
            "Very doubtful.",
        ]
        embed = discord.Embed(title="🎱 Magic 8-Ball 🎱", color=discord.Color.dark_blue())
        embed.add_field(name="Question", value=question, inline=False)
        embed.add_field(name="Answer", value=random.choice(responses), inline=False)
        await interaction.response.send_message(embed=embed)

    @app_commands.command(name="coinflip", description="Flips a coin.")
    @app_commands.checks.cooldown(1, 3, key=lambda i: i.user.id)
    async def coinflip(self, interaction: discord.Interaction):
        result = random.choice(["Heads", "Tails"])
        await interaction.response.send_message(f"The coin landed on: **{result}**")

    @app_commands.command(name="roll", description="Rolls a dice in NdN format (e.g., 2d6).")
    @app_commands.describe(dice="The dice to roll (e.g., 1d6, 2d8).")
    @app_commands.checks.cooldown(1, 3, key=lambda i: i.user.id)
    async def roll(self, interaction: discord.Interaction, dice: str):
        try:
            rolls, limit = map(int, dice.lower().split("d"))
        except Exception:
            await interaction.response.send_message("Format has to be in NdN (e.g., 1d6)!", ephemeral=True)
            return

        if not (1 <= rolls <= 100 and 1 <= limit <= 1000):
            await interaction.response.send_message(
                "Please keep rolls between 1-100 and faces between 1-1000.", ephemeral=True
            )
            return

        results = [random.randint(1, limit) for _ in range(rolls)]
        total = sum(results)
        embed = discord.Embed(title=f"Dice Roll: {dice}", description=f"Total: **{total}**", color=discord.Color.red())
        embed.add_field(name="Individual Rolls", value=", ".join(map(str, results)), inline=False)
        await interaction.response.send_message(embed=embed)

    @app_commands.command(name="guess", description="Starts a 'guess the number' game.")
    @app_commands.checks.cooldown(1, 60, key=lambda i: i.channel_id)
    async def guess(self, interaction: discord.Interaction):
        if interaction.channel.id in self.active_guess_games:
            await interaction.response.send_message("A game is already active in this channel!", ephemeral=True)
            return

        self.active_guess_games.add(interaction.channel.id)
        number_to_guess = random.randint(1, 100)
        attempts = 0

        await interaction.response.send_message(
            f"I've picked a number between 1 and 100. You have 60 seconds to guess it!"
        )

        def check(m):
            return m.channel == interaction.channel and m.author == interaction.user and m.content.isdigit()

        try:
            while True:
                guess_msg = await self.bot.wait_for("message", timeout=60.0, check=check)
                guess = int(guess_msg.content)
                attempts += 1
                if guess < number_to_guess:
                    await guess_msg.reply("Too low! Try again.", delete_after=5)
                elif guess > number_to_guess:
                    await guess_msg.reply("Too high! Try again.", delete_after=5)
                else:
                    await guess_msg.reply(
                        f"🎉 You got it! The number was **{number_to_guess}**. It took you {attempts} attempts."
                    )
                    self.active_guess_games.remove(interaction.channel.id)
                    return
        except asyncio.TimeoutError:
            if interaction.channel.id in self.active_guess_games:
                await interaction.followup.send(f"Time's up! The number was {number_to_guess}.")
                self.active_guess_games.remove(interaction.channel.id)

    @app_commands.command(name="rps", description="Play Rock, Paper, Scissors with the bot.")
    @app_commands.describe(choice="Your choice.")
    @app_commands.choices(
        choice=[
            app_commands.Choice(name="Rock", value="rock"),
            app_commands.Choice(name="Paper", value="paper"),
            app_commands.Choice(name="Scissors", value="scissors"),
        ]
    )
    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)
    async def rockpaperscissors(self, interaction: discord.Interaction, choice: app_commands.Choice[str]):
        user_choice = choice.value
        bot_choice = random.choice(["rock", "paper", "scissors"])
        emoji_map = {"rock": "🗿", "paper": "📄", "scissors": "✂️"}
        result_text = f"You chose {emoji_map[user_choice]}, I chose {emoji_map[bot_choice]}.\n\n"

        if user_choice == bot_choice:
            result_text += "**It's a tie!**"
        elif (
            (user_choice == "rock" and bot_choice == "scissors")
            or (user_choice == "paper" and bot_choice == "rock")
            or (user_choice == "scissors" and bot_choice == "paper")
        ):
            result_text += "**You win!** 🎉"
        else:
            result_text += "**I win!** 🤖"

        await interaction.response.send_message(result_text)

    @app_commands.command(name="tictactoe", description="Play a game of Tic-Tac-Toe with another member.")
    @app_commands.describe(opponent="The member you want to challenge.")
    @app_commands.checks.cooldown(1, 30, key=lambda i: i.channel_id)
    async def tictactoe(self, interaction: discord.Interaction, opponent: discord.Member):
        if opponent == interaction.user:
            await interaction.response.send_message("You can't play against yourself!", ephemeral=True)
            return
        if opponent.bot:
            await interaction.response.send_message(
                "You can't challenge a bot to a game of Tic-Tac-Toe!", ephemeral=True
            )
            return

        view = TicTacToe(interaction.user, opponent)
        await interaction.response.send_message(
            f"Tic-Tac-Toe: {interaction.user.name} vs {opponent.name}\nIt's {interaction.user.name}'s turn (X).",
            view=view,
        )
        view.message = await interaction.original_response()


async def setup(bot: commands.Bot):
    await bot.add_cog(Games(bot))


================================================
FILE: cogs/interactions.py
================================================
import discord
from discord import app_commands
from discord.ext import commands


class Interactions(commands.Cog):
    def __init__(self, bot: commands.Bot):
        self.bot = bot
        self.session = bot.http_session

    async def get_gif(self, category: str):
        try:
            async with self.session.get(f"https://api.waifu.pics/sfw/{category}") as response:
                if response.status == 200:
                    data = await response.json()
                    return data.get("url")
        except Exception as e:
            print(f"Could not fetch GIF for category {category}: {e}")
            return None

    async def create_interaction_embed(
        self,
        interaction: discord.Interaction,
        member: discord.Member,
        category: str,
        self_message: str,
        other_message: str,
    ):
        await interaction.response.defer()
        gif_url = await self.get_gif(category)

        if not gif_url:
            await interaction.followup.send(
                f"Sorry, couldn't get a GIF right now. But... {other_message.format(user=interaction.user.mention, target=member.mention)}",
                ephemeral=True,
            )
            return

        message = (
            self_message.format(user=interaction.user.mention, target=member.mention)
            if member == interaction.user
            else other_message.format(user=interaction.user.mention, target=member.mention)
        )

        embed = discord.Embed(description=message, color=discord.Color.from_rgb(255, 182, 193))  # Pink
        embed.set_image(url=gif_url)

        await interaction.followup.send(embed=embed)

    @app_commands.command(name="hug", description="Give someone a hug.")
    @app_commands.describe(member="The person you want to hug.")
    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)
    async def hug(self, interaction: discord.Interaction, member: discord.Member):
        await self.create_interaction_embed(
            interaction,
            member,
            category="hug",
            self_message="You can't hug yourself, but I can! Here's a hug from me to you.",
            other_message="{user} gives {target} a big, warm hug!",
        )

    @app_commands.command(name="pat", description="Pat someone's head.")
    @app_commands.describe(member="The person you want to pat.")
    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)
    async def pat(self, interaction: discord.Interaction, member: discord.Member):
        await self.create_interaction_embed(
            interaction,
            member,
            category="pat",
            self_message="Feeling a bit lonely? I'll pat your head for you. *pats*",
            other_message="{user} gently pats {target}'s head. Aww.",
        )

    @app_commands.command(name="slap", description="Slap someone.")
    @app_commands.describe(member="The person you want to slap.")
    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)
    async def slap(self, interaction: discord.Interaction, member: discord.Member):
        if member == self.bot.user:
            await interaction.response.send_message(f"Ouch! What did I do to deserve that, {interaction.user.mention}?")
            return

        await self.create_interaction_embed(
            interaction,
            member,
            category="slap",
            self_message="{user} slaps themself in confusion!",
            other_message="Oof! {user} slaps {target} right across the face!",
        )

    @app_commands.command(name="kiss", description="Give someone a kiss.")
    @app_commands.describe(member="The person you want to kiss.")
    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)
    async def kiss(self, interaction: discord.Interaction, member: discord.Member):
        await self.create_interaction_embed(
            interaction,
            member,
            category="kiss",
            self_message="Blowing a kiss to yourself in the mirror, nice!",
            other_message="{user} gives {target} a sweet kiss. Mwah!",
        )

    @app_commands.command(name="cuddle", description="Cuddle with someone.")
    @app_commands.describe(member="The person you want to cuddle with.")
    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)
    async def cuddle(self, interaction: discord.Interaction, member: discord.Member):
        await self.create_interaction_embed(
            interaction,
            member,
            category="cuddle",
            self_message="Cuddling with a pillow is nice, but here's a virtual one!",
            other_message="{user} snuggles up and cuddles with {target}. So cozy!",
        )

    @app_commands.command(name="poke", description="Poke someone.")
    @app_commands.describe(member="The person you want to poke.")
    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)
    async def poke(self, interaction: discord.Interaction, member: discord.Member):
        await self.create_interaction_embed(
            interaction,
            member,
            category="poke",
            self_message="You poke yourself. Why?",
            other_message="Hey! {user} just poked {target}.",
        )


async def setup(bot: commands.Bot):
    await bot.add_cog(Interactions(bot))


================================================
FILE: cogs/media.py
================================================
import logging

import discord
from discord import app_commands
from discord.ext import commands

logger = logging.getLogger(__name__)


class Media(commands.Cog):
    def __init__(self, bot: commands.Bot):
        self.bot = bot
        self.session = bot.http_session

    @app_commands.command(name="meme", description="Fetches a random meme.")
    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)
    async def meme(self, interaction: discord.Interaction):
        await interaction.response.defer()
        try:
            async with self.session.get("https://meme-api.com/gimme") as response:
                if response.status == 200:
                    data = await response.json()
                    embed = discord.Embed(title=data["title"], url=data["postLink"], color=discord.Color.blue())
                    embed.set_image(url=data["url"])
                    embed.set_footer(text=f"From r/{data['subreddit']} | Upvotes: {data['ups']}")
                    await interaction.followup.send(embed=embed)
                else:
                    await interaction.followup.send("Could not fetch a meme, please try again.")
        except Exception:
            logger.exception("Error fetching meme")
            await interaction.followup.send("An error occurred while fetching a meme. Please try again later.", ephemeral=True)

    @app_commands.command(name="cat", description="Fetches a random cat picture.")
    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)
    async def cat(self, interaction: discord.Interaction):
        await interaction.response.defer()
        try:
            async with self.session.get("https://api.thecatapi.com/v1/images/search") as response:
                if response.status == 200:
                    data = await response.json()
                    embed = discord.Embed(title="Here's a random cat!", color=discord.Color.purple())
                    embed.set_image(url=data[0]["url"])
                    await interaction.followup.send(embed=embed)
                else:
                    await interaction.followup.send("Could not fetch a cat picture, the cats are hiding!")
        except Exception:
            logger.exception("Error fetching cat picture")
            await interaction.followup.send("An error occurred while fetching a cat picture. Please try again later.", ephemeral=True)

    @app_commands.command(name="dog", description="Fetches a random dog picture.")
    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)
    async def dog(self, interaction: discord.Interaction):
        await interaction.response.defer()
        try:
            async with self.session.get("https://dog.ceo/api/breeds/image/random") as response:
                if response.status == 200:
                    data = await response.json()
                    embed = discord.Embed(title="Here's a random dog!", color=discord.Color.gold())
                    embed.set_image(url=data["message"])
                    await interaction.followup.send(embed=embed)
                else:
                    await interaction.followup.send("Could not fetch a dog picture, the dogs are playing fetch!")
        except Exception:
            logger.exception("Error fetching dog picture")
            await interaction.followup.send("An error occurred while fetching a dog picture. Please try again later.", ephemeral=True)


async def setup(bot: commands.Bot):
    await bot.add_cog(Media(bot))


================================================
FILE: cogs/moderation.py
================================================
import json
import re
from datetime import timedelta
from typing import Dict, List, Optional

import discord
from discord import app_commands
from discord.ext import commands


INVITE_RE = re.compile(r"(discord\.gg/|discord\.com/invite/)", re.IGNORECASE)
LINK_RE = re.compile(r"https?://", re.IGNORECASE)


class Moderation(commands.Cog):
    def __init__(self, bot: commands.Bot):
        self.bot = bot
        self.bot.loop.create_task(self.setup_database())

    async def setup_database(self):
        async with self.bot.db.cursor() as cursor:
            await cursor.execute(
                """
                CREATE TABLE IF NOT EXISTS automod_settings (
                    guild_id INTEGER PRIMARY KEY,
                    filter_invites INTEGER NOT NULL DEFAULT 0,
                    filter_links INTEGER NOT NULL DEFAULT 0,
                    bad_words TEXT,
                    whitelist_channel_ids TEXT,
                    action TEXT NOT NULL DEFAULT 'delete'
                )
                """
            )
            await cursor.execute(
                """
                CREATE TABLE IF NOT EXISTS moderation_warnings (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    guild_id INTEGER NOT NULL,
                    user_id INTEGER NOT NULL,
                    moderator_id INTEGER NOT NULL,
                    reason TEXT NOT NULL,
                    created_at TEXT NOT NULL
                )
                """
            )
            await cursor.execute(
                """
                CREATE INDEX IF NOT EXISTS idx_moderation_warnings_guild_user_created_at
                ON moderation_warnings(guild_id, user_id, created_at DESC)
                """
            )
        await self.bot.db.commit()

    def _serialize_ids(self, ids: List[int]) -> Optional[str]:
        cleaned = sorted({int(item) for item in ids})
        return json.dumps(cleaned) if cleaned else None

    def _deserialize_ids(self, raw: Optional[str]) -> List[int]:
        if not raw:
            return []
        try:
            values = json.loads(raw)
        except json.JSONDecodeError:
            return []
        return [int(item) for item in values if str(item).isdigit()]

    async def get_automod_settings(self, guild_id: int) -> Dict[str, object]:
        async with self.bot.db.cursor() as cursor:
            await cursor.execute(
                """
                SELECT filter_invites, filter_links, bad_words, whitelist_channel_ids, action
                FROM automod_settings
                WHERE guild_id = ?
                """,
                (guild_id,),
            )
            row = await cursor.fetchone()

        if row is None:
            return {
                "filter_invites": False,
                "filter_links": False,
                "bad_words": [],
                "whitelist_channel_ids": [],
                "action": "delete",
            }

        filter_invites, filter_links, bad_words, whitelist_channel_ids, action = row
        return {
            "filter_invites": bool(filter_invites),
            "filter_links": bool(filter_links),
            "bad_words": [word for word in (bad_words or "").split(",") if word],
            "whitelist_channel_ids": self._deserialize_ids(whitelist_channel_ids),
            "action": action or "delete",
        }

    async def update_automod(self, guild_id: int, **fields):
        async with self.bot.db.cursor() as cursor:
            await cursor.execute("INSERT OR IGNORE INTO automod_settings (guild_id) VALUES (?)", (guild_id,))
            for field, value in fields.items():
                await cursor.execute(f"UPDATE automod_settings SET {field} = ? WHERE guild_id = ?", (value, guild_id))
        await self.bot.db.commit()

    async def mutate_whitelist(self, guild_id: int, channel_id: int, add: bool) -> List[int]:
        settings = await self.get_automod_settings(guild_id)
        current = set(settings["whitelist_channel_ids"])
        if add:
            current.add(channel_id)
        else:
            current.discard(channel_id)
        await self.update_automod(guild_id, whitelist_channel_ids=self._serialize_ids(list(current)))
        return sorted(current)

    async def add_warning(self, guild_id: int, user_id: int, moderator_id: int, reason: str) -> int:
        async with self.bot.db.cursor() as cursor:
            await cursor.execute(
                """
                INSERT INTO moderation_warnings (guild_id, user_id, moderator_id, reason, created_at)
                VALUES (?, ?, ?, ?, ?)
                """,
                (guild_id, user_id, moderator_id, reason, discord.utils.utcnow().isoformat()),
            )
            warning_id = cursor.lastrowid
        await self.bot.db.commit()
        return warning_id

    async def log_action(self, guild: discord.Guild, title: str, description: str, color: discord.Color):
        community = self.bot.get_cog("Community")
        if community:
            await community._log_to_modlog(guild, title, description, color)

    automod = app_commands.Group(name="automod", description="Configure basic automod rules.", guild_only=True)

    @automod.command(name="view", description="View the current automod settings.")
    @app_commands.checks.has_permissions(manage_guild=True)
    async def automod_view(self, interaction: discord.Interaction):
        settings = await self.get_automod_settings(interaction.guild_id)
        whitelist = ", ".join(f"<#{channel_id}>" for channel_id in settings["whitelist_channel_ids"]) or "Not set"
        bad_words = ", ".join(f"`{word}`" for word in settings["bad_words"]) or "Not set"
        embed = discord.Embed(title="Automod Settings", color=discord.Color.orange())
        embed.add_field(name="Invite Filter", value="On" if settings["filter_invites"] else "Off", inline=True)
        embed.add_field(name="Link Filter", value="On" if settings["filter_links"] else "Off", inline=True)
        embed.add_field(name="Action", value=settings["action"], inline=True)
        embed.add_field(name="Bad Words", value=bad_words, inline=False)
        embed.add_field(name="Whitelisted Channels", value=whitelist, inline=False)
        await interaction.response.send_message(embed=embed, ephemeral=True)

    @automod.command(name="toggle-invites", description="Turn invite filtering on or off.")
    @app_commands.checks.has_permissions(manage_guild=True)
    async def automod_toggle_invites(self, interaction: discord.Interaction, enabled: bool):
        await self.update_automod(interaction.guild_id, filter_invites=int(enabled))
        await interaction.response.send_message(f"Invite filtering is now {'on' if enabled else 'off'}.", ephemeral=True)

    @automod.command(name="toggle-links", description="Turn link filtering on or off.")
    @app_commands.checks.has_permissions(manage_guild=True)
    async def automod_toggle_links(self, interaction: discord.Interaction, enabled: bool):
        await self.update_automod(interaction.guild_id, filter_links=int(enabled))
        await interaction.response.send_message(f"Link filtering is now {'on' if enabled else 'off'}.", ephemeral=True)

    @automod.command(name="set-action", description="Choose what automod should do when it triggers.")
    @app_commands.checks.has_permissions(manage_guild=True)
    @app_commands.choices(
        action=[
            app_commands.Choice(name="Delete", value="delete"),
            app_commands.Choice(name="Warn", value="warn"),
            app_commands.Choice(name="Timeout", value="timeout"),
        ]
    )
    async def automod_set_action(self, interaction: discord.Interaction, action: app_commands.Choice[str]):
        await self.update_automod(interaction.guild_id, action=action.value)
        await interaction.response.send_message(f"Automod action set to `{action.value}`.", ephemeral=True)

    @automod.command(name="set-bad-words", description="Set a comma-separated list of blocked words.")
    @app_commands.checks.has_permissions(manage_guild=True)
    async def automod_set_bad_words(self, interaction: discord.Interaction, words: str):
        normalized = ",".join(sorted({word.strip().lower() for word in words.split(",") if word.strip()}))
        await self.update_automod(interaction.guild_id, bad_words=normalized or None)
        count = 0 if not normalized else len(normalized.split(","))
        await interaction.response.send_message(f"Stored {count} blocked word(s).", ephemeral=True)

    @automod.command(name="clear-bad-words", description="Clear all blocked words.")
    @app_commands.checks.has_permissions(manage_guild=True)
    async def automod_clear_bad_words(self, interaction: discord.Interaction):
        await self.update_automod(interaction.guild_id, bad_words=None)
        await interaction.response.send_message("Blocked words cleared.", ephemeral=True)

    @automod.command(name="whitelist-channel", description="Exclude a channel from automod checks.")
    @app_commands.checks.has_permissions(manage_guild=True)
    async def automod_whitelist_channel(self, interaction: discord.Interaction, channel: discord.TextChannel):
        channels = await self.mutate_whitelist(interaction.guild_id, channel.id, add=True)
        await interaction.response.send_message(
            f"Automod whitelist updated. {len(channels)} channel(s) are exempt now.",
            ephemeral=True,
        )

    @automod.command(name="remove-whitelist-channel", description="Remove a channel from the automod whitelist.")
    @app_commands.checks.has_permissions(manage_guild=True)
    async def automod_remove_whitelist_channel(self, interaction: discord.Interaction, channel: discord.TextChannel):
        channels = await self.mutate_whitelist(interaction.guild_id, channel.id, add=False)
        await interaction.response.send_message(
            f"Automod whitelist updated. {len(channels)} channel(s) remain exempt.",
            ephemeral=True,
        )

    @app_commands.command(name="warn", description="Warn a member and log it.")
    @app_commands.guild_only()
    @app_commands.checks.has_permissions(manage_messages=True)
    async def warn(self, interaction: discord.Interaction, member: discord.Member, reason: str):
        warning_id = await self.add_warning(interaction.guild_id, member.id, interaction.user.id, reason)
        await self.log_action(
            interaction.guild,
            "Member Warned",
            f"{member.mention} was warned by {interaction.user.mention}.\nReason: {reason}\nWarning ID: `{warning_id}`",
            discord.Color.orange(),
        )
        await interaction.response.send_message(
            f"Warning `{warning_id}` recorded for {member.mention}.",
            ephemeral=True,
        )

    @app_commands.command(name="warnings", description="View warning history for a member.")
    @app_commands.guild_only()
    @app_commands.checks.has_permissions(manage_messages=True)
    async def warnings(self, interaction: discord.Interaction, member: discord.Member):
        async with self.bot.db.cursor() as cursor:
            await cursor.execute(
                """
                SELECT id, moderator_id, reason, created_at
                FROM moderation_warnings
                WHERE guild_id = ? AND user_id = ?
                ORDER BY created_at DESC
                LIMIT 10
                """,
                (interaction.guild_id, member.id),
            )
            rows = await cursor.fetchall()

        if not rows:
            await interaction.response.send_message(f"{member.mention} has no warnings in this server.", ephemeral=True)
            return

        lines = []
        for warning_id, moderator_id, reason, created_at in rows:
            moderator = interaction.guild.get_member(moderator_id)
            moderator_label = moderator.mention if moderator else f"`{moderator_id}`"
            lines.append(
                f"`{warning_id}` • {discord.utils.format_dt(discord.utils.parse_time(created_at), style='R')} • {moderator_label}\n{reason}"
            )

        embed = discord.Embed(title=f"Warnings for {member}", description="\n\n".join(lines), color=discord.Color.orange())
        await interaction.response.send_message(embed=embed, ephemeral=True)

    @app_commands.command(name="clear-warning", description="Delete a single warning by id.")
    @app_commands.guild_only()
    @app_commands.checks.has_permissions(manage_messages=True)
    async def clear_warning(self, interaction: discord.Interaction, warning_id: int):
        async with self.bot.db.cursor() as cursor:
            await cursor.execute(
                "DELETE FROM moderation_warnings WHERE id = ? AND guild_id = ?",
                (warning_id, interaction.guild_id),
            )
            deleted = cursor.rowcount
        await self.bot.db.commit()
        if not deleted:
            await interaction.response.send_message("Warning not found.", ephemeral=True)
            return

        await self.log_action(
            interaction.guild,
            "Warning Cleared",
            f"{interaction.user.mention} removed warning `{warning_id}`.",
            discord.Color.green(),
        )
        await interaction.response.send_message(f"Warning `{warning_id}` removed.", ephemeral=True)

    async def handle_violation(self, message: discord.Message, reason: str, action: str):
        deleted = False
        try:
            await message.delete()
            deleted = True
        except (discord.Forbidden, discord.HTTPException):
            deleted = False

        details = [f"User: {message.author.mention}", f"Channel: {message.channel.mention}", f"Reason: {reason}"]
        if not deleted:
            details.append("Action skipped because the original message could not be deleted")
            await self.log_action(message.guild, "Automod Triggered", "\n".join(details), discord.Color.red())
            return

        if action == "warn":
            warning_id = await self.add_warning(message.guild.id, message.author.id, self.bot.user.id, f"Automod: {reason}")
            details.append(f"Warning ID: `{warning_id}`")
            try:
                await message.channel.send(
                    f"{message.author.mention}, your message was removed by automod: {reason}",
                    delete_after=10,
                )
            except (discord.Forbidden, discord.HTTPException):
                pass
        elif action == "timeout" and isinstance(message.author, discord.Member):
            try:
                await message.author.timeout(discord.utils.utcnow() + timedelta(minutes=10), reason=f"Automod: {reason}")
                details.append("Action: timeout")
            except (discord.Forbidden, discord.HTTPException):
                details.append("Action: timeout failed, message removed only")
                try:
                    await message.channel.send(
                        f"{message.author.mention}, automod tried to time you out but lacked permission. Message removed.",
                        delete_after=10,
                    )
                except (discord.Forbidden, discord.HTTPException):
                    pass

        await self.log_action(message.guild, "Automod Triggered", "\n".join(details), discord.Color.red())

    @commands.Cog.listener("on_message")
    async def automod_listener(self, message: discord.Message):
        if message.author.bot or message.guild is None:
            return
        if not isinstance(message.author, discord.Member):
            return
        if not isinstance(message.channel, (discord.TextChannel, discord.Thread)):
            return
        if message.author.guild_permissions.manage_messages:
            return

        settings = await self.get_automod_settings(message.guild.id)
        whitelisted = settings["whitelist_channel_ids"]
        parent_id = getattr(message.channel, "parent_id", None)
        if message.channel.id in whitelisted or (parent_id and parent_id in whitelisted):
            return

        lowered = message.content.lower()
        violation = None
        if settings["filter_invites"] and INVITE_RE.search(message.content):
            violation = "Discord invite links are not allowed."
        elif settings["filter_links"] and LINK_RE.search(message.content):
            violation = "Links are not allowed."
        else:
            for word in settings["bad_words"]:
                if not word:
                    continue
                pattern = re.compile(rf"(?<!\w){re.escape(word)}(?!\w)", re.IGNORECASE)
                if pattern.search(lowered):
                    violation = f"Blocked word detected: `{word}`"
                    break

        if violation:
            await self.handle_violation(message, violation, settings["action"])


async def setup(bot: commands.Bot):
    await bot.add_cog(Moderation(bot))


================================================
FILE: cogs/utility.py
================================================
import platform
import re
import sys
import time
from datetime import timedelta
from typing import Optional, Union

import discord
from discord import app_commands
from discord.ext import commands, tasks


REMINDER_LIMIT_SECONDS = 30 * 24 * 60 * 60
REMINDER_POLL_SECONDS = 30
REMINDER_BATCH_SIZE = 20
MAX_REMINDER_DELIVERY_FAILURES = 5
MAX_REMINDER_TEXT_LENGTH = 1500
REMINDER_RETRY_BASE_SECONDS = 300
REMINDER_RETRY_MAX_SECONDS = 21600


def parse_duration_spec(value: str) -> Optional[int]:
    match = re.fullmatch(r"(\d+)([smhd])", value.lower())
    if not match:
        return None
    quantity, unit = match.groups()
    return int(quantity) * {"s": 1, "m": 60, "h": 3600, "d": 86400}[unit]


class Utility(commands.Cog):
    def __init__(self, bot: commands.Bot):
        self.bot = bot
        self.afk_cache: dict[tuple[int, int], tuple[str, str]] = {}
        if not hasattr(bot, "start_time"):
            self.bot.start_time = discord.utils.utcnow()
        self.reminder_loop.start()

    def cog_unload(self):
        self.reminder_loop.cancel()

    async def setup_database(self):
        async with self.bot.db.cursor() as cursor:
            await cursor.execute(
                """
                CREATE TABLE IF NOT EXISTS reminders (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    user_id INTEGER NOT NULL,
                    channel_id INTEGER,
                    guild_id INTEGER,
                    remind_at TEXT NOT NULL,
                    reason TEXT NOT NULL,
                    recurring_seconds INTEGER,
                    delivery_failures INTEGER NOT NULL DEFAULT 0
                )
                """
            )
            await cursor.execute(
                """
                CREATE TABLE IF NOT EXISTS afk_statuses (
                    guild_id INTEGER NOT NULL,
                    user_id INTEGER NOT NULL,
                    reason TEXT NOT NULL,
                    set_at TEXT NOT NULL,
                    PRIMARY KEY (guild_id, user_id)
                )
                """
            )
            await cursor.execute("PRAGMA table_info(reminders)")
            reminder_columns = [row[1] for row in await cursor.fetchall()]
            if "recurring_seconds" not in reminder_columns:
                await cursor.execute("ALTER TABLE reminders ADD COLUMN recurring_seconds INTEGER")
            if "delivery_failures" not in reminder_columns:
                await cursor.execute("ALTER TABLE reminders ADD COLUMN delivery_failures INTEGER NOT NULL DEFAULT 0")
            if "disabled" not in reminder_columns:
                await cursor.execute("ALTER TABLE reminders ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0")
            if "last_error" not in reminder_columns:
                await cursor.execute("ALTER TABLE reminders ADD COLUMN last_error TEXT")
            await cursor.execute(
                "CREATE INDEX IF NOT EXISTS idx_reminders_due ON reminders(disabled, remind_at)"
            )
            await cursor.execute(
                "CREATE INDEX IF NOT EXISTS idx_reminders_user_remind_at ON reminders(user_id, remind_at)"
            )
        await self.bot.db.commit()

    async def get_afk_status(self, guild_id: int, user_id: int):
        cache_key = (guild_id, user_id)
        if cache_key in self.afk_cache:
            return self.afk_cache[cache_key]
        async with self.bot.db.cursor() as cursor:
            await cursor.execute(
                "SELECT reason, set_at FROM afk_statuses WHERE guild_id = ? AND user_id = ?",
                (guild_id, user_id),
            )
            row = await cursor.fetchone()
        if row:
            self.afk_cache[cache_key] = row
        return row

    async def get_many_afk_statuses(self, guild_id: int, user_ids: list[int]) -> dict[int, tuple[str, str]]:
        statuses: dict[int, tuple[str, str]] = {}
        missing_ids = []
        for user_id in user_ids:
            cache_key = (guild_id, user_id)
            cached = self.afk_cache.get(cache_key)
            if cached:
                statuses[user_id] = cached
            else:
                missing_ids.append(user_id)

        if not missing_ids:
            return statuses

        placeholders = ",".join("?" for _ in missing_ids)
        async with self.bot.db.cursor() as cursor:
            await cursor.execute(
                f"""
                SELECT user_id, reason, set_at
                FROM afk_statuses
                WHERE guild_id = ? AND user_id IN ({placeholders})
                """,
                [guild_id, *missing_ids],
            )
            rows = await cursor.fetchall()

        for user_id, reason, set_at in rows:
            status = (reason, set_at)
            self.afk_cache[(guild_id, user_id)] = status
            statuses[user_id] = status

        return statuses

    async def set_afk_status(self, guild_id: int, user_id: int, reason: str):
        set_at = discord.utils.utcnow().isoformat()
        async with self.bot.db.cursor() as cursor:
            await cursor.execute(
                """
                INSERT INTO afk_statuses (guild_id, user_id, reason, set_at)
                VALUES (?, ?, ?, ?)
                ON CONFLICT(guild_id, user_id)
                DO UPDATE SET reason = excluded.reason, set_at = excluded.set_at
                """,
                (guild_id, user_id, reason, set_at),
            )
        await self.bot.db.commit()
        self.afk_cache[(guild_id, user_id)] = (reason, set_at)

    async def clear_afk_status(self, guild_id: int, user_id: int):
        async with self.bot.db.cursor() as cursor:
            await cursor.execute(
                "DELETE FROM afk_statuses WHERE guild_id = ? AND user_id = ?",
                (guild_id, user_id),
            )
        await self.bot.db.commit()
        self.afk_cache.pop((guild_id, user_id), None)

    async def list_user_reminders(self, user_id: int):
        async with self.bot.db.cursor() as cursor:
            await cursor.execute(
                """
                SELECT id, guild_id, channel_id, remind_at, reason, recurring_seconds, delivery_failures, disabled, last_error
                FROM reminders
                WHERE user_id = ?
                ORDER BY remind_at ASC
                """,
                (user_id,),
            )
            return await cursor.fetchall()

    def reminder_retry_delay(self, failures: int) -> timedelta:
        seconds = min(REMINDER_RETRY_BASE_SECONDS * (2 ** max(failures - 1, 0)), REMINDER_RETRY_MAX_SECONDS)
        return timedelta(seconds=seconds)

    async def create_reminder(
        self,
        user_id: int,
        channel_id: Optional[int],
        guild_id: Optional[int],
        remind_at,
        reason: str,
        recurring_seconds: Optional[int] = None,
    ) -> int:
        async with self.bot.db.cursor() as cursor:
            await cursor.execute(
                """
                INSERT INTO reminders (user_id, channel_id, guild_id, remind_at, reason, recurring_seconds)
                VALUES (?, ?, ?, ?, ?, ?)
                """,
                (user_id, channel_id, guild_id, remind_at.isoformat(), reason, recurring_seconds),
            )
            reminder_id = cursor.lastrowid
        await self.bot.db.commit()
        return reminder_id

    help_group = app_commands.Group(name="help", description="Get help with the bot's commands.")
    reminders_group = app_commands.Group(name="reminders", description="Manage your active reminders.")

    @app_commands.command(name="ping", description="Checks the bot's latency.")
    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)
    async def ping(self, interaction: discord.Interaction):
        latency = self.bot.latency * 1000
        await interaction.response.send_message(f"Pong! Latency: {latency:.2f}ms")

    @app_commands.command(name="ping_raw", description="Checks the bot's detailed latency (Websocket & REST).")
    @app_commands.checks.cooldown(1, 10, key=lambda i: i.user.id)
    async def ping_raw(self, interaction: discord.Interaction):
        ws_latency = self.bot.latency * 1000
        rest_latency = "N/A"

        session = getattr(self.bot, "http_session", None)
        if session and not session.closed:
            started = time.perf_counter()
            try:
                async with session.get("https://discord.com/api/v10/gateway") as response:
                    if 200 <= response.status < 500:
                        rest_latency = f"{(time.perf_counter() - started) * 1000:.2f}"
            except Exception:
                rest_latency = "N/A"

        embed = discord.Embed(title="🏓 Pong!", color=discord.Color.blue())
        embed.add_field(name="WebSocket Latency", value=f"{ws_latency:.2f}ms", inline=False)
        embed.add_field(name="REST API Latency", value=f"{rest_latency}ms", inline=False)

        await interaction.response.send_message(embed=embed)

    @app_commands.command(name="memberinfo", description="Displays information about a member.")
    @app_commands.guild_only()
    @app_commands.describe(member="The member to get info about.")
    @app_commands.checks.cooldown(1, 10, key=lambda i: i.user.id)
    async def memberinfo(self, interaction: discord.Interaction, member: discord.Member = None):
        target_member = member or interaction.user

        embed = discord.Embed(color=target_member.color, timestamp=discord.utils.utcnow())
        embed.set_author(name=f"User Info - {target_member}")
        if target_member.display_avatar:
            embed.set_thumbnail(url=target_member.display_avatar.url)
        embed.set_footer(
            text=f"Requested by {interaction.user.name}",
            icon_url=interaction.user.avatar.url if interaction.user.avatar else None,
        )

        embed.add_field(name="ID", value=target_member.id, inline=False)
        embed.add_field(name="Display Name", value=target_member.display_name, inline=True)
        embed.add_field(name="Bot?", value=target_member.bot, inline=True)
        embed.add_field(name="Created At", value=discord.utils.format_dt(target_member.created_at, style="F"), inline=False)

        if target_member.joined_at:
            embed.add_field(name="Joined At", value=discord.utils.format_dt(target_member.joined_at, style="F"), inline=False)

        roles = [role.mention for role in reversed(target_member.roles[1:])]
        roles_str = ", ".join(roles) if roles else "No roles"
        embed.add_field(
            name=f"Roles [{len(roles)}]",
            value=roles_str if len(roles_str) < 1024 else f"{len(roles)} roles (too many to display)",
            inline=False,
        )

        await interaction.response.send_message(embed=embed)

    @app_commands.command(name="clear", description="Deletes a specified number of messages.")
    @app_commands.guild_only()
    @app_commands.describe(amount="The number of messages to delete (1-100).")
    @app_commands.checks.has_permissions(manage_messages=True)
    async def clear(self, interaction: discord.Interaction, amount: app_commands.Range[int, 1, 100]):
        me = interaction.guild.me or interaction.guild.get_member(self.bot.user.id)
        if me is None or not interaction.channel.permissions_for(me).manage_messages:
            await interaction.response.send_message(
                "I don't have permission to manage messages in this channel.", ephemeral=True
            )
            return

        await interaction.response.defer(ephemeral=True)
        deleted = await interaction.channel.purge(limit=amount)
        await interaction.followup.send(f"Successfully deleted {len(deleted)} messages.", ephemeral=True)

    @app_commands.command(name="serverinfo", description="Displays detailed information about the server.")
    @app_commands.guild_only()
    @app_commands.checks.cooldown(1, 10, key=lambda i: i.guild_id)
    async def serverinfo(self, interaction: discord.Interaction):
        guild = interaction.guild
        embed = discord.Embed(
            title=f"Server Info: {guild.name}", color=discord.Color.blue(), timestamp=discord.utils.utcnow()
        )

        if guild.icon:
            embed.set_thumbnail(url=guild.icon.url)

        owner_text = guild.owner.mention if guild.owner else f"ID: {guild.owner_id}"
        embed.add_field(name="Owner", value=owner_text, inline=True)
        embed.add_field(name="ID", value=guild.id, inline=True)
        embed.add_field(name="Created At", value=discord.utils.format_dt(guild.created_at, "F"), inline=False)

        total_members = guild.member_count if guild.member_count is not None else len(guild.members)
        bots = sum(1 for member in guild.members if member.bot) if guild.members else 0
        humans = total_members - bots
        embed.add_field(name="Members", value=f"Total: {total_members}\nHumans: {humans}\nBots: {bots}", inline=True)

        channels_total = len(guild.text_channels) + len(guild.voice_channels)
        channels_total += len(guild.stage_channels) if hasattr(guild, "stage_channels") else 0
        channels_total += len(guild.forum_channels) if hasattr(guild, "forum_channels") else 0
        embed.add_field(
            name="Channels",
            value=(
                f"Total: {channels_total}\nText: {len(guild.text_channels)}\nVoice: {len(guild.voice_channels)}\n"
                f"Stage: {len(guild.stage_channels) if hasattr(guild, 'stage_channels') else 0}\n"
                f"Forum: {len(guild.forum_channels) if hasattr(guild, 'forum_channels') else 0}"
            ),
            inline=True,
        )
        embed.add_field(name="Roles", value=len(guild.roles), inline=True)

        if guild.features:
            embed.add_field(
                name="Features",
                value=", ".join(feature.replace("_", " ").title() for feature in guild.features),
                inline=False,
            )

        embed.set_footer(
            text=f"Requested by {interaction.user.name}",
            icon_url=interaction.user.avatar.url if interaction.user.avatar else None,
        )
        await interaction.response.send_message(embed=embed)

    @app_commands.command(name="botinfo", description="Displays information and stats about the bot.")
    @app_commands.checks.cooldown(1, 10, key=lambda i: i.user.id)
    async def botinfo(self, interaction: discord.Interaction):
        delta_uptime = discord.utils.utcnow() - self.bot.start_time
        days = delta_uptime.days
        hours, remainder = divmod(int(delta_uptime.total_seconds() % 86400), 3600)
        minutes, seconds = divmod(remainder, 60)
        uptime_str = f"{days}d {hours}h {minutes}m {seconds}s"

        embed = discord.Embed(
            title=f"{self.bot.user.name} Stats", color=discord.Color.purple(), timestamp=discord.utils.utcnow()
        )
        if self.bot.user.avatar:
            embed.set_thumbnail(url=self.bot.user.avatar.url)

        embed.add_field(name="Developer", value="Sentinel Team", inline=True)
        embed.add_field(name="Servers", value=str(len(self.bot.guilds)), inline=True)
        embed.add_field(name="Total Users", value=str(len(self.bot.users)), inline=True)
        embed.add_field(name="Python Version", value=sys.version.split(" ")[0], inline=True)
        embed.add_field(name="discord.py Version", value=discord.__version__, inline=True)
        embed.add_field(name="Uptime", value=uptime_str, inline=True)

        await interaction.response.send_message(embed=embed)

    @app_commands.command(name="uptime", description="Shows how long the bot has been online, and server info.")
    @app_commands.checks.cooldown(1, 5, key=lambda i: i.user.id)
    async def uptime(self, interaction: discord.Interaction):
        delta_uptime = discord.utils.utcnow() - self.bot.start_time
        days = delta_uptime.days
        hours, remainder = divmod(int(delta_uptime.total_seconds() % 86400), 3600)
        minutes, seconds = divmod(remainder, 60)
        uptime_str = f"{days}d {hours}h {minutes}m {seconds}s"

        embed = discord.Embed(title="Bot Uptime & System Info", color=discord.Color.teal())
        embed.add_field(name="Bot Uptime", value=f"**{uptime_str}**", inline=False)
        embed.add_field(
            name="Operating System",
            value=f"{platform.system()} {platform.release()} ({platform.version()})",
            inline=False,
        )
        embed.add_field(name="Architecture", value=platform.machine(), inline=False)

        await interaction.response.send_message(embed=embed)

    def _iter_commands(self):
        stack = list(self.bot.tree.get_commands())
        while stack:
            command = stack.pop(0)
            yield command
            if isinstance(command, app_commands.Group):
                stack[0:0] = command.commands

    def _get_full_command_name(self, command: Union[app_commands.Command, app_commands.Group]) -> str:
        parts = [command.name]
        parent = command.parent
        while parent is not None:
            parts.append(parent.name)
            parent = parent.parent
        return " ".join(reversed(parts))

    def _command_support_label(self, command: Union[app_commands.Command, app_commands.Group]) -> str:
        checks = getattr(command, "checks", [])
        if any(getattr(check, "__qualname__", "").endswith("guild_only.<locals>.predicate") for check in checks):
            return "Servers only"
        return "Servers and DMs"

    def _command_permission_label(self, command: Union[app_commands.Command, app_commands.Group]) -> Optional[str]:
        checks = getattr(command, "checks", [])
        for check in checks:
            qualname = get
Download .txt
gitextract_ia4j90bs/

├── .env.example
├── .flake8
├── .github/
│   ├── ISSUE_TEMPLATE/
│   │   ├── bug_report.yml
│   │   └── feature_request.yml
│   ├── pull_request_template.md
│   └── workflows/
│       ├── ci.yml
│       └── release.yml
├── .gitignore
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── SECURITY.md
├── SUPPORT.md
├── cogs/
│   ├── chat.py
│   ├── community.py
│   ├── economy.py
│   ├── farming.py
│   ├── fun.py
│   ├── games.py
│   ├── interactions.py
│   ├── media.py
│   ├── moderation.py
│   └── utility.py
├── config.example.json
├── config_loader.py
├── docs/
│   ├── commands.md
│   ├── configuration.md
│   ├── deployment.md
│   ├── faq.md
│   └── operations.md
├── install.bat
├── install.sh
├── main.py
├── requirements-dev.txt
├── requirements.txt
├── site/
│   ├── 404.html
│   ├── _headers
│   ├── index.html
│   ├── script.js
│   └── styles.css
└── tests/
    ├── conftest.py
    ├── test_config_loader.py
    ├── test_duration_parsing.py
    ├── test_issue_fixes.py
    └── test_retry_delays.py
Download .txt
SYMBOL INDEX (273 symbols across 16 files)

FILE: cogs/chat.py
  class Chat (line 29) | class Chat(commands.Cog):
    method __init__ (line 30) | def __init__(self, bot: commands.Bot):
    method cog_unload (line 39) | def cog_unload(self):
    method load_config (line 42) | def load_config(self):
    method _prune_cooldowns (line 54) | async def _prune_cooldowns(self):
    method setup_database (line 60) | async def setup_database(self):
    method _table_columns (line 100) | async def _table_columns(self, cursor, table_name: str) -> List[str]:
    method session (line 105) | def session(self):
    method _context_key (line 108) | def _context_key(self, interaction: discord.Interaction) -> Tuple[Any,...
    method _serialize_ids (line 113) | def _serialize_ids(self, ids: List[int]) -> Optional[str]:
    method _deserialize_ids (line 117) | def _deserialize_ids(self, raw: Optional[str]) -> List[int]:
    method get_guild_config (line 126) | async def get_guild_config(self, guild_id: Optional[int]):
    method set_guild_key (line 133) | async def set_guild_key(self, guild_id: int, key: Optional[str] = None):
    method set_guild_persona (line 139) | async def set_guild_persona(self, guild_id: int, persona: Optional[str...
    method get_policy (line 145) | async def get_policy(self, guild_id: Optional[int]) -> Dict[str, Any]:
    method update_policy (line 184) | async def update_policy(self, guild_id: int, **fields):
    method mutate_id_list (line 191) | async def mutate_id_list(self, guild_id: int, field: str, value: int, ...
    method get_usage_count (line 201) | async def get_usage_count(self, guild_id: int) -> int:
    method increment_usage (line 211) | async def increment_usage(self, guild_id: int):
    method validate_api_key (line 225) | async def validate_api_key(self, api_key: str) -> Tuple[bool, str]:
    method enforce_policy (line 241) | async def enforce_policy(self, interaction: discord.Interaction, polic...
    method define_tools (line 275) | def define_tools(self):
    method execute_google_search (line 291) | async def execute_google_search(self, query: str):
    method model_autocomplete (line 308) | async def model_autocomplete(self, interaction: discord.Interaction, c...
    method _channel_labels (line 316) | def _channel_labels(self, guild: discord.Guild, ids: List[int]) -> str:
    method _role_labels (line 325) | def _role_labels(self, guild: discord.Guild, ids: List[int]) -> str:
    method _truncate_field_value (line 334) | def _truncate_field_value(self, value: str, limit: int = MAX_EMBED_FIE...
    method _trim_history (line 339) | def _trim_history(self, messages: List[Dict[str, Any]]) -> List[Dict[s...
    method _prune_runtime_state (line 344) | def _prune_runtime_state(self) -> None:
    method _set_conversation_state (line 359) | def _set_conversation_state(self, context_key: Tuple[Any, ...], messag...
    method set_key (line 376) | async def set_key(self, interaction: discord.Interaction, key: str):
    method set_persona (line 395) | async def set_persona(self, interaction: discord.Interaction, persona:...
    method set_enabled (line 408) | async def set_enabled(self, interaction: discord.Interaction, enabled:...
    method set_cooldown (line 416) | async def set_cooldown(self, interaction: discord.Interaction, seconds...
    method set_usage_cap (line 423) | async def set_usage_cap(self, interaction: discord.Interaction, limit:...
    method allow_channel (line 431) | async def allow_channel(self, interaction: discord.Interaction, channe...
    method block_channel (line 440) | async def block_channel(self, interaction: discord.Interaction, channe...
    method clear_channel_rules (line 449) | async def clear_channel_rules(self, interaction: discord.Interaction):
    method allow_role (line 455) | async def allow_role(self, interaction: discord.Interaction, role: dis...
    method remove_role (line 464) | async def remove_role(self, interaction: discord.Interaction, role: di...
    method clear_role_rules (line 473) | async def clear_role_rules(self, interaction: discord.Interaction):
    method view_config (line 479) | async def view_config(self, interaction: discord.Interaction):
    method test_config (line 532) | async def test_config(self, interaction: discord.Interaction):
    method list_models (line 549) | async def list_models(self, interaction: discord.Interaction):
    method chat (line 566) | async def chat(self, interaction: discord.Interaction, prompt: str, mo...
    method chat_reset (line 680) | async def chat_reset(self, interaction: discord.Interaction):
  function setup (line 687) | async def setup(bot: commands.Bot):

FILE: cogs/community.py
  function parse_duration (line 21) | def parse_duration(value: str) -> Optional[int]:
  class Community (line 31) | class Community(commands.Cog):
    method __init__ (line 32) | def __init__(self, bot: commands.Bot):
    method cog_unload (line 36) | def cog_unload(self):
    method setup_database (line 39) | async def setup_database(self):
    method get_settings (line 87) | async def get_settings(self, guild_id: int) -> Dict[str, Optional[int]]:
    method update_setting (line 122) | async def update_setting(self, guild_id: int, field: str, value):
    method validate_template (line 128) | def validate_template(self, template: str) -> Optional[str]:
    method render_template (line 141) | def render_template(self, template: Optional[str], member: discord.abc...
    method try_render_template (line 145) | def try_render_template(
    method schedule_retry_delay (line 153) | def schedule_retry_delay(self, failures: int) -> timedelta:
    method _resolve_channel (line 157) | async def _resolve_channel(self, channel_id: Optional[int]):
    method _send_to_channel (line 168) | async def _send_to_channel(
    method _log_to_modlog (line 187) | async def _log_to_modlog(self, guild: discord.Guild, title: str, descr...
    method _channel_value (line 196) | def _channel_value(self, channel_id: Optional[int]) -> str:
    method _schedule_announcement (line 199) | async def _schedule_announcement(
    method view (line 233) | async def view(self, interaction: discord.Interaction):
    method set_welcome_channel (line 258) | async def set_welcome_channel(self, interaction: discord.Interaction, ...
    method set_goodbye_channel (line 264) | async def set_goodbye_channel(self, interaction: discord.Interaction, ...
    method set_announcement_channel (line 270) | async def set_announcement_channel(self, interaction: discord.Interact...
    method set_modlog_channel (line 276) | async def set_modlog_channel(self, interaction: discord.Interaction, c...
    method set_welcome_message (line 283) | async def set_welcome_message(self, interaction: discord.Interaction, ...
    method set_goodbye_message (line 296) | async def set_goodbye_message(self, interaction: discord.Interaction, ...
    method preview_welcome (line 308) | async def preview_welcome(self, interaction: discord.Interaction):
    method preview_goodbye (line 323) | async def preview_goodbye(self, interaction: discord.Interaction):
    method reset_message (line 344) | async def reset_message(self, interaction: discord.Interaction, target...
    method reset_channel (line 358) | async def reset_channel(self, interaction: discord.Interaction, target...
    method announce (line 366) | async def announce(self, interaction: discord.Interaction, message: str):
    method schedule (line 415) | async def schedule(
    method list_scheduled (line 463) | async def list_scheduled(self, interaction: discord.Interaction):
    method diagnose_scheduled (line 496) | async def diagnose_scheduled(self, interaction: discord.Interaction, a...
    method cancel_scheduled (line 532) | async def cancel_scheduled(self, interaction: discord.Interaction, ann...
    method schedule_loop (line 546) | async def schedule_loop(self):
    method before_schedule_loop (line 629) | async def before_schedule_loop(self):
    method on_member_join (line 634) | async def on_member_join(self, member: discord.Member):
    method on_member_remove (line 656) | async def on_member_remove(self, member: discord.Member):
    method on_message_delete (line 678) | async def on_message_delete(self, message: discord.Message):
  function setup (line 696) | async def setup(bot: commands.Bot):

FILE: cogs/economy.py
  class Economy (line 34) | class Economy(commands.Cog):
    method __init__ (line 35) | def __init__(self, bot: commands.Bot):
    method _guild_lock (line 40) | def _guild_lock(self, guild_id: int) -> asyncio.Lock:
    method setup_database (line 47) | async def setup_database(self):
    method _get_guild_id (line 91) | def _get_guild_id(self, interaction: discord.Interaction) -> int:
    method _get_or_create_user_unlocked (line 96) | async def _get_or_create_user_unlocked(self, guild_id: int, user_id: i...
    method get_or_create_user (line 112) | async def get_or_create_user(self, guild_id: int, user_id: int) -> int:
    method change_balance (line 116) | async def change_balance(self, guild_id: int, user_id: int, delta: int...
    method transfer_balance (line 131) | async def transfer_balance(self, guild_id: int, sender_id: int, receiv...
    method set_balance (line 150) | async def set_balance(self, guild_id: int, user_id: int, amount: int) ...
    method balance (line 168) | async def balance(self, interaction: discord.Interaction, member: disc...
    method daily (line 179) | async def daily(self, interaction: discord.Interaction):
    method daily_error (line 192) | async def daily_error(self, interaction: discord.Interaction, error: a...
    method jobs_freelance (line 204) | async def jobs_freelance(self, interaction: discord.Interaction):
    method freelance_error (line 217) | async def freelance_error(self, interaction: discord.Interaction, erro...
    method jobs_regular (line 226) | async def jobs_regular(self, interaction: discord.Interaction):
    method regular_work_error (line 239) | async def regular_work_error(self, interaction: discord.Interaction, e...
    method jobs_crime (line 248) | async def jobs_crime(self, interaction: discord.Interaction):
    method crime_error (line 266) | async def crime_error(self, interaction: discord.Interaction, error: a...
    method gamble (line 277) | async def gamble(self, interaction: discord.Interaction, amount: app_c...
    method leaderboard (line 301) | async def leaderboard(self, interaction: discord.Interaction):
    method transfer (line 337) | async def transfer(
    method rob (line 366) | async def rob(self, interaction: discord.Interaction, member: discord....
    method rob_error (line 418) | async def rob_error(self, interaction: discord.Interaction, error: app...
    method slots (line 429) | async def slots(self, interaction: discord.Interaction, bet: app_comma...
    method admin_add (line 465) | async def admin_add(self, interaction: discord.Interaction, member: di...
    method admin_remove (line 476) | async def admin_remove(
    method admin_set (line 491) | async def admin_set(self, interaction: discord.Interaction, member: di...
    method admin_reset_guild (line 501) | async def admin_reset_guild(self, interaction: discord.Interaction):
  function setup (line 510) | async def setup(bot: commands.Bot):

FILE: cogs/farming.py
  class Farming (line 20) | class Farming(commands.Cog):
    method __init__ (line 21) | def __init__(self, bot: commands.Bot):
    method setup_database (line 38) | async def setup_database(self):
    method get_farm_data (line 96) | async def get_farm_data(self, guild_id: int, user_id: int):
    method get_xp_for_next_level (line 109) | def get_xp_for_next_level(self, level: int):
    method profile (line 115) | async def profile(self, interaction: discord.Interaction):
    method shop (line 152) | async def shop(self, interaction: discord.Interaction):
    method plant (line 172) | async def plant(self, interaction: discord.Interaction, crop: str):
    method harvest (line 228) | async def harvest(self, interaction: discord.Interaction):
    method upgrade (line 300) | async def upgrade(self, interaction: discord.Interaction):
  function setup (line 343) | async def setup(bot: commands.Bot):

FILE: cogs/fun.py
  class Fun (line 10) | class Fun(commands.Cog):
    method __init__ (line 11) | def __init__(self, bot: commands.Bot):
    method joke (line 103) | async def joke(self, interaction: discord.Interaction):
    method fact (line 111) | async def fact(self, interaction: discord.Interaction):
    method avatar (line 131) | async def avatar(self, interaction: discord.Interaction, member: disco...
    method love (line 140) | async def love(self, interaction: discord.Interaction, member1: discor...
    method emojify (line 159) | async def emojify(self, interaction: discord.Interaction, text: str):
    method poll (line 206) | async def poll(
    method clap (line 249) | async def clap(self, interaction: discord.Interaction, text: str):
    method tweet (line 259) | async def tweet(self, interaction: discord.Interaction, text: str):
  function setup (line 307) | async def setup(bot: commands.Bot):

FILE: cogs/games.py
  class TicTacToeButton (line 8) | class TicTacToeButton(discord.ui.Button["TicTacToe"]):
    method __init__ (line 9) | def __init__(self, x: int, y: int):
    method callback (line 14) | async def callback(self, interaction: discord.Interaction):
  class TicTacToe (line 57) | class TicTacToe(discord.ui.View):
    method __init__ (line 62) | def __init__(self, player1: discord.Member, player2: discord.Member):
    method on_timeout (line 77) | async def on_timeout(self):
    method check_board_winner (line 83) | def check_board_winner(self):
  class Games (line 111) | class Games(commands.Cog):
    method __init__ (line 112) | def __init__(self, bot: commands.Bot):
    method eightball (line 119) | async def eightball(self, interaction: discord.Interaction, question: ...
    method coinflip (line 149) | async def coinflip(self, interaction: discord.Interaction):
    method roll (line 156) | async def roll(self, interaction: discord.Interaction, dice: str):
    method guess (line 177) | async def guess(self, interaction: discord.Interaction):
    method rockpaperscissors (line 223) | async def rockpaperscissors(self, interaction: discord.Interaction, ch...
    method tictactoe (line 245) | async def tictactoe(self, interaction: discord.Interaction, opponent: ...
  function setup (line 263) | async def setup(bot: commands.Bot):

FILE: cogs/interactions.py
  class Interactions (line 6) | class Interactions(commands.Cog):
    method __init__ (line 7) | def __init__(self, bot: commands.Bot):
    method get_gif (line 11) | async def get_gif(self, category: str):
    method create_interaction_embed (line 21) | async def create_interaction_embed(
    method hug (line 53) | async def hug(self, interaction: discord.Interaction, member: discord....
    method pat (line 65) | async def pat(self, interaction: discord.Interaction, member: discord....
    method slap (line 77) | async def slap(self, interaction: discord.Interaction, member: discord...
    method kiss (line 93) | async def kiss(self, interaction: discord.Interaction, member: discord...
    method cuddle (line 105) | async def cuddle(self, interaction: discord.Interaction, member: disco...
    method poke (line 117) | async def poke(self, interaction: discord.Interaction, member: discord...
  function setup (line 127) | async def setup(bot: commands.Bot):

FILE: cogs/media.py
  class Media (line 10) | class Media(commands.Cog):
    method __init__ (line 11) | def __init__(self, bot: commands.Bot):
    method meme (line 17) | async def meme(self, interaction: discord.Interaction):
    method cat (line 35) | async def cat(self, interaction: discord.Interaction):
    method dog (line 52) | async def dog(self, interaction: discord.Interaction):
  function setup (line 68) | async def setup(bot: commands.Bot):

FILE: cogs/moderation.py
  class Moderation (line 15) | class Moderation(commands.Cog):
    method __init__ (line 16) | def __init__(self, bot: commands.Bot):
    method setup_database (line 20) | async def setup_database(self):
    method _serialize_ids (line 54) | def _serialize_ids(self, ids: List[int]) -> Optional[str]:
    method _deserialize_ids (line 58) | def _deserialize_ids(self, raw: Optional[str]) -> List[int]:
    method get_automod_settings (line 67) | async def get_automod_settings(self, guild_id: int) -> Dict[str, object]:
    method update_automod (line 97) | async def update_automod(self, guild_id: int, **fields):
    method mutate_whitelist (line 104) | async def mutate_whitelist(self, guild_id: int, channel_id: int, add: ...
    method add_warning (line 114) | async def add_warning(self, guild_id: int, user_id: int, moderator_id:...
    method log_action (line 127) | async def log_action(self, guild: discord.Guild, title: str, descripti...
    method automod_view (line 136) | async def automod_view(self, interaction: discord.Interaction):
    method automod_toggle_invites (line 150) | async def automod_toggle_invites(self, interaction: discord.Interactio...
    method automod_toggle_links (line 156) | async def automod_toggle_links(self, interaction: discord.Interaction,...
    method automod_set_action (line 169) | async def automod_set_action(self, interaction: discord.Interaction, a...
    method automod_set_bad_words (line 175) | async def automod_set_bad_words(self, interaction: discord.Interaction...
    method automod_clear_bad_words (line 183) | async def automod_clear_bad_words(self, interaction: discord.Interacti...
    method automod_whitelist_channel (line 189) | async def automod_whitelist_channel(self, interaction: discord.Interac...
    method automod_remove_whitelist_channel (line 198) | async def automod_remove_whitelist_channel(self, interaction: discord....
    method warn (line 208) | async def warn(self, interaction: discord.Interaction, member: discord...
    method warnings (line 224) | async def warnings(self, interaction: discord.Interaction, member: dis...
    method clear_warning (line 256) | async def clear_warning(self, interaction: discord.Interaction, warnin...
    method handle_violation (line 276) | async def handle_violation(self, message: discord.Message, reason: str...
    method automod_listener (line 317) | async def automod_listener(self, message: discord.Message):
  function setup (line 352) | async def setup(bot: commands.Bot):

FILE: cogs/utility.py
  function parse_duration_spec (line 22) | def parse_duration_spec(value: str) -> Optional[int]:
  class Utility (line 30) | class Utility(commands.Cog):
    method __init__ (line 31) | def __init__(self, bot: commands.Bot):
    method cog_unload (line 38) | def cog_unload(self):
    method setup_database (line 41) | async def setup_database(self):
    method get_afk_status (line 86) | async def get_afk_status(self, guild_id: int, user_id: int):
    method get_many_afk_statuses (line 100) | async def get_many_afk_statuses(self, guild_id: int, user_ids: list[in...
    method set_afk_status (line 133) | async def set_afk_status(self, guild_id: int, user_id: int, reason: str):
    method clear_afk_status (line 148) | async def clear_afk_status(self, guild_id: int, user_id: int):
    method list_user_reminders (line 157) | async def list_user_reminders(self, user_id: int):
    method reminder_retry_delay (line 170) | def reminder_retry_delay(self, failures: int) -> timedelta:
    method create_reminder (line 174) | async def create_reminder(
    method ping (line 200) | async def ping(self, interaction: discord.Interaction):
    method ping_raw (line 206) | async def ping_raw(self, interaction: discord.Interaction):
    method memberinfo (line 230) | async def memberinfo(self, interaction: discord.Interaction, member: d...
    method clear (line 264) | async def clear(self, interaction: discord.Interaction, amount: app_co...
    method serverinfo (line 279) | async def serverinfo(self, interaction: discord.Interaction):
    method botinfo (line 327) | async def botinfo(self, interaction: discord.Interaction):
    method uptime (line 351) | async def uptime(self, interaction: discord.Interaction):
    method _iter_commands (line 369) | def _iter_commands(self):
    method _get_full_command_name (line 377) | def _get_full_command_name(self, command: Union[app_commands.Command, ...
    method _command_support_label (line 385) | def _command_support_label(self, command: Union[app_commands.Command, ...
    method _command_permission_label (line 391) | def _command_permission_label(self, command: Union[app_commands.Comman...
    method command_autocomplete (line 402) | async def command_autocomplete(
    method help_all (line 413) | async def help_all(self, interaction: discord.Interaction):
    method _iter_group_commands (line 459) | def _iter_group_commands(self, group: app_commands.Group):
    method help_command (line 470) | async def help_command(self, interaction: discord.Interaction, command...
    method remindme (line 504) | async def remindme(self, interaction: discord.Interaction, time: str, ...
    method reminders_recurring (line 540) | async def reminders_recurring(self, interaction: discord.Interaction, ...
    method reminders_list (line 574) | async def reminders_list(self, interaction: discord.Interaction):
    method reminders_cancel (line 601) | async def reminders_cancel(self, interaction: discord.Interaction, rem...
    method reminders_snooze (line 617) | async def reminders_snooze(self, interaction: discord.Interaction, rem...
    method reminders_clear (line 646) | async def reminders_clear(self, interaction: discord.Interaction):
    method afk (line 656) | async def afk(self, interaction: discord.Interaction, reason: str = "A...
    method afk_clear (line 662) | async def afk_clear(self, interaction: discord.Interaction):
    method afk_message_listener (line 667) | async def afk_message_listener(self, message: discord.Message):
    method reminder_loop (line 708) | async def reminder_loop(self):
    method before_reminder_loop (line 805) | async def before_reminder_loop(self):
  function setup (line 810) | async def setup(bot: commands.Bot):

FILE: config_loader.py
  function _load_file_config (line 15) | def _load_file_config() -> dict[str, Any]:
  function _get_bool (line 23) | def _get_bool(name: str, default: bool, file_config: dict[str, Any]) -> ...
  function _get_list (line 37) | def _get_list(name: str, default: list[str], file_config: dict[str, Any]...
  function load_runtime_config (line 51) | def load_runtime_config() -> dict[str, Any]:

FILE: main.py
  function _find_local_venv_python (line 11) | def _find_local_venv_python() -> Optional[Path]:
  function _maybe_reexec_into_local_venv (line 20) | def _maybe_reexec_into_local_venv() -> None:
  class FunBot (line 79) | class FunBot(commands.Bot):
    method __init__ (line 80) | def __init__(self, runtime_config: dict):
    method setup_hook (line 90) | async def setup_hook(self):
    method on_ready (line 134) | async def on_ready(self):
    method _flush_message_logs (line 139) | async def _flush_message_logs(self, batch: list[tuple[int, int, int, s...
    method _message_log_worker (line 149) | async def _message_log_worker(self) -> None:
    method on_message (line 181) | async def on_message(self, message: discord.Message):
    method close (line 231) | async def close(self):
    method _prune_message_logs_task (line 247) | async def _prune_message_logs_task(self):
    method _before_prune_message_logs (line 256) | async def _before_prune_message_logs(self):
  function on_app_command_error (line 264) | async def on_app_command_error(interaction: discord.Interaction, error: ...

FILE: tests/test_config_loader.py
  function test_load_runtime_config_prefers_env_over_file (line 6) | def test_load_runtime_config_prefers_env_over_file(monkeypatch, tmp_path):
  function test_load_runtime_config_falls_back_to_config_json (line 30) | def test_load_runtime_config_falls_back_to_config_json(monkeypatch, tmp_...

FILE: tests/test_duration_parsing.py
  function test_parse_duration_spec_supports_seconds_minutes_hours_days (line 5) | def test_parse_duration_spec_supports_seconds_minutes_hours_days():
  function test_parse_duration_spec_rejects_invalid_values (line 12) | def test_parse_duration_spec_rejects_invalid_values():
  function test_parse_duration_supports_minutes_hours_days (line 18) | def test_parse_duration_supports_minutes_hours_days():
  function test_parse_duration_rejects_invalid_values (line 24) | def test_parse_duration_rejects_invalid_values():

FILE: tests/test_issue_fixes.py
  class FakeCursor (line 14) | class FakeCursor:
    method __init__ (line 15) | def __init__(self, rows=None):
    method __aenter__ (line 19) | async def __aenter__(self):
    method __aexit__ (line 22) | async def __aexit__(self, exc_type, exc, tb):
    method execute (line 25) | async def execute(self, query, params=()):
    method fetchall (line 28) | async def fetchall(self):
  class FakeDB (line 32) | class FakeDB:
    method __init__ (line 33) | def __init__(self, rows=None):
    method cursor (line 38) | def cursor(self):
    method commit (line 43) | async def commit(self):
  function test_afk_listener_deduplicates_repeated_mentions (line 48) | async def test_afk_listener_deduplicates_repeated_mentions():
  function test_reminder_loop_queries_due_work_in_bounded_batches (line 75) | async def test_reminder_loop_queries_due_work_in_bounded_batches():
  function test_schedule_loop_queries_due_work_in_bounded_batches (line 86) | async def test_schedule_loop_queries_due_work_in_bounded_batches():
  function test_chat_prune_cooldowns_removes_only_expired_entries (line 97) | async def test_chat_prune_cooldowns_removes_only_expired_entries():
  function test_economy_guild_lock_is_scoped_per_guild (line 113) | def test_economy_guild_lock_is_scoped_per_guild():

FILE: tests/test_retry_delays.py
  function test_reminder_retry_delay_grows_and_caps (line 7) | def test_reminder_retry_delay_grows_and_caps():
  function test_schedule_retry_delay_grows_and_caps (line 16) | def test_schedule_retry_delay_grows_and_caps():
Condensed preview — 47 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (308K chars).
[
  {
    "path": ".env.example",
    "chars": 558,
    "preview": "# Milo configuration\n# Copy this file to `.env` and replace placeholder values.\n\n# Required\nDISCORD_TOKEN=your_discord_b"
  },
  {
    "path": ".flake8",
    "chars": 127,
    "preview": "[flake8]\nmax-line-length = 120\nexclude = \n    .git,\n    __pycache__,\n    .venv,\n    venv,\n    database\nignore = E203,E50"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yml",
    "chars": 1776,
    "preview": "name: Bug Report\ndescription: Report a reproducible problem in Milo\ntitle: \"[Bug]: \"\nlabels:\n  - bug\nbody:\n  - type: mar"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yml",
    "chars": 1219,
    "preview": "name: Feature Request\ndescription: Suggest an improvement or new feature for Milo\ntitle: \"[Feature]: \"\nlabels:\n  - enhan"
  },
  {
    "path": ".github/pull_request_template.md",
    "chars": 335,
    "preview": "## 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?"
  },
  {
    "path": ".github/workflows/ci.yml",
    "chars": 672,
    "preview": "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"
  },
  {
    "path": ".github/workflows/release.yml",
    "chars": 448,
    "preview": "name: Release\n\non:\n  push:\n    tags:\n      - \"v*\"\n\npermissions:\n  contents: write\n\njobs:\n  create-release:\n    runs-on: "
  },
  {
    "path": ".gitignore",
    "chars": 116,
    "preview": "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",
    "chars": 1115,
    "preview": "## Changelog\n\n**v1.0.4** – 2026-03-20\n- Added bounded reminder and scheduled announcement polling, plus admin diagnostic"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "chars": 1039,
    "preview": "# Code of Conduct\n\n## Our Standard\n\nParticipation in this project should be respectful, direct, and constructive.\n\nExamp"
  },
  {
    "path": "CONTRIBUTING.md",
    "chars": 2206,
    "preview": "# Contributing to Milo\n\nThanks for contributing. Keep changes small, reviewable, and easy to test.\n\n## Before You Start\n"
  },
  {
    "path": "LICENSE",
    "chars": 1070,
    "preview": "Copyright 2026 Sentinel Team and contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a c"
  },
  {
    "path": "README.md",
    "chars": 7116,
    "preview": "# Milo\n\nMilo is an open-source Discord bot for community operations, support, and lightweight moderation, built with `di"
  },
  {
    "path": "SECURITY.md",
    "chars": 1022,
    "preview": "# Security Policy\n\n## Supported Versions\n\nOnly the latest code on `main` should be assumed to receive security fixes.\n\n#"
  },
  {
    "path": "SUPPORT.md",
    "chars": 816,
    "preview": "# Support\n\n## Getting Help\n\nUse the repository issue tracker for:\n\n- Reproducible bugs\n- Feature requests\n- Documentatio"
  },
  {
    "path": "cogs/chat.py",
    "chars": 33647,
    "preview": "import json\nimport logging\nfrom collections import defaultdict\nfrom datetime import timedelta\nfrom typing import Any, Di"
  },
  {
    "path": "cogs/community.py",
    "chars": 33179,
    "preview": "import string\nfrom datetime import timedelta\nfrom typing import Dict, Optional\n\nimport discord\nfrom discord import app_c"
  },
  {
    "path": "cogs/economy.py",
    "chars": 24213,
    "preview": "import asyncio\nimport logging\nimport random\n\nimport discord\nfrom discord import app_commands\nfrom discord.ext import com"
  },
  {
    "path": "cogs/farming.py",
    "chars": 15570,
    "preview": "import logging\nimport math\nimport random\nfrom datetime import datetime, timedelta\n\nimport discord\nfrom discord import ap"
  },
  {
    "path": "cogs/fun.py",
    "chars": 16565,
    "preview": "import discord\nfrom discord import app_commands\nfrom discord.ext import commands\nimport random\nfrom PIL import Image, Im"
  },
  {
    "path": "cogs/games.py",
    "chars": 10613,
    "preview": "import discord\nfrom discord import app_commands\nfrom discord.ext import commands\nimport random\nimport asyncio\n\n\nclass Ti"
  },
  {
    "path": "cogs/interactions.py",
    "chars": 5318,
    "preview": "import discord\nfrom discord import app_commands\nfrom discord.ext import commands\n\n\nclass Interactions(commands.Cog):\n   "
  },
  {
    "path": "cogs/media.py",
    "chars": 3487,
    "preview": "import logging\n\nimport discord\nfrom discord import app_commands\nfrom discord.ext import commands\n\nlogger = logging.getLo"
  },
  {
    "path": "cogs/moderation.py",
    "chars": 16953,
    "preview": "import json\nimport re\nfrom datetime import timedelta\nfrom typing import Dict, List, Optional\n\nimport discord\nfrom discor"
  },
  {
    "path": "cogs/utility.py",
    "chars": 37151,
    "preview": "import platform\nimport re\nimport sys\nimport time\nfrom datetime import timedelta\nfrom typing import Optional, Union\n\nimpo"
  },
  {
    "path": "config.example.json",
    "chars": 381,
    "preview": "{\n  \"DISCORD_TOKEN\": \"your_discord_token_here\",\n  \"OPENAI_API_KEY\": \"your_openai_api_key_here\",\n  \"OPENAI_API_BASE\": \"ht"
  },
  {
    "path": "config_loader.py",
    "chars": 2173,
    "preview": "import json\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nfrom dotenv import load_dotenv\n\n\nCONFIG_PATH = Pa"
  },
  {
    "path": "docs/commands.md",
    "chars": 6608,
    "preview": "# Command Reference\n\nThis document summarizes the current slash commands exposed by Milo.\n\n## AI Chat\n\n- `/chat`\n  Purpo"
  },
  {
    "path": "docs/configuration.md",
    "chars": 2285,
    "preview": "# Configuration Guide\n\nMilo supports two config sources:\n\n1. Environment variables, including values loaded from `.env`\n"
  },
  {
    "path": "docs/deployment.md",
    "chars": 1939,
    "preview": "# Deployment Guide\n\nThis guide covers practical ways to run Milo outside a local development shell.\n\n## Requirements\n\n- "
  },
  {
    "path": "docs/faq.md",
    "chars": 2176,
    "preview": "# FAQ\n\n## Does Milo support AI chat in DMs?\n\nYes. `/chat` and `/chat-reset` work in DMs. Server configuration commands u"
  },
  {
    "path": "docs/operations.md",
    "chars": 1706,
    "preview": "# Operations Notes\n\nThis document covers behavior that matters when hosting or maintaining Milo.\n\n## Runtime Requirement"
  },
  {
    "path": "install.bat",
    "chars": 6749,
    "preview": "@echo off\ntitle Milo Discord Fun Bot Installer v1.3.0\n\nsetlocal enabledelayedexpansion\n\n:: ---------------- ASCII LOGO -"
  },
  {
    "path": "install.sh",
    "chars": 4717,
    "preview": "#!/usr/bin/env bash\n\ncat << \"EOF\"\n  __  __ _ _       _         ____        _   \n |  \\/  (_) |_ ___| |__     | __ )  ___ "
  },
  {
    "path": "main.py",
    "chars": 10715,
    "preview": "import asyncio\nimport os\nimport sys\nfrom collections import defaultdict\nimport datetime\nfrom pathlib import Path\nfrom ty"
  },
  {
    "path": "requirements-dev.txt",
    "chars": 123,
    "preview": "# 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"
  },
  {
    "path": "requirements.txt",
    "chars": 49,
    "preview": "discord.py\naiohttp\npython-dotenv\naiosqlite\nPillow"
  },
  {
    "path": "site/404.html",
    "chars": 1512,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-w"
  },
  {
    "path": "site/_headers",
    "chars": 175,
    "preview": "/*\n  X-Content-Type-Options: nosniff\n  Referrer-Policy: strict-origin-when-cross-origin\n  Permissions-Policy: camera=(),"
  },
  {
    "path": "site/index.html",
    "chars": 11944,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-w"
  },
  {
    "path": "site/script.js",
    "chars": 2464,
    "preview": "document.addEventListener('DOMContentLoaded', () => {\n  const revealTargets = document.querySelectorAll('.animate-on-scr"
  },
  {
    "path": "site/styles.css",
    "chars": 13828,
    "preview": ":root {\n  --bg: #09090b;\n  --card: #131316;\n  --border: rgba(255, 255, 255, 0.08);\n  --border-hover: rgba(255, 255, 255,"
  },
  {
    "path": "tests/conftest.py",
    "chars": 169,
    "preview": "import sys\nfrom pathlib import Path\n\n\nPROJECT_ROOT = Path(__file__).resolve().parents[1]\nif str(PROJECT_ROOT) not in sys"
  },
  {
    "path": "tests/test_config_loader.py",
    "chars": 2250,
    "preview": "import json\n\nfrom config_loader import load_runtime_config\n\n\ndef test_load_runtime_config_prefers_env_over_file(monkeypa"
  },
  {
    "path": "tests/test_duration_parsing.py",
    "chars": 921,
    "preview": "from cogs.community import parse_duration\nfrom cogs.utility import parse_duration_spec\n\n\ndef test_parse_duration_spec_su"
  },
  {
    "path": "tests/test_issue_fixes.py",
    "chars": 3647,
    "preview": "from datetime import timedelta\nfrom types import SimpleNamespace\nfrom unittest.mock import AsyncMock\n\nimport discord\nimp"
  },
  {
    "path": "tests/test_retry_delays.py",
    "chars": 944,
    "preview": "from datetime import timedelta\n\nfrom cogs.community import Community, SCHEDULE_RETRY_MAX_SECONDS\nfrom cogs.utility impor"
  }
]

About this extraction

This page contains the full source code of the msgaxzzz/Milo-discord-fun-bot GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 47 files (286.9 KB), approximately 66.7k tokens, and a symbol index with 273 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!