mirror of
https://github.com/dsec-hub/dsec-discord-bot.git
synced 2026-09-22 07:44:26 +00:00
Phase 1: bot fixes (BOT-02/03/04/05, COR-03, UXA11Y-11, OPS-04) + Codex hardening (#7)
* COL-BOT-03: make /member_info expiry reply ephemeral and drop dead query
Restructure the membership lookup to `let Some(user_data) = ... else { ... }`
so the "Couldn't find info" branch now sends `.ephemeral(true)` like the other
two replies; whether a member's membership has lapsed is no longer announced to
the whole channel. Delete the commented-out `student_data` query that duplicated
member_data(). No embed fields or wording changed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE
* COL-BOT-05: parse all config once in AppState, remove per-event env reads
Finishes the AppState config hoisting (COR-12's groundwork was not present in
the repo, so this ticket establishes it): AppState now carries guild_id,
honeypot_channel_id, leetcode_channel_id, verified_role_id, logs_channel_id and
weather_token, all parsed once in AppState::new(). A missing or unparseable id
now produces one boot error naming every offending variable plus a pointer to
.env.example, instead of a per-event .expect() panic (a data race under the
multi-threaded runtime, since dotenv() calls the now-unsafe set_var).
- main.rs: new fields, required_u64() collector, fail-fast with combined message.
- events/message.rs: honeypot/create_leetcode_thread/on_message take &Data and
read channel/guild ids from AppState; dropped dotenv() and env::var.
- events/interaction_create.rs: deleted verified_role_id(); use
data.state.verified_role_id.
- commands/mods_only.rs: log_embed takes logs_channel_id: ChannelId; caller in
message.rs passes data.state.logs_channel_id.
- commands/weather.rs: WEATHER_TOKEN read once into AppState (non-fatal), passed
to get_weather — required so the new CI grep guard can be clean.
- .env.example: add LEETCODE_CHANNEL_ID.
- .github/workflows/ci.yml: add "No environment reads outside main.rs" guard.
grep -rn 'dotenv()\|env::var' src/ | grep -v '^src/main.rs:' now returns nothing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE
* COL-BOT-02: guard the honeypot, log failed bans, stop log_embed panicking
The honeypot banned whoever posted, unconditionally. Add guards so it never
bans a bot, a webhook, the bot itself, or anyone who can moderate
(ban_members / manage_messages / administrator, computed from the cached guild).
A failed ban now logs the user and error instead of being silent.
Remove the .expect("LOG FAIL") in log_embed: it runs inside a message handler,
so a transient Discord failure was a panic; it now eprintln!s and returns.
Decouple on_message so honeypot and the LeetCode thread each run and log
independently — a failure in one no longer skips the other, and neither
propagates out of the event handler.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE
* COL-BOT-04: clamp LeetCode thread names and add the repo's first tests
create_title_from_message dropped the two-character numbering prefix but a
first line of two chars or fewer left an empty name, and a line over 100 chars
exceeded Discord's limit — both produced a silent 400. Move the function to
module level (&str), clamp to 100 characters (on a char boundary, not bytes),
and fall back to "LeetCode discussion" when nothing is left. A failed
create_thread now logs instead of propagating. Add six unit tests covering
normal input, empty, a two-char line, a >100-char line, a multibyte first
character, and a multi-line message — the first tests in this repo.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE
* COR-03: fix verification failing on a member's first attempt
The insert into dsec_discord_members deserialised the response into
Vec<DiscordMemberRow>, but without `.returning(...)` supabase-lib-rs never sends
`Prefer: return=representation`, so PostgREST returns a 201 with an empty body.
Parsing that empty body failed and the `?` aborted before add_role and before
any reply — so every member's first click died with "This interaction failed",
while the row was still written (which is why the second attempt worked). Add
`.returning("student_id,discord_id")` so the row comes back and parses.
Also wrap the post-modal verification work so any failure sends the user an
ephemeral "something went wrong" instead of a dead interaction (poise's on_error
has no handle to the modal submission), and set an explicit on_error on
FrameworkOptions that replies ephemerally on command errors and keeps the
default logging. The database-write-first ordering is left unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE
* NEW-UXA11Y-11: normalise names and student IDs before comparing them
Verification compared names with exact lowercased equality and never trimmed, so
a trailing space, a double space, or a missing middle name turned a real paid
member away — and a pasted "s123456789 " was looked up with the trailing space
and missed. Add normalise_name (trim + collapse whitespace), normalise_student_id
(strip whitespace and a leading "s"), and name_matches (equal, or every typed
word present in the roster name in order), and route the database path, the cache
path (cached_name_matches / cache_student) and the student-id lookup through them.
"Doe John" still does not match "John Doe" and an empty name matches nothing.
Update the modal placeholder to "As it appears on your DUSA membership". Four unit
tests added. The two failure embeds are left byte-identical (SEC-19 owns those).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE
* OPS-04: restart on crash, real logging, and a deploy that fails when the bot does
- docker-compose.yml: `restart: unless-stopped` so a panic, bad frame or VPS
reboot brings the bot back instead of leaving it dead. Comment explaining why
no healthcheck (slim runtime, no HTTP port).
- Cargo.toml: enable tracing-subscriber's env-filter feature (declared but never
initialised until now).
- main.rs: initialise tracing as the first statement in main(), defaulting to
`info` — serenity/poise/supabase logs now surface, without leaking student IDs
or the service email that the Supabase client emits at `debug`.
- deploy.yml: after `up -d`, assert the container is actually Running 30s later
and dump its logs and fail if not — `up -d` returns 0 on container creation,
not on a working process.
- Dockerfile: pin the builder to rust:1-bookworm to match the bookworm-slim
runtime, removing the trixie/bookworm glibc mismatch.
- README: document the info-level default and the RUST_LOG=debug PII footgun.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE
* NEW-UXA11Y-11: require first AND last name to match (close a verification bypass)
Codex review: name_matches accepted any non-empty ordered subset, so a single
common token ("John" or "Doe") matched "John Michael Doe" — a student id plus one
name token could claim the verified role for someone else. Now both the first and
last tokens must match, middle tokens the student typed must appear in the roster
in order (omitted middles still fine), and a single token, an arbitrary subset,
or a wrong first/last name is rejected. Add tests: single-token input, wrong
surname and wrong first name do NOT verify; full name and first+last with the
middle omitted do.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE
* OPS-04: make the deploy assertion catch a crash-looping bot
Codex review: with `restart: unless-stopped` a bot that panics at boot is
restarted, so a single `docker inspect` still reads Running while it crash-loops
— the deploy went green while broken. The assertion now waits for the bot to
reach READY (it logs "Logged in as ..." from the Ready handler) within a
stability window AND requires RestartCount == 0, breaking early and failing the
moment a restart is observed, dumping container logs on any failure.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE
* COL-BOT-05: stop /weather panicking on missing token or error payloads
Codex review: a missing/blank WEATHER_TOKEN and invalid locations produced an API
error body that then hit as_str().unwrap() calls. Store the token as
Option<String> (None when missing or blank) and disable /weather with a friendly
ephemeral reply instead of running with an empty key. get_weather now returns the
HTTP status with the parsed body; a non-2xx surfaces the API's error message, and
every success field is read via JSON pointers with no unwrap() on external JSON.
The thumbnail is only set when an icon URL is actually present.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE
* COL-BOT-02: make the honeypot moderator guard fail safe
Codex review: the guard fell THROUGH to banning when the member lookup or the
permission calculation failed, and it ignored per-channel permission overwrites.
Now it computes effective permissions in the honeypot channel (honouring
overwrites via Guild::user_permissions_in) and ABSTAINS from banning whenever the
guild, the member, or the channel cannot be resolved — fail-safe: never ban when
identity or permissions cannot be established. Replaces the deprecated
Member::permissions call, so the #[allow(deprecated)] is gone.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
345f4ec513
commit
5918ceb1c8
15 changed files with 673 additions and 203 deletions
|
|
@ -5,6 +5,7 @@ VERIFIED_ROLE_ID=""
|
||||||
GUILD_ID=""
|
GUILD_ID=""
|
||||||
LOGS_CHANNEL_ID=""
|
LOGS_CHANNEL_ID=""
|
||||||
HONEYPOT_CHANNEL_ID=""
|
HONEYPOT_CHANNEL_ID=""
|
||||||
|
LEETCODE_CHANNEL_ID=""
|
||||||
|
|
||||||
SUPABASE_URL=""
|
SUPABASE_URL=""
|
||||||
SUPABASE_KEY=""
|
SUPABASE_KEY=""
|
||||||
|
|
|
||||||
11
.github/workflows/ci.yml
vendored
11
.github/workflows/ci.yml
vendored
|
|
@ -80,3 +80,14 @@ jobs:
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: cargo test
|
run: cargo test
|
||||||
|
|
||||||
|
# COL-BOT-05: configuration is parsed once in main.rs and stored on
|
||||||
|
# AppState. A per-event dotenv()/env::var read is a data race under the
|
||||||
|
# multi-threaded runtime (set_var is unsafe in edition 2024) and turns a
|
||||||
|
# config typo into a random future handler failure instead of a boot error.
|
||||||
|
- name: No environment reads outside main.rs
|
||||||
|
run: |
|
||||||
|
if grep -rn 'dotenv()\|env::var' src/ | grep -v '^src/main.rs:'; then
|
||||||
|
echo "::error::Environment variables must be read once in main.rs and stored on AppState."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
|
||||||
45
.github/workflows/deploy.yml
vendored
45
.github/workflows/deploy.yml
vendored
|
|
@ -42,7 +42,50 @@ jobs:
|
||||||
working-directory: ${{ github.workspace }}
|
working-directory: ${{ github.workspace }}
|
||||||
run: |
|
run: |
|
||||||
sudo docker compose up -d --build --force-recreate
|
sudo docker compose up -d --build --force-recreate
|
||||||
echo "Deployment complete."
|
echo "Container created."
|
||||||
|
|
||||||
|
# `docker compose up -d` exits 0 once the container is created, not once the
|
||||||
|
# program inside is working. And with `restart: unless-stopped` a container
|
||||||
|
# that panics at boot is restarted, so a crash loop still reads Running at any
|
||||||
|
# single instant — "Running" alone is not proof of a healthy deploy. Assert
|
||||||
|
# the bot actually reached READY (it logs "Logged in as ..." from the Ready
|
||||||
|
# handler) within a stability window AND has not restarted, and dump its logs
|
||||||
|
# and fail otherwise (OPS-04).
|
||||||
|
- name: Verify the bot came up cleanly
|
||||||
|
working-directory: ${{ github.workspace }}
|
||||||
|
run: |
|
||||||
|
id=$(sudo docker compose ps -q dsec_bot)
|
||||||
|
if [ -z "$id" ]; then
|
||||||
|
echo "::error::dsec_bot container was not created"
|
||||||
|
sudo docker compose logs --tail=200 dsec_bot || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ready=""
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
sleep 2
|
||||||
|
running=$(sudo docker inspect -f '{{.State.Running}}' "$id" 2>/dev/null || echo "false")
|
||||||
|
restarts=$(sudo docker inspect -f '{{.RestartCount}}' "$id" 2>/dev/null || echo "0")
|
||||||
|
# A non-zero restart count means it has already crashed at least once:
|
||||||
|
# stop waiting and fail rather than let a later restart look healthy.
|
||||||
|
if [ "$restarts" != "0" ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$running" = "true" ] \
|
||||||
|
&& sudo docker compose logs dsec_bot 2>&1 | grep -q "Logged in as"; then
|
||||||
|
ready="yes"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
running=$(sudo docker inspect -f '{{.State.Running}}' "$id" 2>/dev/null || echo "false")
|
||||||
|
restarts=$(sudo docker inspect -f '{{.RestartCount}}' "$id" 2>/dev/null || echo "0")
|
||||||
|
if [ "$ready" != "yes" ] || [ "$running" != "true" ] || [ "$restarts" != "0" ]; then
|
||||||
|
echo "::error::dsec_bot did not come up cleanly (running=$running restarts=$restarts ready=${ready:-no})"
|
||||||
|
sudo docker compose logs --tail=200 dsec_bot
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Deployment complete: dsec_bot reached READY with no restarts."
|
||||||
|
|
||||||
- name: Remove environment file
|
- name: Remove environment file
|
||||||
if: always()
|
if: always()
|
||||||
|
|
|
||||||
13
Cargo.lock
generated
13
Cargo.lock
generated
|
|
@ -1604,6 +1604,15 @@ dependencies = [
|
||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matchers"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||||
|
dependencies = [
|
||||||
|
"regex-automata",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "maybe-rayon"
|
name = "maybe-rayon"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
|
|
@ -3533,10 +3542,14 @@ version = "0.3.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
|
checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"matchers",
|
||||||
"nu-ansi-term",
|
"nu-ansi-term",
|
||||||
|
"once_cell",
|
||||||
|
"regex-automata",
|
||||||
"sharded-slab",
|
"sharded-slab",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"thread_local",
|
"thread_local",
|
||||||
|
"tracing",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
"tracing-log",
|
"tracing-log",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,6 @@ reqwest = "0.12.24"
|
||||||
serde_json = "1.0.145"
|
serde_json = "1.0.145"
|
||||||
serenity = "0.12"
|
serenity = "0.12"
|
||||||
tokio = { version = "1.21.2", features = ["macros", "rt-multi-thread"] }
|
tokio = { version = "1.21.2", features = ["macros", "rt-multi-thread"] }
|
||||||
tracing-subscriber = "0.3.20"
|
tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }
|
||||||
supabase-lib-rs = "0.5.3"
|
supabase-lib-rs = "0.5.3"
|
||||||
serde = "1.0.228"
|
serde = "1.0.228"
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
FROM rust:trixie AS builder
|
# Pin the builder to the same Debian release as the runtime (bookworm). A binary
|
||||||
|
# built on a newer release can die at exec on the older one the day a dependency
|
||||||
|
# reaches for a newer glibc/OpenSSL symbol — with no log and, before OPS-04, no
|
||||||
|
# restart. Keep this in lockstep with the runtime FROM below.
|
||||||
|
FROM rust:1-bookworm AS builder
|
||||||
|
|
||||||
# Install build dependencies
|
# Install build dependencies
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,13 @@ docker-compose build
|
||||||
docker-compose up
|
docker-compose up
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
The bot logs at `info` by default. Do **not** set `RUST_LOG` to `debug` or `trace`
|
||||||
|
on the VPS or in the `DOT_ENV` secret: at `debug` the Supabase client logs the
|
||||||
|
generated query URLs (which contain **student IDs**) and the service-account
|
||||||
|
email. Adjust the level with `RUST_LOG` locally only (e.g. `RUST_LOG=warn`).
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
### General Rules
|
### General Rules
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
services:
|
services:
|
||||||
dsec_bot:
|
dsec_bot:
|
||||||
build: .
|
build: .
|
||||||
|
# Bring the bot back on process exit — a panic, a bad Discord frame, or a VPS
|
||||||
|
# reboot — instead of leaving it dead until someone notices (OPS-04). No
|
||||||
|
# `healthcheck:` block: the runtime image is debian:bookworm-slim with only
|
||||||
|
# ca-certificates (no ps/curl) and the bot serves no HTTP port, so there is
|
||||||
|
# nothing to probe; `restart: unless-stopped` acts on process exit, which is
|
||||||
|
# exactly the failure mode here. Do not add one.
|
||||||
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
|
@ -66,7 +66,20 @@ pub async fn member_info(ctx: ApplicationContext<'_>) -> Result<(), Error> {
|
||||||
|
|
||||||
let user_data_option = member_data(&database, &student_id).await?;
|
let user_data_option = member_data(&database, &student_id).await?;
|
||||||
|
|
||||||
if let Some(user_data) = user_data_option {
|
let Some(user_data) = user_data_option else {
|
||||||
|
ctx.send(
|
||||||
|
CreateReply::default()
|
||||||
|
.embed(CreateEmbed::new().title("Couldn't find info").description(
|
||||||
|
"Your membership may have expired. Contact a club executive to be sure.",
|
||||||
|
))
|
||||||
|
// Ephemeral like the other two replies: whether someone's
|
||||||
|
// membership has lapsed is their business, not the channel's.
|
||||||
|
.ephemeral(true),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
let member_name = user_data.full_name;
|
let member_name = user_data.full_name;
|
||||||
let member_campus = user_data.campus;
|
let member_campus = user_data.campus;
|
||||||
let membership_status = user_data.membership_status;
|
let membership_status = user_data.membership_status;
|
||||||
|
|
@ -89,23 +102,6 @@ pub async fn member_info(ctx: ApplicationContext<'_>) -> Result<(), Error> {
|
||||||
.ephemeral(true),
|
.ephemeral(true),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
} else {
|
|
||||||
ctx.send(CreateReply::default().embed(
|
|
||||||
CreateEmbed::new().title("Couldn't find info").description(
|
|
||||||
"Your membership may have expired. Contact a club executive to be sure.",
|
|
||||||
),
|
|
||||||
))
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// let student_data = state
|
|
||||||
// .supabase
|
|
||||||
// .database()
|
|
||||||
// .from("active_members")
|
|
||||||
// .select("full_name, student_id")
|
|
||||||
// .eq("student_id", &student_id)
|
|
||||||
// .execute()
|
|
||||||
// .await?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
use crate::{Context, Error};
|
use crate::{Context, Error};
|
||||||
use dotenv::dotenv;
|
|
||||||
use poise::{CreateReply, serenity_prelude as serenity};
|
use poise::{CreateReply, serenity_prelude as serenity};
|
||||||
|
|
||||||
/// Send message to logs channel
|
/// Send message to logs channel
|
||||||
|
|
@ -8,6 +7,7 @@ use poise::{CreateReply, serenity_prelude as serenity};
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn log_embed(
|
pub async fn log_embed(
|
||||||
ctx: &serenity::Context,
|
ctx: &serenity::Context,
|
||||||
|
logs_channel_id: serenity::ChannelId,
|
||||||
title: Option<String>,
|
title: Option<String>,
|
||||||
title_url: Option<String>,
|
title_url: Option<String>,
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
|
|
@ -17,7 +17,6 @@ pub async fn log_embed(
|
||||||
image_url: Option<String>,
|
image_url: Option<String>,
|
||||||
timestamp: Option<bool>,
|
timestamp: Option<bool>,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
dotenv().ok();
|
|
||||||
let mut embed = serenity::CreateEmbed::new();
|
let mut embed = serenity::CreateEmbed::new();
|
||||||
|
|
||||||
// Set title and title URL
|
// Set title and title URL
|
||||||
|
|
@ -60,16 +59,14 @@ pub async fn log_embed(
|
||||||
embed = embed.timestamp(serenity::Timestamp::now());
|
embed = embed.timestamp(serenity::Timestamp::now());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send the embed
|
// Send the embed (logs_channel_id is parsed once at boot; see AppState).
|
||||||
let logs_channel_id_env = std::env::var("LOGS_CHANNEL_ID")
|
|
||||||
.expect("missing LOGS_CHANNEL_ID")
|
|
||||||
.parse::<u64>()
|
|
||||||
.expect("Invalid LOGS_CHANNEL_ID value");
|
|
||||||
let logs_channel_id = serenity::ChannelId::new(logs_channel_id_env);
|
|
||||||
let builder = serenity::CreateMessage::new().embed(embed);
|
let builder = serenity::CreateMessage::new().embed(embed);
|
||||||
|
|
||||||
let send_log = logs_channel_id.send_message(ctx, builder).await;
|
// Do NOT .expect() here: this runs inside a message handler, and a panic on a
|
||||||
send_log.expect("LOG FAIL");
|
// transient Discord failure takes the whole handler down for that message.
|
||||||
|
if let Err(err) = logs_channel_id.send_message(ctx, builder).await {
|
||||||
|
eprintln!("[log_embed] failed to write to logs channel: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ pub struct StudentRow {
|
||||||
#[name = "Club Verification"]
|
#[name = "Club Verification"]
|
||||||
pub struct VerificationModal {
|
pub struct VerificationModal {
|
||||||
#[name = "Full Name"]
|
#[name = "Full Name"]
|
||||||
#[placeholder = "John Doe"]
|
#[placeholder = "As it appears on your DUSA membership"]
|
||||||
#[max_length = 50]
|
#[max_length = 50]
|
||||||
pub name: String,
|
pub name: String,
|
||||||
#[name = "Student ID"]
|
#[name = "Student ID"]
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,25 @@ use crate::{Context, Error};
|
||||||
use poise::CreateReply;
|
use poise::CreateReply;
|
||||||
use serenity::{all::CreateEmbed, json::Value};
|
use serenity::{all::CreateEmbed, json::Value};
|
||||||
|
|
||||||
async fn get_weather(location: String) -> Result<String, Error> {
|
/// Fetch the raw weather response for a location. Returns the HTTP status
|
||||||
let weather_api_key = std::env::var("WEATHER_TOKEN").expect("missing WEATHER_TOKEN");
|
/// alongside the parsed JSON body so the caller can tell a success payload from
|
||||||
|
/// an API error payload (e.g. an unknown location) without unwrapping anything
|
||||||
|
/// on external JSON. A body that is not valid JSON parses to `Value::Null`.
|
||||||
|
async fn get_weather(
|
||||||
|
location: &str,
|
||||||
|
weather_api_key: &str,
|
||||||
|
) -> Result<(reqwest::StatusCode, Value), Error> {
|
||||||
let request_url = format!(
|
let request_url = format!(
|
||||||
"https://api.weatherapi.com/v1/current.json?key={key}&q={location}",
|
"https://api.weatherapi.com/v1/current.json?key={key}&q={location}",
|
||||||
key = weather_api_key,
|
key = weather_api_key,
|
||||||
location = location
|
location = location
|
||||||
);
|
);
|
||||||
|
|
||||||
// retrieve weather data
|
let response = reqwest::get(request_url).await?;
|
||||||
let response = reqwest::get(request_url).await?.text().await?;
|
let status = response.status();
|
||||||
|
let body = response.text().await?;
|
||||||
Ok(response)
|
let value: Value = serde_json::from_str(&body).unwrap_or(Value::Null);
|
||||||
|
Ok((status, value))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shows weather information
|
/// Shows weather information
|
||||||
|
|
@ -23,33 +29,83 @@ pub async fn weather(
|
||||||
ctx: Context<'_>,
|
ctx: Context<'_>,
|
||||||
#[description = "Location (City or Country)"] location: String,
|
#[description = "Location (City or Country)"] location: String,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
let weather_response = get_weather(location).await?;
|
// Weather is optional. Without a configured token the command is disabled
|
||||||
let value: Value = serde_json::from_str(&weather_response)?;
|
// rather than panicking on a missing key.
|
||||||
|
let Some(weather_api_key) = ctx.data().state.weather_token.as_deref() else {
|
||||||
|
ctx.send(
|
||||||
|
CreateReply::default()
|
||||||
|
.content("The weather command is not configured on this bot.")
|
||||||
|
.ephemeral(true),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
let location_name = value["location"]["name"].as_str().unwrap();
|
let (status, value) = get_weather(&location, weather_api_key).await?;
|
||||||
let location_region = value["location"]["region"].as_str().unwrap();
|
|
||||||
let location_country = value["location"]["country"].as_str().unwrap();
|
|
||||||
|
|
||||||
let weather_condition = value["current"]["condition"]["text"].as_str().unwrap();
|
// weatherapi.com returns a JSON error body (unknown location, bad key, quota)
|
||||||
let weather_temp = &value["current"]["temp_c"];
|
// with a non-2xx status. Surface a friendly message instead of unwrapping
|
||||||
let weather_feels_like = &value["current"]["feelslike_c"];
|
// fields that are not present in an error payload.
|
||||||
let weather_wind_kph = &value["current"]["wind_kph"];
|
if !status.is_success() {
|
||||||
let weather_humidity = &value["current"]["humidity"];
|
let message = value
|
||||||
let weather_cloud = &value["current"]["cloud"];
|
.pointer("/error/message")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("Could not fetch the weather for that location.");
|
||||||
|
ctx.send(
|
||||||
|
CreateReply::default()
|
||||||
|
.content(format!("Weather lookup failed: {message}"))
|
||||||
|
.ephemeral(true),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
let weather_icon = value["current"]["condition"]["icon"].as_str().unwrap();
|
// Read every field defensively — external JSON is never unwrapped.
|
||||||
|
let text = |ptr: &str| -> String {
|
||||||
|
value
|
||||||
|
.pointer(ptr)
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("Unknown")
|
||||||
|
.to_string()
|
||||||
|
};
|
||||||
|
let number = |ptr: &str| -> String {
|
||||||
|
match value.pointer(ptr) {
|
||||||
|
Some(v) if !v.is_null() => v.to_string(),
|
||||||
|
_ => "?".to_string(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let embed = CreateEmbed::new()
|
let mut embed = CreateEmbed::new()
|
||||||
.field("Name", location_name.to_string(), true)
|
.field("Name", text("/location/name"), true)
|
||||||
.field("Region", location_region.to_string(), true)
|
.field("Region", text("/location/region"), true)
|
||||||
.field("Country", location_country.to_string(), true)
|
.field("Country", text("/location/country"), true)
|
||||||
.field("Condition", weather_condition.to_string(), true)
|
.field("Condition", text("/current/condition/text"), true)
|
||||||
.field("Temperature", format!("{} °C", weather_temp), true)
|
.field(
|
||||||
.field("Feels like", format!("{} °C", weather_feels_like), true)
|
"Temperature",
|
||||||
.field("Wind", format!("{} kph", weather_wind_kph), true)
|
format!("{} °C", number("/current/temp_c")),
|
||||||
.field("Humidity", format!("{}%", weather_humidity), true)
|
true,
|
||||||
.field("Cloud", format!("{}%", weather_cloud), true)
|
)
|
||||||
.thumbnail(format!("https:{}", weather_icon));
|
.field(
|
||||||
|
"Feels like",
|
||||||
|
format!("{} °C", number("/current/feelslike_c")),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.field("Wind", format!("{} kph", number("/current/wind_kph")), true)
|
||||||
|
.field(
|
||||||
|
"Humidity",
|
||||||
|
format!("{}%", number("/current/humidity")),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.field("Cloud", format!("{}%", number("/current/cloud")), true);
|
||||||
|
|
||||||
|
// Only set the thumbnail when the API actually returned an icon URL — a
|
||||||
|
// placeholder would produce an invalid "https:Unknown" URL that Discord rejects.
|
||||||
|
if let Some(icon) = value
|
||||||
|
.pointer("/current/condition/icon")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
{
|
||||||
|
embed = embed.thumbnail(format!("https:{icon}"));
|
||||||
|
}
|
||||||
|
|
||||||
ctx.send(CreateReply::default().embed(embed)).await?;
|
ctx.send(CreateReply::default().embed(embed)).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ use ::serenity::{
|
||||||
},
|
},
|
||||||
model::guild::Member,
|
model::guild::Member,
|
||||||
};
|
};
|
||||||
use dotenv::dotenv;
|
|
||||||
use poise::Modal as _;
|
use poise::Modal as _;
|
||||||
use poise::serenity_prelude as serenity;
|
use poise::serenity_prelude as serenity;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
@ -23,16 +22,6 @@ pub struct DiscordMemberRow {
|
||||||
pub discord_id: String,
|
pub discord_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read the verified role id from the environment.
|
|
||||||
fn verified_role_id() -> RoleId {
|
|
||||||
dotenv().ok();
|
|
||||||
let role_id_string = std::env::var("VERIFIED_ROLE_ID").expect("missing VERIFIED_ROLE_ID");
|
|
||||||
let role_id_u64: u64 = role_id_string
|
|
||||||
.parse()
|
|
||||||
.expect("Unable to parse VERIFIED_ROLE_ID into number");
|
|
||||||
RoleId::new(role_id_u64)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Wrap an embed into an ephemeral interaction response.
|
/// Wrap an embed into an ephemeral interaction response.
|
||||||
fn ephemeral_embed(embed: CreateEmbed) -> CreateInteractionResponse {
|
fn ephemeral_embed(embed: CreateEmbed) -> CreateInteractionResponse {
|
||||||
CreateInteractionResponse::Message(
|
CreateInteractionResponse::Message(
|
||||||
|
|
@ -97,12 +86,18 @@ async fn add_dsec_discord_table(
|
||||||
"discord_id": member_id,
|
"discord_id": member_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// `.returning(...)` makes supabase-lib-rs send `Prefer: return=representation`.
|
||||||
|
// Without it PostgREST defaults a POST to `return=minimal` — a 201 with an
|
||||||
|
// empty body — and deserialising that empty body into Vec<DiscordMemberRow>
|
||||||
|
// failed, aborting before the role grant even though the row was written.
|
||||||
|
// That is why verification failed on every member's first attempt (COR-03).
|
||||||
let _: Vec<DiscordMemberRow> = data
|
let _: Vec<DiscordMemberRow> = data
|
||||||
.state
|
.state
|
||||||
.supabase
|
.supabase
|
||||||
.database()
|
.database()
|
||||||
.insert("dsec_discord_members")
|
.insert("dsec_discord_members")
|
||||||
.values(new_member)?
|
.values(new_member)?
|
||||||
|
.returning("student_id,discord_id")
|
||||||
.execute()
|
.execute()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
@ -136,6 +131,68 @@ async fn grant_verified_role(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lower-case, trim, and collapse runs of internal whitespace to one space.
|
||||||
|
/// Used for every name comparison so a stray space or a double space in the
|
||||||
|
/// DUSA roster never rejects a real member.
|
||||||
|
fn normalise_name(raw: &str) -> String {
|
||||||
|
raw.to_lowercase()
|
||||||
|
.split_whitespace()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trim, lower-case, strip a leading "s", and drop spaces so a pasted
|
||||||
|
/// "s123 456 789 " looks up as "123456789".
|
||||||
|
fn normalise_student_id(raw: &str) -> String {
|
||||||
|
let lowered: String = raw
|
||||||
|
.chars()
|
||||||
|
.filter(|c| !c.is_whitespace())
|
||||||
|
.collect::<String>()
|
||||||
|
.to_lowercase();
|
||||||
|
lowered.strip_prefix('s').unwrap_or(&lowered).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the submitted name matches the roster name closely enough to be the
|
||||||
|
/// same person.
|
||||||
|
///
|
||||||
|
/// The first and last name tokens must BOTH match, and any tokens the student
|
||||||
|
/// typed in between must appear in the roster name in order — so an omitted
|
||||||
|
/// middle name is fine, but a single token, an arbitrary subset, a reordered
|
||||||
|
/// name, or a wrong surname is not. This is deliberately strict: verification is
|
||||||
|
/// already weak identity evidence (a name plus a student id), and a looser rule
|
||||||
|
/// would let a student id plus one common name token ("John", "Doe") claim the
|
||||||
|
/// verified role for someone else.
|
||||||
|
fn name_matches(roster: &str, submitted: &str) -> bool {
|
||||||
|
let roster = normalise_name(roster);
|
||||||
|
let submitted = normalise_name(submitted);
|
||||||
|
|
||||||
|
let roster_words: Vec<&str> = roster.split_whitespace().collect();
|
||||||
|
let submitted_words: Vec<&str> = submitted.split_whitespace().collect();
|
||||||
|
|
||||||
|
// A single token (or empty) is far too weak to identify a person, and a roster
|
||||||
|
// row without a distinct first and last name cannot be matched safely.
|
||||||
|
if submitted_words.len() < 2 || roster_words.len() < 2 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first and last name must both match.
|
||||||
|
if submitted_words.first() != roster_words.first()
|
||||||
|
|| submitted_words.last() != roster_words.last()
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every token the student typed must appear in the roster name in order.
|
||||||
|
let mut idx = 0usize;
|
||||||
|
for &word in &submitted_words {
|
||||||
|
match roster_words[idx..].iter().position(|&w| w == word) {
|
||||||
|
Some(offset) => idx += offset + 1,
|
||||||
|
None => return false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the cached name for `student_id` matches the submitted `name`.
|
/// Whether the cached name for `student_id` matches the submitted `name`.
|
||||||
fn cached_name_matches(data: &Data, student_id: &str, name: &str) -> bool {
|
fn cached_name_matches(data: &Data, student_id: &str, name: &str) -> bool {
|
||||||
let cache = data
|
let cache = data
|
||||||
|
|
@ -144,15 +201,15 @@ fn cached_name_matches(data: &Data, student_id: &str, name: &str) -> bool {
|
||||||
.lock()
|
.lock()
|
||||||
.expect("Failed to get cache");
|
.expect("Failed to get cache");
|
||||||
match cache.get(student_id) {
|
match cache.get(student_id) {
|
||||||
Some(cached_name) => cached_name == &name.to_lowercase(),
|
Some(cached_name) => name_matches(cached_name, name),
|
||||||
None => false,
|
None => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Store the resolved student name in the cache (lower-cased for comparison).
|
/// Store the resolved student name in the cache (normalised for comparison).
|
||||||
fn cache_student(data: &Data, student_id: &str, full_name: &str) {
|
fn cache_student(data: &Data, student_id: &str, full_name: &str) {
|
||||||
let mut cache = data.state.student_cache.lock().unwrap();
|
let mut cache = data.state.student_cache.lock().unwrap();
|
||||||
cache.insert(student_id.to_string(), full_name.to_lowercase());
|
cache.insert(student_id.to_string(), normalise_name(full_name));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Look up a student by id in the database.
|
/// Look up a student by id in the database.
|
||||||
|
|
@ -204,7 +261,7 @@ async fn handle_verify(
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
let verified_role_id = verified_role_id();
|
let verified_role_id = data.state.verified_role_id;
|
||||||
|
|
||||||
// Fast, no-network "already verified" check using the member data that is
|
// Fast, no-network "already verified" check using the member data that is
|
||||||
// already attached to the button interaction. Anything slower than this
|
// already attached to the button interaction. Anything slower than this
|
||||||
|
|
@ -231,23 +288,25 @@ async fn handle_verify(
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
// From here on we hold the modal-submit token, so the slower member fetch
|
// From here on we hold the modal-submit token, so the slower member fetch and
|
||||||
// and database work below is no longer racing the button's ack window.
|
// database work below is no longer racing the button's ack window. Wrap that
|
||||||
|
// work so any failure (e.g. a database error) still sends the user an
|
||||||
|
// ephemeral message rather than leaving a dead "This interaction failed"
|
||||||
|
// interaction — poise's on_error cannot reach this modal submission (COR-03).
|
||||||
let user_id = component_interaction.user.id;
|
let user_id = component_interaction.user.id;
|
||||||
|
|
||||||
|
let verify_result: Result<(), Error> = async {
|
||||||
let discord_member = GuildId::member(guild_id, ctx, user_id).await?;
|
let discord_member = GuildId::member(guild_id, ctx, user_id).await?;
|
||||||
|
|
||||||
let input_student_id = modal_data.student_id.to_lowercase();
|
let student_id = normalise_student_id(&modal_data.student_id);
|
||||||
let student_id = input_student_id
|
|
||||||
.strip_prefix("s")
|
|
||||||
.unwrap_or(&input_student_id);
|
|
||||||
|
|
||||||
if cached_name_matches(data, student_id, &modal_data.name) {
|
if cached_name_matches(data, &student_id, &modal_data.name) {
|
||||||
grant_verified_role(
|
grant_verified_role(
|
||||||
ctx,
|
ctx,
|
||||||
data,
|
data,
|
||||||
&modal_submit,
|
&modal_submit,
|
||||||
&discord_member,
|
&discord_member,
|
||||||
student_id,
|
&student_id,
|
||||||
verified_role_id,
|
verified_role_id,
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
|
|
@ -255,7 +314,7 @@ async fn handle_verify(
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(student) = fetch_student(data, student_id).await? else {
|
let Some(student) = fetch_student(data, &student_id).await? else {
|
||||||
modal_submit
|
modal_submit
|
||||||
.create_response(
|
.create_response(
|
||||||
ctx,
|
ctx,
|
||||||
|
|
@ -269,15 +328,15 @@ async fn handle_verify(
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
cache_student(data, student_id, &student.full_name);
|
cache_student(data, &student_id, &student.full_name);
|
||||||
|
|
||||||
if student.full_name.to_lowercase() == modal_data.name.to_lowercase() {
|
if name_matches(&student.full_name, &modal_data.name) {
|
||||||
grant_verified_role(
|
grant_verified_role(
|
||||||
ctx,
|
ctx,
|
||||||
data,
|
data,
|
||||||
&modal_submit,
|
&modal_submit,
|
||||||
&discord_member,
|
&discord_member,
|
||||||
student_id,
|
&student_id,
|
||||||
verified_role_id,
|
verified_role_id,
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
|
|
@ -293,6 +352,28 @@ async fn handle_verify(
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(err) = verify_result {
|
||||||
|
eprintln!("[verify] verification failed after modal submit: {err}");
|
||||||
|
// Best-effort ephemeral error so the user does not see the generic
|
||||||
|
// "This interaction failed" with no way forward.
|
||||||
|
let _ = modal_submit
|
||||||
|
.create_response(
|
||||||
|
ctx,
|
||||||
|
ephemeral_embed(
|
||||||
|
CreateEmbed::new()
|
||||||
|
.title("Something went wrong")
|
||||||
|
.description(
|
||||||
|
"A maintainer has been notified. Please try again in a minute.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -311,3 +392,51 @@ pub async fn on_interaction_create(
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trims_and_collapses_names() {
|
||||||
|
assert!(name_matches("John Doe", " john doe "));
|
||||||
|
assert!(name_matches("John Doe", "JOHN DOE"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verifies_full_name_and_omitted_middle() {
|
||||||
|
assert!(name_matches("John Michael Doe", "John Michael Doe"));
|
||||||
|
assert!(name_matches("John Michael Doe", "John Doe"));
|
||||||
|
assert!(name_matches("John Michael Doe", "john michael doe"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_a_single_token() {
|
||||||
|
// A student id plus one common name token must never verify.
|
||||||
|
assert!(!name_matches("John Michael Doe", "John"));
|
||||||
|
assert!(!name_matches("John Michael Doe", "Doe"));
|
||||||
|
assert!(!name_matches("John Michael Doe", "Michael"));
|
||||||
|
assert!(!name_matches("John Doe", "John"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_wrong_surname_or_first_name() {
|
||||||
|
assert!(!name_matches("John Michael Doe", "John Smith"));
|
||||||
|
assert!(!name_matches("John Michael Doe", "Jane Doe"));
|
||||||
|
assert!(!name_matches("John Doe", "Jack Doe"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_a_different_person() {
|
||||||
|
assert!(!name_matches("John Michael Doe", "Jane Doe"));
|
||||||
|
assert!(!name_matches("John Doe", "Doe John"));
|
||||||
|
assert!(!name_matches("John Doe", ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalises_student_ids() {
|
||||||
|
assert_eq!(normalise_student_id("s123456789 "), "123456789");
|
||||||
|
assert_eq!(normalise_student_id("S123456789"), "123456789");
|
||||||
|
assert_eq!(normalise_student_id(" 123 456 789 "), "123456789");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,85 @@
|
||||||
use crate::{Error, commands::mods_only::log_embed};
|
use crate::{Data, Error, commands::mods_only::log_embed};
|
||||||
use ::serenity::model::id::{ChannelId, GuildId};
|
|
||||||
use dotenv::dotenv;
|
|
||||||
use poise::serenity_prelude as serenity;
|
use poise::serenity_prelude as serenity;
|
||||||
|
|
||||||
async fn honeypot(ctx: &serenity::Context, new_message: &serenity::Message) -> Result<(), Error> {
|
/// Effective permissions for `member` in `channel_id`, honouring per-channel
|
||||||
dotenv().ok();
|
/// permission overwrites. Returns `None` when the guild or the channel is not in
|
||||||
// check if it's the honeypot channel
|
/// cache — the caller must treat that as "permissions could not be established"
|
||||||
let discord_guild_id = std::env::var("GUILD_ID")
|
/// and fail safe (do not ban), never as "no permissions".
|
||||||
.expect("missing GUILD_ID")
|
fn channel_permissions(
|
||||||
.parse::<u64>()
|
ctx: &serenity::Context,
|
||||||
.expect("Invalid GUILD_ID value");
|
guild_id: serenity::GuildId,
|
||||||
|
channel_id: serenity::ChannelId,
|
||||||
|
member: &serenity::Member,
|
||||||
|
) -> Option<serenity::Permissions> {
|
||||||
|
let guild = ctx.cache.guild(guild_id)?;
|
||||||
|
let channel = guild.channels.get(&channel_id)?;
|
||||||
|
Some(guild.user_permissions_in(channel, member))
|
||||||
|
}
|
||||||
|
|
||||||
let honeypot_channel_id_env = std::env::var("HONEYPOT_CHANNEL_ID")
|
async fn honeypot(
|
||||||
.expect("missing HONEYPOT_CHANNEL_ID")
|
ctx: &serenity::Context,
|
||||||
.parse::<u64>()
|
new_message: &serenity::Message,
|
||||||
.expect("Invalid HONEYPOT_CHANNEL_ID value");
|
data: &Data,
|
||||||
let honeypot_channel_id = ChannelId::new(honeypot_channel_id_env);
|
) -> Result<(), Error> {
|
||||||
|
// Config is parsed once at boot and stored on AppState (COL-BOT-05).
|
||||||
|
let honeypot_channel_id = data.state.honeypot_channel_id;
|
||||||
let current_channel_id = &new_message.channel_id;
|
let current_channel_id = &new_message.channel_id;
|
||||||
|
|
||||||
if honeypot_channel_id.eq(current_channel_id) {
|
if honeypot_channel_id.eq(current_channel_id) {
|
||||||
let user = &new_message.author;
|
let user = &new_message.author;
|
||||||
|
|
||||||
|
// Never ban a bot, a webhook, or ourselves. The honeypot exists to catch
|
||||||
|
// spam accounts; banning another club integration (or the bot itself)
|
||||||
|
// because it posted in the wrong channel is a self-inflicted outage.
|
||||||
|
if user.bot || new_message.webhook_id.is_some() || user.id == ctx.cache.current_user().id {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never ban someone who can moderate, and fail SAFE: whenever the author's
|
||||||
|
// identity or permissions cannot be established, abstain rather than ban.
|
||||||
|
// A honeypot that occasionally misses a spammer is far cheaper than one
|
||||||
|
// that bans a moderator or a legitimate member.
|
||||||
|
let Some(message_guild_id) = new_message.guild_id else {
|
||||||
|
eprintln!("[honeypot] message has no guild_id; abstaining from ban");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let member = match message_guild_id.member(ctx, user.id).await {
|
||||||
|
Ok(member) => member,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!(
|
||||||
|
"[honeypot] could not fetch member {} to check permissions; abstaining: {err}",
|
||||||
|
user.id
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Effective permissions IN THE HONEYPOT CHANNEL (honouring overwrites).
|
||||||
|
let Some(perms) = channel_permissions(ctx, message_guild_id, *current_channel_id, &member)
|
||||||
|
else {
|
||||||
|
eprintln!(
|
||||||
|
"[honeypot] could not compute permissions for {} in {}; abstaining",
|
||||||
|
user.id, current_channel_id
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if perms.ban_members() || perms.manage_messages() || perms.administrator() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
let username = &user.name;
|
let username = &user.name;
|
||||||
let user_id = &user.id;
|
let user_id = &user.id;
|
||||||
let avatar_url = user.avatar_url();
|
let avatar_url = user.avatar_url();
|
||||||
let ban_user = GuildId::new(discord_guild_id)
|
let ban_user = data
|
||||||
|
.state
|
||||||
|
.guild_id
|
||||||
.ban_with_reason(ctx, user_id, 2, "Message sent in honeypot channel.")
|
.ban_with_reason(ctx, user_id, 2, "Message sent in honeypot channel.")
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
if ban_user.is_ok() {
|
match ban_user {
|
||||||
|
Ok(()) => {
|
||||||
log_embed(
|
log_embed(
|
||||||
ctx,
|
ctx,
|
||||||
|
data.state.logs_channel_id,
|
||||||
Some("Honeypot activated!".to_string()),
|
Some("Honeypot activated!".to_string()),
|
||||||
None,
|
None,
|
||||||
Some(format!("User got banned: {}", username)),
|
Some(format!("User got banned: {}", username)),
|
||||||
|
|
@ -41,51 +91,68 @@ async fn honeypot(ctx: &serenity::Context, new_message: &serenity::Message) -> R
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
// A honeypot that cannot ban is information the mods need.
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!(
|
||||||
|
"[honeypot] failed to ban {username} ({user_id}) in the honeypot channel: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Discord rejects a thread name that is empty or over 100 characters, and the
|
||||||
|
/// resulting 400 is invisible to the person who posted (no logging is set up —
|
||||||
|
/// see OPS-04). So clamp here rather than letting the API reject it.
|
||||||
|
const MAX_THREAD_NAME_CHARS: usize = 100;
|
||||||
|
const DEFAULT_THREAD_NAME: &str = "LeetCode discussion";
|
||||||
|
|
||||||
|
/// Derive a thread name from a LeetCode post.
|
||||||
|
///
|
||||||
|
/// Posts are conventionally numbered ("1. Two Sum"), so the leading two
|
||||||
|
/// characters are dropped. A first line of two characters or fewer leaves
|
||||||
|
/// nothing behind, and a very long one exceeds Discord's limit — both are
|
||||||
|
/// handled here rather than at the API.
|
||||||
|
fn create_title_from_message(message: &str) -> String {
|
||||||
|
let first = message.lines().next().unwrap_or("");
|
||||||
|
let start = first
|
||||||
|
.char_indices()
|
||||||
|
.nth(2)
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.unwrap_or(first.len());
|
||||||
|
let trimmed = first[start..].trim();
|
||||||
|
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return DEFAULT_THREAD_NAME.to_string();
|
||||||
|
}
|
||||||
|
// Truncate on a CHARACTER boundary — byte slicing would panic mid-emoji.
|
||||||
|
trimmed.chars().take(MAX_THREAD_NAME_CHARS).collect()
|
||||||
|
}
|
||||||
|
|
||||||
async fn create_leetcode_thread(
|
async fn create_leetcode_thread(
|
||||||
ctx: &serenity::Context,
|
ctx: &serenity::Context,
|
||||||
new_message: &serenity::Message,
|
new_message: &serenity::Message,
|
||||||
|
data: &Data,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
dotenv().ok();
|
let leetcode_channel_id = data.state.leetcode_channel_id;
|
||||||
|
|
||||||
let leetcode_channel_id_env = std::env::var("LEETCODE_CHANNEL_ID")
|
|
||||||
.expect("missing LEETCODE_CHANNEL_ID")
|
|
||||||
.parse::<u64>()
|
|
||||||
.expect("Invalid LEETCODE_CHANNEL_ID value");
|
|
||||||
|
|
||||||
let leetcode_channel_id = ChannelId::new(leetcode_channel_id_env);
|
|
||||||
|
|
||||||
let current_channel_id = &new_message.channel_id;
|
let current_channel_id = &new_message.channel_id;
|
||||||
|
|
||||||
if leetcode_channel_id.eq(current_channel_id) {
|
if leetcode_channel_id.eq(current_channel_id) {
|
||||||
fn create_title_from_message(message: impl Into<String>) -> String {
|
|
||||||
let message_string: String = message.into();
|
|
||||||
|
|
||||||
message_string
|
|
||||||
.lines()
|
|
||||||
.next()
|
|
||||||
.map(|line| {
|
|
||||||
let start = line
|
|
||||||
.char_indices()
|
|
||||||
.nth(2)
|
|
||||||
.map(|(i, _)| i)
|
|
||||||
.unwrap_or(line.len());
|
|
||||||
&line[start..]
|
|
||||||
})
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
let new_thread =
|
let new_thread =
|
||||||
serenity::CreateThread::new(create_title_from_message(&new_message.content));
|
serenity::CreateThread::new(create_title_from_message(&new_message.content));
|
||||||
current_channel_id
|
if let Err(err) = current_channel_id
|
||||||
.create_thread_from_message(ctx, new_message.id, new_thread)
|
.create_thread_from_message(ctx, new_message.id, new_thread)
|
||||||
.await?;
|
.await
|
||||||
};
|
{
|
||||||
|
eprintln!(
|
||||||
|
"[leetcode] failed to create thread for message {}: {err}",
|
||||||
|
new_message.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -93,8 +160,61 @@ async fn create_leetcode_thread(
|
||||||
pub async fn on_message(
|
pub async fn on_message(
|
||||||
ctx: &serenity::Context,
|
ctx: &serenity::Context,
|
||||||
new_message: &serenity::Message,
|
new_message: &serenity::Message,
|
||||||
|
data: &Data,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
honeypot(ctx, new_message).await?;
|
// Independent features. A failure in one must not skip the other, and neither
|
||||||
create_leetcode_thread(ctx, new_message).await?;
|
// should propagate out of the event handler, where the default on_error only
|
||||||
|
// eprintln!s (OPS-04).
|
||||||
|
if let Err(err) = honeypot(ctx, new_message, data).await {
|
||||||
|
eprintln!("[on_message] honeypot failed: {err}");
|
||||||
|
}
|
||||||
|
if let Err(err) = create_leetcode_thread(ctx, new_message, data).await {
|
||||||
|
eprintln!("[on_message] leetcode thread failed: {err}");
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::create_title_from_message;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strips_the_numbering_prefix() {
|
||||||
|
assert_eq!(create_title_from_message("1. Two Sum"), "Two Sum");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_message_falls_back_to_the_default() {
|
||||||
|
assert_eq!(create_title_from_message(""), "LeetCode discussion");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_character_line_falls_back_to_the_default() {
|
||||||
|
// "hi" has nothing left after the two-character prefix is dropped,
|
||||||
|
// and Discord rejects an empty thread name with a 400.
|
||||||
|
assert_eq!(create_title_from_message("hi"), "LeetCode discussion");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn long_line_is_clamped_to_the_discord_limit() {
|
||||||
|
let long = format!("1. {}", "a".repeat(200));
|
||||||
|
let got = create_title_from_message(&long);
|
||||||
|
assert_eq!(got.chars().count(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multibyte_prefix_does_not_panic_and_truncates_on_a_char_boundary() {
|
||||||
|
// A leading emoji is two chars wide in some fonts but one char here;
|
||||||
|
// byte-slicing this would panic.
|
||||||
|
let got = create_title_from_message("🔥 Daily challenge: reverse a linked list");
|
||||||
|
assert!(!got.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_first_line_is_used() {
|
||||||
|
assert_eq!(
|
||||||
|
create_title_from_message("1. Two Sum\nsome body text"),
|
||||||
|
"Two Sum"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
88
src/main.rs
88
src/main.rs
|
|
@ -19,6 +19,30 @@ type ApplicationContext<'a> = poise::ApplicationContext<'a, Data, Error>;
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub supabase: Client,
|
pub supabase: Client,
|
||||||
pub student_cache: Mutex<HashMap<String, String>>,
|
pub student_cache: Mutex<HashMap<String, String>>,
|
||||||
|
// Parsed once at boot. Re-reading these per event means a config typo takes
|
||||||
|
// down a handler at some random future moment instead of failing the deploy.
|
||||||
|
pub guild_id: serenity::GuildId,
|
||||||
|
pub honeypot_channel_id: serenity::ChannelId,
|
||||||
|
pub leetcode_channel_id: serenity::ChannelId,
|
||||||
|
pub verified_role_id: serenity::RoleId,
|
||||||
|
pub logs_channel_id: serenity::ChannelId,
|
||||||
|
// Optional: only /weather uses it. Read once here so no handler re-reads the
|
||||||
|
// process environment; `None` (missing or blank) simply disables /weather
|
||||||
|
// rather than stopping the bot from booting.
|
||||||
|
pub weather_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a required `u64` snowflake from the environment, recording the variable
|
||||||
|
/// name in `missing` (rather than panicking on the first one) so every offending
|
||||||
|
/// variable can be reported together.
|
||||||
|
fn required_u64(name: &str, missing: &mut Vec<String>) -> u64 {
|
||||||
|
match std::env::var(name).ok().and_then(|v| v.parse::<u64>().ok()) {
|
||||||
|
Some(v) => v,
|
||||||
|
None => {
|
||||||
|
missing.push(name.to_string());
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
|
|
@ -27,6 +51,29 @@ impl AppState {
|
||||||
#[allow(clippy::result_large_err)]
|
#[allow(clippy::result_large_err)]
|
||||||
pub async fn new() -> supabase::Result<Self> {
|
pub async fn new() -> supabase::Result<Self> {
|
||||||
dotenv().ok();
|
dotenv().ok();
|
||||||
|
|
||||||
|
// Parse and validate every required id up front, collecting all failures
|
||||||
|
// into one actionable startup error instead of dying on the first one.
|
||||||
|
let mut missing: Vec<String> = Vec::new();
|
||||||
|
let guild_id = required_u64("GUILD_ID", &mut missing);
|
||||||
|
let honeypot_channel_id = required_u64("HONEYPOT_CHANNEL_ID", &mut missing);
|
||||||
|
let leetcode_channel_id = required_u64("LEETCODE_CHANNEL_ID", &mut missing);
|
||||||
|
let verified_role_id = required_u64("VERIFIED_ROLE_ID", &mut missing);
|
||||||
|
let logs_channel_id = required_u64("LOGS_CHANNEL_ID", &mut missing);
|
||||||
|
if !missing.is_empty() {
|
||||||
|
eprintln!(
|
||||||
|
"Missing or unparseable environment variables: {}.\nCopy .env.example to .env and fill them in.",
|
||||||
|
missing.join(", ")
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-fatal: the bot boots without a weather key; a missing or blank
|
||||||
|
// value becomes None and disables /weather rather than failing.
|
||||||
|
let weather_token = std::env::var("WEATHER_TOKEN")
|
||||||
|
.ok()
|
||||||
|
.filter(|t| !t.trim().is_empty());
|
||||||
|
|
||||||
let supabase_url = std::env::var("SUPABASE_URL").expect("missing SUPABASE_URL");
|
let supabase_url = std::env::var("SUPABASE_URL").expect("missing SUPABASE_URL");
|
||||||
let supabase_key = std::env::var("SUPABASE_KEY").expect("missing SUPABASE_KEY");
|
let supabase_key = std::env::var("SUPABASE_KEY").expect("missing SUPABASE_KEY");
|
||||||
let supabase_user_email =
|
let supabase_user_email =
|
||||||
|
|
@ -52,6 +99,12 @@ impl AppState {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
supabase: client,
|
supabase: client,
|
||||||
student_cache: Mutex::new(HashMap::new()),
|
student_cache: Mutex::new(HashMap::new()),
|
||||||
|
guild_id: serenity::GuildId::new(guild_id),
|
||||||
|
honeypot_channel_id: serenity::ChannelId::new(honeypot_channel_id),
|
||||||
|
leetcode_channel_id: serenity::ChannelId::new(leetcode_channel_id),
|
||||||
|
verified_role_id: serenity::RoleId::new(verified_role_id),
|
||||||
|
logs_channel_id: serenity::ChannelId::new(logs_channel_id),
|
||||||
|
weather_token,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -70,15 +123,47 @@ async fn event_handler(
|
||||||
events::interaction_create::on_interaction_create(ctx, interaction, data).await?;
|
events::interaction_create::on_interaction_create(ctx, interaction, data).await?;
|
||||||
}
|
}
|
||||||
serenity::FullEvent::Message { new_message } => {
|
serenity::FullEvent::Message { new_message } => {
|
||||||
events::message::on_message(ctx, new_message).await?;
|
events::message::on_message(ctx, new_message, data).await?;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Framework-level error handler. On a slash-command error it replies ephemerally
|
||||||
|
/// so the user is not left with a dead interaction, and it always runs poise's
|
||||||
|
/// default logging. `FrameworkOptions::default()` installs this default logger
|
||||||
|
/// too, but nothing surfaces it (no tracing subscriber, no log shipping — OPS-04),
|
||||||
|
/// so an explicit handler is set (COR-03).
|
||||||
|
async fn on_error(error: poise::FrameworkError<'_, Data, Error>) {
|
||||||
|
if let poise::FrameworkError::Command { ctx, .. } = &error {
|
||||||
|
let _ = ctx
|
||||||
|
.send(
|
||||||
|
poise::CreateReply::default()
|
||||||
|
.content(
|
||||||
|
"Something went wrong — a maintainer has been notified. Please try again in a minute.",
|
||||||
|
)
|
||||||
|
.ephemeral(true),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
if let Err(e) = poise::builtins::on_error(error).await {
|
||||||
|
eprintln!("[on_error] failed while handling a framework error: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
|
// Initialise logging first, before anything can log. Default to `info`:
|
||||||
|
// supabase-lib-rs logs generated query URLs (containing student IDs) and the
|
||||||
|
// service-account email at `debug`, so RUST_LOG must never be set to debug or
|
||||||
|
// trace on the VPS. See OPS-04 and the README.
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
dotenv().ok(); // load env
|
dotenv().ok(); // load env
|
||||||
|
|
||||||
let app_state = AppState::new()
|
let app_state = AppState::new()
|
||||||
|
|
@ -106,6 +191,7 @@ async fn main() {
|
||||||
event_handler: |ctx, event, framework, data| {
|
event_handler: |ctx, event, framework, data| {
|
||||||
Box::pin(event_handler(ctx, event, framework, data))
|
Box::pin(event_handler(ctx, event, framework, data))
|
||||||
},
|
},
|
||||||
|
on_error: |error| Box::pin(on_error(error)),
|
||||||
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue