Initial commit

This commit is contained in:
파링 2026-01-05 22:54:32 +09:00
commit 8c6f7d4379
Signed by: paring
SSH key fingerprint: SHA256:8uCHhCpn/gVOLEaTolmbub9kfM6XBxWkIWmHxUZoWWk
21 changed files with 4184 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/target
config.toml

3387
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

24
Cargo.toml Normal file
View file

@ -0,0 +1,24 @@
[package]
name = "paringboard"
version = "0.1.0"
edition = "2024"
build = "build.rs"
[[bin]]
name = "paringboard"
path = "src/bot/main.rs"
[dependencies]
anyhow = "1.0.100"
dashmap = "6.1.0"
figment = { version = "0.10.19", features = ["toml", "env"] }
rhai = "1.23.6"
secrecy = { version = "0.10", features = ["serde"] }
serde = { version = "1.0.228", features = ["derive"] }
serenity = { git = "https://github.com/serenity-rs/serenity", branch = "next", features = [
"unstable",
] }
sqlx = { version = "0.8.6", features = ["runtime-tokio", "postgres"] }
tokio = { version = "1.49.0", features = ["full"] }
tracing = "0.1.44"
tracing-subscriber = "0.3.22"

3
build.rs Normal file
View file

@ -0,0 +1,3 @@
fn main() {
println!("cargo::rerun-if-changed=migrations");
}

12
compose.dev.yaml Normal file
View file

@ -0,0 +1,12 @@
services:
postgres:
image: postgres:18
environment:
- POSTGRES_PASSWORD=wowsans
volumes:
- pg_data:/var/lib/postgresql/18/docker
ports:
- "5432:5432"
volumes:
pg_data:

5
config copy.example Normal file
View file

@ -0,0 +1,5 @@
[bot]
token = ""
[db]
url = "postgresql://postgres:wowsans@localhost:5432/postgres"

View file

@ -0,0 +1,22 @@
use std::collections::HashMap;
fn main() {
tracing_subscriber::fmt::init();
let mut map = HashMap::new();
map.insert("".into(), 2);
let result = paringboard::script::check(
r#"
if reactions[""] >= 3 {
#{ channel: "YAHO", reactions: [""] }
} else {
()
}
"#,
map,
"123123123".into(),
);
dbg!(result);
}

View file

@ -0,0 +1,9 @@
create table
guilds (id text not null primary key, script text null);
create table
messages (
guild_id text not null references guilds (id),
message_id text not null,
primary key (guild_id, message_Id)
);

View file

@ -0,0 +1,2 @@
alter table messages
add column counter_id text not null;

View file

@ -0,0 +1,2 @@
alter table messages
add column counter_channel_id text not null;

View file

@ -0,0 +1,5 @@
-- DELETE ALL MESSAGES!
delete from messages;
alter table messages
drop column counter_channel_id;

View file

@ -0,0 +1,7 @@
create table
webhooks (
guild_id text not null references guilds (id),
channel_id text not null primary key,
webhook_id text not null,
webhook_token text not null
);

View file

@ -0,0 +1,5 @@
-- DELETE ALL MESSAGES!
delete from messages;
alter table messages
add column counter_channel_id text not null;

74
src/bot/commands.rs Normal file
View file

@ -0,0 +1,74 @@
use serenity::all::{
CommandInteraction, CommandOptionType, Context, CreateCommand, CreateCommandOption,
CreateInputText, CreateLabel, CreateModal, CreateModalComponent, CreateTextDisplay,
InputTextStyle, Permissions,
};
use crate::handler::{Handler, get_guild};
pub fn config_command() -> CreateCommand<'static> {
CreateCommand::new("starboard")
.description("스타보드 설정")
.add_option(CreateCommandOption::new(
CommandOptionType::SubCommand,
"script",
"스크립트 수정",
))
.default_member_permissions(Permissions::ADMINISTRATOR)
}
impl Handler {
pub async fn process_starboard_command(
&self,
ctx: &Context,
interaction: &CommandInteraction,
) -> anyhow::Result<()> {
let Some(guild_id) = interaction.guild_id else {
return Ok(());
};
// if let CommandType interaction.data.kind {}
for option in interaction.data.options() {
if option.name == "script" {
let script = get_guild(&self.db, guild_id)
.await?
.and_then(|x| x.script)
.unwrap_or("".to_string());
interaction
.create_response(
&ctx.http,
serenity::all::CreateInteractionResponse::Modal(
CreateModal::new("edit_script", "스크립트 편집").components(vec![
CreateModalComponent::TextDisplay(CreateTextDisplay::new(
r#"rhai 스크립트로 스타보드 동작을 지정할 수 있습니다(반복문은 제한됩니다)
:
- `reactions`: map<string, i64> ( - )
- `channel`: string ( ID)
`result("채널ID", , "메시지에 사용될 아이콘(이모지 권장)")` (unit이나 )
:
```rs
if reactions[""] >= 3 {
return result("0000000000000000000", reactions[""], "")
}
```"#,
)),
CreateModalComponent::Label(CreateLabel::input_text(
"스크립트",
CreateInputText::new(InputTextStyle::Paragraph, "script")
.value(script)
.placeholder("사용하지 않으려면 () 입력"),
)),
]),
),
)
.await?;
}
}
Ok(())
}
}

18
src/bot/config.rs Normal file
View file

@ -0,0 +1,18 @@
use secrecy::SecretString;
use serde::Deserialize;
#[derive(Deserialize, Debug)]
pub struct Config {
pub bot: BotConfig,
pub db: DatabaseConfig,
}
#[derive(Deserialize, Debug)]
pub struct DatabaseConfig {
pub url: SecretString,
}
#[derive(Deserialize, Debug)]
pub struct BotConfig {
pub token: SecretString,
}

23
src/bot/db.rs Normal file
View file

@ -0,0 +1,23 @@
use sqlx::prelude::FromRow;
#[derive(Debug, FromRow)]
pub struct GuildRow {
pub id: String,
pub script: Option<String>,
}
#[derive(Debug, FromRow)]
pub struct MessageRow {
// pub guild_id: String,
pub message_id: String,
pub counter_id: String,
pub counter_channel_id: String,
}
#[derive(Debug, FromRow)]
pub struct WebhookRow {
// pub guild_id: String,
// pub channel_id: String,
pub webhook_id: String,
pub webhook_token: String,
}

393
src/bot/handler.rs Normal file
View file

@ -0,0 +1,393 @@
use std::{collections::HashMap, sync::Arc};
use anyhow::{Context as _, bail};
use dashmap::DashMap;
use serenity::{
all::{
Attachment, CacheHttp, ChannelId, Component, Context, CreateComponent, CreateMediaGallery,
CreateMediaGalleryItem, CreateSeparator, CreateTextDisplay, CreateUnfurledMediaItem,
CreateWebhook, EditWebhookMessage, EventHandler, ExecuteWebhook, FullEvent,
GenericChannelId, GuildId, Interaction, InteractionType, Message, MessageFlags, MessageId,
MessageSnapshot, Reaction, StickerFormatType, StickerItem, Webhook, WebhookId,
},
async_trait,
futures::lock::Mutex,
small_fixed_array::{FixedArray, FixedString},
};
use sqlx::{PgExecutor, PgPool};
use crate::{
commands,
db::{GuildRow, MessageRow, WebhookRow},
};
struct CloneBuilderMessage {
pub content: FixedString<u16>,
pub attachments: FixedArray<Attachment>,
pub sticker_items: FixedArray<StickerItem>,
}
impl From<Message> for CloneBuilderMessage {
fn from(value: Message) -> Self {
Self {
content: value.content,
attachments: value.attachments,
sticker_items: value.sticker_items,
}
}
}
impl From<MessageSnapshot> for CloneBuilderMessage {
fn from(value: MessageSnapshot) -> Self {
CloneBuilderMessage {
content: value.content,
attachments: value.attachments,
sticker_items: value.sticker_items,
}
}
}
pub struct Handler {
pub db: PgPool,
pub message_lock: Arc<DashMap<MessageId, Arc<Mutex<()>>>>,
}
#[async_trait]
impl EventHandler for Handler {
async fn dispatch(&self, context: &Context, event: &FullEvent) {
match event {
FullEvent::Ready { data_about_bot, .. } => {
info!("Bot is ready as {}", data_about_bot.user.tag());
if let Err(e) = context
.http
.create_global_commands(&vec![commands::config_command()])
.await
{
error!("Failed to register config command: {e:?}");
}
}
FullEvent::ReactionAdd { add_reaction, .. } => {
let lock = self
.message_lock
.entry(add_reaction.message_id)
.or_insert_with(|| Arc::new(Mutex::new(())));
{
let _guard = lock.lock().await;
if let Err(e) = self.reaction_add(context, add_reaction).await {
error!("Error while processing reaction add event: {e:?}");
}
}
if Arc::strong_count(&lock) == 2 {
self.message_lock
.remove_if(&add_reaction.message_id, |_, val| {
Arc::strong_count(val) <= 2
});
}
}
FullEvent::InteractionCreate { interaction } => {
if let Err(e) = self.interaction_create(context, interaction).await {
error!("Error while processing interaction: {e:?}");
}
}
_ => return,
}
}
}
impl Handler {
async fn interaction_create(
&self,
ctx: &Context,
interaction: &Interaction,
) -> anyhow::Result<()> {
if let Some(command) = interaction.as_command() {
if command.data.name == "starboard" {
self.process_starboard_command(ctx, command).await?;
return Ok(());
}
}
if let Some(modal) = interaction.as_modal_submit() {
self.process_modal(ctx, modal).await?;
return Ok(());
}
Ok(())
}
async fn reaction_add(&self, ctx: &Context, reaction: &Reaction) -> anyhow::Result<()> {
if reaction.user(&ctx.http).await?.bot() {
return Ok(());
}
let Some(guild_id) = reaction.guild_id else {
return Ok(());
};
let Some(guild) = get_guild(&self.db, guild_id).await? else {
return Ok(());
};
let Some(script) = &guild.script else {
return Ok(());
};
let existing_message = get_message(&self.db, reaction.message_id).await?;
let msg = reaction.message(&ctx.http).await?;
let reactions: HashMap<String, i64> = msg
.reactions
.iter()
.map(|x| (x.reaction_type.to_string(), x.count as i64))
.collect();
let reference_msg = if !msg.message_snapshots.is_empty() {
CloneBuilderMessage::from(msg.message_snapshots[0].clone())
} else {
CloneBuilderMessage::from(msg.clone())
};
let Some(result) =
paringboard::script::check(script, reactions, reaction.channel_id.to_string())?
else {
return Ok(());
};
let channel_id = result
.channel_id
.parse()
.context("failed to parse send channel id")?;
let webhook = get_or_create_webhook(&self.db, &ctx.http, guild_id, channel_id).await?;
let mut components: Vec<CreateComponent<'_>> = vec![];
if let Some(existing_message) = &existing_message {
let msg_id: MessageId = existing_message
.counter_id
.parse()
.context("unable to parse existing counter message id")?;
let chn_id: GenericChannelId = existing_message
.counter_channel_id
.parse()
.context("unable to parse counter channel id")?;
let existing_message = ctx.http.get_message(chn_id, msg_id).await?;
for comp in existing_message
.components
.iter()
.take(existing_message.components.len().saturating_sub(2) as _)
{
match comp {
Component::TextDisplay(text) => {
components.push(CreateComponent::TextDisplay(CreateTextDisplay::new(
text.content.clone().unwrap(),
)));
}
Component::Separator(separator) => {
components.push(CreateComponent::Separator(CreateSeparator::new(
separator.divider.unwrap(),
)));
}
Component::MediaGallery(gallery) => {
let items = gallery
.items
.iter()
.map(|x| {
let media = CreateUnfurledMediaItem::new(x.media.url.to_string());
CreateMediaGalleryItem::new(media)
})
.collect::<Vec<_>>();
components.push(CreateComponent::MediaGallery(CreateMediaGallery::new(
items,
)));
}
unknown => {
bail!("unhandle component value received: {unknown:?}");
}
}
}
} else {
if !reference_msg.content.is_empty() {
components.push(CreateComponent::TextDisplay(CreateTextDisplay::new(
&reference_msg.content,
)));
}
let images = reference_msg
.attachments
.iter()
.filter(|x| x.width.is_some())
.collect::<Vec<_>>();
if !images.is_empty() {
components.push(CreateComponent::MediaGallery(CreateMediaGallery::new(
images
.into_iter()
.map(|x| {
CreateMediaGalleryItem::new(CreateUnfurledMediaItem::new(x.url.clone()))
})
.collect::<Vec<_>>(),
)));
}
if let Some(sticker_url) = reference_msg
.sticker_items
.iter()
.filter_map(|x| {
let ext = match x.format_type {
StickerFormatType::Png | StickerFormatType::Apng => "png",
StickerFormatType::Gif => "gif",
_ => return None,
};
let url = format!(
"https://media.discordapp.net/stickers/{}.{}?size=160&quality=lossless",
&x.id, ext
);
Some(url)
})
.next()
{
let item = CreateMediaGalleryItem::new(CreateUnfurledMediaItem::new(sticker_url));
components.push(CreateComponent::MediaGallery(CreateMediaGallery::new(
vec![item],
)));
}
}
components.push(CreateComponent::Separator(CreateSeparator::new(true)));
components.push(CreateComponent::TextDisplay(CreateTextDisplay::new(
format!(
"-# {} {} · [원본 메시지]({})",
result.icon,
result.count,
msg.link().to_string()
),
)));
if let Some(existing) = existing_message {
let msg_id = existing
.counter_id
.parse()
.context("unable to parse counter message id")?;
webhook
.edit_message(
&ctx.http,
msg_id,
EditWebhookMessage::new().components(components),
)
.await?;
} else {
let counter_msg = webhook
.execute(
&ctx.http,
true,
ExecuteWebhook::new()
.username(msg.author.display_name())
.avatar_url(
msg.author
.avatar_url()
.unwrap_or_else(|| msg.author.default_avatar_url()),
)
.components(components)
.flags(MessageFlags::IS_COMPONENTS_V2),
)
.await?
.unwrap();
sqlx::query(
"insert into messages (guild_id, message_id, counter_id, counter_channel_id) values ($1, $2, $3, $4)",
)
.bind(guild_id.to_string())
.bind(msg.id.to_string())
.bind(counter_msg.id.to_string())
.bind(counter_msg.channel_id.to_string())
.execute(&self.db).await?;
}
Ok(())
}
}
pub async fn get_guild(
executor: impl PgExecutor<'_>,
id: GuildId,
) -> anyhow::Result<Option<GuildRow>> {
Ok(
sqlx::query_as::<_, GuildRow>("select * from guilds where id = $1")
.bind(id.to_string())
.fetch_optional(executor)
.await
.context("failed to get guild data from db")?,
)
}
async fn get_message(
executor: impl PgExecutor<'_>,
message_id: MessageId,
) -> anyhow::Result<Option<MessageRow>> {
Ok(
sqlx::query_as::<_, MessageRow>("select * from messages where message_id = $1")
.bind(message_id.to_string())
.fetch_optional(executor)
.await
.context("failed to get message data from db")?,
)
}
async fn get_or_create_webhook(
executor: impl PgExecutor<'_> + Copy,
http: impl CacheHttp,
guild_id: GuildId,
channel_id: ChannelId,
) -> anyhow::Result<Webhook> {
let existing = sqlx::query_as::<_, WebhookRow>(
"select * from webhooks where guild_id = $1 and channel_id = $2",
)
.bind(guild_id.to_string())
.bind(channel_id.to_string())
.fetch_optional(executor)
.await
.context("failed to get webhook data from db")?;
if let Some(existing) = existing {
let webhook_id: WebhookId = existing
.webhook_id
.parse()
.context("failed to parse webhook id")?;
let webhook =
Webhook::from_id_with_token(http.http(), webhook_id, &existing.webhook_token).await?;
return Ok(webhook);
}
let webhook = channel_id
.create_webhook(
http.http(),
CreateWebhook::new("스타보드").audit_log_reason("스타보드 메시지 전송용 웹훅"),
)
.await
.context("failed to create a webhook")?;
let token = webhook
.token
.clone()
.context("why token does not exist???")?;
sqlx::query(
"insert into webhooks (guild_id, channel_id, webhook_id, webhook_token) values ($1, $2, $3, $4)",
).bind(guild_id.to_string()).bind(channel_id.to_string()).bind(webhook.id.to_string()).bind(token.expose_secret())
.execute(executor)
.await?;
Ok(webhook)
}

63
src/bot/main.rs Normal file
View file

@ -0,0 +1,63 @@
use std::{str::FromStr, sync::Arc};
use anyhow::Context;
use dashmap::DashMap;
use figment::{
Figment,
providers::{Env, Format, Toml},
};
use secrecy::ExposeSecret;
use serenity::{
Client,
all::{GatewayIntents, Token},
};
use sqlx::{migrate, postgres::PgPoolOptions};
use crate::{config::Config, handler::Handler};
mod commands;
mod config;
mod db;
mod handler;
mod modal;
#[macro_use]
extern crate tracing;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt::init();
let config: Config = Figment::new()
.merge(Toml::file("config.toml"))
.merge(Env::prefixed("PB_"))
.extract()?;
info!("config: {config:?}");
let db = PgPoolOptions::new()
.max_connections(5)
.connect(config.db.url.expose_secret())
.await
.context("unable to connect to database")?;
let migrator = migrate!("./migrations");
migrator.run(&db).await.context("failed to migrate")?;
info!("migrated database");
let mut client = Client::builder(
Token::from_str(config.bot.token.expose_secret()).unwrap(),
GatewayIntents::GUILD_MESSAGES | GatewayIntents::GUILD_MESSAGE_REACTIONS,
)
.event_handler(Arc::new(Handler {
db,
message_lock: Arc::new(DashMap::new()),
}))
.await
.expect("Err creating client");
client.start().await?;
Ok(())
}

64
src/bot/modal.rs Normal file
View file

@ -0,0 +1,64 @@
use anyhow::Context as _;
use serenity::all::{
Component, Context, CreateInteractionResponseMessage, Label, LabelComponent, ModalInteraction,
};
use crate::{db, handler::Handler};
impl Handler {
pub async fn process_modal(
&self,
ctx: &Context,
interaction: &ModalInteraction,
) -> anyhow::Result<()> {
if interaction.data.custom_id == "edit_script" {
return self.process_script_modal(ctx, interaction).await;
}
Ok(())
}
async fn process_script_modal(
&self,
ctx: &Context,
interaction: &ModalInteraction,
) -> anyhow::Result<()> {
let Some(guild_id) = interaction.guild_id else {
return Ok(());
};
let components = &interaction.data.components;
let value = components
.get(1)
.and_then(|x| match x {
Component::Label(label) => Some(label),
_ => None,
})
.and_then(|x| match &x.component {
LabelComponent::InputText(input) => Some(input),
_ => None,
})
.and_then(|x| x.value.clone())
.context("unable to get value text from input")?;
sqlx::query("update guilds set script = $1 where id = $2")
.bind(value.as_str())
.bind(guild_id.to_string())
.execute(&self.db)
.await?;
interaction
.create_response(
&ctx.http,
serenity::all::CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
.content("스타보드 스크립트가 설정되었습니다.")
.ephemeral(true),
),
)
.await?;
Ok(())
}
}

1
src/lib.rs Normal file
View file

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

63
src/script.rs Normal file
View file

@ -0,0 +1,63 @@
use std::collections::HashMap;
use anyhow::anyhow;
use rhai::{Dynamic, Engine, Map, Scope};
use serde::Deserialize;
#[derive(Debug, Deserialize, Clone)]
pub struct ReactionResult {
pub channel_id: String,
pub count: i64,
pub icon: String,
}
pub fn check(
script: &str,
reactions: HashMap<String, i64>,
channel: String,
) -> anyhow::Result<Option<ReactionResult>> {
let mut engine = Engine::new();
engine.set_max_operations(1000);
engine.register_type::<ReactionResult>();
engine.register_fn("result", |webhook_url: String, count: i64, icon: String| {
ReactionResult {
channel_id: webhook_url,
count,
icon,
}
});
engine
.disable_symbol("for")
.disable_symbol("while")
.disable_symbol("loop")
.set_max_expr_depths(50, 5)
.set_max_string_size(60)
.set_max_map_size(512)
.set_max_array_size(512);
let mut emotes_input = Map::new();
for (emoji, count) in reactions {
emotes_input.insert(emoji.into(), Dynamic::from(count));
}
let mut scope = Scope::new();
scope.set_value("reactions", Dynamic::from(emotes_input));
scope.set_value("channel", Dynamic::from(channel));
let result: Dynamic = engine
.eval_with_scope(&mut scope, script)
.map_err(|e| anyhow!("{e:?}"))?;
let result: Option<ReactionResult> = if result.is_unit() {
None
} else {
Some(
result
.try_cast_result()
.map_err(|e| anyhow!("failed to cast: {e:?}"))?,
)
};
return Ok(result);
}