feat: initial commit

This commit is contained in:
oSumAtrIX 2022-07-06 06:04:37 +02:00
commit c07ad28a60
No known key found for this signature in database
GPG Key ID: A9B3094ACDB604B4
12 changed files with 1811 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/target
configuration.json

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "submodules/serenity"]
path = submodules/serenity
url = git@github.com:serenity-rs/serenity.git

41
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,41 @@
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "launch",
"name": "Debug executable 'revanced-discord-bot'",
"cargo": {
"args": [
"build",
"--bin=revanced-discord-bot",
"--package=revanced-discord-bot"
],
"filter": {
"name": "revanced-discord-bot",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug unit tests in executable 'revanced-discord-bot'",
"cargo": {
"args": [
"test",
"--no-run",
"--bin=revanced-discord-bot",
"--package=revanced-discord-bot"
],
"filter": {
"name": "revanced-discord-bot",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}"
}
]
}

1277
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

29
Cargo.toml Normal file
View File

@ -0,0 +1,29 @@
[package]
authors = ["oSumAtrIX"]
description = "The official Discord bot assisting the ReVanced Discord server"
homepage = "https://revanced.app"
keywords = ["ReVanced"]
license = "GPL-3.0"
name = "revanced-discord-bot"
repository = "https://github.com/revanced/revanced-discord-bot"
version = "0.1.0"
edition = "2021"
[profile.release]
strip = true
opt-level = 3
lto = true
codegen-units = 1
panic = "abort"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1.0", features = ["rt-multi-thread"] }
log = "0.4"
chrono = "0.4"
[dependencies.serenity]
default-features = false
features = ["client", "gateway", "rustls_backend", "model"]
path = "submodules/serenity" # submodule due to serenity just recently including global slash commands

View File

@ -0,0 +1,32 @@
{
"$schema": "./configuration.schema.json",
"discord-authorization-token": "",
"administrators": {
"roles": [0],
"users": [0]
},
"thread-introductions": [
{
"channels": [0],
"message": ""
}
],
"message-responders": [
{
"includes": {
"channels": [0],
"match": [""]
},
"excludes": {
"roles": [0],
"match": [""]
},
"condition": {
"user": {
"server-age": 2
}
},
"message": ""
}
]
}

136
configuration.schema.json Normal file
View File

@ -0,0 +1,136 @@
{
"$schema": "https://json-schema.org/draft-07/schema",
"$id": "https://github.com/ReVancedTeam/revanced-signatures/blob/main/signatures.schema.json",
"title": "Configuration schema",
"description": "The Revanced Discord bot configuration schema.",
"type": "object",
"required": ["discord-authorization-token"],
"properties": {
"discord-authorization-token": {
"type": "string",
"description": "The authorization token for the Discord bot."
},
"administrators": {
"type": "object",
"properties": {
"roles": {
"$ref": "#/$defs/roles",
"description": "A list of role ids. Users with these roles have administrative privileges over this Discord bot."
},
"users": {
"type": "array",
"items": {
"type": "integer"
},
"description": "A list of user ids. Users with these ids have administrative privileges over this Discord bot.",
"minItems": 1,
"uniqueItems": true
}
},
"description": "The list of administrators to control the Discord bot."
},
"thread-introductions": {
"type": "array",
"items": {
"type": "object",
"required": ["channels", "message"],
"properties": {
"channels": {
"$ref": "#/$defs/channels",
"description": "A list of channel ids. The bot will only introduce in threads under these channels."
},
"message": {
"$ref": "#/$defs/message",
"description": "The message to send when the thread has been created."
}
}
},
"description": "Introduce new threads with a message.",
"minItems": 1,
"uniqueItems": true
},
"message-responders": {
"type": "array",
"items": {
"type": "object",
"properties": {
"includes": {
"type": "object",
"channels": {
"$ref": "#/$defs/channels",
"description": "A list of channel ids. The bot will only respond to messages in these channels."
},
"match": {
"$ref": "#/$defs/match",
"description": "The message must match this regex to be responded to."
}
},
"excludes": {
"type": "object",
"roles": {
"$ref": "#/$defs/roles",
"description": "A list of role ids. The bot will not respond to messages from users with these roles."
},
"match": {
"$ref": "#/$defs/match",
"description": "Messages matching this regex will not be responded to."
}
},
"condition": {
"type": "object",
"properties": {
"user": {
"type": "object",
"properties": {
"server-age": {
"type": "integer",
"description": "The user must be at least this many days old on the server."
}
},
"description": "User condition."
}
},
"description": "The conditions to respond to the message."
},
"message": {
"$ref": "#/$defs/message",
"description": "The message to send when the message is responded to."
}
},
"description": "The conditions to respond to a message."
},
"description": "A list of responses the Discord bot should send based on given conditions."
}
},
"$defs": {
"roles": {
"type": "array",
"items": {
"type": "integer"
},
"description": "A list of role ids.",
"uniqueItems": true,
"minItems": 1
},
"channels": {
"type": "array",
"items": {
"type": "integer"
},
"uniqueItems": true,
"minItems": 1
},
"match": {
"type": "array",
"items": {
"type": "string"
},
"description": "A list of regex strings.",
"uniqueItems": true,
"minItems": 1
},
"message": {
"type": "string"
}
}
}

16
rustfmt.toml Normal file
View File

@ -0,0 +1,16 @@
edition = "2018"
match_block_trailing_comma = true
newline_style = "Unix"
use_field_init_shorthand = true
use_small_heuristics = "Max"
use_try_shorthand = true
hard_tabs = true
format_code_in_doc_comments = true
group_imports = "StdExternalCrate"
imports_granularity = "Module"
imports_layout = "HorizontalVertical"
match_arm_blocks = true
normalize_comments = true
overflow_delimited_expr = true
struct_lit_single_line = false

92
src/configuration.rs Normal file
View File

@ -0,0 +1,92 @@
use std::fs::File;
use std::io::{Error, Read, Write};
use serde::{Deserialize, Serialize};
#[derive(Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BotConfiguration {
#[serde(rename = "discord-authorization-token")]
pub discord_authorization_token: String,
pub administrators: Administrators,
#[serde(rename = "thread-introductions")]
pub thread_introductions: Option<Vec<Introduction>>,
#[serde(rename = "message-responders")]
pub message_responders: Option<Vec<MessageResponder>>,
}
#[derive(Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Administrators {
pub roles: Vec<u64>,
pub users: Option<Vec<u64>>,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Introduction {
pub channels: Vec<u64>,
pub message: String,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MessageResponder {
pub includes: Option<Includes>,
pub excludes: Option<Excludes>,
pub condition: Option<Condition>,
pub message: String,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Includes {
pub channels: Option<Vec<u64>>,
#[serde(rename = "match")]
pub match_field: Option<Vec<String>>,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Excludes {
pub roles: Option<Vec<u64>>,
#[serde(rename = "match")]
pub match_field: Option<Vec<String>>,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Condition {
pub user: User,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct User {
#[serde(rename = "server-age")]
pub server_age: Option<i16>,
}
impl BotConfiguration {
fn save(&self) -> Result<(), Error> {
let mut file = File::create("configuration.json")?;
let json = serde_json::to_string_pretty(&self)?;
file.write(json.as_bytes())?;
Ok(())
}
pub fn load() -> Result<BotConfiguration, Error> {
let mut file = match File::open("configuration.json") {
Ok(file) => file,
Err(_) => {
let configuration = BotConfiguration::default();
configuration.save()?;
return Ok(configuration);
},
};
let mut buf = String::new();
file.read_to_string(&mut buf)?;
Ok(serde_json::from_str(&buf)?)
}
}

17
src/logger/logging.rs Normal file
View File

@ -0,0 +1,17 @@
use log::{Level, Metadata, Record};
pub struct SimpleLogger;
impl log::Log for SimpleLogger {
fn enabled(&self, metadata: &Metadata) -> bool {
metadata.level() <= Level::Info
}
fn log(&self, record: &Record) {
if self.enabled(record.metadata()) {
println!("{} - {}", record.level(), record.args());
}
}
fn flush(&self) {}
}

1
src/logger/mod.rs Normal file
View File

@ -0,0 +1 @@
pub mod logging;

165
src/main.rs Normal file
View File

@ -0,0 +1,165 @@
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use chrono::{DateTime, Datelike, NaiveDateTime, Utc};
use configuration::BotConfiguration;
use log::{error, info, trace, LevelFilter};
use logger::logging::SimpleLogger;
use serenity::client::{Context, EventHandler};
use serenity::model::application::command::Command;
use serenity::model::channel::{GuildChannel, Message};
use serenity::model::gateway::Ready;
use serenity::model::prelude::interaction::application_command::CommandDataOptionValue;
use serenity::model::prelude::interaction::{Interaction, InteractionResponseType};
use serenity::model::Timestamp;
use serenity::prelude::{GatewayIntents, RwLock, TypeMapKey};
use serenity::{async_trait, Client};
mod configuration;
mod logger;
static LOGGER: SimpleLogger = SimpleLogger;
struct Configuration;
impl TypeMapKey for Configuration {
type Value = Arc<RwLock<BotConfiguration>>;
}
pub struct Handler;
async fn get_configuration_lock(ctx: &Context) -> Arc<RwLock<BotConfiguration>> {
ctx.data
.read()
.await
.get::<Configuration>()
.expect("Expected Configuration in TypeMap.")
.clone()
}
#[async_trait]
impl EventHandler for Handler {
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
trace!("Created an interaction: {:?}", interaction);
if let Interaction::ApplicationCommand(command) = interaction {
let content = match command.data.name.as_str() {
"reload" => {
trace!("{:?} reloading configuration.", command.user);
let configuration_lock = get_configuration_lock(&ctx).await;
let mut configuration = configuration_lock.write().await;
let new_config =
BotConfiguration::load().expect("Could not load configuration.");
configuration.administrators = new_config.administrators;
configuration.message_responders = new_config.message_responders;
configuration.thread_introductions = new_config.thread_introductions;
"Successfully reload configuration.".to_string()
},
_ => "Unknown command.".to_string(),
};
if let Err(why) = command
.create_interaction_response(&ctx.http, |response| {
response
.kind(InteractionResponseType::ChannelMessageWithSource)
.interaction_response_data(|message| message.content(content))
})
.await
{
error!("Cannot respond to slash command: {}", why);
}
}
}
async fn message(&self, ctx: Context, msg: Message) {
trace!("Received message: {}", msg.content);
let configuration_lock = get_configuration_lock(&ctx).await;
let configuration = configuration_lock.read().await;
if let Some(message_responders) = &configuration.message_responders {
if let Some(responder) = message_responders.iter().find(|responder| {
responder.includes.iter().any(|include| {
include.channels.iter().any(|channel| todo!("Implement inclusion check"))
}) && responder.excludes.iter().all(|exclude| todo!("Implement exclusion check"))
}) {
if let Some(condition) = &responder.condition {
let join_date = ctx
.http
.get_member(msg.guild_id.unwrap().0, msg.author.id.0)
.await
.unwrap()
.joined_at
.unwrap();
let member_age = Timestamp::now().unix_timestamp() - join_date.unix_timestamp();
if let Some(age) = condition.user.server_age {
todo!("Implement age check")
}
}
}
}
}
async fn thread_create(&self, ctx: Context, thread: GuildChannel) {
trace!("Thread created: {}", thread.name);
let configuration_lock = get_configuration_lock(&ctx).await;
let configuration = configuration_lock.read().await;
if let Some(introducers) = &configuration.thread_introductions {
if let Some(introducer) = introducers.iter().find(|introducer| {
introducer
.channels
.iter()
.any(|channel_id| *channel_id == thread.parent_id.unwrap().0)
}) {
if let Err(why) = thread.say(&ctx.http, &introducer.message).await {
error!("Error sending message: {:?}", why);
}
}
}
}
async fn ready(&self, ctx: Context, ready: Ready) {
info!("Connected as {}", ready.user.name);
Command::create_global_application_command(&ctx.http, |command| {
command.name("reload").description("Reloads the configuration.")
})
.await
.expect("Could not create command.");
}
}
#[tokio::main]
async fn main() {
log::set_logger(&LOGGER)
.map(|()| log::set_max_level(LevelFilter::Info))
.expect("Could not set logger.");
let configuration = BotConfiguration::load().expect("Failed to load configuration");
let mut client = Client::builder(
&configuration.discord_authorization_token,
GatewayIntents::GUILDS | GatewayIntents::GUILD_MESSAGES | GatewayIntents::MESSAGE_CONTENT,
)
.event_handler(Handler)
.await
.expect("Failed to create client");
client.data.write().await.insert::<Configuration>(Arc::new(RwLock::new(
BotConfiguration::load().expect("Failed to load configuration"),
)));
if let Err(why) = client.start().await {
error!("{:?}", why);
} else {
info!("Client started.");
}
}