공부시간 측정 디스코드봇 만들기!

허준기·2025년 1월 3일
8

글또

목록 보기
3/4

같이 공부하기로 한 친구들이 있는데 이번에 디스코드 채널을 하나 파서 음성채널에서 같이 공부하기로 했다

그냥 공부만 하기에는 뭔가 아쉬운 부분이 있어서 음성채널에 들어와 있는 시간을 측정해서 기록을 하는 디스코드봇을 만들어보기로 했다

나는 SpringBoot를 사용하기 때문에 관련된 정보를 찾아보니 JDA 라는게 있어서 이걸로 디스코드 봇을 한 번 만들어 봤다!

구현

우선 디스코드봇을 사용하기 위해 등록을 해줘야 한다

https://discord.com/developers/applications

여기에 접속을 해서 New Application을 통해 study-bot을 만들어준다
실습은 두번째 봇을 만드는거라서 study-bot2 라고 해줬다

MESSAGE CONTENT INTENT 부분도 활성화 해주어야 한다

그리고 TOKEN 부분에서 Reset Token 을 눌러 비밀번호를 입력하고 토큰 값을 받아야 한다

이 토큰은 나중에 쓰여서 알아둬야 한다

만들고 OAuth2 부분에서 bot 부분과 Administrator 부분에 체크를 해준다

그리고 GENERATED URL 부분에 있는 곳으로 들어가면

이런 화면이 뜨게 되고 계속하기를 누르면

선택한 디스코드 채널에 봇이 나타나게 된다!
지금은 아무런 기능도 하지 않는 이름만 있는 봇이다
이제 원하는 기능을 만들어서 봇에 적용시켜보자!

원하는 기능

일단 원하는 기능은 이렇다

  • 특정 음성 채널에 입장 후 퇴장시까지 머무른 시간을 측정한다 - 이벤트 리스너
  • 나의 일간 공부 기록을 보여준다 → ! 일간기록
  • 나의 주간 공부 기록을 보여준다 → ! 주간기록
  • 나의 월간 공부 기록을 보여준다 → ! 월간기록
  • 모든 사용자의 일간 공부 기록을 보여준다 → ! 전체일간기록
  • 모든 사용자의 주간 공부 기록을 보여준다 → ! 전체주간기록
  • 모든 사용자의 월간 공부 기록을 보여준다 → ! 전체월간기록

위의 명령어를 통해서 동작하는 기능을 원했고 만들기 시작했다
우선 SpringBoot 프로젝트를 하나 만들어주고 시작해보자

딱 위의 기능만 원했기 때문에 코드의 퀄리티를 챙기기보다는 기능의 구현에 집중했다
→ 막 만들었다는 뜻

만드는게 엄청 어렵지는 않았다
자세히 뜯어보지는 않았지만, 이벤트 리스너를 기본으로 두고 동작하는 것 같았다

public static void main(String[] args){
        ApplicationContext context = SpringApplication.run(StudybotApplication.class, args);
        DiscordBotToken discordBotTokenEntity = context.getBean(DiscordBotToken.class);
        String discordBotToken = discordBotTokenEntity.getDiscordBotToken();

        JDA jda = JDABuilder.createDefault(discordBotToken)
            .setActivity(Activity.playing("메시지 기다리는 중!"))
            .enableIntents(GatewayIntent.MESSAGE_CONTENT)
            .addEventListeners(context.getBean(StudyBotDiscordListener.class))
            .build();

        JDA jdaVoice = JDABuilder.createDefault(discordBotToken)
            .setActivity(Activity.playing("메시지 기다리는 중!"))
            .enableIntents(GatewayIntent.MESSAGE_CONTENT, GatewayIntent.GUILD_VOICE_STATES)
            .addEventListeners(context.getBean(VoiceChannelTracker.class))
            .build();
    }

main 코드를 잠깐 보자면 별거 없다
시간을 좀 썼던 부분은 .addEventListners() 부분에서 빈을 넣어줄때 new 로 넣으면 계속 오류가 나서 context.getBean()을 통해서 넣어줬다

이벤트 리스너에 빈을 등록한 후 해당 채널에서 설정한 이벤트들이 발성하면 로직에 따라 기능이 동작한다

위의 jda는 메세지를 받기 위한거고, 아래의 jdaVoice는 음성채널 이벤트를 받기 위한 코드이다

그럼 이제 내부를 살펴보자

메세지 JDA

메세지 JDAStudyBotDiscordListener라는 클래스에서 이벤트를 캐치해서 동작을 하도록 되어 있다

@Override
    public void onMessageReceived(MessageReceivedEvent event) {

        User user = event.getAuthor();
        Member member = event.getMember();

        TextChannel textChannel = event.getChannel().asTextChannel();
        Message message = event.getMessage();

        log.info("get message : " + message.getContentDisplay());

        if (user.isBot()) {
            return;
        } else if (message.getContentDisplay().equals("")) {
            log.info("문자열 비어있음");
        }

        String[] messageArray = message.getContentDisplay().split(" ");

        if (messageArray[0].equalsIgnoreCase("!")) {
            String[] messageArgs = Arrays.copyOfRange(messageArray, 1, messageArray.length);

            String nickname = member.getNickname(); // 길드에서의 닉네임 (null일 수도 있음)
            String displayName = nickname != null ? nickname : user.getName(); // 닉네임 없으면 기본 이름

            for (String msg : messageArgs) {
                String returnMessage = sendMessage(msg, displayName, user.getName()); // 길드 이름 전달
                textChannel.sendMessage(returnMessage).queue();
            }
        }
    }

코드만 보면 어렵지 않은데 디스코드 채널에 메세지 이벤트가 들어왔을때 ! 로 시작하면 if(messageArray[0].equalsIgnoreCase("!")) 아래 부분의 코드를 실행 시킨다

sendMessage() 함수를 잠깐 살펴보자

private String sendMessage(String message, String displayName, String userName) {
        String returnMessage = "";

        switch (message) {
            case "안녕":
                returnMessage = displayName + " 얼른 공부좀해!";
                break;
            case "하기싫어":
                returnMessage = displayName + " 그냥 좀 해";
                break;
            case "오늘만쉴까":
                returnMessage = displayName + " 그럼 평생 쉬겠지";
                break;
            case "김민선바보":
                returnMessage = "김민선 바보멍청이";
                break;
            case "오주영바보":
                returnMessage = "오주영 바보멍청이";
                break;
            case "한승희바보":
                returnMessage = "한승희 바보멍청이";
                break;
            case "허준기바보":
                returnMessage = "허준기 바보멍청이";
                break;
            case "전체월간기록":
                returnMessage = getAllMonthlyLogs();
                break;
            case "전체주간기록":
                returnMessage = getAllWeeklyLogs();
                break;
            case "전체일간기록":
                returnMessage = getAllDailyLogs();
                break;
            case "월간기록":
                returnMessage = getMonthlyLogs(userName);
                break;
            case "주간기록":
                returnMessage = getWeeklyLogs(userName);
                break;
            case "일간기록":
                returnMessage = getDailyLogs(userName);
                break;
            case "명령어":
                returnMessage = getHelpMessage();
                break;
            default:
                returnMessage = "잘못된 명령어입니다.";
        }

        return returnMessage;
    }

그냥 재미로 만든 명령어들은 하드코딩으로 해줬고, 기록을 가져오는 부분만 함수로 가져오게 해줬다

재미로 만든 명령어가 동작하는걸 보자!

이런식으로 switch 문을 통해서 등록된 명령어가 들어오면 출력이 되게 한다

등록이 되지 않은 명령어가 들어오게 되면?

이런식으로 잘못된 명령어라고 출력이 된다!

이 부분은 만들고 싶은 명령어로 바꿔서 사용하면 될 것 같다
글 마지막에 깃 레포 넣어놓을게용

! 명령어 라는 키워드를 통해서 전체 명령어 모음집도 출력이 되게 해줬다!

좀 더 예쁘게 꾸밀까 했지만 디스코드는 뭔가 형식이 다른것 같아서 이 정도로 만족했다
나름 예쁜듯하다

음성채널 JDA

이제 이 디스코드봇을 만든 이유인 음성채널 시간 기록 코드를 살펴보자!

원래 H2 DB를 쓰려고 하다가 잘 안돼서 MySQL 로 DB를 구성해줬다
사실 원래 DB도 안쓰려고 했는데 그래도 열품타처럼 기록이 되면 좋을 것 같아서 하나 넣었다

거창한 DB는 아니고 그냥 음성채널 입장기록 저장할 간단한 로그 테이블 하나밖에 없긴 하다..ㅎㅎ

@Entity
@Table(name = "voice_channel_logs")
@Getter
@Setter
public class VoiceChannelLog {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private Long userId;
    private String nickName;
    private Long channelId;
    private String channelName;
    private Long duration; // 머문 시간(초)
    private LocalDateTime recordedAt; // 기록 시간
    private String userName;
}

진짜 간단한 사용자, 채널, 머무른 시간 정도 저장하는 테이블!!

@Override
    public void onGuildVoiceUpdate(GuildVoiceUpdateEvent event) {
        var member = event.getEntity();
        var userId = member.getIdLong();
        var nickName = member.getNickname();
        var joinedChannel = event.getChannelJoined();
        var leftChannel = event.getChannelLeft();
        User user = member.getUser();

        var textChannels = event.getGuild().getTextChannelsByName("공부-기록", true);
        TextChannel textChannel = textChannels != null && !textChannels.isEmpty() ? textChannels.get(0) : null;

        // 사용자가 새로운 채널에 입장했는지 확인
        if (joinedChannel != null && leftChannel == null) {
            if (!userJoinTimes.containsKey(userId)) { // 이미 기록된 사용자가 아닌 경우만 처리
                userJoinTimes.put(userId, LocalDateTime.now());
                System.out.println(nickName + "님이 `" + joinedChannel.getName() + "` 채널에 입장했습니다.");

                if (textChannel != null) {
                    textChannel.sendMessage(
                        nickName + "님이 `" + joinedChannel.getName() + "` 채널에 입장했습니다."
                    ).queue();
                }
            }
        }

        // 사용자가 채널에서 완전히 나갔는지 확인
        if (leftChannel != null && joinedChannel == null) {
            LocalDateTime joinTime = userJoinTimes.remove(userId);

            if (joinTime != null) {
                long duration = ChronoUnit.SECONDS.between(joinTime, LocalDateTime.now());
                long hours = duration / 3600;
                long minutes = (duration % 3600) / 60;
                long seconds = duration % 60;

                VoiceChannelLog log = new VoiceChannelLog();
                log.setUserId(userId);
                log.setNickName(nickName);
                log.setChannelId(leftChannel.getIdLong());
                log.setChannelName(leftChannel.getName());
                log.setDuration(duration);
                log.setRecordedAt(LocalDateTime.now());
                log.setUserName(user.getName());

                repository.save(log);

                if (textChannel != null) {
                    textChannel.sendMessage(
                        nickName + "님이 `" + leftChannel.getName() + "` 채널에서 퇴장했습니다.\n" +
                            "머문 시간: " +
                            (hours > 0 ? hours + "시간 " : "") +
                            (minutes > 0 ? minutes + "분 " : "") +
                            seconds + "초"
                    ).queue();
                }
            }
        }
    }

음성JDA 는 사실 이 코드가 전부다

채널에 입장하면 함수가 실행되고, 채널에서 나가면 머무른 시간만큼 db에 저장하는 코드.

그래도 입장과 퇴장 시에 채널에 메세지를 띄워주긴 한다

event.getGuild().getTextChannelsByName("공부-기록", true); 라는 코드를 통해 공부-기록 이라는 채널에 메세지를 띄워주게 된다

공부-기록 이라는 채널이 없으면 안돼서 꼭 만들어줘야한다!!!
아니면 저 코드를 바꾸는 방법도 있다

근데 이 블로그를 쓰면서 너무 별로인것 같아서 application.yml 파일에 넣는 방식으로 변경했다

voice-channel:
  target-channel-name: // 디스코드 음성 채널 이름

text-channel:
  target-channel-name: // 디스코드 공부 기록 채널 이름

이런식으로 yml 파일에 넣어두면 저 값을 가져와서 기록해준다!

이 과정에서 원래는 모든 음성채널의 기록을 가져왔는데 설정한 음성채널만 기록하도록 변경도 했다

멍청한 짓

진짜 멍청한 짓을 하나 해서 시간을 좀 많이 쓴 부분이 있다

jpa:
  hibernate:
    ddl-auto: 

바로 이 부분이다..
처음에 ddl-auto 부분에 create 라고 넣고 로컬에서 테스트를 할 때 실행시킬때마다 DB 가 초기화 되는 문제가 있었다

나는 다른 문제인줄 알고 열심히 찾아보다가 1시간 뒤에 저 부분을 봤는데 update가 아닌 create 로 되어있는걸 발견할 수 있었다...!

이런 기초적인 실수를 하다니
create로 해 두면 코드를 실행시킬때마다 db를 새로 만들어서 이전에 실행했을때의 데이터가 다 사라지고 새로 만들기 때문에 발생하는 문제였다

update로 고치고 해보니 다행히도 데이터가 사라지지 않고 잘 동작하는걸 볼 수 있었당

기록 불러오기

기록을 했으니 이제 기록한걸 불러오는 기능이 필요하다!

원래 개인기록만 불러오는 기능을 만들려고 했는데 그래도 남들이 한 기록이 보여야 경쟁이 되고 자극을 받을것 같아서 전체 기록을 불러오는 기록도 만들기로 했다!!

공부한 시간으로 경쟁하다니.. 좋은 경쟁이다

기록을 불러오는건 그냥 간단한 SQL 문으로 구현했다

일간, 주간, 월간 기록이 있는데 주간 기록의 코드를 살펴보자

특별한건 아니고 그냥 중간에 있어서..

VoiceChannleLogRepository 클래스

@Query("SELECT v FROM VoiceChannelLog v WHERE v.recordedAt >= :start AND v.recordedAt < :end AND v.userName = :userName")
    List<VoiceChannelLog> findLogsBetween(@Param("start") LocalDateTime start, @Param("end") LocalDateTime end, String userName);

이런식으로 시작 날짜와 끝나는 날짜를 받아서 DB에서 정보를 가져온다

private String getWeeklyLogs(String userName) {
        LocalDate startOfWeek = LocalDate.now().with(DayOfWeek.MONDAY);
        LocalDate endOfWeek = startOfWeek.plusDays(6);
        LocalDateTime start = startOfWeek.atStartOfDay();
        LocalDateTime end = endOfWeek.atTime(23, 59, 59);
        List<VoiceChannelLog> logs = repository.findLogsBetween(start, end, userName);
        return formatLogsSummed(logs, "주간");
    }

위의 repository 에서 긁어오는 코드는 이렇다

formatLogsSummed() 메서드에서는 가져온 기록들을 더해서 반환해주는 역할을 한다

private String formatLogsSummed(List<VoiceChannelLog> logs, String periodName) {
        if (logs.isEmpty()) {
            return periodName + " 기간 동안 기록이 없습니다.";
        }

        // 사용자별로 총 머문 시간을 계산
        Map<String, Long> userDurationMap = new HashMap<>();
        logs.forEach(log -> userDurationMap.merge(
            log.getNickName(), log.getDuration(), Long::sum
        ));

        // 결과 메시지 작성
        StringBuilder response = new StringBuilder(periodName + " 기간 내 기록:\n");
        userDurationMap.forEach((username, totalDuration) -> {
            long hours = totalDuration / 3600;
            long minutes = (totalDuration % 3600) / 60;
            long seconds = totalDuration % 60;

            response.append(String.format(
                "%s님이 총 %d시간 %d분 %d초 동안 머물렀습니다.\n",
                username, hours, minutes, seconds
            ));
        });

        return response.toString();
    }

굉장히 더러운 코드..

돌아가는 쓰레기를 만들기 위해서 대충 만들었다

시간이 생기면 코드 개선을 한번 시도해봐야겠다

다음에 계속

기능/코드 설명은 이 정도로 끝!!

다음 글은 만든 디스코드봇을 서버에 배포하는 내용을 작성해봐야겠다!!

배포하는 방법을 아시는 분들은 이거 가져다 쓰시면 됩니다!

스터디봇 Git 레포

후기

만들고 배포했는데 애들이 잘 써서 뿌듯하다 후후

profile
나는 허준기

2개의 댓글

comment-user-thumbnail
2025년 2월 28일

덕분에 공부 열정이 불타올랐어요

1개의 답글

관련 채용 정보