diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e33ba75 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +#app.__init__ \ No newline at end of file diff --git a/app/bot.py b/app/bot.py new file mode 100644 index 0000000..23b5311 --- /dev/null +++ b/app/bot.py @@ -0,0 +1,206 @@ +#app.bot + +import discord +import aiohttp +from datetime import datetime, timezone, timedelta +from discord import app_commands +from discord.ext import tasks + +from .config import settings +from .db import init_db, insert_check, fetch_checks_since,fetch_month_checks +from .utils import ( + check_site, summarize_counts, get_site_names, + render_bar, compute_uptime, format_detection_reason, +) + +TOKEN = settings.discord_secret_key + +# Convert Pydantic models → plain dicts once so the rest of bot.py is unchanged. +MONITORED_SITES = [site.to_dict() for site in settings.monitored_sites] + +# SITE_CHOICES stays exactly the same — it reads from MONITORED_SITES: +SITE_CHOICES = [ + app_commands.Choice(name=site["name"], value=site["name"]) + for site in MONITORED_SITES +] + +intents = discord.Intents.default() +client = discord.Client(intents=intents) +tree = app_commands.CommandTree(client) +session: aiohttp.ClientSession | None = None + +@client.event +async def on_ready(): + global session + init_db() + + if session is None: + session = aiohttp.ClientSession(headers={"User-Agent": "VoteUptimeBot/1.0"}) + + if not poll_sites.is_running(): + poll_sites.start() + + await tree.sync() + print(f"Logged in as {client.user}") + +@tasks.loop(minutes=15) +async def poll_sites(): + now = datetime.now(timezone.utc).isoformat() + + for site in MONITORED_SITES: + result = await check_site(session, site) + # Combine notes + detection_reason into the notes field so the DB + # schema doesn't need a migration. error_type already carries reason. + notes = result["notes"] + if result.get("detection_reason") and not notes: + notes = format_detection_reason(result["detection_reason"]) + + insert_check( + site_name=site["name"], + checked_at=now, + http_status=result["http_status"], + latency_ms=result["latency_ms"], + result=result["result"], + error_type=result["error_type"], # now carries detection_reason too + notes=notes, + ) + + +@poll_sites.before_loop +async def before_poll_sites(): + await client.wait_until_ready() + +class SiteNameTransformer(app_commands.Transformer): + async def transform(self, interaction: discord.Interaction, value: str) -> str: + if value not in get_site_names(MONITORED_SITES): + raise app_commands.AppCommandError(f"Unknown site: {value}") + return value + +uptime_group = app_commands.Group(name="uptime", description="Uptime tools") +tree.add_command(uptime_group) + + +@uptime_group.command(name="now", description="Show current configured sites") +async def uptime_now(interaction: discord.Interaction): + lines = [] + for site in MONITORED_SITES: + rows = fetch_checks_since( + site["name"], datetime.now(timezone.utc) - timedelta(hours=1) + ) + if not rows: + lines.append(f"**{site['name']}**: no recent data") + continue + + checked_at, result, http_status, latency_ms, error_type = rows[-1] + + # Emoji prefix for quick scanning + emoji = {"up": "🟩", "degraded": "🟨", "down": "🟥"}.get(result, "⬛") + + detail = f"{result.upper()} | status={http_status} | latency={latency_ms}ms" + if error_type: + detail += f" | reason={format_detection_reason(error_type)}" + + lines.append(f"{emoji} **{site['name']}**: {detail}") + + await interaction.response.send_message("\n".join(lines)) + + +SITE_CHOICES = [ + app_commands.Choice(name=site["name"], value=site["name"]) + for site in MONITORED_SITES +] + +@uptime_group.command(name="day", description="Last 24 hours in 15-minute bars") +@app_commands.describe(site="Site name") +@app_commands.choices(site=SITE_CHOICES) +async def uptime_day( + interaction: discord.Interaction, + site: app_commands.Transform[str, SiteNameTransformer], +): + since = datetime.now(timezone.utc) - timedelta(hours=24) + rows = fetch_checks_since(site, since) + results = [row[1] for row in rows] + + bar = render_bar(results[-96:]) if results else "⬛" + uptime = compute_uptime(results) + up, degraded, down = summarize_counts(results) + + msg = ( + f"**{site}** last 24h\n" + f"{bar}\n" + f"Uptime: **{uptime:.2f}%**\n" + f"Up: {up} | Degraded: {degraded} | Down: {down}" + ) + await interaction.response.send_message(msg) + + +@uptime_group.command(name="month", description="Current month summary") +@app_commands.describe(site="Site name") +@app_commands.choices(site=SITE_CHOICES) +async def uptime_month( + interaction: discord.Interaction, + site: app_commands.Transform[str, SiteNameTransformer], +): + now = datetime.now(timezone.utc) + rows = fetch_month_checks(site, now.year, now.month) + + by_day: dict[str, list[str]] = {} + for checked_at, result, *_ in rows: + day_key = checked_at[:10] + by_day.setdefault(day_key, []).append(result) + + day_bars = [] + for day in sorted(by_day.keys()): + pct = compute_uptime(by_day[day]) + if pct >= 99: + day_bars.append("🟩") + elif pct >= 95: + day_bars.append("🟨") + else: + day_bars.append("🟥") + + all_results = [row[1] for row in rows] + uptime = compute_uptime(all_results) + up, degraded, down = summarize_counts(all_results) + + msg = ( + f"**{site}** {now.year}-{now.month:02d}\n" + f"{''.join(day_bars) if day_bars else '⬛'}\n" + f"Uptime: **{uptime:.2f}%**\n" + f"Up: {up} | Degraded: {degraded} | Down: {down}" + ) + await interaction.response.send_message(msg) + + +@uptime_group.command(name="summarize", description="Summarize current month for all sites") +async def uptime_summarize(interaction: discord.Interaction): + now = datetime.now(timezone.utc) + lines = [f"**Monthly summary for {now.year}-{now.month:02d}**"] + + for site in MONITORED_SITES: + rows = fetch_month_checks(site["name"], now.year, now.month) + results = [row[1] for row in rows] + uptime = compute_uptime(results) + up, degraded, down = summarize_counts(results) + lines.append( + f"{site['name']}: uptime={uptime:.2f}% | up={up} | degraded={degraded} | down={down}" + ) + + await interaction.response.send_message("\n".join(lines)) + + +@tree.command(name="incident", description="Placeholder incident review command") +async def incident(interaction: discord.Interaction): + await interaction.response.send_message("Incident review command placeholder.") + + +@tree.command(name="hello", description="Say hello") +async def hello(interaction: discord.Interaction): + await interaction.response.send_message("Hello, world!") + + +@tree.command(name="add", description="Add two numbers") +async def add(interaction: discord.Interaction, a: float, b: float): + await interaction.response.send_message(f"Sum: {a + b}") + +client.run(TOKEN) \ No newline at end of file diff --git a/app/checker.py b/app/checker.py new file mode 100644 index 0000000..5b7ea3c --- /dev/null +++ b/app/checker.py @@ -0,0 +1,161 @@ +#app.checker + +import asyncio +import time +import aiohttp + +# Fingerprints that identify a Cloudflare interstitial/challenge/block page. +# These appear in the response body even when the HTTP status is 200. +CLOUDFLARE_FINGERPRINTS = [ + "Just a moment", # JS challenge page