Compare commits

...
Author SHA1 Message Date
40a576ff0b
annihilate warnings
Some checks failed
Build and Test / spacebot (push) Failing after 7m52s
2026-08-08 20:12:55 +02:00
2564c154cf
chore: change mentions to spacebot 2026-08-08 20:02:12 +02:00
c9d3b44026
cleanup 2026-08-08 19:51:38 +02:00
558625b4ec
chore: remove music module 2026-08-08 19:37:02 +02:00
10 changed files with 194 additions and 3583 deletions

View file

@ -10,7 +10,7 @@ env:
SHORT_SHA: ${{ github.sha }}
jobs:
splatie:
spacebot:
runs-on: debian-trixie
steps:

2973
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
[package]
name = "professor"
name = "spacebot"
version = "0.1.0"
edition = "2021"
@ -19,11 +19,8 @@ panic = "abort"
dotenv = "0.15.0"
regex = "1.11.1"
once_cell = "1.20.3"
sarcastic = { git = "https://github.com/DJMrTV/bored-yaml.git" }
chrono = "0.4.39"
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
songbird = { version = "0.6.0", features = ["builtin-queue", "driver", "gateway"] }
youtube_dl = { version = "0.10.0", features = ["downloader-rustls-tls"] }
dashmap = "6.1.0"
symphonia = { version = "0.6.0", features = ["aac", "mp3", "isomp4", "alac", "flac", "pcm", "vorbis"] }

View file

@ -27,15 +27,12 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \
cargo build --release --target x86_64-unknown-linux-musl && \
mkdir -p /app/dist && \
cp /app/target/x86_64-unknown-linux-musl/release/professor /app/dist/
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/spacebot /app/dist/
FROM alpine:latest AS final
WORKDIR /app
RUN apk add --no-cache ffmpeg python3 curl libstdc++ \
&& curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp \
&& chmod a+rx /usr/local/bin/yt-dlp \
&& apk del curl
RUN apk add --no-cache python3 libstdc++
COPY --from=builder /app/dist/professor ./splatie
ENTRYPOINT ["./splatie"]
COPY --from=builder /app/dist/spacebot ./spacebot
ENTRYPOINT ["./spacebot"]

View file

@ -1,28 +1,7 @@
use std::fmt::Write;
use std::{env, fs};
use std::fs::FileType;
use std::iter::Map;
use serde::Deserialize;
use serde_json::Value;
#[derive(Deserialize)]
struct CategoryInfo{
name: String,
description: String,
system: String,
}
#[derive(Deserialize)]
struct ErrorInfo{
name: String,
message: String,
short_description: String,
long_description: String,
short_solution: String,
long_solution: String,
support_link: String,
}
fn main(){
println!("cargo:rerun-if-changed=./error-codes");
@ -173,13 +152,6 @@ fn main(){
let long_solution = long_solution.replace("\"", "\\\"")
.replace("\\n", "\n");
let Value::String(support_link) = &error["support_link"] else {
panic!("unable to parse error json");
};
let support_link = support_link.replace("\"", "\\\"")
.replace("\\n", "\n");
code.write_str(
&format!("\
({},{}) => super::ErrorInfo{{\
@ -189,9 +161,8 @@ fn main(){
long_description: \"{}\",
short_solution: \"{}\",
long_solution: \"{}\",
support_link: \"{}\",
}},\
", category_num, error_num, name, message, short_description, long_description, short_solution, long_solution, support_link)
", category_num, error_num, name, message, short_description, long_description, short_solution, long_solution)
).expect("unable to write");
}
}

View file

@ -1,12 +1,9 @@
use once_cell::sync::Lazy;
use regex::Regex;
use serenity::all::CreateActionRow::Buttons;
use serenity::all::{
Context, CreateActionRow, CreateButton, CreateEmbed, CreateEmbedFooter, CreateMessage,
EditInteractionResponse, EditMessage, EventHandler, Http, Interaction, Message,
Context, CreateActionRow, CreateButton, CreateEmbed, CreateMessage, EditMessage, EventHandler, Http, Interaction, Message,
};
use serenity::async_trait;
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Duration;
use tokio::time::sleep;
@ -38,7 +35,6 @@ struct ErrorInfo {
long_description: &'static str,
short_solution: &'static str,
long_solution: &'static str,
support_link: &'static str,
}
impl Default for ErrorInfo {
@ -50,15 +46,12 @@ impl Default for ErrorInfo {
long_solution: "unknown",
short_description: "unknown",
short_solution: "unknown",
support_link: "unknown",
}
}
}
static ERROR_CODE_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new("\\b\\d\\d\\d-\\d\\d\\d\\d\\b").expect("invalid regex"));
static WORD_BOUNDARY_START: Lazy<Regex> = Lazy::new(|| Regex::new("^\\b").expect("invalid regex"));
static WORD_BOUNDARY_END: Lazy<Regex> = Lazy::new(|| Regex::new("\\b$").expect("invalid regex"));
pub struct ErrorCodeHandler;
@ -133,7 +126,7 @@ impl EventHandler for ErrorCodeHandler {
if let Some(err_code) = ERROR_CODE_REGEX.find(&msg.content) {
let str_code = err_code.as_str();
let Some(message) = create_error_explain_message(str_code, true) else {
let Some(_message) = create_error_explain_message(str_code, true) else {
return;
};

View file

@ -1,194 +0,0 @@
use sarcastic::byaml::{Byaml, Node};
use serenity::all::{ChannelId, Colour, Command, CommandId, CommandOptionType, Context, CreateCommand, CreateCommandOption, CreateEmbed, CreateEmbedAuthor, CreateInputText, CreateInteractionResponse, CreateInteractionResponseMessage, CreateMessage, CreateQuickModal, EventHandler, InputTextStyle, Interaction, Ready, ResolvedValue, Timestamp};
use serenity::all::ResolvedValue::Attachment;
use serenity::async_trait;
use tokio::sync::OnceCell;
use std::fmt::Write;
use chrono::{DateTime, Utc};
#[derive(Default)]
pub struct FestFaxHandler{
command_id: OnceCell<CommandId>
}
#[derive(Debug)]
struct TeamData{
color: [f32; 4],
name: String
}
fn read_team_data(node: &Node<'_>) -> Option<TeamData>{
let Node::DictionaryNode(node) = node else{
return None;
};
let Some(Node::StringValue(color)) = node.find("Color") else{
return None;
};
let Some(color): Option<[f32; 4]> = color.split(',')
.filter_map(|v| v.parse::<f32>().ok())
.collect::<Vec<f32>>().try_into().ok() else {
return None;
};
let Some(Node::DictionaryNode(names)) = node.find("Name") else{
return None;
};
let Some(Node::StringValue(name)) = names.find("EUen") else{
return None;
};
Some(TeamData{
name: name.to_string(),
color
})
}
#[async_trait]
impl EventHandler for FestFaxHandler {
async fn ready(&self, ctx: Context, data_about_bot: Ready) {
let command = CreateCommand::new("process-fest-in-fax-machine")
.add_option(CreateCommandOption::new(CommandOptionType::String, "fes_tex_url", "Texture url")
.required(true))
.add_option(CreateCommandOption::new(CommandOptionType::String, "details", "Details")
.required(true))
.description("Announce a fest!");
let cmd = Command::create_global_command(&ctx.http, command).await.expect("unable to register command");
self.command_id.set(cmd.id).ok();
}
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
let Interaction::Command(command_interaction) = interaction else {
return;
};
let Some(command_id) = self.command_id.get() else{
return;
};
if command_interaction.data.id != *command_id{
return
}
if command_interaction.user.id != 400291421799710720 {
command_interaction.create_response(
&ctx.http,
CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
.content("nuh uh(cnd still isnt up so im using this to test for perms lol)")
)).await.ok();
return;
}
let options = command_interaction.data.options();
let Ok(byaml_data) = reqwest::get("https://dl.app.spfn.cc/p01/data/1/Festival.byaml").await else{
return;
};
let Ok(byaml_data) = byaml_data.bytes().await else{
return;
};
macro_rules! byaml_handle_error {
($($stuff:tt)*) => {
match $($stuff)*{
Ok(v) => v,
Err(e) => {
command_interaction.create_response(&ctx.http, CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
.content(format!("an error ocurred whilest reading the byaml: {}", e)))).await.ok();
return;
}
}
};
}
let byaml = byaml_handle_error!(Byaml::new(&byaml_data));
let root_node = byaml_handle_error!(byaml.get_root_node());
let Node::DictionaryNode(dict) = root_node else {
return;
};
let Some(Node::ArrayNode(teams)) = dict.find("Teams") else {
return
};
let mut iter = teams.into_iter()
.filter_map(|v| v.ok());
let Some(alpha) = iter.next() else {
return;
};
let Some(bravo) = iter.next() else {
return;
};
let Some(alpha) = read_team_data(&alpha) else{
return;
};
let Some(bravo) = read_team_data(&bravo) else{
return;
};
let Some(Node::DictionaryNode(times)) = dict.find("Time") else {
return
};
let Some(Node::StringValue(start_time)) = times.find("Start") else {
return
};
let Ok(start_time) = DateTime::parse_from_rfc3339(start_time) else{
return;
};
let Some(Node::StringValue(end_time)) = times.find("End") else {
return
};
let Ok(end_time) = DateTime::parse_from_rfc3339(end_time) else{
return;
};
let Some(fest_image) = options.iter().find(|o| o.name == "fes_tex_url") else{
return;
};
let ResolvedValue::String(fest_image) = fest_image.value else {
return;
};
let Some(fest_file) = options.iter().find(|o| o.name == "details") else{
return;
};
let ResolvedValue::String(details) = fest_file.value else {
return;
};
command_interaction.create_response(&ctx.http, CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
.embed(
CreateEmbed::new()
.image(fest_image)
.field("Theme", format!("{} vs {}", alpha.name, bravo.name), false)
.field("Starts", format!("<t:{}:R>", start_time.timestamp()), false)
.field("End", format!("<t:{}:f>", end_time.timestamp()), true)
.field("Details", details, true)
)
)
).await.ok();
}
}

View file

@ -1,51 +1,20 @@
mod emergency_report;
mod error_codes;
mod fest_fax;
mod miiverse_mod_application;
mod music;
use once_cell::sync::Lazy;
use regex::Regex;
use serenity::async_trait;
use serenity::prelude::*;
use songbird::SerenityInit;
use std::env;
use std::process::Command;
#[tokio::main(flavor = "current_thread")]
async fn main() {
println!("splattie starting!");
match Command::new("ffmpeg").arg("-version").output() {
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout);
let first_line = stdout.lines().next().unwrap_or("");
println!("detected ffmpeg ver {}", first_line);
}
Err(_) => {
eprintln!("'ffmpeg' was not found in your system PATH");
std::process::exit(1);
}
}
match Command::new("yt-dlp").arg("--version").output() {
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout);
let version = stdout.trim();
println!("detected yt-dlp ver {}", version);
}
Err(_) => {
eprintln!("'yt-dlp' was not found in your system PATH");
std::process::exit(1);
}
}
println!("spacebot starting!");
dotenv::dotenv().ok();
let token = env::var("SPACEBOT_TOKEN").expect("Token not specified");
let intents = GatewayIntents::GUILD_MESSAGES
| GatewayIntents::MESSAGE_CONTENT
| GatewayIntents::GUILDS
| GatewayIntents::GUILD_VOICE_STATES;
| GatewayIntents::GUILDS;
let mut client = Client::builder(&token, intents)
.event_handler(error_codes::ErrorCodeHandler)
@ -55,8 +24,6 @@ async fn main() {
.event_handler(emergency_report::EmergencyReportHandler::default())
.event_handler(miiverse_mod_application::MiiverseModApplicationHandler::default())
//.event_handler(fest_fax::FestFaxHandler::default())
.event_handler(music::MusicHandler::default())
.register_songbird()
.await
.expect("unable to create client");

View file

@ -1,5 +1,5 @@
use serenity::all::{ActivityData, ActivityType, ChannelId, Colour, Command, CommandId, Context, CreateCommand, CreateEmbed, CreateEmbedAuthor, CreateInputText, CreateInteractionResponse, CreateInteractionResponseMessage, CreateMessage, CreateQuickModal, EventHandler, Guild, GuildId, InputTextStyle, Interaction, Message, Ready, Timestamp};
use serenity::all::{ChannelId, Colour, Command, CommandId, Context, CreateCommand, CreateEmbed, CreateEmbedAuthor, CreateInteractionResponse, CreateInteractionResponseMessage, CreateMessage, CreateQuickModal, EventHandler, Interaction, Ready, Timestamp};
use serenity::async_trait;
use tokio::sync::OnceCell;
@ -10,7 +10,7 @@ pub struct MiiverseModApplicationHandler{
#[async_trait]
impl EventHandler for MiiverseModApplicationHandler {
async fn ready(&self, ctx: Context, data_about_bot: Ready) {
async fn ready(&self, ctx: Context, _data_about_bot: Ready) {
let command = CreateCommand::new("mod-application")
.description("Apply for moderator");
@ -56,7 +56,7 @@ impl EventHandler for MiiverseModApplicationHandler {
let emergency_channel = ChannelId::new(1504018819071610970);
let mut author = CreateEmbedAuthor::from(response.interaction.user);
let author = CreateEmbedAuthor::from(response.interaction.user);
let embed = CreateEmbed::new()
.title("Moderator Application")

View file

@ -1,271 +0,0 @@
//note to anyone reading my commits lol: not exactly efficient but this probs will only be used internally so who gaf!! meow meow
use serenity::async_trait;
use serenity::model::channel::Message;
use serenity::prelude::*;
use serenity::builder::{CreateMessage, CreateEmbed, CreateEmbedAuthor};
use serenity::model::id::{GuildId, ChannelId};
use songbird::input::{YoutubeDl, Input, Compose};
use dashmap::DashMap;
use once_cell::sync::Lazy;
use std::sync::Arc;
use std::env;
static PLAYLISTS: Lazy<DashMap<u64, Vec<String>>> = Lazy::new(DashMap::new);
pub struct MusicHandler {
prefix: String,
}
impl Default for MusicHandler {
fn default() -> Self {
// account for lowercase & uppercase... cuz that was pissing me off for no reason that it didnt??
let prefix = env::var("PREFIX")
.or_else(|_| env::var("prefix"))
.unwrap_or_else(|_| "space!".to_string()) // fallback to space!, ngl binder probably wont check so hopefully this will stay :3
.to_lowercase();
Self { prefix }
}
}
async fn join_channel(
ctx: &Context,
msg: &Message,
guild_id: GuildId,
channel_id: ChannelId
) -> Result<Arc<Mutex<songbird::Call>>, &'static str> {
let manager = songbird::get(ctx).await.expect("Songbird client not initialized").clone();
if manager.get(guild_id).is_some() {
return Err("bubs im already in another channel...");
}
if manager.join(guild_id, channel_id).await.is_ok() {
PLAYLISTS.insert(guild_id.get(), Vec::new());
if let Some(handler) = manager.get(guild_id) {
return Ok(handler);
}
}
Err("Failed to join the voice channel.")
}
#[async_trait]
impl EventHandler for MusicHandler {
async fn message(&self, ctx: Context, msg: Message) {
if msg.author.bot { return; }
let content_lower = msg.content.trim_start().to_lowercase();
if !content_lower.starts_with(&self.prefix) { return; }
let original_content = msg.content.trim_start();
let stripped_content = &original_content[self.prefix.len()..];
let args: Vec<&str> = stripped_content.split_whitespace().collect();
if args.is_empty() { return; }
match args[0].to_lowercase().as_str() {
"join" => {
let guild = msg.guild(&ctx.cache).unwrap().clone();
let guild_id = guild.id;
let voice_channel_id = guild
.voice_states
.get(&msg.author.id)
.and_then(|state| state.channel_id);
match voice_channel_id {
Some(channel_id) => {
if let Err(err_msg) = join_channel(&ctx, &msg, guild_id, channel_id).await {
let builder = CreateMessage::new().content(err_msg);
let _ = msg.channel_id.send_message(&ctx.http, builder).await;
}
}
None => {
let embed = CreateEmbed::new()
.title("You must be in a voice channel to use ts!!!!!")
.color(16728123);
let builder = CreateMessage::new().embed(embed);
let _ = msg.channel_id.send_message(&ctx.http, builder).await;
}
}
}
"leave" => {
let guild_id = msg.guild_id.unwrap();
let manager = songbird::get(&ctx).await.expect("Songbird client not initialized").clone();
if manager.get(guild_id).is_some() {
let _ = manager.leave(guild_id).await;
let _ = manager.remove(guild_id).await;
PLAYLISTS.remove(&guild_id.get());
} else {
let builder = CreateMessage::new().content("uuuuhhhhhhhh i think i have dementia...");
let _ = msg.channel_id.send_message(&ctx.http, builder).await;
}
}
"play" => {
let _ = msg.delete(&ctx.http).await;
let guild = msg.guild(&ctx.cache).unwrap().clone();
let guild_id = guild.id;
let manager = songbird::get(&ctx).await.expect("Songbird client not initialized").clone();
let voice_channel_id = guild
.voice_states
.get(&msg.author.id)
.and_then(|state| state.channel_id);
let handler_lock = match manager.get(guild_id) {
Some(handler) => handler,
None => match voice_channel_id {
Some(channel_id) => match join_channel(&ctx, &msg, guild_id, channel_id).await {
Ok(handler) => handler,
Err(err_msg) => {
let builder = CreateMessage::new().content(err_msg);
let _ = msg.channel_id.send_message(&ctx.http, builder).await;
return;
}
},
None => {
let embed = CreateEmbed::new()
.title("IM NOT IN A VOICE CHANNEL THOUGH...")
.color(16728123)
.description("what kinda drugs you on?? in fact.. lemme have some of those.")
.author(CreateEmbedAuthor::new("help: use join first"));
let builder = CreateMessage::new().embed(embed);
let _ = msg.channel_id.send_message(&ctx.http, builder).await;
return;
}
}
};
if args.len() < 2 {
let embed = CreateEmbed::new()
.title("You must be in a voice channel to use ts!!!!!")
.color(16728123);
let builder = CreateMessage::new().embed(embed);
let _ = msg.channel_id.send_message(&ctx.http, builder).await;
return;
}
let url = args[1].to_string();
if !url.contains("youtube.com") && !url.contains("youtu.be") {
let embed = CreateEmbed::new()
.title("You must be in a voice channel to use ts!!!!!")
.color(16728123);
let builder = CreateMessage::new().embed(embed);
let _ = msg.channel_id.send_message(&ctx.http, builder).await;
return;
}
let is_playing = {
let handler = handler_lock.lock().await;
!handler.queue().is_empty()
};
let client = reqwest::Client::new();
let mut src = YoutubeDl::new(client, url.clone());
match src.aux_metadata().await {
Ok(metadata) => {
let mut handler = handler_lock.lock().await;
let input: Input = src.into();
let queue = handler.queue().clone();
let _ = queue.add_source(input, &mut *handler).await;
let mut queue_list = PLAYLISTS.entry(guild_id.get()).or_insert_with(Vec::new);
queue_list.push(url);
let current_position = queue_list.len();
let title = metadata.title.unwrap_or_else(|| "Unknown Track".to_string());
let artist = metadata.artist.or(metadata.channel).unwrap_or_else(|| "Unknown Artist".to_string());
let thumbnail = metadata.thumbnail.unwrap_or_default();
let display_name = msg.author.global_name.as_ref().unwrap_or(&msg.author.name);
let user_avatar = msg.author.avatar_url().unwrap_or_default();
let mut author_node = CreateEmbedAuthor::new(format!("Requested by {}", display_name));
if !user_avatar.is_empty() {
author_node = author_node.icon_url(user_avatar);
}
let mut embed = CreateEmbed::new()
.color(12543992)
.author(author_node)
.field("Title", title, true)
.field("Artist", format!("by\n{}", artist), true)
.field(" ", "Meowwww :3", false)
.field("Position in Queue", format!("#{}", current_position), false);
if !thumbnail.is_empty() {
embed = embed.thumbnail(thumbnail);
}
let builder = CreateMessage::new().embed(embed);
let _ = msg.channel_id.send_message(&ctx.http, builder).await;
if !is_playing {
let start_embed = CreateEmbed::new()
.title(":white_check_mark: | Playback started successfully!")
.color(3066993);
let start_builder = CreateMessage::new().embed(start_embed);
let _ = msg.channel_id.send_message(&ctx.http, start_builder).await;
}
}
Err(e) => {
println!("YTDL extraction error: {:?}", e);
}
}
}
"skip" => {
let guild_id = msg.guild_id.unwrap();
let manager = songbird::get(&ctx).await.expect("Songbird client not initialized").clone();
if let Some(handler_lock) = manager.get(guild_id) {
let handler = handler_lock.lock().await;
let queue = handler.queue();
if queue.is_empty() {
let builder = CreateMessage::new().content("uuuuhhhhhhhh i think i have dementia...");
let _ = msg.channel_id.send_message(&ctx.http, builder).await;
return;
}
let skip_count = args.get(1).and_then(|s| s.parse::<usize>().ok()).unwrap_or(1);
if skip_count > 1 {
if let Some(mut local_queue) = PLAYLISTS.get_mut(&guild_id.get()) {
let len = local_queue.len();
let skip_limit = std::cmp::min(len, skip_count - 1);
local_queue.drain(0..skip_limit);
let embed = CreateEmbed::new()
.title(format!(":wastebasket: | Trashed **{}** songs from queue.", skip_limit));
let builder = CreateMessage::new().embed(embed);
let _ = msg.channel_id.send_message(&ctx.http, builder).await;
}
}
let _ = queue.skip();
if skip_count <= 1 {
let embed = CreateEmbed::new()
.title(":fast_forward: | Skipped that mf!! Playing next song in queue..");
let builder = CreateMessage::new().embed(embed);
let _ = msg.channel_id.send_message(&ctx.http, builder).await;
}
} else {
let builder = CreateMessage::new().content("IM NOT IN A VOICE CHANNEL U CHUD");
let _ = msg.channel_id.send_message(&ctx.http, builder).await;
}
}
_ => {}
}
}
}