Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,12 @@ private void sendScheduledMessage(ScheduledMessage scheduledMessage) {
return;
}

channel.sendMessage(scheduledMessage.getContent()).queue();
String parsedContent = scheduledMessage.getContent()
.replaceAll("`<", "<") // 맨 앞 백틱 제거
.replaceAll(">`", ">"); // 맨 뒤 백틱 제거

channel.sendMessage(parsedContent).queue();

log.info("📢 예약된 메시지가 Discord 채널({})에 전송됨: {}", scheduledMessage.getChannelId(), scheduledMessage.getContent());
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package greedy.greedybot.presentation.jda.configuration;

import greedy.greedybot.presentation.jda.listener.ScheduledMessageModalLauncher;
import greedy.greedybot.presentation.jda.listener.ScheduledMessageSubmitListener;
import greedy.greedybot.presentation.jda.listener.SlashCommandListenerMapper;
import java.util.EnumSet;
import net.dv8tion.jda.api.JDA;
Expand All @@ -17,6 +19,9 @@
public class JdaConfiguration {

private final SlashCommandListenerMapper slashCommandListenerMapper;
private final ScheduledMessageModalLauncher scheduledMessageModalLauncher;
private final ScheduledMessageSubmitListener scheduledMessageSubmitListener;

@Value("${discord.token}")
private String token;
@Value("${discord.guild_id}")
Expand All @@ -26,8 +31,12 @@ public class JdaConfiguration {
@Value("${discord.scheduled_message_channel_id}")
private String scheduledMessageChannelId;

public JdaConfiguration(final SlashCommandListenerMapper slashCommandListenerMapper) {
public JdaConfiguration(SlashCommandListenerMapper slashCommandListenerMapper,
ScheduledMessageModalLauncher scheduledMessageModalLauncher,
ScheduledMessageSubmitListener scheduledMessageSubmitListener) {
this.slashCommandListenerMapper = slashCommandListenerMapper;
this.scheduledMessageModalLauncher = scheduledMessageModalLauncher;
this.scheduledMessageSubmitListener = scheduledMessageSubmitListener;
}

@Bean
Expand All @@ -43,7 +52,10 @@ JDA jda() throws InterruptedException {
return JDABuilder.createLight(token)
.setActivity(Activity.listening("메세지 입력"))
.setStatus(OnlineStatus.ONLINE)
.addEventListeners(slashCommandListenerMapper)
.addEventListeners( // 수동 입력
slashCommandListenerMapper,
scheduledMessageSubmitListener
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

빈 등록 하나가 누락 되었는데, 이것만 추가 하고 머지하시죠!

Suggested change
scheduledMessageSubmitListener
scheduledMessageSubmitListener,
scheduledMessageModalLauncher

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분 관련해서 질문이 있습니다🤔

ScheduledMessageModalLauncher를 Listener에 등록할 경우 모달 제출 시 다음과 같은 에러가 발생했습니다:

[ErrorResponseException] 10062: Failed to acknowledge this interaction
net.dv8tion.jda.api.exceptions.ErrorResponseException: 10062: Unknown interaction

이 에러는 하나의 Interaction을 두 개의 Listener(ScheduledMessageModalLauncher, ScheduledMessageSubmitListener)가 동시에 수신하면서 중복 응답이 발생했기 때문입니다.

그래서 실제로 이벤트를 처리해야 하는 SlashCommandListenerMapper(=명령어 등록)와 ScheduledMessageSubmitListener(=모달 제출) 두 개 Bean만 등록하도록 수정했습니다.
(이렇게 수정했을 시 정상 작동은 하면서 위의 오류가 발생하지 않습니다!)

Copy link
Member Author

@haeyoon1 haeyoon1 Oct 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

많은 리스너들 중 왜 하필 저 두 리스너만 충돌이 일어날까?에 대해 찾아보았으나 충분한 이해가 되지 않습니다....🤯
일단 플로우를 찾아보았을 때 아래와 같다고 하네요. 🚨 표시된 곳이 문제가 일어나는 부분들인 것 같은데 알맞게 이해한 것인지 태연님 생각이 궁금합니다!

① 드롭다운 선택 → 모달 띄우기 (ScheduledMessageModalLauncher)

@Override
public void onStringSelectInteraction(@NotNull StringSelectInteractionEvent event) {
    // 모달 생성
    
    // 첫번째 ACK
    event.replyModal(modal).queue();
}

JDA가 Discord에 첫 번째 응답 전송

"사용자가 드롭다운을 선택 → 모달을 띄워줬으니 이벤트 응답 완료!"


② 사용자가 모달에 메시지/시간을 입력하고 제출함

ScheduledMessageSubmitListener 에서 ModalInteractionEvent 발생

→ 🚨🚨🚨 새로운 Interaction(ModalInteractionEvent)을 모든 ListenerAdapter에게 broadcast


ScheduledMessageSubmitListener 가 이벤트를 받아 처리

@Override
public void onModalInteraction(@NotNull ModalInteractionEvent event) {
    // 올바른 모달 ID면 예약 메시지 등록 처리

    // 메시지 파싱 + 유효성 검증

    // 성공적으로 저장 후 Discord에 응답
    event.reply("✅ 메시지가 예약되었습니다.").queue(); // 두 번째 ACK
}

⚠️ 하지만 ScheduledMessageModalLauncher 도 ListenerAdapter 이기 때문에

🚨🚨🚨 JDA는 이 ModalInteractionEventScheduledMessageModalLauncher에게도 전달

-> 내부적으로

[JDA 내부 이벤트 브로드캐스트]
 ├── ScheduledMessageSubmitListener.onModalInteraction(event)
 └── ScheduledMessageModalLauncher.onModalInteraction(event?) ← 중복


⑤ Discord 입장에서는 중복 응답

  • ScheduledMessageSubmitListenerevent.reply("✅ 메시지가...")
  • ScheduledMessageModalLauncher → 내부적으로 이미 event.replyModal() 로 응답한 적 있음

→ Discord API는 해당 이벤트가 이미 응답된 Interaction임을 감지

→ 에러 발생

[ErrorResponseException] 10062: Failed to acknowledge this interaction
40060: Interaction already acknowledged

)
.enableIntents(intents)
.build()
.awaitReady(); // https://ci.dv8tion.net/job/JDA/javadoc/net/dv8tion/jda/api/JDABuilder.html#build()
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package greedy.greedybot.presentation.jda.listener;

import greedy.greedybot.common.exception.GreedyBotException;
import greedy.greedybot.presentation.jda.role.DiscordRole;
import greedy.greedybot.presentation.jda.role.ScheduledMessageChannel;
import java.util.Map;
import java.util.Set;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
import net.dv8tion.jda.api.interactions.commands.OptionMapping;
import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.Commands;
import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData;
import net.dv8tion.jda.api.interactions.components.selections.StringSelectMenu;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
public class ScheduledMessageCommandListener implements SlashCommandListener {

private static final Logger log = LoggerFactory.getLogger(ScheduledMessageCommandListener.class);

private static final Map<String, ScheduledMessageChannel> CHANNEL_NAME_TO_ENUM = Map.of(
"🚀공통-자유", ScheduledMessageChannel.NOTICE,
"🍃백엔드-스터디", ScheduledMessageChannel.BACKEND,
"\uD83E\uDD8B프론트엔드-스터디", ScheduledMessageChannel.FRONT,
"\uD83E\uDEE7리드-대화", ScheduledMessageChannel.LEAD_CONVERSATION,
"tf-discord-test-ground", ScheduledMessageChannel.TEST
);

@Value("${discord.message_writing_channel}")
private Long allowedChannelId;

@Override
public String getCommandName() {
return "scheduled-message";
}

@Override
public SlashCommandData getCommandData() {
return Commands.slash("scheduled-message", "예약 메세지 등록")
.addOption(OptionType.STRING, "member", "멘션할 멤버나 그룹이 있다면, 엔터 대신 Space(띄어쓰기)로 구분해 입력하세요. \n"
+ "사용자는 @이름 형태로 입력하세요.", false);
}

@Override
public void onAction(@NotNull SlashCommandInteractionEvent event) {
try {
validateAllowedChannel(event);

String members = null;
OptionMapping memberOption = event.getOption("member");
if (memberOption != null) {
members = memberOption.getAsString();
}

String selectMenuId = "scheduled-channel-select";
if (members != null && !members.isEmpty()) {
selectMenuId += ":" + members;
}

// 채널 선택 드롭다운 생성
StringSelectMenu.Builder channelMenu = StringSelectMenu.create(selectMenuId)
.setPlaceholder("메시지를 보낼 채널을 선택하세요");

CHANNEL_NAME_TO_ENUM.forEach((name, enumValue) -> {
channelMenu.addOption(name, enumValue.name());
});

event.reply("📌 메시지를 보낼 채널을 선택하세요:")
.addActionRow(channelMenu.build())
.setEphemeral(true)
.queue();
} catch (GreedyBotException e) {
log.error(e.getMessage());
event.reply(e.getMessage()).setEphemeral(true).queue();
}
}

private void validateAllowedChannel(final @NotNull SlashCommandInteractionEvent event) {
if (event.getChannel().getIdLong() != allowedChannelId) {
log.warn("[NOT ALLOWED CHANNEL COMMAND]: {}", event.getUser().getEffectiveName());
throw new GreedyBotException("예약 메세지는 현재 채널에서 작성할 수 없습니다");
}
}

@Override
public Set<DiscordRole> allowedRoles() {
return Set.of(DiscordRole.LEAD);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package greedy.greedybot.presentation.jda.listener;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
import net.dv8tion.jda.api.interactions.components.text.TextInput;
import net.dv8tion.jda.api.interactions.components.text.TextInputStyle;
import net.dv8tion.jda.api.interactions.modals.Modal;
import org.jetbrains.annotations.NotNull;
import org.springframework.stereotype.Component;

@Component
public class ScheduledMessageModalLauncher extends ListenerAdapter {

private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm",
Locale.ENGLISH);

@Override
public void onStringSelectInteraction(@NotNull StringSelectInteractionEvent event) {
String[] parts = event.getComponentId().split(":", 2);
String componentId = parts[0];
String members = (parts.length > 1) ? parts[1] : null;

if (!componentId.equals("scheduled-channel-select")) {
return;
}

TextInput messageInput = TextInput.create("message", "메시지", TextInputStyle.PARAGRAPH)
.setRequired(true)
.build();

final String defaultTime = LocalDateTime.now().format(DATE_TIME_FORMATTER);
TextInput timeInput = TextInput.create("time", "예약 시간", TextInputStyle.SHORT)
.setValue(defaultTime)
.setRequired(true)
.build();

final String channelId = event.getValues().get(0);

String modalId = "scheduled-message-modal:" + channelId;
if (members != null && !members.isEmpty()) {
modalId += ":" + members;
}

Modal modal = Modal.create(modalId, "예약 메시지 등록")
.addActionRow(messageInput)
.addActionRow(timeInput)
.build();

event.replyModal(modal).queue();
}
}
Loading