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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
10
discord-agent/Dockerfile
Normal 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"]
|
||||
57
discord-agent/cogs/base_cog.py
Normal file
57
discord-agent/cogs/base_cog.py
Normal 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))
|
||||
250
discord-agent/cogs/integration_cog.py
Normal file
250
discord-agent/cogs/integration_cog.py
Normal 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))
|
||||
244
discord-agent/discord_agent.py
Normal file
244
discord-agent/discord_agent.py
Normal 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())
|
||||
4
discord-agent/requirements.txt
Normal file
4
discord-agent/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
discord.py
|
||||
aiohttp
|
||||
PyYAML
|
||||
PyNaCl
|
||||
Reference in New Issue
Block a user