From e883671d63915c7c493fb4c8bf9b396316361d9c Mon Sep 17 00:00:00 2001 From: Jamie Miller Date: Fri, 6 Feb 2026 04:47:43 +0000 Subject: [PATCH] Add discord-agent files and enable reply notifications - Add discord_agent.py with reply() instead of send() for user notifications - Add Discord bot Dockerfile and requirements.txt - Add bot cogs (base_cog.py and integration_cog.py) - Update .gitignore to track discord-agent directory - Bot now replies to messages triggering notifications for users --- .gitignore | 2 + discord-agent/Dockerfile | 10 ++ discord-agent/cogs/base_cog.py | 57 ++++++ discord-agent/cogs/integration_cog.py | 250 ++++++++++++++++++++++++++ discord-agent/discord_agent.py | 244 +++++++++++++++++++++++++ discord-agent/requirements.txt | 4 + 6 files changed, 567 insertions(+) create mode 100644 discord-agent/Dockerfile create mode 100644 discord-agent/cogs/base_cog.py create mode 100644 discord-agent/cogs/integration_cog.py create mode 100644 discord-agent/discord_agent.py create mode 100644 discord-agent/requirements.txt diff --git a/.gitignore b/.gitignore index 5b6de06..d1844ae 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ !README.md !RESTORE.md !AGENTS.md +!discord-agent/ +!discord-agent/** # Never track actual secrets .env diff --git a/discord-agent/Dockerfile b/discord-agent/Dockerfile new file mode 100644 index 0000000..0d0acfa --- /dev/null +++ b/discord-agent/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["python", "discord_agent.py"] diff --git a/discord-agent/cogs/base_cog.py b/discord-agent/cogs/base_cog.py new file mode 100644 index 0000000..64a67d0 --- /dev/null +++ b/discord-agent/cogs/base_cog.py @@ -0,0 +1,57 @@ +"""Discord Agent Base Cog Template + +This provides a base class for all integration cogs with common functionality. +""" +import discord +from discord.ext import commands +import logging +from typing import Optional + +logger = logging.getLogger(__name__) + + +class BaseCog(commands.Cog): + """Base cog class with common functionality for all integration cogs.""" + + def __init__(self, bot): + self.bot = bot + self.config = bot.config + self.session = getattr(bot, 'session', None) + + @commands.Cog.listener() + async def on_ready(self): + """Called when the cog is ready.""" + logger.info(f"{self.qualified_name} cog ready") + + @commands.Cog.listener() + async def on_command_error(self, ctx, error): + """Handle command errors.""" + if isinstance(error, commands.CommandNotFound): + return # Ignore command not found errors + elif isinstance(error, commands.MissingRequiredArgument): + await ctx.send(f"āŒ Missing required argument: {error.param.name}") + elif isinstance(error, commands.BadArgument): + await ctx.send(f"āŒ Invalid argument provided") + else: + logger.error(f"Command error in {ctx.command}: {error}", exc_info=error) + await ctx.send("āŒ An error occurred while processing your command") + + def create_embed(self, title: str, description: str = None, + color: discord.Color = discord.Color.blue()) -> discord.Embed: + """Create a standard embed with consistent styling.""" + embed = discord.Embed(title=title, description=description, color=color) + embed.set_footer(text=f"Requested by {self.bot.user.name}") + return embed + + async def check_integration_enabled(self, ctx, integration_name: str) -> bool: + """Check if an integration is enabled.""" + integrations = self.config.get('integrations', {}).get('enabled', []) + if integration_name not in integrations: + await ctx.send(f"āŒ {integration_name.title()} integration is not enabled.") + return False + return True + + +async def setup(bot): + """Setup function for loading the cog.""" + await bot.add_cog(BaseCog(bot)) \ No newline at end of file diff --git a/discord-agent/cogs/integration_cog.py b/discord-agent/cogs/integration_cog.py new file mode 100644 index 0000000..b0f3d91 --- /dev/null +++ b/discord-agent/cogs/integration_cog.py @@ -0,0 +1,250 @@ +"""Discord Agent Integration Cog + +Handles all service integrations and provides commands for interacting with them. +""" +import discord +from discord.ext import commands +import logging +import aiohttp +from typing import Optional, Dict, Any +from datetime import datetime + +logger = logging.getLogger(__name__) + + +class IntegrationCog(commands.Cog): + """Manages integrations with external services.""" + + def __init__(self, bot): + self.bot = bot + self.config = bot.config + self.session = getattr(bot, 'session', None) + self.db_pool = getattr(bot, 'db_pool', None) + self.redis_client = getattr(bot, 'redis_client', None) + + # Integration configurations + self.integrations = self.config.get('integrations', {}).get('enabled', []) + self.agent_endpoint = self.config.get('agent', {}).get('endpoint', 'http://192.168.0.10:8080') + self.agent_api_key = self.config.get('agent', {}).get('api_key', '') + + # Integration metadata + self.integration_info = { + 'jellyfin': {'name': 'Jellyfin', 'description': 'Access Jellyfin media server'}, + 'paperless': {'name': 'Paperless', 'description': 'Manage documents in Paperless'}, + 'gitea': {'name': 'Gitea', 'description': 'Interact with Gitea repositories'}, + 'wygiwyh': {'name': 'WYGIWYH', 'description': 'Check financial tracking'}, + 'syncthing': {'name': 'Syncthing', 'description': 'Sync files with Syncthing'}, + 'immich': {'name': 'Immich', 'description': 'Manage photos in Immich'}, + 'speedtest-tracker': {'name': 'Speedtest', 'description': 'Check network speed'}, + 'maloja': {'name': 'Maloja', 'description': 'Access music scrobbling'}, + 'npm': {'name': 'Nginx Proxy Manager', 'description': 'Manage reverse proxy'}, + } + + def _create_help_embed(self) -> discord.Embed: + """Create a formatted help embed with all available commands.""" + embed = discord.Embed( + title="šŸ¤– Discord Agent Commands", + description="Available commands and integrations", + color=discord.Color.blue() + ) + + # Integration commands + if self.integrations: + integration_text = "" + for integration_id in self.integrations: + if integration_id in self.integration_info: + info = self.integration_info[integration_id] + cmd_name = 'speedtest' if integration_id == 'speedtest-tracker' else integration_id + integration_text += f"**!{cmd_name}** - {info['description']}\n" + + if integration_text: + embed.add_field( + name="šŸ“¦ Integration Commands", + value=integration_text, + inline=False + ) + + # General commands + general_text = ( + "**!agent ** - Chat with the AI agent\n" + "**!status** - Check system status\n" + "**!help** - Show this help message" + ) + embed.add_field(name="āš™ļø General Commands", value=general_text, inline=False) + + return embed + + @commands.command(name='help') + async def help_command(self, ctx): + """Show help information for integrations.""" + try: + embed = self._create_help_embed() + await ctx.send(embed=embed) + except Exception as e: + logger.error(f"Error in help command: {e}") + await ctx.send("āŒ Sorry, I'm having trouble showing help right now.") + + @commands.command() + async def agent(self, ctx, *, query: str): + """Chat with the AI agent.""" + try: + async with ctx.typing(): + response = await self._query_agent(query) + + # Split long responses + if len(response) > 2000: + chunks = [response[i:i+2000] for i in range(0, len(response), 2000)] + for chunk in chunks: + await ctx.send(chunk) + else: + await ctx.send(response) + + except Exception as e: + logger.error(f"Error in agent command: {e}") + await ctx.send("āŒ Sorry, I'm having trouble connecting to the agent service.") + + async def _query_agent(self, query: str) -> str: + """Query the agent API.""" + if not self.session: + return "āŒ HTTP session not initialized" + + try: + async with self.session.post( + f"{self.agent_endpoint}/api/chat", + json={"query": query, "api_key": self.agent_api_key}, + timeout=aiohttp.ClientTimeout(total=30) + ) as resp: + if resp.status == 200: + data = await resp.json() + return data.get('response', 'āŒ No response from agent') + else: + return f"āŒ Agent error: HTTP {resp.status}" + except asyncio.TimeoutError: + return "āŒ Agent request timed out" + except Exception as e: + logger.error(f"Agent query error: {e}") + return "āŒ Failed to connect to agent" + + @commands.command() + async def status(self, ctx): + """Check system status.""" + try: + embed = discord.Embed( + title="šŸ“Š System Status", + color=discord.Color.green() + ) + + # Bot status + embed.add_field( + name="šŸ¤– Bot", + value=f"Connected to {len(self.bot.guilds)} server(s)", + inline=True + ) + + # Agent status + embed.add_field( + name="🧠 Agent", + value="Online āœ…", + inline=True + ) + + # Database status + db_status = "Connected āœ…" if self.db_pool else "Disconnected āŒ" + embed.add_field(name="šŸ’¾ Database", value=db_status, inline=True) + + # Redis status + redis_status = "Connected āœ…" if self.redis_client else "Disconnected āŒ" + embed.add_field(name="šŸ”“ Redis", value=redis_status, inline=True) + + # Integrations + embed.add_field( + name="šŸ“¦ Integrations", + value=f"{len(self.integrations)} enabled", + inline=True + ) + + # Latency + latency = round(self.bot.latency * 1000) + embed.add_field(name="⚔ Latency", value=f"{latency}ms", inline=True) + + await ctx.send(embed=embed) + + except Exception as e: + logger.error(f"Error in status command: {e}") + await ctx.send("āŒ Sorry, I'm having trouble checking system status.") + + async def _handle_integration_command(self, ctx, integration_id: str, query: str = ""): + """Generic handler for integration commands.""" + if integration_id not in self.integrations: + info = self.integration_info.get(integration_id, {}) + service_name = info.get('name', integration_id.title()) + await ctx.send(f"āŒ {service_name} integration is not enabled.") + return + + try: + # Placeholder for actual integration logic + info = self.integration_info[integration_id] + embed = discord.Embed( + title=f"šŸ“¦ {info['name']}", + description=f"Integration is active. Query: `{query or 'None'}`", + color=discord.Color.green() + ) + embed.add_field(name="Status", value="āœ… Working", inline=True) + embed.set_footer(text="This is a placeholder - implement actual integration logic") + await ctx.send(embed=embed) + + except Exception as e: + logger.error(f"Error in {integration_id} command: {e}") + await ctx.send(f"āŒ Sorry, I'm having trouble accessing {integration_id.title()}.") + + # Integration commands using the generic handler + + @commands.command() + async def jellyfin(self, ctx, *, query: str = ""): + """Access Jellyfin media server.""" + await self._handle_integration_command(ctx, 'jellyfin', query) + + @commands.command() + async def paperless(self, ctx, *, query: str = ""): + """Manage documents in Paperless.""" + await self._handle_integration_command(ctx, 'paperless', query) + + @commands.command() + async def gitea(self, ctx, *, query: str = ""): + """Interact with Gitea repositories.""" + await self._handle_integration_command(ctx, 'gitea', query) + + @commands.command() + async def wygiwyh(self, ctx, *, query: str = ""): + """Check financial tracking.""" + await self._handle_integration_command(ctx, 'wygiwyh', query) + + @commands.command() + async def syncthing(self, ctx, *, query: str = ""): + """Sync files with Syncthing.""" + await self._handle_integration_command(ctx, 'syncthing', query) + + @commands.command() + async def immich(self, ctx, *, query: str = ""): + """Manage photos in Immich.""" + await self._handle_integration_command(ctx, 'immich', query) + + @commands.command() + async def speedtest(self, ctx, *, query: str = ""): + """Check network speed.""" + await self._handle_integration_command(ctx, 'speedtest-tracker', query) + + @commands.command() + async def maloja(self, ctx, *, query: str = ""): + """Access music scrobbling.""" + await self._handle_integration_command(ctx, 'maloja', query) + + @commands.command() + async def npm(self, ctx, *, query: str = ""): + """Access Nginx Proxy Manager.""" + await self._handle_integration_command(ctx, 'npm', query) + + +async def setup(bot): + """Setup function for loading the cog.""" + await bot.add_cog(IntegrationCog(bot)) \ No newline at end of file diff --git a/discord-agent/discord_agent.py b/discord-agent/discord_agent.py new file mode 100644 index 0000000..e07389b --- /dev/null +++ b/discord-agent/discord_agent.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +""" +Discord Agent Bot + +AI-powered Discord bot using Ollama. +Configuration loaded from ./config/agent-config.yaml +""" + +import discord +from discord.ext import commands +import os +import asyncio +import aiohttp +import logging +import yaml +import re +from typing import Any, Dict, Optional +from datetime import datetime + + +# ------------------------- +# Constants +# ------------------------- + +CONFIG_PATH = "config/agent-config.yaml" + + +# ------------------------- +# Configuration helpers +# ------------------------- + +def load_config(path: str) -> Dict[str, Any]: + """Load YAML config with environment variable substitution.""" + + def substitute(value: Any) -> Any: + if isinstance(value, dict): + return {k: substitute(v) for k, v in value.items()} + if isinstance(value, list): + return [substitute(v) for v in value] + if isinstance(value, str): + pattern = r"\$\{([^}:]+)(?::([^}]*))?\}" + return re.sub( + pattern, + lambda m: os.getenv(m.group(1), m.group(2) or ""), + value, + ) + return value + + try: + with open(path, "r") as f: + raw = yaml.safe_load(f) or {} + config = substitute(raw) + config["_config_path"] = path + logging.info(f"Loaded config from {path}") + return config + except FileNotFoundError: + logging.error(f"Config file not found: {path}") + except Exception as e: + logging.error(f"Failed to load config: {e}") + + return { + "_config_path": path, + "discord": {"token": "", "prefix": ";"}, + "ollama": {"endpoint": "http://192.168.0.31:11434", "model": "ministral-3:8b"}, + "agent": {"instructions": "You are a helpful AI assistant."}, + "features": {"max_response_length": 1900}, + "advanced": {"admin_users": []}, + } + + +def cfg(config: Dict[str, Any], path: str, default=None): + cur = config + for key in path.split("."): + if not isinstance(cur, dict) or key not in cur: + return default + cur = cur[key] + return cur + + +# ------------------------- +# Discord bot +# ------------------------- + +class DiscordAgent(commands.Bot): + def __init__(self, config_path: str): + self.config_path = config_path + self.config = load_config(config_path) + + intents = discord.Intents.default() + intents.message_content = True + + super().__init__( + command_prefix=cfg(self.config, "discord.prefix", ";"), + intents=intents, + # help_command=None, + ) + + self.session: Optional[aiohttp.ClientSession] = None + self.apply_config() + + async def on_message(self, message): + if message.author == self.user: + return + print(f"Message seen: {message.content}") # Debug log + await self.process_commands(message) # Necessary to make commands work + + def apply_config(self): + """Apply configuration to runtime attributes.""" + self.ollama_endpoint = cfg(self.config, "ollama.endpoint", "http://192.168.0.31:11434") + self.ollama_model = cfg(self.config, "ollama.model", "ministral-3:8b") + self.ollama_params = cfg(self.config, "ollama.parameters", {}) + self.ollama_timeout = cfg(self.config, "ollama.timeout", 60) + self.agent_instructions = cfg( + self.config, + "agent.instructions", + "You are a helpful AI assistant.", + ) + self.max_response_length = cfg(self.config, "features.max_response_length", 1900) + + async def setup_hook(self): + self.session = aiohttp.ClientSession() + + async def close(self): + if self.session: + await self.session.close() + await super().close() + + async def on_ready(self): + activity = discord.Activity( + type=discord.ActivityType.watching, + name=cfg(self.config, "discord.status", "AI Assistant"), + ) + await self.change_presence(activity=activity) + + logging.info(f"Logged in as {self.user} ({self.user.id})") + print(f"āœ… Bot ready as {self.user}") + + async def call_ollama(self, message: str) -> str: + payload = { + "model": self.ollama_model, + "messages": [ + {"role": "system", "content": self.agent_instructions}, + {"role": "user", "content": message}, + ], + "stream": False, + "options": self.ollama_params, + } + + try: + async with self.session.post( + f"{self.ollama_endpoint}/api/chat", + json=payload, + timeout=aiohttp.ClientTimeout(total=self.ollama_timeout), + ) as r: + if r.status == 200: + data = await r.json() + return data.get("message", {}).get("content", "") + return f"āŒ Ollama HTTP {r.status}" + except asyncio.TimeoutError: + return "āŒ Ollama request timed out" + except Exception as e: + logging.error(f"Ollama error: {e}") + return f"āŒ Ollama error: {e}" + + +# ------------------------- +# Commands +# ------------------------- + +bot = DiscordAgent(CONFIG_PATH) + + +@bot.command() +async def agent(ctx, *, message: str): + """Chat with the AI agent.""" + async with ctx.typing(): + reply = await bot.call_ollama(message) + await ctx.reply(reply[: bot.max_response_length]) + + +@bot.command() +async def reload(ctx): + """Reload configuration (admin only).""" + admins = cfg(bot.config, "advanced.admin_users", []) + + if admins and ctx.author.id not in admins: + await ctx.reply("āŒ You don't have permission to do that.") + return + + bot.config = load_config(bot.config_path) + bot.apply_config() + + await ctx.reply("āœ… Configuration reloaded") + logging.info(f"Config reloaded by {ctx.author}") + + +@bot.command() +async def status(ctx): + """Show bot status.""" + embed = discord.Embed( + title="šŸ“Š Bot Status", + color=discord.Color.green(), + timestamp=datetime.utcnow(), + ) + + embed.add_field(name="Model", value=bot.ollama_model) + embed.add_field(name="Latency", value=f"{round(bot.latency * 1000)}ms") + embed.add_field(name="Config", value=bot.config_path) + + await ctx.reply(embed=embed) + + +@bot.event +async def on_command_error(ctx, error): + if isinstance(error, commands.CommandNotFound): + return + logging.error(f"Command error: {error}") + await ctx.reply("āŒ An error occurred while processing that command") + + +# ------------------------- +# Entrypoint +# ------------------------- + +def main(): + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + ) + + token = cfg(bot.config, "discord.token") + if not token: + print("āŒ DISCORD_BOT_TOKEN not set") + return 1 + + try: + bot.run(token, log_handler=None) + except KeyboardInterrupt: + print("\nšŸ‘‹ Shutting down") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/discord-agent/requirements.txt b/discord-agent/requirements.txt new file mode 100644 index 0000000..b15a907 --- /dev/null +++ b/discord-agent/requirements.txt @@ -0,0 +1,4 @@ +discord.py +aiohttp +PyYAML +PyNaCl