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