Технический deep-dive в проект discordbot: архитектура, интеграция JDA + LavaPlayer + Telegram API, работа с голосом, TTS-синтез, запись каналов и административная панель. Разбираем проблемы и их решения на реальном кейсе.

Введение: зачем еще один Discord-бот?

Проблема началась с простого вопроса: как быстро проиграть заранее подготовленный MP3-файл в голосовой канал Discord? Существующие боты либо требовали сложной настройки, либо не покрывали весь набор наших задач:

  • воспроизведение аудио по команде из чата;

  • синтез речи из текста в реальном времени;

  • запись голосовых каналов с автоматической отправкой в Telegram;

  • получение статистики по voice-активности через Telegram API;

  • модерационные сценарии (автокик по ролям, массовый перенос пользователей);

  • административный web-интерфейс для управления всем этим зоопарком.

Вместо набора разрозненных скриптов я решил собрать единую платформу на Spring Boot, которая объединяет Discord, Telegram и веб-панель в одном процессе.

Исходный код MVP проекта

Технологический стек

  • Spring Boot 3.4.4 — DI-контейнер, web-слой, security, конфигурация;

  • JDA 5 (beta) — библиотека для работы с Discord API, event-driven архитектура;

  • LavaPlayer — audio playback engine с поддержкой локальных файлов, HTTP-стримов и YouTube;

  • gtts4j — Google Text-to-Speech для синтеза русской речи;

  • Telegram Bot API — long-polling через HttpClient без внешних webhook;

  • Thymeleaf — серверный рендеринг административной панели;

  • Spring Security + OpenAPI — защита эндпоинтов и автодокументация;

  • Docker — контейнеризация для упрощения деплоя.

Архитектурный overview

Система построена как монолит с тремя фронтами: Discord-команды, Telegram-интерфейс и веб-панель. Все три канала используют общий слой сервисов для работы с голосом, блок-листами и кэшем Discord-сущностей.

Архитектура
Архитектура

Ключевая идея: бизнес-операции (список каналов, выбор voice-канала, синтез/проигрывание аудио, управление блок-листами) реализованы в виде Spring-сервисов и переиспользуются из всех трех интерфейсов.

Почему монолит, а не микросервисы?

Для контекста: сервер на ~50 активных пользователей, пик нагрузки — 20 одновременных voice-подключений.

Аргументы за монолит:

  1. Простота деплоя — один Docker-контейнер, один процесс, одна точка мониторинга;

  2. Транзакционность — операции типа "заблокировать пользователя + кикнуть из войса" выполняются атомарно;

  3. Latency — все компоненты в одном heap, нет network overhead между сервисами;

  4. Debugging — полный stacktrace от Discord-события до ffmpeg-конвертации в одном логе.

Если бы нагрузка выросла на порядок, я бы вынес в отдельные сервисы:

  • Audio Processing Service (LavaPlayer + ffmpeg) — для горизонтального масштабирования;

  • Recording Service — для изоляции долгоживущих операций;

  • Telegram Gateway — для независимого scaling polling-workers.

Но пока монолит справляется и экономит время на разработку.

Discord-интеграция: от событий к командам

Инициализация JDA-клиента

Точка входа — Spring-конфигурация JdaConfig:


@Configuration
public class JdaConfig {
@Bean
public JDA jda(@Value("${discord.bot.token}") String token,
               VoiceChannelListener listener) throws InterruptedException {
    
    JDA jda = JDABuilder.createDefault(token)
        .enableIntents(
            GatewayIntent.GUILD_MESSAGES,
            GatewayIntent.MESSAGE_CONTENT,
            GatewayIntent.GUILD_VOICE_STATES,
            GatewayIntent.GUILD_MEMBERS
        )
        .setActivity(Activity.watching("голосовые каналы"))
        .addEventListeners(listener)
        .build();
        
    jda.awaitReady();
    return jda;
}

@Bean
public Guild targetGuild(JDA jda, 
                        @Value("${discord.guild.id}") String guildId) {
    return jda.getGuildById(guildId);
}

}

Важные моменты:

  • MESSAGE_CONTENT intent — обязателен для чтения текста команд (с апреля 2022);

  • GUILD_VOICE_STATES — для отслеживания подключений/отключений в voice;

  • awaitReady() — блокирующий вызов, гарантирует, что при старте приложения JDA уже полностью инициализирована;

  • Single-guild бот — упрощает логику, избавляет от multi-tenancy.

Обработка команд: Command Pattern

VoiceChannelListener делегирует текстовые команды в CommandManager:

@Component
public class VoiceChannelListener extends ListenerAdapter {
private final CommandManager commandManager;

@Override
public void onMessageReceived(@NotNull MessageReceivedEvent event) {
    if (event.getAuthor().isBot()) return;
    
    String content = event.getMessage().getContentRaw();
    if (!content.startsWith("!")) return;
    
    commandManager.handle(event);
  }

}

CommandManager хранит маппинг префиксов на команды:


@Service
public class CommandManager {
private final Map<String, Command> commands = new HashMap<>();

public CommandManager(List<Command> commandList) {
    commandList.forEach(cmd -> 
        commands.put(cmd.getPrefix(), cmd)
    );
}

public void handle(MessageReceivedEvent event) {
    String[] parts = event.getMessage()
        .getContentRaw()
        .substring(1)
        .split("\\s+", 2);
        
    String prefix = parts[0].toLowerCase();
    Command command = commands.get(prefix);
    
    if (command != null) {
        command.execute(event, parts.length > 1 ? parts[1] : "");
    } else {
        // Fallback: пытаемся воспроизвести файл <prefix>.mp3
        handleAudioFallback(event, prefix);
    }
  }

}

Преимущество fallback-логики: добавление нового звука не требует изменения кода — достаточно положить victory.mp3 в аудио-директорию, и команда !victory заработает автоматически.

Регистрируемые команды

Команда

Описание

Пример

!delete <канал>

Удаляет пользователей из указанного голосового канала после воспроизведения сообщения.

!delete test voice channel

!speak <канал> <текст>

Преобразует текст в речь в указанном голосовом канале.

!speak test voice channel Привет, мир!

!record <канал>

Начинается запись в голосовом канале.

!record test voice channel

!stop

Останавливает запись и отправляет файл в Telegram.

!stop

!case

Отправляет сообщение "Кто хочет поиграть?" в Discord и Telegram.

!case

Голосовой движок: архитектура audio pipeline

LavaPlayer: почему именно он?

LavaPlayer — это не плеер для Discord, а полноценный audio playback framework:

  • поддержка локальных файлов, HTTP(S), YouTube, SoundCloud;

  • встроенное декодирование MP3, FLAC, WAV, Opus;

  • автоматический resampling в 48kHz (требование Discord);

  • buffer management для предотвращения audio stuttering;

  • thread-safe, можно использовать один AudioPlayerManager на весь бот.

Базовый флоу воспроизведения

public void playFile(VoiceChannel channel, String filepath) {
    AudioManager audioManager = channel.getGuild().getAudioManager();
    
    // 1. Подключаемся к каналу
    audioManager.openAudioConnection(channel);
    
    // 2. Создаем плеер
    AudioPlayer player = playerManager.createPlayer();
    
    // 3. Подключаем к AudioManager
    audioManager.setSendingHandler(
        new AudioPlayerSendHandler(player)
    );
    
    // 4. Загружаем трек
    playerManager.loadItem(filepath, new AudioLoadResultHandler() {
        @Override
        public void trackLoaded(AudioTrack track) {
            player.playTrack(track);
            
            // 5. Отключаемся после завершения
            player.addListener(new AudioEventAdapter() {
                @Override
                public void onTrackEnd(AudioPlayer p, AudioTrack t, 
                                      AudioTrackEndReason reason) {
                    audioManager.closeAudioConnection();
                }
            });
        }
        
        @Override
        public void loadFailed(FriendlyException e) {
            log.error("Failed to load track: {}", filepath, e);
            audioManager.closeAudioConnection();
        }
    });
}

Важная деталь: AudioPlayerSendHandler — это адаптер между LavaPlayer и JDA. Он берет audio frames из плеера и отправляет в Discord gateway по UDP.

Text-to-Speech: многоэтапный синтез

Прямой вызов gtts4j с длинным текстом часто приводит к ошибкам 500 от Google API. Решение — chunked synthesis:

public File synthesizeSpeech(String text) throws IOException {
    List chunks = splitTextIntoChunks(text, 200);
    List tempFiles = new ArrayList<>();
// 1. Синтезируем каждый chunk
for (int i = 0; i < chunks.size(); i++) {
    File chunkFile = Files.createTempFile("tts_chunk_" + i, ".mp3")
        .toFile();
    
    GTTSBuilder.getInstance()
        .setText(chunks.get(i))
        .setLanguage("ru")
        .saveTo(chunkFile);
        
    tempFiles.add(chunkFile);
}

// 2. Склеиваем через ffmpeg
File result = Files.createTempFile("tts_final", ".mp3").toFile();
concatenateAudioFiles(tempFiles, result);

// 3. Чистим временные файлы
tempFiles.forEach(File::delete);

return result;

}
private List splitTextIntoChunks(String text, int maxLength) {
List chunks = new ArrayList<>();
String[] sentences = text.split(“[.!?]\s+”);
StringBuilder current = new StringBuilder();
for (String sentence : sentences) {
    if (current.length() + sentence.length() > maxLength) {
        chunks.add(current.toString().trim());
        current = new StringBuilder();
    }
    current.append(sentence).append(". ");
}

if (current.length() > 0) {
    chunks.add(current.toString().trim());
}

return chunks;

}

Запись голосовых каналов: от PCM до Telegram

Проблема: Discord не дает "готовую запись"

Discord предоставляет только raw PCM-поток (20ms фреймы, 48kHz, stereo). Задача:

  1. Собрать поток в единый аудиофайл;

  2. Конвертировать в сжатый формат (OGG Opus);

  3. Отправить в Telegram.

Реализация через AudioReceiveHandler


public class SimpleAudioRecorder extends AudioReceiveHandler {
private final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
private final AudioInputStream combinedStream;

@Override
public boolean canReceiveCombined() {
    return true; // Получаем уже смикшированный поток
}

@Override
public void handleCombinedAudio(@NotNull CombinedAudio audio) {
    try {
        byte[] data = audio.getAudioData(1.0f); // Full volume
        buffer.write(data);
    } catch (IOException e) {
        log.error("Failed to write audio data", e);
    }
}

public File saveAsWav(String filename) throws IOException {
    byte[] audioData = buffer.toByteArray();
    
    // PCM specs для Discord
    AudioFormat format = new AudioFormat(
        48000,  // sampleRate
        16,     // sampleSizeInBits
        2,      // channels (stereo)
        true,   // signed
        false   // bigEndian
    );
    
    ByteArrayInputStream bais = new ByteArrayInputStream(audioData);
    AudioInputStream ais = new AudioInputStream(bais, format, 
        audioData.length / format.getFrameSize());
    
    File output = new File(filename);
    AudioSystem.write(ais, AudioFileFormat.Type.WAVE, output);
    
    return output;
}

}

Конвертация в OGG и отправка в Telegram


public void stopRecordingAndSendToTelegram(String channelName) {
    try {
    // 1. Сохраняем WAV
    File wavFile = recorder.saveAsWav("recording.wav");
    // 2. Конвертируем в OGG Opus
    File oggFile = new File("recording.ogg");
    ProcessBuilder pb = new ProcessBuilder(
        "ffmpeg", "-i", wavFile.getAbsolutePath(),
        "-c:a", "libopus",
        "-b:a", "64k",
        oggFile.getAbsolutePath()
    );
    
    Process process = pb.start();
    int exitCode = process.waitFor();
    
    if (exitCode != 0) {
        throw new RuntimeException("ffmpeg failed: " + exitCode);
    }
    
    // 3. Отправляем в Telegram
    telegramService.sendVoice(oggFile, 
        "Запись канала: " + channelName);
    
    // 4. Cleanup
    wavFile.delete();
    oggFile.delete();
    
} catch (Exception e) {
    log.error("Recording pipeline failed", e);
}

}

Почему именно OGG Opus?

  • Telegram требует Opus codec для voice messages;

  • Сжатие 10:1 относительно WAV при сопоставимом качестве;

  • Opus оптимизирован для речи (variable bitrate, DTX).

Грабли: memory leaks при длинных записях

Первоначальная версия держала весь PCM-поток в heap. При записи >10 минут это приводило к OOM.

Решение: streaming запись напрямую в файл через FileOutputStream:

public class StreamingAudioRecorder extends AudioReceiveHandler {
private final FileOutputStream fileOutput;
private final DataOutputStream dataOutput;

public StreamingAudioRecorder(String filename) throws IOException {
    this.fileOutput = new FileOutputStream(filename);
    this.dataOutput = new DataOutputStream(fileOutput);
    writeWavHeader(); // Записываем заголовок с placeholder для размера
}

@Override
public void handleCombinedAudio(@NotNull CombinedAudio audio) {
    try {
        byte[] data = audio.getAudioData(1.0f);
        dataOutput.write(data);
    } catch (IOException e) {
        log.error("Failed to write audio chunk", e);
    }
}

public void finalize() throws IOException {
    dataOutput.close();
    fileOutput.close();
    
    // Обновляем размер в WAV-заголовке
    updateWavHeader();
}

}

Telegram-интеграция: long-polling без webhook

Почему polling, а не webhook?

Webhook требует:

  • Публичный HTTPS endpoint;

  • Валидный SSL-сертификат;

  • Обработку Telegram IP whitelist;

  • Nginx/load balancer для терминации SSL.

Long-polling дает:

  • Работу за NAT/firewall;

  • Простоту деплоя (не нужен обратный proxy);

  • Автоматический retry при сбоях сети.

Для self-hosted бота на одном сервере polling — проще и надежнее.

Реализация поллера


@Service
public class TelegramBotPoller {
private final HttpClient httpClient;
private final String botToken;
private final String chatId;

private long lastUpdateId = 0;

@PostConstruct
public void startPolling() {
    Timer timer = new Timer(true);
    timer.scheduleAtFixedRate(new TimerTask() {
        @Override
        public void run() {
            try {
                pollUpdates();
            } catch (Exception e) {
                log.error("Polling failed", e);
            }
        }
    }, 0, 2000); // Каждые 2 секунды
}

private void pollUpdates() throws IOException {
    String url = String.format(
        "https://api.telegram.org/bot%s/getUpdates?offset=%d&timeout=25",
        botToken, lastUpdateId + 1
    );
    
    HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create(url))
        .GET()
        .build();
        
    HttpResponse<String> response = httpClient.send(request, 
        HttpResponse.BodyHandlers.ofString());
        
    JsonObject json = JsonParser.parseString(response.body())
        .getAsJsonObject();
        
    if (!json.get("ok").getAsBoolean()) {
        log.error("Telegram API error: {}", json);
        return;
    }
    
    JsonArray updates = json.getAsJsonArray("result");
    for (JsonElement update : updates) {
        processUpdate(update.getAsJsonObject());
    }
}

private void processUpdate(JsonObject update) {
    lastUpdateId = update.get("update_id").getAsLong();
    
    if (!update.has("message")) return;
    
    JsonObject message = update.getAsJsonObject("message");
    String text = message.has("text") 
        ? message.get("text").getAsString() 
        : "";
        
    if (text.startsWith("/")) {
        handleCommand(text, message);
    }
}

}

Важно: timeout=25 в getUpdates — это long-polling параметр. Telegram держит соединение открытым до 25 секунд, пока не придет новый update. Это снижает количество лишних запросов.

Команды Telegram

Команда

Описание

/vcinfo

Статистика по активным voice-каналам

/sendvoice <канал>

Переслать voice-сообщение в Discord

/case

Созвать всех на игру (дублирует Discord)

Интересный кейс: проброс voice из Telegram в Discord

Алгоритм:

  1. Пользователь отвечает на voice-сообщение командой /sendvoice Общий;

  2. Бот извлекает file_id из reply_to_message;

  3. Вызывает getFile для получения file_path;

  4. Формирует прямой URL: https://api.telegram.org/file/bot{token}/{file_path};

  5. Передает URL в LavaPlayer;

  6. LavaPlayer стримит файл напрямую в Discord voice.

Грабли: file_path действителен только 1 час. Если пользователь попытается воспроизвести старое сообщение, получит 403. Решение — кэшировать файл локально при первом запросе.

Модерационный слой: автоматический кик

Задача

Автоматически выкидывать пользователей из voice-канала, если:

  • у них есть заблокированная роль (например, "Muted");

  • их никнейм/username в черном списке.

Реализация через VoiceUpdateEvent


@Override
public void onGuildVoiceUpdate(@NotNull GuildVoiceUpdateEvent event) {
    Member member = event.getMember();
// Проверяем роли
boolean hasBlockedRole = member.getRoles().stream()
    .anyMatch(role -> blockedEntityService.isRoleBlocked(role.getId()));
    
// Проверяем никнейм и username
boolean isUserBlocked = blockedEntityService.isUserBlocked(
    member.getEffectiveName()
) || blockedEntityService.isUserBlocked(
    member.getUser().getName()
);

if (hasBlockedRole || isUserBlocked) {
    VoiceChannel channel = event.getChannelJoined() != null
        ? event.getChannelJoined().asVoiceChannel()
        : null;
        
    if (channel != null) {
        log.info("Kicking {} from {} (blocked)", 
            member.getEffectiveName(), channel.getName());
            
        event.getGuild()
            .moveVoiceMember(member, null) // null = отключить
            .queue();
    }
}

}

Web-панель администратора

Функционал

  • Управление блок-листами (роли, пользователи);

  • Запуск сценария "play and kick" (проигрывание + кик всех из канала);

  • Воспроизведение произвольного MP3;

  • TTS-синтез с выбором голосового канала;

  • Просмотр актуальных voice-каналов, ролей и участников.

Thymeleaf + HTMX для интерактивности

Админ панель
Админ панель

Production Roadmap: что улучшить

1. Централизация audio-логики

Проблема: код воспроизведения дублируется в PlayFileCommand, SpeakCommand, AdminController, TelegramBotPoller.

Решение: единый PlaybackService с очередью:


@Service
public class PlaybackService {
private final Map<String, Queue<AudioTask>> channelQueues = 
    new ConcurrentHashMap<>();

public CompletableFuture<Void> enqueue(VoiceChannel channel, 
                                       AudioTask task) {
    String channelId = channel.getId();
    Queue<AudioTask> queue = channelQueues
        .computeIfAbsent(channelId, k -> new ConcurrentLinkedQueue<>());
        
    queue.offer(task);
    
    if (queue.size() == 1) {
        return processQueue(channel);
    }
    
    return CompletableFuture.completedFuture(null);
}

private CompletableFuture<Void> processQueue(VoiceChannel channel) {
    // Обработка очереди + retry логика + cleanup
}

}

Профит:

  • Нет race conditions при одновременных запросах;

  • Централизованный retry и error handling;

  • Метрики: queue depth, playback latency, error rate.

2. Persistent storage для блок-листов

Проблема: после рестарта все блокировки теряются.

Решение: JPA + Flyway migration:


-- V1__init_schema.sql
CREATE TABLE blocked_users (
    id BIGSERIAL PRIMARY KEY,
    username VARCHAR(255) NOT NULL UNIQUE,
    blocked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    blocked_by VARCHAR(255),
    reason TEXT
);
CREATE TABLE blocked_roles (
id BIGSERIAL PRIMARY KEY,
role_id VARCHAR(255) NOT NULL UNIQUE,
role_name VARCHAR(255),
blocked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_blocked_users_username ON blocked_users(username);
CREATE INDEX idx_blocked_roles_role_id ON blocked_roles(role_id);

3. Observability: метрики и логирование

Что добавить:

  • Micrometer + Prometheus — метрики playback, TTS, recording;

  • Structured logging (Logback JSON) — для Grafana Loki;

  • Spring Boot Actuator — health checks, thread dump, heap dump;

  • Distributed tracing (Zipkin/Jaeger) — если будет переход на микросервисы.


@Service
public class AudioMetricsService {
private final MeterRegistry registry;

public void recordPlayback(VoiceChannel channel, Duration duration) {
    registry.timer("audio.playback.duration",
        "channel", channel.getName()
    ).record(duration);
}

public void recordTtsSynthesis(int textLength, Duration duration) {
    registry.timer("tts.synthesis.duration").record(duration);
    registry.counter("tts.characters.processed").increment(textLength);
}

}

4. Graceful shutdown

Проблема: при остановке приложения активные записи обрываются, файлы не сохраняются.

Решение:


@Component
public class GracefulShutdownHook {
private final AudioRecordingService recordingService;

@PreDestroy
public void onShutdown() {
    log.info("Graceful shutdown initiated");
    
    // Останавливаем все активные записи
    recordingService.stopAllRecordings();
    
    // Отключаемся от всех voice-каналов
    jda.getGuilds().forEach(guild -> {
        AudioManager am = guild.getAudioManager();
        if (am.isConnected()) {
            am.closeAudioConnection();
        }
    });
    
    log.info("Cleanup completed");
}

}

5. Rate limiting и abuse prevention

Проблема: пользователь может спамить !speak и забивать voice-канал.

Решение: Bucket4j (token bucket algorithm):


@Service
public class RateLimitService {
private final Map<String, Bucket> userBuckets = new ConcurrentHashMap<>();

public boolean tryConsume(String userId) {
    Bucket bucket = userBuckets.computeIfAbsent(userId, k -> 
        Bucket.builder()
            .addLimit(Bandwidth.simple(5, Duration.ofMinutes(1)))
            .build()
    );
    
    return bucket.tryConsume(1);
}

}

Использование:


@Override
public void execute(MessageReceivedEvent event, String args) {
    String userId = event.getAuthor().getId();
if (!rateLimitService.tryConsume(userId)) {
    event.getChannel()
        .sendMessage("⏱️ Слишком много запросов. Подожди минуту.")
        .queue();
    return;
}

// Основная логика команды

}

Деплой:

Dockerfile

FROM gradle:7.6.0-jdk17 AS build
COPY --chown=gradle:gradle . /home/gradle/project
WORKDIR /home/gradle/project
RUN chmod +x ./gradlew
RUN ./gradlew bootJar --no-daemon

# Stage 2: Run the application
FROM openjdk:17-jdk-slim
WORKDIR /app

RUN apt-get update && \
    apt-get install -y ffmpeg && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

COPY --from=build /home/gradle/project/build/libs/*.jar app.jar
COPY src/main/resources/audio/ audio/

RUN mkdir -p resources

ENTRYPOINT ["java", "-jar", "app.jar"]

Запуск

docker pull ialakey/discordbot:latest

docker run -d \
  -p 8080:8080 \
  -e DISCORD_TOKEN=your_discord_token \
  -e GUILD_ID=your_guild_id \
  -e TELEGRAM_TOKEN=your_telegram_token \
  -e TELEGRAM_CHAT=your_telegram_chat_id \
  ialakey/discordbot:latest

Выводы и уроки

Что получилось хорошо

  1. Многоканальность — один бот, три интерфейса (Discord, Telegram, Web). Пользователи выбирают удобный канал управления;

  2. Переиспользование сервисов — бизнес-логика не привязана к источнику команды;

  3. Простота деплоя — монолит в Docker проще поддерживать, чем зоопарк микросервисов;

  4. Extensibility — новый звук = новый MP3-файл, без кода.

Главные грабли

  1. Memory management при записи — держать весь PCM в heap = OOM. Нужен streaming;

  2. ffmpeg errors без context — всегда логировать stderr процесса;

  3. Telegram long-polling timeout — нужен параметр timeout=25, иначе polling агрессивный;

  4. Discord intents — без MESSAGE_CONTENT бот слепой после апреля 2022;

  5. LavaPlayer thread pool — один AudioPlayerManager на весь бот, иначе leak threads.

Ссылки и ресурсы

Исходный код MVP проекта

Комментарии (0)