Skip to content

Commit 3d2ecc7

Browse files
committed
awesome new feature
1 parent 4c1d538 commit 3d2ecc7

File tree

7 files changed

+322
-2
lines changed

7 files changed

+322
-2
lines changed

Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,11 @@ color-eyre = "0.6.3"
4141
vesper = "0.13.0"
4242
once_cell = { version = "1.19.0", features = ["parking_lot"] }
4343
serde_rusqlite = "0.39.0"
44-
rig-core = "0.13.0"
44+
rig = { package = "rig-core", version = "0.13.0" }
4545
thiserror = "2.0.12"
4646
r2d2_sqlite = "0.30.0"
4747
r2d2 = "0.8.10"
4848
wb_sqlite = "0.2.1"
4949
rusqlite = { version = "0.36.0", features = ["bundled"] }
5050
num-format = "0.4.4"
51+
fasteval = "0.2.4"

src/database.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,14 @@ pub struct Memory {
8181
#[sql(constraint = "NOT NULL")]
8282
pub key: String,
8383
}
84+
85+
#[derive(CreateTableSql, InsertSync, UpdateSync, Serialize, Deserialize, Debug, PartialEq, Clone)]
86+
pub struct MathQuestion {
87+
#[sql(constraint = "PRIMARY KEY AUTOINCREMENT")]
88+
#[sql(typ = "INTEGER")]
89+
pub id: i32,
90+
#[sql(constraint = "NOT NULL")]
91+
pub question: String,
92+
#[sql(constraint = "NOT NULL")]
93+
pub answer: f64,
94+
}

src/main.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ mod commands;
4141
mod config;
4242
mod database;
4343
mod event_handler;
44+
mod math_test;
4445
mod message_handler;
4546
mod structs;
4647
pub mod utils;
@@ -82,6 +83,7 @@ async fn main() -> color_eyre::Result<()> {
8283
}
8384

8485
rusqlite.execute(database::Memory::CREATE_TABLE_SQL, [])?;
86+
rusqlite.execute(database::MathQuestion::CREATE_TABLE_SQL, [])?;
8587

8688
let config = Arc::new(cfg);
8789

src/math_test.rs

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
use crate::database::MathQuestion;
2+
use color_eyre::Result;
3+
use rand::Rng;
4+
use rig::{completion::Prompt, providers, client::CompletionClient};
5+
use r2d2::Pool;
6+
use r2d2_sqlite::SqliteConnectionManager;
7+
use serde_rusqlite::from_rows;
8+
9+
const MATH_PROMPTS: [&str; 3] = [
10+
"Generate a simple mental math expression that can be solved in your head. Use basic operations like addition, subtraction, multiplication, or division with small numbers (prefer numbers under 20, maximum 100). Output ONLY the mathematical expression, nothing else. Examples: {ex1}, {ex2}, {ex3}, {ex4}",
11+
"Create an easy math problem with 2-4 numbers that someone can calculate mentally. Use addition, subtraction, multiplication, or simple division. Keep numbers small and friendly (single or double digits preferred). Output ONLY the expression. Examples: {ex1}, {ex2}, {ex3}, {ex4}",
12+
"Write a simple arithmetic calculation using small, friendly numbers that can be computed without a calculator. Stick to basic operations (+, -, *, /). Output ONLY the math expression. Examples: {ex1}, {ex2}, {ex3}, {ex4}"
13+
];
14+
15+
pub struct MathTest {
16+
pub question: String,
17+
pub answer: f64,
18+
}
19+
20+
impl MathTest {
21+
pub async fn generate(
22+
openai_api_key: &str,
23+
db: &Pool<SqliteConnectionManager>,
24+
rng: &mut impl Rng,
25+
) -> Result<Self> {
26+
let client = providers::openai::Client::new(openai_api_key);
27+
28+
// Retry loop to avoid recursion
29+
for attempt in 0..5 {
30+
// Generate random example questions to show the AI
31+
// Example 1: Addition
32+
let ex1_a = rng.gen_range(5..50);
33+
let ex1_b = rng.gen_range(5..50);
34+
let ex1 = format!("{} + {}", ex1_a, ex1_b);
35+
36+
// Example 2: Multiplication
37+
let ex2_a = rng.gen_range(3..15);
38+
let ex2_b = rng.gen_range(3..15);
39+
let ex2 = format!("{} * {}", ex2_a, ex2_b);
40+
41+
// Example 3: Division or Subtraction
42+
let ex3 = if rng.gen_bool(0.5) {
43+
let divisor = rng.gen_range(2..13);
44+
let result = rng.gen_range(5..20);
45+
format!("{} / {}", divisor * result, divisor)
46+
} else {
47+
let ex3_a = rng.gen_range(30..100);
48+
let ex3_b = rng.gen_range(5..30);
49+
format!("{} - {}", ex3_a, ex3_b)
50+
};
51+
52+
// Example 4: Multi-operation or simple operation
53+
let ex4 = if rng.gen_bool(0.3) {
54+
// Multi-operation
55+
let a = rng.gen_range(3..20);
56+
let b = rng.gen_range(3..20);
57+
let c = rng.gen_range(3..20);
58+
match rng.gen_range(0..3) {
59+
0 => format!("{} + {} + {}", a, b, c),
60+
1 => format!("{} - {} + {}", a + b + c, b, c),
61+
_ => format!("{} + {} - {}", a, b, c),
62+
}
63+
} else {
64+
// Simple division
65+
let divisor = rng.gen_range(2..11);
66+
let result = rng.gen_range(5..15);
67+
format!("{} / {}", divisor * result, divisor)
68+
};
69+
70+
// Select a random prompt template and fill in the example questions
71+
let prompt_template = MATH_PROMPTS[rng.gen_range(0..MATH_PROMPTS.len())];
72+
let prompt = prompt_template
73+
.replace("{ex1}", &ex1)
74+
.replace("{ex2}", &ex2)
75+
.replace("{ex3}", &ex3)
76+
.replace("{ex4}", &ex4);
77+
78+
// Generate question using AI
79+
let agent = client
80+
.agent("gpt-4o")
81+
.preamble(
82+
"You are a math expression generator for mental math challenges. \
83+
Generate simple arithmetic expressions that can be solved mentally. \
84+
Output ONLY the mathematical expression with numbers and operators, no explanations, no greetings, no additional text."
85+
)
86+
.temperature(0.9)
87+
.max_tokens(50)
88+
.build();
89+
90+
let question = match agent.prompt(&prompt).await {
91+
Ok(q) => q.trim().to_string(),
92+
Err(e) => {
93+
tracing::error!("AI generation failed on attempt {}: {:?}", attempt + 1, e);
94+
continue;
95+
}
96+
};
97+
98+
// Validate the expression with fasteval
99+
let mut ns = fasteval::EmptyNamespace;
100+
let answer = match fasteval::ez_eval(&question, &mut ns) {
101+
Ok(result) => result,
102+
Err(e) => {
103+
tracing::error!("Invalid math expression generated '{}': {:?}", question, e);
104+
continue;
105+
}
106+
};
107+
108+
// Check if this question already exists in the database
109+
let conn = db.get()?;
110+
let mut stmt = conn.prepare("SELECT * FROM math_question WHERE question = ?")?;
111+
let existing: Result<Vec<MathQuestion>, _> = from_rows(stmt.query([&question])?).collect();
112+
113+
// If question exists, try again
114+
if existing.is_ok() && !existing.as_ref().unwrap().is_empty() {
115+
tracing::debug!("Question '{}' already exists, retrying", question);
116+
continue;
117+
}
118+
119+
// Store in database - use manual INSERT to let SQLite handle autoincrement
120+
conn.execute(
121+
"INSERT INTO math_question (question, answer) VALUES (?, ?)",
122+
rusqlite::params![&question, &answer],
123+
)?;
124+
125+
return Ok(MathTest { question, answer });
126+
}
127+
128+
// If all attempts failed, return error
129+
Err(color_eyre::eyre::eyre!("Failed to generate valid math question after 5 attempts"))
130+
}
131+
132+
pub fn validate_answer(&self, user_answer: &str) -> bool {
133+
// Parse user answer
134+
let user_answer = match user_answer.trim().parse::<f64>() {
135+
Ok(v) => v,
136+
Err(_) => return false,
137+
};
138+
139+
// Check if answer is within 0.1 tolerance (1 decimal place)
140+
(self.answer - user_answer).abs() <= 0.1
141+
}
142+
}

src/message_handler.rs

Lines changed: 143 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,16 @@ use rand::{
66
use serde_rusqlite::from_row;
77
use std::{sync::Arc, time::Instant};
88
use tokio::sync::MutexGuard;
9+
use tokio::time::Instant as TokioInstant;
910
use twilight_http::Client as HttpClient;
1011
use twilight_model::{gateway::payload::incoming::MessageCreate, id::Id};
1112
use vesper::twilight_exports::UserMarker;
1213

1314
use crate::{
1415
ai_message,
1516
database::User,
16-
structs::{Command, List, State},
17+
math_test::MathTest,
18+
structs::{Command, List, PendingMathTest, State},
1719
utils::levels::xp_required_for_level,
1820
zalgos::zalgify_text,
1921
RESPONDERS,
@@ -33,6 +35,146 @@ pub async fn handle_message(
3335
}
3436
}
3537

38+
// Check if user has a pending math test
39+
if let Some(pending_test) = locked_state.pending_math_tests.get(&msg.author.id.get()) {
40+
let elapsed = pending_test.started_at.elapsed();
41+
42+
// Check if 30 seconds have passed
43+
if elapsed.as_secs() > 30 {
44+
// Failed - timeout
45+
locked_state.pending_math_tests.remove(&msg.author.id.get());
46+
47+
// Timeout the user for 1 minute
48+
let timeout_until = twilight_model::util::Timestamp::from_secs(
49+
std::time::SystemTime::now()
50+
.duration_since(std::time::UNIX_EPOCH)
51+
.unwrap()
52+
.as_secs() as i64
53+
+ 60,
54+
)
55+
.unwrap();
56+
57+
if let Some(guild_id) = msg.guild_id {
58+
match http
59+
.update_guild_member(guild_id, msg.author.id)
60+
.communication_disabled_until(Some(timeout_until))
61+
{
62+
Ok(req) => {
63+
if let Err(e) = req.exec().await {
64+
tracing::error!("Failed to execute timeout: {:?}", e);
65+
}
66+
}
67+
Err(e) => {
68+
tracing::error!("Failed to timeout user: {:?}", e);
69+
}
70+
}
71+
}
72+
73+
return Ok(Command::text(format!(
74+
"<@{}> Time's up! You took too long to answer. You've been timed out for 1 minute.",
75+
msg.author.id.get()
76+
))
77+
.reply());
78+
}
79+
80+
// Check if answer is correct
81+
let user_answer = msg.content.trim();
82+
if (MathTest {
83+
question: pending_test.question.clone(),
84+
answer: pending_test.answer,
85+
})
86+
.validate_answer(user_answer)
87+
{
88+
locked_state.pending_math_tests.remove(&msg.author.id.get());
89+
90+
// Award 50x normal XP (normal is 5-20, so this is 250-1000)
91+
let bonus_xp = locked_state.rng.gen_range(250..1000);
92+
93+
// Update user XP
94+
let db = locked_state.db.get()?;
95+
let mut statement = db.prepare("SELECT * FROM user WHERE id = ?").unwrap();
96+
if let Ok(mut user) = statement.query_one([msg.author.id.get().to_string()], |row| {
97+
from_row::<User>(row).map_err(|_| rusqlite::Error::QueryReturnedNoRows)
98+
}) {
99+
let level = user.level;
100+
let xp_required = xp_required_for_level(level);
101+
let new_xp = user.xp + bonus_xp;
102+
user.name = msg.author.name.clone();
103+
104+
if new_xp >= xp_required {
105+
let new_level = level + 1;
106+
user.level = new_level;
107+
user.xp = new_xp - xp_required;
108+
user.update_sync(&db)?;
109+
110+
return Ok(Command::text(format!(
111+
"<@{}> Correct! Well done. You earned {} XP and leveled up to level {}!",
112+
msg.author.id.get(),
113+
bonus_xp,
114+
new_level
115+
))
116+
.reply());
117+
} else {
118+
user.xp = new_xp;
119+
user.update_sync(&db)?;
120+
}
121+
}
122+
123+
return Ok(Command::text(format!(
124+
"<@{}> Correct! Well done. You earned {} XP!",
125+
msg.author.id.get(),
126+
bonus_xp
127+
))
128+
.reply());
129+
} else {
130+
// Wrong answer - don't remove, they can keep trying until timeout
131+
return Ok(Command::text(format!(
132+
"<@{}> Wrong answer! Try again. (Time remaining: {} seconds)",
133+
msg.author.id.get(),
134+
30 - elapsed.as_secs()
135+
))
136+
.reply());
137+
}
138+
}
139+
140+
// Random 1/100 chance to trigger math test
141+
let should_trigger_math = locked_state.config.openai_api_key.is_some()
142+
&& locked_state.rng.gen_range(0..1) == 0
143+
&& !locked_state.pending_math_tests.contains_key(&msg.author.id.get());
144+
145+
if should_trigger_math {
146+
let api_key = locked_state.config.openai_api_key.clone().unwrap();
147+
let db_clone = locked_state.db.clone();
148+
149+
// Use a new RNG for the async operation
150+
let mut new_rng = rand::thread_rng();
151+
152+
match MathTest::generate(&api_key, &db_clone, &mut new_rng).await {
153+
Ok(test) => {
154+
let pending = PendingMathTest {
155+
user_id: msg.author.id.get(),
156+
channel_id: msg.channel_id.get(),
157+
question: test.question.clone(),
158+
answer: test.answer,
159+
started_at: TokioInstant::now(),
160+
};
161+
162+
locked_state.pending_math_tests.insert(msg.author.id.get(), pending);
163+
164+
return Ok(Command::text(format!(
165+
"<@{}> **MATH TEST TIME!** Solve this in 30 seconds:\n`{}`\n(Answer to 1 decimal place)",
166+
msg.author.id.get(),
167+
test.question
168+
))
169+
.reply()
170+
.mention());
171+
}
172+
Err(e) => {
173+
tracing::error!("Failed to generate math test: {:?}", e);
174+
}
175+
}
176+
}
177+
36178
let user = {
37179
let db = locked_state.db.get()?;
38180
let mut statement = db.prepare("SELECT * FROM user WHERE id = ?").unwrap();

0 commit comments

Comments
 (0)