This commit is contained in:
Michael Zhang 2024-01-09 19:40:13 -06:00
commit e03236e1c7
7 changed files with 2496 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/target
.env

2174
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

16
Cargo.toml Normal file
View file

@ -0,0 +1,16 @@
[package]
name = "ctftime-discord-bot"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1.0.79"
async-trait = "0.1.77"
chrono = { version = "0.4.31", features = ["serde"] }
clap = { version = "4.4.14", features = ["derive"] }
dotenv = "0.15.0"
reqwest = { version = "0.11.23", features = ["json"] }
serde = { version = "1.0.195", features = ["derive"] }
serde_json = "1.0.111"
serenity = "0.12.0"
tokio = { version = "1.35.1", features = ["full"] }

4
rustfmt.toml Normal file
View file

@ -0,0 +1,4 @@
max_width = 80
tab_spaces = 2
wrap_comments = true
fn_single_line = true

18
src/ctftime.rs Normal file
View file

@ -0,0 +1,18 @@
use chrono::{DateTime, Utc};
pub type Events = Vec<CtftimeEvent>;
#[derive(Debug, Serialize, Deserialize)]
pub struct CtftimeEvent {
pub id: u64,
pub title: String,
pub description: String,
pub url: String,
pub ctftime_url: String,
#[serde(rename = "start")]
pub start_time: DateTime<Utc>,
#[serde(rename = "finish")]
pub end_time: DateTime<Utc>,
}

112
src/each_guild.rs Normal file
View file

@ -0,0 +1,112 @@
use std::{
collections::{HashMap, HashSet},
sync::Arc,
};
use anyhow::Result;
use serde::de;
use serde_json::json;
use serenity::{all::GuildId, http::Http, Client};
use crate::ctftime::Events;
#[derive(Debug, Serialize, Deserialize)]
struct FrontMatter {
ctftime_id: u64,
}
pub async fn each_guild_update_events(
api: Arc<Http>,
guild_id: GuildId,
events: &Events,
) -> Result<()> {
let scheduled_events = api.get_scheduled_events(guild_id, false).await?;
let events = events
.iter()
.map(|event| (event.id, event))
.collect::<HashMap<_, _>>();
let event_ids = events.keys().cloned().collect::<HashSet<_>>();
let already_created_events = scheduled_events
.iter()
.filter_map(|event| {
event
.description
.as_ref()
.and_then(|description| {
let mut iter = description.lines();
iter.last().map(|s| s.to_owned())
})
.and_then(|last_line| {
serde_json::from_str::<FrontMatter>(&last_line).ok().map(
|front_matter| (front_matter.ctftime_id, (event, front_matter)),
)
})
})
.collect::<HashMap<_, _>>();
let already_created_event_ids = already_created_events
.keys()
.cloned()
.collect::<HashSet<_>>();
let missing_event_ids = event_ids.difference(&already_created_event_ids);
for event_id in missing_event_ids {
let event = events.get(&event_id).unwrap();
let front_matter = FrontMatter {
ctftime_id: event.id,
};
api
.create_scheduled_event(
guild_id,
&json!({
"name": event.title,
"privacy_level": 2,
"entity_type": 3,
"entity_metadata": { "location": event.ctftime_url },
"scheduled_start_time": event.start_time.to_rfc3339(),
"scheduled_end_time": event.end_time.to_rfc3339(),
"description": format!("{}\n\n{}",
serde_json::to_string(&front_matter)?,
event.description
),
}),
Some(&format!("created by ctftime bot")),
)
.await?;
}
println!("created new events");
let update_event_ids = event_ids.intersection(&already_created_event_ids);
for event_id in update_event_ids {
let event = events.get(&event_id).unwrap();
let (scheduled_event, front_matter) =
already_created_events.get(&event_id).unwrap();
api
.edit_scheduled_event(
guild_id,
scheduled_event.id,
&json!({
"name": event.title,
"privacy_level": 2,
"entity_type": 3,
"entity_metadata": { "location": event.ctftime_url },
"scheduled_start_time": event.start_time.to_rfc3339(),
"scheduled_end_time": event.end_time.to_rfc3339(),
"description": format!("{}\n\n{}",
serde_json::to_string(&front_matter)?,
event.description
),
}),
Some(&format!("created by ctftime bot")),
)
.await?;
}
Ok(())
}

170
src/main.rs Normal file
View file

@ -0,0 +1,170 @@
#[macro_use]
extern crate serde;
mod ctftime;
mod each_guild;
use std::env;
use std::sync::Arc;
use std::time::Duration as StdDuration;
use anyhow::{Context as _, Result};
use async_trait::async_trait;
use chrono::{Duration, Utc};
use clap::Parser;
use serenity::all::{ActivityType, OnlineStatus, UnavailableGuild};
use serenity::gateway::ActivityData;
use serenity::{
all::{GatewayIntents, Message, Ready},
client::{Context, EventHandler},
Client,
};
use tokio::sync::RwLock;
use tokio::{join, time::sleep};
use crate::ctftime::Events;
use crate::each_guild::each_guild_update_events;
#[derive(Debug, Parser)]
struct Opt {
/// Refresh rate (in seconds, defaults to 1 day)
refresh_rate: Option<u64>,
}
#[derive(Default, Clone)]
struct AppState {
guild_ids: Arc<RwLock<Vec<UnavailableGuild>>>,
}
#[tokio::main]
async fn main() -> Result<()> {
dotenv::dotenv()?;
let opt = Opt::parse();
// Configure the client with your Discord bot token in the environment.
let token =
env::var("DISCORD_TOKEN").context("Expected a token in the environment")?;
let state = AppState::default();
let state2 = state.clone();
// Set gateway intents, which decides what events the bot will be notified about
let intents = GatewayIntents::GUILD_SCHEDULED_EVENTS;
// Create a new instance of the Client, logging in as a bot. This will automatically prepend
// your bot token with "Bot ", which is a requirement by Discord for bot users.
let mut client = Client::builder(&token, intents)
.event_handler(Handler(state))
.await
.expect("Err creating client");
let api = client.http.clone();
let shard_manager = client.shard_manager.clone();
// Finally, start a single shard, and start listening to events.
//
// Shards will automatically attempt to reconnect, and will perform exponential backoff until
// it reconnects.
let discord_handle = tokio::spawn(async move {
if let Err(why) = client.start().await {
println!("Client error: {why:?}");
}
});
// Main loop
let loop_handle = tokio::spawn(async move {
let state = state2;
loop {
println!("start of loop...");
// Get next month's events
let now = Utc::now();
let one_month_from_now = now + Duration::weeks(4);
let url = format!(
"https://ctftime.org/api/v1/events/?limit=100&start={}&finish={}",
now.timestamp(),
one_month_from_now.timestamp()
);
let response = reqwest::get(&url).await.unwrap();
let events: Events = response.json().await.unwrap();
// Get events from discord
{
let guilds = state.guild_ids.read().await;
for guild in guilds.iter() {
each_guild_update_events(api.clone(), guild.id, &events)
.await
.unwrap();
println!("done with guild {:?}", guild.id);
}
// let wtf = guild_ids.iter().map(|guild| {
// each_guild_update_events(api.clone(), guild.id, &events)
// });
// let shiet = future::join_all(wtf).await;
}
{
let status = format!("last updated <t:{}:R>", now.timestamp());
let runners = shard_manager.runners.lock().await;
for (_, runner) in runners.iter() {
runner.runner_tx.set_presence(
Some(ActivityData {
name: "CTFTime".to_owned(),
kind: ActivityType::Watching,
state: Some(status.clone()),
url: None,
}),
OnlineStatus::Online,
);
}
}
sleep(StdDuration::from_secs(
opt.refresh_rate.unwrap_or(60 * 60 * 24),
))
.await;
}
});
let (a, b) = join!(discord_handle, loop_handle);
a?;
b?;
Ok(())
}
struct Handler(AppState);
#[async_trait]
impl EventHandler for Handler {
// Set a handler for the `message` event - so that whenever a new message is received - the
// closure (or function) passed will be called.
//
// Event handlers are dispatched through a threadpool, and so multiple events can be dispatched
// simultaneously.
async fn message(&self, ctx: Context, msg: Message) {
if msg.content == "!ping" {
// Sending a message can fail, due to a network error, an authentication error, or lack
// of permissions to post in the channel, so log to stdout when some error happens,
// with a description of it.
if let Err(why) = msg.channel_id.say(&ctx.http, "Pong!").await {
println!("Error sending message: {why:?}");
}
}
}
// Set a handler to be called on the `ready` event. This is called when a shard is booted, and
// a READY payload is sent by Discord. This payload contains data like the current user's guild
// Ids, current user data, private channels, and more.
//
// In this case, just print what the current user's username is.
async fn ready(&self, _: Context, ready: Ready) {
println!("{} is connected!", ready.user.name);
{
let mut guard = self.0.guild_ids.write().await;
*guard = ready.guilds.clone();
}
}
}