new_command_distributor into master
| @ -0,0 +1,48 @@ | |||||
| package de.yannicpunktdee.yoshibot.command.commands; | |||||
| import de.yannicpunktdee.yoshibot.command.YoshiCommand; | |||||
| import de.yannicpunktdee.yoshibot.command.YoshiCommandContext; | |||||
| import de.yannicpunktdee.yoshibot.utils.SauceProvider; | |||||
| import net.dv8tion.jda.api.entities.MessageEmbed; | |||||
| import java.util.Arrays; | |||||
| import java.util.List; | |||||
| public class SauceCommand extends YoshiCommand { | |||||
| /** | |||||
| * Erzeugt ein neues Kommando, führt es aber noch nicht aus. Es wird ermittelt, ob die Argumentenkombination valide | |||||
| * ist und das isOk-Flag gesetzt. Im Fehlerfall wird eine Fehleremeldung spezifiziert. | |||||
| * | |||||
| * @param context Der Kontext mit dem das Kommando aufgerufen wurde. | |||||
| */ | |||||
| public SauceCommand(YoshiCommandContext context) { | |||||
| super(context); | |||||
| } | |||||
| /** | |||||
| * {@inheritDoc} | |||||
| */ | |||||
| @Override | |||||
| public boolean execute() { | |||||
| if (!super.execute()) return false; | |||||
| if (!context.getEvent().getTextChannel().isNSFW()) { | |||||
| sendErrorMessage("Dieser Kanal is nix gut, weil vong nsfw her. Geh woanders hin du kek"); | |||||
| return true; | |||||
| } | |||||
| List<String> arguments = Arrays.asList(context.getArgument("tags").split(" ")); | |||||
| try { | |||||
| arguments.stream().map(Integer::parseInt).map(this::byIndex).forEach(this::sendCustomMessage); | |||||
| } catch (Exception e) { | |||||
| sendCustomMessage(byTags(arguments)); | |||||
| } | |||||
| return true; | |||||
| } | |||||
| private MessageEmbed byIndex(int index) { | |||||
| return SauceProvider.getSauce(index); | |||||
| } | |||||
| private MessageEmbed byTags(List<String> tags){ | |||||
| return SauceProvider.getRandomSauce(String.join(" ", tags)); | |||||
| } | |||||
| } | |||||
| @ -1,7 +0,0 @@ | |||||
| package de.yannicpunktdee.yoshibot.utils; | |||||
| public interface Provider { | |||||
| void onStop(); | |||||
| } | |||||
| @ -0,0 +1,156 @@ | |||||
| package de.yannicpunktdee.yoshibot.utils; | |||||
| import de.yannicpunktdee.yoshibot.main.YoshiBot; | |||||
| import net.dv8tion.jda.api.EmbedBuilder; | |||||
| import net.dv8tion.jda.api.entities.MessageEmbed; | |||||
| import net.dv8tion.jda.api.entities.TextChannel; | |||||
| import org.json.JSONArray; | |||||
| import org.json.JSONObject; | |||||
| import java.util.*; | |||||
| import java.util.concurrent.Executors; | |||||
| import java.util.concurrent.ScheduledExecutorService; | |||||
| import java.util.concurrent.TimeUnit; | |||||
| import java.util.stream.Collectors; | |||||
| import java.util.stream.StreamSupport; | |||||
| public class SauceProvider { | |||||
| private static final String BASE_URL = "https://r34-json.herokuapp.com/"; | |||||
| private int lastKnownSauce = -1; | |||||
| private boolean isSauceInit = false; | |||||
| private static MessageEmbed notFoundEmbed = null; | |||||
| public SauceProvider(int timer) { | |||||
| lastKnownSauce = this.getNewestIndex(); | |||||
| ScheduledExecutorService sauceScheduler = Executors.newScheduledThreadPool(1); | |||||
| sauceScheduler.scheduleAtFixedRate(this::provideSauce, 0, timer, TimeUnit.SECONDS); | |||||
| new Thread(this::initSauceProviding).start(); | |||||
| } | |||||
| public SauceProvider(int timer, int lastKnownSauce) { | |||||
| this(timer); | |||||
| this.lastKnownSauce = lastKnownSauce; | |||||
| } | |||||
| public static MessageEmbed getSauce(int index) { | |||||
| String url = BASE_URL + "posts?id=" + index; | |||||
| JSONObject base = getParsedSauceData(url); | |||||
| if (base.getInt("count") == 0) { | |||||
| return getNotFoundEmbed(); | |||||
| } | |||||
| JSONObject post = base.getJSONArray("posts").getJSONObject(0); | |||||
| return makeStringFromJson(post); | |||||
| } | |||||
| public static MessageEmbed getRandomSauce(String tags) { | |||||
| tags = tagsForRest(tags); | |||||
| tags += "+" + String.join("+", Resources.getFilteredTags()); | |||||
| Random rand = YoshiBot.getInstance().getRandom(); | |||||
| String url = BASE_URL + "posts?tags=" + String.join("+", tags); | |||||
| Logger.logInfo("Soße angefordert für tags " + tags); | |||||
| JSONObject baseObj = getParsedSauceData(url); | |||||
| int amount = baseObj.getInt("count"); | |||||
| if (amount == 0) { | |||||
| return getNotFoundEmbed(); | |||||
| } | |||||
| int selectedIndex = rand.nextInt(amount); | |||||
| int page = (selectedIndex / 100) % 100; | |||||
| int pageIndex = selectedIndex % 100; | |||||
| String pagedUrl = url + "&pid=" + page; | |||||
| JSONObject post = Objects.requireNonNull(getParsedSauceData(pagedUrl)).getJSONArray("posts") | |||||
| .getJSONObject(pageIndex); | |||||
| return makeStringFromJson(post); | |||||
| } | |||||
| private static MessageEmbed makeStringFromJson(JSONObject post) { | |||||
| EmbedBuilder eb = new EmbedBuilder(); | |||||
| eb.setTitle("Soße").setDescription("URL: " + post.getString("file_url").substring(42)); | |||||
| eb.addField("ID", post.getString("id"), false); | |||||
| eb.addField("Tags", "`" + post.getJSONArray("tags").join("` `") + "`", false); | |||||
| eb.setImage(post.getString("file_url").substring(42)); | |||||
| return eb.build(); | |||||
| } | |||||
| private void provideSauce() { | |||||
| if (!isSauceInit) return; | |||||
| for (Map.Entry<String, List<String>> feed : Resources.getFeedDetails().entrySet()) { | |||||
| String url = BASE_URL + "posts?tags=" + String.join("+", feed.getValue()) | |||||
| + "+" + String.join("+", Resources.getFilteredTags()); | |||||
| JSONArray posts = getParsedSauceData(url).getJSONArray("posts"); | |||||
| assert posts != null; | |||||
| List<JSONObject> postsInternal = | |||||
| StreamSupport.stream(posts.spliterator(), false) | |||||
| .map(obj -> (JSONObject) obj) | |||||
| .filter(obj -> obj.getInt("id") > lastKnownSauce) | |||||
| .collect(Collectors.toList()); | |||||
| Collections.reverse(postsInternal); | |||||
| YoshiBot yoshiBot = YoshiBot.getInstance(); | |||||
| for (JSONObject post : postsInternal) { | |||||
| List<TextChannel> channels = yoshiBot.jda.getTextChannelsByName(feed.getKey(), true); | |||||
| if (channels.size() == 0) { | |||||
| Logger.logError("Kein Kanal mit dem Namen " + feed.getKey() + "gefunden"); | |||||
| break; | |||||
| } else if (!channels.get(0).isNSFW()) { | |||||
| Logger.logError("Kanal " + feed.getKey() + " ist nicht als NSFW markiert!"); | |||||
| break; | |||||
| } | |||||
| channels.get(0).sendMessage(makeStringFromJson(post)).queue(); | |||||
| } | |||||
| if (postsInternal.size() > 0) { | |||||
| Logger.logInfo(String.format("Found %d posts for feed '%s'", postsInternal.size(), feed.getKey())); | |||||
| } | |||||
| } | |||||
| lastKnownSauce = this.getNewestIndex(); | |||||
| } | |||||
| private void initSauceProviding() { | |||||
| YoshiBot yoshiBot = YoshiBot.getInstance(); | |||||
| try { | |||||
| yoshiBot.jda.awaitReady(); | |||||
| } catch (InterruptedException e) { | |||||
| e.printStackTrace(); | |||||
| } | |||||
| for (Map.Entry<String, List<String>> entry : Resources.getFeedDetails().entrySet()) { | |||||
| List<TextChannel> channels = yoshiBot.jda.getTextChannelsByName(entry.getKey(), true); | |||||
| if (channels.size() > 0) { | |||||
| this.isSauceInit = true; | |||||
| this.provideSauce(); | |||||
| } else { | |||||
| Logger.logError("Konnte keine Kanaäle finden für die Soße"); | |||||
| } | |||||
| } | |||||
| } | |||||
| private int getNewestIndex() { | |||||
| JSONObject result = getParsedSauceData("https://r34-json.herokuapp.com/posts?limit=1&q=index"); | |||||
| int id = result.getJSONArray("posts").getJSONObject(0).getInt("id"); | |||||
| Logger.logDebug("Neuste Soßen-ID: " + id); | |||||
| return id; | |||||
| } | |||||
| private static JSONObject getParsedSauceData(String url) { | |||||
| String raw = RestHelper.getFromURL(url); | |||||
| return new JSONObject(raw); | |||||
| } | |||||
| private static String tagsForRest(String tags) { | |||||
| tags = tags.replace(" ", "+"); | |||||
| return tags; | |||||
| } | |||||
| private static MessageEmbed getNotFoundEmbed() { | |||||
| if (SauceProvider.notFoundEmbed == null) { | |||||
| EmbedBuilder eb = new EmbedBuilder(); | |||||
| eb.setTitle("Could not find any posts matching the filter!"); | |||||
| SauceProvider.notFoundEmbed = eb.build(); | |||||
| } | |||||
| return SauceProvider.notFoundEmbed; | |||||
| } | |||||
| } | |||||
| @ -1,136 +0,0 @@ | |||||
| package de.yannicpunktdee.yoshibot.utils; | |||||
| import lombok.Getter; | |||||
| import net.dv8tion.jda.api.EmbedBuilder; | |||||
| import org.json.JSONObject; | |||||
| import java.io.BufferedReader; | |||||
| import java.io.IOException; | |||||
| import java.io.InputStreamReader; | |||||
| import java.time.LocalDateTime; | |||||
| import java.util.HashMap; | |||||
| import java.util.List; | |||||
| import java.util.Map; | |||||
| import java.util.concurrent.Executors; | |||||
| import java.util.concurrent.ScheduledExecutorService; | |||||
| import java.util.concurrent.TimeUnit; | |||||
| import java.util.stream.Collectors; | |||||
| import java.util.stream.StreamSupport; | |||||
| public class StatusProvider implements Provider { | |||||
| private static final ScheduledExecutorService statusScheduler = Executors.newScheduledThreadPool(1); | |||||
| private int lastPlayersOnline = -1; | |||||
| @Getter | |||||
| private final String ip; | |||||
| private final String desc; | |||||
| private LocalDateTime timestampLastPlayerOnline; | |||||
| private final ProcessBuilder mcstatus = new ProcessBuilder(); | |||||
| public StatusProvider(String desc, int secondsPerTime, String ip, LocalDateTime timestamp) { | |||||
| this.timestampLastPlayerOnline = timestamp; | |||||
| this.desc = desc; | |||||
| this.ip = ip; | |||||
| this.mcstatus.command(Resources.getPath_to_mcstatus(), ip, "json"); | |||||
| statusScheduler.scheduleAtFixedRate(this::updateStatusMessage, 0, secondsPerTime, | |||||
| TimeUnit.SECONDS); | |||||
| } | |||||
| @SuppressWarnings("unchecked") | |||||
| public void updateStatusMessage() { | |||||
| EmbedBuilder eb = new EmbedBuilder(); | |||||
| eb.setTitle(desc); | |||||
| eb.addField("IP", ip, false); | |||||
| try { | |||||
| Map<String, Object> serverInfo = getPlayersOnline(); | |||||
| if ((boolean) serverInfo.get("online")) { | |||||
| if ((boolean) serverInfo.get("Server starting")) { | |||||
| eb.addField("Server still Starting", "True", false); | |||||
| } else { | |||||
| int newPlayersOnline = (int) serverInfo.get("playerCount"); | |||||
| eb.addField("Version", (String) serverInfo.get("version"), true); | |||||
| eb.addField("MOTD", (String) serverInfo.get("motd"), true); | |||||
| eb.addField("Spieler online", newPlayersOnline + " / " + serverInfo.get("playerMax"), false); | |||||
| if (newPlayersOnline == 0) { | |||||
| if (lastPlayersOnline > newPlayersOnline) { | |||||
| timestampLastPlayerOnline = LocalDateTime.now(); | |||||
| StatusProviderFactory.updateTimestamp(this, timestampLastPlayerOnline); | |||||
| } | |||||
| eb.addField("Zuletzt gesehen", | |||||
| StatusProviderFactory.TIME_FORMATTER.format(timestampLastPlayerOnline), false); | |||||
| } else { | |||||
| eb.addField("Spieler:", String.join(", ", (List<String>) serverInfo.get("playerNames")), false); | |||||
| } | |||||
| lastPlayersOnline = newPlayersOnline; | |||||
| } | |||||
| } else { | |||||
| eb.addField("Offline", "", false); | |||||
| } | |||||
| StatusProviderFactory.updateStatus(this, eb.build()); | |||||
| } catch (IOException e) { | |||||
| Logger.logError(e.toString()); | |||||
| } | |||||
| } | |||||
| private Map<String, Object> getPlayersOnline() throws IOException { | |||||
| Process process = mcstatus.start(); | |||||
| try { | |||||
| process.waitFor(); | |||||
| } catch (InterruptedException e) { | |||||
| e.printStackTrace(); | |||||
| } | |||||
| if (process.exitValue() != 0) { | |||||
| Logger.logError("MCStatus on IP " + ip + " exited with errorcode " + process.exitValue()); | |||||
| } | |||||
| String output = new BufferedReader(new InputStreamReader(process.getInputStream())).lines() | |||||
| .collect(Collectors.joining()); | |||||
| Map<String, Object> result = new HashMap<>(); | |||||
| if (output.startsWith("The server did not respond to the query protocol.")) { | |||||
| result.put("online", false); | |||||
| return result; | |||||
| } | |||||
| JSONObject obj = new JSONObject(output); | |||||
| result.put("online", obj.getBoolean("online")); | |||||
| if (!obj.getBoolean("online")) { | |||||
| return result; | |||||
| } | |||||
| for (String key : new String[]{"player_count", "player_max", "players", "motd", "version"}) { | |||||
| if (!obj.has(key)) { | |||||
| result.put("Server starting", true); | |||||
| return result; | |||||
| } | |||||
| } | |||||
| result.put("Server starting", false); | |||||
| result.put("playerCount", obj.getInt("player_count")); | |||||
| result.put("playerMax", obj.getInt("player_max")); | |||||
| result.put("playerNames", StreamSupport.stream(obj.getJSONArray("players").spliterator(), false) | |||||
| .map(jsonobj -> ((JSONObject) jsonobj).getString("name")).sorted().collect(Collectors.toList())); | |||||
| result.put("motd", obj.getString("motd")); | |||||
| result.put("version", obj.getString("version")); | |||||
| return result; | |||||
| } | |||||
| @Override | |||||
| public void onStop() { | |||||
| StatusProviderFactory.updateTimestamp(this, timestampLastPlayerOnline); | |||||
| } | |||||
| } | |||||
| @ -1,98 +0,0 @@ | |||||
| package de.yannicpunktdee.yoshibot.utils; | |||||
| import de.yannicpunktdee.yoshibot.main.YoshiBot; | |||||
| import lombok.NonNull; | |||||
| import lombok.SneakyThrows; | |||||
| import net.dv8tion.jda.api.MessageBuilder; | |||||
| import net.dv8tion.jda.api.entities.Message; | |||||
| import net.dv8tion.jda.api.entities.MessageEmbed; | |||||
| import net.dv8tion.jda.api.entities.TextChannel; | |||||
| import net.dv8tion.jda.api.exceptions.ErrorResponseException; | |||||
| import org.json.JSONArray; | |||||
| import org.json.JSONObject; | |||||
| import java.io.BufferedWriter; | |||||
| import java.io.File; | |||||
| import java.io.FileWriter; | |||||
| import java.nio.file.Files; | |||||
| import java.time.LocalDateTime; | |||||
| import java.time.format.DateTimeFormatter; | |||||
| import java.util.*; | |||||
| public abstract class StatusProviderFactory { | |||||
| public static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss E, dd.MM.yyyy", | |||||
| Locale.GERMANY); | |||||
| private static final Map<StatusProvider, Long> providers = new HashMap<>(); | |||||
| private static final Map<StatusProvider, JSONObject> providerJSON = new HashMap<>(); | |||||
| private static JSONObject rootJSON; | |||||
| private static File configFile; | |||||
| @NonNull | |||||
| private static final TextChannel statusChannel = | |||||
| Objects.requireNonNull(YoshiBot.getInstance().getGuild().getTextChannelById(Resources.getStatus_channel())); | |||||
| @SneakyThrows | |||||
| public static void createStatusProviders(String configFilePath, Set<Provider> allProviders) { | |||||
| configFile = new File(configFilePath); | |||||
| rootJSON = new JSONObject(String.join("\n", Files.readAllLines(configFile.toPath()))); | |||||
| final JSONArray servers = rootJSON.getJSONArray("servers"); | |||||
| for (int i = 0; i < servers.length(); i++) { | |||||
| final JSONObject server = servers.getJSONObject(i); | |||||
| final String ip = server.getString("ip"); | |||||
| final String name = server.getString("name"); | |||||
| if (!server.has("timestamp")) { | |||||
| server.put("timestamp", TIME_FORMATTER.format(LocalDateTime.now())); | |||||
| } | |||||
| final LocalDateTime timestamp = LocalDateTime.parse(server.getString("timestamp"), TIME_FORMATTER); | |||||
| if (!server.has("message")) { | |||||
| server.put("message", -1); | |||||
| } | |||||
| final long msg = ensureMessageId(server.getLong("message")); | |||||
| server.put("message", msg); | |||||
| StatusProvider provider = new StatusProvider(name, Resources.getStatusUpdate(), ip, timestamp); | |||||
| providers.put(provider, msg); | |||||
| providerJSON.put(provider, servers.getJSONObject(i)); | |||||
| } | |||||
| allProviders.addAll(providers.keySet()); | |||||
| writeJSON(); | |||||
| } | |||||
| public static void updateStatus(StatusProvider provider, MessageEmbed msg) { | |||||
| statusChannel.editMessageById(providers.get(provider), msg).queue(); | |||||
| } | |||||
| public static void updateTimestamp(StatusProvider provider, LocalDateTime timestamp) { | |||||
| providerJSON.get(provider).put("timestamp", TIME_FORMATTER.format(timestamp)); | |||||
| writeJSON(); | |||||
| } | |||||
| @SneakyThrows | |||||
| private static void writeJSON() { | |||||
| BufferedWriter bw = new BufferedWriter(new FileWriter(configFile)); | |||||
| bw.write(rootJSON.toString(2)); | |||||
| bw.flush(); | |||||
| } | |||||
| private static long createStatusMessage() { | |||||
| Logger.logInfo("Creating new Status Message!"); | |||||
| return StatusProviderFactory.statusChannel.sendMessage( | |||||
| new MessageBuilder() | |||||
| .append("ServerInformation") | |||||
| .build()) | |||||
| .complete().getIdLong(); | |||||
| } | |||||
| private static long ensureMessageId(long messageId) { | |||||
| try { | |||||
| Message msg = StatusProviderFactory.statusChannel.retrieveMessageById(messageId).complete(); | |||||
| return msg.getIdLong(); | |||||
| } catch (ErrorResponseException e) { | |||||
| return createStatusMessage(); | |||||
| } | |||||
| } | |||||
| } | |||||
| @ -1,2 +0,0 @@ | |||||
| 255*.txt | |||||
| mcstatus.sh | |||||
| @ -1,14 +1,4 @@ | |||||
| #Sun Mar 13 19:43:48 CET 2022 | |||||
| status_message_vanilla=890007862456238120 | |||||
| status_channel=889880296168755231 | |||||
| guild_id=801554100814741524 | |||||
| # audio_source_directory=C:/Users/linky/workspace/Yoshi_Bot_Audio/ | |||||
| restrict_commands_to_channel=bot-muell schrein-auf-den-bot | restrict_commands_to_channel=bot-muell schrein-auf-den-bot | ||||
| status_message_eighteen_vanilla=952638126327726150 | |||||
| status_message_selina=952638127451820042 | |||||
| mc_server=85.214.148.23 | |||||
| greetings_and_byebyes_on=true | greetings_and_byebyes_on=true | ||||
| guild_id=801554100814741524 | |||||
| status_message_modded=952638124197040139 | |||||
| status_message_kreativ=952638123324616735 | |||||
| status_update=30 | |||||
| path_to_mcstatus=/home/paul/.local/bin/mcstatus | |||||
| path_to_status_json=/home/paul/gits/YoshiBot/rsc/mcservers.json | |||||
| @ -1,7 +1,6 @@ | |||||
| Haare waschen | Haare waschen | ||||
| Kleine Menschen (Honey) verprügeln | |||||
| Kleine Menschen (Vanessa) verprügeln | |||||
| Im Kosovo Hexen verbennen | Im Kosovo Hexen verbennen | ||||
| Magnesiumcarbonat schnupfen | Magnesiumcarbonat schnupfen | ||||
| Offene Wunden mit Sekundenkleber verschließen | Offene Wunden mit Sekundenkleber verschließen | ||||
| Sich 'nen saftigen Knackarsch reinzimmern | Sich 'nen saftigen Knackarsch reinzimmern | ||||
| Spielt Rasputin bei Just Dance | |||||
| @ -1,3 +0,0 @@ | |||||
| %s hat jetzt fertig. | |||||
| %s ist wieder aufnahmebereit. | |||||
| %s hat sich wieder erholt. | |||||
| @ -1,4 +0,0 @@ | |||||
| %s ist dann mal abwesend. | |||||
| %s hat jetzt erstmal nix mehr zu sagen. | |||||
| %s ist verschwunden. | |||||
| %s kommt bald wieder.... vielleicht. | |||||
| @ -1,20 +0,0 @@ | |||||
| {"servers": [ | |||||
| { | |||||
| "ip": "85.214.148.23:25568", | |||||
| "name": "MCMuffing™®㋏ Inc.", | |||||
| "message": 960983446350594049, | |||||
| "timestamp": "13:43:35 Fr., 15.04.2022" | |||||
| }, | |||||
| { | |||||
| "ip": "85.214.148.23:25566", | |||||
| "name": "Enigmatica 6: Expert 1.0.0", | |||||
| "message": 960983448410013737, | |||||
| "timestamp": "21:25:08 Di., 05.04.2022" | |||||
| }, | |||||
| { | |||||
| "ip": "85.214.148.23:25570", | |||||
| "name": "Medieval Minecraft 1.16.5 v52", | |||||
| "message": 960983449961922600, | |||||
| "timestamp": "11:30:09 So., 10.04.2022" | |||||
| } | |||||
| ]} | |||||
| @ -0,0 +1,52 @@ | |||||
| { | |||||
| "tags_general_filter": [ | |||||
| "-anthro", | |||||
| "-nonconsensual", | |||||
| "-rape", | |||||
| "-vore", | |||||
| "-scat", | |||||
| "-yiff", | |||||
| "-snuff", | |||||
| "-crossdressing", | |||||
| "-mind_break", | |||||
| "-overweight", | |||||
| "-hyper", | |||||
| "-udders" | |||||
| ], | |||||
| "feeds": [ | |||||
| { | |||||
| "channel": "snek-feed", | |||||
| "tags": [ | |||||
| "lamia" | |||||
| ] | |||||
| }, | |||||
| { | |||||
| "channel": "auto-feed", | |||||
| "tags": [ | |||||
| [ | |||||
| "fire_emblem", | |||||
| "sword_art_online", | |||||
| "monster_musume_no_iru_nichijou", | |||||
| "samus_aran", | |||||
| "metroid", | |||||
| "palutena", | |||||
| "xenoblade_chronicles", | |||||
| "xenoblade_chronicles_2", | |||||
| "nintendo", | |||||
| "star_wars", | |||||
| "nier:_automata", | |||||
| "monster_girl", | |||||
| "tate_no_yuusha_no_nariagari", | |||||
| "zero_two_(darling_in_the_franxx)", | |||||
| "re:zero_kara_hajimeru_isekai_seikatsu", | |||||
| "fate_(series)", | |||||
| "darling_in_the_franxx", | |||||
| "dungeon_ni_deai_wo_motomeru_no_wa_machigatteiru_darou_ka", | |||||
| "touhou", | |||||
| "elf" | |||||
| ], | |||||
| "breasts" | |||||
| ] | |||||
| } | |||||
| ] | |||||
| } | |||||