Repository: purduehackers/webring
Pull request #16: Discord integration
Author: login: kdkasad
Labels: name: enhancement
description: New feature or request
Created: 2025-05-20T07:05:49Z | Merged: 2025-05-24T02:20:47Z

Sends Discord notifications when site checks fail.

Closes #7 

# Nitpicks
- The `discord::tests::send_notifications` unit test is really an integration test disguised as a unit test. But since we don't have real integration testing set up (yet), it's probably best to just leave it that way.
- The messages aren't formatted as nicely as the current webring's messages.

# To do
- [x] Rebase on `rust` now that #18 is merged.
- [x] Test that a failing site does actually trigger a notification.
- [x] Fix unfinished doc comments.

Linked issue #7: Discord notifications when validation fails
Need to implement sending Discord notifications when a site fails the validation check.

Diff:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6a0f376..6a7be8b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -62,6 +62,10 @@ jobs:
   test:
     name: Run tests
     runs-on: ubuntu-latest
+    environment: Testing
+    env:
+      DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
+      DISCORD_PING_USER_ID: ${{ secrets.DISCORD_PING_USER_ID }}
     steps:
       - name: Check out sources
         uses: actions/checkout@v4
@@ -72,9 +76,12 @@ jobs:
       - name: Cache build artifacts
         uses: Swatinem/rust-cache@v2
 
-      - name: Run tests
+      - name: Run default tests
         run: cargo test --workspace --all-targets --no-fail-fast --locked
 
+      - name: Run ignored tests
+        run: cargo test --workspace --all-targets --no-fail-fast --locked -- --ignored
+
   # Adding this because we don't want to run the whole build workflow on each
   # push
   build:
diff --git a/Cargo.lock b/Cargo.lock
index 8af80a9..159fbdd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1422,6 +1422,7 @@ dependencies = [
  "same-file",
  "sarlacc",
  "serde",
+ "serde_json",
  "surge-ping",
  "tempfile",
  "tera",
diff --git a/Cargo.toml b/Cargo.toml
index a3497c2..acf1105 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -13,6 +13,8 @@ edition = "2024"
 # Enable clippy's pedantic group
 [lints.clippy]
 pedantic = { level = "warn", priority = -1 }
+# Slice indexing can always panic. Why only warn in match blocks?
+match-on-vec-items = "allow"
 
 [dependencies]
 axum = "0.8.4"
@@ -27,10 +29,11 @@ notify = "8.0.0"
 papaya = { version = "0.2.1", features = ["serde"] }
 quick-xml = { version = "0.37.5", features = ["async-tokio"] }
 rand = "0.9.1"
-reqwest = { version = "0.12.15", features = ["stream"] }
+reqwest = { version = "0.12.15", features = ["stream", "json"] }
 same-file = "1.0.6"
 sarlacc = "0.1.3"
 serde = { version = "1.0.219", features = ["derive"] }
+serde_json = "1.0.140"
 surge-ping = "0.8.2"
 tera = "1.20.0"
 thiserror = "2.0.12"
diff --git a/src/checking.rs b/src/checking.rs
index c89dd71..1651aef 100644
--- a/src/checking.rs
+++ b/src/checking.rs
@@ -1,11 +1,14 @@
-use std::{error::Error, fmt::Display, io::ErrorKind};
+use std::{
+    fmt::{Display, Write as _},
+    io::ErrorKind,
+};
 
 use axum::http::{Uri, uri::Scheme};
 use chrono::Utc;
-use eyre::Report;
 use futures::TryStreamExt;
 use log::{error, info};
 use quick_xml::{Reader, events::Event};
+use reqwest::StatusCode;
 use sarlacc::Intern;
 use tokio::sync::RwLock;
 use tokio_util::io::StreamReader;
@@ -18,7 +21,7 @@ static ONLINE_CHECK_TTL_MS: i64 = 1000;
 static PING_INFO: RwLock<(i64, bool)> = RwLock::const_new((i64::MIN, false));
 
 /// If a request succeeds, then call this function to mark the server as definitely online.
-async fn server_definitely_online() {
+async fn mark_server_as_online() {
     let at = Utc::now().timestamp_millis();
     let mut ping_info = PING_INFO.write().await;
     let now = Utc::now().timestamp_millis();
@@ -36,7 +39,7 @@ async fn is_online() -> bool {
         // Has it been checked within the TTL?
         let ping_info = PING_INFO.read().await;
         let now = Utc::now().timestamp_millis();
-        if now < ping_info.0 + 1000 {
+        if now < ping_info.0 + ONLINE_CHECK_TTL_MS {
             return ping_info.1;
         }
     }
@@ -50,37 +53,54 @@ async fn is_online() -> bool {
         return ping_info.1;
     }
 
-    // Ping something
-    let status = surge_ping::ping("8.8.8.8".parse().unwrap(), &[0; 8]).await;
+    // Ping something.
+    // Pings don't work in GitHub Actions runners, so if we're running in GH Actions, just pretend
+    // our ping succeeded, since we know we're online. We do this check only in non-release builds
+    // so we don't incur the runtime cost of checking the environment variable repeatedly. We don't
+    // just check at compile time because we may want to build binaries on GH Actions in the
+    // future.
+    let ping_successful = if cfg!(debug_assertions)
+        && std::env::var("GITHUB_ACTIONS").is_ok_and(|val| &val == "true")
+    {
+        true
+    } else {
+        let result = surge_ping::ping("8.8.8.8".parse().unwrap(), &[0; 8]).await;
+        result.is_ok()
+    };
 
     // Write the info
     let now = Utc::now().timestamp_millis();
-    *ping_info = (now, status.is_ok());
+    *ping_info = (now, ping_successful);
 
-    ping_info.1
+    ping_successful
 }
 
-/// Checks whether a given URL passes the given check level and sends off discord messages if not.
+/// Checks whether a given URL passes the given check level.
 ///
-/// Returns Some with the result, or None if the server seems to be not connected to the internet.
-#[allow(clippy::unused_async)]
+/// Returns `Some` with the failure details if the site fails the check, or `None` if it passes (or
+/// if the check cannot be performed, e.g., due to the server being offline).
 pub async fn check(
     website: &Uri,
     check_level: CheckLevel,
     base_address: Intern<Uri>,
-) -> Option<bool> {
+) -> Option<CheckFailure> {
     match check_impl(website, check_level, base_address).await {
-        Ok(()) => Some(true),
-        Err(e) => {
-            // TODO: Discord
-
-            if is_online().await {
-                info!("{website} failed a check: {e}");
-                Some(false)
-            } else {
-                error!("Server side connectivity issue detected!");
-                None
+        None => None,
+        Some(failure) => {
+            // If the issue is not a connection issue, or if it is a connection issue and the
+            // server is online, return it. Otherwise, it's a connection issue on our end, so log
+            // and count the check as successful.
+            if let CheckFailure::Connection(connection_error) = &failure {
+                if !is_online().await {
+                    error!(
+                        "Server-side connectivity issue detected: Could not reach {website}: {connection_error}"
+                    );
+                    return None;
+                }
             }
+
+            info!("{website} failed a check: {failure}");
+            Some(failure)
         }
     }
 }
@@ -89,40 +109,103 @@ async fn check_impl(
     website: &Uri,
     check_level: CheckLevel,
     base_address: Intern<Uri>,
-) -> eyre::Result<()> {
-    match check_level {
-        CheckLevel::ForLinks => {
-            let res = reqwest::get(website.to_string())
-                .await?
-                .error_for_status()?;
-            server_definitely_online().await;
-
-            // Stream the body instead of collecting it into memory
-            let stream = res.bytes_stream();
-
-            // Adapt the stream type returned by reqwest to the type expected by quick_xml
-            match contains_link(
-                StreamReader::new(stream.map_err(|e| std::io::Error::new(ErrorKind::Other, e))),
-                base_address,
-            )
-            .await
-            {
-                None => Ok(()),
-                Some(links) => Err(Report::new(links)),
+) -> Option<CheckFailure> {
+    if check_level == CheckLevel::None {
+        return None;
+    }
+
+    let response = match reqwest::get(website.to_string()).await {
+        Ok(response) => response,
+        Err(err) => return Some(CheckFailure::Connection(err)),
+    };
+    mark_server_as_online().await;
+    let successful_response = match response.error_for_status() {
+        Ok(r) => r,
+        Err(err) => return Some(CheckFailure::ResponseStatus(err.status().unwrap())),
+    };
+
+    if check_level == CheckLevel::ForLinks {
+        let stream = successful_response.bytes_stream();
+
+        return scan_for_links(
+            StreamReader::new(stream.map_err(|e| std::io::Error::new(ErrorKind::Other, e))),
+            base_address,
+        )
+        .await
+        .map(CheckFailure::MissingLinks);
+    }
+
+    None
+}
+
+/// Represents a failed result of a validation check
+#[derive(Debug)]
+pub enum CheckFailure {
+    /// Failed to connect to the server
+    Connection(reqwest::Error),
+    /// Site returned a non-2xx response
+    ResponseStatus(StatusCode),
+    /// Site returned a successful response but is missing the expected links
+    MissingLinks(MissingLinks),
+}
+
+impl CheckFailure {
+    /// Construct a message suitable for the site owner about the given check failure. For
+    /// a shorter message format suitable for debugging/logging, use the [`Display`] trait.
+    #[must_use]
+    pub fn to_message(&self) -> String {
+        match self {
+            CheckFailure::Connection(err) => format!("Connection to your site failed: {err}"),
+            CheckFailure::ResponseStatus(status_code) => {
+                let mut msg = format!(
+                    "Your site returned an error response: {}",
+                    status_code.as_u16()
+                );
+                if let Some(description) = status_code.canonical_reason() {
+                    write!(&mut msg, " ({description})").unwrap();
+                }
+                msg
             }
+            CheckFailure::MissingLinks(missing_links) => missing_links.to_string(),
         }
-        CheckLevel::JustOnline => {
-            reqwest::get(website.to_string())
-                .await?
-                .error_for_status()?;
-            server_definitely_online().await;
+    }
+}
 
-            Ok(())
+/// Displays this check failure in a short format suitable for debugging/logging but not suitable
+/// for sending to a site owner. For that, use [`CheckFailure::to_message()`].
+impl Display for CheckFailure {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            CheckFailure::Connection(error) => write!(f, "Connection error: {error}"),
+            CheckFailure::ResponseStatus(status_code) => {
+                write!(f, "Site returned {}", status_to_string(*status_code))
+            }
+            CheckFailure::MissingLinks(missing_links) => {
+                let mut missing_link_names = Vec::with_capacity(3);
+                if missing_links.home {
+                    missing_link_names.push("ring homepage");
+                }
+                if missing_links.prev {
+                    missing_link_names.push("previous site");
+                }
+                if missing_links.next {
+                    missing_link_names.push("next site");
+                }
+                write!(f, "Missing links: {}", missing_link_names.join(", "))
+            }
         }
-        CheckLevel::None => Ok(()),
     }
 }
 
+/// Format a status code as `Code (Reason String)`, e.g. `404 (Not Found)`.
+fn status_to_string(status: StatusCode) -> String {
+    let mut msg = status.as_u16().to_string();
+    if let Some(description) = status.canonical_reason() {
+        write!(&mut msg, " ({description})").unwrap();
+    }
+    msg
+}
+
 #[derive(Debug, PartialEq, Eq)]
 pub struct MissingLinks {
     base_address: Intern<Uri>,
@@ -157,16 +240,17 @@ impl MissingLinks {
 
 impl Display for MissingLinks {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        let address = &self.base_address;
-        writeln!(f, "Your webpage is missing the following links:")?;
+        let address_string = self.base_address.to_string();
+        let address = address_string.strip_suffix('/').unwrap_or(&address_string);
+        writeln!(f, "Your site is missing the following links:")?;
         if self.home {
-            writeln!(f, "- {address}")?;
+            writeln!(f, "- <{address}>")?;
         }
         if self.next {
-            writeln!(f, "- {address}/next")?;
+            writeln!(f, "- <{address}/next>")?;
         }
         if self.prev {
-            writeln!(f, "- {address}/prev")?;
+            writeln!(f, "- <{address}/prev>")?;
         }
 
         writeln!(
@@ -174,14 +258,12 @@ impl Display for MissingLinks {
             "\nWhat to do:
 - If your webpage is rendered client-side, ask the administrators to set the validator to only check for your site being online.
 - If you don't use anchor tags for the links, add the attribute `data-phwebring=\"prev\"|\"home\"|\"next\"` to the link elements.
-- If you think this alert is in error, contact Henry."
+- If you think this alert is in error, send a message in #webring."
         )
     }
 }
 
-impl Error for MissingLinks {}
-
-async fn contains_link(
+async fn scan_for_links(
     webpage: impl tokio::io::AsyncBufRead + Unpin,
     base_address: Intern<Uri>,
 ) -> Option<MissingLinks> {
@@ -255,10 +337,11 @@ async fn contains_link(
 
 #[cfg(test)]
 mod tests {
-    use axum::http::Uri;
+    use axum::{Router, http::Uri, response::Html, routing::get};
+    use reqwest::StatusCode;
     use sarlacc::Intern;
 
-    use super::{MissingLinks, contains_link};
+    use super::{CheckFailure, CheckLevel, MissingLinks, scan_for_links};
 
     async fn assert_links_gives(
         base_address: &'static str,
@@ -266,7 +349,7 @@ mod tests {
         res: impl Into<Option<(bool, bool, bool)>>,
     ) {
         assert_eq!(
-            contains_link(
+            scan_for_links(
                 file.replace("ADDRESS", base_address).as_bytes(),
                 Intern::new(Uri::from_static(base_address))
             )
@@ -426,4 +509,85 @@ mod tests {
         )
         .await;
     }
+
+    #[tokio::test]
+    async fn check_failure_types() {
+        // Start a web server so we can do each kinds of checks
+        let server_addr = ("127.0.0.1", 32750);
+        tokio::spawn(async move {
+            let listener = tokio::net::TcpListener::bind(&server_addr).await.unwrap();
+            let router: Router<()> = Router::new()
+                .route("/up", get(async || "Hi there!"))
+                .route(
+                    "/links",
+                    get(async || {
+                        Html(
+                            r#"
+                        <a href="https://ring.purduehackers.com/">Purdue Hackers webring</a>
+                        <a href="https://ring.purduehackers.com/prev">Previous site</a>
+                        <a href="https://ring.purduehackers.com/next">Next site</a>
+                        "#,
+                        )
+                    }),
+                )
+                .route(
+                    "/error",
+                    get(async || {
+                        let status = StatusCode::from_u16(rand::random_range(400..600)).unwrap();
+                        (status, "Uh oh :(")
+                    }),
+                );
+            axum::serve(listener, router).await.unwrap();
+        });
+
+        // Create a site for each endpoint, plus one which will fail to connect.
+        // The second value in the tuple is the list of checks for which this member should succeed.
+        // The third is the check failure we expect.
+        #[expect(clippy::type_complexity)]
+        let sites: Vec<(Uri, Vec<CheckLevel>, fn(CheckFailure) -> bool)> = vec![
+            (
+                Uri::from_static("http://127.0.0.10:0/connection"),
+                vec![CheckLevel::None],
+                |failure| matches!(failure, CheckFailure::Connection(_)),
+            ),
+            (
+                Uri::from_static("http://127.0.0.1:32750/error"),
+                vec![CheckLevel::None],
+                |failure| matches!(failure, CheckFailure::ResponseStatus(_)),
+            ),
+            (
+                Uri::from_static("http://127.0.0.1:32750/up"),
+                vec![CheckLevel::None, CheckLevel::JustOnline],
+                |failure| matches!(failure, CheckFailure::MissingLinks(_)),
+            ),
+            (
+                Uri::from_static("http://127.0.0.1:32750/links"),
+                vec![
+                    CheckLevel::None,
+                    CheckLevel::JustOnline,
+                    CheckLevel::ForLinks,
+                ],
+                |_| false,
+            ),
+        ];
+
+        let base = Intern::new(Uri::from_static("https://ring.purduehackers.com"));
+        for (site, expect_passing, does_failure_match) in sites {
+            let levels = [
+                CheckLevel::None,
+                CheckLevel::JustOnline,
+                CheckLevel::ForLinks,
+            ];
+            for level in levels {
+                // FIXME: Collect CheckFailure and check type
+                let maybe_failure = super::check(&site, level, base).await;
+                eprintln!("Checking {} at level {:?}", &site, level);
+                let was_successful = maybe_failure.is_none();
+                assert_eq!(expect_passing.contains(&level), was_successful);
+                if !was_successful {
+                    assert!(does_failure_match(maybe_failure.unwrap()));
+                }
+            }
+        }
+    }
 }
diff --git a/src/discord.rs b/src/discord.rs
new file mode 100644
index 0000000..7068e9c
--- /dev/null
+++ b/src/discord.rs
@@ -0,0 +1,162 @@
+//! Send messages to members over Discord
+//!
+//! Uses [Discord's webhook feature][webhook-api-ref].
+//!
+//! [webhook-api-ref]: https://discord.com/developers/docs/resources/webhook#execute-webhook
+
+use std::{fmt::Display, str::FromStr, time::Duration};
+
+use eyre::{Context, bail};
+use reqwest::{
+    Client, Response, StatusCode, Url,
+    header::{self, HeaderValue},
+};
+use serde::Serialize;
+use serde_json::json;
+
+/// A user agent representing our program. [Required by Discord][api-doc-ua].
+///
+/// [api-doc-ua]: https://discord.com/developers/docs/reference#user-agent
+const USER_AGENT: &str = concat!(
+    "Purdue Hackers Webring (",
+    env!("CARGO_PKG_REPOSITORY"),
+    ", ",
+    env!("CARGO_PKG_VERSION"),
+    ")"
+);
+
+/// Represents a snowflake, the type used to encode identifiers in Discord APIs.
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize)]
+pub struct Snowflake(u64);
+impl From<u64> for Snowflake {
+    fn from(value: u64) -> Self {
+        Self(value)
+    }
+}
+impl From<Snowflake> for u64 {
+    fn from(value: Snowflake) -> Self {
+        value.0
+    }
+}
+impl FromStr for Snowflake {
+    type Err = <u64 as FromStr>::Err;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        s.parse().map(Self)
+    }
+}
+impl Display for Snowflake {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        self.0.fmt(f)
+    }
+}
+
+#[derive(Clone, Debug)]
+pub struct DiscordNotifier {
+    webhook_url: reqwest::Url,
+    client: Client,
+}
+
+impl DiscordNotifier {
+    /// Create a new notifier that sends notifications using the given
+    /// [webhook](https://discord.com/developers/docs/resources/webhook#execute-webhook) URL.
+    pub fn new(webhook_url: &Url) -> Self {
+        Self {
+            webhook_url: webhook_url.to_owned(),
+            client: Client::new(),
+        }
+    }
+
+    /// Send a message in the channel this notifier is registered to.
+    pub async fn send_message(&self, ping: Option<Snowflake>, message: &str) -> eyre::Result<()> {
+        loop {
+            let response = self.send_single_message(ping, message).await?;
+            // If we get rate limited, try again.
+            // See https://discord.com/developers/docs/topics/rate-limits.
+            if response.status() == StatusCode::TOO_MANY_REQUESTS {
+                match response.headers().get(header::RETRY_AFTER) {
+                    Some(value) => {
+                        sleep_from_retry_after(value).await?;
+                        continue;
+                    }
+                    None => bail!("Got rate-limited but response contains no Retry-After header"),
+                }
+            }
+            response
+                .error_for_status()
+                .wrap_err("Discord webhook returned error")?;
+            break;
+        }
+        Ok(())
+    }
+
+    async fn send_single_message(
+        &self,
+        ping: Option<Snowflake>,
+        message: &str,
+    ) -> eyre::Result<Response> {
+        const SUPPRESS_EMBEDS: u16 = 1 << 2;
+        self.client
+            .post(self.webhook_url.clone())
+            .header(header::USER_AGENT, USER_AGENT)
+            .json(&json!(
+                {
+                    "content": message,
+                    "allowed_mentions": match ping {
+                        Some(id) => json!({ "users": [id.to_string()] }),
+                        None => json!({}),
+                    },
+                    "flags": SUPPRESS_EMBEDS,
+                }
+            ))
+            .query(&[("wait", "true")])
+            .send()
+            .await
+            .map_err(reqwest::Error::without_url)
+            .wrap_err("Failed to execute Discord webhook")
+    }
+}
+
+/// Sleep for the number of seconds specified in the `Retry-After` HTTP header of a response.
+///
+/// Returns an `Err` if the `Retry-After` value cannot be parsed.
+async fn sleep_from_retry_after(header_val: &HeaderValue) -> eyre::Result<()> {
+    let val_str = header_val
+        .to_str()
+        .wrap_err("Retry-After header is invalid")?;
+    let seconds = val_str
+        .parse::<u64>()
+        .wrap_err("Retry-After header cannot be parsed into a u64")?;
+    tokio::time::sleep(Duration::from_secs(seconds)).await;
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use reqwest::Url;
+
+    use super::DiscordNotifier;
+
+    /// Tests sending a message in Discord.
+    ///
+    /// Requires the following environment variables:
+    /// - `DISCORD_WEBHOOK_URL`: The Discord webhook URL to send test messages to.
+    /// - `DISCORD_PING_USER_ID`: The ID of the user to ping.
+    #[tokio::test]
+    #[ignore = "requires secret Discord webhook environment"]
+    async fn send_notification() {
+        let url = std::env::var("DISCORD_WEBHOOK_URL").unwrap();
+        let user_id = std::env::var("DISCORD_PING_USER_ID").unwrap();
+        let notifier = DiscordNotifier::new(&Url::parse(&url).unwrap());
+        // Send a message with no ping
+        notifier.send_message(None, "this is a test").await.unwrap();
+        // Send a message with a ping
+        notifier
+            .send_message(
+                Some(user_id.parse().unwrap()),
+                &format!("this is a test to ping <@{user_id}>"),
+            )
+            .await
+            .unwrap();
+    }
+}
diff --git a/src/main.rs b/src/main.rs
index 516ff92..780c7cc 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -7,18 +7,21 @@ use std::{
     path::PathBuf,
     process::ExitCode,
     sync::Arc,
+    time::Duration,
 };
 
 use axum::http::{Uri, uri::Scheme};
-use chrono::Duration;
 use clap::{Parser, ValueEnum};
+use discord::DiscordNotifier;
 use ftail::Ftail;
 use log::LevelFilter;
+use reqwest::Url;
 use routes::create_router;
 use sarlacc::Intern;
 use webring::Webring;
 
 mod checking;
+mod discord;
 mod homepage;
 mod routes;
 mod stats;
@@ -60,6 +63,10 @@ struct CliOptions {
 
     #[arg(short = 'a', long, default_value = "https://ring.purduehackers.com", value_parser = parse_uri)]
     address: Intern<Uri>,
+
+    /// Discord webhook URL
+    #[arg(long)]
+    discord_webhook_url: Option<Url>,
 }
 
 fn parse_uri(str: &str) -> eyre::Result<Intern<Uri>> {
@@ -143,11 +150,15 @@ async fn main() -> ExitCode {
         return ExitCode::FAILURE;
     }
 
+    // Create Discord notifier
+    let maybe_notifier = cli.discord_webhook_url.as_ref().map(DiscordNotifier::new);
+
     // Create webring data structure
     let webring = match Webring::new(
         cli.members_file.clone(),
         cli.static_dir.clone(),
         cli.address,
+        maybe_notifier,
     )
     .await
     {
@@ -158,12 +169,24 @@ async fn main() -> ExitCode {
         }
     };
 
+    // Perform site checks every 5 minutes
+    {
+        const SITE_CHECK_INTERVAL: Duration = Duration::from_secs(60 * 5);
+        let webring_for_task = Arc::clone(&webring);
+        tokio::spawn(async move {
+            loop {
+                webring_for_task.check_members().await;
+                tokio::time::sleep(SITE_CHECK_INTERVAL).await;
+            }
+        });
+    }
+
     // Create member file watcher
     if let Err(err) = webring.enable_reloading() {
         log::error!("Unable to watch member file for changes: {err}");
         log::warn!("Webring will not reload automatically.");
     }
-    webring.enable_ip_pruning(Duration::hours(1));
+    webring.enable_ip_pruning(chrono::Duration::hours(1));
     log::info!("Watching {} for changes", cli.members_file.display());
 
     // Start server
diff --git a/src/routes.rs b/src/routes.rs
index 634d12c..b88026d 100644
--- a/src/routes.rs
+++ b/src/routes.rs
@@ -472,7 +472,7 @@ mod tests {
 henry — hrovnyak.gitlab.io — 123 — None
 kian — kasad.com — 456 — NonE
 cynthia — https://clementine.viridian.page — 789 — nONE
-??? — ws://refuse-the-r.ing — bruh — none
+??? — ws://refuse-the-r.ing — - — none
 ",
         )
         .await
@@ -487,6 +487,7 @@ cynthia — https://clementine.viridian.page — 789 — nONE
                 members_file.path().to_owned(),
                 static_dir.path().to_owned(),
                 Intern::new(Uri::from_static("https://ring.purduehackers.com")),
+                None,
             )
             .await
             .unwrap(),
diff --git a/src/webring.rs b/src/webring.rs
index 2eb7025..f24c83f 100644
--- a/src/webring.rs
+++ b/src/webring.rs
@@ -2,6 +2,7 @@ use std::{
     collections::HashMap,
     net::IpAddr,
     path::{Path, PathBuf},
+    str::FromStr,
     sync::{
         Arc, OnceLock, RwLock, RwLockReadGuard,
         atomic::{AtomicBool, Ordering},
@@ -10,7 +11,7 @@ use std::{
 
 use axum::http::{Uri, uri::Authority};
 use chrono::TimeDelta;
-use eyre::{OptionExt, eyre};
+use eyre::{Context, OptionExt, bail, eyre};
 use futures::{StreamExt, stream::FuturesUnordered};
 use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
 use rand::seq::SliceRandom;
@@ -20,16 +21,34 @@ use sarlacc::Intern;
 use thiserror::Error;
 
 use crate::{
-    checking::check,
+    checking::{CheckFailure, check},
+    discord::{DiscordNotifier, Snowflake},
     homepage::{Homepage, MemberForHomepage},
     stats::{Stats, UNKNOWN_ORIGIN},
 };
 
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
 pub enum CheckLevel {
-    ForLinks,
-    JustOnline,
     None,
+    JustOnline,
+    ForLinks,
+}
+
+impl FromStr for CheckLevel {
+    type Err = eyre::Report;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        Ok(match s.to_lowercase().as_str() {
+            "links" => CheckLevel::ForLinks,
+            "online" => CheckLevel::JustOnline,
+            "none" => CheckLevel::None,
+            _ => {
+                bail!(
+                    "Expected the check level to be one of {{\"links\", \"online\", \"none\"}}. Got \"{s}\""
+                );
+            }
+        })
+    }
 }
 
 #[derive(Clone, Debug)]
@@ -37,27 +56,39 @@ struct Member {
     name: String,
     website: Intern<Uri>,
     authority: Intern<Authority>,
-    #[allow(dead_code)]
-    discord_id: String,
+    discord_id: Option<Snowflake>,
     check_level: CheckLevel,
     check_successful: Arc<AtomicBool>,
 }
 
 impl Member {
-    fn check_and_store(
+    fn check_and_store_and_optionally_notify(
         &self,
         base_address: Intern<Uri>,
-    ) -> impl Future<Output = eyre::Result<()>> + Send + 'static {
+        notifier: Option<Arc<DiscordNotifier>>,
+    ) -> impl Future<Output = Option<CheckFailure>> + Send + 'static {
         let website = self.website;
         let check_level = self.check_level;
         let successful = Arc::clone(&self.check_successful);
 
+        let discord_id_for_block = self.discord_id;
+        let base_address_for_block = base_address;
         async move {
-            if let Some(v) = check(&website, check_level, base_address).await {
-                successful.store(v, Ordering::Relaxed);
+            let check_result = check(&website, check_level, base_address_for_block).await;
+            if let Some(failure) = &check_result {
+                successful.store(false, Ordering::Relaxed);
+                if let (Some(notifier), Some(user_id)) = (notifier, discord_id_for_block) {
+                    let message = format!("<@{}> {}", user_id, failure.to_message());
+                    tokio::spawn(async move {
+                        if let Err(err) = notifier.send_message(Some(user_id), &message).await {
+                            log::error!("Error sending Discord notification: {err}");
+                        }
+                    });
+                }
+            } else {
+                successful.store(true, Ordering::Relaxed);
             }
-
-            Ok(())
+            check_result
         }
     }
 }
@@ -81,43 +112,46 @@ pub struct Webring {
     file_watcher: OnceLock<RecommendedWatcher>,
     base_address: Intern<Uri>,
     base_authority: Intern<Authority>,
+    notifier: Option<Arc<DiscordNotifier>>,
     stats: Arc<Stats>,
 }
 
 impl Webring {
     /// Create a webring by parsing the file at the given path.
+    ///
+    /// The members' `check_successful` fields will be initialized to `true` until a check is
+    /// performed using [`check_members()`][Self::check_members].
     pub async fn new(
         members_file: PathBuf,
         static_dir: PathBuf,
         base_address: Intern<Uri>,
+        notifier: Option<DiscordNotifier>,
     ) -> eyre::Result<Webring> {
         let stats = Stats::new();
-        let webring_data = parse_file(&members_file).await;
+        let webring_data = parse_file(&members_file).await?;
 
-        let webring = Webring {
-            inner: RwLock::new(webring_data?),
+        Ok(Webring {
+            inner: RwLock::new(webring_data),
             members_file_path: members_file,
             static_dir_path: static_dir,
             homepage: tokio::sync::RwLock::new(None),
             stats: Arc::new(stats),
             file_watcher: OnceLock::default(),
             base_address,
+            notifier: notifier.map(Arc::new),
             base_authority: Intern::from_ref(base_address.authority().ok_or_eyre(
                 "The base address does not include an authority component (is relative)",
             )?),
-        };
-
-        webring.check_members().await?;
-
-        Ok(webring)
+        })
     }
 
-    /// Update the webring in-place by re-parsing the file given in the original `new` call, and invalidating the cache of the SSR'ed homepage.
+    /// Update the webring in-place by re-parsing the file given in the original `new` call, and
+    /// invalidating the cache of the SSR'ed homepage. All new URLs are checked.
     ///
     /// Useful for updating the webring without restarting the server.
-    pub async fn update_from_file(&self) -> eyre::Result<()> {
+    pub async fn update_from_file_and_check(&self) -> eyre::Result<()> {
         let mut new_members = parse_file(&self.members_file_path).await?;
-        let tasks = FuturesUnordered::new();
+        let mut tasks = FuturesUnordered::new();
 
         {
             let old_members = self.inner.read().unwrap();
@@ -130,38 +164,45 @@ impl Webring {
                         new_members.ordering[*idx].check_successful = check_successful;
                     }
                     None => {
-                        tasks.push(new_members.ordering[*idx].check_and_store(self.base_address));
+                        tasks.push(
+                            new_members.ordering[*idx].check_and_store_and_optionally_notify(
+                                self.base_address,
+                                self.notifier.as_ref().map(Arc::clone),
+                            ),
+                        );
                     }
                 }
             }
         }
 
-        let ret = collect_errs(tasks).await;
+        // Wait for all tasks
+        while tasks.next().await.is_some() {}
 
         *self.inner.write().unwrap() = new_members;
-
         *self.homepage.write().await = None;
-
-        ret
+        Ok(())
     }
 
     /// Query everyone's webpages and check them according to their respective check levels.
-    pub async fn check_members(&self) -> eyre::Result<()> {
-        let tasks = FuturesUnordered::new();
-
-        {
-            let inner = self.inner.read().unwrap();
-
-            for member in &inner.ordering {
-                tasks.push(member.check_and_store(self.base_address));
-            }
-        }
-
-        let ret = collect_errs(tasks).await;
+    pub async fn check_members(&self) {
+        let mut tasks = self
+            .inner
+            .read()
+            .unwrap()
+            .ordering
+            .iter()
+            .map(|member| {
+                member.check_and_store_and_optionally_notify(
+                    self.base_address,
+                    self.notifier.as_ref().map(Arc::clone),
+                )
+            })
+            .collect::<FuturesUnordered<_>>();
+
+        // Wait for all tasks
+        while tasks.next().await.is_some() {}
 
         *self.homepage.write().await = None;
-
-        ret
     }
 
     fn get_authority(uri: &Uri) -> Result<Intern<Authority>, TraverseWebringError> {
@@ -384,7 +425,9 @@ impl Webring {
                                 );
                                 let webring_for_task = Arc::clone(&webring);
                                 rt.spawn(async move {
-                                    if let Err(err) = webring_for_task.update_from_file().await {
+                                    if let Err(err) =
+                                        webring_for_task.update_from_file_and_check().await
+                                    {
                                         error!("Failed to update webring: {err}");
                                     }
                                     info!("Webring reloaded");
@@ -443,26 +486,6 @@ impl Webring {
     }
 }
 
-async fn collect_errs(
-    tasks: FuturesUnordered<impl Future<Output = Result<(), eyre::Error>> + Send>,
-) -> Result<(), eyre::Error> {
-    let errs = tasks
-        .filter_map(async |res| res.err())
-        .collect::<Vec<_>>()
-        .await;
-
-    if errs.is_empty() {
-        Ok(())
-    } else {
-        Err(eyre!(
-            errs.into_iter()
-                .map(|v| v.to_string())
-                .collect::<Vec<_>>()
-                .join("\n\n")
-        ))
-    }
-}
-
 async fn parse_file(path: &Path) -> eyre::Result<WebringData> {
     let file = tokio::fs::read_to_string(&path).await?;
 
@@ -485,17 +508,7 @@ async fn parse_file(path: &Path) -> eyre::Result<WebringData> {
             ));
         }
 
-        #[allow(clippy::match_on_vec_items)]
-        let check_level = match &*split[3].to_lowercase() {
-            "for links" => CheckLevel::ForLinks,
-            "just online" => CheckLevel::JustOnline,
-            "none" => CheckLevel::None,
-            _ => {
-                return Err(eyre!(
-                    "Expected the check level to be one of {{\"for links\", \"just online\", \"none\"}}. Got:\n\n{line}"
-                ));
-            }
-        };
+        let check_level = CheckLevel::from_str(split[3])?;
 
         let uri = split[1].parse::<Uri>()?;
 
@@ -508,9 +521,12 @@ async fn parse_file(path: &Path) -> eyre::Result<WebringData> {
             name: split[0].to_owned(),
             website: Intern::new(uri),
             authority,
-            discord_id: split[2].to_owned(),
+            discord_id: match split[2] {
+                "-" => None,
+                id => Some(id.parse().wrap_err("Discord ID is not valid")?),
+            },
             check_level,
-            check_successful: Arc::new(AtomicBool::new(false)),
+            check_successful: Arc::new(AtomicBool::new(true)),
         };
 
         members
@@ -547,6 +563,7 @@ mod tests {
 
     use axum::http::{Uri, uri::Authority};
     use chrono::Utc;
+    use pretty_assertions::assert_eq;
     use sarlacc::Intern;
     use tempfile::{NamedTempFile, TempDir};
 
@@ -568,6 +585,7 @@ mod tests {
                 file_watcher: OnceLock::new(),
                 base_address: Intern::default(),
                 base_authority: Intern::new("ring.purduehackers.com".parse().unwrap()),
+                notifier: None,
                 stats: Arc::default(),
             }
         }
@@ -618,7 +636,7 @@ mod tests {
 henry — hrovnyak.gitlab.io — 123 — None
 kian — kasad.com — 456 — NonE
 cynthia — https://clementine.viridian.page — 789 — nONE
-??? — ws://refuse-the-r.ing — bruh — none
+??? — ws://refuse-the-r.ing — - — none
 ",
         )
         .await
@@ -629,6 +647,7 @@ cynthia — https://clementine.viridian.page — 789 — nONE
             members_file.path().to_owned(),
             static_dir.path().to_owned(),
             Intern::new(Uri::from_static("https://ring.purduehackers.com")),
+            None,
         )
         .await
         .unwrap();
@@ -649,7 +668,7 @@ cynthia — https://clementine.viridian.page — 789 — nONE
                     name: "henry".to_owned(),
                     website: Intern::new(Uri::from_static("hrovnyak.gitlab.io")),
                     authority: Intern::new("hrovnyak.gitlab.io".parse().unwrap()),
-                    discord_id: "123".to_owned(),
+                    discord_id: Some("123".parse().unwrap()),
                     check_level: CheckLevel::None,
                     check_successful: Arc::new(AtomicBool::new(true)),
                 },
@@ -657,7 +676,7 @@ cynthia — https://clementine.viridian.page — 789 — nONE
                     name: "kian".to_owned(),
                     website: Intern::new(Uri::from_static("kasad.com")),
                     authority: Intern::new("kasad.com".parse().unwrap()),
-                    discord_id: "456".to_owned(),
+                    discord_id: Some("456".parse().unwrap()),
                     check_level: CheckLevel::None,
                     check_successful: Arc::new(AtomicBool::new(true)),
                 },
@@ -665,7 +684,7 @@ cynthia — https://clementine.viridian.page — 789 — nONE
                     name: "cynthia".to_owned(),
                     website: Intern::new(Uri::from_static("https://clementine.viridian.page")),
                     authority: Intern::new("clementine.viridian.page".parse().unwrap()),
-                    discord_id: "789".to_owned(),
+                    discord_id: Some("789".parse().unwrap()),
                     check_level: CheckLevel::None,
                     check_successful: Arc::new(AtomicBool::new(true)),
                 },
@@ -673,7 +692,7 @@ cynthia — https://clementine.viridian.page — 789 — nONE
                     name: "???".to_owned(),
                     website: Intern::new(Uri::from_static("ws://refuse-the-r.ing")),
                     authority: Intern::new("refuse-the-r.ing".parse().unwrap()),
-                    discord_id: "bruh".to_owned(),
+                    discord_id: None,
                     check_level: CheckLevel::None,
                     check_successful: Arc::new(AtomicBool::new(true)),
                 },
@@ -798,15 +817,15 @@ cynthia — https://clementine.viridian.page — 789 — nONE
             "
 cynthia — https://clementine.viridian.page — 789 — nONE
 henry — hrovnyak.gitlab.io — 123 — None
-??? — http://refuse-the-r.ing — bruh — none
-arhan — arhan.sh — qter — none
+??? — http://refuse-the-r.ing — 293847 — none
+arhan — arhan.sh — 0 — none
 kian — kasad.com — 456 — NonE
 ",
         )
         .await
         .unwrap();
 
-        webring.update_from_file().await.unwrap();
+        webring.update_from_file_and_check().await.unwrap();
 
         webring.assert_next("clementine.viridian.page", Ok("http://refuse-the-r.ing"));
         webring.assert_next("hrovnyak.gitlab.io", Ok("http://refuse-the-r.ing"));
@@ -869,6 +888,7 @@ kian — kasad.com — 456 — NonE
             members_file.path().to_owned(),
             static_dir.path().to_owned(),
             Intern::new(Uri::from_static("https://ring.purduehackers.com")),
+            None,
         )
         .await
         .unwrap();
@@ -908,6 +928,7 @@ cynthia — https://clementine.viridian.page — 789 — nONE
             members_file.path().to_owned(),
             static_dir.path().to_owned(),
             Intern::new(Uri::from_static("https://ring.purduehackers.com")),
+            None,
         )
         .await
         .unwrap();
@@ -966,6 +987,7 @@ cynthia — https://clementine.viridian.page — 789 — nONE";
                 members_file.clone(),
                 static_dir,
                 Intern::new(Uri::from_static("https://ring.purduehackers.com")),
+                None,
             )
             .await
             .unwrap(),


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

Adds Discord notifications for failed site checks by extending the checking flow, exposing a webhook option in the CLI, and wiring a `DiscordNotifier` through the `Webring` struct.

- Introduce `DiscordNotifier` and `Snowflake` types in a new `discord.rs` module with rate-limit retry logic.
- Refactor `check` to return a `CheckFailure` enum, update `Member` and `Webring` to optionally send messages on failure.
- Expose `--discord-webhook-url` CLI flag, pass notifier into `Webring::new`, and update CI to run ignored integration tests.

### Reviewed Changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

<details>
<summary>Show a summary per file</summary>

| File                 | Description                                                        |
| -------------------- | ------------------------------------------------------------------ |
| src/webring.rs       | Refactored `Member` and `Webring` to accept an optional notifier and renamed `check_and_store` to handle notifications when checks fail. |
| src/main.rs          | Added `--discord-webhook-url` flag, created `DiscordNotifier`, and passed it into `Webring::new`. |
| src/discord.rs       | New module implementing `DiscordNotifier` with retry on rate limits and a `Snowflake` ID type. |
| src/checking.rs      | Changed `check` to return `Option<CheckFailure>` with detailed failures and updated documentation. |
| Cargo.toml           | Enabled `reqwest`’s `json` feature and added `serde_json`.         |
| .github/workflows/ci.yml | Configured secrets for Discord tests and added a job to run ignored tests.    |
</details>



<details>
<summary>Comments suppressed due to low confidence (3)</summary>

**src/discord.rs:6**
* Remove or replace the placeholder text `Note lakjdf` with a meaningful note or remove the footnote if it's not needed.
```
[^webhook]: Note lakjdf
```
**src/webring.rs:43**
* [nitpick] This function name is very long; consider renaming to something shorter like `check_and_notify` to improve readability and maintainability.
```
fn check_and_store_and_optionally_notify(
```
**src/webring.rs:544**
* Add a test case that provides a `DiscordNotifier` to `Webring::new` and verifies that a failed site check triggers an actual notification.
```
let webring = Webring::new(
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T07:12:35Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR adds Discord webhook notifications for site check failures and refactors the check workflow to handle optional notifier integration.

- Introduces `DiscordNotifier` to send messages when checks fail  
- Changes `Member` to store an optional Discord ID and updates check methods to trigger notifications  
- Refactors `check` to return `CheckFailure` details and simplifies task collection

### Reviewed Changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

<details>
<summary>Show a summary per file</summary>

| File                   | Description                                                    |
| ---------------------- | -------------------------------------------------------------- |
| src/webring.rs         | Store `Option<Snowflake>` IDs, wire up `DiscordNotifier`, rename and simplify check methods |
| src/main.rs            | Add `--discord-webhook-url` flag and schedule periodic checks |
| src/discord.rs         | New `DiscordNotifier` implementation with rate‐limit handling  |
| src/checking.rs        | Refactor `check` to return `CheckFailure`, unify link scanning |
| Cargo.toml             | Add `serde_json` and `reqwest` JSON feature, adjust lints     |
| .github/workflows/ci.yml | Expose webhook env vars and run ignored tests               |
</details>



<details>
<summary>Comments suppressed due to low confidence (3)</summary>

**src/webring.rs:60**
* [nitpick] The method name `check_and_store_and_optionally_notify` is very long. Consider renaming to something shorter like `check_and_notify` to improve readability.
```
fn check_and_store_and_optionally_notify(
```
**src/checking.rs:55**
* Use the `ONLINE_CHECK_TTL_MS` constant instead of the literal `1000` to keep the TTL value consistent and avoid magic numbers.
```
if now < ping_info.0 + 1000 {
```
**src/webring.rs:140**
* [nitpick] The method name `update_from_file_and_check` is quite verbose; consider renaming to `reload_and_check` or similar for clarity and brevity.
```
pub async fn update_from_file_and_check(&self) -> eyre::Result<()> {
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T05:17:49Z
user: login: Xendergo
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-23T14:03:53Z
user: login: Xendergo
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-23T14:12:37Z
user: login: kdkasad
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-23T14:33:10Z
user: login: Xendergo
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-23T14:48:42Z

Review comments:
diff_hunk: @@ -56,19 +56,18 @@ async fn is_online() -> bool {
     ping_info.1
 }
 
-/// Checks whether a given URL passes the given check level and sends of discord messages if not.
+/// Checks whether a given URL passes the given check level.
 ///
-/// Returns Some with the result, or None if a connectivity issue on the server side is detected.
-#[allow(clippy::unused_async)]
-pub async fn check(website: &Uri, check_level: CheckLevel) -> Option<bool> {
+/// Returns `Some` with the failure details if the site
path: src/checking.rs
user: login: Copilot
body: Complete this doc comment to explain precisely when `Some(CheckFailure)` is returned (on check failures) and when `None` is returned (on success or connectivity issues).
```suggestion
/// Returns:
/// - `Some(CheckFailure)` with the failure details if the site fails the specified check level.
/// - `None` if the site passes the check or if there are server-side connectivity issues.
```
created_at: 2025-05-20T07:12:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -33,20 +34,34 @@ struct Member {
     name: String,
     website: Arc<Uri>,
     #[allow(dead_code)] // FIXME: Remove once Discord integration is implemented
-    discord_id: String,
+    discord_id: Snowflake,
     check_level: CheckLevel,
     check_successful: Arc<AtomicBool>,
 }
 
 impl Member {
-    fn check_and_store(&self) -> impl Future<Output = eyre::Result<()>> + Send + 'static {
+    fn check_and_store_and_optionally_notify(
+        &self,
+        notifier: Option<Arc<DiscordNotifier>>,
+    ) -> impl Future<Output = eyre::Result<()>> + Send + 'static {
         let website = Arc::clone(&self.website);
         let check_level = self.check_level;
         let successful = Arc::clone(&self.check_successful);
 
+        let discord_id_for_block = self.discord_id;
         async move {
-            if let Some(v) = check(&website, check_level).await {
-                successful.store(v, Ordering::Relaxed);
+            if let Some(failure) = check(&website, check_level).await {
+                successful.store(false, Ordering::Relaxed);
+                if let Some(notifier) = notifier {
+                    tokio::spawn(async move {
+                        notifier.send_message(
+                            Some(discord_id_for_block),
+                            &format!("Your site ({website}) failed validation for the following reason:\n{failure}"),
+                        ).await.unwrap();
path: src/webring.rs
user: login: Copilot
body: Avoid using `.unwrap()` inside a spawned task, which can panic silently; consider logging or handling the error instead of panicking.
```suggestion
                        ).await {
                            error!("Failed to send notification for site {website}: {err}");
                        }
```
created_at: 2025-05-20T07:12:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -155,6 +167,16 @@ async fn main() -> ExitCode {
         }
     };
 
+    // Perform site checks every 5 minutes
+    {
+        const SITE_CHECK_INTERVAL: Duration = Duration::from_secs(60 * 5);
+        let webring_for_task = Arc::clone(&webring);
+        tokio::spawn(async move {
+            webring_for_task.check_members().await;
+            tokio::time::sleep(SITE_CHECK_INTERVAL).await;
path: src/main.rs
user: login: Copilot
body: This spawn only executes one check and then sleeps once. To perform checks repeatedly at the interval, wrap the `check_members` and `sleep` calls inside a loop (e.g., `loop { check; sleep; }`).
```suggestion
            loop {
                webring_for_task.check_members().await;
                tokio::time::sleep(SITE_CHECK_INTERVAL).await;
            }
```
created_at: 2025-05-23T05:17:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -13,6 +13,8 @@ edition = "2024"
 # Enable clippy's pedantic group
 [lints.clippy]
 pedantic = { level = "warn", priority = -1 }
+# Slice indexing can always panic. Why only warn in match blocks?
+match-on-vec-items = "allow"
path: Cargo.toml
user: login: Xendergo
body: Is there a reason why you're setting lint options in the `Cargo.toml` instead of at the top of main? If it's just stylistic then it's fine, but I don't think I've seen it before
created_at: 2025-05-23T14:12:37Z
author_association: COLLABORATOR
diff_hunk: @@ -13,6 +13,8 @@ edition = "2024"
 # Enable clippy's pedantic group
 [lints.clippy]
 pedantic = { level = "warn", priority = -1 }
+# Slice indexing can always panic. Why only warn in match blocks?
+match-on-vec-items = "allow"
path: Cargo.toml
user: login: kdkasad
body: Doing it here makes it apply for things in `examples/` and `tests/` as well. We don't have anything there, but once we add integration tests, we will.
created_at: 2025-05-23T14:33:10Z
author_association: COLLABORATOR
diff_hunk: @@ -13,6 +13,8 @@ edition = "2024"
 # Enable clippy's pedantic group
 [lints.clippy]
 pedantic = { level = "warn", priority = -1 }
+# Slice indexing can always panic. Why only warn in match blocks?
+match-on-vec-items = "allow"
path: Cargo.toml
user: login: Xendergo
body: Oh ok, I didn't know that. That makes sense.
created_at: 2025-05-23T14:48:42Z
author_association: COLLABORATOR

PR comments:
user: login: Xendergo
created_at: 2025-05-21T20:16:10Z
body: If you're interested, a nice feature might be [implementing a logger](https://github.com/tjardoo/ftail?tab=readme-ov-file#custom-channel) that sends discord messages for all 'warn' and 'error' level logs. ftail was a good choice of logger since it would let you easily slot in the new logger along with all of the other ones.

You could even implement notifying users of failing checks by just logging an error level message. If you do that, you should look into [structured data in logs](https://docs.rs/log/latest/log/kv/index.html) because it might be helpful.
author_association: COLLABORATOR
user: login: kdkasad
created_at: 2025-05-22T16:38:50Z
body: For some reason, `checking::tests::test_check_failure_types` passes locally but not in CI. It looks like the connection to `127.0.0.10:0` is succeeding on the workflow runner, even though it shouldn't.

I'll try to figure that out, but once that's done, this should be good to go.
author_association: COLLABORATOR
user: login: kdkasad
created_at: 2025-05-23T05:14:21Z
body: > For some reason, `checking::tests::test_check_failure_types` passes locally but not in CI. It looks like the connection to `127.0.0.10:0` is succeeding on the workflow runner, even though it shouldn't.
> 
> I'll try to figure that out, but once that's done, this should be good to go.

@Xendergo Check this out: it fails because... 🥁🥁🥁... pinging isn't supported in GH Actions runners!

```
---- checking::tests::check_failure_types stdout ----
Checking http://127.0.0.10:0/connection at level None
2025-05-23 04:59:39 TRACE hyper_util::client::legacy::pool checkout waiting for idle connection: ("http", 127.0.0.10:0)
2025-05-23 04:59:39 DEBUG reqwest::connect starting new connection: http://127.0.0.10:0/
2025-05-23 04:59:39 TRACE hyper_util::client::legacy::connect::http Http::connect; scheme=Some("http"), host=Some("127.0.0.10"), port=Some(Port(0))
2025-05-23 04:59:39 DEBUG hyper_util::client::legacy::connect::http connecting to 127.0.0.10:0
2025-05-23 04:59:39 TRACE mio::poll registering event source with poller: token=Token(140401879094656), interests=READABLE | WRITABLE
2025-05-23 04:59:39 TRACE mio::poll registering event source with poller: token=Token(140401878187136), interests=READABLE | WRITABLE
2025-05-23 04:59:39 TRACE mio::poll deregistering event source from poller
2025-05-23 04:59:39 TRACE hyper_util::client::legacy::connect::http connect error for 127.0.0.10:0: ConnectError("tcp connect error", Os { code: 111, kind: ConnectionRefused, message: "Connection refused" })
2025-05-23 04:59:39 TRACE hyper_util::client::legacy::pool checkout dropped for ("http", 127.0.0.10:0)
2025-05-23 04:59:39 TRACE ph_webring::checking Request failed: error sending request for url (http://127.0.0.10:0/connection)
2025-05-23 04:59:39 TRACE ph_webring::checking is_online()
2025-05-23 04:59:39 TRACE ph_webring::checking now - last ping = 1747976379.888 s
2025-05-23 04:59:39 TRACE ph_webring::checking ping is not recent
2025-05-23 04:59:39 DEBUG surge_ping::client error opening SOCK_DGRAM type socket, trying SOCK_RAW: Os { code: 13, kind: PermissionDenied, message: "Permission denied" }
2025-05-23 04:59:39 TRACE ph_webring::checking tried new ping. success is false
2025-05-23 04:59:39 ERROR ph_webring::checking Server-side connectivity issue detected: Could not reach http://127.0.0.10:0/connection: error sending request for url (http://127.0.0.10:0/connection)
```
(Note third line from bottom)
author_association: COLLABORATOR