initial
This commit is contained in:
commit
e03236e1c7
7 changed files with 2496 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
/target
|
||||||
|
.env
|
2174
Cargo.lock
generated
Normal file
2174
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
16
Cargo.toml
Normal file
16
Cargo.toml
Normal 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
4
rustfmt.toml
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
max_width = 80
|
||||||
|
tab_spaces = 2
|
||||||
|
wrap_comments = true
|
||||||
|
fn_single_line = true
|
18
src/ctftime.rs
Normal file
18
src/ctftime.rs
Normal 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
112
src/each_guild.rs
Normal 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
170
src/main.rs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue