Initial commit
This commit is contained in:
commit
8c6f7d4379
21 changed files with 4184 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
/target
|
||||||
|
config.toml
|
||||||
3387
Cargo.lock
generated
Normal file
3387
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
24
Cargo.toml
Normal file
24
Cargo.toml
Normal 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
3
build.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
fn main() {
|
||||||
|
println!("cargo::rerun-if-changed=migrations");
|
||||||
|
}
|
||||||
12
compose.dev.yaml
Normal file
12
compose.dev.yaml
Normal 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
5
config copy.example
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
[bot]
|
||||||
|
token = ""
|
||||||
|
|
||||||
|
[db]
|
||||||
|
url = "postgresql://postgres:wowsans@localhost:5432/postgres"
|
||||||
22
examples/interpreter-test.rs
Normal file
22
examples/interpreter-test.rs
Normal 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);
|
||||||
|
}
|
||||||
9
migrations/00000_init.sql
Normal file
9
migrations/00000_init.sql
Normal 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)
|
||||||
|
);
|
||||||
2
migrations/00001_counter_id.sql
Normal file
2
migrations/00001_counter_id.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
alter table messages
|
||||||
|
add column counter_id text not null;
|
||||||
2
migrations/00002_counter_channel_id.sql
Normal file
2
migrations/00002_counter_channel_id.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
alter table messages
|
||||||
|
add column counter_channel_id text not null;
|
||||||
5
migrations/00003_convert_webhook.sql
Normal file
5
migrations/00003_convert_webhook.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
-- DELETE ALL MESSAGES!
|
||||||
|
delete from messages;
|
||||||
|
|
||||||
|
alter table messages
|
||||||
|
drop column counter_channel_id;
|
||||||
7
migrations/00004_webhooks.sql
Normal file
7
migrations/00004_webhooks.sql
Normal 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
|
||||||
|
);
|
||||||
5
migrations/00005_include_channel_id.sql
Normal file
5
migrations/00005_include_channel_id.sql
Normal 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
74
src/bot/commands.rs
Normal 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
18
src/bot/config.rs
Normal 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
23
src/bot/db.rs
Normal 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
393
src/bot/handler.rs
Normal 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
63
src/bot/main.rs
Normal 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
64
src/bot/modal.rs
Normal 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
1
src/lib.rs
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
pub mod script;
|
||||||
63
src/script.rs
Normal file
63
src/script.rs
Normal 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);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue