
Технический deep-dive в проект discordbot: архитектура, интеграция JDA + LavaPlayer + Telegram API, работа с голосом, TTS-синтез, запись каналов и административная панель. Разбираем проблемы и их решения на реальном кейсе.
Введение: зачем еще один Discord-бот?
Проблема началась с простого вопроса: как быстро проиграть заранее подготовленный MP3-файл в голосовой канал Discord? Существующие боты либо требовали сложной настройки, либо не покрывали весь набор наших задач:
воспроизведение аудио по команде из чата;
синтез речи из текста в реальном времени;
запись голосовых каналов с автоматической отправкой в Telegram;
получение статистики по voice-активности через Telegram API;
модерационные сценарии (автокик по ролям, массовый перенос пользователей);
административный web-интерфейс для управления всем этим зоопарком.
Вместо набора разрозненных скриптов я решил собрать единую платформу на Spring Boot, которая объединяет Discord, Telegram и веб-панель в одном процессе.
Технологический стек
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-подключений.
Аргументы за монолит:
Простота деплоя — один Docker-контейнер, один процесс, одна точка мониторинга;
Транзакционность — операции типа "заблокировать пользователя + кикнуть из войса" выполняются атомарно;
Latency — все компоненты в одном heap, нет network overhead между сервисами;
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_CONTENTintent — обязателен для чтения текста команд (с апреля 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 заработает автоматически.
Регистрируемые команды
Команда |
Описание |
Пример |
|---|---|---|
|
Удаляет пользователей из указанного голосового канала после воспроизведения сообщения. |
|
|
Преобразует текст в речь в указанном голосовом канале. |
|
|
Начинается запись в голосовом канале. |
|
|
Останавливает запись и отправляет файл в Telegram. |
|
|
Отправляет сообщение "Кто хочет поиграть?" в Discord и Telegram. |
|
Голосовой движок: архитектура 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). Задача:
Собрать поток в единый аудиофайл;
Конвертировать в сжатый формат (OGG Opus);
Отправить в 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
Команда |
Описание |
|---|---|
|
Статистика по активным voice-каналам |
|
Переслать voice-сообщение в Discord |
|
Созвать всех на игру (дублирует Discord) |
Интересный кейс: проброс voice из Telegram в Discord
Алгоритм:
Пользователь отвечает на voice-сообщение командой
/sendvoice Общий;Бот извлекает
file_idизreply_to_message;Вызывает
getFileдля полученияfile_path;Формирует прямой URL:
https://api.telegram.org/file/bot{token}/{file_path};Передает URL в LavaPlayer;
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
Выводы и уроки
Что получилось хорошо
Многоканальность — один бот, три интерфейса (Discord, Telegram, Web). Пользователи выбирают удобный канал управления;
Переиспользование сервисов — бизнес-логика не привязана к источнику команды;
Простота деплоя — монолит в Docker проще поддерживать, чем зоопарк микросервисов;
Extensibility — новый звук = новый MP3-файл, без кода.
Главные грабли
Memory management при записи — держать весь PCM в heap = OOM. Нужен streaming;
ffmpeg errors без context — всегда логировать stderr процесса;
Telegram long-polling timeout — нужен параметр
timeout=25, иначе polling агрессивный;Discord intents — без
MESSAGE_CONTENTбот слепой после апреля 2022;LavaPlayer thread pool — один
AudioPlayerManagerна весь бот, иначе leak threads.
Ссылки и ресурсы
JDA GitHub — документация и примеры;
LavaPlayer GitHub — audio playback engine;
Telegram Bot API — official docs;
Discord Developer Portal — API reference;
Spring Boot Guides — best practices.