commit 9788c5d9b377bd87a53e3a836e8b9ce07970cbac Author: JakeBreath Date: Sun Aug 16 16:37:01 2026 -0500 Started repo and finished the bot in one shot 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