diff --git a/serenity/weather-forecast/Cargo.toml b/serenity/weather-forecast/Cargo.toml new file mode 100644 index 00000000..8cb47309 --- /dev/null +++ b/serenity/weather-forecast/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "shuttle-docs-weather-bot" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1.0.66" +reqwest = { version = "0.11.24", features = ["json"] } +serde = "1.0.197" +serenity = { version = "0.12.0", default-features = false, features = ["client", "gateway", "rustls_backend", "model"] } +shuttle-runtime = "0.41.0" +shuttle-secrets = "0.41.0" +shuttle-serenity = "0.41.0" +tokio = "1.26.0" +tracing = "0.1.37" diff --git a/serenity/weather-forecast/README.md b/serenity/weather-forecast/README.md new file mode 100644 index 00000000..34b9de58 --- /dev/null +++ b/serenity/weather-forecast/README.md @@ -0,0 +1,3 @@ +# Serenity Weather Forecast Bot with Shuttle + +For a full tutorial on how to build and set up this bot, please refer to [Shuttle docs](https://docs.shuttle.rs/templates/tutorials/discord-weather-forecast) diff --git a/serenity/weather-forecast/Secrets.toml b/serenity/weather-forecast/Secrets.toml new file mode 100644 index 00000000..b1b9f929 --- /dev/null +++ b/serenity/weather-forecast/Secrets.toml @@ -0,0 +1,3 @@ +DISCORD_TOKEN = "the contents of my discord token" +DISCORD_GUILD_ID="123456789" +WEATHER_API_KEY="the contents of my weather api key" \ No newline at end of file diff --git a/serenity/weather-forecast/src/main.rs b/serenity/weather-forecast/src/main.rs new file mode 100644 index 00000000..36a483eb --- /dev/null +++ b/serenity/weather-forecast/src/main.rs @@ -0,0 +1,131 @@ +mod weather; + +use anyhow::Context as _; +use serenity::all::{GuildId, Interaction}; +use serenity::async_trait; +use serenity::builder::{ + CreateCommand, CreateCommandOption, CreateInteractionResponse, CreateInteractionResponseMessage, +}; +use serenity::model::gateway::Ready; +use serenity::prelude::*; +use shuttle_secrets::SecretStore; +use tracing::info; + +struct Bot { + weather_api_key: String, + client: reqwest::Client, + discord_guild_id: GuildId, +} + +#[async_trait] +impl EventHandler for Bot { + async fn ready(&self, ctx: Context, ready: Ready) { + info!("{} is connected!", ready.user.name); + + let commands = vec![ + CreateCommand::new("hello").description("Say hello"), + CreateCommand::new("weather") + .description("Display the weather") + .add_option( + CreateCommandOption::new( + serenity::all::CommandOptionType::String, + "place", + "City to lookup forecast", + ) + .required(true), + ), + ]; + + let commands = &self + .discord_guild_id + .set_commands(&ctx.http, commands) + .await + .unwrap(); + + info!("Registered commands: {:#?}", commands); + } + + async fn interaction_create(&self, ctx: Context, interaction: Interaction) { + if let Interaction::Command(command) = interaction { + let response_content = match command.data.name.as_str() { + "hello" => "hello".to_owned(), + "weather" => { + let argument = command + .data + .options + .iter() + .find(|opt| opt.name == "place") + .cloned(); + + let value = argument.unwrap().value; + let place = value.as_str().unwrap(); + + let result = + weather::get_forecast(place, &self.weather_api_key, &self.client).await; + + match result { + Ok((location, forecast)) => { + format!("Forecast: {} in {}", forecast.headline.overview, location) + } + Err(err) => { + format!("Err: {}", err) + } + } + } + command => unreachable!("Unknown command: {}", command), + }; + + let data = CreateInteractionResponseMessage::new().content(response_content); + let builder = CreateInteractionResponse::Message(data); + + if let Err(why) = command.create_response(&ctx.http, builder).await { + println!("Cannot respond to slash command: {why}"); + } + } + } +} + +#[shuttle_runtime::main] +async fn serenity( + #[shuttle_secrets::Secrets] secret_store: SecretStore, +) -> shuttle_serenity::ShuttleSerenity { + // Get the discord token set in `Secrets.toml` + let discord_token = secret_store + .get("DISCORD_TOKEN") + .context("'DISCORD_TOKEN' was not found")?; + + let weather_api_key = secret_store + .get("WEATHER_API_KEY") + .context("'WEATHER_API_KEY' was not found")?; + + let discord_guild_id = secret_store + .get("DISCORD_GUILD_ID") + .context("'DISCORD_GUILD_ID' was not found")?; + + let client = get_client( + &discord_token, + &weather_api_key, + discord_guild_id.parse().unwrap(), + ) + .await; + Ok(client.into()) +} + +pub async fn get_client( + discord_token: &str, + weather_api_key: &str, + discord_guild_id: u64, +) -> Client { + // Set gateway intents, which decides what events the bot will be notified about. + // Here we don't need any intents so empty + let intents = GatewayIntents::empty(); + + Client::builder(discord_token, intents) + .event_handler(Bot { + weather_api_key: weather_api_key.to_owned(), + client: reqwest::Client::new(), + discord_guild_id: GuildId::new(discord_guild_id), + }) + .await + .expect("Err creating client") +} diff --git a/serenity/weather-forecast/src/weather.rs b/serenity/weather-forecast/src/weather.rs new file mode 100644 index 00000000..21269f52 --- /dev/null +++ b/serenity/weather-forecast/src/weather.rs @@ -0,0 +1,88 @@ +use reqwest::Client; +use serde::Deserialize; +use std::fmt::Display; + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "PascalCase")] +pub struct Location { + key: String, + localized_name: String, + country: Country, +} + +impl Display for Location { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}, {}", self.localized_name, self.country.id) + } +} + +#[derive(Deserialize, Debug)] +pub struct Country { + #[serde(alias = "ID")] + pub id: String, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "PascalCase")] +pub struct Forecast { + pub headline: Headline, +} + +#[derive(Deserialize, Debug)] +pub struct Headline { + #[serde(alias = "Text")] + pub overview: String, +} + +#[derive(Debug)] +pub struct CouldNotFindLocation { + place: String, +} + +impl Display for CouldNotFindLocation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Could not find location '{}'", self.place) + } +} + +impl std::error::Error for CouldNotFindLocation {} + +pub async fn get_forecast( + place: &str, + api_key: &str, + client: &Client, +) -> Result<(Location, Forecast), Box> { + // Endpoints we will use + const LOCATION_REQUEST: &str = "http://dataservice.accuweather.com/locations/v1/cities/search"; + const DAY_REQUEST: &str = "http://dataservice.accuweather.com/forecasts/v1/daily/1day/"; + + // The URL to call combined with our API_KEY and the place (via the q search parameter) + let url = format!("{}?apikey={}&q={}", LOCATION_REQUEST, api_key, place); + // Make the request we will call + let request = client.get(url).build().unwrap(); + // Execute the request and await a JSON result that will be converted to a + // vector of locations + let resp = client + .execute(request) + .await? + .json::>() + .await?; + + // Get the first location. If empty respond with the above declared + // `CouldNotFindLocation` error type + let first_location = resp + .into_iter() + .next() + .ok_or_else(|| CouldNotFindLocation { + place: place.to_owned(), + })?; + + // Now have the location combine the key/identifier with the URL + let url = format!("{}{}?apikey={}", DAY_REQUEST, first_location.key, api_key); + + let request = client.get(url).build().unwrap(); + let forecast = client.execute(request).await?.json::().await?; + + // Combine the location with the forecast + Ok((first_location, forecast)) +} diff --git a/templates.toml b/templates.toml index b398fa7e..4596e18d 100644 --- a/templates.toml +++ b/templates.toml @@ -275,6 +275,13 @@ path = "serenity/postgres" use_cases = ["Discord bot", "Storage"] tags = ["serenity", "database", "postgres"] +[templates.serenity-weather-forecast] +title = "Weather Bot" +description = "Weather forecast Discord bot using the Accuweather API" +path = "serenity/weather-forecast" +use_cases = ["Discord bot"] +tags = ["serenity"] + [templates.shuttle-cron] title = "Shutte custom Cron service" description = "Schedule tasks on a cron schedule with apalis"