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
This commit is contained in:
Jamie Miller
2026-02-06 04:47:43 +00:00
parent e7cf672603
commit e883671d63
6 changed files with 567 additions and 0 deletions

2
.gitignore vendored
View File

@@ -8,6 +8,8 @@
!README.md
!RESTORE.md
!AGENTS.md
!discord-agent/
!discord-agent/**
# Never track actual secrets
.env

10
discord-agent/Dockerfile Normal file
View File

@@ -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"]

View File

@@ -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))

View File

@@ -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 <query>** - 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))

View File

@@ -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())

View File

@@ -0,0 +1,4 @@
discord.py
aiohttp
PyYAML
PyNaCl