Skip to content

Commit 4fcd424

Browse files
Initial Bot Creation (#1)
* Initial commit - basic functionality * Filter out /proc /sys and /dev from disk usage * Update README * Update README * Remove config from dockerfile * Add docker compose example * Update README * Remove settings from bot.py, not needed * Fix errors in view-stats.py * Fix errors in view-stats.py
1 parent ba1ef8e commit 4fcd424

8 files changed

Lines changed: 366 additions & 0 deletions

File tree

.gitattributes

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
* text=auto
2+
*.sh text eol=lf
3+
Dockerfile text eol=lf
4+
*.py text eol=lf

README.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,59 @@
11
# DiscServerMonitor
22
A docker-based discord bot to help monitor the host system.
3+
4+
<a href="https://hub.docker.com/r/thisismynameok/disc-server-monitor"><img alt="Docker Image Size (tag)" src="https://img.shields.io/docker/image-size/thisismynameok/disc-server-monitor/latest?style=for-the-badge">
5+
<img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/thisismynameok/disc-server-monitor?style=for-the-badge"></a>
6+
<img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/thisismygithubok/DiscServerMonitor?color=brightgreen&style=for-the-badge">
7+
<img alt="GitHub" src="https://img.shields.io/github/license/thisismygithubok/DiscServerMonitor?style=for-the-badge"></p>
8+
9+
## Slash Command ##
10+
This bot is currently, a singular slash commands to use:
11+
- /view-stats
12+
13+
## Docker Run ##
14+
```
15+
docker run -e DISCORD_GUILD_ID=<your_guild_id> -e DISCORD_BOT_TOKEN=<your_bot_token> -e TZ=<your_tz> -v /proc:/host_proc:ro thisismynameok/disc-server-monitor:latest
16+
```
17+
18+
## Docker Compose ##
19+
You can find an example in [docker-compose-example.yml](https://github.com/thisismygithubok/DiscServerMonitor/blob/main/docker-compose-example.yml)
20+
21+
## Environment Variables ##
22+
- REQUIRED
23+
- DISCORD_GUILD_ID
24+
- This is your discord server ID
25+
- DISCORD_BOT_TOKEN
26+
- This is your discord bot token
27+
- If you need information on how to create a discord bot, please see the section below on [setting up a discord bot](#setting-up-a-discord-bot)
28+
29+
- OPTIONAL
30+
- TZ
31+
- This is optional, but you can specify this for the container/logging output timezone
32+
- Must use IANA standard timezones
33+
34+
```
35+
environment:
36+
DISCORD_BOT_TOKEN: ${DISCORD_BOT_TOKEN}
37+
DISCORD_GUILD_ID: ${DISCORD_GUILD_ID}
38+
TZ: ${TZ}
39+
```
40+
41+
## Volumes ##
42+
You need to mount the host's /proc as a volume to the container to be able to see system stats.
43+
If you'd like to see usage stats on other drives than the boot drive, you'll need to mount them as well.
44+
```
45+
volumes:
46+
- /proc:/host_proc:ro
47+
```
48+
49+
## Setting Up a Discord Bot ##
50+
1. Navigate to the [Discord Developer Portal](https://discord.com/developers/applications)
51+
2. Create a new application
52+
- Name it whatever you'd like the app to be named, in this case I've used "DiscServerMonitor"
53+
3. On the "General Information" page, give it a name and description.
54+
4. On the "Installation" page, change the install link to "None"
55+
5. On the "Bot" page, disable "Public Bot", and enable "Message Content Intent"
56+
- On this same page, make sure to copy your TOKEN as you'll need to pass this to the container
57+
6. On the "OAuth2" page, in the OAuth2 URL Generator section, choose "bot".
58+
- In the "Bot Permissions" section below this, in text permissions, choose "Send Messages" and "Manage Messages".
59+
- Copy the generated URL at the bottom and paste it into your browser. This will open the add bot to discord screen IN DISCORD. Select the server you want to add the bot to, and viola!

docker-compose-example.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
services:
2+
DiscServerMonitor:
3+
image: thisismynameok/disc-server-monitor:latest
4+
container_name: DiscServerMonitor
5+
restart: always
6+
environment:
7+
DISCORD_BOT_TOKEN: $DISCORD_BOT_TOKEN
8+
DISCORD_GUILD_ID: $DISCORD_GUILD_ID
9+
TZ: $TZ
10+
volumes:
11+
- /proc:/host_proc:ro

dockerfile

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
FROM alpine:3
2+
3+
WORKDIR /src
4+
RUN mkdir /config
5+
6+
COPY src/ .
7+
COPY src/entrypoint.sh /entrypoint.sh
8+
9+
RUN apk update && apk upgrade
10+
RUN apk add docker-cli python3 py3-pip tzdata gosu
11+
12+
RUN python3 -m venv /opt/venv
13+
ENV PATH="/opt/venv/bin:$PATH"
14+
15+
RUN pip3 install discord.py prettytable setuptools>=78.1.1
16+
17+
# Non-Root Docker
18+
RUN addgroup -S -g 988 docker && \
19+
adduser -S -D -H -h /src -s /sbin/nologin -G docker -u 1000 nonroot && \
20+
adduser nonroot docker && \
21+
chown -R nonroot:docker /src /entrypoint.sh && \
22+
chmod -R 755 /src /entrypoint.sh
23+
24+
ENTRYPOINT ["/entrypoint.sh"]
25+
CMD ["python3", "bot.py"]

src/bot.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import os
2+
import discord
3+
import logging
4+
from discord import app_commands
5+
from discord.ext import commands
6+
7+
# Suppress the PyNaCl warning since voice functionality isn't needed.
8+
discord.VoiceClient.warn_nacl = False
9+
10+
logging.basicConfig(
11+
level=logging.INFO,
12+
format='%(asctime)s - %(levelname)s - %(message)s',
13+
datefmt='%Y-%m-%d %H:%M:%S',
14+
force=True
15+
)
16+
logger = logging.getLogger(__name__)
17+
18+
DISCORD_BOT_TOKEN = os.environ.get('DISCORD_BOT_TOKEN')
19+
DISCORD_GUILD_ID = os.environ.get('DISCORD_GUILD_ID')
20+
21+
intents = discord.Intents.default()
22+
intents.message_content = True
23+
intents.guilds = True
24+
25+
bot = commands.Bot(command_prefix=lambda bot, message: [], intents=intents, command_tree_cls=app_commands.CommandTree)
26+
27+
if not DISCORD_BOT_TOKEN:
28+
logger.error("DISCORD_BOT_TOKEN is not set in the environment variables")
29+
raise ValueError("DISCORD_BOT_TOKEN is required to run the bot")
30+
31+
if not DISCORD_GUILD_ID:
32+
logger.error("DISCORD_GUILD_ID is not set in the environment variables")
33+
raise ValueError("DISCORD_GUILD_ID is required to run the bot")
34+
35+
async def load_cogs():
36+
for filename in os.listdir('./cogs'):
37+
if filename.endswith('.py'):
38+
await bot.load_extension(f'cogs.{filename[:-3]}')
39+
logger.info(f'Loaded {filename} cog successfully')
40+
41+
@bot.event
42+
async def on_ready():
43+
logger.info(f'Bot is ready and logged in as {bot.user}')
44+
try:
45+
await load_cogs()
46+
logger.info("Cogs loaded successfully")
47+
48+
# Add command syncing
49+
guild = discord.Object(id=DISCORD_GUILD_ID) # type: ignore
50+
bot.tree.copy_global_to(guild=guild)
51+
synced_commands = await bot.tree.sync(guild=guild)
52+
logger.info(f'Synced {len(synced_commands)} commands')
53+
54+
except Exception as e:
55+
logger.error(f'Error syncing commands: {e}')
56+
57+
logger.info("Starting Discord bot...")
58+
bot.run(f'{DISCORD_BOT_TOKEN}')

src/cogs/ping.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import os
2+
import discord
3+
import logging
4+
from discord import app_commands
5+
from discord.ext import commands
6+
7+
logger = logging.getLogger(__name__)
8+
9+
DISCORD_GUILD_ID = os.environ.get('DISCORD_GUILD_ID')
10+
11+
class Ping(commands.Cog):
12+
def __init__(self, bot):
13+
self.bot = bot
14+
15+
@app_commands.guilds(discord.Object(id=DISCORD_GUILD_ID)) # type: ignore
16+
@app_commands.command(name='ping', description='Ping the bot to check if it is online')
17+
18+
async def ping(self, interaction: discord.Interaction):
19+
await interaction.response.send_message(f'{interaction.user.mention} Pong!', ephemeral=True, delete_after=30)
20+
21+
async def setup(bot):
22+
await bot.add_cog(Ping(bot))

src/cogs/view-stats.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import os
2+
import asyncio
3+
import discord
4+
import logging
5+
import subprocess
6+
from discord import app_commands
7+
from discord.ext import commands
8+
from prettytable import PrettyTable
9+
10+
logger = logging.getLogger(__name__)
11+
12+
DISCORD_GUILD_ID = os.environ.get('DISCORD_GUILD_ID')
13+
14+
class ViewStats(commands.Cog):
15+
def __init__(self, bot: commands.Bot):
16+
self.bot = bot
17+
18+
def get_meminfo(self, proc_root="/proc/meminfo"):
19+
meminfo = {}
20+
try:
21+
with open(f"{proc_root}", "r") as f:
22+
for line in f:
23+
if ":" in line:
24+
key, value = line.split(":", 1)
25+
meminfo[key.strip()] = value.strip()
26+
except Exception as e:
27+
meminfo['error'] = f"Error reading meminfo: {e}"
28+
return meminfo
29+
30+
def get_cpu_stat(self, proc_root="/proc/stat"):
31+
cpu_stats = {}
32+
cpu_error = {}
33+
try:
34+
with open(f"{proc_root}", "r") as f:
35+
for line in f:
36+
if line.startswith("cpu "):
37+
parts = line.split()
38+
cpu_stats = {
39+
"user": int(parts[1]),
40+
"nice": int(parts[2]),
41+
"system": int(parts[3]),
42+
"idle": int(parts[4]),
43+
"iowait": int(parts[5]) if len(parts) > 5 else 0,
44+
"irq": int(parts[6]) if len(parts) > 6 else 0,
45+
"softirq": int(parts[7]) if len(parts) > 7 else 0,
46+
}
47+
break
48+
except Exception as e:
49+
cpu_error['error'] = f"Error reading CPU stat: {e}"
50+
return cpu_stats
51+
52+
async def get_current_cpu_usage(self, proc_root="/proc/stat") -> float:
53+
# Get the first reading.
54+
stat1 = self.get_cpu_stat(proc_root)
55+
await asyncio.sleep(1)
56+
# Get the second reading.
57+
stat2 = self.get_cpu_stat(proc_root)
58+
59+
# Sum up all CPU times for each reading.
60+
total1 = sum(stat1.get(k, 0) for k in ("user", "nice", "system", "idle", "iowait", "irq", "softirq"))
61+
total2 = sum(stat2.get(k, 0) for k in ("user", "nice", "system", "idle", "iowait", "irq", "softirq"))
62+
idle1 = stat1.get("idle", 0) + stat1.get("iowait", 0)
63+
idle2 = stat2.get("idle", 0) + stat2.get("iowait", 0)
64+
65+
delta_total = total2 - total1
66+
delta_idle = idle2 - idle1
67+
68+
if delta_total == 0:
69+
return 0.0
70+
# CPU usage during the interval.
71+
current_usage = (delta_total - delta_idle) / delta_total * 100
72+
return current_usage
73+
74+
def get_uptime(self, proc_root="/proc/uptime"):
75+
try:
76+
with open(f"{proc_root}", "r") as f:
77+
uptime_info = f.read().strip().split()
78+
return {
79+
"uptime_seconds": float(uptime_info[0]),
80+
"idle_seconds": float(uptime_info[1])
81+
}
82+
except Exception as e:
83+
return {"error": f"Error reading uptime: {e}"}
84+
85+
def format_memory_usage(self, meminfo: dict) -> str:
86+
try:
87+
total_str = meminfo.get("MemTotal", "0 kB").split()[0]
88+
avail_str = meminfo.get("MemAvailable", "0 kB").split()[0]
89+
total_kb = int(total_str)
90+
avail_kb = int(avail_str)
91+
used_kb = total_kb - avail_kb
92+
# Convert kilobytes to gigabytes (1 GB = 1024*1024 kB)
93+
used_gb = used_kb / (1024 * 1024)
94+
total_gb = total_kb / (1024 * 1024)
95+
return f"{used_gb:.2f} GB / {total_gb:.2f} GB"
96+
except Exception as e:
97+
return f"Error: {e}"
98+
99+
def format_uptime(self, uptime_seconds: float) -> str:
100+
try:
101+
total_seconds = int(uptime_seconds)
102+
days = total_seconds // 86400
103+
hours = (total_seconds % 86400) // 3600
104+
minutes = (total_seconds % 3600) // 60
105+
seconds = total_seconds % 60
106+
return f"{days:02d}d:{hours:02d}h:{minutes:02d}m:{seconds:02d}s"
107+
except Exception as e:
108+
return f"Error: {e}"
109+
110+
def get_disk_usage(self) -> str:
111+
try:
112+
result = subprocess.run(["df", "-h"], capture_output=True, text=True, check=True)
113+
lines = result.stdout.strip().splitlines()
114+
if not lines:
115+
return "No disk usage data available"
116+
pt = PrettyTable(["Mounted On", "Used", "Available"])
117+
# Skip the header line and process data rows.
118+
for line in lines[1:]:
119+
row = line.strip().split(None, 5)
120+
if len(row) < 6:
121+
continue
122+
mounted_on = row[5]
123+
if mounted_on.startswith("/proc") or mounted_on.startswith("/sys") or mounted_on.startswith("/dev"):
124+
continue
125+
used = row[2]
126+
available = row[3]
127+
pt.add_row([mounted_on, used, available])
128+
return pt.get_string()
129+
except subprocess.CalledProcessError as e:
130+
logger.error("Error running 'df -h': %s", e)
131+
return f"Error retrieving disk usage: {e}"
132+
133+
@app_commands.command(name='view-stats', description='See system stats on host')
134+
@app_commands.guilds(discord.Object(id=DISCORD_GUILD_ID)) # type: ignore
135+
async def view_stats(self, interaction: discord.Interaction):
136+
proc_root_meminfo = "/proc/meminfo"
137+
proc_root_stat = "/proc/stat"
138+
proc_root_uptime = "/proc/uptime"
139+
meminfo = self.get_meminfo(proc_root_meminfo)
140+
uptime_info = self.get_uptime(proc_root_uptime)
141+
142+
# Format system metrics.
143+
memory_usage = self.format_memory_usage(meminfo)
144+
current_cpu = await self.get_current_cpu_usage(proc_root_stat)
145+
formatted_uptime = self.format_uptime(float(uptime_info.get("uptime_seconds", 0)))
146+
147+
system_data = {
148+
"Memory Usage": memory_usage,
149+
"CPU Usage": f"{current_cpu:.2f}%",
150+
"Uptime": formatted_uptime
151+
}
152+
153+
pt_sys = PrettyTable(["Metric", "Value"])
154+
for metric, value in system_data.items():
155+
pt_sys.add_row([metric, value])
156+
system_table_str = pt_sys.get_string()
157+
158+
disk_usage_str = self.get_disk_usage()
159+
160+
system_embed = discord.Embed(
161+
title="System Stats",
162+
description=f"```plaintext\n{system_table_str}\n```",
163+
color=discord.Color.blue()
164+
)
165+
166+
disk_embed = discord.Embed(
167+
title="Disk Usage Stats",
168+
description=f"```plaintext\n{disk_usage_str}\n```",
169+
color=discord.Color.green()
170+
)
171+
172+
await interaction.response.send_message(embeds=[system_embed, disk_embed], ephemeral=True, delete_after=30)
173+
174+
async def setup(bot: commands.Bot):
175+
await bot.add_cog(ViewStats(bot))

src/entrypoint.sh

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/bin/sh
2+
3+
if [ -n "$TZ" ]; then
4+
if [ -f "/usr/share/zoneinfo/$TZ" ]; then
5+
cp "/usr/share/zoneinfo/$TZ" /etc/localtime
6+
echo "$TZ" > /etc/timezone
7+
else
8+
echo "Warning: Timezone $TZ not found, using default"
9+
fi
10+
else
11+
echo "No TZ environment variable set, using default"
12+
fi
13+
14+
exec gosu nonroot "$@"

0 commit comments

Comments
 (0)