First Version

This commit is contained in:
Evann Regnault 2023-11-24 02:10:05 +01:00
commit 5619a27101
12 changed files with 443 additions and 0 deletions

2
.dockerignore Normal file
View file

@ -0,0 +1,2 @@
.github
target

59
.github/workflows/main.yml vendored Normal file
View 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
View file

@ -0,0 +1 @@
/target

21
Cargo.toml Normal file
View 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
View 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
View 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
View 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"))
}
}

View 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
View 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
View file

@ -0,0 +1,3 @@
pub mod captcha_builder;
pub mod i18n;

32
translations/en.json Normal file
View 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
View 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"
}