디스코드봇 개발하다 오픈소스 커밋한 이야기

· Backend, Java, OpenSource

발단

디스코드에서 사람들과 함께 게임을 할 수 있도록 마인크래프트 서버를 열게 되었는데, 화이트리스트를 관리하는게 너무 귀찮은 작업이였다. 그렇다고 화이트리스트 기능을 끄자니 누구든 주소만 알면 접속할 수 있게 된다는 문제가 생기니 관리를 안할 수 없었다. 그래서 디스코드 봇 명령어로 디스코드 커뮤니티에 소속된 유저들이 봇 명령어를 통해 화이트리스트 추가를 직접 할 수 있도록 만들기로 했다.

관련하여 기능을 찾아보니 밸브에서 만든 rcon이라는 서비스를 여러 서버에서 사용하고 있었다. 마인크래프트 외부에서 마인크래프트 서버에 명령어를 날릴 수 있는 서비스였다. 나는 봇에 이 기능을 붙이기 위해서 rcon client 기능을 지원하는 라이브러리를 찾았다. 생각보다 자료가 많지 않았다. 마지막 업데이트가 3~4년 정도 연식이 있는 rcon-client 자바 라이브러리가 가장 쓸만해보였다.

minecraft-rcon 깃허브 링크

만들 때는 생각보다 잘 동작하는 것 같았다. 필요한 기능들은 다 있었다. 여기까지는 별 문제가 없었다. 화이트리스트에 유저를 추가하는 기능도 성공적으로 만들었다.

아래는 화이트리스트 추가 기능을 구현한 코드다. 실제 코드는 서비스 레이어에서 구현했지만, 아티클에서 읽기 편하게끔 예제 코드는 파일 하나로 작성했다.

@Override
    public void execute(SlashCommandInteractionEvent event) {
        MinecraftRcon minecraftRcon = minecraftRconService.minecraftRcon().orElseThrow(IllegalStateException::new); // fails here
        if (minecraftRcon == null) {
            event.replyEmbeds(new EmbedBuilder().setDescription("RCON 연결에 실패했습니다. 운영자에게 문의해주세요.").build()).queue();
            return;
        }
        String username;
        try {
            username = event.getOption("username").getAsString();
        } catch (NullPointerException e) {
            event.replyEmbeds(new EmbedBuilder().setDescription("마인크래프트 유저 닉네임을 입력해주세요.").build()).queue();
            return;
        }
        Target target = Target.player(username);
        WhiteListCommand whiteListCommand = new WhiteListCommand(target, WhiteListModes.ADD);
        Future<RconResponse> response = minecraftRcon.sendAsync(whiteListCommand);
        try {
            RconResponse rconResponse = response.get();
            if (rconResponse.getResponseString().contains("already whitelisted")) {
                event.replyEmbeds(new EmbedBuilder().setDescription(username + "은 이미 화이트리스트에 등록된 유저입니다.").build()).queue();
            } else if (rconResponse.getResponseString().contains("Added")) {
                event.replyEmbeds(new EmbedBuilder().setDescription(username+ "을 화이트리스트에 추가했습니다.").build()).queue();
            }
        } catch (Exception e) {
            log.error("Exception: {}", e.getMessage());
            event.replyEmbeds(new EmbedBuilder().setDescription("화이트리스트에 유저를 추가하는데 실패했습니다.").build()).queue();
        }
    }

한국어가 전송되지 않아!

오래된 라이브러리인데다가 그렇게 유명한 라이브러리도 아니다보니 자료가 많지 않아 개발 과정에서 라이브러리 소스 코드를 직접 씹고 뜯고 맛보고 즐기게 되었었는데, 기능을 톺아보니 디스코드 서버의 텍스트 채널에서 메시지를 보내면 마인크래프트 인게임 채팅으로도 전송되는 기능을 만들 수 있을 것 같았다.

처음에는 당연히 될 줄 알고 뚝딱뚝딱 만들었다. 만들고 보니 영어 메시지는 전송이 잘되었는데, 한국어 메시지는 전송이 안되는 문제가 발생했다. 대체 문제가 뭘까? 아래 코드는 동작에는 문제가 없다. 단지 영어는 전송이 되고 한국어는 전송이 안됐을 뿐이다.

@Override
    public void execute(MessageReceivedEvent event) {
        if (event.getAuthor().isBot()) return;
        if (event.isWebhookMessage()) return;
        Message message = event.getMessage();
        MinecraftRcon minecraftRcon = minecraftRconService.minecraftRcon().orElseThrow(IllegalStateException::new); // fails here
        if (minecraftRcon == null) {
            event.getChannel().sendMessageEmbeds(new EmbedBuilder().setDescription("RCON 연결에 실패했습니다. 운영자에게 문의해주세요.").build()).queue();
        }

        TellRawCommand messageCommand = new TellRawCommandBuilder()
                .targeting(Selectors.ALL_PLAYERS)
                .withText("[" + message.getAuthor().getName() + "]: " + message.getContentRaw())
                .withColor(Colors.GRAY)
                .italic()
                .build();
        Future<RconResponse> response = minecraftRcon.sendAsync(messageCommand);
        try {
            RconResponse rconResponse = response.get();
            event.getChannel().sendMessage(rconResponse.getResponseString());
            log.info(rconResponse.getResponseString());
        } catch (Exception e) {
            log.error("Exception: {}", e.getMessage());
        }
    }

다시 시작된 라이브러리 뜯기

영어메시지만 간다

영어 메시지는 전송이 되는데 한국어는 안된다면 인코딩이나 문자열 관련된 문제가 아닐까?라고 생각했다.

메시지가 들어간 메소드부터 해당 메시지가 네트워크 채널로 보내는 함수의 파라미터에 담기는 부분까지 흐름을 따라가며 코드를 읽었다. sendAsync 메소드에 파라미터로 넘겨준 Command는 String으로 바뀌어서 이곳저곳을 항해했고, 마지막에는 소켓 채널을 통해 rcon을 지원하는 마인크래프트 서버로 메시지를 보내게 되는데, 그 과정 속에서 범인을 발견했다.

private ByteBuffer createRconByteBuffer(int requestCount, int requestType, String command) {
        // In accordance with the RCON format: Length + Request ID + Type + Payload + Two nil bytes
        ByteBuffer byteBuffer = ByteBuffer.allocate((3 * Integer.BYTES) + command.length() + (2 * Byte.BYTES));
        byteBuffer.order(ByteOrder.LITTLE_ENDIAN);

        byteBuffer.putInt((2 * Integer.BYTES) + command.length() + (2 * Byte.BYTES));
        byteBuffer.putInt(requestCount);
        byteBuffer.putInt(requestType);
        byteBuffer.put(command.getBytes());
        byteBuffer.put((byte) 0);
        byteBuffer.put((byte) 0);

        byteBuffer.position(0);

        return byteBuffer;
    }

MinecraftClient 클래스의 createRconByteBuffer 메소드 부분이 범인이였다. 더 정확히는 아래 부분이 문제였다.

ByteBuffer byteBuffer = ByteBuffer.allocate((3 * Integer.BYTES) + command.length() + (2 * Byte.BYTES));
...
byteBuffer.putInt((2 * Integer.BYTES) + command.length() + (2 * Byte.BYTES));

command.length() 로 ByteBuffer를 allocate하면 당연히 문제가 생긴다. 영어는 1바이트고, 한국어는 UTF 기준으로 3바이트기 때문이다.

예를 들어 “반갑습니다” 라는 String을 command.length()로 출력하면 5가 나온다. 하지만 ByteBuffer에 담기 위해서는 15바이트가 필요하다. 그런데 위의 코드는 “반갑습니다”를 5로 인식해서 바이트버퍼를 할당하게 되는 문제가 있다.

ByteBuffer byteBuffer = ByteBuffer.allocate((3 * Integer.BYTES) + command.getBytes().length + (2 * Byte.BYTES));
...
byteBuffer.putInt((2 * Integer.BYTES) + command.getBytes().length + (2 * Byte.BYTES));
...

정상적으로 동작하게 만들기 위해선 command.getBytes().length로 바꿔주어야 한다.

한국어도 지원해줘…

해당 레포를 포크를 떠서 코드를 수정한 후 PR을 날렸다. 워낙 업데이트된지 오래된 라이브러리라 수정을 받아줄지는 모르겠다.

한국어도 지원해줘잉

(2024년 2월 15일 추가) PR을 받아줬다. 머지가 됐다!

PR을 받아줬다