diff --git a/.gitignore b/.gitignore index 7595ee1..30293c8 100644 --- a/.gitignore +++ b/.gitignore @@ -157,4 +157,7 @@ $RECYCLE.BIN/ # コンパイル関係 core/bin/* -selfhost/bin/* \ No newline at end of file +selfhost/bin/* + +# Claude Code +nul \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9b05ab9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,78 @@ +# プロジェクト概要 + +I-TTS (Integration TTS) はDiscord用の読み上げBOT。VOICEVOX、COEIROINK、SHAREVOX、VoiceTextなどの音声合成APIに対応。 + +# ビルドコマンド + +```bash +# ビルド (テストとCheckstyle含む) +./gradlew build + +# テストのみ +./gradlew test + +# 特定モジュールのテスト +./gradlew :core:test +./gradlew :selfhost:test + +# 単一テストクラス実行 +./gradlew :core:test --tests "dev.felnull.itts.core.savedata.repository.BotStateDataTest" + +# Checkstyleのみ +./gradlew checkstyleMain + +# Shadow JAR作成 (実行可能JAR) +./gradlew :selfhost:shadowJar +``` + +成果物は `selfhost/build/libs/itts-selfhost-{version}.jar` に生成される。 + +# アーキテクチャ + +## モジュール構成 + +- **core**: BOTのコアロジック。JDAやLavaPlayerを使用したDiscord連携、音声合成、データ永続化 +- **selfhost**: セルフホスト用エントリポイント。設定ファイル読み込みとランタイムコンテキスト提供 + +## マネージャーパターン + +`ITTSRuntime`がシングルトンとして各マネージャーを保持: +- `ConfigManager`: 設定管理 +- `VoiceManager`: 音声タイプ管理 +- `TTSManager`: テキスト読み上げ処理 +- `DictionaryManager`: 辞書管理 +- `CacheManager`: 音声キャッシュ +- `SaveDataManager`: データ永続化 + +## 音声合成 (voice パッケージ) + +抽象化レイヤー: +- `VoiceType`: 音声合成エンジン種別 (VOICEVOX, COEIROINK等) +- `VoiceCategory`: エンジン内のキャラクター +- `Voice`: 具体的な声スタイル + +各エンジン実装は `voice.voicevox`, `voice.coeiroink`, `voice.voicetext` サブパッケージに配置。 + +## データ永続化 (savedata パッケージ) + +- **dao**: データアクセスオブジェクト。SQLite/MySQL対応 +- **repository**: ビジネスロジック向けリポジトリ層 +- **legacy**: 旧バージョンデータ移行 + +スキーマ定義は [docs/schema-sqlite.md](docs/schema-sqlite.md) を参照。 + +## Discordコマンド (discord.command パッケージ) + +`BaseCommand`を継承してスラッシュコマンドを実装。主要コマンド: +- `JoinCommand`, `LeaveCommand`: VC参加/退出 +- `VoiceCommand`: 音声タイプ変更 +- `DictCommand`: 辞書管理 +- `ConfigCommand`: サーバー設定 + +# コードスタイル + +Checkstyle (`config/checkstyle/checkstyle.xml`) で強制: +- `var` 禁止 (明示的な型宣言必須) +- 全publicメソッド/フィールドにJavadoc必須 +- 行長170文字以内 +- タブ文字禁止 diff --git a/CHANGELOG.md b/CHANGELOG.md index dc460c9..3215ab1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,13 +8,14 @@ ### Added ### Changed - +- IANAのTLDリストを参照してドメイン省略するように変更 ### Deprecated ### Removed ### Fixed - +- 多数の不備を修正 +- 辞書登録のバリデーションを強化 ### Security ## [2.1.0] - 2026-02-11 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 32f6aa4..41a51b2 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -18,17 +18,17 @@ dependencies { testRuntimeOnly("org.junit.platform:junit-platform-launcher") testImplementation("org.mockito:mockito-junit-jupiter:5.21.0") - api("net.dv8tion:JDA:6.3.0") + api("net.dv8tion:JDA:6.3.2") api("org.apache.commons:commons-lang3:3.20.0") api("com.google.code.gson:gson:2.13.2") api("com.google.guava:guava:33.5.0-jre") api("dev.felnull:felnull-java-library:1.75") api("dev.arbjerg:lavaplayer:2.2.6") api("club.minnced:jdave-api:0.1.5") - runtimeOnly("club.minnced:jdave-native-linux-x86-64:0.1.5") - runtimeOnly("club.minnced:jdave-native-linux-aarch64:0.1.5") - runtimeOnly("club.minnced:jdave-native-win-x86-64:0.1.5") - runtimeOnly("club.minnced:jdave-native-darwin:0.1.5") + runtimeOnly("club.minnced:jdave-native-linux-x86-64:0.1.7") + runtimeOnly("club.minnced:jdave-native-linux-aarch64:0.1.7") + runtimeOnly("club.minnced:jdave-native-win-x86-64:0.1.7") + runtimeOnly("club.minnced:jdave-native-darwin:0.1.7") api("commons-io:commons-io:2.20.0") api("com.ibm.icu:icu4j:77.1") api("com.atilika.kuromoji:kuromoji-ipadic:0.9.0") diff --git a/core/src/main/java/dev/felnull/itts/core/ITTSRuntime.java b/core/src/main/java/dev/felnull/itts/core/ITTSRuntime.java index 16259b0..2142575 100644 --- a/core/src/main/java/dev/felnull/itts/core/ITTSRuntime.java +++ b/core/src/main/java/dev/felnull/itts/core/ITTSRuntime.java @@ -5,6 +5,7 @@ import dev.felnull.itts.core.cache.CacheManager; import dev.felnull.itts.core.config.ConfigManager; import dev.felnull.itts.core.dict.DictionaryManager; +import dev.felnull.itts.core.dict.DomainListManager; import dev.felnull.itts.core.discord.Bot; import dev.felnull.itts.core.savedata.SaveDataManager; import dev.felnull.itts.core.tts.TTSManager; @@ -99,6 +100,11 @@ public class ITTSRuntime { */ private final VoiceAudioManager voiceAudioManager = new VoiceAudioManager(); + /** + * ドメイン名リストマネージャー + */ + private final DomainListManager domainListManager = new DomainListManager(); + /** * 辞書マネージャー */ @@ -148,7 +154,7 @@ private ITTSRuntime(ITTSRuntimeContext runtimeContext) { this.configManager = new ConfigManager(runtimeContext.getConfigContext()); this.cacheManager = new CacheManager(runtimeContext.getGlobalCacheAccessFactory()); - this.managers = ImmutableList.of(configManager, voiceManager); + this.managers = ImmutableList.of(configManager, voiceManager, domainListManager); } /** @@ -265,6 +271,9 @@ public DictionaryManager getDictionaryManager() { return dictionaryManager; } + public DomainListManager getDomainListManager() { + return domainListManager; + } public ITTSNetworkManager getNetworkManager() { return networkManager; diff --git a/core/src/main/java/dev/felnull/itts/core/ITTSRuntimeUse.java b/core/src/main/java/dev/felnull/itts/core/ITTSRuntimeUse.java index 8c7c8f3..28208bb 100644 --- a/core/src/main/java/dev/felnull/itts/core/ITTSRuntimeUse.java +++ b/core/src/main/java/dev/felnull/itts/core/ITTSRuntimeUse.java @@ -4,6 +4,7 @@ import dev.felnull.itts.core.cache.CacheManager; import dev.felnull.itts.core.config.ConfigManager; import dev.felnull.itts.core.dict.DictionaryManager; +import dev.felnull.itts.core.dict.DomainListManager; import dev.felnull.itts.core.discord.Bot; import dev.felnull.itts.core.tts.TTSManager; import dev.felnull.itts.core.voice.VoiceManager; @@ -63,6 +64,10 @@ default DictionaryManager getDictionaryManager() { return getITTSRuntime().getDictionaryManager(); } + default DomainListManager getDomainListManager() { + return getITTSRuntime().getDomainListManager(); + } + default VoiceManager getVoiceManager() { return getITTSRuntime().getVoiceManager(); } diff --git a/core/src/main/java/dev/felnull/itts/core/dict/AbbreviationDictionary.java b/core/src/main/java/dev/felnull/itts/core/dict/AbbreviationDictionary.java index f6acf91..0cb467b 100644 --- a/core/src/main/java/dev/felnull/itts/core/dict/AbbreviationDictionary.java +++ b/core/src/main/java/dev/felnull/itts/core/dict/AbbreviationDictionary.java @@ -29,11 +29,11 @@ public class AbbreviationDictionary implements Dictionary { Matcher matcher = pattern.matcher(s); return matcher.find(); })*/ - .addOption(1, "ドメインショウリャク", s -> { + /*.addOption(1, "ドメインショウリャク", s -> { Pattern pattern = Pattern.compile("^([a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]*\\.)+[a-zA-Z]{2,}$"); Matcher matcher = pattern.matcher(s); return matcher.find(); - }) + })*/ .addOption(1, "アイピーブイフォーショウリャク", s -> { Pattern pattern = Pattern.compile("^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])$"); Matcher matcher = pattern.matcher(s); @@ -61,9 +61,15 @@ public class AbbreviationDictionary implements Dictionary { */ private final URLReplacer urlReplacer = new URLReplacer("ユーアルエルショウリャク"); + /** + * ドメインリプレーサー + */ + private final DomainReplacer domainReplacer = new DomainReplacer("ドメインショウリャク"); + @Override public @NotNull String apply(@NotNull String text, long guildId) { text = urlReplacer.replace(text); + text = domainReplacer.replace(text); text = CODE_BLOCK_REGEX.matcher(text).replaceAll("コードブロックショウリャク"); return regexUtil.replaceText(text); } diff --git a/core/src/main/java/dev/felnull/itts/core/dict/DictionaryManager.java b/core/src/main/java/dev/felnull/itts/core/dict/DictionaryManager.java index 64ad2b6..3efd810 100644 --- a/core/src/main/java/dev/felnull/itts/core/dict/DictionaryManager.java +++ b/core/src/main/java/dev/felnull/itts/core/dict/DictionaryManager.java @@ -8,6 +8,7 @@ import dev.felnull.itts.core.savedata.legacy.LegacyDictData; import dev.felnull.itts.core.savedata.legacy.LegacySaveDataLayer; import dev.felnull.itts.core.util.JsonUtils; +import dev.felnull.itts.core.util.PatternValidator; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Unmodifiable; @@ -28,6 +29,16 @@ public class DictionaryManager implements ITTSRuntimeUse { */ private static final int FILE_VERSION = 0; + /** + * アップロード時の最大エントリ数 + */ + private static final int MAX_DICT_ENTRIES = 1000; + + /** + * エントリの最大文字数 + */ + private static final int MAX_TEXT_LENGTH = 1000; + /** * グローバル辞書 */ @@ -254,35 +265,55 @@ public List serverDictLoadFromJson(@NotNull JsonObject jo, long int version = JsonUtils.getInt(jo, "version", -1); if (version != FILE_VERSION) { - throw new RuntimeException("Unsupported dictionary file version."); + throw new RuntimeException("Unsupported dictionary file version"); + } + + JsonElement entryElement = jo.get("entry"); + if (entryElement == null || !entryElement.isJsonObject()) { + throw new RuntimeException("Invalid dictionary file format"); + } + + JsonObject entry = jo.getAsJsonObject("entry"); + + if (entry.size() > MAX_DICT_ENTRIES) { + throw new RuntimeException("Dictionary entry count exceeds limit. Maximum is " + MAX_DICT_ENTRIES); } - if (jo.get("entry").isJsonObject()) { - JsonObject entry = jo.getAsJsonObject("entry"); - LegacySaveDataLayer legacySaveDataLayer = SaveDataManager.getInstance().getLegacySaveDataLayer(); + LegacySaveDataLayer legacySaveDataLayer = SaveDataManager.getInstance().getLegacySaveDataLayer(); + + for (Map.Entry en : entry.entrySet()) { + String target = en.getKey(); + + if (!en.getValue().isJsonPrimitive() || !en.getValue().getAsJsonPrimitive().isString()) { + continue; + } + + String read = en.getValue().getAsString(); - for (Map.Entry en : entry.entrySet()) { - String target = en.getKey(); + if (target.length() > MAX_TEXT_LENGTH || read.length() > MAX_TEXT_LENGTH) { + continue; + } - if (!en.getValue().isJsonPrimitive() || !en.getValue().getAsJsonPrimitive().isString()) { - continue; - } + if (target.isBlank() || read.isBlank()) { + continue; + } - String read = en.getValue().getAsString(); + if (!PatternValidator.validate(target).valid()) { + continue; + } - LegacyDictData pre = legacySaveDataLayer.getServerDictData(guildId, target); + LegacyDictData pre = legacySaveDataLayer.getServerDictData(guildId, target); - if (!overwrite && pre != null) { - continue; - } + if (!overwrite && pre != null) { + continue; + } - legacySaveDataLayer.addServerDictData(guildId, target, read); + legacySaveDataLayer.addServerDictData(guildId, target, read); - LegacyDictData ndata = Objects.requireNonNull(legacySaveDataLayer.getServerDictData(guildId, target)); + LegacyDictData ndata = Objects.requireNonNull(legacySaveDataLayer.getServerDictData(guildId, target)); - if (!ndata.equals(pre)) { - ret.add(ndata); - } + if (!ndata.equals(pre)) { + ret.add(ndata); } } diff --git a/core/src/main/java/dev/felnull/itts/core/dict/DomainListManager.java b/core/src/main/java/dev/felnull/itts/core/dict/DomainListManager.java new file mode 100644 index 0000000..63feaa2 --- /dev/null +++ b/core/src/main/java/dev/felnull/itts/core/dict/DomainListManager.java @@ -0,0 +1,64 @@ +package dev.felnull.itts.core.dict; + +import dev.felnull.itts.core.ITTSBaseManager; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.concurrent.CompletableFuture; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * TLDリストマネージャー + **/ + +public class DomainListManager implements ITTSBaseManager { + + /** + * 取得先URL + */ + private static final URI IANA_DATA_URI = URI.create("https://data.iana.org/TLD/tlds-alpha-by-domain.txt"); + + /** + * 読み込み済みTLDリスト + */ + private Pattern domainPattern; + + @Override + public @NotNull CompletableFuture init() { + return CompletableFuture.runAsync(() -> { + try { + HttpClient hc = getNetworkManager().getHttpClient(); + HttpRequest request = HttpRequest.newBuilder().uri(IANA_DATA_URI).GET().build(); + + HttpResponse res = hc.send(request, HttpResponse.BodyHandlers.ofString()); + + String processedDomains = res.body().lines() + .filter(line -> !line.startsWith("#")) + .filter(list -> !list.isBlank()) + .map(String::trim) + .map(String::toLowerCase) + .map(Pattern::quote) + .collect(Collectors.joining("|")); + + String regex = "\\b[a-zA-Z0-9.-]+\\.(?i:" + processedDomains + ")\\b"; + domainPattern = Pattern.compile(regex); + } catch (IOException | InterruptedException e) { + getITTSLogger().error("failed to get domain list."); + } + }, getAsyncExecutor()); + } + + /** + * 読み込み済みTLDゲッター + * + * @return ドメイン検知正規表現 + */ + public @Nullable Pattern getPattern() { + return domainPattern; + } +} diff --git a/core/src/main/java/dev/felnull/itts/core/dict/DomainReplacer.java b/core/src/main/java/dev/felnull/itts/core/dict/DomainReplacer.java new file mode 100644 index 0000000..b8934a3 --- /dev/null +++ b/core/src/main/java/dev/felnull/itts/core/dict/DomainReplacer.java @@ -0,0 +1,39 @@ +package dev.felnull.itts.core.dict; + +import dev.felnull.itts.core.ITTSRuntimeUse; +import java.util.regex.Pattern; + +/** + * ドメインを置き換える処理 + */ +public class DomainReplacer implements ITTSRuntimeUse { + + /** + * 置き換えるテキスト + */ + private final String replacedText; + + /** + * コンストラクタ + * + * @param replacedText 置き換えるテキスト + */ + public DomainReplacer(String replacedText) { + this.replacedText = replacedText; + } + + /** + * テキスト置き換え + * + * @param text テキスト + * @return 置き換えられたテキスト + */ + public String replace(String text) { + Pattern domainPattern = getDomainListManager().getPattern(); + if (domainPattern != null) { + return domainPattern.matcher(text).replaceAll(replacedText); + } + + return text; + } +} diff --git a/core/src/main/java/dev/felnull/itts/core/dict/GlobalDictionary.java b/core/src/main/java/dev/felnull/itts/core/dict/GlobalDictionary.java index 833b012..e8fbcd3 100644 --- a/core/src/main/java/dev/felnull/itts/core/dict/GlobalDictionary.java +++ b/core/src/main/java/dev/felnull/itts/core/dict/GlobalDictionary.java @@ -11,7 +11,9 @@ import java.util.Map; import java.util.function.Function; import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; import java.util.stream.Collectors; +import java.util.stream.Stream; /** * グローバル辞書 @@ -50,7 +52,15 @@ public int getDefaultPriority() { protected @NotNull Map> getReplaces(long guildId) { LegacySaveDataLayer legacySaveDataLayer = SaveDataManager.getInstance().getLegacySaveDataLayer(); return legacySaveDataLayer.getAllGlobalDictData().stream() - .map(n -> Pair.of(Pattern.compile(n.getTarget()), n.getRead())) + .flatMap(n -> { + try { + Pattern pattern = Pattern.compile(n.getTarget()); + return Stream.of(Pair.of(pattern, n.getRead())); + } catch (PatternSyntaxException e) { + getITTSLogger().warn("Invalid regex pattern in global dict: {}", n.getTarget()); + return Stream.empty(); + } + }) .collect(Collectors.toMap(Pair::getLeft, patternStringPair -> n -> patternStringPair.getRight())); } } diff --git a/core/src/main/java/dev/felnull/itts/core/dict/ServerDictionary.java b/core/src/main/java/dev/felnull/itts/core/dict/ServerDictionary.java index cbd01ed..5f2b79c 100644 --- a/core/src/main/java/dev/felnull/itts/core/dict/ServerDictionary.java +++ b/core/src/main/java/dev/felnull/itts/core/dict/ServerDictionary.java @@ -11,7 +11,9 @@ import java.util.Map; import java.util.function.Function; import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; import java.util.stream.Collectors; +import java.util.stream.Stream; /** * サーバー辞書 @@ -50,7 +52,15 @@ public int getDefaultPriority() { protected @NotNull Map> getReplaces(long guildId) { LegacySaveDataLayer legacySaveDataLayer = SaveDataManager.getInstance().getLegacySaveDataLayer(); return legacySaveDataLayer.getAllServerDictData(guildId).stream() - .map(n -> Pair.of(Pattern.compile(n.getTarget()), n.getRead())) + .flatMap(n -> { + try { + Pattern pattern = Pattern.compile(n.getTarget()); + return Stream.of(Pair.of(pattern, n.getRead())); + } catch (PatternSyntaxException e) { + getITTSLogger().warn("Invalid regex pattern in server dict: {}", n.getTarget()); + return Stream.empty(); + } + }) .collect(Collectors.toMap(Pair::getLeft, patternStringPair -> n -> patternStringPair.getRight())); } } diff --git a/core/src/main/java/dev/felnull/itts/core/discord/DCEventListener.java b/core/src/main/java/dev/felnull/itts/core/discord/DCEventListener.java index f613c12..1eca8ac 100644 --- a/core/src/main/java/dev/felnull/itts/core/discord/DCEventListener.java +++ b/core/src/main/java/dev/felnull/itts/core/discord/DCEventListener.java @@ -48,6 +48,10 @@ public void onCommandAutoCompleteInteraction(@NotNull CommandAutoCompleteInterac @Override public void onMessageReceived(@NotNull MessageReceivedEvent event) { + if (!event.isFromGuild()) { + return; + } + if (getTTSManager().canSpeak(event.getGuild())) { getTTSManager().sayChat(event.getGuild(), event.getChannel(), event.getMember(), event.getMessage()); getTTSManager().sayUploadFile(event.getGuild(), event.getChannel(), event.getMember(), event.getMessage().getAttachments()); diff --git a/core/src/main/java/dev/felnull/itts/core/discord/command/AdminCommand.java b/core/src/main/java/dev/felnull/itts/core/discord/command/AdminCommand.java index eef3812..5be6950 100644 --- a/core/src/main/java/dev/felnull/itts/core/discord/command/AdminCommand.java +++ b/core/src/main/java/dev/felnull/itts/core/discord/command/AdminCommand.java @@ -79,6 +79,8 @@ private void vnick(SlashCommandInteractionEvent event) { if (name == null) { sud.setNickName(null); event.reply(DiscordUtils.getEscapedName(event.getGuild(), user) + "の読み上げユーザ名をリセットしました。").queue(); + } else if (name.isBlank()) { + event.reply("名前を空にすることはできません。リセットするには名前を指定せずに実行してください。").setEphemeral(true).queue(); } else { sud.setNickName(name); event.reply(DiscordUtils.getEscapedName(event.getGuild(), user) + "の読み上げユーザ名を変更しました。").queue(); diff --git a/core/src/main/java/dev/felnull/itts/core/discord/command/ConfigCommand.java b/core/src/main/java/dev/felnull/itts/core/discord/command/ConfigCommand.java index 3ae0e01..d1e0415 100644 --- a/core/src/main/java/dev/felnull/itts/core/discord/command/ConfigCommand.java +++ b/core/src/main/java/dev/felnull/itts/core/discord/command/ConfigCommand.java @@ -5,6 +5,7 @@ import dev.felnull.itts.core.savedata.legacy.LegacySaveDataLayer; import dev.felnull.itts.core.savedata.legacy.LegacyServerData; import dev.felnull.itts.core.savedata.repository.ServerData; +import dev.felnull.itts.core.util.PatternValidator; import dev.felnull.itts.core.voice.VoiceCategory; import dev.felnull.itts.core.voice.VoiceManager; import dev.felnull.itts.core.voice.VoiceType; @@ -171,6 +172,13 @@ private void readIgnore(SlashCommandInteractionEvent event) { Guild guild = Objects.requireNonNull(event.getGuild()); String op = Objects.requireNonNull(event.getOption("regex", OptionMapping::getAsString)); + + PatternValidator.ValidationResult validationResult = PatternValidator.validate(op); + if (!validationResult.valid()) { + event.reply(validationResult.error()).setEphemeral(true).queue(); + return; + } + LegacySaveDataLayer legacySaveDataLayer = SaveDataManager.getInstance().getLegacySaveDataLayer(); LegacyServerData sd = legacySaveDataLayer.getServerData(guild.getIdLong()); diff --git a/core/src/main/java/dev/felnull/itts/core/discord/command/DenyCommand.java b/core/src/main/java/dev/felnull/itts/core/discord/command/DenyCommand.java index 5c2f9e8..68a9061 100644 --- a/core/src/main/java/dev/felnull/itts/core/discord/command/DenyCommand.java +++ b/core/src/main/java/dev/felnull/itts/core/discord/command/DenyCommand.java @@ -92,7 +92,6 @@ private void show(SlashCommandInteractionEvent event) { return; } - MessageCreateBuilder msg = new MessageCreateBuilder().addContent("読み上げ拒否されたユーザ一覧\n"); StringBuilder sb = new StringBuilder(); JDA jda = event.getJDA(); @@ -103,7 +102,15 @@ private void show(SlashCommandInteractionEvent event) { sb.append(DiscordUtils.getEscapedName(guild, user)).append("\n"); } } - msg.addContent("``" + sb + "``"); + + if (sb.isEmpty()) { + event.reply("読み上げ拒否されたユーザの情報を取得できませんでした。").setEphemeral(true).queue(); + return; + } + + MessageCreateBuilder msg = new MessageCreateBuilder() + .addContent("読み上げ拒否されたユーザ一覧\n") + .addContent("```\n" + sb + "```"); event.reply(msg.build()).setEphemeral(true).queue(); } diff --git a/core/src/main/java/dev/felnull/itts/core/discord/command/DictCommand.java b/core/src/main/java/dev/felnull/itts/core/discord/command/DictCommand.java index 142fc76..52230db 100644 --- a/core/src/main/java/dev/felnull/itts/core/discord/command/DictCommand.java +++ b/core/src/main/java/dev/felnull/itts/core/discord/command/DictCommand.java @@ -3,12 +3,14 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.JsonObject; +import com.google.gson.JsonSyntaxException; import dev.felnull.itts.core.dict.Dictionary; import dev.felnull.itts.core.dict.DictionaryManager; import dev.felnull.itts.core.dict.ServerDictionary; import dev.felnull.itts.core.savedata.SaveDataManager; import dev.felnull.itts.core.savedata.legacy.LegacyDictData; import dev.felnull.itts.core.savedata.legacy.LegacySaveDataLayer; +import dev.felnull.itts.core.util.PatternValidator; import dev.felnull.itts.core.util.StringUtils; import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.entities.Guild; @@ -54,6 +56,11 @@ public class DictCommand extends BaseCommand { */ private static final int MAX_FIELD_TEXT_LENGTH = 125; + /** + * アップロードファイルの最大サイズ (1MB) + */ + private static final int MAX_UPLOAD_FILE_SIZE = 1024 * 1024; + /** * GSOUN */ @@ -175,11 +182,24 @@ private void upload(SlashCommandInteractionEvent event) { Message.Attachment file = Objects.requireNonNull(event.getOption("file", OptionMapping::getAsAttachment)); boolean overwrite = Objects.requireNonNull(event.getOption("overwrite", OptionMapping::getAsBoolean)); + if (file.getSize() > MAX_UPLOAD_FILE_SIZE) { + int maxSizeMB = MAX_UPLOAD_FILE_SIZE / (1024 * 1024); + event.reply(String.format("ファイルサイズが大きすぎます。最大%dMBまでです。", maxSizeMB)).setEphemeral(true).queue(); + return; + } + event.deferReply().queue(); file.getProxy().download().thenApplyAsync(stream -> { - try (InputStream st = new BufferedInputStream(stream); Reader reader = new InputStreamReader(st)) { - return GSON.fromJson(reader, JsonObject.class); + try (InputStream st = new BufferedInputStream(stream); + Reader reader = new InputStreamReader(st, StandardCharsets.UTF_8)) { + JsonObject result = GSON.fromJson(reader, JsonObject.class); + if (result == null) { + throw new RuntimeException("Invalid JSON file"); + } + return result; + } catch (JsonSyntaxException e) { + throw new RuntimeException("Invalid JSON format: " + e.getMessage()); } catch (IOException e) { throw new RuntimeException(e); } @@ -204,7 +224,8 @@ private void upload(SlashCommandInteractionEvent event) { event.getHook().sendMessageEmbeds(replayEmbedBuilder.build()).addContent(overwrite ? "以下の単語の読みを上書き登録しました" : "以下の単語の読みを登録しました").queue(); } else { getITTSLogger().error("Dictionary registration failure", error); - event.getHook().sendMessage("辞書ファイルの読み込み中にエラーが発生しました").queue(); + String errorMessage = error.getCause() != null ? error.getCause().getMessage() : error.getMessage(); + event.getHook().sendMessage("辞書ファイルの読み込み中にエラーが発生しました: " + errorMessage).queue(); } }, getAsyncExecutor()); @@ -266,8 +287,19 @@ private void add(SlashCommandInteractionEvent event) { String word = Objects.requireNonNull(event.getOption("word", OptionMapping::getAsString)); String reading = Objects.requireNonNull(event.getOption("reading", OptionMapping::getAsString)); + if (word.isBlank() || reading.isBlank()) { + event.reply("単語と読みを空にすることはできません。").setEphemeral(true).queue(); + return; + } + if (word.length() > MAX_TEXT_LENGTH || reading.length() > MAX_TEXT_LENGTH) { - event.reply(String.format("登録可能な最大文字数は%d文字です", MAX_TEXT_LENGTH)).queue(); + event.reply(String.format("登録可能な最大文字数は%d文字です", MAX_TEXT_LENGTH)).setEphemeral(true).queue(); + return; + } + + PatternValidator.ValidationResult validationResult = PatternValidator.validate(word); + if (!validationResult.valid()) { + event.reply(validationResult.error()).setEphemeral(true).queue(); return; } diff --git a/core/src/main/java/dev/felnull/itts/core/discord/command/VnickCommand.java b/core/src/main/java/dev/felnull/itts/core/discord/command/VnickCommand.java index c5aa5e0..9788909 100644 --- a/core/src/main/java/dev/felnull/itts/core/discord/command/VnickCommand.java +++ b/core/src/main/java/dev/felnull/itts/core/discord/command/VnickCommand.java @@ -51,6 +51,8 @@ public void commandInteraction(SlashCommandInteractionEvent event) { if (name == null) { sud.setNickName(null); event.reply("自分の読み上げユーザ名をリセットしました。").setEphemeral(true).queue(); + } else if (name.isBlank()) { + event.reply("名前を空にすることはできません。リセットするには名前を指定せずに実行してください。").setEphemeral(true).queue(); } else { sud.setNickName(name); event.reply("自分の読み上げユーザ名を変更しました。").setEphemeral(true).queue(); diff --git a/core/src/main/java/dev/felnull/itts/core/tts/TTSManager.java b/core/src/main/java/dev/felnull/itts/core/tts/TTSManager.java index 3f7bc75..0228610 100644 --- a/core/src/main/java/dev/felnull/itts/core/tts/TTSManager.java +++ b/core/src/main/java/dev/felnull/itts/core/tts/TTSManager.java @@ -24,6 +24,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; /** * TTS管理 @@ -186,9 +187,13 @@ public void sayChat(@NotNull Guild guild, @NotNull MessageChannel messageChannel String ignoreRegex = legacySaveDataLayer.getServerData(guild.getIdLong()).getIgnoreRegex(); if (ignoreRegex != null) { - Pattern ignorePattern = Pattern.compile(ignoreRegex); - if (ignorePattern.matcher(message.getContentDisplay()).matches()) { - return; + try { + Pattern ignorePattern = Pattern.compile(ignoreRegex); + if (ignorePattern.matcher(message.getContentDisplay()).matches()) { + return; + } + } catch (PatternSyntaxException e) { + getITTSLogger().warn("Invalid ignore regex for guild {}: {}", guild.getIdLong(), ignoreRegex); } } diff --git a/core/src/main/java/dev/felnull/itts/core/util/PatternValidator.java b/core/src/main/java/dev/felnull/itts/core/util/PatternValidator.java new file mode 100644 index 0000000..75bde2f --- /dev/null +++ b/core/src/main/java/dev/felnull/itts/core/util/PatternValidator.java @@ -0,0 +1,94 @@ +package dev.felnull.itts.core.util; + +import org.jetbrains.annotations.NotNull; + +import java.util.Optional; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +/** + * 正規表現バリデーションユーティリティ + */ +public final class PatternValidator { + + /** + * ReDoS脆弱性を持つ可能性のあるパターン + * ネストされた量子 (例: (a+)+, (a*)*) を検出 + */ + private static final Pattern REDOS_PATTERN = Pattern.compile( + "\\([^)]*[+*][^)]*\\)[+*]|\\([^)]*\\|[^)]*\\)[+*]" + ); + + private PatternValidator() { + } + + /** + * 正規表現の構文を検証する + * + * @param regex 検証する正規表現 + * @return 検証結果 + */ + @NotNull + public static ValidationResult validate(@NotNull String regex) { + try { + Pattern pattern = Pattern.compile(regex); + + if (isPotentiallyDangerous(regex)) { + return ValidationResult.failure("この正規表現はパフォーマンス上の問題を引き起こす可能性があります"); + } + + return ValidationResult.success(pattern); + } catch (PatternSyntaxException e) { + return ValidationResult.failure("無効な正規表現です: " + e.getDescription()); + } + } + + /** + * 正規表現がReDoS脆弱性を持つ可能性があるかチェックする + * + * @param regex チェックする正規表現 + * @return 危険な可能性がある場合はtrue + */ + public static boolean isPotentiallyDangerous(@NotNull String regex) { + return REDOS_PATTERN.matcher(regex).find(); + } + + /** + * 正規表現バリデーションの結果 + * + * @param valid 有効かどうか + * @param pattern コンパイル済みPattern (有効な場合のみ) + * @param error エラーメッセージ (無効な場合のみ) + */ + public record ValidationResult(boolean valid, Pattern pattern, String error) { + + /** + * 成功結果を作成 + * + * @param pattern コンパイル済みPattern + * @return 成功結果 + */ + public static ValidationResult success(@NotNull Pattern pattern) { + return new ValidationResult(true, pattern, null); + } + + /** + * 失敗結果を作成 + * + * @param error エラーメッセージ + * @return 失敗結果 + */ + public static ValidationResult failure(@NotNull String error) { + return new ValidationResult(false, null, error); + } + + /** + * Patternを取得 (成功時のみ) + * + * @return コンパイル済みPattern + */ + public Optional getPattern() { + return Optional.ofNullable(pattern); + } + } +} diff --git a/core/src/main/java/dev/felnull/itts/core/voice/VoiceManager.java b/core/src/main/java/dev/felnull/itts/core/voice/VoiceManager.java index aa53393..a35359e 100644 --- a/core/src/main/java/dev/felnull/itts/core/voice/VoiceManager.java +++ b/core/src/main/java/dev/felnull/itts/core/voice/VoiceManager.java @@ -63,8 +63,8 @@ private void registerVoiceTypes(Supplier> availableVoiceTypes) { return CompletableFuture.allOf( voicevoxManager.init(), coeiroinkManager.init(), - sharevoxManager.init()). - thenAcceptAsync(v -> { + sharevoxManager.init()) + .thenAcceptAsync(v -> { registerVoiceTypes(voiceTextManager::getVoiceTypes); registerVoiceTypes(voicevoxManager::getAvailableVoiceTypes); registerVoiceTypes(coeiroinkManager::getAvailableVoiceTypes); diff --git a/core/src/main/java/dev/felnull/itts/core/voice/coeiroink/CoeiroinkManager.java b/core/src/main/java/dev/felnull/itts/core/voice/coeiroink/CoeiroinkManager.java index a9c5aae..a1e233d 100644 --- a/core/src/main/java/dev/felnull/itts/core/voice/coeiroink/CoeiroinkManager.java +++ b/core/src/main/java/dev/felnull/itts/core/voice/coeiroink/CoeiroinkManager.java @@ -76,6 +76,9 @@ protected VoicevoxConfig getConfig() { * @return 初期化の非同期CompletableFuture */ public CompletableFuture init() { + if (!getConfig().isEnable()) { + return CompletableFuture.completedFuture(null); + } return balancer.init(); } @@ -115,12 +118,22 @@ protected List requestSpeakers(CIURL ciurl) throws IOException .timeout(Duration.of(3000, ChronoUnit.MILLIS)) .build(); HttpResponse rep = hc.send(req, HttpResponse.BodyHandlers.ofInputStream()); + + int statusCode = rep.statusCode(); + if (statusCode != 200) { + throw new IOException(name + " API error: HTTP " + statusCode); + } + JsonArray ja; try (InputStream stream = new BufferedInputStream(rep.body()); Reader reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) { ja = GSON.fromJson(reader, JsonArray.class); } + if (ja == null) { + throw new IOException(name + " API returned invalid JSON response"); + } + ImmutableList.Builder speakerBuilder = new ImmutableList.Builder<>(); for (JsonElement je : ja) { diff --git a/core/src/main/java/dev/felnull/itts/core/voice/voicevox/VoicevoxManager.java b/core/src/main/java/dev/felnull/itts/core/voice/voicevox/VoicevoxManager.java index 1fc846b..b607cd7 100644 --- a/core/src/main/java/dev/felnull/itts/core/voice/voicevox/VoicevoxManager.java +++ b/core/src/main/java/dev/felnull/itts/core/voice/voicevox/VoicevoxManager.java @@ -77,6 +77,9 @@ protected VoicevoxConfig getConfig() { * @return 初期化の非同期CompletableFuture */ public CompletableFuture init() { + if (!getConfig().isEnable()) { + return CompletableFuture.completedFuture(null); + } return balancer.init(); } @@ -116,12 +119,22 @@ protected List requestSpeakers(VVURL vvurl) throws IOException, .timeout(Duration.of(3000, ChronoUnit.MILLIS)) .build(); HttpResponse rep = hc.send(req, HttpResponse.BodyHandlers.ofInputStream()); + + int statusCode = rep.statusCode(); + if (statusCode != 200) { + throw new IOException(name + " API error: HTTP " + statusCode); + } + JsonArray ja; try (InputStream stream = new BufferedInputStream(rep.body()); Reader reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) { ja = GSON.fromJson(reader, JsonArray.class); } + if (ja == null) { + throw new IOException(name + " API returned invalid JSON response"); + } + ImmutableList.Builder speakerBuilder = new ImmutableList.Builder<>(); for (JsonElement je : ja) { @@ -142,9 +155,21 @@ private JsonObject getQuery(String text, int speakerId) { .build(); HttpResponse rep = hc.send(req, HttpResponse.BodyHandlers.ofInputStream()); + int statusCode = rep.statusCode(); + if (statusCode != 200) { + throw new IOException(name + " audio_query API error: HTTP " + statusCode); + } + + JsonObject result; try (InputStream stream = new BufferedInputStream(rep.body()); Reader reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) { - return GSON.fromJson(reader, JsonObject.class); + result = GSON.fromJson(reader, JsonObject.class); } + + if (result == null) { + throw new IOException(name + " audio_query API returned invalid JSON response"); + } + + return result; } catch (Exception e) { throw new RuntimeException(e); } diff --git a/core/src/test/java/dev/felnull/itts/core/dict/DictionaryManagerTest.java b/core/src/test/java/dev/felnull/itts/core/dict/DictionaryManagerTest.java new file mode 100644 index 0000000..497bc2e --- /dev/null +++ b/core/src/test/java/dev/felnull/itts/core/dict/DictionaryManagerTest.java @@ -0,0 +1,104 @@ +package dev.felnull.itts.core.dict; + +import com.google.gson.JsonObject; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * DictionaryManagerのバリデーションテスト + */ +public class DictionaryManagerTest { + + private final DictionaryManager dictionaryManager = new DictionaryManager(); + + @Nested + @DisplayName("serverDictLoadFromJson()のバリデーションテスト") + class ServerDictLoadFromJsonTests { + + @Test + @DisplayName("バージョンが異なる場合は例外をスロー") + void invalidVersionThrowsException() { + JsonObject jo = new JsonObject(); + jo.addProperty("version", 999); + JsonObject entry = new JsonObject(); + entry.addProperty("test", "テスト"); + jo.add("entry", entry); + + RuntimeException exception = Assertions.assertThrows(RuntimeException.class, () -> + dictionaryManager.serverDictLoadFromJson(jo, 123456L, false)); + Assertions.assertEquals("Unsupported dictionary file version", exception.getMessage()); + } + + @Test + @DisplayName("versionフィールドがない場合は例外をスロー") + void missingVersionThrowsException() { + JsonObject jo = new JsonObject(); + JsonObject entry = new JsonObject(); + entry.addProperty("test", "テスト"); + jo.add("entry", entry); + + RuntimeException exception = Assertions.assertThrows(RuntimeException.class, () -> + dictionaryManager.serverDictLoadFromJson(jo, 123456L, false)); + Assertions.assertEquals("Unsupported dictionary file version", exception.getMessage()); + } + + @Test + @DisplayName("entryフィールドがない場合は例外をスロー") + void missingEntryFieldThrowsException() { + JsonObject jo = new JsonObject(); + jo.addProperty("version", 0); + + RuntimeException exception = Assertions.assertThrows(RuntimeException.class, () -> + dictionaryManager.serverDictLoadFromJson(jo, 123456L, false)); + Assertions.assertEquals("Invalid dictionary file format", exception.getMessage()); + } + + @Test + @DisplayName("entryがオブジェクトでない場合は例外をスロー") + void entryNotObjectThrowsException() { + JsonObject jo = new JsonObject(); + jo.addProperty("version", 0); + jo.addProperty("entry", "not an object"); + + RuntimeException exception = Assertions.assertThrows(RuntimeException.class, () -> + dictionaryManager.serverDictLoadFromJson(jo, 123456L, false)); + Assertions.assertEquals("Invalid dictionary file format", exception.getMessage()); + } + + @Test + @DisplayName("エントリ数が上限を超える場合は例外をスロー") + void tooManyEntriesThrowsException() { + JsonObject jo = new JsonObject(); + jo.addProperty("version", 0); + JsonObject entry = new JsonObject(); + + for (int i = 0; i <= 1000; i++) { + entry.addProperty("word" + i, "読み" + i); + } + jo.add("entry", entry); + + RuntimeException exception = Assertions.assertThrows(RuntimeException.class, () -> + dictionaryManager.serverDictLoadFromJson(jo, 123456L, false)); + Assertions.assertTrue(exception.getMessage().contains("Dictionary entry count exceeds limit")); + } + + @ParameterizedTest + @DisplayName("負のバージョン番号でも例外をスロー") + @ValueSource(ints = {-1, -100, Integer.MIN_VALUE}) + void negativeVersionThrowsException(int version) { + JsonObject jo = new JsonObject(); + jo.addProperty("version", version); + JsonObject entry = new JsonObject(); + entry.addProperty("test", "テスト"); + jo.add("entry", entry); + + RuntimeException exception = Assertions.assertThrows(RuntimeException.class, () -> + dictionaryManager.serverDictLoadFromJson(jo, 123456L, false)); + Assertions.assertEquals("Unsupported dictionary file version", exception.getMessage()); + } + } +} diff --git a/core/src/test/java/dev/felnull/itts/core/util/PatternValidatorTest.java b/core/src/test/java/dev/felnull/itts/core/util/PatternValidatorTest.java new file mode 100644 index 0000000..984e3f9 --- /dev/null +++ b/core/src/test/java/dev/felnull/itts/core/util/PatternValidatorTest.java @@ -0,0 +1,157 @@ +package dev.felnull.itts.core.util; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.stream.Stream; + +/** + * PatternValidatorのテストクラス + */ +public class PatternValidatorTest { + + @Nested + @DisplayName("validate()メソッドのテスト") + class ValidateTests { + + @ParameterizedTest + @DisplayName("有効な正規表現が成功を返す") + @ValueSource(strings = { + "^[a-z]+$", + "[0-9]{3}-[0-9]{4}", + "hello", + "\\d+", + "a*b+c?", + "^(foo|bar)$", + "[A-Za-z0-9_]+", + "\\w+@\\w+\\.\\w+", + "(?:abc)+", + "\\p{L}+" + }) + void validPatternsReturnSuccess(String regex) { + PatternValidator.ValidationResult result = PatternValidator.validate(regex); + Assertions.assertTrue(result.valid()); + Assertions.assertNotNull(result.pattern()); + Assertions.assertNull(result.error()); + Assertions.assertTrue(result.getPattern().isPresent()); + } + + @ParameterizedTest + @DisplayName("無効な構文がエラーを返す") + @MethodSource("invalidPatterns") + void invalidSyntaxReturnsError(String regex, String expectedErrorContains) { + PatternValidator.ValidationResult result = PatternValidator.validate(regex); + Assertions.assertFalse(result.valid()); + Assertions.assertNull(result.pattern()); + Assertions.assertNotNull(result.error()); + Assertions.assertTrue(result.error().contains("無効な正規表現です")); + Assertions.assertTrue(result.getPattern().isEmpty()); + } + + private static Stream invalidPatterns() { + return Stream.of( + Arguments.of("[a-z", "Unclosed character class"), + Arguments.of("(abc", "Unclosed group"), + Arguments.of("(?P", "Unknown inline modifier"), + Arguments.of("*abc", "Dangling meta character"), + Arguments.of("+abc", "Dangling meta character"), + Arguments.of("?abc", "Dangling meta character"), + Arguments.of("\\", "Unexpected internal error"), + Arguments.of("[z-a]", "Illegal character range") + ); + } + + @ParameterizedTest + @DisplayName("ReDoS脆弱性パターンがエラーを返す") + @ValueSource(strings = { + "(a+)+", + "(a*)*", + "(a+)*", + "(a*)+", + "(x+)+b", + "(a|b)+", + "(a|b)*", + "(x|y)+z" + }) + void redosPatternsReturnError(String regex) { + PatternValidator.ValidationResult result = PatternValidator.validate(regex); + Assertions.assertFalse(result.valid()); + Assertions.assertNull(result.pattern()); + Assertions.assertNotNull(result.error()); + Assertions.assertTrue(result.error().contains("パフォーマンス上の問題")); + } + } + + @Nested + @DisplayName("isPotentiallyDangerous()メソッドのテスト") + class IsPotentiallyDangerousTests { + + @ParameterizedTest + @DisplayName("危険なパターンを検出する") + @ValueSource(strings = { + "(a+)+", + "(a*)*", + "(a+)*", + "(a*)+", + "(x+)+b", + "(a|b)+", + "(a|b)*", + "(foo|bar)*" + }) + void detectsDangerousPatterns(String regex) { + Assertions.assertTrue(PatternValidator.isPotentiallyDangerous(regex)); + } + + @ParameterizedTest + @DisplayName("安全なパターンは検出しない") + @ValueSource(strings = { + "^[a-z]+$", + "[0-9]{3}-[0-9]{4}", + "hello", + "\\d+", + "a+b+c+", + "^(foo|bar)$", + "[A-Za-z0-9_]+", + "\\w+@\\w+\\.\\w+", + "(?:abc)", + "a{1,10}" + }) + void safePatternNotDetected(String regex) { + Assertions.assertFalse(PatternValidator.isPotentiallyDangerous(regex)); + } + } + + @Nested + @DisplayName("ValidationResultのテスト") + class ValidationResultTests { + + @ParameterizedTest + @DisplayName("成功結果が正しく作成される") + @ValueSource(strings = {"abc", "\\d+", "[a-z]+"}) + void successResultCreatedCorrectly(String regex) { + PatternValidator.ValidationResult result = PatternValidator.ValidationResult.success( + java.util.regex.Pattern.compile(regex) + ); + Assertions.assertTrue(result.valid()); + Assertions.assertNotNull(result.pattern()); + Assertions.assertNull(result.error()); + Assertions.assertEquals(regex, result.pattern().pattern()); + } + + @ParameterizedTest + @DisplayName("失敗結果が正しく作成される") + @ValueSource(strings = {"エラー1", "エラー2", "無効な正規表現です"}) + void failureResultCreatedCorrectly(String errorMessage) { + PatternValidator.ValidationResult result = PatternValidator.ValidationResult.failure(errorMessage); + Assertions.assertFalse(result.valid()); + Assertions.assertNull(result.pattern()); + Assertions.assertEquals(errorMessage, result.error()); + Assertions.assertTrue(result.getPattern().isEmpty()); + } + } +}