Skip to content

Commit

Permalink
add serenity weather forecast bot example (#146)
Browse files Browse the repository at this point in the history
* 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
Lonanche and jonaro00 authored Mar 11, 2024
1 parent 4a05ef9 commit b1407cf
Show file tree
Hide file tree
Showing 6 changed files with 247 additions and 0 deletions.
15 changes: 15 additions & 0 deletions serenity/weather-forecast/Cargo.toml
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"
3 changes: 3 additions & 0 deletions serenity/weather-forecast/README.md
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)
3 changes: 3 additions & 0 deletions serenity/weather-forecast/Secrets.toml
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"
131 changes: 131 additions & 0 deletions serenity/weather-forecast/src/main.rs
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")
}
88 changes: 88 additions & 0 deletions serenity/weather-forecast/src/weather.rs
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))
}
7 changes: 7 additions & 0 deletions templates.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down

0 comments on commit b1407cf

Please sign in to comment.