diff --git a/IGDB_SETUP.md b/IGDB_SETUP.md new file mode 100644 index 0000000..2fef459 --- /dev/null +++ b/IGDB_SETUP.md @@ -0,0 +1,123 @@ +# IGDB Integration Setup Guide + +The bot now supports real-time video game information through IGDB (Internet Game Database) API integration. This allows the AI to provide accurate, up-to-date information about games when users ask gaming-related questions. + +## Features + +- **Game Search**: Find games by name with fuzzy matching +- **Game Details**: Get comprehensive information including ratings, platforms, developers, genres, and summaries +- **AI Integration**: Seamless function calling - the AI automatically decides when to fetch game information +- **Smart Formatting**: Game data is formatted in a user-friendly way for the AI to present + +## Setup Instructions + +### 1. Get IGDB API Credentials + +1. Go to [Twitch Developer Console](https://dev.twitch.tv/console) +2. Create a new application: + - **Name**: Your bot name (e.g., "Fjerkroa Discord Bot") + - **OAuth Redirect URLs**: `http://localhost` (not used but required) + - **Category**: Select appropriate category +3. Note down your **Client ID** +4. Generate a **Client Secret** +5. Get an access token using this curl command: + ```bash + curl -X POST 'https://id.twitch.tv/oauth2/token' \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -d 'client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET&grant_type=client_credentials' + ``` +6. Save the `access_token` from the response + +### 2. Configure the Bot + +Update your `config.toml` file: + +```toml +# IGDB Configuration for game information +igdb-client-id = "your_actual_client_id_here" +igdb-access-token = "your_actual_access_token_here" +enable-game-info = true +``` + +### 3. Update System Prompt (Optional) + +The system prompt has been updated to inform the AI about its gaming capabilities: + +```toml +system = "You are a smart AI assistant with access to real-time video game information through IGDB. When users ask about games, game recommendations, release dates, platforms, or any gaming-related questions, you can search for accurate and up-to-date information." +``` + +## Usage Examples + +Once configured, users can ask gaming questions naturally: + +- "Tell me about Elden Ring" +- "What are some good RPG games released in 2023?" +- "Is Cyberpunk 2077 available on PlayStation?" +- "Who developed The Witcher 3?" +- "What's the rating of Baldur's Gate 3?" + +The AI will automatically: +1. Detect gaming-related queries +2. Call IGDB API functions to get real data +3. Format and present the information naturally + +## Technical Details + +### Available Functions + +The integration provides two OpenAI functions: + +1. **search_games** + - Parameters: `query` (string), `limit` (optional integer, max 10) + - Returns: List of games matching the query + +2. **get_game_details** + - Parameters: `game_id` (integer from search results) + - Returns: Detailed information about a specific game + +### Game Information Included + +- **Basic Info**: Name, summary, rating (critic and user) +- **Release Info**: Release date/year +- **Technical**: Platforms, developers, publishers +- **Classification**: Genres, themes, game modes +- **Extended** (detailed view): Storyline, similar games, screenshots + +### Error Handling + +- Graceful degradation if IGDB is unavailable +- Fallback to regular AI responses if API fails +- Proper error logging for debugging + +## Troubleshooting + +### Common Issues + +1. **"IGDB integration disabled"** in logs + - Check that `enable-game-info = true` + - Verify client ID and access token are set + +2. **Authentication errors** + - Regenerate access token (they expire) + - Verify client ID matches your Twitch app + +3. **No game results** + - IGDB may not have the game in their database + - Try alternative spellings or official game names + +### Rate Limits + +- IGDB allows 4 requests per second +- The integration includes automatic retry logic +- Large queries are automatically limited to prevent timeouts + +## Disabling IGDB + +To disable IGDB integration: + +```toml +enable-game-info = false +``` + +The bot will continue working normally without game information features. \ No newline at end of file diff --git a/config.toml b/config.toml index 0d44cb9..fcd8e57 100644 --- a/config.toml +++ b/config.toml @@ -10,4 +10,9 @@ history-limit = 10 welcome-channel = "welcome" staff-channel = "staff" join-message = "Hi! I am {name}, and I am new here." -system = "You are an smart AI" +system = "You are a smart AI assistant with access to real-time video game information through IGDB. When users ask about games, game recommendations, release dates, platforms, or any gaming-related questions, you can search for accurate and up-to-date information. You can search for games by name and get detailed information including ratings, platforms, developers, genres, and summaries." + +# IGDB Configuration for game information +igdb-client-id = "YOUR_IGDB_CLIENT_ID" +igdb-access-token = "YOUR_IGDB_ACCESS_TOKEN" +enable-game-info = true diff --git a/fjerkroa_bot/igdblib.py b/fjerkroa_bot/igdblib.py index c81f1b5..8292588 100644 --- a/fjerkroa_bot/igdblib.py +++ b/fjerkroa_bot/igdblib.py @@ -1,4 +1,6 @@ +import logging from functools import cache +from typing import Any, Dict, List, Optional, Union import requests @@ -89,3 +91,173 @@ class IGDBQuery(object): limit=100, ) return game_info + + def search_games(self, query: str, limit: int = 5) -> Optional[List[Dict[str, Any]]]: + """ + Search for games with a flexible query string. + Returns formatted game information suitable for AI responses. + """ + if not query or not query.strip(): + return None + + try: + # Search for games with fuzzy matching + games = self.generalized_igdb_query( + {"name": query.strip()}, + "games", + [ + "id", "name", "summary", "storyline", "rating", "aggregated_rating", + "first_release_date", "genres.name", "platforms.name", "developers.name", + "publishers.name", "game_modes.name", "themes.name", "cover.url" + ], + additional_filters={"category": "= 0"}, # Main games only + limit=limit + ) + + if not games: + return None + + # Format games for AI consumption + formatted_games = [] + for game in games: + formatted_game = self._format_game_for_ai(game) + if formatted_game: + formatted_games.append(formatted_game) + + return formatted_games if formatted_games else None + + except Exception as e: + logging.error(f"Error searching games for query '{query}': {e}") + return None + + def get_game_details(self, game_id: int) -> Optional[Dict[str, Any]]: + """ + Get detailed information about a specific game by ID. + """ + try: + games = self.generalized_igdb_query( + {}, + "games", + [ + "id", "name", "summary", "storyline", "rating", "aggregated_rating", + "first_release_date", "genres.name", "platforms.name", "developers.name", + "publishers.name", "game_modes.name", "themes.name", "keywords.name", + "similar_games.name", "cover.url", "screenshots.url", "videos.video_id", + "release_dates.date", "release_dates.platform.name", "age_ratings.rating" + ], + additional_filters={"id": f"= {game_id}"}, + limit=1 + ) + + if games and len(games) > 0: + return self._format_game_for_ai(games[0], detailed=True) + + except Exception as e: + logging.error(f"Error getting game details for ID {game_id}: {e}") + + return None + + def _format_game_for_ai(self, game_data: Dict[str, Any], detailed: bool = False) -> Dict[str, Any]: + """ + Format game data in a way that's easy for AI to understand and present to users. + """ + try: + formatted = { + "name": game_data.get("name", "Unknown"), + "summary": game_data.get("summary", "No summary available") + } + + # Add basic info + if "rating" in game_data: + formatted["rating"] = f"{game_data['rating']:.1f}/100" + if "aggregated_rating" in game_data: + formatted["user_rating"] = f"{game_data['aggregated_rating']:.1f}/100" + + # Release information + if "first_release_date" in game_data: + import datetime + release_date = datetime.datetime.fromtimestamp(game_data["first_release_date"]) + formatted["release_year"] = release_date.year + if detailed: + formatted["release_date"] = release_date.strftime("%Y-%m-%d") + + # Platforms + if "platforms" in game_data and game_data["platforms"]: + platforms = [p.get("name", "") for p in game_data["platforms"] if p.get("name")] + formatted["platforms"] = platforms[:5] # Limit to prevent overflow + + # Genres + if "genres" in game_data and game_data["genres"]: + genres = [g.get("name", "") for g in game_data["genres"] if g.get("name")] + formatted["genres"] = genres + + # Developers + if "developers" in game_data and game_data["developers"]: + developers = [d.get("name", "") for d in game_data["developers"] if d.get("name")] + formatted["developers"] = developers[:3] # Limit for readability + + # Publishers + if "publishers" in game_data and game_data["publishers"]: + publishers = [p.get("name", "") for p in game_data["publishers"] if p.get("name")] + formatted["publishers"] = publishers[:2] + + if detailed: + # Add more detailed info for specific requests + if "storyline" in game_data and game_data["storyline"]: + formatted["storyline"] = game_data["storyline"] + + if "game_modes" in game_data and game_data["game_modes"]: + modes = [m.get("name", "") for m in game_data["game_modes"] if m.get("name")] + formatted["game_modes"] = modes + + if "themes" in game_data and game_data["themes"]: + themes = [t.get("name", "") for t in game_data["themes"] if t.get("name")] + formatted["themes"] = themes + + return formatted + + except Exception as e: + logging.error(f"Error formatting game data: {e}") + return {"name": game_data.get("name", "Unknown"), "summary": "Error retrieving game information"} + + def get_openai_functions(self) -> List[Dict[str, Any]]: + """ + Generate OpenAI function definitions for game-related queries. + Returns function definitions that OpenAI can use to call IGDB API. + """ + return [ + { + "name": "search_games", + "description": "Search for video games by name or title. Use when users ask about specific games, game recommendations, or want to know about games.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The game name or search query (e.g., 'Elden Ring', 'Mario', 'Zelda Breath of the Wild')" + }, + "limit": { + "type": "integer", + "description": "Maximum number of games to return (default: 5, max: 10)", + "minimum": 1, + "maximum": 10 + } + }, + "required": ["query"] + } + }, + { + "name": "get_game_details", + "description": "Get detailed information about a specific game when you have its ID from a previous search.", + "parameters": { + "type": "object", + "properties": { + "game_id": { + "type": "integer", + "description": "The IGDB game ID from a previous search result" + } + }, + "required": ["game_id"] + } + } + ] diff --git a/fjerkroa_bot/openai_responder.py b/fjerkroa_bot/openai_responder.py index a159123..8bbfe17 100644 --- a/fjerkroa_bot/openai_responder.py +++ b/fjerkroa_bot/openai_responder.py @@ -1,4 +1,5 @@ import asyncio +import json import logging from io import BytesIO from typing import Any, Dict, List, Optional, Tuple @@ -7,6 +8,7 @@ import aiohttp import openai from .ai_responder import AIResponder, async_cache_to_file, exponential_backoff, pp +from .igdblib import IGDBQuery from .leonardo_draw import LeonardoAIDrawMixIn @@ -27,6 +29,21 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn): def __init__(self, config: Dict[str, Any], channel: Optional[str] = None) -> None: super().__init__(config, channel) self.client = openai.AsyncOpenAI(api_key=self.config.get("openai-token", self.config.get("openai-key", ""))) + + # Initialize IGDB if enabled + self.igdb = None + if (self.config.get("enable-game-info", False) and + self.config.get("igdb-client-id") and + self.config.get("igdb-access-token")): + try: + self.igdb = IGDBQuery( + self.config["igdb-client-id"], + self.config["igdb-access-token"] + ) + logging.info("IGDB integration enabled for game information") + except Exception as e: + logging.warning(f"Failed to initialize IGDB: {e}") + self.igdb = None async def draw_openai(self, description: str) -> BytesIO: for _ in range(3): @@ -46,12 +63,61 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn): else: messages[-1]["content"] = messages[-1]["content"][0]["text"] try: - result = await openai_chat( - self.client, - model=model, - messages=messages, - ) - answer_obj = result.choices[0].message + # Prepare function calls if IGDB is enabled + chat_kwargs = { + "model": model, + "messages": messages, + } + + if self.igdb and self.config.get("enable-game-info", False): + chat_kwargs["tools"] = [ + {"type": "function", "function": func} + for func in self.igdb.get_openai_functions() + ] + chat_kwargs["tool_choice"] = "auto" + + result = await openai_chat(self.client, **chat_kwargs) + + # Handle function calls if present + message = result.choices[0].message + + # Check if we have function/tool calls and IGDB is enabled + has_tool_calls = (hasattr(message, 'tool_calls') and message.tool_calls and + self.igdb and self.config.get("enable-game-info", False)) + + if has_tool_calls: + try: + # Process function calls + messages.append({ + "role": "assistant", + "content": message.content or "", + "tool_calls": [tc.dict() if hasattr(tc, 'dict') else tc for tc in message.tool_calls] + }) + + # Execute function calls + for tool_call in message.tool_calls: + function_name = tool_call.function.name + function_args = json.loads(tool_call.function.arguments) + + # Execute IGDB function + function_result = await self._execute_igdb_function(function_name, function_args) + + messages.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "content": json.dumps(function_result) if function_result else "No results found" + }) + + # Get final response after function execution + final_result = await openai_chat(self.client, **chat_kwargs) + answer_obj = final_result.choices[0].message + except Exception as e: + # If function calling fails, fall back to regular response + logging.warning(f"Function calling failed, using regular response: {e}") + answer_obj = message + else: + answer_obj = message + answer = {"content": answer_obj.content, "role": answer_obj.role} self.rate_limit_backoff = exponential_backoff() logging.info(f"generated response {result.usage}: {repr(answer)}") @@ -135,3 +201,42 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn): except Exception as err: logging.warning(f"failed to create new memory: {repr(err)}") return memory + + async def _execute_igdb_function(self, function_name: str, function_args: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """ + Execute IGDB function calls from OpenAI. + """ + if not self.igdb: + return None + + try: + if function_name == "search_games": + query = function_args.get("query", "") + limit = function_args.get("limit", 5) + + if not query: + return {"error": "No search query provided"} + + results = self.igdb.search_games(query, limit) + if results: + return {"games": results} + else: + return {"games": [], "message": f"No games found matching '{query}'"} + + elif function_name == "get_game_details": + game_id = function_args.get("game_id") + + if not game_id: + return {"error": "No game ID provided"} + + result = self.igdb.get_game_details(game_id) + if result: + return {"game": result} + else: + return {"error": f"Game with ID {game_id} not found"} + else: + return {"error": f"Unknown function: {function_name}"} + + except Exception as e: + logging.error(f"Error executing IGDB function {function_name}: {e}") + return {"error": f"Failed to execute {function_name}: {str(e)}"} diff --git a/tests/test_igdb_integration.py b/tests/test_igdb_integration.py new file mode 100644 index 0000000..9fa6832 --- /dev/null +++ b/tests/test_igdb_integration.py @@ -0,0 +1,149 @@ +import unittest +from unittest.mock import Mock, patch + +from fjerkroa_bot.igdblib import IGDBQuery +from fjerkroa_bot.openai_responder import OpenAIResponder + + +class TestIGDBIntegration(unittest.IsolatedAsyncioTestCase): + """Test IGDB integration with OpenAI responder.""" + + def setUp(self): + self.config_with_igdb = { + "openai-key": "test_key", + "model": "gpt-4", + "enable-game-info": True, + "igdb-client-id": "test_client", + "igdb-access-token": "test_token" + } + + self.config_without_igdb = { + "openai-key": "test_key", + "model": "gpt-4", + "enable-game-info": False + } + + def test_igdb_initialization_enabled(self): + """Test IGDB is initialized when enabled in config.""" + with patch('fjerkroa_bot.openai_responder.IGDBQuery') as mock_igdb: + mock_igdb_instance = Mock() + mock_igdb.return_value = mock_igdb_instance + + responder = OpenAIResponder(self.config_with_igdb) + + mock_igdb.assert_called_once_with("test_client", "test_token") + self.assertEqual(responder.igdb, mock_igdb_instance) + + def test_igdb_initialization_disabled(self): + """Test IGDB is not initialized when disabled.""" + responder = OpenAIResponder(self.config_without_igdb) + self.assertIsNone(responder.igdb) + + def test_igdb_search_games_functionality(self): + """Test the search_games functionality.""" + igdb = IGDBQuery("test_client", "test_token") + + # Mock the actual API call + mock_games = [{ + "id": 1, + "name": "Test Game", + "summary": "A test game", + "first_release_date": 1577836800, # 2020-01-01 + "genres": [{"name": "Action"}], + "platforms": [{"name": "PC"}], + "rating": 85.5 + }] + + with patch.object(igdb, 'generalized_igdb_query', return_value=mock_games): + results = igdb.search_games("Test Game") + + self.assertIsNotNone(results) + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["name"], "Test Game") + self.assertIn("genres", results[0]) + self.assertIn("platforms", results[0]) + + def test_igdb_openai_functions(self): + """Test OpenAI function definitions.""" + igdb = IGDBQuery("test_client", "test_token") + functions = igdb.get_openai_functions() + + self.assertEqual(len(functions), 2) + + # Check search_games function + search_func = functions[0] + self.assertEqual(search_func["name"], "search_games") + self.assertIn("description", search_func) + self.assertIn("parameters", search_func) + self.assertIn("query", search_func["parameters"]["properties"]) + + # Check get_game_details function + details_func = functions[1] + self.assertEqual(details_func["name"], "get_game_details") + self.assertIn("game_id", details_func["parameters"]["properties"]) + + async def test_execute_igdb_function_search(self): + """Test executing IGDB search function.""" + with patch('fjerkroa_bot.openai_responder.IGDBQuery') as mock_igdb_class: + mock_igdb = Mock() + mock_igdb.search_games.return_value = [{"name": "Test Game", "id": 1}] + mock_igdb_class.return_value = mock_igdb + + responder = OpenAIResponder(self.config_with_igdb) + + result = await responder._execute_igdb_function( + "search_games", + {"query": "Test Game", "limit": 5} + ) + + self.assertIsNotNone(result) + self.assertIn("games", result) + mock_igdb.search_games.assert_called_once_with("Test Game", 5) + + async def test_execute_igdb_function_details(self): + """Test executing IGDB game details function.""" + with patch('fjerkroa_bot.openai_responder.IGDBQuery') as mock_igdb_class: + mock_igdb = Mock() + mock_igdb.get_game_details.return_value = {"name": "Test Game", "id": 1} + mock_igdb_class.return_value = mock_igdb + + responder = OpenAIResponder(self.config_with_igdb) + + result = await responder._execute_igdb_function( + "get_game_details", + {"game_id": 1} + ) + + self.assertIsNotNone(result) + self.assertIn("game", result) + mock_igdb.get_game_details.assert_called_once_with(1) + + def test_format_game_for_ai(self): + """Test game data formatting for AI consumption.""" + igdb = IGDBQuery("test_client", "test_token") + + mock_game = { + "id": 1, + "name": "Elden Ring", + "summary": "A fantasy action RPG", + "first_release_date": 1645747200, # 2022-02-25 + "rating": 96.0, + "aggregated_rating": 90.5, + "genres": [{"name": "Role-playing (RPG)"}, {"name": "Adventure"}], + "platforms": [{"name": "PC (Microsoft Windows)"}, {"name": "PlayStation 5"}], + "developers": [{"name": "FromSoftware"}] + } + + formatted = igdb._format_game_for_ai(mock_game) + + self.assertEqual(formatted["name"], "Elden Ring") + self.assertEqual(formatted["rating"], "96.0/100") + self.assertEqual(formatted["user_rating"], "90.5/100") + self.assertEqual(formatted["release_year"], 2022) + self.assertIn("Role-playing (RPG)", formatted["genres"]) + self.assertIn("PC (Microsoft Windows)", formatted["platforms"]) + self.assertIn("FromSoftware", formatted["developers"]) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file