From d2748ef3e242c71e1cc600c1b7f8429ac4daf40e Mon Sep 17 00:00:00 2001 From: Aditya Kumar <117935160+AS1100K@users.noreply.github.com> Date: Sat, 27 Jul 2024 08:00:41 +0530 Subject: [PATCH] Initial Commit This Repository has moved from AS1100K/aether to it's own repository. For previous commits see https://github.com/AS1100K/aether/pull/17 --- .github/workflows/ci.yml | 78 +++++++++ .github/workflows/publish.yml | 23 +++ Cargo.toml | 24 +++ README.md | 57 +++++++ src/common.rs | 298 ++++++++++++++++++++++++++++++++++ src/lib.rs | 11 ++ src/runtime.rs | 9 + src/webhook/mod.rs | 123 ++++++++++++++ 8 files changed, 623 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish.yml create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 src/common.rs create mode 100644 src/lib.rs create mode 100644 src/runtime.rs create mode 100644 src/webhook/mod.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8c77c1c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,78 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + +jobs: + # Run cargo test + test: + name: Test Suite + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout sources + uses: actions/checkout@v4 + - name: Cache + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-test-${{ hashFiles('**/Cargo.toml') }} + - name: Install stable toolchain + uses: dtolnay/rust-toolchain@stable + - name: Install Dependencies + run: sudo apt-get update; sudo apt-get install --no-install-recommends libasound2-dev libudev-dev + - name: Run cargo test + run: cargo test + + # Run cargo clippy -- -D warnings + clippy_check: + name: Clippy + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout sources + uses: actions/checkout@v4 + - name: Cache + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-clippy-${{ hashFiles('**/Cargo.toml') }} + - name: Install stable toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - name: Install Dependencies + run: sudo apt-get update; sudo apt-get install --no-install-recommends libasound2-dev libudev-dev + - name: Run clippy + run: cargo clippy -- -D warnings + + # Run cargo fmt --all -- --check + format: + name: Format + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout sources + uses: actions/checkout@v4 + - name: Install stable toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - name: Run cargo fmt + run: cargo fmt --all -- --check \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..401b4c9 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,23 @@ +on: + push: + # Pattern matched against refs/tags + tags: + - 'v*' + +jobs: + publish: + name: Publish + # Specify OS + runs-on: ubuntu-latest + + steps: + - name: Checkout sources + uses: actions/checkout@v4 + + - name: Install Rust Toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Publish Crate + run: cargo publish --token ${CRATES_TOKEN} + env: + CRATES_TOKEN: ${{ secrets.CRATES_TOKEN }} \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..b2115bf --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "bevy-discord" +description = "A bevy plugin that can send messages to discord." +version = "0.2.0-alpha.1" +edition = "2021" +authors = ["Aditya Kumar <117935160+AS1100K@users.noreply.github.com>"] +readme = "README.md" +repository = "https://github.com/AS1100K/aether/tree/main/plugins/discord" +publish = true +license = "GPL-3.0-only" +keywords = ["bevy", "plugin", "discord"] + +[dependencies] +bevy_app = "0.13.2" +bevy_ecs = "0.13.2" +reqwest = { version = "0.12.5", features = ["json", "rustls-tls"]} +serde = { version = "1.0.203", features = ["derive"] } +tracing = "0.1.40" +tokio = { version = "1.38.0", features = ["rt-multi-thread", "rt"] } +serde_json = { version = "1.0.117" } + +[dev-dependencies] +anyhow = "1.0.86" +azalea = "0.9.1" \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d994857 --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# Bevy Discord Plugin + +![GitHub License](https://img.shields.io/github/license/AS1100K/bevy-discord) +![Crates.io Version](https://img.shields.io/crates/v/bevy-discord) +![CI](https://github.com/as1100k/bevy-discord/actions/workflows/ci.yml/badge.svg?event=push) + + +A very simple, bevy plugin that let you send messages through discord webhooks. _In Future releases, this plugin will support +discord applications & bots and can send & receive messages by them._ + +## Example +This example is shown inside azalea, but this plugin can be used with any bevy app. + +```rust,no_run +use azalea::prelude::*; +use azalea::Vec3; +use bevy_discord::common::DiscordMessage; +use bevy_discord::webhook::{DiscordWebhookPlugin, DiscordWebhookRes, SendMessageEvent}; + +#[tokio::main] +async fn main() { + let account = Account::offline("_aether"); + + let discord_webhook = DiscordWebhookRes::new() + .add_channel( + "channel_name", + "webhook_url", + "", + "" + ); + ClientBuilder::new() + .set_handler(handle) + .add_plugins(DiscordWebhookPlugin::new(discord_webhook)) + .start(account, "localhost") + .await + .unwrap(); +} + +#[derive(Default, Clone, Component)] +pub struct State {} + +async fn handle(bot: Client, event: Event, _state: State) -> anyhow::Result<()> { + match event { + Event::Chat(m) => { + let content = m.message(); + println!("{}", &content.to_ansi()); + let message = DiscordMessage::new() + .content(content.to_string()); + + bot.ecs.lock().send_event(SendMessageEvent::new("channel_name", message)); + } + _ => {} + } + + Ok(()) +} +``` diff --git a/src/common.rs b/src/common.rs new file mode 100644 index 0000000..60e134f --- /dev/null +++ b/src/common.rs @@ -0,0 +1,298 @@ +use serde::Serialize; +#[macro_export] +macro_rules! new { + () => { + #[must_use] + #[doc = "Creates a new empty struct."] + pub fn new() -> Self { + Self::default() + } + }; +} + +#[macro_export] +macro_rules! override_field { + ($name:ident, $type:ty) => { + #[doc = concat!("Adds `", stringify!($name), "` field.")] + pub fn $name(mut self, $name: $type) -> Self { + self.$name = Some($name); + self + } + }; +} + +#[macro_export] +macro_rules! initialize_field { + ($name:ident, $type:ty) => { + #[doc = concat!("Adds `", stringify!($name), "` field.")] + pub fn $name(mut self, $name: $type) -> Self { + self.$name = $name; + self + } + }; +} + +/// Representation of discord message +#[derive(Default, Serialize, Clone)] +pub struct DiscordMessage { + /// the message contents (up to 2000 characters) + pub content: String, + /// override the default username of the webhook + pub username: Option, + /// override the default avatar of the webhook + pub avatar_url: Option, + /// true if this is a TTS message + pub tts: Option, + /// embedded [rich](DiscordEmbedType::Rich) content (upto 10 embeds) + pub embeds: Option>, + /// allowed mentions for the message + pub allowed_mentions: Option, + /// the components to include with the message + pub components: Option>, + // attachment,files[n], payload_json, flags, thread_name, applied_tags, poll isn't supported yet +} + +impl DiscordMessage { + new!(); + initialize_field!(content, String); + override_field!(username, String); + override_field!(avatar_url, String); + override_field!(tts, bool); + override_field!(embeds, Vec); + override_field!(allowed_mentions, DiscordAllowedMentions); + override_field!(components, Vec); +} + +#[derive(Default, Serialize, Clone)] +pub struct DiscordEmbed { + /// title of embed + pub title: Option, + // field "type" isn't allowed + /// [type of embed](DiscordEmbedType) (always ["rich"](DiscordEmbedType::Rich) for webhook embeds) + pub r#type: DiscordEmbedType, + /// description of embed + pub description: Option, + /// url of embed + pub url: Option, + // timestamp is optional, so ignoring it + // pub timestamp: Option<> + /// color code of the embed, use [DiscordEmbed::color] to use hex code + pub color: Option, + /// footer information + pub footer: Option, + /// image information + pub image: Option, + /// thumbnail information [DiscordImageEmbed] is also used for it + pub thumbnail: Option, + /// video information [DiscordImageEmbed] is also used for it + pub video: Option, + /// provider information + pub provider: Option, + /// fields information, max of 25 + pub fields: Option>, + /// author information + pub author: Option +} + +impl DiscordEmbed { + new!(); + override_field!(title, String); + override_field!(description, String); + override_field!(url, String); + override_field!(footer, DiscordFooterEmbed); + override_field!(image, DiscordImageEmbed); + override_field!(thumbnail, DiscordImageEmbed); + override_field!(video, DiscordImageEmbed); + override_field!(provider, DiscordProviderEmbed); + override_field!(fields, Vec); + override_field!(author, DiscordAuthorEmbed); + initialize_field!(r#type, DiscordEmbedType); + + /// color should be hex value without `#` + pub fn color(mut self, color: &str) -> Self { + let parsed_color: i64 = i64::from_str_radix(color, 16).expect("Unable to parse color code"); + + self.color = Some(parsed_color); + self + } +} + +#[derive(Default, Serialize, Clone)] +pub enum DiscordEmbedType { + /// generic embed rendered from embed attributes + #[default] + Rich, + /// image embed + Image, + /// video embed + Video, + /// animated gif image embed rendered as a video embed + GIFV, + /// article embed + Article, + /// link embed + Link +} + +#[derive(Default, Serialize, Clone)] +pub struct DiscordFooterEmbed { + /// footer text + pub text: String, + /// url of footer icon (only supports http(s) and attachments) + pub icon_url: Option, + /// a proxied url of footer icon + pub proxy_icon_url: Option +} + +impl DiscordFooterEmbed { + new!(); + initialize_field!(text, String); + override_field!(icon_url, String); + override_field!(proxy_icon_url, String); +} + +#[derive(Default, Serialize, Clone)] +pub struct DiscordFieldEmbed { + /// name of the field + pub name: String, + /// value of the field + pub value: String, + /// whether or not this field should display inline + pub inline: Option +} + +impl DiscordFieldEmbed { + new!(); + initialize_field!(name, String); + initialize_field!(value, String); + override_field!(inline, bool); +} + +#[derive(Default, Serialize, Clone)] +pub struct DiscordImageEmbed { + /// source url of image (only supports http(s) and attachments) + pub url: String, + /// a proxied url of the image + pub proxy_url: Option, + /// height of image + pub height: Option, + /// width of image + pub width: Option +} + +impl DiscordImageEmbed { + new!(); + initialize_field!(url, String); + override_field!(proxy_url, String); + override_field!(height, i64); + override_field!(width, i64); +} + +#[derive(Default, Serialize, Clone)] +pub struct DiscordProviderEmbed { + /// name of provider + pub name: Option, + /// url of provider + pub url: Option +} + +impl DiscordProviderEmbed { + new!(); + override_field!(name, String); + override_field!(url, String); +} + +#[derive(Default, Serialize, Clone)] +pub struct DiscordAuthorEmbed { + /// name of author + pub name: String, + /// url of author (only supports http(s)) + pub url: Option, + /// url of author icon (only supports http(s) and attachments) + pub icon_url: Option, + /// a proxied url of author icon + pub proxy_icon_url: Option +} + +impl DiscordAuthorEmbed { + new!(); + initialize_field!(name, String); + override_field!(url, String); + override_field!(icon_url, String); + override_field!(proxy_icon_url, String); +} + +#[derive(Default, Serialize, Clone)] +pub struct DiscordAllowedMentions { + /// An array of [allowed mention types](DiscordAllowedMentionTypes) to parse from the content. + pub parse: Option>, + /// Array of role_ids to mention (Max size of 100) + pub roles: Option>, + /// Array of role_ids to mention (Max size of 100) + pub users: Option>, + /// For replies, whether to mention the author of the message being replied to (default false) + pub replied_user: bool +} + +impl DiscordAllowedMentions { + new!(); + override_field!(parse, Vec); + override_field!(roles, Vec); + override_field!(users, Vec); + initialize_field!(replied_user, bool); +} + +#[derive(Serialize, Clone)] +pub enum DiscordAllowedMentionTypes { + /// Controls role mentions + Roles, + /// Controls user mentions + Users, + /// Controls `@everyone` and `@here` mentions + Everyone +} + +#[derive(Default, Serialize, Clone)] +/// See to +/// learn more +pub struct DiscordMessageComponent { + pub r#type: u8, + pub label: Option, + pub style: Option, + pub custom_id: Option, + pub components: Option> +} + +impl DiscordMessageComponent { + new!(); + override_field!(label, String); + override_field!(style, String); + override_field!(custom_id, String); + override_field!(components, Vec); + + pub fn r#type(mut self, r#type: DiscordMessageComponentTypes) -> Self { + self.r#type = r#type as u8; + self + } +} + +#[derive(Default, Serialize)] +pub enum DiscordMessageComponentTypes { + /// Container for other components + ActionRow = 1, + /// Button object + #[default] + Button = 2, + /// Select menu for picking from defined text options + StringSelect = 3, + /// Text input object + TextInput = 4, + /// Select menu for users + UserSelect = 5, + /// Select menu for roles + RoleSelect = 6, + /// Select menu for mentionables (users and roles) + MentionableSelect = 7, + /// Select menu for channels + ChannelSelect = 8 +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..399bed0 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,11 @@ +#![doc = include_str!("../README.md")] + +use bevy_ecs::schedule::SystemSet; + +pub mod webhook; +pub mod common; +mod runtime; + +/// Bevy [`SystemSet`] that contains all system of this plugin. +#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] +pub struct DiscordSet; \ No newline at end of file diff --git a/src/runtime.rs b/src/runtime.rs new file mode 100644 index 0000000..8ae8f67 --- /dev/null +++ b/src/runtime.rs @@ -0,0 +1,9 @@ +use std::sync::OnceLock; +use tokio::runtime::Runtime; + +pub(crate) fn tokio_runtime() -> &'static Runtime { + static RUNTIME: OnceLock = OnceLock::new(); + RUNTIME.get_or_init(|| { + Runtime::new().expect("Setting up tokio runtime needs to succeed.") + }) +} \ No newline at end of file diff --git a/src/webhook/mod.rs b/src/webhook/mod.rs new file mode 100644 index 0000000..4d7f325 --- /dev/null +++ b/src/webhook/mod.rs @@ -0,0 +1,123 @@ +use crate::common::DiscordMessage; +use crate::runtime::tokio_runtime; +use crate::DiscordSet; +use bevy_app::prelude::*; +use bevy_ecs::prelude::*; +use reqwest::StatusCode; +use std::collections::HashMap; +use tracing::{error, trace}; + +#[derive(Clone)] +pub struct DiscordWebhookPlugin(DiscordWebhookRes); + +#[derive(Resource, Clone, Default)] +pub struct DiscordWebhookRes { + channels: HashMap<&'static str, Channel<'static>>, +} + +#[derive(Clone)] +pub struct Channel<'a> { + /// Prefix in every message of this channel. Mainly you would use mention here, like `@everyone` + /// NOTE: When text are joined with prefix, it automatically adds a space. + pub message_prefix: &'a str, + /// Similar to `Channel::message_prefix` but at the end of the message. + pub message_suffix: &'a str, + pub webhook_url: &'a str, +} + +impl DiscordWebhookPlugin { + /// Create a new discord Plugin + pub fn new(discord_webhook_res: DiscordWebhookRes) -> Self { + Self(discord_webhook_res) + } +} + +/// Discord Plugin Resource +impl DiscordWebhookRes { + pub fn new() -> Self { + Self { + channels: HashMap::new(), + } + } + + /// Adds a new channel + /// To know more about its fields see, [`Channel`] + pub fn add_channel( + mut self, + name_identifier: &'static str, + webhook_url: &'static str, + message_prefix: &'static str, + message_suffix: &'static str, + ) -> Self { + self.channels.insert( + name_identifier, + Channel { + message_prefix, + message_suffix, + webhook_url, + }, + ); + self + } +} + +impl Plugin for DiscordWebhookPlugin { + fn build(&self, app: &mut App) { + app.insert_resource(self.0.clone()) + .add_event::() + .add_systems(Update, handle_send_message.in_set(DiscordSet)); + } +} + +/// Sending this event will send a message on the channel. +#[derive(Event, Clone)] +pub struct SendMessageEvent { + name_identifier: &'static str, + message: DiscordMessage, +} + +impl SendMessageEvent { + /// Create a new [`SendMessageEvent`] + pub fn new(name_identifier: &'static str, message: DiscordMessage) -> Self { + Self { + name_identifier, + message, + } + } +} + +fn handle_send_message( + mut events: EventReader, + discord_webhook_res: Res, +) { + for event in events.read() { + if let Some(channel) = discord_webhook_res.channels.get(event.name_identifier) { + let channel_clone = channel.clone(); + let event_clone = event.clone(); + + tokio_runtime().spawn(async move { + let client = reqwest::Client::new(); + trace!("body => {:?}", serde_json::to_string(&event_clone.message)); + + let res = client.post(channel_clone.webhook_url) + .query(&[("wait", true)]) + .json(&event_clone.message) + .send() + .await; + + match res { + Ok(response) => { + if response.status() != StatusCode::OK { + error!("Got response code {}. The message might contains problem in body. Make sure messages are compliant with discord webhook API. Learn more at https://discord.com/developers/docs/resources/webhook#execute-webhook", response.status()) + } + } + Err(err) => { + error!("Unable to send message to discord webhook, error => {:?}", err.without_url()) + } + } + }); + } else { + error!("Unable to find discord channel."); + } + } +}