chore: bump to 1.0.1 #21

Merged
phundrak merged 8 commits from develop into main 2024-01-18 22:25:35 +00:00
23 changed files with 1036 additions and 532 deletions

32
.dockerignore Normal file
View File

@ -0,0 +1,32 @@
# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/go/build-context-dockerignore/
**/.DS_Store
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/charts
**/docker-compose*
**/compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/secrets.dev.yaml
**/values.dev.yaml
/bin
/target
LICENSE
README.md

View File

@ -1,2 +1 @@
DISCORD_TOKEN=changeme
DATABASE_URL=sqlite:p4bl0t.db

1
.gitignore vendored
View File

@ -1,4 +1,3 @@
/target
/.env
*.db
/.sqlx/

View File

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\nINSERT INTO guild_log_channels (guild_id, channel_id)\nVALUES ( ?1, ?2 )",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "5b44991d1514160fa00572e398f0577ad44f839a0470f9eeb89da8b5e77f0e03"
}

View File

@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "\nSELECT channel_id\nFROM guild_log_channels\nWHERE guild_id = ?1",
"describe": {
"columns": [
{
"name": "channel_id",
"ordinal": 0,
"type_info": "Int64"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "8444f7b7452a5ace6352aef943274f8a345a958257d896c7658b7700557959ab"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\nDELETE FROM guild_log_channels\nWHERE guild_id = ?1 AND channel_id = ?2",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "d6e9f422d6ae29a00658f55165018119d1e13d407266440415dfcc17a97ba00e"
}

972
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package]
name = "p4bl0t"
version = "1.0.0"
version = "1.0.1"
edition = "2021"
authors = ["Lucien Cartier-Tilet <lucien@phundrak.com>"]
license-file = "LICENSE.md"
@ -10,12 +10,12 @@ homepage = "https://github.com/phundrak/p4bl0t"
repository = "https://github.com/phundrak/p4bl0t"
keywords = ["discord", "bot", "logging"]
publish = false
build = "build.rs"
[dependencies]
color-eyre = "0.6.2"
dotenvy = "0.15.7"
poise = { version = "0.5.7" }
sqlx = { version = "0.7.2", features = ["sqlite", "tls-rustls", "runtime-tokio-rustls"] }
poise = { version = "0.6.1" }
sqlx = { version = "0.7.3", features = ["sqlite", "tls-rustls", "runtime-tokio-rustls"] }
tokio = { version = "1.34.0", features = ["macros", "rt-multi-thread"] }
tracing = "0.1.40"
tracing-subscriber = "0.3.18"

93
Dockerfile Normal file
View File

@ -0,0 +1,93 @@
# syntax=docker/dockerfile:1
# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Dockerfile reference guide at
# https://docs.docker.com/go/dockerfile-reference/
ARG RUST_VERSION=1.73.0
ARG APP_NAME=p4bl0t
################################################################################
# xx is a helper for cross-compilation.
# See https://github.com/tonistiigi/xx/ for more information.
FROM --platform=$BUILDPLATFORM tonistiigi/xx:1.3.0 AS xx
################################################################################
# Create a stage for building the application.
FROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-alpine AS build
ARG APP_NAME
WORKDIR /app
# Copy cross compilation utilities from the xx stage.
COPY --from=xx / /
# Install host build dependencies.
RUN apk add --no-cache clang lld musl-dev git file
# This is the architecture youre building for, which is passed in by the builder.
# Placing it here allows the previous steps to be cached across architectures.
ARG TARGETPLATFORM
# Install cross compilation build dependencies.
RUN xx-apk add --no-cache musl-dev gcc
# Build the application.
# Leverage a cache mount to /usr/local/cargo/registry/
# for downloaded dependencies, a cache mount to /usr/local/cargo/git/db
# for git repository dependencies, and a cache mount to /app/target/ for
# compiled dependencies which will speed up subsequent builds.
# Leverage a bind mount to the src directory to avoid having to copy the
# source code into the container. Once built, copy the executable to an
# output directory before the cache mounted /app/target is unmounted.
RUN --mount=type=bind,source=src,target=src \
--mount=type=bind,source=Cargo.toml,target=Cargo.toml \
--mount=type=bind,source=Cargo.lock,target=Cargo.lock \
--mount=type=bind,source=build.rs,target=build.rs \
--mount=type=bind,source=.sqlx,target=.sqlx \
--mount=type=bind,source=migrations,target=migrations \
--mount=type=cache,target=/app/target/,id=rust-cache-${APP_NAME}-${TARGETPLATFORM} \
--mount=type=cache,target=/usr/local/cargo/git/db \
--mount=type=cache,target=/usr/local/cargo/registry/ \
<<EOF
set -e
# xx-cargo build --locked --release --target-dir ./target
xx-cargo build --locked --target-dir ./target
cp ./target/$(xx-cargo --print-target-triple)/debug/$APP_NAME /bin/server
xx-verify /bin/server
EOF
################################################################################
# Create a new stage for running the application that contains the minimal
# runtime dependencies for the application. This often uses a different base
# image from the build stage where the necessary files are copied from the build
# stage.
#
# The example below uses the alpine image as the foundation for running the app.
# By specifying the "3.18" tag, it will use version 3.18 of alpine. If
# reproducability is important, consider using a digest
# (e.g., alpine@sha256:664888ac9cfd28068e062c991ebcff4b4c7307dc8dd4df9e728bedde5c449d91).
FROM alpine:3.18 AS final
# Create a non-privileged user that the app will run under.
# See https://docs.docker.com/go/dockerfile-user-best-practices/
ARG UID=10001
RUN adduser \
--disabled-password \
--gecos "" \
--home "/nonexistent" \
--shell "/sbin/nologin" \
--no-create-home \
--uid "${UID}" \
appuser
WORKDIR /app
RUN chown -R appuser /app
USER appuser
# Copy the executable from the "build" stage.
COPY --from=build /bin/server /bin/
# Expose the port that the application listens on.
# EXPOSE 8080
# What the container should run when it is started.
CMD ["/bin/server"]

View File

@ -3,11 +3,24 @@
p4bl0t is a simple logging bot for Discord written in Rust.
## Usage
In order to run p4bl0t, head over to your [developer
### Preparation
In order to run p4bl0t, you will need a Discord token with which your
bot will authenticate. Head over to your [developer
portal](https://discord.com/developers) on Discords website, and
create a bot there. Then, copy the `.env.example` file to a `.env`
file and fill in the details.
create a bot there. You will be able to get the bots token there.
### Docker
The easiest way to run p4bl0t is using Docker. Copy
`docker-compose.example.yml` to `docker-compose.yml` and modify the
`DISCORD_TOKEN` variable.
Then, you can simply run
```sh
docker compose up # or docker-compose on some machines
```
### Building and running it yourself
Copy the `.env.example` file to a `.env` file and fill in the details.
```sh
cp .env.example .env
emacs .env
@ -27,7 +40,6 @@ cargo install sqlx-cli
Setup your SQLite database.
```sh
export DATABASE_URL=<your-database-url> # should be the same as in the .env file
sqlx database create
sqlx migrate run
```

5
build.rs Normal file
View File

@ -0,0 +1,5 @@
// generated by `sqlx migrate build-script`
fn main() {
// trigger recompilation when a new migration is added
println!("cargo:rerun-if-changed=migrations");
}

View File

@ -0,0 +1,9 @@
services:
p4bl0t:
build:
context: .
target: final
environment:
DISCORD_TOKEN: changeme
volumes:
- ./p4bl0t.db:/app/p4bl0t.db

View File

@ -1,66 +1,92 @@
#![allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)]
use std::env;
use poise::serenity_prelude::{ChannelId, GuildId};
use sqlx::SqlitePool;
use tracing::error;
use sqlx::{migrate::MigrateDatabase, Sqlite, SqlitePool};
use tracing::{error, info, debug};
pub type Result<T> = ::std::result::Result<T, sqlx::Error>;
pub struct Database {
pool: SqlitePool,
}
pub struct Database(SqlitePool);
impl Database {
/// Initialize Sqlite database.
///
/// The Sqlite database should already exist and have its
/// migrations already executed.
///
/// # Errors
///
/// This function will return an error if the Sqlite pool fails to
/// create.
pub async fn new() -> Result<Self> {
Ok(Self {
pool: SqlitePool::connect(
&env::var("DATABASE_URL")
.expect("Missing enviroment variable DATABASE_URL"),
)
.await?,
})
let url = "sqlite:p4bl0t.db";
if !Sqlite::database_exists(url).await? {
info!("Creating database");
Sqlite::create_database(url).await?;
info!("Database created");
}
debug!("Getting pool connection");
let pool = SqlitePool::connect(url).await?;
info!("Running migrations");
sqlx::migrate!().run(&pool).await?;
debug!("Database initialized");
Ok(Self(pool))
}
/// Return from database all channels registered as loggers for a
/// guild.
///
/// # Errors
///
/// This function will return an error if `sqlx` does so.
pub async fn get_logging_channels(
&self,
guild_id: GuildId,
) -> Result<Vec<u64>> {
let guild_id = guild_id.0 as i64;
let channels = sqlx::query!(
) -> Result<Vec<ChannelId>> {
let guild_id = guild_id.get() as i64;
sqlx::query!(
r#"
SELECT channel_id
FROM guild_log_channels
WHERE guild_id = ?1
"#,
WHERE guild_id = ?1"#,
guild_id
)
.fetch_all(&self.pool)
.fetch_all(&self.0)
.await
.map_err(|e| {
error!(
"Error getting logging channels for guild {guild_id}: {e:?}"
);
e
})?;
Ok(channels.iter().map(|id| id.channel_id as u64).collect())
})
.map(|channels| {
channels
.iter()
.map(|id| ChannelId::new(id.channel_id as u64))
.collect()
})
}
/// Adds a channel as a logger for a guild.
///
/// # Errors
///
/// This function will return an error if `sqlx` does so. This may
/// be either a database issue, or a channel is already registered
/// as a guild's logger, therefore violating the unicity
/// constraint for guild ID and channel ID pairs.
pub async fn set_logging_channel(
&self,
guild_id: GuildId,
channel_id: ChannelId,
) -> Result<()> {
let guild_id = guild_id.0 as i64;
let channel_id = channel_id.0 as i64;
let mut conn = self.pool.acquire().await?;
let guild_id = guild_id.get() as i64;
let channel_id = channel_id.get() as i64;
let mut conn = self.0.acquire().await?;
sqlx::query!(
r#"
sqlx::query!(r#"
INSERT INTO guild_log_channels (guild_id, channel_id)
VALUES ( ?1, ?2 )
"#,
VALUES ( ?1, ?2 )"#,
guild_id,
channel_id
)
@ -73,18 +99,25 @@ VALUES ( ?1, ?2 )
.map(|_| ())
}
/// Unregister a channel as a logger for a guild.
///
/// This function will return a success value even if `channel`
/// was not a logger of `guild` already.
///
/// # Errors
///
/// This function will return an error if `sqlx` does so.
pub async fn remove_logging_channel(
&self,
guild_id: GuildId,
channel_id: ChannelId,
guild: GuildId,
channel: ChannelId,
) -> Result<()> {
let guild_id = guild_id.0 as i64;
let channel_id = channel_id.0 as i64;
let mut conn = self.pool.acquire().await?;
let guild_id = guild.get() as i64;
let channel_id = channel.get() as i64;
let mut conn = self.0.acquire().await?;
sqlx::query!(r#"
DELETE FROM guild_log_channels
WHERE guild_id = ?1 AND channel_id = ?2
"#,
WHERE guild_id = ?1 AND channel_id = ?2"#,
guild_id,
channel_id)
.execute(&mut *conn)

View File

@ -1,7 +1,16 @@
use super::{Context, Result};
use super::super::{Context, Result};
use super::utils::serenity;
use poise::serenity_prelude as serenity;
/// Main command for logging subcommands.
///
/// This command cannot be called on its own and will do nothing by
/// itself.
///
/// # Errors
///
/// This command will never error out, even if its signature says it
/// can.
#[allow(clippy::unused_async)]
#[poise::command(
slash_command,
@ -12,8 +21,13 @@ pub async fn logging(_ctx: Context<'_>) -> Result {
Ok(())
}
/// Add a channel as a logger.
///
/// # Errors
///
/// This function will return an error if .
#[poise::command(slash_command)]
pub async fn add_channel(
async fn add_channel(
ctx: Context<'_>,
#[description = "New logging channel"] channel: serenity::Channel,
) -> Result {
@ -50,8 +64,16 @@ pub async fn add_channel(
Ok(())
}
/// List all channels registered as loggers for a guild.
///
/// This will list all channels that are logger channels in the server
/// from which the command was executed.
///
/// # Errors
///
/// This function will return an error if the database returns one.
#[poise::command(slash_command)]
pub async fn list_channels(ctx: Context<'_>) -> Result {
async fn list_channels(ctx: Context<'_>) -> Result {
let response = match ctx.guild_id() {
None => "Error: Could not determine the guild's ID".to_owned(),
Some(guild_id) => {
@ -78,8 +100,18 @@ pub async fn list_channels(ctx: Context<'_>) -> Result {
Ok(())
}
/// Remove a channel as a logger in a guild.
///
/// This will remove a channel from the list of logger channels in the
/// guild from which the command was executed. If the channel is not a
/// logger, the bot will still consider unsetting the channel as a
/// logger a success.
///
/// # Errors
///
/// This function will return an error if the database errors.
#[poise::command(slash_command)]
pub async fn remove_channel(
async fn remove_channel(
ctx: Context<'_>,
#[description = "Logger channel to remove"] channel: serenity::Channel,
) -> Result {

View File

@ -0,0 +1,3 @@
mod logging;
pub(crate) use logging::logging;

24
src/discord/error.rs Normal file
View File

@ -0,0 +1,24 @@
use std::error::Error as StdError;
use std::fmt::{self, Display};
#[derive(Debug, Clone, Copy)]
pub enum Error {
GuildIdNotFound,
}
impl Error {
pub fn boxed(self) -> Box<Self> {
Box::new(self)
}
}
impl Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
// write!(f, "")
match self {
Self::GuildIdNotFound => write!(f, "Guild ID not found!"),
}
}
}
impl StdError for Error {}

View File

@ -1,72 +0,0 @@
use crate::db::Database;
use super::{utils::BotData, Error, Result};
use poise::{serenity_prelude as serenity, Event};
use tracing::{error, info};
async fn handle_everyone_mention(
ctx: &serenity::Context,
database: &Database,
message: &serenity::Message,
) -> Result {
use serenity::ChannelId;
if let Some(guild_id) = message.guild_id {
if message.mention_everyone {
let author = message.author.clone();
let message_channel = message.channel_id;
let channels: Vec<ChannelId> = database
.get_logging_channels(guild_id)
.await?
.iter()
.map(|channel_id| serenity::ChannelId(channel_id.to_owned()))
.collect();
for channel in &channels {
channel
.send_message(&ctx, |m| {
m.embed(|e| {
e.title("Someone mentioned everyone!")
.field("Author", author.clone(), true)
.field(
"When",
message.timestamp.naive_local().to_string(),
true,
)
.field(
"Channel",
format!("<#{message_channel}>"),
true,
)
.field("Link", format!("https://discord.com/channels/{guild_id}/{}/{}", channel.0, message.id), false)
})
})
.await
.map_err(|e| {
error!("Failed to send message: {e:?}");
e
})?;
}
}
} else {
error!("Could not determine guild id of message {message:?}");
}
Ok(())
}
pub async fn event_handler(
ctx: &serenity::Context,
event: &Event<'_>,
_framework: poise::FrameworkContext<'_, BotData, Error>,
data: &BotData,
) -> Result {
match event {
Event::Ready { data_about_bot } => {
info!("Logged in as {}", data_about_bot.user.name);
}
Event::Message { new_message } => {
handle_everyone_mention(ctx, &data.database, new_message).await?;
}
_ => {}
}
Ok(())
}

View File

@ -0,0 +1,64 @@
use crate::db::Database;
use super::super::Result;
use super::super::error::Error as DiscordError;
use poise::serenity_prelude::{self as serenity, CreateEmbed, CreateMessage};
use tracing::{error, info};
fn create_embed_for_mention(
message: &serenity::Message,
guild_id: u64,
) -> CreateEmbed {
let author_id = message.author.id.to_string();
let message_channel = message.channel_id.get();
CreateEmbed::new()
.title("Someone mentioned everyone!")
.field("Author", format!("<@{author_id}>"), true)
.field("When", message.timestamp.naive_local().to_string(), true)
.field("Channel", format!("<#{message_channel}>"), true)
.field(
"Link",
format!(
"https://discord.com/channels/{guild_id}/{message_channel}/{}",
message.id
),
false,
)
}
/// Handle messages mentioning everyone.
///
/// # Errors
///
/// This function will return an error if a message fails to be sent,
/// if retrieving the list of channels registered as loggers fails, or
/// if there is not guild ID that can be retrieved from the message.
pub async fn handle_everyone_mention(
ctx: &serenity::Context,
database: &Database,
message: &serenity::Message,
) -> Result {
info!("Message mentioning everyone: {message:?}");
if !message.mention_everyone {
return Ok(());
}
if message.guild_id.is_none() {
error!("Message without a guild_id! {message:?}");
return Err(DiscordError::GuildIdNotFound.boxed());
}
let guild_id = message.guild_id.unwrap();
let channels: Vec<serenity::ChannelId> =
database.get_logging_channels(guild_id).await?;
for channel in &channels {
// Ignore result, it'll be in the bot's logger
let embed = create_embed_for_mention(message, guild_id.get());
let builder = CreateMessage::new().embed(embed);
let _ = channel
.send_message(&ctx, builder)
.await
.map_err(|e| error!("Failed to send message: {e:?}"));
}
Ok(())
}

31
src/discord/events/mod.rs Normal file
View File

@ -0,0 +1,31 @@
use super::{utils::BotData, Error, Result};
use poise::serenity_prelude::{self as serenity, FullEvent};
use tracing::info;
mod everyone;
use everyone::handle_everyone_mention;
/// Function handling events the bot can see.
///
/// # Errors
///
/// This function will return an error if one of the functions error
/// themselves.
pub async fn event_handler(
ctx: &serenity::Context,
event: &FullEvent,
_framework: poise::FrameworkContext<'_, BotData, Error>,
data: &BotData,
) -> Result {
match event {
FullEvent::Ready { data_about_bot } => {
info!("Logged in as {}", data_about_bot.user.name);
}
FullEvent::Message { new_message } => {
handle_everyone_mention(ctx, &data.database, new_message).await?;
}
_ => {}
}
Ok(())
}

View File

@ -1,8 +1,9 @@
mod commands;
pub mod error;
mod events;
pub mod utils;
use poise::FrameworkBuilder;
use poise::serenity_prelude::ClientBuilder;
use utils::serenity;
use commands::logging;
@ -12,8 +13,15 @@ use self::events::event_handler;
pub type Result = ::std::result::Result<(), Error>;
pub fn make_bot() -> FrameworkBuilder<BotData, Error> {
poise::Framework::builder()
/// Bootstraps the Discord bot.
///
/// # Panics
///
/// Panics if the environment `DISCORD_TOKEN` is unavailable.
pub fn make_bot() -> ClientBuilder {
let intents = serenity::GatewayIntents::non_privileged();
let token = std::env::var("DISCORD_TOKEN").expect("missing DISCORD_TOKEN");
let framework = poise::Framework::builder()
.options(poise::FrameworkOptions {
commands: vec![logging()],
event_handler: |ctx, event, framework, data| {
@ -21,8 +29,6 @@ pub fn make_bot() -> FrameworkBuilder<BotData, Error> {
},
..Default::default()
})
.token(std::env::var("DISCORD_TOKEN").expect("missing DISCORD_TOKEN"))
.intents(serenity::GatewayIntents::non_privileged())
.setup(|ctx, _ready, framework| {
Box::pin(async move {
poise::builtins::register_globally(
@ -33,4 +39,6 @@ pub fn make_bot() -> FrameworkBuilder<BotData, Error> {
Ok(BotData::new().await?)
})
})
.build();
ClientBuilder::new(token, intents).framework(framework)
}

View File

@ -6,6 +6,14 @@ pub struct BotData {
}
impl BotData {
/// Initialize state data for bot.
///
/// For now, this only includes a connector to its database.
///
/// # Errors
///
/// This function will return an error if the database fails to
/// initialize.
pub async fn new() -> color_eyre::Result<Self> {
Ok(Self {
database: Database::new().await?,

View File

@ -1,19 +1,18 @@
#![warn(clippy::style, clippy::pedantic)]
mod utils;
mod db;
mod discord;
mod utils;
use std::error::Error;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
dotenvy::dotenv()?;
color_eyre::install()?;
utils::setup_logging();
color_eyre::install()?;
let bot = discord::make_bot();
bot.run().await?;
let mut bot = discord::make_bot().await?;
bot.start().await?;
Ok(())
}

View File

@ -1,6 +1,11 @@
use tracing::Level;
use tracing_subscriber::FmtSubscriber;
/// Initialize logging for the project.
///
/// # Panics
///
/// Panics if the logger fails to initialize.
pub fn setup_logging() {
let subscriber = FmtSubscriber::builder()
.with_max_level(Level::INFO)