mirror of
https://github.com/dsec-hub/dsec-discord-bot.git
synced 2026-09-22 15:53:56 +00:00
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
This commit is contained in:
parent
fb867a2efa
commit
b960d84bfc
7 changed files with 84 additions and 52 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
|
||||||
|
|
|
||||||
|
|
@ -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,12 +59,7 @@ 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;
|
let send_log = logs_channel_id.send_message(ctx, builder).await;
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,7 @@ 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> {
|
async fn get_weather(location: String, weather_api_key: &str) -> Result<String, Error> {
|
||||||
let weather_api_key = std::env::var("WEATHER_TOKEN").expect("missing WEATHER_TOKEN");
|
|
||||||
|
|
||||||
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,
|
||||||
|
|
@ -23,7 +21,7 @@ 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?;
|
let weather_response = get_weather(location, &ctx.data().state.weather_token).await?;
|
||||||
let value: Value = serde_json::from_str(&weather_response)?;
|
let value: Value = serde_json::from_str(&weather_response)?;
|
||||||
|
|
||||||
let location_name = value["location"]["name"].as_str().unwrap();
|
let location_name = value["location"]["name"].as_str().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
@ -204,7 +193,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
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,13 @@
|
||||||
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> {
|
async fn honeypot(
|
||||||
dotenv().ok();
|
ctx: &serenity::Context,
|
||||||
// check if it's the honeypot channel
|
new_message: &serenity::Message,
|
||||||
let discord_guild_id = std::env::var("GUILD_ID")
|
data: &Data,
|
||||||
.expect("missing GUILD_ID")
|
) -> Result<(), Error> {
|
||||||
.parse::<u64>()
|
// Config is parsed once at boot and stored on AppState (COL-BOT-05).
|
||||||
.expect("Invalid GUILD_ID value");
|
let honeypot_channel_id = data.state.honeypot_channel_id;
|
||||||
|
|
||||||
let honeypot_channel_id_env = std::env::var("HONEYPOT_CHANNEL_ID")
|
|
||||||
.expect("missing HONEYPOT_CHANNEL_ID")
|
|
||||||
.parse::<u64>()
|
|
||||||
.expect("Invalid HONEYPOT_CHANNEL_ID value");
|
|
||||||
let honeypot_channel_id = ChannelId::new(honeypot_channel_id_env);
|
|
||||||
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) {
|
||||||
|
|
@ -23,13 +15,16 @@ async fn honeypot(ctx: &serenity::Context, new_message: &serenity::Message) -> R
|
||||||
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() {
|
if ban_user.is_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)),
|
||||||
|
|
@ -49,15 +44,9 @@ async fn honeypot(ctx: &serenity::Context, new_message: &serenity::Message) -> R
|
||||||
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;
|
||||||
|
|
||||||
|
|
@ -93,8 +82,9 @@ 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?;
|
honeypot(ctx, new_message, data).await?;
|
||||||
create_leetcode_thread(ctx, new_message).await?;
|
create_leetcode_thread(ctx, new_message, data).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
51
src/main.rs
51
src/main.rs
|
|
@ -19,6 +19,29 @@ 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, but a missing value must not stop the bot from booting.
|
||||||
|
pub weather_token: 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 +50,26 @@ 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, /weather just fails.
|
||||||
|
let weather_token = std::env::var("WEATHER_TOKEN").unwrap_or_default();
|
||||||
|
|
||||||
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 +95,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,7 +119,7 @@ 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?;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue