First Version
This commit is contained in:
commit
5619a27101
12 changed files with 443 additions and 0 deletions
2
.dockerignore
Normal file
2
.dockerignore
Normal file
|
@ -0,0 +1,2 @@
|
|||
.github
|
||||
target
|
59
.github/workflows/main.yml
vendored
Normal file
59
.github/workflows/main.yml
vendored
Normal file
|
@ -0,0 +1,59 @@
|
|||
name: Docker Build and Push with Version
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- action-test
|
||||
|
||||
env:
|
||||
DOCKER_REGISTRY: r.regnault.dev
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
PORTAINER_API_WEBHOOK: ${{ secrets.PORTAINER_API_WEBHOOK }}
|
||||
|
||||
jobs:
|
||||
build_and_push:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ env.DOCKER_USERNAME }}
|
||||
password: ${{ env.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Install cargo-semver
|
||||
uses: actions-rs/install@v0.1.2
|
||||
with:
|
||||
crate: cargo-get
|
||||
version: latest
|
||||
|
||||
- name: Semver
|
||||
run:
|
||||
echo "VERSION=$(cargo get package.version --pretty)" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.DOCKER_REGISTRY }}/captchuccino:latest
|
||||
${{ env.DOCKER_REGISTRY }}/captchuccino:${{ env.VERSION }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Deploy to production
|
||||
uses: fjogeleit/http-request-action@v1.14.1
|
||||
with:
|
||||
url: ${{ format('{0}?TAG={1}',env.PORTAINER_API_WEBHOOK, env.VERSION) }}
|
||||
method: 'POST'
|
||||
preventFailureOnNoResponse: true
|
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/target
|
21
Cargo.toml
Normal file
21
Cargo.toml
Normal file
|
@ -0,0 +1,21 @@
|
|||
[package]
|
||||
name = "captchuccino"
|
||||
version = "1.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
captcha-rs="0.2.10"
|
||||
serenity = { version="0.11", default-features = false, features = ["client", "gateway", "rustls_backend", "model"] }
|
||||
tokio = { version = "1.21.2", features = ["macros", "rt-multi-thread"] }
|
||||
image = "0.24.7"
|
||||
r_i18n = "1.0.1"
|
||||
|
||||
[dependencies.uuid]
|
||||
version = "1.6.1"
|
||||
features = [
|
||||
"v4", # Lets you generate random UUIDs
|
||||
"fast-rng", # Use a faster (but still sufficiently random) RNG
|
||||
"macro-diagnostics", # Enable better diagnostics for compile-time UUIDs
|
||||
]
|
22
Dockerfile
Normal file
22
Dockerfile
Normal file
|
@ -0,0 +1,22 @@
|
|||
FROM rust:slim-bookworm AS base
|
||||
RUN apt-get update
|
||||
RUN apt-get install libssl-dev pkg-config -y
|
||||
RUN cargo install cargo-chef
|
||||
WORKDIR app
|
||||
|
||||
FROM base AS planner
|
||||
COPY . .
|
||||
RUN cargo chef prepare --recipe-path recipe.json
|
||||
|
||||
FROM base AS builder
|
||||
COPY --from=planner /app/recipe.json recipe.json
|
||||
RUN cargo chef cook --release --recipe-path recipe.json
|
||||
COPY . .
|
||||
RUN cargo build --release --bin captchuccino
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install ca-certificates libssl3 -y && apt-get clean autoclean && apt-get autoremove --yes && rm -rf /var/lib/{apt,dpkg,cache,log}/
|
||||
WORKDIR /root/
|
||||
COPY --from=builder /app/target/release/captchuccino .
|
||||
COPY translations/ translations/
|
||||
CMD ["./captchuccino"]
|
36
README.md
Normal file
36
README.md
Normal file
|
@ -0,0 +1,36 @@
|
|||
|
||||
# Captchuccino
|
||||
|
||||
A Self-Hosted Discord bot that adds a captcha to gatekeep a discord server
|
||||
|
||||
## Environment Variables
|
||||
|
||||
To run this project, you will need to add the following environment variables to your .env file
|
||||
|
||||
`DISCORD_TOKEN` : The token you'll use to run this bot.
|
||||
|
||||
`LANG` : The locale that the bot will use, either `fr` or `en` for now.
|
||||
|
||||
`GUILD_ID` : The ID of the server the bot will run in.
|
||||
|
||||
`ROLE_ID` : The ID of the Unverified role on your server.
|
||||
|
||||
## Deployment
|
||||
|
||||
> ⚠️ The bot needs to have the SERVER MEMBERS INTENT enabled in the developper dashboard.
|
||||
|
||||
Get the docker image from [my registry](https://registry.regnault.dev)
|
||||
```bash
|
||||
docker pull r.regnault.dev/captchuccino:latest
|
||||
```
|
||||
|
||||
Launch the docker image with environment variables
|
||||
|
||||
```bash
|
||||
docker run r.regnault.dev/captchuccino:latest \
|
||||
-e DISCORD_TOKEN=<TOKEN> \
|
||||
-e LANG=<LANG> \
|
||||
-e GUILD_ID=<GUILD_ID> \
|
||||
-e ROLE_ID=<ROLE_ID>
|
||||
```
|
||||
|
185
src/main.rs
Normal file
185
src/main.rs
Normal file
|
@ -0,0 +1,185 @@
|
|||
mod utils;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::fs::remove_file;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use captcha_rs::Captcha;
|
||||
use tokio::fs::{create_dir, File, try_exists};
|
||||
use image::ImageFormat;
|
||||
use serenity::{async_trait, Client};
|
||||
use serenity::client::Context;
|
||||
use serenity::model::channel::{Message};
|
||||
use serenity::model::gateway::Ready;
|
||||
use serenity::model::guild::Member;
|
||||
use serenity::model::id::{RoleId, UserId};
|
||||
use serenity::model::prelude::Guild;
|
||||
use serenity::prelude::{EventHandler, GatewayIntents, TypeMapKey};
|
||||
use crate::utils::captcha_builder::build_captcha;
|
||||
use crate::utils::i18n::{get_translation, get_env_error_message, get_server_message};
|
||||
|
||||
struct Handler;
|
||||
|
||||
struct CaptchaData;
|
||||
|
||||
impl TypeMapKey for CaptchaData {
|
||||
type Value = Arc<RwLock<HashMap<UserId, String>>>;
|
||||
}
|
||||
|
||||
async fn get_lock(_ctx: &Context) -> Arc<RwLock<HashMap<UserId, String>>> {
|
||||
let captcha_data = _ctx.data.read().await;
|
||||
|
||||
captcha_data.get::<CaptchaData>()
|
||||
.expect(get_translation("captcha-get-data-error").as_str()).clone()
|
||||
}
|
||||
|
||||
async fn create_captcha(_ctx: &Context, user_id: UserId) -> Captcha {
|
||||
let captcha = build_captcha();
|
||||
|
||||
let captcha_data_lock = get_lock(&_ctx).await;
|
||||
{
|
||||
let mut data = captcha_data_lock.write()
|
||||
.expect(get_translation("mutex-lock-error").as_str());
|
||||
|
||||
let clone_captcha = captcha.text.clone().to_owned();
|
||||
let entry = data.entry(user_id).or_insert(clone_captcha.clone());
|
||||
*entry = clone_captcha;
|
||||
};
|
||||
|
||||
captcha
|
||||
}
|
||||
|
||||
async fn send_captcha(_ctx: &Context, _new_member: Member, msg: &str) {
|
||||
let private_channel = _new_member.user.create_dm_channel(&_ctx).await;
|
||||
let captcha = create_captcha(&_ctx, _new_member.user.id).await;
|
||||
|
||||
let file_name_string = format!("captcha/{}.jpg", uuid::Uuid::new_v4());
|
||||
let file_name = file_name_string.as_str();
|
||||
captcha.image.save_with_format(&file_name, ImageFormat::Jpeg)
|
||||
.expect(get_translation("save-image-error").as_str());
|
||||
|
||||
if let Ok(channel) = private_channel {
|
||||
let file = File::open(&file_name).await
|
||||
.expect(get_translation("open-image-error").as_str());
|
||||
|
||||
let files = vec![(&file, file_name)];
|
||||
|
||||
channel.send_files(&_ctx, files, |m| m.content(msg)).await
|
||||
.expect(get_translation("server-cantsendmessage-error").as_str());
|
||||
|
||||
remove_file(file_name_string.as_str())
|
||||
.expect(get_translation("delete-image-error").as_str());
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EventHandler for Handler {
|
||||
async fn guild_member_addition(&self, _ctx: Context, mut _new_member: Member) {
|
||||
_new_member.add_role(&_ctx, &get_role_id()).await
|
||||
.expect(get_translation("server-cantaddrole-error").as_str());
|
||||
|
||||
let guild = Guild::get(&_ctx, get_guild_id()).await
|
||||
.expect(get_translation("server-cantgetguild-error").as_str());
|
||||
|
||||
send_captcha(&_ctx, _new_member,
|
||||
get_server_message(
|
||||
"server-captcha-prompt",
|
||||
guild.name.as_str()
|
||||
).as_str()
|
||||
).await;
|
||||
}
|
||||
|
||||
async fn message(&self, _ctx: Context, _new_message: Message) {
|
||||
let guild = Guild::get(&_ctx, get_guild_id()).await
|
||||
.expect(get_translation("server-cantgetguild-error").as_str());
|
||||
match guild.member(&_ctx, _new_message.author.id).await {
|
||||
Ok(member) => {
|
||||
if !member.roles.contains(&get_role_id()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(_) => { return; }
|
||||
}
|
||||
|
||||
|
||||
let mut member = guild.member(&_ctx, _new_message.author.id).await
|
||||
.expect(get_translation("server-cantgetmember-error").as_str());
|
||||
|
||||
if let Some(channel) = _new_message.channel(&_ctx).await
|
||||
.expect(get_translation("server-cantgetchannel-error").as_str()).private()
|
||||
{
|
||||
let captcha_data_lock = get_lock(&_ctx).await;
|
||||
|
||||
let res = {
|
||||
let mut data = captcha_data_lock.write()
|
||||
.expect(get_translation("mutex-lock-error").as_str());
|
||||
|
||||
match data.get(&member.user.id) {
|
||||
None => None,
|
||||
Some(captcha_text) => {
|
||||
let res = captcha_text.eq(&_new_message.content);
|
||||
if res {
|
||||
data.remove(&member.user.id)
|
||||
.expect(get_translation("captcha-delete-data-error").as_str());
|
||||
}
|
||||
Some(res)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match res {
|
||||
Some(true) => {
|
||||
member.remove_role(&_ctx, &get_role_id()).await
|
||||
.expect(get_translation("server-cantremoverole-error").as_str());
|
||||
|
||||
channel.send_message(&_ctx, |m|
|
||||
m.content(get_server_message("server-captcha-validated", guild.name.as_str()))
|
||||
).await
|
||||
.expect(get_translation("server-cantsendmessage-error").as_str());
|
||||
},
|
||||
_ => {
|
||||
send_captcha(&_ctx, member, get_translation("server-captcha-incorrect").as_str()).await
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async fn ready(&self, _ctx: Context, _data_about_bot: Ready) {
|
||||
println!("{}", get_translation("bot-started"));
|
||||
}
|
||||
}
|
||||
fn get_role_id() -> RoleId {
|
||||
let role_id = env::var("ROLE_ID").expect(get_env_error_message("ROLE_ID").as_str());
|
||||
RoleId::from(role_id.parse::<u64>().expect("Cannot parse ROLE_ID to int"))
|
||||
}
|
||||
|
||||
fn get_guild_id() -> u64 {
|
||||
let guild_id = env::var("GUILD_ID").expect(get_env_error_message("GUILD_ID").as_str());
|
||||
guild_id.parse::<u64>().expect("Cannot parse GUILD_ID to int")
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let token = env::var("DISCORD_TOKEN").expect(get_env_error_message("DISCORD_TOKEN").as_str());
|
||||
|
||||
if !try_exists("captcha").await.expect(get_translation("exist-dir-error").as_str()) {
|
||||
create_dir("captcha").await.expect(get_translation("create-dir-error").as_str());
|
||||
}
|
||||
|
||||
|
||||
let intents = GatewayIntents::DIRECT_MESSAGES
|
||||
| GatewayIntents::GUILD_MEMBERS;
|
||||
|
||||
let mut client = Client::builder(&token, intents)
|
||||
.event_handler(Handler).await.expect(get_translation("client-build-error").as_str());
|
||||
|
||||
{
|
||||
let mut data = client.data.write().await;
|
||||
data.insert::<CaptchaData>(Arc::new(RwLock::new(HashMap::default())));
|
||||
}
|
||||
|
||||
|
||||
if let Err(why) = client.start().await {
|
||||
println!("{}: {why:?}", get_translation("bot-error"))
|
||||
}
|
||||
}
|
12
src/utils/captcha_builder.rs
Normal file
12
src/utils/captcha_builder.rs
Normal file
|
@ -0,0 +1,12 @@
|
|||
use captcha_rs::{Captcha, CaptchaBuilder};
|
||||
|
||||
pub fn build_captcha() -> Captcha {
|
||||
CaptchaBuilder::new()
|
||||
.length(5)
|
||||
.width(200)
|
||||
.height(100)
|
||||
.dark_mode(true)
|
||||
.complexity(5)
|
||||
.compression(40)
|
||||
.build()
|
||||
}
|
38
src/utils/i18n.rs
Normal file
38
src/utils/i18n.rs
Normal file
|
@ -0,0 +1,38 @@
|
|||
use std::env;
|
||||
use r_i18n::{I18n, I18nConfig};
|
||||
|
||||
|
||||
|
||||
pub fn get_translation(translation_key: &str) -> String {
|
||||
let config: I18nConfig = I18nConfig{
|
||||
locales: &["en", "fr"],
|
||||
directory: "translations"
|
||||
};
|
||||
let mut r_i18n = I18n::configure(&config);
|
||||
let locale = env::var("LANG")
|
||||
.expect(get_env_error_message("LANG").as_str());
|
||||
|
||||
r_i18n.set_current_lang(locale.as_str());
|
||||
r_i18n.t(translation_key).to_string()
|
||||
}
|
||||
|
||||
pub fn get_server_message(translation_key: &str, server_name: &str) -> String {
|
||||
get_translation(translation_key).replace("SERVER_NAME", server_name)
|
||||
}
|
||||
|
||||
pub fn get_env_error_message(env_name: &str) -> String {
|
||||
let config: I18nConfig = I18nConfig{
|
||||
locales: &["en", "fr"],
|
||||
directory: "translations"
|
||||
};
|
||||
let mut r_i18n = I18n::configure(&config);
|
||||
let locale = env::var("LANG")
|
||||
.expect("LANG ENV NOT SET");
|
||||
|
||||
r_i18n.set_current_lang(locale.as_str());
|
||||
let error_msg = r_i18n.t("env-error");
|
||||
match error_msg.to_string().contains("ENV_VAR") {
|
||||
true => {error_msg.to_string().replace("ENV_VAR", env_name)}
|
||||
false => panic!("Cannot find env-error")
|
||||
}
|
||||
}
|
3
src/utils/mod.rs
Normal file
3
src/utils/mod.rs
Normal file
|
@ -0,0 +1,3 @@
|
|||
pub mod captcha_builder;
|
||||
pub mod i18n;
|
||||
|
32
translations/en.json
Normal file
32
translations/en.json
Normal file
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"captcha-get-data-error": "Cannot get the captchas data",
|
||||
"captcha-delete-data-error": "Cannot delete the captcha data",
|
||||
|
||||
"env-error": "The ENV_VAR environment variable is not set",
|
||||
"translation-error": "The 'TRANSLATION_KEY' translation key doesn't exist for the LOCALE locale",
|
||||
|
||||
"client-build-error": "Cannot create the bot",
|
||||
"bot-error": "The bot errored while starting",
|
||||
"bot-started": "The bot started !",
|
||||
|
||||
"exist-dir-error": "Cannot test if the directory exist",
|
||||
"create-dir-error": "Cannot create directory",
|
||||
|
||||
"save-image-error": "Cannot save the image",
|
||||
"open-image-error": "Cannot open the image",
|
||||
"delete-image-error": "Cannot delete the image",
|
||||
|
||||
"mutex-lock-error": "Cannot get the Mutex Lock",
|
||||
|
||||
|
||||
"server-captcha-validated": "The captcha has been validated !\nWelcome to SERVER_NAME :sparkles:",
|
||||
"server-captcha-incorrect": "The captcha is incorrect, please try again.",
|
||||
"server-captcha-prompt": "Please complete the captcha below in order to join SERVER_NAME !",
|
||||
|
||||
"server-cantgetmember-error": "Cannot get the member",
|
||||
"server-cantgetguild-error": "Cannot get the server",
|
||||
"server-cantaddrole-error": "Cannot add a role",
|
||||
"server-cantremoverole-error": "Cannot remove a role",
|
||||
"server-cantgetchannel-error": "Cannot get the channel",
|
||||
"server-cantsendmessage-error": "Cannot send a message"
|
||||
}
|
32
translations/fr.json
Normal file
32
translations/fr.json
Normal file
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"captcha-get-data-error": "Impossible de récuperer les données des captchas",
|
||||
"captcha-delete-data-error": "Impossible de supprimer les données du captcha",
|
||||
|
||||
"env-error": "La variable d'environnement ENV_VAR n'existe pas",
|
||||
"translation-error": "La clé de traduction 'TRANSLATION_KEY' n'existe pas pour la locale LOCALE",
|
||||
|
||||
"client-build-error": "Impossible de créer le bot",
|
||||
"bot-error": "Le bot n'a pas réussi a se lancer",
|
||||
"bot-started": "Le bot a démarré !",
|
||||
|
||||
"exist-dir-error": "Impossible de tester si le dossier existe",
|
||||
"create-dir-error": "Impossible de créer le dossier",
|
||||
|
||||
"save-image-error": "Impossible de sauvegarder l'image",
|
||||
"open-image-error": "Impossible d'ouvrir l'image",
|
||||
"delete-image-error": "Impossible de supprimer l'image",
|
||||
|
||||
"mutex-lock-error": "Impossible de récuperer le Lock du Mutex",
|
||||
|
||||
|
||||
"server-captcha-validated": "Le captcha a été validé !\nNous vous souhaitons la bienvenue sur SERVER_NAME :sparkles:",
|
||||
"server-captcha-incorrect": "Le captcha est invalide, veuillez réessayer.",
|
||||
"server-captcha-prompt": "Veuillez remplir le captcha ci-dessous pour rejoindre SERVER_NAME !",
|
||||
|
||||
"server-cantgetmember-error": "Impossible de récuperer le membre",
|
||||
"server-cantgetguild-error": "Impossible de récuperer le serveur",
|
||||
"server-cantaddrole-error": "Impossible d'ajouter un role",
|
||||
"server-cantremoverole-error": "Impossible de retirer un role",
|
||||
"server-cantgetchannel-error": "Impossible de récuperer le salon",
|
||||
"server-cantsendmessage-error": "Impossible d'envoyer un message"
|
||||
}
|
Loading…
Reference in a new issue