diff --git a/src/main/java/me/chayapak1/chomens_bot/Bot.java b/src/main/java/me/chayapak1/chomens_bot/Bot.java index 3305fc2..4f2958d 100644 --- a/src/main/java/me/chayapak1/chomens_bot/Bot.java +++ b/src/main/java/me/chayapak1/chomens_bot/Bot.java @@ -69,6 +69,7 @@ public class Bot { public TPSPlugin tps; public EvalPlugin eval; public TrustedPlugin trusted; + public GrepLogPlugin grepLog; public BruhifyPlugin bruhify; public CloopPlugin cloop; public ExploitsPlugin exploits; @@ -123,6 +124,7 @@ public class Bot { this.tps = new TPSPlugin(this); this.eval = new EvalPlugin(this); this.trusted = new TrustedPlugin(this); + this.grepLog = new GrepLogPlugin(this); this.bruhify = new BruhifyPlugin(this); this.cloop = new CloopPlugin(this); this.exploits = new ExploitsPlugin(this); diff --git a/src/main/java/me/chayapak1/chomens_bot/commands/GrepLogCommand.java b/src/main/java/me/chayapak1/chomens_bot/commands/GrepLogCommand.java new file mode 100644 index 0000000..4dfc3f1 --- /dev/null +++ b/src/main/java/me/chayapak1/chomens_bot/commands/GrepLogCommand.java @@ -0,0 +1,93 @@ +package me.chayapak1.chomens_bot.commands; + +import me.chayapak1.chomens_bot.Bot; +import me.chayapak1.chomens_bot.command.Command; +import me.chayapak1.chomens_bot.command.CommandContext; +import me.chayapak1.chomens_bot.command.CommandException; +import me.chayapak1.chomens_bot.command.TrustLevel; +import me.chayapak1.chomens_bot.util.ColorUtilities; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; + +public class GrepLogCommand extends Command { + private Thread thread; + + public GrepLogCommand() { + super( + "greplog", + "Queries the bot's logs", + new String[] { "", "...-ignorecase...", "...-regex...", "stop" }, + new String[] { "logquery", "findlog" }, + TrustLevel.PUBLIC, + false + ); + } + + @Override + public Component execute(CommandContext context) throws CommandException { + final Bot bot = context.bot; + + if (bot.discord.jda == null) throw new CommandException(Component.text("The bot's Discord integration has to be enabled to use the command.")); + + boolean ignoreCase = false; + boolean regex = false; + + String firstInput = context.getString(false, true); + + // run 2 times. for example `*greplog -ignorecase -regex test` will be both accepted + for (int i = 0; i < 2; i++) { + if (firstInput.equals("-ignorecase")) { + ignoreCase = true; + firstInput = context.getString(false, true); + } else if (firstInput.equals("-regex")) { + regex = true; + firstInput = context.getString(false, true); + } + } + + // interesting code + final String input = (firstInput + " " + context.getString(true, false)).trim(); + + if (input.equals("stop")) { + if (thread == null) throw new CommandException(Component.text("There is no query process running")); + + bot.grepLog.running = false; + + thread.interrupt(); // ? should i interrupt it this way? + + thread = null; + + return Component.text("Stopped querying the logs").color(ColorUtilities.getColorByString(bot.config.colorPalette.defaultColor)); + } + + if (thread != null) throw new CommandException(Component.text("Another query is already running")); + + context.sendOutput( + Component + .translatable("Started querying the logs for %s") + .color(ColorUtilities.getColorByString(bot.config.colorPalette.defaultColor)) + .arguments( + Component + .text(input) + .color(ColorUtilities.getColorByString(bot.config.colorPalette.string)) + ) + ); + + final boolean finalIgnoreCase = ignoreCase; + final boolean finalRegex = regex; + + thread = new Thread(() -> { + try { + bot.grepLog.search(context, input, finalIgnoreCase, finalRegex); + } catch (CommandException e) { + context.sendOutput(e.message.color(NamedTextColor.RED)); + } + + thread = null; + }); + + thread.start(); + + return null; + } +} diff --git a/src/main/java/me/chayapak1/chomens_bot/plugins/CommandHandlerPlugin.java b/src/main/java/me/chayapak1/chomens_bot/plugins/CommandHandlerPlugin.java index 51bda2e..c7074e3 100644 --- a/src/main/java/me/chayapak1/chomens_bot/plugins/CommandHandlerPlugin.java +++ b/src/main/java/me/chayapak1/chomens_bot/plugins/CommandHandlerPlugin.java @@ -57,6 +57,7 @@ public class CommandHandlerPlugin { registerCommand(new SeenCommand()); registerCommand(new IPFilterCommand()); registerCommand(new StopCommand()); + registerCommand(new GrepLogCommand()); } public boolean disabled = false; diff --git a/src/main/java/me/chayapak1/chomens_bot/plugins/GrepLogPlugin.java b/src/main/java/me/chayapak1/chomens_bot/plugins/GrepLogPlugin.java new file mode 100644 index 0000000..c8ee474 --- /dev/null +++ b/src/main/java/me/chayapak1/chomens_bot/plugins/GrepLogPlugin.java @@ -0,0 +1,168 @@ +package me.chayapak1.chomens_bot.plugins; + +import me.chayapak1.chomens_bot.Bot; +import me.chayapak1.chomens_bot.command.CommandContext; +import me.chayapak1.chomens_bot.command.CommandException; +import me.chayapak1.chomens_bot.util.ColorUtilities; +import me.chayapak1.chomens_bot.util.FileLoggerUtilities; +import me.chayapak1.chomens_bot.util.StringUtilities; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.utils.FileUpload; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.NotDirectoryException; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Comparator; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; +import java.util.stream.Stream; +import java.util.zip.GZIPInputStream; + +public class GrepLogPlugin { + private final Bot bot; + + private Pattern pattern; + + private int count = 0; + + public boolean running = false; + + public GrepLogPlugin (Bot bot) { + this.bot = bot; + } + + public void search (CommandContext context, String input, boolean ignoreCase, boolean regex) throws CommandException { + running = true; + + try (final Stream files = Files.list(FileLoggerUtilities.logDirectory)) { + final Path[] fileList = files.toArray(Path[]::new); + + Arrays.sort(fileList, Comparator.comparing(a -> a.getFileName().toString())); + + final StringBuilder result = new StringBuilder(); + + for (Path filePath : fileList) { + if (!running) return; + + if (count > 1_000_000) break; + + final String fileName = filePath.getFileName().normalize().toString(); + final String absolutePath = filePath.toAbsolutePath().normalize().toString(); + + if (fileName.endsWith(".txt.gz")) { + try ( + final GZIPInputStream gzipInputStream = new GZIPInputStream(new FileInputStream(absolutePath)); + final BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(gzipInputStream, StandardCharsets.UTF_8)) + ) { + result.append(process(bufferedReader, input, ignoreCase, regex)); + } + } else if (fileName.endsWith(".txt")) { + try ( + final FileInputStream fileInputStream = new FileInputStream(absolutePath); + final BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(fileInputStream, StandardCharsets.UTF_8)) + ) { + result.append(process(bufferedReader, input, ignoreCase, regex)); + } + } + } + + // ? should this be here? (see `process` function below) + pattern = null; + + count = 0; + + final String stringifiedResult = result.toString(); + + final long matches = stringifiedResult.lines().count(); + + if (matches == 0) throw new CommandException(Component.text("No matches has been found")); + + final String channelId = bot.discord.servers.get(bot.host + ":" + bot.port); + final TextChannel logChannel = bot.discord.jda.getTextChannelById(channelId); + + if (logChannel == null) return; + + logChannel + .sendMessage("Greplog result:") + .addFiles( + FileUpload.fromData( + // as of the time writing this, discord has an 8 MB file size limit for bots + StringUtilities.truncateToFitUtf8ByteLength(stringifiedResult, 8 * 1000 * 1000).getBytes(), + String.format("result-%d.txt", System.currentTimeMillis() / 1000) + ) + ) + .queue(message -> { + final String url = message.getAttachments().get(0).getUrl(); + + final Component component = Component.translatable("Found %s matches for %s. You can see the results by clicking %s or in the Discord server.") + .color(ColorUtilities.getColorByString(bot.config.colorPalette.defaultColor)) + .arguments( + Component.text(matches).color(ColorUtilities.getColorByString(bot.config.colorPalette.number)), + Component.text(input).color(ColorUtilities.getColorByString(bot.config.colorPalette.string)), + Component + .text("here") + .color(NamedTextColor.GREEN) + .hoverEvent( + HoverEvent.showText( + Component + .text("Click! :D") + .color(ColorUtilities.getColorByString(bot.config.colorPalette.secondary)) + ) + ) + .clickEvent( + ClickEvent.openUrl(url) + ) + ); + + context.sendOutput(component); + }); + } catch (FileNotFoundException e) { + running = false; + throw new CommandException(Component.text("File not found")); + } catch (NotDirectoryException e) { + running = false; + throw new CommandException(Component.text("Logger directory is not a directory")); + } catch (PatternSyntaxException e) { + running = false; + throw new CommandException(Component.text("Pattern is invalid")); + } catch (IOException e) { + running = false; + throw new CommandException(Component.text("An I/O error has occurred")); + } catch (Exception e) { + e.printStackTrace(); + } + + running = false; + } + + private StringBuilder process (BufferedReader bufferedReader, String input, boolean ignoreCase, boolean regex) throws IOException, PatternSyntaxException { + if (regex && pattern == null) { + if (ignoreCase) pattern = Pattern.compile(input, Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CHARACTER_CLASS); + else pattern = Pattern.compile(input, Pattern.UNICODE_CHARACTER_CLASS); + } + + final StringBuilder result = new StringBuilder(); + + String line; + while ((line = bufferedReader.readLine()) != null) { + if ( + (regex && pattern.matcher(line).find()) || // *greplog -regex text OR *greplog -ignorecase -regex text + (!ignoreCase && !regex && line.contains(input)) || // *greplog text + (ignoreCase && StringUtilities.containsIgnoreCase(line, input)) // *greplog -ignorecase + ) { + result.append(line).append("\n"); + + count++; + } + } + + return result; + } +} diff --git a/src/main/java/me/chayapak1/chomens_bot/util/StringUtilities.java b/src/main/java/me/chayapak1/chomens_bot/util/StringUtilities.java new file mode 100644 index 0000000..af8ff81 --- /dev/null +++ b/src/main/java/me/chayapak1/chomens_bot/util/StringUtilities.java @@ -0,0 +1,53 @@ +package me.chayapak1.chomens_bot.util; + +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.Charset; +import java.nio.charset.CharsetDecoder; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; + +public class StringUtilities { + // https://stackoverflow.com/a/35148974/18518424 + public static String truncateToFitUtf8ByteLength(String s, int maxBytes) { + if (s == null) { + return null; + } + Charset charset = StandardCharsets.UTF_8; + CharsetDecoder decoder = charset.newDecoder(); + byte[] sba = s.getBytes(charset); + if (sba.length <= maxBytes) { + return s; + } + // Ensure truncation by having byte buffer = maxBytes + ByteBuffer bb = ByteBuffer.wrap(sba, 0, maxBytes); + CharBuffer cb = CharBuffer.allocate(maxBytes); + // Ignore an incomplete character + decoder.onMalformedInput(CodingErrorAction.IGNORE); + decoder.decode(bb, cb, true); + decoder.flush(cb); + return new String(cb.array(), 0, cb.position()); + } + + // https://stackoverflow.com/a/25379180/18518424 + public static boolean containsIgnoreCase(String src, String what) { + final int length = what.length(); + if (length == 0) + return true; // Empty string is contained + + final char firstLo = Character.toLowerCase(what.charAt(0)); + final char firstUp = Character.toUpperCase(what.charAt(0)); + + for (int i = src.length() - length; i >= 0; i--) { + // Quick check before calling the more expensive regionMatches() method: + final char ch = src.charAt(i); + if (ch != firstLo && ch != firstUp) + continue; + + if (src.regionMatches(true, i, what, 0, length)) + return true; + } + + return false; + } +}