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"(? 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..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 = getattr(check, "__qualname__", "") if "has_permissions" in qualname: closure = getattr(check, "__closure__", None) or [] for cell in closure: if isinstance(cell.cell_contents, dict) and cell.cell_contents: return ", ".join(name.replace("_", " ").title() for name in cell.cell_contents.keys()) return None async def command_autocomplete( self, interaction: discord.Interaction, current: str ) -> list[app_commands.Choice[str]]: current_lower = current.lower() return [ app_commands.Choice(name=self._get_full_command_name(command), value=self._get_full_command_name(command)) for command in self._iter_commands() if current_lower in self._get_full_command_name(command).lower() ][:25] @help_group.command(name="all", description="Lists all available commands.") async def help_all(self, interaction: discord.Interaction): embeds = [] embed = discord.Embed( title="Bot Commands", description="Full command list grouped by cog.", color=discord.Color.blurple(), ) embed_char_count = len(embed.title or "") + len(embed.description or "") field_count = 0 for cog_name in sorted(self.bot.cogs.keys()): cog = self.bot.get_cog(cog_name) expanded = [] for command in cog.get_app_commands(): if isinstance(command, app_commands.Group): expanded.extend(f"`/{self._get_full_command_name(sub)}`" for sub in self._iter_group_commands(command)) else: expanded.append(f"`/{self._get_full_command_name(command)}`") if not expanded: continue value = " ".join(expanded) if len(value) > 1024: value = value[:1021] + "..." field_chars = len(cog_name) + len(value) if field_count >= 25 or embed_char_count + field_chars > 6000: embeds.append(embed) embed = discord.Embed( title="Bot Commands (continued)", color=discord.Color.blurple(), ) embed_char_count = len(embed.title or "") field_count = 0 embed.add_field(name=cog_name, value=value, inline=False) embed_char_count += field_chars field_count += 1 embeds.append(embed) await interaction.response.send_message(embed=embeds[0], ephemeral=True) for extra in embeds[1:]: await interaction.followup.send(embed=extra, ephemeral=True) def _iter_group_commands(self, group: app_commands.Group): stack = list(group.commands) while stack: command = stack.pop(0) yield command if isinstance(command, app_commands.Group): stack[0:0] = command.commands @help_group.command(name="command", description="Get detailed help for a specific command.") @app_commands.autocomplete(command=command_autocomplete) @app_commands.describe(command="The command you need help with.") async def help_command(self, interaction: discord.Interaction, command: str): target = None for item in self._iter_commands(): if self._get_full_command_name(item) == command: target = item break if target is None: await interaction.response.send_message(f"Command `{command}` not found.", ephemeral=True) return embed = discord.Embed( title=f"Help: `/{self._get_full_command_name(target)}`", description=target.description or "No description provided.", color=discord.Color.blurple(), ) embed.add_field(name="Availability", value=self._command_support_label(target), inline=False) permission_label = self._command_permission_label(target) if permission_label: embed.add_field(name="Required Permissions", value=permission_label, inline=False) if isinstance(target, app_commands.Group): subcommands = [f"`/{self._get_full_command_name(item)}`" for item in self._iter_group_commands(target)] if subcommands: embed.add_field(name="Subcommands", value="\n".join(subcommands), inline=False) elif target.parameters: params = [f"`{param.name}`: {param.description or 'No description'}" for param in target.parameters] embed.add_field(name="Parameters", value="\n".join(params), inline=False) await interaction.response.send_message(embed=embed, ephemeral=True) @app_commands.command(name="remindme", description="Sets a reminder for you.") @app_commands.describe(time="When to remind (e.g., 10s, 5m, 2h, 1d).", reason="What to be reminded of.") async def remindme(self, interaction: discord.Interaction, time: str, reason: str): seconds = parse_duration_spec(time) if seconds is None: await interaction.response.send_message( "Invalid time format. Use s, m, h, or d (e.g., `10s`, `5m`, `2h`, `1d`).", ephemeral=True, ) return if seconds > REMINDER_LIMIT_SECONDS: await interaction.response.send_message("You cannot set a reminder for more than 30 days.", ephemeral=True) return if len(reason) > MAX_REMINDER_TEXT_LENGTH: await interaction.response.send_message( f"Reminder text is too long ({len(reason)} chars). Maximum is {MAX_REMINDER_TEXT_LENGTH}.", ephemeral=True, ) return remind_at = discord.utils.utcnow() + timedelta(seconds=seconds) reminder_id = await self.create_reminder( interaction.user.id, interaction.channel_id, interaction.guild_id, remind_at, reason, ) await interaction.response.send_message( f"Reminder `{reminder_id}` created for **{time.lower()}** from now: `{reason}`", ephemeral=True, ) @reminders_group.command(name="recurring", description="Create a recurring reminder.") @app_commands.describe(interval="How often to repeat, like 30m, 2h, or 1d.", reason="What to be reminded of.") async def reminders_recurring(self, interaction: discord.Interaction, interval: str, reason: str): seconds = parse_duration_spec(interval) if seconds is None: await interaction.response.send_message( "Invalid interval format. Use s, m, h, or d (e.g., `30m`, `2h`, `1d`).", ephemeral=True, ) return if seconds > REMINDER_LIMIT_SECONDS: await interaction.response.send_message("Recurring interval cannot be more than 30 days.", ephemeral=True) return if len(reason) > MAX_REMINDER_TEXT_LENGTH: await interaction.response.send_message( f"Reminder text is too long ({len(reason)} chars). Maximum is {MAX_REMINDER_TEXT_LENGTH}.", ephemeral=True, ) return remind_at = discord.utils.utcnow() + timedelta(seconds=seconds) reminder_id = await self.create_reminder( interaction.user.id, interaction.channel_id, interaction.guild_id, remind_at, reason, recurring_seconds=seconds, ) await interaction.response.send_message( f"Recurring reminder `{reminder_id}` created every **{interval.lower()}**: `{reason}`", ephemeral=True, ) @reminders_group.command(name="list", description="List your active reminders.") async def reminders_list(self, interaction: discord.Interaction): reminders = await self.list_user_reminders(interaction.user.id) if not reminders: await interaction.response.send_message("You do not have any active reminders.", ephemeral=True) return lines = [] for reminder_id, guild_id, channel_id, remind_at, reason, recurring_seconds, delivery_failures, disabled, last_error in reminders[:20]: if guild_id is None: scope = "DM" else: guild = self.bot.get_guild(guild_id) scope = guild.name if guild else f"Guild `{guild_id}`" channel_text = f", channel <#{channel_id}>" if channel_id else "" recurring_text = "" if not recurring_seconds else f" • repeats every `{int(recurring_seconds)}s`" failure_text = "" if not delivery_failures else f" • delivery failures: {delivery_failures}" disabled_text = " • paused after repeated delivery failures" if disabled else "" error_text = "" if not last_error else f"\nLast error: {last_error[:120]}" lines.append( f"`{reminder_id}` • {discord.utils.format_dt(discord.utils.parse_time(remind_at), style='R')} • {scope}{channel_text}{recurring_text}{failure_text}{disabled_text}\n{reason}{error_text}" ) embed = discord.Embed(title="Active Reminders", description="\n\n".join(lines), color=discord.Color.gold()) await interaction.response.send_message(embed=embed, ephemeral=True) @reminders_group.command(name="cancel", description="Cancel one reminder by id.") @app_commands.describe(reminder_id="The reminder id shown by /reminders list.") async def reminders_cancel(self, interaction: discord.Interaction, reminder_id: int): async with self.bot.db.cursor() as cursor: await cursor.execute( "DELETE FROM reminders WHERE id = ? AND user_id = ?", (reminder_id, interaction.user.id), ) deleted = cursor.rowcount await self.bot.db.commit() if deleted: await interaction.response.send_message(f"Reminder `{reminder_id}` canceled.", ephemeral=True) else: await interaction.response.send_message("Reminder not found.", ephemeral=True) @reminders_group.command(name="snooze", description="Push one reminder further into the future.") @app_commands.describe(reminder_id="The reminder id shown by /reminders list.", delay="How much longer, like 10m or 2h.") async def reminders_snooze(self, interaction: discord.Interaction, reminder_id: int, delay: str): seconds = parse_duration_spec(delay) if seconds is None: await interaction.response.send_message( "Invalid delay format. Use s, m, h, or d (e.g., `10m`, `2h`).", ephemeral=True, ) return new_time = discord.utils.utcnow() + timedelta(seconds=seconds) async with self.bot.db.cursor() as cursor: await cursor.execute( """ UPDATE reminders SET remind_at = ?, delivery_failures = 0, disabled = 0, last_error = NULL WHERE id = ? AND user_id = ? """, (new_time.isoformat(), reminder_id, interaction.user.id), ) updated = cursor.rowcount await self.bot.db.commit() if updated: await interaction.response.send_message( f"Reminder `{reminder_id}` snoozed for `{delay.lower()}`.", ephemeral=True, ) else: await interaction.response.send_message("Reminder not found.", ephemeral=True) @reminders_group.command(name="clear", description="Clear all of your reminders.") async def reminders_clear(self, interaction: discord.Interaction): async with self.bot.db.cursor() as cursor: await cursor.execute("DELETE FROM reminders WHERE user_id = ?", (interaction.user.id,)) deleted = cursor.rowcount await self.bot.db.commit() await interaction.response.send_message(f"Cleared {deleted} reminder(s).", ephemeral=True) @app_commands.command(name="afk", description="Set or update your AFK status in this server.") @app_commands.guild_only() @app_commands.describe(reason="The reason for your AFK status.") async def afk(self, interaction: discord.Interaction, reason: str = "AFK"): await self.set_afk_status(interaction.guild_id, interaction.user.id, reason) await interaction.response.send_message(f"{interaction.user.mention} is now AFK: **{reason}**") @app_commands.command(name="afk-clear", description="Clear your AFK status in this server.") @app_commands.guild_only() async def afk_clear(self, interaction: discord.Interaction): await self.clear_afk_status(interaction.guild_id, interaction.user.id) await interaction.response.send_message("Your AFK status has been cleared.", ephemeral=True) @commands.Cog.listener("on_message") async def afk_message_listener(self, message: discord.Message): if message.author.bot or not message.guild: return own_status = await self.get_afk_status(message.guild.id, message.author.id) if own_status: await self.clear_afk_status(message.guild.id, message.author.id) await message.channel.send(f"Welcome back {message.author.mention}! Your AFK status has been removed.") notified = [] mentioned_users = [] seen_user_ids = set() for mentioned_user in message.mentions: if mentioned_user.id in seen_user_ids: continue seen_user_ids.add(mentioned_user.id) mentioned_users.append(mentioned_user) mention_statuses = await self.get_many_afk_statuses( message.guild.id, [mentioned_user.id for mentioned_user in mentioned_users if not mentioned_user.bot], ) for mentioned_user in mentioned_users: if mentioned_user.bot: continue status = mention_statuses.get(mentioned_user.id) if not status: continue reason, set_at = status time_afk = discord.utils.utcnow() - discord.utils.parse_time(set_at) days = time_afk.days hours, remainder = divmod(int(time_afk.total_seconds() % 86400), 3600) minutes, seconds = divmod(remainder, 60) time_str = f"{days}d {hours}h {minutes}m {seconds}s" notified.append(f"{mentioned_user.display_name} is AFK: **{reason}** (for {time_str})") if notified: await message.channel.send("\n".join(notified)) @tasks.loop(seconds=REMINDER_POLL_SECONDS) async def reminder_loop(self): now = discord.utils.utcnow().isoformat() async with self.bot.db.cursor() as cursor: await cursor.execute( """ SELECT id, user_id, channel_id, reason, remind_at, recurring_seconds, delivery_failures FROM reminders WHERE disabled = 0 AND remind_at <= ? ORDER BY remind_at ASC LIMIT ? """, (now, REMINDER_BATCH_SIZE), ) reminders = await cursor.fetchall() if not reminders: return for reminder_id, user_id, channel_id, reason, remind_at, recurring_seconds, delivery_failures in reminders: user = self.bot.get_user(user_id) failure_reason = "delivery failed" if user is None: try: user = await self.bot.fetch_user(user_id) except discord.HTTPException: user = None failure_reason = "unable to fetch the reminder recipient" delivered = False if user is not None: try: await user.send(f"**Reminder:** {reason}") delivered = True except discord.Forbidden: delivered = False failure_reason = "direct messages are disabled for the recipient" except discord.HTTPException: delivered = False failure_reason = "failed to send the reminder through direct messages" if not delivered and channel_id is not None: channel = self.bot.get_channel(channel_id) if channel is None: try: channel = await self.bot.fetch_channel(channel_id) except discord.HTTPException: channel = None failure_reason = "unable to access the fallback channel" if channel is not None: try: mention = user.mention if user else f"<@{user_id}>" await channel.send(f"Hey {mention}, you had a reminder: {reason}") delivered = True except discord.HTTPException: delivered = False failure_reason = "failed to send the reminder in the fallback channel" async with self.bot.db.cursor() as cursor: if delivered and recurring_seconds: next_time = discord.utils.parse_time(remind_at) while next_time <= discord.utils.utcnow(): next_time += timedelta(seconds=recurring_seconds) await cursor.execute( """ UPDATE reminders SET remind_at = ?, delivery_failures = 0, disabled = 0, last_error = NULL WHERE id = ? """, (next_time.isoformat(), reminder_id), ) elif delivered: await cursor.execute("DELETE FROM reminders WHERE id = ?", (reminder_id,)) else: next_failures = delivery_failures + 1 if next_failures >= MAX_REMINDER_DELIVERY_FAILURES: await cursor.execute( """ UPDATE reminders SET delivery_failures = ?, disabled = 1, last_error = ? WHERE id = ? """, (next_failures, failure_reason, reminder_id), ) else: retry_at = discord.utils.utcnow() + self.reminder_retry_delay(next_failures) await cursor.execute( """ UPDATE reminders SET remind_at = ?, delivery_failures = ?, last_error = ? WHERE id = ? """, (retry_at.isoformat(), next_failures, failure_reason, reminder_id), ) await self.bot.db.commit() @reminder_loop.before_loop async def before_reminder_loop(self): await self.bot.wait_until_ready() await self.setup_database() async def setup(bot: commands.Bot): await bot.add_cog(Utility(bot)) ================================================ FILE: config.example.json ================================================ { "DISCORD_TOKEN": "your_discord_token_here", "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" ], "GOOGLE_API_KEY": "your_google_api_key_here", "GOOGLE_CSE_ID": "your_custom_search_engine_id_here" } ================================================ FILE: config_loader.py ================================================ import json import os from pathlib import Path from typing import Any from dotenv import load_dotenv CONFIG_PATH = Path("config.json") DEFAULT_API_BASE = "https://api.openai.com/v1" DEFAULT_CHAT_MODEL = "gpt-4o-mini" DEFAULT_ALLOWED_MODELS = [DEFAULT_CHAT_MODEL, "gpt-4o"] def _load_file_config() -> dict[str, Any]: if not CONFIG_PATH.exists(): return {} with CONFIG_PATH.open("r", encoding="utf-8") as handle: return json.load(handle) def _get_bool(name: str, default: bool, file_config: dict[str, Any]) -> bool: value = os.getenv(name) if value is None: value = file_config.get(name, default) if isinstance(value, bool): return value if isinstance(value, str): return value.strip().lower() in {"1", "true", "yes", "on"} return bool(value) def _get_list(name: str, default: list[str], file_config: dict[str, Any]) -> list[str]: value = os.getenv(name) if value is None: value = file_config.get(name, default) if isinstance(value, list): return [str(item).strip() for item in value if str(item).strip()] if isinstance(value, str): return [item.strip() for item in value.split(",") if item.strip()] return list(default) def load_runtime_config() -> dict[str, Any]: load_dotenv() file_config = _load_file_config() return { "DISCORD_TOKEN": os.getenv("DISCORD_TOKEN", file_config.get("DISCORD_TOKEN", "")), "OPENAI_API_KEY": os.getenv("OPENAI_API_KEY", file_config.get("OPENAI_API_KEY", "")), "OPENAI_API_BASE": os.getenv("OPENAI_API_BASE", file_config.get("OPENAI_API_BASE", DEFAULT_API_BASE)), "ALLOW_USER_KEYS": _get_bool("ALLOW_USER_KEYS", True, file_config), "DEFAULT_CHAT_MODEL": os.getenv( "DEFAULT_CHAT_MODEL", file_config.get("DEFAULT_CHAT_MODEL", DEFAULT_CHAT_MODEL) ), "ALLOWED_CHAT_MODELS": _get_list("ALLOWED_CHAT_MODELS", DEFAULT_ALLOWED_MODELS, file_config), "GOOGLE_API_KEY": os.getenv("GOOGLE_API_KEY", file_config.get("GOOGLE_API_KEY", "")), "GOOGLE_CSE_ID": os.getenv("GOOGLE_CSE_ID", file_config.get("GOOGLE_CSE_ID", "")), } ================================================ FILE: docs/commands.md ================================================ # Command Reference This document summarizes the current slash commands exposed by Milo. ## AI Chat - `/chat` Purpose: talk to the configured AI model, optionally with web search. Works in: servers and DMs. Inputs: `prompt`, optional `model`, optional `search_web`. - `/chat-reset` Purpose: clear the current conversation context. Works in: servers and DMs. - `/chat-config set-key` Purpose: store a server-specific OpenAI API key. Works in: servers only. Permission: `Manage Server`. - `/chat-config set-persona` Purpose: set a server-specific system persona for chat. Works in: servers only. Permission: `Manage Server`. - `/chat-config view` Purpose: inspect the current server chat configuration. Works in: servers only. Permission: `Manage Server`. - `/chat-config set-enabled` - `/chat-config set-cooldown` - `/chat-config set-usage-cap` - `/chat-config allow-channel` - `/chat-config block-channel` - `/chat-config clear-channel-rules` - `/chat-config allow-role` - `/chat-config remove-role` - `/chat-config clear-role-rules` Purpose: control where AI chat can be used, who can use it, and how often. Works in: servers only. Permission: `Manage Server`. - `/chat-config test` Purpose: validate the effective API key for the current server. Works in: servers only. Permission: `Manage Server`. - `/chat-config models` Purpose: show the default model and the allowlisted models. Works in: servers only. Permission: `Manage Server`. ## Economy All economy data is per guild. - `/balance` Purpose: view your balance or another member's balance. Works in: servers only. - `/daily` Purpose: claim the daily reward. Works in: servers only. - `/jobs freelance` - `/jobs regular` - `/jobs crime` Purpose: earn coins through cooldown-based work commands. Works in: servers only. - `/gamble` Purpose: wager coins on a simple chance outcome. Works in: servers only. - `/slots` Purpose: use the slot machine. Works in: servers only. - `/leaderboard` Purpose: show the richest users in the current server. Works in: servers only. - `/transfer` Purpose: send coins to another member. Works in: servers only. - `/rob` Purpose: attempt to steal coins from another member. Works in: servers only. - `/economy-admin add` - `/economy-admin remove` - `/economy-admin set` - `/economy-admin reset-guild` Purpose: administrative balance management for the current guild. Works in: servers only. Permission: `Manage Server` or stronger depending on the command. ## Farming All farming progress is per guild and uses the local economy balance. - `/farm profile` Purpose: inspect current farm progress, land type, and crop status. - `/farm shop` Purpose: view crop unlocks, costs, rewards, and XP. - `/farm plant` Purpose: plant a crop if you have enough coins and the required farm level. - `/farm harvest` Purpose: collect crop rewards and farm XP. - `/farm upgrade` Purpose: buy better land to reduce growth time. ## Games - `/eightball` Purpose: answer a question with a random eight-ball response. - `/coinflip` Purpose: flip a coin. - `/roll` Purpose: roll dice in `NdN` form. - `/guess` Purpose: run a timed number guessing game in the current channel. - `/rps` Purpose: play rock-paper-scissors against the bot. - `/tictactoe` Purpose: challenge another member to a button-based tic-tac-toe game. ## Fun - `/joke` - `/fact` - `/avatar` - `/love` - `/emojify` - `/poll` - `/clap` - `/tweet` Purpose: lightweight entertainment, formatting, and image-generation features. ## Interactions - `/hug` - `/pat` - `/slap` - `/kiss` - `/cuddle` - `/poke` Purpose: social reaction commands backed by external GIF APIs. ## Media - `/meme` - `/cat` - `/dog` Purpose: fetch random media from public APIs. ## Utility - `/ping` - `/ping_raw` Purpose: inspect websocket and basic API latency. - `/memberinfo` Works in: servers only. Purpose: inspect a member profile. - `/clear` Works in: servers only. Permission: `Manage Messages`. Purpose: bulk-delete recent messages. - `/serverinfo` Works in: servers only. Purpose: inspect guild metadata. - `/botinfo` - `/uptime` Purpose: inspect runtime information. - `/help all` - `/help command` Purpose: list commands or show detail for one command. - `/remindme` Purpose: create a persisted reminder. Works in: servers and DMs. - `/reminders recurring` Purpose: create a recurring reminder with a repeat interval. Works in: servers and DMs. - `/reminders list` - `/reminders cancel` - `/reminders clear` - `/reminders snooze` Purpose: manage your existing reminders, including snoozing one into the future. Works in: servers and DMs. - `/afk` Purpose: set AFK state until your next server message in that guild. Works in: servers only. - `/afk-clear` Purpose: clear your AFK status manually. Works in: servers only. ## Community - `/server-config view` - `/server-config set-welcome-channel` - `/server-config set-goodbye-channel` - `/server-config set-announcement-channel` - `/server-config set-modlog-channel` - `/server-config set-welcome-message` - `/server-config set-goodbye-message` - `/server-config preview-welcome` - `/server-config preview-goodbye` - `/server-config reset-message` - `/server-config reset-channel` Purpose: manage server community automation. Works in: servers only. Permission: `Manage Server`. - `/announce` Purpose: send an announcement to the configured announcement channel or the current channel. Works in: servers only. Permission: `Manage Server`. - `/announcements schedule` - `/announcements list` - `/announcements cancel` Purpose: manage scheduled server announcements. Works in: servers only. Permission: `Manage Server`. ## Moderation - `/automod view` - `/automod toggle-invites` - `/automod toggle-links` - `/automod set-action` - `/automod set-bad-words` - `/automod clear-bad-words` - `/automod whitelist-channel` - `/automod remove-whitelist-channel` Purpose: configure invite filtering, link filtering, blocked words, channel exemptions, and automod actions. Works in: servers only. Permission: `Manage Server`. - `/warn` - `/warnings` - `/clear-warning` Purpose: manage per-guild warning history and moderation notes. Works in: servers only. Permission: `Manage Messages`. ## Notes - Cooldowns are enforced on many commands. - Some commands call third-party APIs and may fail if those services are unavailable. - Chat, media, and interaction commands depend on external services and configuration. ================================================ FILE: docs/configuration.md ================================================ # Configuration Guide Milo supports two config sources: 1. Environment variables, including values loaded from `.env` 2. Local `config.json` Environment variables take priority. ## Required - `DISCORD_TOKEN` Description: Discord bot token. Required: yes. ## AI Chat - `OPENAI_API_KEY` Description: default API key used when a guild has not provided its own key. Required: no. Note: if omitted, `/chat` only works in guilds where admins set a server-specific key. - `OPENAI_API_BASE` Description: base URL for the chat completion API. Default: `https://api.openai.com/v1` - `ALLOW_USER_KEYS` Description: whether guild admins may store their own API key with `/chat-config set-key`. Default: `true` Values: `true` or `false` - `DEFAULT_CHAT_MODEL` Description: model used when the user does not specify one. Default: `gpt-4o-mini` - `ALLOWED_CHAT_MODELS` Description: comma-separated environment value or JSON array in `config.json`. Default: `gpt-4o-mini,gpt-4o` Note: `/chat` rejects models outside this allowlist. ## Google Custom Search - `GOOGLE_API_KEY` - `GOOGLE_CSE_ID` Description: enable optional live web search for `/chat`. Important: - Both values must be present for web search to be enabled. - These services may incur cost depending on your Google account setup. ## Example `.env` ```env DISCORD_TOKEN=your_discord_bot_token_here 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 GOOGLE_API_KEY=your_google_api_key_here GOOGLE_CSE_ID=your_custom_search_engine_id_here ``` ## Example `config.json` ```json { "DISCORD_TOKEN": "your_discord_token_here", "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"], "GOOGLE_API_KEY": "your_google_api_key_here", "GOOGLE_CSE_ID": "your_custom_search_engine_id_here" } ``` ## Secret Handling - Do not commit real values to git. - Prefer `.env` for local development. - Keep `config.json` local and gitignored if you use it for secrets. - Rotate any credential immediately if it is exposed. ================================================ FILE: docs/deployment.md ================================================ # Deployment Guide This guide covers practical ways to run Milo outside a local development shell. ## Requirements - Python 3.9+ - Persistent disk access for `database/main.db` - Network access to Discord - Network access to optional upstream APIs if you enable them ## Minimal Host Requirements Milo is lightweight enough for a small VPS or home server. You need: - one persistent process - a writable working directory - a way to restart the bot after crashes or machine reboot ## Recommended Layout ```text Milo-discord-fun-bot/ .env database/ main.db cogs/ docs/ main.py ``` ## Linux With `systemd` Example unit file: ```ini [Unit] Description=Milo Discord Bot After=network.target [Service] Type=simple WorkingDirectory=/opt/Milo-discord-fun-bot EnvironmentFile=/opt/Milo-discord-fun-bot/.env ExecStart=/opt/Milo-discord-fun-bot/.venv/bin/python /opt/Milo-discord-fun-bot/main.py Restart=always RestartSec=5 [Install] WantedBy=multi-user.target ``` Typical flow: 1. Create the virtual environment 2. Install dependencies 3. Place secrets in `.env` 4. Create the `database/` directory if it does not exist 5. Start and enable the service ## Containers If you containerize Milo: - mount persistent storage for `database/main.db` - inject secrets through environment variables - do not bake real keys into the image ## Upgrading A Running Instance 1. Stop the process 2. Pull the latest code 3. Reinstall dependencies if `requirements.txt` changed 4. Start the process again 5. Watch logs for migrations or failed cog loads ## Backups Back up: - `database/main.db` - `.env` if used - local `config.json` if used ## Operational Warnings - SQLite is fine for small and medium self-hosted usage, but it is not designed for horizontally scaled multi-writer deployments - OpenAI and Google integrations can produce external cost - user-supplied API keys are stored locally when that feature is enabled ================================================ FILE: docs/faq.md ================================================ # FAQ ## Does Milo support AI chat in DMs? Yes. `/chat` and `/chat-reset` work in DMs. Server configuration commands under `/chat-config` are guild-only. ## Is the economy shared across all servers? No. Economy balances, leaderboards, and farming progress are stored per guild. ## Do reminders survive bot restarts? Yes. Reminders are stored in SQLite and replayed after startup. ## Can reminders repeat or be snoozed? Yes. Use `/reminders recurring` for repeating reminders and `/reminders snooze` to move an existing reminder further out. ## Is AI chat shared between users in the same server channel? No. Guild chat history is isolated per user, so one member's `/chat` history does not leak into another member's conversation. ## Do I need OpenAI credentials? Only if you want AI chat features. The rest of the bot can run without OpenAI credentials. ## Do I need Google API credentials? Only if you want web-enabled search in AI chat. Both `GOOGLE_API_KEY` and `GOOGLE_CSE_ID` must be configured. ## Where should I put secrets? Use `.env` or a gitignored local `config.json`. Do not commit real secrets to the repository. ## What Python version should I use? Python 3.9 or newer. ## What database does Milo use? SQLite, stored locally at `database/main.db`. ## Can I host Milo on a VPS? Yes. A small VPS or any machine that can keep a Python process online is enough for typical usage. ## Can I use a Python-capable hosting provider or panel? Yes, as long as the hosting environment can: - run Python 3.9 or newer - keep a long-running process online - write to local storage for SQLite - access Discord and any external APIs you enable Examples include VPS providers, Python app hosting platforms, Pterodactyl-style panels, and self-hosted Linux servers. The main limitation is that Milo is a persistent Discord bot process, so it is not a fit for serverless-only platforms that cannot keep a worker running continuously. ## Is this project production-ready? It is suitable for self-hosted real usage, but it is still a small open-source bot project. It does not yet have a full automated test suite or enterprise-style operational tooling. ================================================ FILE: docs/operations.md ================================================ # Operations Notes This document covers behavior that matters when hosting or maintaining Milo. ## Runtime Requirements - Python 3.9+ - Network access for Discord and any external APIs you enable - Writable filesystem access for `database/main.db` ## Storage Milo uses SQLite at: ```text database/main.db ``` At startup, the bot creates or migrates tables as needed. ## Data Behavior - Economy balances are stored per guild - Farming progress is stored per guild - Chat configuration is stored per guild - Reminders are persisted and replayed after restart - Message metadata is stored for spam detection and migration support ## External Services Optional services: - OpenAI-compatible chat API - Google Custom Search - Meme and animal image APIs - GIF APIs for social interaction commands If any external service fails, affected commands may return an error or degraded response. ## Upgrades When updating the bot: 1. Stop the running process 2. Pull the latest code 3. Reinstall dependencies if needed 4. Restart the bot 5. Watch startup logs for schema migration or API errors ## Logging Logging is configured in the main process and writes to stdout. Watch for: - configuration load failures - failed cog loads - database migration problems - external API errors ## Backup For a simple backup strategy, copy: - `database/main.db` - local `.env` if you are using it - local `config.json` if you are using it Do not commit these backups to the repository. ## Security and Cost - OpenAI and Google integrations can create ongoing cost - User-provided API keys are stored in SQLite when enabled - Rotate leaked keys immediately - Keep public logs and screenshots free of secrets ================================================ FILE: install.bat ================================================ @echo off title Milo Discord Fun Bot Installer v1.3.0 setlocal enabledelayedexpansion :: ---------------- ASCII LOGO ---------------- echo __ __ _ _ _ ____ _ echo ^| \/ ^| (_) ^|_ ^|___^| ^|__ ^| ^| __ ) ___ ^| ^|_^ echo ^| ^|\/^| ^| ^| __/^|__ ^| '_ ^\____^| ^| _ ^\ / _ ^| ^| __^| echo ^| ^| ^| ^| ^| ^|^| (__^| ^| ^| ^|____^| ^|_) ^| (_) ^| ^| ^|_ echo ^|_| |_|_| \__\___|_| |_| |____/ \___/ \__| echo Milo Discord Fun Bot Installer echo -------------------------------------------------- :: ---------------- Variables ---------------- set "REPO_URL=https://github.com/msgaxzzz/Milo-discord-fun-bot.git" set "ZIP_URL=https://github.com/msgaxzzz/Milo-discord-fun-bot/archive/refs/heads/main.zip" set "DIR_NAME=Milo-discord-fun-bot" set "VENV_DIR=.venv" if exist "main.py" if exist "requirements.txt" goto use_current_dir if not exist "%DIR_NAME%" ( where git >nul 2>&1 if errorlevel 1 ( echo Git is not available. Downloading source archive instead... call :download_zip_source if errorlevel 1 ( pause exit /b 1 ) ) else ( echo Cloning repository from %REPO_URL%... git clone "%REPO_URL%" if errorlevel 1 ( echo Failed to clone repository. pause exit /b 1 ) ) cd "%DIR_NAME%" || ( echo Cannot enter directory %DIR_NAME%. pause exit /b 1 ) ) else ( echo Directory %DIR_NAME% already exists, using it. cd "%DIR_NAME%" || ( echo Cannot enter directory %DIR_NAME%. pause exit /b 1 ) ) goto after_dir_setup :use_current_dir echo Installer is running inside an existing Milo checkout. :after_dir_setup :: Find Python 3.9+ set PYTHON_BIN= for %%P in (python3.13 python3.12 python3.11 python3.10 python3.9 python) do ( for /f "tokens=2 delims= " %%V in ('%%P --version 2^>^&1') do ( set "VER=%%V" for /f "tokens=1,2 delims=." %%A in ("!VER!") do ( set "MAJOR=%%A" set "MINOR=%%B" if !MAJOR! GEQ 3 ( if !MINOR! GEQ 9 ( set PYTHON_BIN=%%P goto foundpython ) ) ) ) ) :foundpython if not defined PYTHON_BIN ( echo No compatible Python (>=3.9) found. Please install Python 3.9 or higher. pause exit /b 1 ) echo Found Python: %PYTHON_BIN% :: Create virtual environment if not exist "%VENV_DIR%" ( echo Creating virtual environment in %CD%\%VENV_DIR%... %PYTHON_BIN% -m venv "%VENV_DIR%" if errorlevel 1 ( echo Failed to create virtual environment. Install the Python venv component and retry. pause exit /b 1 ) ) :: Install requirements echo Installing dependencies from requirements.txt... "%CD%\%VENV_DIR%\Scripts\python.exe" -m pip install --upgrade pip "%CD%\%VENV_DIR%\Scripts\python.exe" -m pip install -r requirements.txt :: Create database directory if not exist database ( mkdir database ) :: Remove existing config.json if exist config.json del config.json echo Configuration wizard: :input_discord set /p DISCORD_TOKEN=Enter Discord Bot Token (required): if "%DISCORD_TOKEN%"=="" ( echo Discord Token cannot be empty. goto input_discord ) set /p OPENAI_KEY=Enter OpenAI API Key (optional): if not "%OPENAI_KEY%"=="" ( set /p OPENAI_API_BASE=Enter OpenAI API Base URL (default https://api.openai.com/v1): if "%OPENAI_API_BASE%"=="" set OPENAI_API_BASE=https://api.openai.com/v1 set /p ALLOW_USER_KEYS=Allow user-provided OpenAI Keys? (true/false, default true): if /i not "%ALLOW_USER_KEYS%"=="true" if /i not "%ALLOW_USER_KEYS%"=="false" set ALLOW_USER_KEYS=true set /p DEFAULT_CHAT_MODEL=Enter default chat model (default gpt-4o-mini): if "%DEFAULT_CHAT_MODEL%"=="" set DEFAULT_CHAT_MODEL=gpt-4o-mini set /p ALLOWED_CHAT_MODELS_INPUT=Enter allowed chat models, comma separated (default gpt-4o-mini,gpt-4o): if "%ALLOWED_CHAT_MODELS_INPUT%"=="" ( set ALLOWED_CHAT_MODELS=["gpt-4o-mini", "gpt-4o"] ) else ( setlocal enabledelayedexpansion set "models=!ALLOWED_CHAT_MODELS_INPUT:,="",""!" set ALLOWED_CHAT_MODELS=[""!models!""] endlocal & set ALLOWED_CHAT_MODELS=%ALLOWED_CHAT_MODELS% ) ) else ( set OPENAI_API_BASE= set ALLOW_USER_KEYS=false set DEFAULT_CHAT_MODEL= set ALLOWED_CHAT_MODELS=[] ) set /p GOOGLE_API_KEY=Enter Google API Key (optional): set /p GOOGLE_CSE_ID=Enter Google CSE ID (optional): ( echo { echo "DISCORD_TOKEN": "%DISCORD_TOKEN%", echo "OPENAI_API_KEY": "%OPENAI_KEY%", echo "OPENAI_API_BASE": "%OPENAI_API_BASE%", echo "ALLOW_USER_KEYS": %ALLOW_USER_KEYS%, echo "DEFAULT_CHAT_MODEL": "%DEFAULT_CHAT_MODEL%", echo "ALLOWED_CHAT_MODELS": %ALLOWED_CHAT_MODELS%, echo "GOOGLE_API_KEY": "%GOOGLE_API_KEY%", echo "GOOGLE_CSE_ID": "%GOOGLE_CSE_ID%" echo } ) > config.json echo config.json created successfully. echo Milo Bot installation completed! echo Please verify config.json and run: echo %CD%\%VENV_DIR%\Scripts\python.exe main.py echo Or activate the virtual environment first: echo %VENV_DIR%\Scripts\activate echo Update log: https://github.com/msgaxzzz/Milo-discord-fun-bot/blob/main/CHANGELOG.md pause exit /b 0 :download_zip_source set "ARCHIVE_FILE=%TEMP%\milo-discord-fun-bot-main.zip" set "ARCHIVE_ROOT=%TEMP%\milo-discord-fun-bot-install-%RANDOM%%RANDOM%" echo Downloading %ZIP_URL%... powershell -NoProfile -ExecutionPolicy Bypass -Command "Invoke-WebRequest -Uri '%ZIP_URL%' -OutFile '%ARCHIVE_FILE%'" if errorlevel 1 ( echo Failed to download the source archive. exit /b 1 ) echo Extracting archive... powershell -NoProfile -ExecutionPolicy Bypass -Command "Expand-Archive -LiteralPath '%ARCHIVE_FILE%' -DestinationPath '%ARCHIVE_ROOT%' -Force" if errorlevel 1 ( echo Failed to extract the source archive. if exist "%ARCHIVE_FILE%" del /q "%ARCHIVE_FILE%" >nul 2>&1 exit /b 1 ) if not exist "%ARCHIVE_ROOT%\%DIR_NAME%-main" ( echo The extracted archive did not contain the expected folder structure. if exist "%ARCHIVE_FILE%" del /q "%ARCHIVE_FILE%" >nul 2>&1 if exist "%ARCHIVE_ROOT%" rmdir /s /q "%ARCHIVE_ROOT%" >nul 2>&1 exit /b 1 ) move "%ARCHIVE_ROOT%\%DIR_NAME%-main" "%DIR_NAME%" >nul if errorlevel 1 ( echo Failed to move the extracted source into %DIR_NAME%. if exist "%ARCHIVE_FILE%" del /q "%ARCHIVE_FILE%" >nul 2>&1 if exist "%ARCHIVE_ROOT%" rmdir /s /q "%ARCHIVE_ROOT%" >nul 2>&1 exit /b 1 ) if exist "%ARCHIVE_FILE%" del /q "%ARCHIVE_FILE%" >nul 2>&1 if exist "%ARCHIVE_ROOT%" rmdir /s /q "%ARCHIVE_ROOT%" >nul 2>&1 exit /b 0 ================================================ FILE: install.sh ================================================ #!/usr/bin/env bash cat << "EOF" __ __ _ _ _ ____ _ | \/ (_) |_ ___| |__ | __ ) ___ | |_ | |\/| | | __/ __| '_ \____| _ \ / _ \| __| | | | | | || (__| | | |____| |_) | (_) | |_ |_| |_|_|\__\___|_| |_| |____/ \___/ \__| Milo Discord Fun Bot Installer -------------------------------------------------- EOF GREEN='\033[1;92m' YELLOW='\033[1;93m' BLUE='\033[1;94m' RED='\033[1;91m' NC='\033[0m' REPO_URL="https://github.com/msgaxzzz/Milo-discord-fun-bot.git" DIR_NAME="Milo-discord-fun-bot" VENV_DIR=".venv" if [ -f "main.py" ] && [ -f "requirements.txt" ]; then echo -e "${YELLOW}Installer is running inside an existing Milo checkout.${NC}" elif [ ! -d "$DIR_NAME" ]; then echo -e "${BLUE}Cloning repository from $REPO_URL...${NC}" git clone "$REPO_URL" || { echo -e "${RED}Failed to clone repository.${NC}"; exit 1; } cd "$DIR_NAME" || { echo -e "${RED}Cannot enter directory $DIR_NAME.${NC}"; exit 1; } else echo -e "${YELLOW}Directory $DIR_NAME already exists, using it.${NC}" cd "$DIR_NAME" || { echo -e "${RED}Cannot enter directory $DIR_NAME.${NC}"; exit 1; } fi PROJECT_DIR=$(pwd) echo -e "${BLUE}Checking for compatible Python versions (>=3.9)...${NC}" PYTHON_BIN="" for PY in python3.13 python3.12 python3.11 python3.10 python3.9 python3; do if command -v $PY &> /dev/null; then if $PY -c "import sys; raise SystemExit(0 if sys.version_info >= (3, 9) else 1)"; then PYTHON_BIN=$PY break fi fi done if [ -z "$PYTHON_BIN" ]; then echo -e "${RED}No compatible Python (>=3.9) found. Please install Python 3.9 or higher.${NC}" exit 1 fi echo -e "${GREEN}Found Python: $PYTHON_BIN${NC}" echo -e "${BLUE}Creating virtual environment in ${PROJECT_DIR}/${VENV_DIR}...${NC}" if [ ! -d "$VENV_DIR" ]; then $PYTHON_BIN -m venv "$VENV_DIR" || { echo -e "${RED}Failed to create virtual environment. Install the Python venv package and retry.${NC}" exit 1 } fi VENV_PYTHON="$PROJECT_DIR/$VENV_DIR/bin/python" VENV_PIP="$PROJECT_DIR/$VENV_DIR/bin/pip" echo -e "${BLUE}Installing dependencies from requirements.txt...${NC}" $VENV_PYTHON -m pip install --upgrade pip $VENV_PIP install -r requirements.txt echo -e "${BLUE}Creating database directory...${NC}" mkdir -p database if [ -f config.json ]; then echo -e "${YELLOW}config.json exists, deleting to regenerate...${NC}" rm config.json fi echo -e "${BLUE}Configuration wizard:${NC}" while true; do read -p "Enter Discord Bot Token (required): " DISCORD_TOKEN [[ -n "$DISCORD_TOKEN" ]] && break echo "Discord Token cannot be empty." done read -p "Enter OpenAI API Key (optional): " OPENAI_KEY if [[ -n "$OPENAI_KEY" ]]; then read -p "Enter OpenAI API Base URL (default https://api.openai.com/v1): " OPENAI_API_BASE OPENAI_API_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1} read -p "Allow user-provided OpenAI Keys? (true/false, default true): " ALLOW_USER_KEYS if [[ ! "$ALLOW_USER_KEYS" =~ ^(true|false)$ ]]; then ALLOW_USER_KEYS=true fi read -p "Enter default chat model (default gpt-4o-mini): " DEFAULT_CHAT_MODEL DEFAULT_CHAT_MODEL=${DEFAULT_CHAT_MODEL:-gpt-4o-mini} read -p "Enter allowed chat models, comma separated (default gpt-4o-mini,gpt-4o): " ALLOWED_CHAT_MODELS_INPUT if [[ -z "$ALLOWED_CHAT_MODELS_INPUT" ]]; then ALLOWED_CHAT_MODELS='["gpt-4o-mini", "gpt-4o"]' else IFS=',' read -ra MODELS_ARR <<< "$ALLOWED_CHAT_MODELS_INPUT" ALLOWED_CHAT_MODELS=$(printf '"%s",' "${MODELS_ARR[@]}") ALLOWED_CHAT_MODELS="[${ALLOWED_CHAT_MODELS%,}]" fi else OPENAI_API_BASE="" ALLOW_USER_KEYS=false DEFAULT_CHAT_MODEL="" ALLOWED_CHAT_MODELS="[]" fi read -p "Enter Google API Key (optional): " GOOGLE_API_KEY read -p "Enter Google CSE ID (optional): " GOOGLE_CSE_ID cat > config.json < Optional[Path]: project_root = Path(__file__).resolve().parent candidates = [ project_root / ".venv" / "bin" / "python", project_root / ".venv" / "Scripts" / "python.exe", ] return next((candidate for candidate in candidates if candidate.exists()), None) def _maybe_reexec_into_local_venv() -> None: if sys.prefix != sys.base_prefix: return venv_python = _find_local_venv_python() if not venv_python: return os.execv(str(venv_python), [str(venv_python), __file__, *sys.argv[1:]]) _maybe_reexec_into_local_venv() try: import aiohttp import aiosqlite import discord from discord import app_commands from discord.ext import commands, tasks from config_loader import load_runtime_config except ModuleNotFoundError as exc: missing_package = exc.name or "a required package" print( f"Missing Python dependency: {missing_package}\n" "Create and activate a local virtual environment, then install requirements:\n" " python3 -m venv .venv\n" " source .venv/bin/activate\n" " python -m pip install -r requirements.txt", file=sys.stderr, ) sys.exit(1) # Configure logging logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") logger = logging.getLogger(__name__) # Constants DATABASE_PATH = "database/main.db" COGS_FOLDER = "cogs" SPAM_THRESHOLD = 5 SPAM_TIMEFRAME = 7 # seconds MESSAGE_LOG_BATCH_SIZE = 50 MESSAGE_LOG_FLUSH_SECONDS = 2 MESSAGE_LOG_RETENTION_DAYS = 30 config = load_runtime_config() TOKEN = config["DISCORD_TOKEN"] if not TOKEN: logger.critical("FATAL ERROR: DISCORD_TOKEN not found in config.") sys.exit(1) intents = discord.Intents.default() intents.message_content = True intents.members = True class FunBot(commands.Bot): def __init__(self, runtime_config: dict): super().__init__(command_prefix="!", intents=intents) self.config = runtime_config self.db: Optional[aiosqlite.Connection] = None self.http_session: Optional[aiohttp.ClientSession] = None self.user_message_timestamps = defaultdict(list) self.message_log_queue: asyncio.Queue[Optional[tuple[int, int, int, str]]] = asyncio.Queue() self.message_log_task: Optional[asyncio.Task] = None self.start_time = discord.utils.utcnow() async def setup_hook(self): """Initialize database and load cogs.""" # Ensure database directory exists os.makedirs(os.path.dirname(DATABASE_PATH), exist_ok=True) # Connect to database self.db = await aiosqlite.connect(DATABASE_PATH) await self.db.execute("PRAGMA journal_mode=WAL") await self.db.execute("PRAGMA foreign_keys=ON") logger.info("Successfully connected to the database.") self.http_session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) # Initialize database schema async with self.db.cursor() as cursor: await cursor.execute( """ CREATE TABLE IF NOT EXISTS messages ( message_id INTEGER PRIMARY KEY, guild_id INTEGER, user_id INTEGER, timestamp TEXT ) """ ) await cursor.execute( "CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp)" ) await self.db.commit() self.message_log_task = asyncio.create_task(self._message_log_worker()) self._prune_message_logs_task.start() # Load all cogs for filename in os.listdir(COGS_FOLDER): if filename.endswith(".py"): try: await self.load_extension(f"{COGS_FOLDER}.{filename[:-3]}") logger.info(f"Loaded cog: {filename}") except Exception as e: logger.error(f"Failed to load cog {filename}: {e}") # Sync slash commands await self.tree.sync() logger.info("Slash commands have been synced.") async def on_ready(self): print(f"Logged in as {self.user.name}") print(f"Bot ID: {self.user.id}") print("------") async def _flush_message_logs(self, batch: list[tuple[int, int, int, str]]) -> None: if not batch or not self.db: return async with self.db.cursor() as cursor: await cursor.executemany( "INSERT OR IGNORE INTO messages (message_id, guild_id, user_id, timestamp) VALUES (?, ?, ?, ?)", batch, ) await self.db.commit() async def _message_log_worker(self) -> None: batch: list[tuple[int, int, int, str]] = [] loop = asyncio.get_running_loop() try: while True: item = await self.message_log_queue.get() if item is None: break batch.append(item) deadline = loop.time() + MESSAGE_LOG_FLUSH_SECONDS while len(batch) < MESSAGE_LOG_BATCH_SIZE: timeout = deadline - loop.time() if timeout <= 0: break try: item = await asyncio.wait_for(self.message_log_queue.get(), timeout=timeout) except asyncio.TimeoutError: break if item is None: await self._flush_message_logs(batch) return batch.append(item) await self._flush_message_logs(batch) batch.clear() except asyncio.CancelledError: if batch: await self._flush_message_logs(batch) raise if batch: await self._flush_message_logs(batch) async def on_message(self, message: discord.Message): """Handle incoming messages for logging and spam detection.""" if message.author.bot: return # Log message to database if message.guild and self.db: try: await self.message_log_queue.put( (message.id, message.guild.id, message.author.id, message.created_at.isoformat()) ) except Exception as e: logger.error(f"Error logging message to database: {e}") # Anti-spam detection now = discord.utils.utcnow() spam_key = (message.guild.id if message.guild else 0, message.channel.id, message.author.id) user_timestamps = self.user_message_timestamps[spam_key] user_timestamps.append(now) # Clean old timestamps user_timestamps = [t for t in user_timestamps if (now - t).total_seconds() < SPAM_TIMEFRAME] self.user_message_timestamps[spam_key] = user_timestamps if len(user_timestamps) > SPAM_THRESHOLD: if len(user_timestamps) == SPAM_THRESHOLD + 1: try: await message.channel.send( f"{message.author.mention}, please slow down! Your recent messages will be deleted.", delete_after=10, ) def is_spam_message(m): return m.author == message.author and (now - m.created_at).total_seconds() < SPAM_TIMEFRAME try: await message.delete() except discord.HTTPException: pass await message.channel.purge(limit=SPAM_THRESHOLD + 1, check=is_spam_message, before=message) except discord.Forbidden: await message.channel.send( f"Warning for {message.author.mention}: Spam detected, but I don't have permission to delete messages." ) except Exception as e: logger.error(f"An error occurred during spam cleanup: {e}") # Process commands await self.process_commands(message) async def close(self): self._prune_message_logs_task.cancel() if self.message_log_task: await self.message_log_queue.put(None) try: await self.message_log_task except Exception: logger.exception("Error while flushing queued message logs during shutdown.") if self.http_session and not self.http_session.closed: await self.http_session.close() if self.db: await self.db.close() print("Database connection closed.") await super().close() @tasks.loop(hours=24) async def _prune_message_logs_task(self): if not self.db: return cutoff = (discord.utils.utcnow() - datetime.timedelta(days=MESSAGE_LOG_RETENTION_DAYS)).isoformat() async with self.db.cursor() as cursor: await cursor.execute("DELETE FROM messages WHERE timestamp < ?", (cutoff,)) await self.db.commit() @_prune_message_logs_task.before_loop async def _before_prune_message_logs(self): await self.wait_until_ready() bot = FunBot(config) @bot.tree.error async def on_app_command_error(interaction: discord.Interaction, error: app_commands.AppCommandError): if isinstance(error, app_commands.CommandOnCooldown): await interaction.response.send_message( f"This command is on cooldown. Please try again in {error.retry_after:.2f}s.", ephemeral=True ) elif isinstance(error, app_commands.MissingPermissions): await interaction.response.send_message( "You don't have the required permissions to use this command.", ephemeral=True ) elif isinstance(error, app_commands.CheckFailure): await interaction.response.send_message( "A check failed, you might not be able to use this command.", ephemeral=True ) else: print(f"An unhandled app command error occurred: {error}") if not interaction.response.is_done(): await interaction.response.send_message( "An unexpected error occurred. Please try again later.", ephemeral=True ) else: await interaction.followup.send("An unexpected error occurred. Please try again later.", ephemeral=True) print("Configuration loaded. Starting bot...") bot.run(TOKEN) ================================================ FILE: requirements-dev.txt ================================================ # Development dependencies -r requirements.txt # Code quality tools flake8>=7.0.0 black>=24.0.0 mypy>=1.8.0 pylint>=3.0.0 ================================================ FILE: requirements.txt ================================================ discord.py aiohttp python-dotenv aiosqlite Pillow ================================================ FILE: site/404.html ================================================ Milo | Page Not Found

404

Page not found.

The page you're looking for doesn't exist.

================================================ FILE: site/_headers ================================================ /* X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: camera=(), geolocation=(), microphone=() X-Frame-Options: DENY ================================================ FILE: site/index.html ================================================ Milo | Community Operations for Discord
M Milo

Open-Source Discord Bot

Support, moderation & ops in one bot.

Built for communities that want self-hosted tooling. Moderation, announcements, reminders, AI support, and workflows in one compact codebase.

242 Stars
MIT License
3.9+ Python
AI

AI-Assisted Support

Chat with model allowlists, web search, daily caps, and per-server policy controls.

MOD

Moderation

Warnings, invite filters, link filters, blocked words, and mod-log workflows.

Quick Start

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
python3 main.py
OPS

Community Ops

Welcome flows, goodbye messages, announcements, reminders, and utility commands.

OSS

Open Source

MIT licensed, contributor docs included, small enough to actually extend.

Getting Started

Three steps to running.

01

Clone

Pull the public repo. Configure secrets via .env or local config.

02

Configure

Set your Discord token and optional API keys before starting the bot.

03

Extend

Commands are split into cogs. Add or remove features independently.

Modules

Organized by concern.

Community

Welcome and goodbye flows, scheduled announcements, reminders, AFK handling.

  • Announcements & recurring reminders
  • Welcome and mod-log events
  • Server utility commands

Moderation

Policies for safer communities without a full enterprise panel.

  • Warnings and warning history
  • Invite and link filtering
  • Bad-word checks & whitelists

AI Support

AI chat, config controls, optional web search, per-server constraints.

  • Per-guild policies & cooldowns
  • Model allowlists & usage caps
  • Context isolation between users

Documentation

For contributors and admins.

FAQ

Common questions.

Is Milo only a fun bot?

No. Entertainment features exist, but the core value is community support and operations.

Does it need a hosted backend?

No. It runs as a self-hosted process. No SaaS dependency.

Who should use it?

Open-source communities, study groups, volunteer-run servers, and maintainers who want practical tooling.

Can I extend it?

Yes. Commands are organized into cogs. Add your own or modify existing ones.

================================================ FILE: site/script.js ================================================ document.addEventListener('DOMContentLoaded', () => { const revealTargets = document.querySelectorAll('.animate-on-scroll'); // Scroll reveal if ('IntersectionObserver' in window) { const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { entry.target.classList.add('is-visible'); observer.unobserve(entry.target); } }); }, { threshold: 0.08 }); revealTargets.forEach((el) => observer.observe(el)); } else { revealTargets.forEach((el) => el.classList.add('is-visible')); } // Copy button const copyBtn = document.querySelector('.copy-btn'); const codeBlock = document.querySelector('.card-code code'); const copyText = async (text) => { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); return; } const fallback = document.createElement('textarea'); fallback.value = text; fallback.setAttribute('readonly', ''); fallback.style.position = 'absolute'; fallback.style.left = '-9999px'; document.body.appendChild(fallback); fallback.select(); document.execCommand('copy'); fallback.remove(); }; const setCopyState = (label, stateClass) => { copyBtn.innerText = label; copyBtn.classList.toggle('copied', stateClass === 'copied'); copyBtn.classList.toggle('copy-failed', stateClass === 'failed'); }; if (copyBtn && codeBlock) { copyBtn.addEventListener('click', async () => { try { await copyText(codeBlock.innerText); setCopyState('Copied!', 'copied'); } catch (error) { console.error('Failed to copy quick start snippet.', error); setCopyState('Copy failed', 'failed'); } window.setTimeout(() => { setCopyState('Copy', ''); }, 2000); }); } // Scroll spy const sections = document.querySelectorAll('section[id]'); const navLinks = document.querySelectorAll('.topnav a'); const updateActiveLink = () => { let current = ''; sections.forEach((section) => { if (window.scrollY >= section.offsetTop - 120) { current = section.getAttribute('id'); } }); navLinks.forEach((link) => { link.classList.toggle('active', link.getAttribute('href') === `#${current}`); }); }; updateActiveLink(); window.addEventListener('scroll', updateActiveLink, { passive: true }); }); ================================================ FILE: site/styles.css ================================================ :root { --bg: #09090b; --card: #131316; --border: rgba(255, 255, 255, 0.08); --border-hover: rgba(255, 255, 255, 0.18); --text: #fafafa; --text-secondary: #a1a1aa; --text-muted: #52525b; --accent-1: #7c3aed; --accent-2: #6366f1; --accent-3: #3b82f6; --gradient: linear-gradient(135deg, var(--accent-1), var(--accent-2), var(--accent-3)); --radius: 16px; --radius-sm: 10px; } * { box-sizing: border-box; margin: 0; padding: 0; } html { scroll-behavior: smooth; scroll-padding-top: 24px; } body { font-family: "Inter", -apple-system, BlinkMacSystemFont, sans-serif; color: var(--text); background: var(--bg); line-height: 1.6; -webkit-font-smoothing: antialiased; overflow-x: hidden; } a { color: inherit; text-decoration: none; } /* --- Background Glows --- */ .glow { position: fixed; border-radius: 50%; filter: blur(120px); opacity: 0.15; pointer-events: none; z-index: 0; } .glow-1 { width: 600px; height: 600px; top: -200px; left: -100px; background: var(--accent-1); } .glow-2 { width: 500px; height: 500px; bottom: -100px; right: -150px; background: var(--accent-3); opacity: 0.1; } /* --- Layout --- */ .site-shell { position: relative; z-index: 1; width: min(1120px, calc(100% - 32px)); margin: 0 auto; padding: 0 0 40px; } /* --- Cards (shared) --- */ .card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); transition: border-color 0.3s ease, box-shadow 0.3s ease; } .card:hover { border-color: var(--border-hover); } /* --- Nav --- */ .topbar { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 16px; padding: 20px 0; position: relative; z-index: 100; background: rgba(9, 9, 11, 0.8); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border-bottom: 1px solid var(--border); } .brand { display: inline-flex; align-items: center; grid-column: 2; justify-self: center; gap: 10px; font-weight: 700; font-size: 1.05rem; } .brand-mark { display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 8px; background: var(--gradient); color: #fff; font-weight: 800; font-size: 0.8rem; } .topnav { display: flex; grid-column: 1; justify-self: start; gap: 28px; align-items: center; flex-wrap: wrap; } .nav-chip { display: inline-flex; align-items: center; justify-content: center; min-height: 36px; padding: 8px 14px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--card); color: var(--text-muted); font-size: 0.88rem; font-weight: 500; transition: color 0.2s, border-color 0.2s, background-color 0.2s; } .nav-chip:hover, .nav-chip.active { border-color: var(--border-hover); background: rgba(255, 255, 255, 0.04); color: var(--text); } .topbar-actions { display: flex; grid-column: 3; justify-content: flex-end; } /* --- Buttons --- */ .btn { display: inline-flex; align-items: center; gap: 8px; font-weight: 600; font-size: 0.88rem; border-radius: var(--radius-sm); transition: all 0.2s ease; cursor: pointer; border: none; } .btn-nav { padding: 8px 16px; background: var(--card); border: 1px solid var(--border); color: var(--text-secondary); } .btn-nav:hover { border-color: var(--border-hover); color: var(--text); } .btn-primary { padding: 12px 24px; background: var(--gradient); color: #fff; } .btn-primary:hover { opacity: 0.9; transform: translateY(-1px); } .btn-ghost { padding: 12px 24px; background: transparent; border: 1px solid var(--border); color: var(--text-secondary); } .btn-ghost:hover { border-color: var(--border-hover); color: var(--text); } /* --- Hero Bento --- */ .bento-hero { display: grid; grid-template-columns: 1fr 320px; gap: 16px; margin-top: 32px; } .card-hero { padding: 48px 44px; } .card-hero h1 { margin-top: 16px; font-size: clamp(2.4rem, 5vw, 3.6rem); font-weight: 800; line-height: 1.08; letter-spacing: -0.04em; background: linear-gradient(to bottom right, #fff 40%, var(--text-secondary)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } .sub { margin-top: 16px; color: var(--text-secondary); font-size: 1.05rem; max-width: 48ch; line-height: 1.7; } .hero-actions { display: flex; gap: 12px; margin-top: 32px; } .card-stats { display: flex; flex-direction: column; justify-content: center; gap: 24px; padding: 36px 32px; } .stat { display: flex; flex-direction: column; } .stat-val { font-size: 2rem; font-weight: 800; letter-spacing: -0.03em; background: var(--gradient); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } .stat-lbl { color: var(--text-muted); font-size: 0.82rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.08em; margin-top: 2px; } /* --- Labels & Tags --- */ .label { color: var(--text-muted); font-size: 0.78rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; } .tag { display: inline-block; padding: 4px 10px; border-radius: 6px; background: rgba(124, 58, 237, 0.12); color: #a78bfa; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; } /* --- Features Bento --- */ .bento-features { display: grid; grid-template-columns: repeat(3, 1fr); grid-template-rows: auto auto; gap: 16px; margin-top: 16px; } .card-feat { padding: 28px; } .card-feat h3 { margin-top: 14px; font-size: 1.1rem; font-weight: 700; } .card-feat p { margin-top: 8px; color: var(--text-secondary); font-size: 0.92rem; } /* AI card spans 2 columns */ .card-feat-ai { grid-column: span 2; position: relative; overflow: hidden; } .card-feat-ai::before { content: ""; position: absolute; top: 0; right: 0; width: 200px; height: 200px; background: radial-gradient(circle, rgba(124, 58, 237, 0.15) 0%, transparent 70%); pointer-events: none; } /* Code card spans 1 col, 2 rows */ .card-code { grid-column: 3; grid-row: 1 / 3; padding: 24px; position: relative; display: flex; flex-direction: column; } .card-code pre { margin-top: 16px; overflow-x: auto; font-family: "SF Mono", "Fira Code", "Fira Mono", monospace; font-size: 0.82rem; line-height: 1.9; flex: 1; } .card-code code { color: var(--text-secondary); } /* --- Copy Button --- */ .copy-btn { position: absolute; top: 16px; right: 16px; padding: 5px 12px; background: rgba(255, 255, 255, 0.06); border: 1px solid var(--border); border-radius: 6px; color: var(--text-muted); font-family: inherit; font-size: 0.72rem; font-weight: 600; cursor: pointer; transition: all 0.2s; } .copy-btn:hover { color: var(--text); border-color: var(--border-hover); } .copy-btn.copied { color: #a78bfa; border-color: rgba(124, 58, 237, 0.4); } /* --- Sections --- */ .section { margin-top: 80px; scroll-margin-top: 24px; } .section-head { margin-bottom: 32px; } .section-head h2 { margin-top: 8px; font-size: clamp(1.5rem, 3vw, 2rem); font-weight: 800; letter-spacing: -0.03em; } /* --- Steps --- */ .steps-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; } .card-step { padding: 28px; position: relative; } .step-num { display: inline-block; font-size: 2.2rem; font-weight: 800; letter-spacing: -0.04em; background: var(--gradient); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; opacity: 0.5; } .card-step h3 { margin-top: 12px; font-size: 1.1rem; font-weight: 700; } .card-step p { margin-top: 8px; color: var(--text-secondary); font-size: 0.92rem; } .card-step code { padding: 2px 6px; background: rgba(255, 255, 255, 0.06); border-radius: 4px; font-family: "SF Mono", monospace; font-size: 0.85em; } /* --- Modules --- */ .module-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; } .card-module { padding: 28px; } .card-module h3 { font-size: 1.1rem; font-weight: 700; } .card-module p { margin-top: 8px; color: var(--text-secondary); font-size: 0.92rem; } .card-module ul { margin-top: 16px; padding-left: 16px; color: var(--text-muted); font-size: 0.88rem; line-height: 1.9; } /* --- Docs --- */ .docs-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; } .card-doc { display: block; padding: 28px; position: relative; } .card-doc:hover { border-color: rgba(124, 58, 237, 0.3); box-shadow: 0 0 24px -8px rgba(124, 58, 237, 0.15); } .doc-tag { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); } .card-doc h3 { margin-top: 10px; font-size: 1.1rem; font-weight: 700; } .card-doc p { margin-top: 6px; color: var(--text-secondary); font-size: 0.88rem; } .card-arrow { position: absolute; top: 28px; right: 28px; color: var(--text-muted); font-size: 1.2rem; transition: transform 0.2s, color 0.2s; } .card-doc:hover .card-arrow { transform: translateX(4px); color: #a78bfa; } /* --- FAQ --- */ .section-faq { border-top: 1px solid var(--border); padding-top: 64px; } .faq-list { display: flex; flex-direction: column; } .faq-item { padding: 20px 0; border-bottom: 1px solid var(--border); } .faq-item h3 { font-size: 0.98rem; font-weight: 600; } .faq-item p { margin-top: 6px; color: var(--text-secondary); font-size: 0.92rem; } /* --- Footer --- */ .footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 64px; padding: 24px 0; border-top: 1px solid var(--border); } .footer-left { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 0.95rem; } .footer-note { color: var(--text-muted); font-size: 0.85rem; } .footer-links { display: flex; gap: 20px; font-size: 0.88rem; color: var(--text-muted); } .footer-links a:hover { color: var(--text); } /* --- Scroll Animations --- */ .animate-on-scroll, .is-visible { opacity: 1; transform: translateY(0); } .js .animate-on-scroll { opacity: 0; transform: translateY(20px); transition: opacity 0.7s cubic-bezier(0.16, 1, 0.3, 1), transform 0.7s cubic-bezier(0.16, 1, 0.3, 1); } .js .is-visible { opacity: 1; transform: translateY(0); } /* Stagger for bento grids */ .bento-features .animate-on-scroll:nth-child(2) { transition-delay: 0.06s; } .bento-features .animate-on-scroll:nth-child(3) { transition-delay: 0.12s; } .bento-features .animate-on-scroll:nth-child(4) { transition-delay: 0.18s; } .bento-features .animate-on-scroll:nth-child(5) { transition-delay: 0.24s; } .steps-grid .animate-on-scroll:nth-child(2) { transition-delay: 0.08s; } .steps-grid .animate-on-scroll:nth-child(3) { transition-delay: 0.16s; } .module-grid .animate-on-scroll:nth-child(2) { transition-delay: 0.08s; } .module-grid .animate-on-scroll:nth-child(3) { transition-delay: 0.16s; } .docs-grid .animate-on-scroll:nth-child(2) { transition-delay: 0.06s; } .docs-grid .animate-on-scroll:nth-child(3) { transition-delay: 0.12s; } .docs-grid .animate-on-scroll:nth-child(4) { transition-delay: 0.18s; } /* --- Responsive --- */ @media (max-width: 960px) { .topbar { grid-template-columns: 1fr; } .brand, .topnav, .topbar-actions { grid-column: auto; justify-self: center; } .bento-hero { grid-template-columns: 1fr; } .card-stats { flex-direction: row; justify-content: flex-start; gap: 40px; } .bento-features { grid-template-columns: 1fr 1fr; grid-template-rows: auto; } .card-feat-ai { grid-column: span 2; } .card-code { grid-column: span 2; grid-row: auto; } .steps-grid, .module-grid { grid-template-columns: 1fr; } .docs-grid { grid-template-columns: 1fr; } } @media (max-width: 640px) { .site-shell { width: calc(100% - 24px); } .topbar { padding: 14px 0; gap: 12px; } .topnav { display: flex; justify-content: center; flex-wrap: wrap; gap: 10px; width: 100%; } .nav-chip { width: auto; min-height: 40px; padding: 9px 12px; } .topbar-actions { width: 100%; justify-content: center; } .btn-nav { width: min(100%, 220px); justify-content: center; } .bento-features { grid-template-columns: 1fr; } .card-feat-ai { grid-column: auto; } .card-code { grid-column: auto; } .card-hero { padding: 32px 24px; } .card-hero h1 { font-size: clamp(2rem, 11vw, 2.85rem); } .sub { font-size: 0.98rem; line-height: 1.65; } .hero-actions { flex-direction: column; } .btn-primary, .btn-ghost, .btn-nav { justify-content: center; } .card-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; padding: 22px 18px; } .stat { text-align: center; } .stat-val { font-size: 1.6rem; } .card-feat, .card-code, .card-step, .card-module, .card-doc { padding: 22px 20px; } .card-code pre { margin-top: 18px; font-size: 0.76rem; line-height: 1.75; } .copy-btn { top: 12px; right: 12px; } .footer { flex-direction: column; align-items: flex-start; gap: 12px; } } @media (prefers-reduced-motion: reduce) { * { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } .animate-on-scroll { opacity: 1 !important; transform: none !important; } } ================================================ FILE: tests/conftest.py ================================================ import sys from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[1] if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) ================================================ FILE: tests/test_config_loader.py ================================================ import json from config_loader import load_runtime_config def test_load_runtime_config_prefers_env_over_file(monkeypatch, tmp_path): monkeypatch.chdir(tmp_path) monkeypatch.setenv("DISCORD_TOKEN", "env-token") monkeypatch.setenv("ALLOW_USER_KEYS", "false") monkeypatch.setenv("ALLOWED_CHAT_MODELS", "gpt-4o-mini,gpt-4o") (tmp_path / "config.json").write_text( json.dumps( { "DISCORD_TOKEN": "file-token", "ALLOW_USER_KEYS": True, "ALLOWED_CHAT_MODELS": ["file-model"], } ), encoding="utf-8", ) config = load_runtime_config() assert config["DISCORD_TOKEN"] == "env-token" assert config["ALLOW_USER_KEYS"] is False assert config["ALLOWED_CHAT_MODELS"] == ["gpt-4o-mini", "gpt-4o"] def test_load_runtime_config_falls_back_to_config_json(monkeypatch, tmp_path): monkeypatch.chdir(tmp_path) for key in ( "DISCORD_TOKEN", "OPENAI_API_KEY", "OPENAI_API_BASE", "ALLOW_USER_KEYS", "DEFAULT_CHAT_MODEL", "ALLOWED_CHAT_MODELS", "GOOGLE_API_KEY", "GOOGLE_CSE_ID", ): monkeypatch.delenv(key, raising=False) (tmp_path / "config.json").write_text( json.dumps( { "DISCORD_TOKEN": "file-token", "OPENAI_API_KEY": "openai-key", "OPENAI_API_BASE": "https://example.invalid/v1", "ALLOW_USER_KEYS": False, "DEFAULT_CHAT_MODEL": "gpt-test", "ALLOWED_CHAT_MODELS": ["gpt-test", "gpt-other"], "GOOGLE_API_KEY": "google-key", "GOOGLE_CSE_ID": "search-id", } ), encoding="utf-8", ) config = load_runtime_config() assert config["DISCORD_TOKEN"] == "file-token" assert config["OPENAI_API_KEY"] == "openai-key" assert config["OPENAI_API_BASE"] == "https://example.invalid/v1" assert config["ALLOW_USER_KEYS"] is False assert config["DEFAULT_CHAT_MODEL"] == "gpt-test" assert config["ALLOWED_CHAT_MODELS"] == ["gpt-test", "gpt-other"] assert config["GOOGLE_API_KEY"] == "google-key" assert config["GOOGLE_CSE_ID"] == "search-id" ================================================ FILE: tests/test_duration_parsing.py ================================================ from cogs.community import parse_duration from cogs.utility import parse_duration_spec def test_parse_duration_spec_supports_seconds_minutes_hours_days(): assert parse_duration_spec("15s") == 15 assert parse_duration_spec("2m") == 120 assert parse_duration_spec("3h") == 10800 assert parse_duration_spec("4d") == 345600 def test_parse_duration_spec_rejects_invalid_values(): assert parse_duration_spec("10") is None assert parse_duration_spec("abc") is None assert parse_duration_spec("5w") is None def test_parse_duration_supports_minutes_hours_days(): assert parse_duration("15m") == 900 assert parse_duration("2h") == 7200 assert parse_duration("3d") == 259200 def test_parse_duration_rejects_invalid_values(): assert parse_duration("") is None assert parse_duration("30") is None assert parse_duration("abc") is None assert parse_duration("7s") is None ================================================ FILE: tests/test_issue_fixes.py ================================================ from datetime import timedelta from types import SimpleNamespace from unittest.mock import AsyncMock import discord import pytest from cogs.chat import COOLDOWN_RETENTION, Chat from cogs.community import Community, SCHEDULE_BATCH_SIZE from cogs.economy import Economy from cogs.utility import REMINDER_BATCH_SIZE, Utility class FakeCursor: def __init__(self, rows=None): self.rows = rows or [] self.executed = [] async def __aenter__(self): return self async def __aexit__(self, exc_type, exc, tb): return False async def execute(self, query, params=()): self.executed.append((query, params)) async def fetchall(self): return list(self.rows) class FakeDB: def __init__(self, rows=None): self.rows = rows or [] self.cursor_instances = [] self.commit_calls = 0 def cursor(self): cursor = FakeCursor(self.rows) self.cursor_instances.append(cursor) return cursor async def commit(self): self.commit_calls += 1 @pytest.mark.asyncio async def test_afk_listener_deduplicates_repeated_mentions(): utility = Utility.__new__(Utility) utility.bot = SimpleNamespace() utility.afk_cache = {} utility.get_afk_status = AsyncMock(return_value=None) utility.clear_afk_status = AsyncMock() set_at = (discord.utils.utcnow() - timedelta(minutes=5)).isoformat() utility.get_many_afk_statuses = AsyncMock(return_value={42: ("Lunch", set_at)}) mentioned_user = SimpleNamespace(id=42, bot=False, display_name="Casey") channel = SimpleNamespace(send=AsyncMock()) message = SimpleNamespace( author=SimpleNamespace(id=100, bot=False), guild=SimpleNamespace(id=555), channel=channel, mentions=[mentioned_user, mentioned_user], ) await Utility.afk_message_listener(utility, message) utility.get_many_afk_statuses.assert_awaited_once_with(555, [42]) channel.send.assert_awaited_once() sent_text = channel.send.await_args.args[0] assert sent_text.count("Casey is AFK") == 1 @pytest.mark.asyncio async def test_reminder_loop_queries_due_work_in_bounded_batches(): utility = Utility.__new__(Utility) utility.bot = SimpleNamespace(db=FakeDB(), get_user=lambda user_id: None) await Utility.reminder_loop.coro(utility) cursor = utility.bot.db.cursor_instances[0] assert cursor.executed[0][1][1] == REMINDER_BATCH_SIZE @pytest.mark.asyncio async def test_schedule_loop_queries_due_work_in_bounded_batches(): community = Community.__new__(Community) community.bot = SimpleNamespace(db=FakeDB()) await Community.schedule_loop.coro(community) cursor = community.bot.db.cursor_instances[0] assert cursor.executed[0][1][1] == SCHEDULE_BATCH_SIZE @pytest.mark.asyncio async def test_chat_prune_cooldowns_removes_only_expired_entries(): chat = Chat.__new__(Chat) now = discord.utils.utcnow() expired_key = (1, 1) fresh_key = (1, 2) chat.chat_cooldowns = { expired_key: now - COOLDOWN_RETENTION - timedelta(minutes=1), fresh_key: now - timedelta(minutes=5), } await Chat._prune_cooldowns.coro(chat) assert expired_key not in chat.chat_cooldowns assert fresh_key in chat.chat_cooldowns def test_economy_guild_lock_is_scoped_per_guild(): economy = Economy.__new__(Economy) economy._guild_locks = {} guild_one_lock = economy._guild_lock(1) same_guild_lock = economy._guild_lock(1) other_guild_lock = economy._guild_lock(2) assert guild_one_lock is same_guild_lock assert guild_one_lock is not other_guild_lock ================================================ FILE: tests/test_retry_delays.py ================================================ from datetime import timedelta from cogs.community import Community, SCHEDULE_RETRY_MAX_SECONDS from cogs.utility import REMINDER_RETRY_MAX_SECONDS, Utility def test_reminder_retry_delay_grows_and_caps(): utility = Utility.__new__(Utility) assert utility.reminder_retry_delay(1) == timedelta(minutes=5) assert utility.reminder_retry_delay(2) == timedelta(minutes=10) assert utility.reminder_retry_delay(3) == timedelta(minutes=20) assert utility.reminder_retry_delay(99) == timedelta(seconds=REMINDER_RETRY_MAX_SECONDS) def test_schedule_retry_delay_grows_and_caps(): community = Community.__new__(Community) assert community.schedule_retry_delay(1) == timedelta(minutes=5) assert community.schedule_retry_delay(2) == timedelta(minutes=10) assert community.schedule_retry_delay(3) == timedelta(minutes=20) assert community.schedule_retry_delay(99) == timedelta(seconds=SCHEDULE_RETRY_MAX_SECONDS)