-
Notifications
You must be signed in to change notification settings - Fork 59
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
add serenity weather forecast bot example (#146)
* add serenity weather forecast bot example * fix: formatting * bump versions * add to templates.toml --------- Co-authored-by: jonaro00 <54029719+jonaro00@users.noreply.github.com>
- Loading branch information
Showing
6 changed files
with
247 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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") | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<dyn std::error::Error>> { | ||
// 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::<Vec<Location>>() | ||
.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::<Forecast>().await?; | ||
|
||
// Combine the location with the forecast | ||
Ok((first_location, forecast)) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters