This commit is contained in:
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/target
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
.env.example
|
||||||
|
data/
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
.woodpecker
|
||||||
|
README.md
|
||||||
|
todo.md
|
||||||
1
.env.example
Normal file
1
.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DISCORD_TOKEN=your_bot_token_here
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/target
|
||||||
|
.env
|
||||||
|
data/
|
||||||
37
.woodpecker/build.yaml
Normal file
37
.woodpecker/build.yaml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
when:
|
||||||
|
- event: push
|
||||||
|
branch: master
|
||||||
|
- event: pull_request
|
||||||
|
branch: master
|
||||||
|
|
||||||
|
clone:
|
||||||
|
git:
|
||||||
|
image: woodpeckerci/plugin-git
|
||||||
|
settings:
|
||||||
|
depth: 1
|
||||||
|
lfs: false
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: lint
|
||||||
|
image: rust:1.96.0-slim-trixie
|
||||||
|
commands:
|
||||||
|
- rustup component add clippy rustfmt
|
||||||
|
- cargo fmt --check
|
||||||
|
- cargo clippy -- -D warnings
|
||||||
|
|
||||||
|
- name: build docker image
|
||||||
|
image: woodpeckerci/plugin-docker-buildx
|
||||||
|
settings:
|
||||||
|
repo: git.infernonode.com/hotarublaze/cinderwatch
|
||||||
|
registry: git.infernonode.com
|
||||||
|
tags: latest
|
||||||
|
cache_images:
|
||||||
|
- git.infernonode.com/hotarublaze/cinderwatch:cache
|
||||||
|
username:
|
||||||
|
from_secret: docker_woodpecker_username
|
||||||
|
password:
|
||||||
|
from_secret: docker_woodpecker_pat
|
||||||
|
platforms: linux/amd64
|
||||||
|
when:
|
||||||
|
- event: push
|
||||||
|
branch: main
|
||||||
3000
Cargo.lock
generated
Normal file
3000
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
Cargo.toml
Normal file
15
Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "cinderwatch"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serenity = { version = "0.12", features = ["rustls_backend", "model", "standard_framework", "gateway", "http", "client", "cache"] }
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
dotenvy = "0.15"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
reqwest = { version = "0.12", features = ["rustls-tls"] }
|
||||||
|
futures = "0.3"
|
||||||
27
Dockerfile
Normal file
27
Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
FROM rust:1.96.0-slim-trixie AS chef
|
||||||
|
RUN apt-get update && apt-get install -y pkg-config libssl-dev ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||||
|
RUN cargo install cargo-chef --locked
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
FROM chef AS planner
|
||||||
|
COPY . .
|
||||||
|
RUN cargo chef prepare --recipe-path recipe.json
|
||||||
|
|
||||||
|
FROM chef AS builder
|
||||||
|
COPY --from=planner /app/recipe.json recipe.json
|
||||||
|
RUN cargo chef cook --release --locked --recipe-path recipe.json
|
||||||
|
COPY . .
|
||||||
|
RUN cargo build --release --locked
|
||||||
|
|
||||||
|
FROM debian:trixie-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=builder /app/target/release/cinderwatch /usr/local/bin/
|
||||||
|
|
||||||
|
RUN mkdir /data
|
||||||
|
VOLUME /data
|
||||||
|
|
||||||
|
ENV DISCORD_TOKEN=
|
||||||
|
ENV DB_PATH=/data/bot.db
|
||||||
|
WORKDIR /app
|
||||||
|
CMD ["cinderwatch"]
|
||||||
9
README.md
Normal file
9
README.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Cinderwatch
|
||||||
|
|
||||||
|
A Discord antispam bot that watches designated channels, deletes messages, and bans users who post in them.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. Copy `.env.example` to `.env` and set `DISCORD_TOKEN`.
|
||||||
|
2. `cargo run`
|
||||||
|
3. Use `/mod_channel`, `/watch`, `/unwatch`, `/ban_reason`, `/immune_role` to configure per-guild.
|
||||||
22
migrations/001_init.sql
Normal file
22
migrations/001_init.sql
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS guild_config (
|
||||||
|
guild_id INTEGER PRIMARY KEY,
|
||||||
|
mod_channel_id INTEGER,
|
||||||
|
immune_role_id INTEGER,
|
||||||
|
ban_reason TEXT NOT NULL DEFAULT 'Posting in a restricted channel'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS watched_channels (
|
||||||
|
guild_id INTEGER NOT NULL,
|
||||||
|
channel_id INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (guild_id, channel_id),
|
||||||
|
FOREIGN KEY (guild_id) REFERENCES guild_config(guild_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ban_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
guild_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
channel_id INTEGER NOT NULL,
|
||||||
|
message_snippet TEXT,
|
||||||
|
banned_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
270
src/commands.rs
Normal file
270
src/commands.rs
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
use crate::db::Database;
|
||||||
|
use serenity::{
|
||||||
|
builder::{
|
||||||
|
CreateCommand, CreateCommandOption, CreateInteractionResponse,
|
||||||
|
CreateInteractionResponseMessage,
|
||||||
|
},
|
||||||
|
client::Context,
|
||||||
|
model::{
|
||||||
|
application::{
|
||||||
|
CommandDataOption, CommandDataOptionValue, CommandInteraction, CommandOptionType,
|
||||||
|
CommandType,
|
||||||
|
},
|
||||||
|
id::{ChannelId, GuildId, RoleId, UserId},
|
||||||
|
permissions::Permissions,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub async fn register_commands(ctx: &Context, guild_id: GuildId) -> Result<(), serenity::Error> {
|
||||||
|
let commands = vec![
|
||||||
|
CreateCommand::new("config")
|
||||||
|
.kind(CommandType::ChatInput)
|
||||||
|
.description("Configure the anti-spam bot")
|
||||||
|
.default_member_permissions(Permissions::ADMINISTRATOR)
|
||||||
|
.add_option(
|
||||||
|
CreateCommandOption::new(
|
||||||
|
CommandOptionType::SubCommand,
|
||||||
|
"mod-channel",
|
||||||
|
"Set the mod notification channel",
|
||||||
|
)
|
||||||
|
.add_sub_option(
|
||||||
|
CreateCommandOption::new(
|
||||||
|
CommandOptionType::Channel,
|
||||||
|
"channel",
|
||||||
|
"The channel for notifications",
|
||||||
|
)
|
||||||
|
.required(true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.add_option(
|
||||||
|
CreateCommandOption::new(
|
||||||
|
CommandOptionType::SubCommand,
|
||||||
|
"watched-channel-add",
|
||||||
|
"Add a channel to the watchlist",
|
||||||
|
)
|
||||||
|
.add_sub_option(
|
||||||
|
CreateCommandOption::new(
|
||||||
|
CommandOptionType::Channel,
|
||||||
|
"channel",
|
||||||
|
"The channel to watch",
|
||||||
|
)
|
||||||
|
.required(true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.add_option(
|
||||||
|
CreateCommandOption::new(
|
||||||
|
CommandOptionType::SubCommand,
|
||||||
|
"watched-channel-remove",
|
||||||
|
"Remove a channel from the watchlist",
|
||||||
|
)
|
||||||
|
.add_sub_option(
|
||||||
|
CreateCommandOption::new(
|
||||||
|
CommandOptionType::Channel,
|
||||||
|
"channel",
|
||||||
|
"The channel to stop watching",
|
||||||
|
)
|
||||||
|
.required(true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.add_option(
|
||||||
|
CreateCommandOption::new(
|
||||||
|
CommandOptionType::SubCommand,
|
||||||
|
"watched-channel-list",
|
||||||
|
"List all watched channels",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.add_option(
|
||||||
|
CreateCommandOption::new(
|
||||||
|
CommandOptionType::SubCommand,
|
||||||
|
"ban-reason",
|
||||||
|
"Set the ban reason",
|
||||||
|
)
|
||||||
|
.add_sub_option(
|
||||||
|
CreateCommandOption::new(
|
||||||
|
CommandOptionType::String,
|
||||||
|
"reason",
|
||||||
|
"The ban reason text",
|
||||||
|
)
|
||||||
|
.required(true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.add_option(
|
||||||
|
CreateCommandOption::new(
|
||||||
|
CommandOptionType::SubCommand,
|
||||||
|
"immune-role",
|
||||||
|
"Set a role that makes users immune from auto-ban",
|
||||||
|
)
|
||||||
|
.add_sub_option(
|
||||||
|
CreateCommandOption::new(
|
||||||
|
CommandOptionType::Role,
|
||||||
|
"role",
|
||||||
|
"The immune role (or none to clear)",
|
||||||
|
)
|
||||||
|
.required(false),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.add_option(
|
||||||
|
CreateCommandOption::new(
|
||||||
|
CommandOptionType::SubCommand,
|
||||||
|
"unban",
|
||||||
|
"Unban a user by ID",
|
||||||
|
)
|
||||||
|
.add_sub_option(
|
||||||
|
CreateCommandOption::new(
|
||||||
|
CommandOptionType::String,
|
||||||
|
"user_id",
|
||||||
|
"The user ID to unban",
|
||||||
|
)
|
||||||
|
.required(true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
guild_id.set_commands(&ctx.http, commands).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_interaction(
|
||||||
|
ctx: &Context,
|
||||||
|
db: &Arc<Database>,
|
||||||
|
interaction: &CommandInteraction,
|
||||||
|
) {
|
||||||
|
let guild_id = match interaction.guild_id {
|
||||||
|
Some(id) => id,
|
||||||
|
None => {
|
||||||
|
respond(ctx, interaction, "This command must be used in a server.").await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (name, sub_opts) = match extract_subcommand(&interaction.data.options) {
|
||||||
|
Some(result) => result,
|
||||||
|
None => {
|
||||||
|
respond(ctx, interaction, "Unknown command.").await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let content = match name {
|
||||||
|
"mod-channel" => get_channel_option(sub_opts, "channel")
|
||||||
|
.and_then(|cid| db.set_mod_channel(guild_id, cid).ok().map(|_| "Mod notification channel set!".to_string()))
|
||||||
|
.unwrap_or_else(|| "Failed to set mod channel.".to_string()),
|
||||||
|
"watched-channel-add" => get_channel_option(sub_opts, "channel")
|
||||||
|
.and_then(|cid| db.add_watched_channel(guild_id, cid).ok().map(|_| "Channel added to watchlist!".to_string()))
|
||||||
|
.unwrap_or_else(|| "Failed to add channel.".to_string()),
|
||||||
|
"watched-channel-remove" => get_channel_option(sub_opts, "channel")
|
||||||
|
.and_then(|cid| db.remove_watched_channel(guild_id, cid).ok().map(|_| "Channel removed from watchlist!".to_string()))
|
||||||
|
.unwrap_or_else(|| "Failed to remove channel.".to_string()),
|
||||||
|
"watched-channel-list" => {
|
||||||
|
let channels = db.get_watched_channels(guild_id).unwrap_or_default();
|
||||||
|
if channels.is_empty() {
|
||||||
|
"No watched channels.".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Watched channels: {}", channels.iter().map(|c| format!("<#{}>", c)).collect::<Vec<_>>().join(", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"ban-reason" => get_string_option(sub_opts, "reason")
|
||||||
|
.and_then(|r| db.set_ban_reason(guild_id, &r).ok().map(|_| format!("Ban reason set to: {}", r)))
|
||||||
|
.unwrap_or_else(|| "Failed to set ban reason.".to_string()),
|
||||||
|
"immune-role" => {
|
||||||
|
match get_role_option(sub_opts, "role") {
|
||||||
|
Some(rid) => db.set_immune_role(guild_id, rid)
|
||||||
|
.map(|_| format!("Immune role set to <@&{}>", rid))
|
||||||
|
.unwrap_or_else(|_| "Failed to set immune role.".to_string()),
|
||||||
|
None => db.clear_immune_role(guild_id)
|
||||||
|
.map(|_| "Immune role cleared. All users will be auto-banned.".to_string())
|
||||||
|
.unwrap_or_else(|_| "Failed to clear immune role.".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"unban" => {
|
||||||
|
match get_string_option(sub_opts, "user_id") {
|
||||||
|
Some(uid_str) => {
|
||||||
|
let uid: u64 = match uid_str.parse() {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => {
|
||||||
|
respond(ctx, interaction, "Invalid user ID. Must be a number.").await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = db.remove_ban_logs(guild_id, uid);
|
||||||
|
match guild_id.unban(&ctx.http, UserId::new(uid)).await {
|
||||||
|
Ok(_) => format!("User <@{}> has been unbanned and ban logs cleared.", uid),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Unban error: {:?}", e);
|
||||||
|
"Ban logs cleared, but Discord unban failed. The user may not be currently banned.".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => "No user ID provided.".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => "Unknown subcommand. Use /config to see available options.".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
respond(ctx, interaction, &content).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_subcommand(
|
||||||
|
options: &[CommandDataOption],
|
||||||
|
) -> Option<(&str, &[CommandDataOption])> {
|
||||||
|
let opt = options.first()?;
|
||||||
|
match &opt.value {
|
||||||
|
CommandDataOptionValue::SubCommand(opts) => Some((&opt.name, opts.as_slice())),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_channel_option(
|
||||||
|
options: &[CommandDataOption],
|
||||||
|
name: &str,
|
||||||
|
) -> Option<ChannelId> {
|
||||||
|
options.iter().find(|o| o.name == name).and_then(|opt| {
|
||||||
|
if let CommandDataOptionValue::Channel(c) = &opt.value {
|
||||||
|
Some(*c)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_role_option(
|
||||||
|
options: &[CommandDataOption],
|
||||||
|
name: &str,
|
||||||
|
) -> Option<RoleId> {
|
||||||
|
options.iter().find(|o| o.name == name).and_then(|opt| {
|
||||||
|
if let CommandDataOptionValue::Role(r) = &opt.value {
|
||||||
|
Some(*r)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_string_option(
|
||||||
|
options: &[CommandDataOption],
|
||||||
|
name: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
options.iter().find(|o| o.name == name).and_then(|opt| {
|
||||||
|
if let CommandDataOptionValue::String(s) = &opt.value {
|
||||||
|
Some(s.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn respond(
|
||||||
|
ctx: &Context,
|
||||||
|
interaction: &CommandInteraction,
|
||||||
|
content: &str,
|
||||||
|
) {
|
||||||
|
let _ = interaction
|
||||||
|
.create_response(
|
||||||
|
&ctx.http,
|
||||||
|
CreateInteractionResponse::Message(
|
||||||
|
CreateInteractionResponseMessage::new().content(content),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
184
src/db.rs
Normal file
184
src/db.rs
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
use rusqlite::{Connection, Result};
|
||||||
|
use serenity::model::id::{ChannelId, GuildId, RoleId};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
pub struct Database {
|
||||||
|
conn: Mutex<Connection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
pub fn new(db_path: &str) -> Result<Self> {
|
||||||
|
let conn = Connection::open_with_flags(
|
||||||
|
db_path,
|
||||||
|
rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE | rusqlite::OpenFlags::SQLITE_OPEN_CREATE,
|
||||||
|
)?;
|
||||||
|
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
|
||||||
|
Ok(Database { conn: Mutex::new(conn) })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init(&self) -> Result<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute_batch(include_str!("../migrations/001_init.sql"))?;
|
||||||
|
let _ = conn.execute_batch("ALTER TABLE guild_config ADD COLUMN immune_role_id INTEGER;");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_mod_channel(&self, guild_id: GuildId, channel_id: ChannelId) -> Result<()> {
|
||||||
|
let gid = guild_id.get() as i64;
|
||||||
|
let cid = channel_id.get() as i64;
|
||||||
|
self.conn.lock().unwrap().execute(
|
||||||
|
"INSERT INTO guild_config (guild_id, mod_channel_id, ban_reason)
|
||||||
|
VALUES (?1, ?2, 'Posting in a restricted channel')
|
||||||
|
ON CONFLICT(guild_id) DO UPDATE SET mod_channel_id = excluded.mod_channel_id",
|
||||||
|
rusqlite::params![gid, cid],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_mod_channel(&self, guild_id: GuildId) -> Result<Option<ChannelId>> {
|
||||||
|
let gid = guild_id.get() as i64;
|
||||||
|
let result: Result<Option<i64>> = self.conn.lock().unwrap().query_row(
|
||||||
|
"SELECT mod_channel_id FROM guild_config WHERE guild_id = ?",
|
||||||
|
[gid],
|
||||||
|
|row| row.get(0),
|
||||||
|
);
|
||||||
|
match result {
|
||||||
|
Ok(Some(id)) => Ok(Some(ChannelId::new(id as u64))),
|
||||||
|
Ok(None) => Ok(None),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_watched_channel(&self, guild_id: GuildId, channel_id: ChannelId) -> Result<()> {
|
||||||
|
let gid = guild_id.get() as i64;
|
||||||
|
let cid = channel_id.get() as i64;
|
||||||
|
self.conn.lock().unwrap().execute(
|
||||||
|
"INSERT OR IGNORE INTO watched_channels (guild_id, channel_id) VALUES (?, ?)",
|
||||||
|
rusqlite::params![gid, cid],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_watched_channel(&self, guild_id: GuildId, channel_id: ChannelId) -> Result<()> {
|
||||||
|
let gid = guild_id.get() as i64;
|
||||||
|
let cid = channel_id.get() as i64;
|
||||||
|
self.conn.lock().unwrap().execute(
|
||||||
|
"DELETE FROM watched_channels WHERE guild_id = ? AND channel_id = ?",
|
||||||
|
rusqlite::params![gid, cid],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_watched_channels(&self, guild_id: GuildId) -> Result<Vec<ChannelId>> {
|
||||||
|
let gid = guild_id.get() as i64;
|
||||||
|
let guard = self.conn.lock().unwrap();
|
||||||
|
let mut stmt = guard.prepare(
|
||||||
|
"SELECT channel_id FROM watched_channels WHERE guild_id = ?",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map([gid], |row| row.get::<_, i64>(0))?;
|
||||||
|
let channels: Vec<ChannelId> = rows
|
||||||
|
.filter_map(|r| r.ok())
|
||||||
|
.map(|id| ChannelId::new(id as u64))
|
||||||
|
.collect();
|
||||||
|
Ok(channels)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_watched_channel(&self, guild_id: GuildId, channel_id: ChannelId) -> Result<bool> {
|
||||||
|
let gid = guild_id.get() as i64;
|
||||||
|
let cid = channel_id.get() as i64;
|
||||||
|
let count: i64 = self.conn.lock().unwrap().query_row(
|
||||||
|
"SELECT COUNT(*) FROM watched_channels WHERE guild_id = ? AND channel_id = ?",
|
||||||
|
rusqlite::params![gid, cid],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
Ok(count > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_ban_reason(&self, guild_id: GuildId, reason: &str) -> Result<()> {
|
||||||
|
let gid = guild_id.get() as i64;
|
||||||
|
self.conn.lock().unwrap().execute(
|
||||||
|
"INSERT INTO guild_config (guild_id, mod_channel_id, ban_reason)
|
||||||
|
VALUES (?1, NULL, ?2)
|
||||||
|
ON CONFLICT(guild_id) DO UPDATE SET ban_reason = excluded.ban_reason",
|
||||||
|
rusqlite::params![gid, reason],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_ban_reason(&self, guild_id: GuildId) -> Result<String> {
|
||||||
|
let gid = guild_id.get() as i64;
|
||||||
|
let reason: String = self.conn.lock().unwrap().query_row(
|
||||||
|
"SELECT COALESCE(ban_reason, 'Posting in a restricted channel') FROM guild_config WHERE guild_id = ?",
|
||||||
|
[gid],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
Ok(reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_immune_role(&self, guild_id: GuildId, role_id: RoleId) -> Result<()> {
|
||||||
|
let gid = guild_id.get() as i64;
|
||||||
|
let rid = role_id.get() as i64;
|
||||||
|
self.conn.lock().unwrap().execute(
|
||||||
|
"INSERT INTO guild_config (guild_id, mod_channel_id, immune_role_id, ban_reason)
|
||||||
|
VALUES (?1, NULL, ?2, 'Posting in a restricted channel')
|
||||||
|
ON CONFLICT(guild_id) DO UPDATE SET immune_role_id = excluded.immune_role_id",
|
||||||
|
rusqlite::params![gid, rid],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_immune_role(&self, guild_id: GuildId) -> Result<()> {
|
||||||
|
let gid = guild_id.get() as i64;
|
||||||
|
self.conn.lock().unwrap().execute(
|
||||||
|
"UPDATE guild_config SET immune_role_id = NULL WHERE guild_id = ?",
|
||||||
|
[gid],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_immune_role(&self, guild_id: GuildId) -> Result<Option<RoleId>> {
|
||||||
|
let gid = guild_id.get() as i64;
|
||||||
|
let result: Result<Option<i64>> = self.conn.lock().unwrap().query_row(
|
||||||
|
"SELECT immune_role_id FROM guild_config WHERE guild_id = ?",
|
||||||
|
[gid],
|
||||||
|
|row| row.get(0),
|
||||||
|
);
|
||||||
|
match result {
|
||||||
|
Ok(Some(id)) => Ok(Some(RoleId::new(id as u64))),
|
||||||
|
Ok(None) => Ok(None),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn log_ban(&self, guild_id: GuildId, user_id: u64, channel_id: ChannelId, message_snippet: &str) -> Result<()> {
|
||||||
|
let gid = guild_id.get() as i64;
|
||||||
|
let uid = user_id as i64;
|
||||||
|
let cid = channel_id.get() as i64;
|
||||||
|
self.conn.lock().unwrap().execute(
|
||||||
|
"INSERT INTO ban_log (guild_id, user_id, channel_id, message_snippet) VALUES (?, ?, ?, ?)",
|
||||||
|
rusqlite::params![gid, uid, cid, message_snippet],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn total_bans(&self) -> Result<i64> {
|
||||||
|
let count: i64 = self.conn.lock().unwrap().query_row(
|
||||||
|
"SELECT COUNT(*) FROM ban_log",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_ban_logs(&self, guild_id: GuildId, user_id: u64) -> Result<()> {
|
||||||
|
let gid = guild_id.get() as i64;
|
||||||
|
let uid = user_id as i64;
|
||||||
|
self.conn.lock().unwrap().execute(
|
||||||
|
"DELETE FROM ban_log WHERE guild_id = ? AND user_id = ?",
|
||||||
|
rusqlite::params![gid, uid],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
156
src/handlers.rs
Normal file
156
src/handlers.rs
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
use crate::commands::{handle_interaction, register_commands};
|
||||||
|
use crate::db::Database;
|
||||||
|
use serenity::all::ActivityData;
|
||||||
|
use serenity::{
|
||||||
|
async_trait,
|
||||||
|
builder::{CreateAttachment, CreateMessage},
|
||||||
|
client::Context,
|
||||||
|
model::{
|
||||||
|
application::Interaction,
|
||||||
|
channel::Message,
|
||||||
|
gateway::Ready,
|
||||||
|
guild::Guild,
|
||||||
|
id::GuildId,
|
||||||
|
user::OnlineStatus,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use serenity::model::channel::MessageFlags;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub struct Handler {
|
||||||
|
pub db: Arc<Database>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl serenity::client::EventHandler for Handler {
|
||||||
|
async fn ready(&self, _ctx: Context, ready: Ready) {
|
||||||
|
let shard_info = ready.shard.map_or("N/A".to_string(), |s| format!("{}/{}", s.id.0, s.total));
|
||||||
|
println!("{} is connected! [Shard {}]", ready.user.name, shard_info);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn guild_create(&self, ctx: Context, guild: Guild, _is_new: Option<bool>) {
|
||||||
|
println!("Joined guild: {} ({})", guild.name, guild.id);
|
||||||
|
if let Err(e) = register_commands(&ctx, guild.id).await {
|
||||||
|
eprintln!("Failed to register commands for {}: {:?}", guild.id, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
let guild_count = ctx.cache.guild_count();
|
||||||
|
let total_bans = self.db.total_bans().unwrap_or(0);
|
||||||
|
println!(
|
||||||
|
"Status updated: {} guilds | {} total bans",
|
||||||
|
guild_count, total_bans
|
||||||
|
);
|
||||||
|
ctx.set_presence(
|
||||||
|
Some(ActivityData::watching(format!("{} guilds | {} bans", guild_count, total_bans))),
|
||||||
|
OnlineStatus::Online,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn message(&self, ctx: Context, msg: Message) {
|
||||||
|
if msg.author.bot {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let guild_id = match msg.guild_id {
|
||||||
|
Some(id) => id,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
match self.db.is_watched_channel(guild_id, msg.channel_id) {
|
||||||
|
Ok(true) => {}
|
||||||
|
_ => return,
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = self.handle_watched_channel_message(&ctx, &msg, guild_id).await {
|
||||||
|
eprintln!("Error handling watched channel message: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
|
||||||
|
if let Interaction::Command(command) = interaction {
|
||||||
|
handle_interaction(&ctx, &self.db, &command).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Handler {
|
||||||
|
async fn handle_watched_channel_message(
|
||||||
|
&self,
|
||||||
|
ctx: &Context,
|
||||||
|
msg: &Message,
|
||||||
|
guild_id: GuildId,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
if let Ok(Some(immune_role)) = self.db.get_immune_role(guild_id) {
|
||||||
|
if let Some(ref member) = msg.member {
|
||||||
|
if member.roles.contains(&immune_role) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mod_channel_id = self.db.get_mod_channel(guild_id)?.unwrap_or(msg.channel_id);
|
||||||
|
|
||||||
|
let mut download_tasks = Vec::new();
|
||||||
|
for attachment in &msg.attachments {
|
||||||
|
let url = attachment.url.clone();
|
||||||
|
let filename = attachment.filename.clone();
|
||||||
|
download_tasks.push(async move {
|
||||||
|
match reqwest::get(&url).await {
|
||||||
|
Ok(resp) => match resp.bytes().await {
|
||||||
|
Ok(bytes) => Some(CreateAttachment::bytes(bytes.to_vec(), filename)),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to read attachment bytes: {:?}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to download attachment: {:?}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let files: Vec<CreateAttachment> = futures::future::join_all(download_tasks)
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let roles_line = if let Some(ref member) = msg.member {
|
||||||
|
if member.roles.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("\nRoles: {}", member.roles.iter().map(|r| format!("<@&{}>", r)).collect::<Vec<_>>().join(" "))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let copy = format!("**{}** ({}):{}{}", msg.author.name, msg.author.id, roles_line, if msg.content.is_empty() { String::new() } else { format!("\n{}", msg.content) });
|
||||||
|
|
||||||
|
let _ = mod_channel_id
|
||||||
|
.send_message(&ctx.http, CreateMessage::new().content(©).files(files).flags(MessageFlags::SUPPRESS_EMBEDS))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let _ = msg.delete(&ctx.http).await;
|
||||||
|
|
||||||
|
let ban_reason = self
|
||||||
|
.db
|
||||||
|
.get_ban_reason(guild_id)
|
||||||
|
.unwrap_or_else(|_| "Posting in a restricted channel".to_string());
|
||||||
|
|
||||||
|
if let Err(why) = guild_id
|
||||||
|
.ban_with_reason(&ctx.http, msg.author.id, 0, &ban_reason)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
eprintln!("Error banning user: {:?}", why);
|
||||||
|
} else {
|
||||||
|
self.db
|
||||||
|
.log_ban(guild_id, msg.author.id.get(), msg.channel_id, &msg.content)
|
||||||
|
.unwrap_or(());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/main.rs
Normal file
40
src/main.rs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
mod commands;
|
||||||
|
mod db;
|
||||||
|
mod handlers;
|
||||||
|
|
||||||
|
use db::Database;
|
||||||
|
use dotenvy::dotenv;
|
||||||
|
use serenity::prelude::GatewayIntents;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
dotenv().expect("Failed to load .env file");
|
||||||
|
|
||||||
|
let token = std::env::var("DISCORD_TOKEN").expect("Expected DISCORD_TOKEN in environment");
|
||||||
|
|
||||||
|
let db_path = &std::env::var("DB_PATH").unwrap_or_else(|_| "data/bot.db".to_string());
|
||||||
|
if let Some(parent) = std::path::Path::new(db_path).parent() {
|
||||||
|
std::fs::create_dir_all(parent).expect("Failed to create database directory");
|
||||||
|
}
|
||||||
|
|
||||||
|
let database = Arc::new(Database::new(db_path).expect("Failed to open database"));
|
||||||
|
database.init().expect("Failed to initialize database");
|
||||||
|
|
||||||
|
let intents = GatewayIntents::non_privileged()
|
||||||
|
| GatewayIntents::GUILD_MESSAGES
|
||||||
|
| GatewayIntents::MESSAGE_CONTENT
|
||||||
|
| GatewayIntents::GUILDS
|
||||||
|
| GatewayIntents::GUILD_MODERATION;
|
||||||
|
|
||||||
|
let mut client = serenity::client::Client::builder(token, intents)
|
||||||
|
.event_handler(handlers::Handler {
|
||||||
|
db: Arc::clone(&database),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("Error creating client");
|
||||||
|
|
||||||
|
if let Err(why) = client.start().await {
|
||||||
|
println!("Client error: {:?}", why);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user