diff --git a/main.py b/main.py index 705bf11..614bd31 100644 --- a/main.py +++ b/main.py @@ -2,22 +2,26 @@ import discord import docker import os import json +import asyncio +from datetime import datetime, timezone from discord.ext import commands, tasks +from discord import app_commands from dotenv import load_dotenv +from aiohttp import web load_dotenv() BOT_TOKEN = os.getenv("BOT_TOKEN") DOCKER_HOST = os.getenv("DOCKER_HOST") -GUILD_ID = int(os.getenv("GUILD_ID")) # Your server ID -CATEGORY_NAME = os.getenv("CATEGORY_NAME") # Category name for the channels +GUILD_ID = int(os.getenv("GUILD_ID")) +CATEGORY_NAME = os.getenv("CATEGORY_NAME", "Docker Status") intents = discord.Intents.default() intents.message_content = True bot = commands.Bot(command_prefix="!", intents=intents) -# Stores groups: { "groupname": { "channel_id": 123, "containers": ["nginx", "mysql"], "message_id": 456 } } GROUPS_FILE = "groups.json" +SETTINGS_FILE = "settings.json" def load_groups(): if os.path.exists(GROUPS_FILE): @@ -29,77 +33,216 @@ def save_groups(groups): with open(GROUPS_FILE, "w") as f: json.dump(groups, f, indent=2) +def load_settings(): + if os.path.exists(SETTINGS_FILE): + with open(SETTINGS_FILE, "r") as f: + return json.load(f) + return { + "alert_mode": None, + "alert_channel_id": None, + "setup_done": False + } + +def save_settings(settings): + with open(SETTINGS_FILE, "w") as f: + json.dump(settings, f, indent=2) + def status_symbol(status): - symbols = { + return { "running": "🟢", "exited": "šŸ”“", "paused": "🟔", "created": "⚪", - } - return symbols.get(status, "ā“") + }.get(status, "ā“") -# --- Commands --- +def get_docker_client(): + return docker.DockerClient(base_url=DOCKER_HOST, timeout=5) -@bot.command() -async def container_list(ctx): - """Shows all available containers on the host""" +def get_all_containers(): + client = get_docker_client() + return client.containers.list(all=True) + +def format_uptime(started_at: str) -> str: try: - client = docker.DockerClient(base_url=DOCKER_HOST, timeout=5) - container_list = client.containers.list(all=True) + started = datetime.fromisoformat(started_at[:26].replace("Z", "+00:00")) + now = datetime.now(timezone.utc) + delta = now - started + days = delta.days + hours, remainder = divmod(delta.seconds, 3600) + minutes, _ = divmod(remainder, 60) + if days > 0: + return f"{days}d {hours}h {minutes}m" + elif hours > 0: + return f"{hours}h {minutes}m" + else: + return f"{minutes}m" + except Exception: + return "unknown" - message = "**🐳 Available Containers:**\n```\n" - for c in container_list: - message += f"{status_symbol(c.status)} {c.name}\n" - message += "```\nUse `!group_create ...` to create a group." +def build_status_embed(group_name: str, containers: list, all_containers: dict, container_objects: list) -> discord.Embed: + embed = discord.Embed( + title=f"🐳 {group_name.upper()}", + color=discord.Color.blurple(), + timestamp=datetime.now(timezone.utc) + ) + container_map = {c.name: c for c in container_objects} + for name in containers: + if name in all_containers: + status = all_containers[name] + uptime = "" + if status == "running" and name in container_map: + try: + started_at = container_map[name].attrs["State"]["StartedAt"] + uptime = f"\nUptime: `{format_uptime(started_at)}`" + except Exception: + pass + embed.add_field( + name=f"{status_symbol(status)} {name}", + value=f"`{status}`{uptime}", + inline=False + ) + else: + embed.add_field( + name=f"ā“ {name}", + value="`not found`", + inline=False + ) + embed.set_footer(text="Last updated") + return embed - await ctx.send(message) +async def health_handler(request): + return web.Response(text="OK") + +async def start_health_server(): + app = web.Application() + app.router.add_get("/health", health_handler) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "0.0.0.0", 8080) + await site.start() + print("Health check running on :8080/health") + +async def container_autocomplete(interaction: discord.Interaction, current: str): + try: + containers = get_all_containers() + names = [c.name for c in containers if current.lower() in c.name.lower()] + return [app_commands.Choice(name=n, value=n) for n in names[:25]] + except Exception: + return [] + +async def group_autocomplete(interaction: discord.Interaction, current: str): + groups = load_groups() + return [ + app_commands.Choice(name=n, value=n) + for n in groups if current.lower() in n.lower() + ][:25] + +@bot.tree.command(name="setup", description="Setup the bot (alert preferences etc.)") +@app_commands.describe( + alert_mode="Where should crash alerts be sent?", + alert_channel="Channel for alerts (only needed if alert_mode is 'channel')" +) +@app_commands.choices(alert_mode=[ + app_commands.Choice(name="In each group channel", value="group"), + app_commands.Choice(name="In a separate alert channel", value="channel"), +]) +async def setup(interaction: discord.Interaction, alert_mode: app_commands.Choice[str], alert_channel: discord.TextChannel = None): + settings = load_settings() + + if alert_mode.value == "channel" and alert_channel is None: + await interaction.response.send_message( + "āŒ Please also select an alert channel when using 'separate alert channel' mode.", + ephemeral=True + ) + return + + settings["alert_mode"] = alert_mode.value + settings["alert_channel_id"] = alert_channel.id if alert_channel else None + settings["setup_done"] = True + save_settings(settings) + + embed = discord.Embed(title="āœ… Setup Complete", color=discord.Color.green()) + embed.add_field(name="Alert Mode", value=alert_mode.name, inline=False) + if alert_channel: + embed.add_field(name="Alert Channel", value=alert_channel.mention, inline=False) + await interaction.response.send_message(embed=embed, ephemeral=True) + + +@bot.tree.command(name="container_list", description="Lists all containers on the Docker host") +async def container_list(interaction: discord.Interaction): + await interaction.response.defer(ephemeral=True) + try: + containers = get_all_containers() + embed = discord.Embed(title="🐳 Available Containers", color=discord.Color.blurple()) + for c in containers: + try: + started_at = c.attrs["State"]["StartedAt"] + uptime = f"\nUptime: `{format_uptime(started_at)}`" if c.status == "running" else "" + except Exception: + uptime = "" + embed.add_field( + name=f"{status_symbol(c.status)} {c.name}", + value=f"`{c.status}`{uptime}", + inline=False + ) + embed.set_footer(text=f"{len(containers)} containers found") + await interaction.followup.send(embed=embed, ephemeral=True) except Exception as e: - await ctx.send(f"āŒ Error: {e}") + await interaction.followup.send(f"āŒ Error: {e}", ephemeral=True) -@bot.command() -async def group_create(ctx, group_name: str, *container_names): - """Creates a group and a channel for it - Example: !group_create webservices nginx apache""" + +@bot.tree.command(name="group_create", description="Creates a container group with a dedicated channel") +@app_commands.describe( + group_name="Name of the group", + containers="Container names separated by spaces (e.g. nginx mysql redis)" +) +async def group_create(interaction: discord.Interaction, group_name: str, containers: str): + await interaction.response.defer(ephemeral=True) + + container_names = containers.split() if not container_names: - await ctx.send("āŒ Please specify at least one container!\nExample: `!group_create webservices nginx apache`") + await interaction.followup.send("āŒ Please specify at least one container!", ephemeral=True) return guild = bot.get_guild(GUILD_ID) groups = load_groups() - # Create category if it doesn't exist category = discord.utils.get(guild.categories, name=CATEGORY_NAME) if not category: category = await guild.create_category(CATEGORY_NAME) - # Create channel channel_name = f"🐳{group_name.lower()}" channel = discord.utils.get(category.channels, name=channel_name) if not channel: - # Only bot is allowed to write overwrites = { guild.default_role: discord.PermissionOverwrite(send_messages=False), guild.me: discord.PermissionOverwrite(send_messages=True) } channel = await category.create_text_channel(channel_name, overwrites=overwrites) - # Save group groups[group_name] = { "channel_id": channel.id, - "containers": list(container_names), + "containers": container_names, "message_id": None } save_groups(groups) - await ctx.send(f"āœ… Group **{group_name}** created with channel {channel.mention}\nContainers: `{', '.join(container_names)}`") + embed = discord.Embed(title="āœ… Group Created", color=discord.Color.green()) + embed.add_field(name="Group", value=group_name, inline=True) + embed.add_field(name="Channel", value=channel.mention, inline=True) + embed.add_field(name="Containers", value="`" + ", ".join(container_names) + "`", inline=False) + await interaction.followup.send(embed=embed, ephemeral=True) -@bot.command() -async def group_remove(ctx, group_name: str): - """Deletes a group and its channel""" + +@bot.tree.command(name="group_remove", description="Deletes a group and its channel") +@app_commands.describe(group_name="Name of the group to delete") +@app_commands.autocomplete(group_name=group_autocomplete) +async def group_remove(interaction: discord.Interaction, group_name: str): + await interaction.response.defer(ephemeral=True) groups = load_groups() if group_name not in groups: - await ctx.send(f"āŒ Group `{group_name}` not found.") + await interaction.followup.send(f"āŒ Group `{group_name}` not found.", ephemeral=True) return guild = bot.get_guild(GUILD_ID) @@ -109,103 +252,202 @@ async def group_remove(ctx, group_name: str): del groups[group_name] save_groups(groups) - await ctx.send(f"āœ… Group **{group_name}** deleted.") -@bot.command() -async def group_list(ctx): - """Shows all groups""" + embed = discord.Embed(title="āœ… Group Deleted", color=discord.Color.red()) + embed.add_field(name="Group", value=group_name) + await interaction.followup.send(embed=embed, ephemeral=True) + + +@bot.tree.command(name="group_list", description="Shows all existing groups") +async def group_list(interaction: discord.Interaction): groups = load_groups() + if not groups: - await ctx.send("No groups found. Use `!group_create` to create one.") + await interaction.response.send_message("No groups found. Use `/group_create` to create one.", ephemeral=True) return - message = "**šŸ“‹ Groups:**\n" + embed = discord.Embed(title="šŸ“‹ Groups", color=discord.Color.blurple()) for name, data in groups.items(): - message += f"\n**{name}:** `{', '.join(data['containers'])}`" - await ctx.send(message) + guild = bot.get_guild(GUILD_ID) + channel = guild.get_channel(data["channel_id"]) + embed.add_field( + name=f"šŸ“ {name}", + value=f"Channel: {channel.mention if channel else '`deleted`'}\nContainers: `{', '.join(data['containers'])}`", + inline=False + ) + await interaction.response.send_message(embed=embed, ephemeral=True) -@bot.command() -async def info(ctx): - """Shows all available commands and their usage""" - message = ( - "**šŸ¤– Docker Monitor Bot - Commands**\n\n" - "**šŸ“‹ Container**\n" - "`!container_list`\n" - "→ Shows all available containers on the host\n\n" - "**šŸ“ Groups**\n" - "`!group_create ...`\n" - "→ Creates a group with a dedicated channel for the given containers\n" - "→ Example: `!group_create webservices nginx apache`\n\n" - "`!group_remove `\n" - "→ Deletes a group and its channel\n" - "→ Example: `!group_remove webservices`\n\n" - "`!group_list`\n" - "→ Shows all existing groups and their containers\n\n" - "**ā„¹ļø Other**\n" - "`!info`\n" - "→ Shows this help message\n\n" - "**šŸ”„ Monitoring**\n" - "Container statuses are automatically updated every **30 seconds** in their group channels.\n" - "🟢 running šŸ”“ exited 🟔 paused ⚪ created ā“ unknown" - ) - await ctx.send(message) -# --- Monitoring --- +@bot.tree.command(name="container_start", description="Start a container") +@app_commands.describe(container_name="Name of the container to start") +@app_commands.autocomplete(container_name=container_autocomplete) +async def container_start(interaction: discord.Interaction, container_name: str): + await interaction.response.defer(ephemeral=True) + try: + client = get_docker_client() + container = client.containers.get(container_name) + container.start() + embed = discord.Embed(title="ā–¶ļø Container Started", color=discord.Color.green()) + embed.add_field(name="Container", value=f"`{container_name}`") + await interaction.followup.send(embed=embed, ephemeral=True) + except Exception as e: + await interaction.followup.send(f"āŒ Error: {e}", ephemeral=True) + + +@bot.tree.command(name="container_stop", description="Stop a container") +@app_commands.describe(container_name="Name of the container to stop") +@app_commands.autocomplete(container_name=container_autocomplete) +async def container_stop(interaction: discord.Interaction, container_name: str): + await interaction.response.defer(ephemeral=True) + try: + client = get_docker_client() + container = client.containers.get(container_name) + container.stop() + embed = discord.Embed(title="ā¹ļø Container Stopped", color=discord.Color.red()) + embed.add_field(name="Container", value=f"`{container_name}`") + await interaction.followup.send(embed=embed, ephemeral=True) + except Exception as e: + await interaction.followup.send(f"āŒ Error: {e}", ephemeral=True) + + +@bot.tree.command(name="container_logs", description="Show the last 20 log lines of a container") +@app_commands.describe(container_name="Name of the container") +@app_commands.autocomplete(container_name=container_autocomplete) +async def container_logs(interaction: discord.Interaction, container_name: str): + await interaction.response.defer(ephemeral=True) + try: + client = get_docker_client() + container = client.containers.get(container_name) + logs = container.logs(tail=20).decode("utf-8", errors="replace") + + if not logs.strip(): + logs = "No logs available." + + if len(logs) > 1900: + logs = "..." + logs[-1900:] + + embed = discord.Embed( + title=f"šŸ“‹ Logs: {container_name}", + description=f"```\n{logs}\n```", + color=discord.Color.blurple(), + timestamp=datetime.now(timezone.utc) + ) + await interaction.followup.send(embed=embed, ephemeral=True) + except Exception as e: + await interaction.followup.send(f"āŒ Error: {e}", ephemeral=True) + + +@bot.tree.command(name="info", description="Shows all available commands") +async def info(interaction: discord.Interaction): + embed = discord.Embed(title="šŸ¤– Docker Monitor Bot", color=discord.Color.blurple()) + embed.add_field(name="/setup", value="Configure alert preferences", inline=False) + embed.add_field(name="/container_list", value="List all containers on the host", inline=False) + embed.add_field(name="/container_start ", value="Start a container", inline=False) + embed.add_field(name="/container_stop ", value="Stop a container", inline=False) + embed.add_field(name="/container_logs ", value="Show last 20 log lines", inline=False) + embed.add_field(name="/group_create ", value="Create a group with a dedicated channel", inline=False) + embed.add_field(name="/group_remove ", value="Delete a group and its channel", inline=False) + embed.add_field(name="/group_list", value="Show all existing groups", inline=False) + embed.add_field(name="šŸ”„ Monitoring", value="Status + Uptime updates every **30 seconds**\n🟢 running šŸ”“ exited 🟔 paused ⚪ created ā“ unknown", inline=False) + await interaction.response.send_message(embed=embed, ephemeral=True) + + +previous_statuses = {} @tasks.loop(seconds=30) async def monitor_containers(): + global previous_statuses + groups = load_groups() + settings = load_settings() if not groups: return try: - docker_client = docker.DockerClient(base_url=DOCKER_HOST, timeout=5) - all_containers = {c.name: c.status for c in docker_client.containers.list(all=True)} + container_objects = get_all_containers() + all_containers = {c.name: c.status for c in container_objects} except Exception: return guild = bot.get_guild(GUILD_ID) + for container_name, status in all_containers.items(): + prev = previous_statuses.get(container_name) + if prev == "running" and status == "exited": + await send_crash_alert(guild, settings, container_name, groups) + + previous_statuses = dict(all_containers) + + running_count = sum(1 for s in all_containers.values() if s == "running") + await bot.change_presence(activity=discord.Activity( + type=discord.ActivityType.watching, + name=f"{running_count} containers" + )) + for group_name, data in groups.items(): channel = guild.get_channel(data["channel_id"]) if not channel: continue - # Build status text - text = f"**🐳 {group_name.upper()}**\n```\n" - for container_name in data["containers"]: - if container_name in all_containers: - status = all_containers[container_name] - text += f"{status_symbol(status)} {container_name:<25} {status}\n" - else: - text += f"ā“ {container_name:<25} not found\n" - text += "```" + embed = build_status_embed(group_name, data["containers"], all_containers, container_objects) try: if data["message_id"]: - # Edit existing message message = await channel.fetch_message(data["message_id"]) - await message.edit(content=text) + await message.edit(embed=embed) else: - # Send first message - message = await channel.send(text) + message = await channel.send(embed=embed) data["message_id"] = message.id save_groups(groups) except discord.NotFound: - # Message was deleted, resend - message = await channel.send(text) + message = await channel.send(embed=embed) data["message_id"] = message.id save_groups(groups) -@bot.event -async def on_command_error(ctx, error): - if isinstance(error, commands.CommandNotFound): - return # Ignore unknown commands silently - raise error # All other errors still show in logs + +async def send_crash_alert(guild, settings, container_name: str, groups: dict): + embed = discord.Embed( + title="🚨 Container Crashed!", + description=f"Container **{container_name}** has stopped unexpectedly.", + color=discord.Color.red(), + timestamp=datetime.now(timezone.utc) + ) + embed.set_footer(text="Docker Monitor Bot") + + alert_mode = settings.get("alert_mode") + + if alert_mode == "channel": + channel_id = settings.get("alert_channel_id") + if channel_id: + channel = guild.get_channel(channel_id) + if channel: + await channel.send(embed=embed) + + elif alert_mode == "group": + for group_name, data in groups.items(): + if container_name in data["containers"]: + channel = guild.get_channel(data["channel_id"]) + if channel: + await channel.send(embed=embed) + else: + for data in groups.values(): + channel = guild.get_channel(data["channel_id"]) + if channel: + await channel.send(embed=embed) + break @bot.event async def on_ready(): print(f"Bot is online as {bot.user}") + try: + guild = discord.Object(id=GUILD_ID) + bot.tree.copy_global_to(guild=guild) + await bot.tree.sync(guild=guild) + print("Slash commands synced.") + except Exception as e: + print(f"Failed to sync commands: {e}") + + await start_health_server() monitor_containers.start() -bot.run(BOT_TOKEN) +bot.run(BOT_TOKEN) \ No newline at end of file