mirror of
https://github.com/dsec-hub/dsec-discord-bot.git
synced 2026-09-22 15:53:56 +00:00
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
193 lines
6.7 KiB
Rust
193 lines
6.7 KiB
Rust
use crate::{Data, Error, commands::mods_only::log_embed};
|
|
use poise::serenity_prelude as serenity;
|
|
|
|
async fn honeypot(
|
|
ctx: &serenity::Context,
|
|
new_message: &serenity::Message,
|
|
data: &Data,
|
|
) -> 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;
|
|
|
|
if honeypot_channel_id.eq(current_channel_id) {
|
|
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. A moderator checking whether the
|
|
// honeypot works should not be the person it catches. This reads the
|
|
// cached guild; if it is unavailable we fall through and let the honeypot
|
|
// act, rather than skipping the check silently for everyone.
|
|
if let Some(guild_id) = new_message.guild_id
|
|
&& let Ok(member) = guild_id.member(ctx, user.id).await
|
|
{
|
|
// Guild-level moderator identity is exactly what we want here; the
|
|
// deprecation is about per-channel permission overwrites, which are
|
|
// irrelevant to "can this person moderate at all".
|
|
#[allow(deprecated)]
|
|
let perms = member.permissions(ctx);
|
|
if let Ok(perms) = perms
|
|
&& (perms.ban_members() || perms.manage_messages() || perms.administrator())
|
|
{
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
let username = &user.name;
|
|
let user_id = &user.id;
|
|
let avatar_url = user.avatar_url();
|
|
let ban_user = data
|
|
.state
|
|
.guild_id
|
|
.ban_with_reason(ctx, user_id, 2, "Message sent in honeypot channel.")
|
|
.await;
|
|
|
|
match ban_user {
|
|
Ok(()) => {
|
|
log_embed(
|
|
ctx,
|
|
data.state.logs_channel_id,
|
|
Some("Honeypot activated!".to_string()),
|
|
None,
|
|
Some(format!("User got banned: {}", username)),
|
|
Some(format!("ID: {}", user_id)),
|
|
None,
|
|
avatar_url,
|
|
None,
|
|
Some(true),
|
|
)
|
|
.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(())
|
|
}
|
|
|
|
/// 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(
|
|
ctx: &serenity::Context,
|
|
new_message: &serenity::Message,
|
|
data: &Data,
|
|
) -> Result<(), Error> {
|
|
let leetcode_channel_id = data.state.leetcode_channel_id;
|
|
|
|
let current_channel_id = &new_message.channel_id;
|
|
|
|
if leetcode_channel_id.eq(current_channel_id) {
|
|
let new_thread =
|
|
serenity::CreateThread::new(create_title_from_message(&new_message.content));
|
|
if let Err(err) = current_channel_id
|
|
.create_thread_from_message(ctx, new_message.id, new_thread)
|
|
.await
|
|
{
|
|
eprintln!(
|
|
"[leetcode] failed to create thread for message {}: {err}",
|
|
new_message.id
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn on_message(
|
|
ctx: &serenity::Context,
|
|
new_message: &serenity::Message,
|
|
data: &Data,
|
|
) -> Result<(), Error> {
|
|
// Independent features. A failure in one must not skip the other, and neither
|
|
// 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(())
|
|
}
|
|
|
|
#[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"
|
|
);
|
|
}
|
|
}
|