Skip to content

Commit 0454088

Browse files
authoredNov 18, 2024··
Merge pull request #1 from bluemoontwo/main
2 parents c2250f8 + f78782d commit 0454088

24 files changed

+1048
-861
lines changed
 

‎package.json

+1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"discord-api-types": "^0.37.104",
3131
"discord.js": "^14.16.3",
3232
"dotenv": "^16.4.5",
33+
"reflect-metadata": "^0.2.2",
3334
"typescript": "^5.6.3"
3435
},
3536
"devDependencies": {

‎pnpm-lock.yaml

+12-4
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/commands/debate.ts

+173
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import {
2+
ActionRowBuilder,
3+
ButtonBuilder,
4+
ButtonStyle,
5+
CacheType,
6+
ChannelType,
7+
ChatInputCommandInteraction,
8+
EmbedBuilder,
9+
SlashCommandBuilder,
10+
} from "discord.js";
11+
import StoreManager from "../util/manange-store";
12+
import {DebateData} from "../types/debate";
13+
import {
14+
AddExecute, CommandData,
15+
InteractionHandler
16+
} from "../util/interaction-handler";
17+
import GuildCommand from "./guild";
18+
19+
@CommandData(
20+
new SlashCommandBuilder()
21+
.setName("debate")
22+
.setDescription("회의와 관련된 조작을 할 수 있습니다.")
23+
// 회의 시작 커맨드
24+
.addSubcommand((option) =>
25+
option
26+
.setName("start")
27+
.setDescription("회의를 시작합니다.")
28+
.addStringOption((option) =>
29+
option
30+
.setName("topic")
31+
.setDescription("회의 주제를 입력합니다.")
32+
.setRequired(true)
33+
)
34+
.addStringOption((option) =>
35+
option
36+
.setName("description")
37+
.setDescription("회의 주제에 대한 설명을 입력합니다.")
38+
.setRequired(true)
39+
)
40+
.addChannelOption((option) =>
41+
option
42+
.setName("category")
43+
.setDescription("회의실을 생성할 카테고리를 입력합니다.")
44+
.setRequired(true)
45+
)
46+
)
47+
// 회의 종료 커맨드
48+
.addSubcommand((option) =>
49+
option
50+
.setName("close")
51+
.setDescription("회의를 닫습니다.")
52+
)
53+
.toJSON()
54+
)
55+
@InteractionHandler()
56+
export default class DebateCommand {
57+
58+
// 회의 시작 커맨드
59+
@AddExecute("debate/start")
60+
public async startDebate(interaction: ChatInputCommandInteraction<CacheType>) {
61+
interaction.reply({
62+
content: "회의실을 생성하고 있습니다..",
63+
ephemeral: true,
64+
});
65+
66+
// 사용자 입력값 가져오기
67+
const topic = interaction.options.getString("topic");
68+
const description = interaction.options.getString("description");
69+
const category = interaction.options.getChannel("category");
70+
71+
// 카테고리 타입 검증
72+
if (category?.type !== ChannelType.GuildCategory) {
73+
return interaction.reply({
74+
content: "카테고리를 선택해주세요.",
75+
ephemeral: true,
76+
});
77+
}
78+
79+
// 고유한 회의 ID 생성
80+
const debateId = `${Date.now()}-${interaction.guildId}`;
81+
82+
const store = new StoreManager("debate");
83+
84+
if (interaction.channel?.type !== ChannelType.GuildText) {
85+
return interaction.reply({
86+
content: "예기치 못한 오류가 발생했습니다.",
87+
ephemeral: true,
88+
})
89+
}
90+
91+
// 회의용 음성 채널 생성
92+
const debateChan = await interaction.guild?.channels.create({
93+
name: `회의 - ${debateId}`,
94+
type: ChannelType.GuildVoice,
95+
parent: category?.id,
96+
reason: `${interaction.user.username}님이 회의를 시작하였습니다.`,
97+
});
98+
99+
// 회의 시작 임베드 생성
100+
const startEmbed = new EmbedBuilder()
101+
.setTitle("새로운 회의가 시작되었습니다.")
102+
.setFooter({
103+
text: `Debate ID: ${debateId}`,
104+
})
105+
.setColor("#1E1F22");
106+
107+
// 회의실 바로가기 버튼 생성
108+
const triggerMsgRow = new ActionRowBuilder<ButtonBuilder>()
109+
.addComponents(
110+
new ButtonBuilder({
111+
label: "회의실 바로가기",
112+
style: ButtonStyle.Link,
113+
url: debateChan?.url
114+
})
115+
);
116+
117+
const triggerMessage = await interaction.channel?.send({
118+
embeds: [startEmbed], components: [triggerMsgRow]
119+
});
120+
121+
// 회의 데이터 구조 생성
122+
const debateData = (store.get(debateId) as DebateData) || {
123+
topic: topic || "",
124+
author: interaction.user.id,
125+
description: description || "",
126+
interactionChannelId: interaction.channelId,
127+
triggerMessageId: triggerMessage.id,
128+
categoryId: category?.id,
129+
channelId: debateChan?.id,
130+
messages: [],
131+
closed: false,
132+
};
133+
134+
// 회의 데이터 저장
135+
store.set(debateId, debateData);
136+
137+
// 회의 안내 임베드 생성
138+
const infoEmbed = new EmbedBuilder()
139+
.setTitle(topic || "")
140+
.setDescription(description || "")
141+
.setFooter({
142+
text: "모든 회의 내용은 기록되며, 이후 html 형식으로 제공됩니다.",
143+
})
144+
.setColor("#1E1F22");
145+
146+
// 회의 제어 버튼 생성
147+
const infoMsgRow = new ActionRowBuilder<ButtonBuilder>()
148+
.addComponents(
149+
new ButtonBuilder({
150+
customId: `debate-close_${debateId}`,
151+
label: "회의 종료",
152+
style: ButtonStyle.Danger,
153+
})
154+
)
155+
.addComponents(
156+
new ButtonBuilder({
157+
customId: `debate-html_${debateId}`,
158+
label: "회의록 다운로드",
159+
style: ButtonStyle.Secondary,
160+
})
161+
);
162+
163+
// 채널에 초기 메시지 전송
164+
debateChan?.send({embeds: [infoEmbed], components: [infoMsgRow]});
165+
}
166+
167+
@AddExecute("debate/close")
168+
public async closeDebate(interaction: ChatInputCommandInteraction<CacheType>) {
169+
interaction.reply({
170+
171+
});
172+
}
173+
}

‎src/commands/debate/start-debate.ts

-114
This file was deleted.

‎src/commands/guild.ts

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import {
2+
CacheType,
3+
CacheTypeReducer,
4+
ChannelType,
5+
ChatInputCommandInteraction,
6+
Guild,
7+
SlashCommandBuilder
8+
} from "discord.js";
9+
import {AddExecute, CommandData, InteractionCallbackManager, InteractionHandler} from "../util/interaction-handler";
10+
11+
@CommandData(
12+
new SlashCommandBuilder()
13+
.setName("guild")
14+
.setDescription("길드와 관련된 조작을 할 수 있습니다.")
15+
.addSubcommand((option) =>
16+
option
17+
.setName("init")
18+
.setDescription("길드를 초기화합니다.")
19+
)
20+
.toJSON()
21+
)
22+
@InteractionHandler()
23+
export default class GuildCommand {
24+
25+
// 길드 초기화 명령어
26+
@AddExecute("guild/init")
27+
public async initGuild(interaction: ChatInputCommandInteraction) {
28+
const statusMessage = await interaction.reply({
29+
content: "서버 초기화를 시작합니다.",
30+
fetchReply: true,
31+
ephemeral: true,
32+
});
33+
34+
const guild: Guild | null = interaction.guild;
35+
36+
if (!guild) {
37+
await statusMessage.edit({content: "서버를 찾을 수 없습니다."});
38+
return;
39+
}
40+
41+
const debateChannel = await guild.channels.create({
42+
name: "debate",
43+
type: ChannelType.GuildText,
44+
});
45+
}
46+
}

‎src/commands/guild/init-guild.ts

-26
This file was deleted.

‎src/commands/highlight.ts

+14-7
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
11
import { EmbedBuilder, Message, SlashCommandBuilder } from "discord.js";
2+
import {AddExecute, CommandData, InteractionHandler} from "../util/interaction-handler";
23

3-
module.exports = {
4-
data: new SlashCommandBuilder()
5-
.setName("강조")
4+
@CommandData(
5+
new SlashCommandBuilder()
6+
.setName("highlight")
7+
.setNameLocalization("ko", "강조")
68
.setDescription("메시지를 강조해 표시합니다.")
79
.addStringOption((option) =>
810
option
911
.setName("메시지")
1012
.setDescription("강조할 메시지를 입력합니다.")
1113
.setRequired(true)
12-
),
13-
async execute(interaction: any) {
14+
)
15+
.toJSON()
16+
)
17+
@InteractionHandler()
18+
export default class HighlightCommand {
19+
@AddExecute("highlight")
20+
public async execute(interaction: any) {
1421
await interaction.reply({
1522
content: "메시지를 표시하고 있어요..",
1623
fetchReply: true,
@@ -46,5 +53,5 @@ module.exports = {
4653
await interaction.editReply({
4754
content: "메시지를 강조했어요!",
4855
});
49-
},
50-
};
56+
}
57+
}

‎src/commands/ping.ts

+11-5
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
import { SlashCommandBuilder } from "discord.js";
2+
import {AddExecute, CommandData, InteractionHandler} from "../util/interaction-handler";
23

3-
module.exports = {
4-
data: new SlashCommandBuilder()
4+
@CommandData(
5+
new SlashCommandBuilder()
56
.setName("ping")
6-
.setDescription("현재 서버의 응답 속도를 확인합니다."),
7+
.setDescription("현재 서버의 응답 속도를 확인합니다.")
8+
.toJSON()
9+
)
10+
@InteractionHandler()
11+
export default class PingCommand {
12+
@AddExecute("ping")
713
async execute(interaction: any) {
814
const sent = await interaction.reply({
915
content: "Pong!",
1016
fetchReply: true,
1117
});
1218
const timeTaken = sent.createdTimestamp - interaction.createdTimestamp;
1319
await interaction.followUp(`응답 속도: ${timeTaken}ms`);
14-
},
15-
};
20+
}
21+
}

‎src/commands/reminder/reminder.ts ‎src/commands/reminder.ts

+13-7
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { SlashCommandBuilder } from "discord.js";
2-
import StoreManager from "../../util/manange-store";
3-
import { Reminder, ReminderTime } from "../../types/reminder";
2+
import StoreManager from "../util/manange-store";
3+
import { Reminder, ReminderTime } from "../types/reminder";
4+
import {AddExecute, CommandData, InteractionHandler} from "../util/interaction-handler";
45

5-
module.exports = {
6-
data: new SlashCommandBuilder()
6+
@CommandData(
7+
new SlashCommandBuilder()
78
.setName("remind")
89
.setDescription("요청한 시간에 맞춰 알림을 보냅니다.")
910
.addStringOption((option) =>
@@ -43,7 +44,12 @@ module.exports = {
4344
.setName("mention-author")
4445
.setDescription("작성자 멘션을 하실 건가요?")
4546
.setRequired(false)
46-
),
47+
)
48+
.toJSON()
49+
)
50+
@InteractionHandler()
51+
export default class ReminderCommand {
52+
@AddExecute("remind")
4753
async execute(interaction: any) {
4854
const time = interaction.options.getString("시간");
4955
if (!time) return;
@@ -102,5 +108,5 @@ module.exports = {
102108
}${dateToRemind.getDate()}${dateToRemind.getHours()}${dateToRemind.getMinutes()}분으로 알람을 설정했습니다.`,
103109
ephemeral: true,
104110
});
105-
},
106-
};
111+
}
112+
}

‎src/commands/set-welcome.ts

+139
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import {CacheType, ChatInputCommandInteraction, PermissionsBitField, SlashCommandBuilder,} from "discord.js";
2+
import StoreManager from "../util/manange-store";
3+
import {WelcomeData} from "../types/welcome";
4+
import {AddExecute, CommandData, InteractionHandler} from "../util/interaction-handler";
5+
6+
@CommandData(
7+
new SlashCommandBuilder()
8+
.setName("welcome") // 커맨드 이름 설정
9+
.setDescription("환영 메시지를 설정합니다.") // 커맨드 설명
10+
.addSubcommandGroup((option) =>
11+
option
12+
.setName("set-format")
13+
.setDescription("환영 메세지 포멧을 설정합니다.")
14+
.addSubcommand((option) =>
15+
option
16+
.setName("system")
17+
.setDescription("시스템 채널에 보낼 메세지 형식을 설정합니다.")
18+
.addStringOption(
19+
(option) =>
20+
option
21+
.setName("message")
22+
.setDescription("환영 메시지를 입력합니다.")
23+
.setRequired(true) // 필수 입력 옵션
24+
)
25+
)
26+
.addSubcommand((option) =>
27+
option
28+
.setName("dm")
29+
.setDescription("DM 채널에 보낼 메세지 형식을 설정합니다.")
30+
.addStringOption(
31+
(option) =>
32+
option
33+
.setName("title") // 제목 옵션 추가
34+
.setDescription("환영 메시지의 제목을 입력합니다.")
35+
.setRequired(true) // 필수 입력 항목으로 설정
36+
)
37+
.addStringOption(
38+
(option) =>
39+
option
40+
.setName("description") // 설명 옵션 추가
41+
.setDescription("환영 메시지의 설명을 입력합니다.")
42+
.setRequired(true) // 필수 입력 항목으로 설정
43+
)
44+
)
45+
)
46+
.toJSON()
47+
)
48+
@InteractionHandler()
49+
export default class WelcomeCommand {
50+
51+
// 시스템 채널에 보낼 메세지 형식 설정
52+
@AddExecute("welcome/set-format/system")
53+
async setWelcomeFormatSystem(interaction: ChatInputCommandInteraction<CacheType>) {
54+
if (
55+
!interaction.memberPermissions?.has(
56+
PermissionsBitField.Flags.Administrator
57+
)
58+
) {
59+
interaction.reply({
60+
content: "권한이 없습니다.",
61+
ephemeral: true,
62+
});
63+
return;
64+
}
65+
// 사용자가 입력한 메시지 가져오기
66+
const message = interaction.options.getString("message");
67+
// welcome 저장소 인스턴스 생성
68+
const store = new StoreManager("welcome");
69+
// 현재 서버의 환영 메시지 데이터 가져오기
70+
let welcomeMessage = store.get(interaction.guildId || "") as WelcomeData;
71+
72+
// 환영 메시지가 없는 경우 새로 생성
73+
if (!welcomeMessage) {
74+
welcomeMessage = {
75+
toSystemChannel: message || undefined,
76+
};
77+
} else {
78+
// 기존 환영 메시지 업데이트
79+
welcomeMessage.toSystemChannel = message || undefined;
80+
}
81+
// 변경된 환영 메시지 저장
82+
store.set(interaction.guildId || "", welcomeMessage);
83+
84+
// 설정 완료 응답
85+
interaction.reply({
86+
content: "환영 메시지를 설정하였습니다.",
87+
});
88+
}
89+
90+
// DM 채널에 보낼 메세지 형식 설정
91+
@AddExecute("welcome/set-format/dm")
92+
async setWelcomeFormatDM(interaction: ChatInputCommandInteraction<CacheType>) {
93+
if (
94+
!interaction.memberPermissions?.has(
95+
PermissionsBitField.Flags.Administrator
96+
)
97+
) {
98+
interaction.reply({
99+
content: "권한이 없습니다.",
100+
ephemeral: true,
101+
});
102+
return;
103+
}
104+
// 사용자가 입력한 제목과 설명을 가져옴
105+
const title = interaction.options.getString("title");
106+
const description = interaction.options.getString("description");
107+
108+
// 환영 메시지 저장소 초기화
109+
const store = new StoreManager("global");
110+
111+
// 현재 서버의 환영 메시지 데이터를 가져옴
112+
const welcomeMessage = store.get(
113+
interaction.guildId + ".welcome" || ""
114+
) as WelcomeData;
115+
116+
// 환영 메시지가 설정되어 있지 않은 경우 에러 메시지 반환
117+
if (!welcomeMessage) {
118+
interaction.reply({
119+
content: "환영 메시지가 설정되어 있지 않습니다.",
120+
ephemeral: true,
121+
});
122+
return;
123+
}
124+
125+
// 새로운 환영 메시지 설정
126+
welcomeMessage.toUser = {
127+
title: title || undefined,
128+
description: description || undefined,
129+
};
130+
131+
// 변경된 환영 메시지를 저장소에 저장
132+
store.set(interaction.guildId || "", welcomeMessage);
133+
134+
// 성공 메시지 전송
135+
interaction.reply({
136+
content: "유저에게 보낼 환영 메시지를 설정하였습니다.",
137+
});
138+
}
139+
}

‎src/commands/vote.ts

+376
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,376 @@
1+
import {
2+
SlashCommandBuilder,
3+
ChatInputCommandInteraction,
4+
MessageContextMenuCommandInteraction,
5+
UserContextMenuCommandInteraction,
6+
CacheType,
7+
TextChannel,
8+
EmbedBuilder,
9+
ButtonBuilder,
10+
ActionRowBuilder,
11+
ButtonStyle,
12+
PermissionsBitField, ChannelType,
13+
} from "discord.js";
14+
import StoreManager from "../util/manange-store";
15+
import { VoteData } from "../types/vote";
16+
import {AddExecute, CommandData, InteractionHandler} from "../util/interaction-handler";
17+
18+
@CommandData(
19+
new SlashCommandBuilder()
20+
.setName("vote") // 커맨드 이름 설정
21+
.setDescription("투표 관련 조작을 할 수 있습니다.") // 커맨드 설명
22+
// 투표 시작 커맨드
23+
.addSubcommand((option) =>
24+
option
25+
.setName("start")
26+
.setDescription("새로운 투표를 시작합니다.")
27+
.addStringOption((option) =>
28+
option
29+
.setName("title")
30+
.setDescription("투표의 제목을 입력합니다.")
31+
.setRequired(true)
32+
)
33+
// 투표 설명 옵션
34+
.addStringOption((option) =>
35+
option
36+
.setName("description")
37+
.setDescription("투표의 설명을 입력합니다.")
38+
.setRequired(true)
39+
)
40+
// 투표를 보낼 채널 옵션
41+
.addChannelOption((option) =>
42+
option
43+
.setName("channel")
44+
.setDescription("투표를 보낼 채널을 선택합니다.")
45+
.setRequired(true)
46+
)
47+
// 투표 항목 개수 옵션 (2-10개)
48+
.addIntegerOption((option) =>
49+
option
50+
.setName("options_count")
51+
.setDescription("투표 항목의 개수를 입력합니다 (최대 10개)")
52+
.setRequired(true)
53+
.setMinValue(2)
54+
.setMaxValue(10)
55+
)
56+
// 투표 항목 1-10 옵션들
57+
.addStringOption((option) =>
58+
option
59+
.setName("option1")
60+
.setDescription("첫 번째 투표 항목")
61+
.setRequired(true)
62+
)
63+
.addStringOption((option) =>
64+
option
65+
.setName("option2")
66+
.setDescription("두 번째 투표 항목")
67+
.setRequired(true)
68+
)
69+
.addStringOption((option) =>
70+
option
71+
.setName("option3")
72+
.setDescription("세 번째 투표 항목")
73+
.setRequired(false)
74+
)
75+
.addStringOption((option) =>
76+
option
77+
.setName("option4")
78+
.setDescription("네 번째 투표 항목")
79+
.setRequired(false)
80+
)
81+
.addStringOption((option) =>
82+
option
83+
.setName("option5")
84+
.setDescription("다섯 번째 투표 항목")
85+
.setRequired(false)
86+
)
87+
.addStringOption((option) =>
88+
option
89+
.setName("option6")
90+
.setDescription("여섯 번째 투표 항목")
91+
.setRequired(false)
92+
)
93+
.addStringOption((option) =>
94+
option
95+
.setName("option7")
96+
.setDescription("일곱 번째 투표 항목")
97+
.setRequired(false)
98+
)
99+
.addStringOption((option) =>
100+
option
101+
.setName("option8")
102+
.setDescription("여덟 번째 투표 항목")
103+
.setRequired(false)
104+
)
105+
.addStringOption((option) =>
106+
option
107+
.setName("option9")
108+
.setDescription("아홉 번째 투표 항목")
109+
.setRequired(false)
110+
)
111+
.addStringOption((option) =>
112+
option
113+
.setName("option10")
114+
.setDescription("열 번째 투표 항목")
115+
.setRequired(false)
116+
)
117+
)
118+
// 투표 종료 커맨드
119+
.addSubcommand((option) =>
120+
option
121+
.setName("close")
122+
.setDescription("투표를 종료합니다.")
123+
.addStringOption(
124+
(option) =>
125+
option
126+
.setName("vote_id") // 투표 ID 옵션
127+
.setDescription("종료할 투표의 아이디를 입력합니다.")
128+
.setRequired(true) // 필수 옵션
129+
)
130+
.addBooleanOption(
131+
(option) =>
132+
option
133+
.setName("mention_everyone") // @everyone 멘션 옵션
134+
.setDescription("모든 사람에게 투표 결과 공개 알림을 보냅니다.")
135+
.setRequired(false) // 선택 옵션
136+
)
137+
.addChannelOption(
138+
(option) =>
139+
option
140+
.setName("channel") // 결과 공개 채널 옵션
141+
.setDescription("투표 결과 공개 알림을 보낼 채널을 선택합니다.")
142+
.setRequired(false) // 선택 옵션
143+
)
144+
)
145+
.toJSON()
146+
)
147+
@InteractionHandler()
148+
export default class VoteCommand {
149+
150+
// 투표 시작 커맨드
151+
@AddExecute("vote/start")
152+
async startVote(interaction: ChatInputCommandInteraction<CacheType>) {
153+
if (
154+
!interaction.memberPermissions?.has(
155+
PermissionsBitField.Flags.Administrator
156+
)
157+
) {
158+
interaction.reply({
159+
content: "권한이 없습니다.",
160+
ephemeral: true,
161+
});
162+
return;
163+
}
164+
// 투표 데이터 저장소 초기화
165+
const store = new StoreManager("votes");
166+
const channel = interaction.channel as TextChannel;
167+
168+
// 채널 유효성 검사
169+
if (!channel) {
170+
interaction.reply({
171+
content: "채널을 선택해주세요.",
172+
ephemeral: true,
173+
});
174+
return;
175+
}
176+
if (!("options" in interaction)) return;
177+
178+
// 투표 제목과 설명 가져오기
179+
const title = (
180+
interaction as ChatInputCommandInteraction
181+
).options.getString("title");
182+
const description = (
183+
interaction as ChatInputCommandInteraction
184+
).options.getString("description");
185+
186+
// 고유한 투표 ID 생성
187+
const voteId = `${Date.now()}`;
188+
189+
// 임베드 메시지 생성
190+
const embed = new EmbedBuilder()
191+
.setTitle(title)
192+
.setDescription(description)
193+
.setColor("#eb7723")
194+
.setFooter({
195+
text: `vote id: ${voteId}`,
196+
})
197+
.setTimestamp();
198+
199+
// 투표 항목 개수 가져오기
200+
const optionsCount = (
201+
interaction as ChatInputCommandInteraction
202+
).options.getInteger("options_count");
203+
if (!optionsCount) return;
204+
205+
// 버튼 컴포넌트 생성
206+
const row = new ActionRowBuilder<ButtonBuilder>();
207+
208+
// 투표 데이터 구조 초기화
209+
let voteData: VoteData = {
210+
title: title || "",
211+
votedUser: [],
212+
options: {},
213+
closed: false,
214+
};
215+
216+
// 투표 항목별 버튼 생성 및 데이터 구조화
217+
for (let i = 1; i <= optionsCount; i++) {
218+
const buttonLabel = (
219+
interaction as ChatInputCommandInteraction
220+
).options.getString(`option${i}`);
221+
if (!buttonLabel) return;
222+
const button = new ButtonBuilder()
223+
.setCustomId(`vote_${voteId}_${buttonLabel}`)
224+
.setLabel(buttonLabel)
225+
.setStyle(ButtonStyle.Primary);
226+
row.addComponents(button);
227+
voteData.options[buttonLabel] = { count: 0 };
228+
}
229+
230+
// 투표 데이터 저장
231+
store.set(voteId, voteData);
232+
233+
// 지정된 채널에 투표 메시지 전송
234+
const voteChannel = interaction.options.getChannel("channel");
235+
if (!voteChannel) return;
236+
if (voteChannel.type !== ChannelType.GuildText) return;
237+
await (voteChannel as TextChannel).send({
238+
embeds: [embed],
239+
components: [row],
240+
});
241+
242+
// 투표 생성 완료 메시지 전송
243+
interaction.reply({
244+
content: "투표가 생성되었습니다.",
245+
ephemeral: true,
246+
});
247+
}
248+
249+
// 투표 종료 커맨드
250+
@AddExecute("vote/close")
251+
async closeVote(interaction: any) {
252+
if (
253+
!interaction.memberPermissions?.has(
254+
PermissionsBitField.Flags.Administrator
255+
)
256+
) {
257+
interaction.reply({
258+
content: "권한이 없습니다.",
259+
ephemeral: true,
260+
});
261+
return;
262+
}
263+
264+
// 투표 ID 가져오기
265+
const voteId = interaction.options.getString("vote_id");
266+
if (!voteId) {
267+
interaction.reply({
268+
content: "투표 아이디를 입력해주세요.",
269+
ephemeral: true,
270+
});
271+
return;
272+
}
273+
274+
// 투표 데이터 스토어에서 투표 정보 가져오기
275+
const store = new StoreManager("votes");
276+
const vote = store.get(voteId) as VoteData;
277+
278+
// 투표가 존재하지 않는 경우
279+
if (!vote) {
280+
interaction.reply({
281+
content: "존재하지 않는 투표입니다.",
282+
ephemeral: true,
283+
});
284+
return;
285+
}
286+
287+
// 이미 종료된 투표인 경우
288+
if (vote.closed) {
289+
interaction.reply({
290+
content: "이미 종료된 투표입니다.",
291+
ephemeral: true,
292+
});
293+
return;
294+
}
295+
296+
// 투표 종료 처리
297+
vote.closed = true;
298+
store.set(voteId, vote);
299+
interaction.reply({
300+
content: "투표가 종료되었습니다.",
301+
ephemeral: true,
302+
});
303+
304+
// 옵션 값 가져오기
305+
const mentionEveryone = interaction.options.getBoolean("mention_everyone");
306+
const channel = interaction.options.getChannel("channel");
307+
308+
// 지정된 채널이 있는 경우
309+
if (channel) {
310+
// @everyone 멘션이 활성화된 경우
311+
if (mentionEveryone) {
312+
await channel?.send({
313+
content: "@everyone",
314+
});
315+
}
316+
317+
// 투표 결과 임베드 생성
318+
const embed = new EmbedBuilder()
319+
.setColor("#eb7723")
320+
.setTitle("투표가 종료되었습니다.")
321+
.setDescription(
322+
`투표 타이틀:${
323+
vote.title
324+
} \n투표 id: ${voteId}\n \n **가장 많은 표를 받은 항목: ${
325+
// 가장 많은 표를 받은 항목 찾기
326+
Object.entries(vote.options).reduce(
327+
(max, [key, value]) =>
328+
value.count > max[1].count ? [key, value] : max,
329+
["", { count: -1 }]
330+
)[0]
331+
}**\n\n`
332+
);
333+
await channel?.send({
334+
embeds: [embed],
335+
});
336+
} else {
337+
// 채널이 지정되지 않은 경우 현재 채널에 결과 표시
338+
if (mentionEveryone) {
339+
await interaction.channel?.send({
340+
content: "@everyone",
341+
});
342+
}
343+
344+
// 투표 결과 임베드 생성
345+
const embed = new EmbedBuilder()
346+
.setColor("#eb7723")
347+
.setTitle("투표가 종료되었습니다.")
348+
.setDescription(
349+
`투표 타이틀:${
350+
vote.title
351+
} \n투표 id: ${voteId}\n \n **가장 많은 표를 받은 항목: ${
352+
// 가장 많은 표를 받은 항목 찾기
353+
Object.entries(vote.options).reduce(
354+
(max, [key, value]) =>
355+
value.count > max[1].count ? [key, value] : max,
356+
["", { count: -1 }]
357+
)[0]
358+
}**\n\n`
359+
);
360+
361+
// 각 투표 옵션별 결과 추가
362+
for (const option in vote.options) {
363+
embed.addFields({
364+
name: option,
365+
value: `${vote.options[option].count}회 클릭됨.`,
366+
});
367+
}
368+
369+
store.delete(voteId);
370+
371+
await interaction.channel?.send({
372+
embeds: [embed],
373+
});
374+
}
375+
}
376+
}

‎src/commands/vote/vote-close.ts

-162
This file was deleted.

‎src/commands/vote/vote.ts

-218
This file was deleted.

‎src/commands/welcome/set-welcome-user.ts

-75
This file was deleted.

‎src/commands/welcome/set-welcome.ts

-59
This file was deleted.

‎src/events/button/debate-getHtml.ts ‎src/components/button/debate.ts

+83-19
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,88 @@
1-
import { ButtonInteraction } from "discord.js";
1+
import {ButtonInteraction, ChannelType, PermissionsBitField,} from "discord.js";
22
import StoreManager from "../../util/manange-store";
3-
import { DebateData } from "../../types/debate";
4-
import fs from "fs";
5-
import { makeDebateHtml } from "../../util/make-debateHtml";
3+
import {DebateData} from "../../types/debate";
4+
import {makeDebateHtml} from "../../util/make-debateHtml";
5+
import {AddExecute, InteractionHandler} from "../../util/interaction-handler";
66

7-
module.exports = {
8-
// 버튼 상호작용이 debate-html_ 으로 시작하는지 확인하는 함수
9-
check: (interaction: ButtonInteraction) => {
7+
@InteractionHandler()
8+
export default class DebateButton {
9+
10+
// 회의 종료 버튼
11+
@AddExecute("debate-close")
12+
async closeDebate(interaction: ButtonInteraction) {
1013
try {
11-
return interaction.customId.startsWith("debate-html_");
14+
const debateId = interaction.customId.split("_")[1];
15+
const store = new StoreManager("debate");
16+
const debate = store.get(debateId) as DebateData;
17+
if (!debate) {
18+
await interaction.reply({
19+
content: "회의를 종료할 수 없습니다.",
20+
ephemeral: true,
21+
});
22+
return;
23+
}
24+
25+
if (
26+
!interaction.memberPermissions?.has(
27+
PermissionsBitField.Flags.Administrator
28+
) &&
29+
interaction.user.id !== debate.author
30+
) {
31+
await interaction.reply({
32+
content: "권한이 없습니다.",
33+
ephemeral: true,
34+
});
35+
return;
36+
}
37+
38+
debate.closed = true;
39+
40+
const channel = interaction.guild?.channels.cache.get(debate.interactionChannelId);
41+
if (channel?.type !== ChannelType.GuildText) {
42+
await interaction.reply({
43+
content: "회의를 종료할 수 없습니다.",
44+
ephemeral: true,
45+
});
46+
return;
47+
}
48+
49+
try {
50+
const attachment = makeDebateHtml(debate, interaction);
51+
52+
const startMessage = await channel.messages.fetch(
53+
debate.triggerMessageId
54+
);
55+
if (startMessage) {
56+
await startMessage.edit({
57+
content: "회의를 종료하였습니다. 회의 기록은 아래를 참고해 주세요.",
58+
files: [attachment],
59+
embeds: [],
60+
components: []
61+
});
62+
}
63+
64+
await interaction.reply({
65+
content: "회의를 종료하였습니다.",
66+
});
67+
68+
const voiceChan = interaction.guild?.channels.cache.get(debate.channelId);
69+
if (voiceChan) {
70+
await voiceChan.delete();
71+
store.delete(debateId);
72+
} else {
73+
store.set(debateId, debate);
74+
}
75+
} catch (error) {
76+
console.error("회의록 생성 중 오류:", error);
77+
}
1278
} catch (error) {
13-
interaction.reply({
14-
content: "커스텀 ID를 확인하는 중 오류가 발생했습니다.",
15-
ephemeral: true,
16-
});
17-
return false;
79+
console.error("예기치 못한 오류:", error);
1880
}
19-
},
20-
// 버튼 클릭 시 실행되는 메인 함수
21-
execute: async (interaction: ButtonInteraction) => {
81+
}
82+
83+
// 회의록 가져오기 버튼
84+
@AddExecute("debate-html")
85+
async getHtml(interaction: ButtonInteraction) {
2286
try {
2387
// 디베이트 ID 추출 및 스토어에서 데이터 가져오기
2488
const debateId = interaction.customId.split("_")[1];
@@ -42,7 +106,7 @@ module.exports = {
42106
}
43107
}
44108

45-
// 초기 응답 전송
109+
// 초기 응답 전송
46110
try {
47111
await interaction.reply({
48112
content: "회의록 만드는 중..",
@@ -111,5 +175,5 @@ module.exports = {
111175
ephemeral: true,
112176
});
113177
}
114-
},
115-
};
178+
}
179+
}

‎src/events/button/vote.ts ‎src/components/button/vote.ts

+7-7
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { ButtonInteraction, EmbedBuilder } from "discord.js";
22
import StoreManager from "../../util/manange-store";
33
import { VoteData } from "../../types/vote";
4+
import {AddExecute, InteractionHandler} from "../../util/interaction-handler";
45

5-
module.exports = {
6-
check: (interaction: ButtonInteraction) => {
7-
return interaction.customId.startsWith("vote_");
8-
},
9-
execute: async (interaction: ButtonInteraction) => {
6+
@InteractionHandler()
7+
export default class VoteButton {
8+
@AddExecute("vote")
9+
async vote(interaction: ButtonInteraction) {
1010
const params = interaction.customId.split("_");
1111
const voteId = params[1];
1212
const option = params[2];
@@ -35,5 +35,5 @@ module.exports = {
3535
content: "투표가 완료되었습니다.",
3636
ephemeral: true,
3737
});
38-
},
39-
};
38+
}
39+
}

‎src/events/button/debate-close.ts

-87
This file was deleted.

‎src/index.ts

+84-65
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,10 @@
1-
import {
2-
Client,
3-
GatewayIntentBits,
4-
REST,
5-
Routes,
6-
Interaction,
7-
Collection,
8-
ActivityType,
9-
} from "discord.js";
10-
import { readdirSync } from "fs";
11-
import { join } from "path";
1+
import {ActivityType, Client, Collection, GatewayIntentBits, REST,} from "discord.js";
2+
import {readdirSync} from "fs";
3+
import {join} from "path";
124
import StoreManager from "./util/manange-store";
13-
import { registReminder } from "./util/reminder";
5+
import {registReminder} from "./util/reminder";
6+
import {RESTPostAPIApplicationCommandsJSONBody, Routes} from "discord-api-types/v10";
7+
import {InteractionCallbackManager} from "./util/interaction-handler";
148

159
require("dotenv").config();
1610

@@ -28,10 +22,11 @@ const client = new Client({
2822
const token = process.env.DISCORD_TOKEN || "";
2923
const clientId = process.env.CLIENT_ID || "";
3024

31-
const rest = new REST({ version: "10" }).setToken(token);
25+
const rest = new REST({version: "10"}).setToken(token);
3226

33-
// 커맨드를 저장할 Collection 생성
34-
const commands = new Collection();
27+
// InteractionCallbackManager 를 저장할 Collection 생성
28+
const commands: Collection<string, InteractionCallbackManager> = new Collection();
29+
const components: InteractionCallbackManager[] = [];
3530

3631
client.once("ready", async () => {
3732
if (process.env.NODE_ENV === "production") {
@@ -44,13 +39,13 @@ client.once("ready", async () => {
4439
});
4540
client.user?.setStatus("idle");
4641

47-
const getAllCommands = (dir: string): string[] => {
42+
const getAllScriptFiles = (dir: string): string[] => {
4843
let files: string[] = [];
49-
const items = readdirSync(join(__dirname, dir), { withFileTypes: true });
44+
const items = readdirSync(join(__dirname, dir), {withFileTypes: true});
5045

5146
for (const item of items) {
5247
if (item.isDirectory()) {
53-
files = [...files, ...getAllCommands(`${dir}/${item.name}`)];
48+
files = [...files, ...getAllScriptFiles(`${dir}/${item.name}`)];
5449
} else if (
5550
item.isFile() &&
5651
(item.name.endsWith(".js") || item.name.endsWith(".ts"))
@@ -61,32 +56,86 @@ client.once("ready", async () => {
6156
return files;
6257
};
6358

64-
const commandFiles = getAllCommands("commands");
59+
// 커맨드 파일 가져오기
60+
const commandFiles = getAllScriptFiles("commands");
6561

66-
// 커맨드들을 Collection에 저장 및 JSON 변환
67-
const commandsArray = commandFiles.map((file) => {
68-
const command = require(`./${file}`);
69-
commands.set(command.data.name, command);
70-
return command.data.toJSON();
71-
});
62+
let commandData: RESTPostAPIApplicationCommandsJSONBody[] = [];
63+
64+
for (const file of commandFiles) {
65+
const obj = await import(`./${file}`);
66+
67+
const prototype = obj.default.prototype;
68+
if (Reflect.hasMetadata("discord:interaction", prototype) && Reflect.hasMetadata("discord:command", prototype)) {
69+
const data = Reflect.getMetadata("discord:command", prototype) as RESTPostAPIApplicationCommandsJSONBody;
70+
const manager = Reflect.getMetadata("discord:interaction", prototype) as InteractionCallbackManager;
71+
72+
commandData.push(data);
73+
commands.set(data.name, manager);
74+
}
75+
}
76+
77+
// 컴포넌트 파일 가져오기
78+
const componentFiles = getAllScriptFiles("components");
79+
80+
for (const file of componentFiles) {
81+
const obj = await import(`./${file}`);
82+
83+
const prototype = obj.default.prototype;
84+
if (Reflect.hasMetadata("discord:interaction", prototype)) {
85+
const manager = Reflect.getMetadata("discord:interaction", prototype) as InteractionCallbackManager;
86+
components.push(manager);
87+
}
88+
}
7289

7390
new StoreManager("global");
7491

7592
console.log("Successfully set global store.");
7693

77-
try {
78-
console.log("Started refreshing application (/) commands.");
94+
if (commandData.length > 0) {
95+
try {
96+
console.log("Started refreshing application (/) commands.");
7997

80-
await rest.put(Routes.applicationCommands(clientId), {
81-
body: commandsArray,
82-
});
98+
await rest.put(
99+
Routes.applicationCommands(clientId),
100+
{body: commandData},
101+
);
83102

84-
console.log(
85-
`Successfully reloaded application ${commandFiles.length} (/) commands.`
86-
);
87-
} catch (error) {
88-
console.error(error);
103+
console.log(`Successfully reloaded application ${commandFiles.length} (/) commands.`);
104+
} catch (error) {
105+
console.error(error);
106+
}
89107
}
108+
109+
client.on("interactionCreate", (interaction) => {
110+
if (interaction.isCommand()) {
111+
let key = interaction.commandName;
112+
const command = commands.get(key);
113+
114+
if (!command) {
115+
return;
116+
}
117+
118+
if (interaction.isChatInputCommand()) {
119+
const groupName = interaction.options.getSubcommandGroup();
120+
const subcommandName = interaction.options.getSubcommand(false);
121+
122+
key += groupName ? "/" + groupName : "";
123+
key += subcommandName ? "/" + subcommandName : "";
124+
}
125+
126+
command.call(key, interaction);
127+
}
128+
129+
if (interaction.isMessageComponent()) {
130+
for (const component of components) {
131+
const customId = interaction.customId.split("_")[0];
132+
if (component.contain(customId)) {
133+
component.call(customId, interaction);
134+
return;
135+
}
136+
}
137+
}
138+
});
90139
});
91140

92141
// 이벤트 파일을 읽어와서 등록
@@ -104,36 +153,6 @@ for (const file of eventFiles) {
104153
event.register(client);
105154
}
106155

107-
client.on("interactionCreate", async (interaction: Interaction) => {
108-
if (interaction.isCommand()) {
109-
const command = commands.get(interaction.commandName);
110-
if (!command) return;
111-
112-
try {
113-
await (command as any).execute(interaction);
114-
} catch (error) {
115-
console.error(error);
116-
await interaction.reply({
117-
content: "There was an error executing this command!",
118-
ephemeral: true,
119-
});
120-
}
121-
}
122-
123-
if (interaction.isButton()) {
124-
const buttonEventFiles = readdirSync(
125-
join(__dirname, "events/button")
126-
).filter((file) => file.endsWith(".js") || file.endsWith(".ts"));
127-
128-
for (const file of buttonEventFiles) {
129-
const event = require(`./events/button/${file}`);
130-
if (typeof event.check === "function" && event.check(interaction)) {
131-
await event.execute(interaction);
132-
}
133-
}
134-
}
135-
});
136-
137156
registReminder(client);
138157

139158
client.login(token);

‎src/types/debate.d.ts

+5-4
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
export interface DebateData {
2-
title: string;
2+
topic: string;
33
author: string;
4-
triggerMessage: string;
54
description: string;
6-
channel: string;
7-
threadId: string;
5+
interactionChannelId: string;
6+
triggerMessageId: string;
7+
categoryId: string;
8+
channelId: string;
89
messages: MessageData[];
910
closed: boolean;
1011
}

‎src/types/discord-interaction.d.ts

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import {Interaction} from "discord.js";
2+
3+
export type InteractionCallbackMethod<T extends Interaction> = (interaction: T) => any;
4+
5+
export type InteractionCallbackPropertyDescriptor<T extends Interaction> =
6+
TypedPropertyDescriptor<InteractionCallbackMethod<T>>;
7+
8+
export type InteractionCallbackMethodDecorator =
9+
<T extends Interaction>(
10+
target: Object,
11+
propertyKey: string,
12+
descriptor: InteractionCallbackPropertyDescriptor<T>
13+
) => InteractionCallbackPropertyDescriptor<T> | void;

‎src/util/interaction-handler.ts

+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import {RESTPostAPIApplicationCommandsJSONBody} from "discord-api-types/v10";
2+
import {Collection, Interaction} from "discord.js";
3+
import "reflect-metadata";
4+
import {
5+
InteractionCallbackMethod,
6+
InteractionCallbackMethodDecorator,
7+
InteractionCallbackPropertyDescriptor
8+
} from "../types/discord-interaction";
9+
10+
export class InteractionCallbackManager {
11+
protected callbackFns: Collection<string, InteractionCallbackMethod<Interaction>>;
12+
13+
constructor(o: any) {
14+
this.callbackFns = new Collection<string, InteractionCallbackMethod<Interaction>>();
15+
16+
const prototype = o.prototype;
17+
const propertyNames = Object.getOwnPropertyNames(prototype);
18+
for (const propertyName of propertyNames) {
19+
const metadata = Reflect.getMetadata("discord:interaction", prototype, propertyName)
20+
if (!metadata) {
21+
continue;
22+
}
23+
24+
const descriptor = Reflect.getOwnPropertyDescriptor(prototype, propertyName);
25+
if (!descriptor) {
26+
continue;
27+
}
28+
29+
this.callbackFns.set(metadata, descriptor.value);
30+
}
31+
}
32+
33+
public contain(key: string): boolean {
34+
return this.callbackFns.has(key);
35+
}
36+
37+
public async call(key: string, interaction: Interaction) {
38+
const callbackFn = this.callbackFns.get(key);
39+
if (!callbackFn) {
40+
console.error(`${key} callback method not exist`);
41+
return;
42+
}
43+
await callbackFn(interaction);
44+
}
45+
}
46+
47+
export function CommandData(data: RESTPostAPIApplicationCommandsJSONBody): ClassDecorator {
48+
return <T extends Function>(constructor: T) => {
49+
Reflect.defineMetadata("discord:command", data, constructor.prototype);
50+
}
51+
}
52+
53+
export function InteractionHandler(): ClassDecorator {
54+
return <T extends Function>(constructor: T) => {
55+
Reflect.defineMetadata("discord:interaction", new InteractionCallbackManager(constructor), constructor.prototype);
56+
}
57+
}
58+
59+
export function AddExecute(key: string): InteractionCallbackMethodDecorator {
60+
return <T extends Interaction>(
61+
target: Object,
62+
propertyKey: string,
63+
_descriptor: InteractionCallbackPropertyDescriptor<T>
64+
) => {
65+
Reflect.defineMetadata("discord:interaction", key, target, propertyKey);
66+
}
67+
}

‎src/util/make-debateHtml.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export function makeDebateHtml(debate: DebateData, interaction: any) {
6464
}, "");
6565
// HTML 템플릿에 제목과 메시지 내용 삽입
6666
const html = baseHtml
67-
.replace(new RegExp("{title}", "g"), debate.title)
67+
.replace(new RegExp("{title}", "g"), debate.topic)
6868
.replace("{messages}", userMessages);
6969

7070
// HTML을 버퍼로 변환하여 첨부 파일 생성

‎tsconfig.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
{
22
"compilerOptions": {
3-
"target": "ES2020",
3+
"target": "ES6",
44
"module": "commonjs",
55
"strict": true,
66
"esModuleInterop": true,
77
"skipLibCheck": true,
88
"forceConsistentCasingInFileNames": true,
9+
"experimentalDecorators": true,
10+
"emitDecoratorMetadata": true,
911
"outDir": "./dist"
1012
},
1113
"include": ["src/**/*"],

0 commit comments

Comments
 (0)
Please sign in to comment.