diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b1105d5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,190 @@ +# AGENTS.md - Discord Bot v2.0 + +Guidelines for AI coding agents working in this repository. + +## Quick Reference + +**Start bot**: `python bot.py` +**Run all tests**: `python -m pytest --tb=short -q` +**Run single test file**: `python -m pytest tests/test_models.py -v` +**Run single test**: `python -m pytest tests/test_models.py::TestTeamModel::test_team_creation_minimal -v` +**Run tests matching pattern**: `python -m pytest -k "test_player" -v` + +## Project Structure + +- `bot.py` - Main entry point +- `commands/` - Discord slash commands (package-based) +- `services/` - API service layer (BaseService pattern) +- `models/` - Pydantic data models +- `views/` - Discord UI components (embeds, modals) +- `utils/` - Logging, decorators, caching +- `tests/` - pytest test suite + +## Code Style + +### Imports +Order: stdlib, third-party, local. Separate groups with blank lines. + +```python +import asyncio +from typing import Optional, List + +import discord +from discord.ext import commands + +from services.player_service import player_service +from utils.decorators import logged_command +``` + +### Formatting +- Line length: 100 characters max +- Docstrings: Google style with triple quotes +- Indentation: 4 spaces +- Trailing commas in multi-line structures + +### Type Hints +Always use type hints for function signatures: + +```python +async def get_player(self, player_id: int) -> Optional[Player]: +async def search_players(self, query: str, limit: int = 10) -> List[Player]: +``` + +### Naming Conventions +- Classes: `PascalCase` (PlayerService, TeamInfoCommands) +- Functions/methods: `snake_case` (get_player, search_players) +- Constants: `UPPER_SNAKE_CASE` (SBA_CURRENT_SEASON) +- Private: prefix with `_` (_client, _team_service) + +### Error Handling +Use custom exceptions from `exceptions.py`. Prefer "raise or return" over Optional: + +```python +from exceptions import APIException, PlayerNotFoundError + +async def get_player(self, player_id: int) -> Player: + result = await self.get_by_id(player_id) + if result is None: + raise PlayerNotFoundError(f"Player {player_id} not found") + return result +``` + +## Discord Command Patterns + +### Always use @logged_command decorator +Eliminates boilerplate logging. Class must have `self.logger` attribute: + +```python +class PlayerInfoCommands(commands.Cog): + def __init__(self, bot): + self.bot = bot + self.logger = get_contextual_logger(f'{__name__}.PlayerInfoCommands') + + @discord.app_commands.command(name="player") + @logged_command("/player") + async def player_command(self, interaction, name: str): + # Business logic only - no try/catch boilerplate needed + player = await player_service.get_player_by_name(name) + await interaction.followup.send(embed=create_embed(player)) +``` + +### Autocomplete: Use standalone functions (not methods) +```python +async def player_name_autocomplete( + interaction: discord.Interaction, + current: str, +) -> List[discord.app_commands.Choice[str]]: + if len(current) < 2: + return [] + try: + players = await player_service.search_players(current, limit=25) + return [discord.app_commands.Choice(name=p.name, value=p.name) for p in players] + except Exception: + return [] # Never break autocomplete + +class MyCommands(commands.Cog): + @discord.app_commands.command() + @discord.app_commands.autocomplete(name=player_name_autocomplete) + async def my_command(self, interaction, name: str): ... +``` + +### Embed emoji rules +Template methods auto-add emojis. Never double up: + +```python +# CORRECT - template adds emoji +embed = EmbedTemplate.success(title="Operation Completed") # Results in: "Operation Completed" + +# WRONG - double emoji +embed = EmbedTemplate.success(title="Operation Completed") # Results in: " Operation Completed" + +# For custom emoji, use create_base_embed +embed = EmbedTemplate.create_base_embed(title="Custom Title", color=EmbedColors.SUCCESS) +``` + +## Service Layer + +### Never bypass services for API calls +```python +# CORRECT +player = await player_service.get_player(player_id) + +# WRONG - never do this +client = await player_service.get_client() +await client.get(f'players/{player_id}') +``` + +### Key service methods +- `TeamService.get_team(team_id)` - not `get_team_by_id()` +- `PlayerService.search_players(query, limit, all_seasons=True)` - cross-season search + +## Models + +### Use from_api_data() classmethod +```python +player = Player.from_api_data(api_response) +``` + +### Database entities require id field +```python +class Player(SBABaseModel): + id: int = Field(..., description="Player ID from database") # Required, not Optional +``` + +## Testing + +### Use aioresponses for HTTP mocking +```python +from aioresponses import aioresponses + +@pytest.mark.asyncio +async def test_get_player(): + with aioresponses() as m: + m.get("https://api.example.com/v3/players/1", payload={"id": 1, "name": "Test"}) + result = await api_client.get("players", object_id=1) + assert result["name"] == "Test" +``` + +### Provide complete model data +Pydantic validates all fields. Use helper functions for test data: + +```python +def create_player_data(player_id: int, name: str, **kwargs): + return {"id": player_id, "name": name, "wara": 2.5, "season": 13, "pos_1": "CF", **kwargs} +``` + +## Critical Rules + +1. **Git**: Never commit directly to `main`. Create feature branches. +2. **Services**: Always use service layer methods, never direct API client access. +3. **Embeds**: Don't add emojis to titles when using template methods (success/error/warning/info). +4. **Tests**: Include docstrings explaining "what" and "why" for each test. +5. **Commits**: Do not commit without user approval. + +## Documentation + +Check `CLAUDE.md` files in directories for detailed patterns: +- `commands/CLAUDE.md` - Command architecture +- `services/CLAUDE.md` - Service patterns +- `models/CLAUDE.md` - Model validation +- `tests/CLAUDE.md` - Testing strategies