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")) CATEGORY_NAME = os.getenv("CATEGORY_NAME", "Docker Status") intents = discord.Intents.default() intents.message_content = True bot = commands.Bot(command_prefix="!", intents=intents) GROUPS_FILE = "groups.json" SETTINGS_FILE = "settings.json" def load_groups(): if os.path.exists(GROUPS_FILE): with open(GROUPS_FILE, "r") as f: return json.load(f) return {} 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): return { "running": "🟢", "exited": "šŸ”“", "paused": "🟔", "created": "⚪", }.get(status, "ā“") def get_docker_client(): return docker.DockerClient(base_url=DOCKER_HOST, timeout=5) def get_all_containers(): client = get_docker_client() return client.containers.list(all=True) def format_uptime(started_at: str) -> str: try: 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" 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 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 interaction.followup.send(f"āŒ Error: {e}", ephemeral=True) @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 interaction.followup.send("āŒ Please specify at least one container!", ephemeral=True) return guild = bot.get_guild(GUILD_ID) groups = load_groups() category = discord.utils.get(guild.categories, name=CATEGORY_NAME) if not category: category = await guild.create_category(CATEGORY_NAME) channel_name = f"🐳{group_name.lower()}" channel = discord.utils.get(category.channels, name=channel_name) if not channel: 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) groups[group_name] = { "channel_id": channel.id, "containers": container_names, "message_id": None } save_groups(groups) 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.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 interaction.followup.send(f"āŒ Group `{group_name}` not found.", ephemeral=True) return guild = bot.get_guild(GUILD_ID) channel = guild.get_channel(groups[group_name]["channel_id"]) if channel: await channel.delete() del groups[group_name] save_groups(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 interaction.response.send_message("No groups found. Use `/group_create` to create one.", ephemeral=True) return embed = discord.Embed(title="šŸ“‹ Groups", color=discord.Color.blurple()) for name, data in groups.items(): 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.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: 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 embed = build_status_embed(group_name, data["containers"], all_containers, container_objects) try: if data["message_id"]: message = await channel.fetch_message(data["message_id"]) await message.edit(embed=embed) else: message = await channel.send(embed=embed) data["message_id"] = message.id save_groups(groups) except discord.NotFound: message = await channel.send(embed=embed) data["message_id"] = message.id save_groups(groups) 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)