From 9788c5d9b377bd87a53e3a836e8b9ce07970cbac Mon Sep 17 00:00:00 2001 From: JakeBreath Date: Sun, 16 Aug 2026 16:37:01 -0500 Subject: [PATCH] Started repo and finished the bot in one shot --- .env.example | 6 ++ .gitignore | 5 ++ AGENTS.md | 1 + checks.py | 90 ++++++++++++++++++++++ config.py | 12 +++ healthcheckbot.service | 17 +++++ main.py | 169 +++++++++++++++++++++++++++++++++++++++++ requirements.txt | 3 + 8 files changed, 303 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 checks.py create mode 100644 config.py create mode 100644 healthcheckbot.service create mode 100644 main.py create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5ecf51a --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Discord bot token (Discord Developer Portal -> your application -> Bot) +DISCORD_TOKEN= + +# Optional tuning +CHECK_INTERVAL=60 +CHECK_TIMEOUT=10 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..70a6c06 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +venv/ +__pycache__/ +*.pyc +.status_message.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..17b3a2d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +you have to use the venv inside the venv folder on this path, dont try to use the system's python diff --git a/checks.py b/checks.py new file mode 100644 index 0000000..8881844 --- /dev/null +++ b/checks.py @@ -0,0 +1,90 @@ +import asyncio +import json +import time + +import aiohttp + +TARGETS = [ + { + 'name': 'Gitea', + 'url': 'https://gitea.rainbow-herring.ts.net/api/healthz', + 'kind': 'gitea', + }, + { + 'name': 'Packs Site (Production)', + 'url': 'https://jakerasp.rainbow-herring.ts.net/healthz/', + 'kind': 'packs', + }, + { + 'name': 'Packs Site (Development)', + 'url': 'https://jakepc.rainbow-herring.ts.net/healthz/', + 'kind': 'packs', + }, +] + +GITEA_VERSION_URL = 'https://gitea.rainbow-herring.ts.net/api/v1/version' + + +async def _get(session, url, timeout): + started = time.monotonic() + async with session.get( + url, + timeout=aiohttp.ClientTimeout(total=timeout), + allow_redirects=False, + ) as resp: + latency_ms = round((time.monotonic() - started) * 1000) + body = await resp.text() + return resp.status, latency_ms, body + + +async def check_target(session, target, timeout): + result = { + 'name': target['name'], + 'kind': target.get('kind', ''), + 'url': target['url'], + 'up': False, + 'status_code': None, + 'latency_ms': None, + 'db': None, + 'version': None, + 'detail': '', + } + try: + status, latency_ms, body = await _get(session, target['url'], timeout) + result['status_code'] = status + result['latency_ms'] = latency_ms + + if status == 200: + data = {} + try: + data = json.loads(body) + except (json.JSONDecodeError, ValueError): + pass + result['up'] = data.get('status') == 'pass' + if data.get('db') is not None: + result['db'] = data['db'] + else: + result['detail'] = f'HTTP {status}' + except asyncio.TimeoutError: + result['detail'] = 'timeout' + except aiohttp.ClientError: + result['detail'] = 'connection error' + except Exception as exc: + result['detail'] = type(exc).__name__ + + if result['up'] and result['kind'] == 'gitea': + try: + status, _, body = await _get(session, GITEA_VERSION_URL, timeout) + if status == 200: + result['version'] = json.loads(body).get('version') + except Exception: + pass + + return result + + +async def run_checks(session, timeout): + results = await asyncio.gather( + *(check_target(session, target, timeout) for target in TARGETS) + ) + return list(results) diff --git a/config.py b/config.py new file mode 100644 index 0000000..01688a9 --- /dev/null +++ b/config.py @@ -0,0 +1,12 @@ +import os + +from dotenv import load_dotenv + +load_dotenv() + +DISCORD_TOKEN = os.getenv('DISCORD_TOKEN', '').strip() + +CHECK_INTERVAL = int(os.getenv('CHECK_INTERVAL', '60')) +CHECK_TIMEOUT = float(os.getenv('CHECK_TIMEOUT', '10')) + +STATE_FILE = os.getenv('STATE_FILE', '.status_message.json') diff --git a/healthcheckbot.service b/healthcheckbot.service new file mode 100644 index 0000000..af496a3 --- /dev/null +++ b/healthcheckbot.service @@ -0,0 +1,17 @@ +[Unit] +Description=Health Check Discord Bot +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=jake +Group=jake +WorkingDirectory=/mnt/Disco/Proyects/Python3.13/HealthCheckBot/ +ExecStart=/mnt/Disco/Proyects/Python3.13/HealthCheckBot/venv/bin/python /mnt/Disco/Proyects/Python3.13/HealthCheckBot/main.py +Restart=always +RestartSec=5 +EnvironmentFile=/mnt/Disco/Proyects/Python3.13/HealthCheckBot/.env + +[Install] +WantedBy=multi-user.target diff --git a/main.py b/main.py new file mode 100644 index 0000000..830a912 --- /dev/null +++ b/main.py @@ -0,0 +1,169 @@ +import asyncio +import json +import logging + +import aiohttp +import discord +from discord import app_commands + +import checks +import config + +log = logging.getLogger('healthbot') + + +class HealthBot(discord.Client): + def __init__(self): + intents = discord.Intents.default() + super().__init__(intents=intents) + self.tree = app_commands.CommandTree(self) + self.session: aiohttp.ClientSession | None = None + self.status_messages: list[dict] = [] + self._monitor_task: asyncio.Task | None = None + + def _load_state(self): + try: + with open(config.STATE_FILE, 'r') as f: + data = json.load(f) + self.status_messages = [ + {'channel_id': int(m['channel_id']), 'message_id': int(m['message_id'])} + for m in data.get('messages', []) + ] + except (OSError, ValueError, TypeError, KeyError): + self.status_messages = [] + + def _save_state(self): + try: + with open(config.STATE_FILE, 'w') as f: + json.dump({'messages': self.status_messages}, f) + except OSError: + log.warning('Could not save state file %s', config.STATE_FILE) + + def register_status_message(self, channel_id, message_id): + if any(m['message_id'] == message_id for m in self.status_messages): + return + self.status_messages.append({'channel_id': channel_id, 'message_id': message_id}) + self._save_state() + + def forget_status_message(self, message_id): + self.status_messages = [ + m for m in self.status_messages if m['message_id'] != message_id + ] + self._save_state() + + async def _ensure_session(self): + if self.session is None or self.session.closed: + self.session = aiohttp.ClientSession() + return self.session + + async def run_checks(self): + session = await self._ensure_session() + return await checks.run_checks(session, config.CHECK_TIMEOUT) + + def build_embed(self, results): + all_up = all(r['up'] for r in results) + color = discord.Color.green() if all_up else discord.Color.red() + embed = discord.Embed( + title='Service Health', + color=color, + timestamp=discord.utils.utcnow(), + ) + for r in results: + emoji = '🟢' if r['up'] else '🔴' + code = r['status_code'] if r['status_code'] is not None else '—' + latency = f"{r['latency_ms']} ms" if r['latency_ms'] is not None else '—' + value = f'{emoji} `HTTP {code}` · {latency}' + if r.get('db') is not None: + value += f' · db {r["db"]}' + if r.get('version'): + value += f' · v{r["version"]}' + if r.get('detail'): + value += f'\n*{r["detail"]}*' + embed.add_field(name=r['name'], value=value, inline=False) + embed.set_footer(text=f'Updates every {config.CHECK_INTERVAL}s') + return embed + + async def update_embeds(self): + if not self.status_messages: + return + results = await self.run_checks() + embed = self.build_embed(results) + + kept = [] + for entry in self.status_messages: + channel = self.get_channel(entry['channel_id']) + if channel is None: + try: + channel = await self.fetch_channel(entry['channel_id']) + except (discord.NotFound, discord.Forbidden, discord.HTTPException): + continue + try: + message = await channel.fetch_message(entry['message_id']) + await message.edit(embed=embed) + kept.append(entry) + except (discord.NotFound, discord.Forbidden, discord.HTTPException): + continue + + self.status_messages = kept + self._save_state() + + async def _monitor_loop(self): + await self.wait_until_ready() + log.info('Monitor loop started (interval %ss)', config.CHECK_INTERVAL) + while not self.is_closed(): + try: + await self.update_embeds() + except Exception as exc: + log.exception('Monitor tick failed: %s', exc) + await asyncio.sleep(config.CHECK_INTERVAL) + + async def on_ready(self): + log.info('Logged in as %s (id %s)', self.user, self.user.id) + self._load_state() + await self.tree.sync() + log.info('Synced slash commands; tracking %d status message(s)', len(self.status_messages)) + if self._monitor_task is None or self._monitor_task.done(): + self._monitor_task = self.loop.create_task(self._monitor_loop()) + + async def close(self): + if self.session is not None and not self.session.closed: + await self.session.close() + await super().close() + + +bot = HealthBot() + + +@bot.tree.command(name='status', description='Post a live-updating health embed for the services') +async def status(interaction: discord.Interaction): + await interaction.response.defer() + results = await bot.run_checks() + embed = bot.build_embed(results) + + existing = next( + (m for m in bot.status_messages if m['channel_id'] == interaction.channel_id), + None, + ) + if existing: + channel = interaction.channel + try: + message = await channel.fetch_message(existing['message_id']) + await message.edit(embed=embed) + await interaction.followup.send( + 'Refreshed the existing status embed.', ephemeral=True, + ) + return + except (discord.NotFound, discord.Forbidden, discord.HTTPException): + bot.forget_status_message(existing['message_id']) + + message = await interaction.followup.send(embed=embed) + bot.register_status_message(interaction.channel_id, message.id) + + +def main(): + logging.basicConfig(level=logging.INFO, format='%(asctime)s %(name)s %(levelname)s %(message)s') + bot.run(config.DISCORD_TOKEN) + + +if __name__ == '__main__': + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..fc0f8e1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +discord.py +aiohttp +python-dotenv