diff --git a/README.md b/README.md index 5f3876e0..754cf361 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ Plays a ping sound when your name is mentioned, with options to create custom al - Adjust sound volume and pitch per-notification. - Customize message highlighting with a color picker and format controls. - Use regex patterns, inclusion and exclusion triggers for fine-grained control. -- Add automatic response messages or trigger [CommandKeys](https://modrinth.com/project/commandkeys) - macros. +- Add automatic response messages, trigger [CommandKeys](https://modrinth.com/project/commandkeys) + macros, or send notifications to Discord webhooks. @@ -133,7 +133,18 @@ match the message. #### Response -Response messages will be sent in chat when the notification is activated. +Response messages can be sent when the notification is activated. ChatNotify supports multiple response types: + +- **Normal**: Sends a message directly in chat +- **Regex**: Uses regex capture groups from the trigger in the response message +- **CommandKeys**: Triggers [CommandKeys](https://modrinth.com/project/commandkeys) macros +- **Discord**: Sends a message to a Discord webhook (useful for monitoring chat events remotely) + +For Discord webhooks: +1. Create a webhook in your Discord server (Server Settings > Integrations > Webhooks) +2. Copy the webhook URL +3. Select "Discord" (🪝) as the response type +4. Enter your message in the left field and paste the webhook URL in the right field Use with caution, as you can easily make a notification send a response which triggers the notification again in a loop, which will spam chat and then crash the game. diff --git a/changelog.md b/changelog.md index 4e6d546a..c27aaad8 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,9 @@ # Changelog +## 2.6.7 + +- Added Discord webhook support for notification responses + ## 2.6.6 - Fixed format code translation not resetting format on color change diff --git a/common/src/main/java/dev/terminalmc/chatnotify/config/Config.java b/common/src/main/java/dev/terminalmc/chatnotify/config/Config.java index 50177065..5c305e86 100644 --- a/common/src/main/java/dev/terminalmc/chatnotify/config/Config.java +++ b/common/src/main/java/dev/terminalmc/chatnotify/config/Config.java @@ -61,7 +61,7 @@ */ public class Config { - public static final int VERSION = 9; + public static final int VERSION = 10; public final int version = VERSION; private static final Path CONFIG_DIR = Services.PLATFORM.getConfigDir(); public static final String FILE_NAME = ChatNotify.MOD_ID + ".json"; diff --git a/common/src/main/java/dev/terminalmc/chatnotify/config/Response.java b/common/src/main/java/dev/terminalmc/chatnotify/config/Response.java index 9e29cd30..d4b90913 100644 --- a/common/src/main/java/dev/terminalmc/chatnotify/config/Response.java +++ b/common/src/main/java/dev/terminalmc/chatnotify/config/Response.java @@ -27,7 +27,7 @@ */ public class Response implements StringSupplier { - public static final int VERSION = 2; + public static final int VERSION = 3; public final int version = VERSION; /** @@ -40,6 +40,11 @@ public class Response implements StringSupplier { */ public transient @Nullable String sendingString; + /** + * The original message that triggered this response. + */ + public transient @Nullable net.minecraft.network.chat.Component triggerMessage; + // Options /** @@ -66,6 +71,12 @@ public class Response implements StringSupplier { public int cooldownTicks; public static final int cooldownTicksDefault = 0; + /** + * The Discord webhook URL (only used when type is DISCORD). + */ + public String webhookUrl; + public static final String webhookUrlDefault = ""; + /** * Controls how {@link Response#string} is processed. */ @@ -83,7 +94,11 @@ public enum Type { /** * Convert into a pair of keys for use by the CommandKeys mod. */ - COMMANDKEYS("K"); + COMMANDKEYS("K"), + /** + * Send as a Discord webhook message. + */ + DISCORD("🪝"); public final String icon; @@ -100,17 +115,26 @@ public Response() { string = stringDefault; delayTicks = delayTicksDefault; type = Type.values()[0]; + webhookUrl = webhookUrlDefault; } /** * Not validated. */ - Response(boolean enabled, String string, Type type, int delayTicks, int cooldownTicks) { + Response( + boolean enabled, + String string, + Type type, + int delayTicks, + int cooldownTicks, + String webhookUrl + ) { this.enabled = enabled; this.string = string; this.type = type; this.delayTicks = delayTicks; this.cooldownTicks = cooldownTicks; + this.webhookUrl = webhookUrl; } @Override @@ -126,6 +150,8 @@ public Response() { Response validate() { if (delayTicks < 0) delayTicks = delayTicksDefault; + if (webhookUrl == null) + webhookUrl = webhookUrlDefault; return this; } @@ -179,7 +205,21 @@ public Response deserialize( silent ); - return new Response(enabled, string, type, delayTicks, cooldownTicks).validate(); + String webhookUrl = JsonUtil.getOrDefault( + obj, + "webhookUrl", + webhookUrlDefault, + silent + ); + + return new Response( + enabled, + string, + type, + delayTicks, + cooldownTicks, + webhookUrl + ).validate(); } } } diff --git a/common/src/main/java/dev/terminalmc/chatnotify/gui/widget/list/FilterList.java b/common/src/main/java/dev/terminalmc/chatnotify/gui/widget/list/FilterList.java index 8a733ff0..046d2b33 100644 --- a/common/src/main/java/dev/terminalmc/chatnotify/gui/widget/list/FilterList.java +++ b/common/src/main/java/dev/terminalmc/chatnotify/gui/widget/list/FilterList.java @@ -670,6 +670,50 @@ public ResponseOptions( ? message.string.split("-")[1] : ""); elements.add(keyField2); + } else if (message.type.equals(Response.Type.DISCORD)) { + // Discord webhook - show message and webhook URL fields + int fieldWidth = msgFieldWidth / 2 - fieldSpacing / 2; + + // Message field + MultiLineTextField msgField = + new MultiLineTextField( + movingX, + 0, + fieldWidth, + height * 2 + ); + msgField.setCharacterLimit(256); + msgField.setValue(message.string); + msgField.setValueListener((val) -> message.string = val.strip()); + msgField.setHint(localized( + "option", + "notif.response.discord.message" + ).copy()); + elements.add(msgField); + movingX += fieldWidth + fieldSpacing; + + // Webhook URL field + MultiLineTextField webhookField = + new MultiLineTextField( + movingX, + 0, + fieldWidth, + height * 2 + ); + webhookField.setCharacterLimit(256); + webhookField.setValue( + message.webhookUrl != null + ? message.webhookUrl + : Response.webhookUrlDefault + ); + webhookField.setValueListener( + (val) -> message.webhookUrl = val.strip() + ); + webhookField.setHint(localized( + "option", + "notif.response.discord.webhook_url" + ).copy()); + elements.add(webhookField); } else { // Response field MultiLineTextField msgField = diff --git a/common/src/main/java/dev/terminalmc/chatnotify/gui/widget/list/root/ControlList.java b/common/src/main/java/dev/terminalmc/chatnotify/gui/widget/list/root/ControlList.java index 8e4189f0..f80dd552 100644 --- a/common/src/main/java/dev/terminalmc/chatnotify/gui/widget/list/root/ControlList.java +++ b/common/src/main/java/dev/terminalmc/chatnotify/gui/widget/list/root/ControlList.java @@ -143,5 +143,6 @@ private static class Controls2 extends Entry { )); } } + } } diff --git a/common/src/main/java/dev/terminalmc/chatnotify/gui/widget/list/root/DetectionList.java b/common/src/main/java/dev/terminalmc/chatnotify/gui/widget/list/root/DetectionList.java index 0fa8540e..dda19b37 100644 --- a/common/src/main/java/dev/terminalmc/chatnotify/gui/widget/list/root/DetectionList.java +++ b/common/src/main/java/dev/terminalmc/chatnotify/gui/widget/list/root/DetectionList.java @@ -31,6 +31,8 @@ import net.minecraft.network.chat.CommonComponents; import net.minecraft.network.chat.Component; +import java.time.Duration; + import static dev.terminalmc.chatnotify.util.Localization.localized; public class DetectionList extends OptionList { diff --git a/common/src/main/java/dev/terminalmc/chatnotify/util/DiscordWebhookHandler.java b/common/src/main/java/dev/terminalmc/chatnotify/util/DiscordWebhookHandler.java new file mode 100644 index 00000000..857b6778 --- /dev/null +++ b/common/src/main/java/dev/terminalmc/chatnotify/util/DiscordWebhookHandler.java @@ -0,0 +1,162 @@ +/* + * Copyright 2026 TerminalMC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.terminalmc.chatnotify.util; + +import com.google.gson.JsonObject; +import dev.terminalmc.chatnotify.ChatNotify; +import net.minecraft.client.Minecraft; +import net.minecraft.network.chat.Component; +import org.jetbrains.annotations.Nullable; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +/** + * Handles sending messages to Discord webhooks. + */ +public class DiscordWebhookHandler { + + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + private DiscordWebhookHandler() { + } + + /** + * Sends a message to a Discord webhook asynchronously. + * + * @param webhookUrl the Discord webhook URL + * @param content the message content to send + * @param triggerMessage the original message that triggered the notification + */ + public static void sendAsync( + String webhookUrl, + String content, + @Nullable Component triggerMessage + ) { + if (webhookUrl == null || webhookUrl.isBlank()) { + ChatNotify.LOG.warn("Discord webhook URL is empty, skipping webhook send"); + return; + } + + if (content == null || content.isBlank()) { + ChatNotify.LOG.warn("Discord webhook content is empty, skipping webhook send"); + return; + } + + // Validate webhook URL format + if (!isValidWebhookUrl(webhookUrl)) { + ChatNotify.LOG.error("Invalid Discord webhook URL format: {}", webhookUrl); + return; + } + + // Build JSON payload with embed + JsonObject embed = new JsonObject(); + embed.addProperty("title", "Chat triggered"); + + // Use the trigger message content if available, otherwise use the response content + String description = triggerMessage != null + ? triggerMessage.getString() + : content; + embed.addProperty("description", description); + embed.addProperty("color", 3303592); // #3268a8 + + JsonObject footer = new JsonObject(); + // Get server/world name + Minecraft mc = Minecraft.getInstance(); + String serverName = "Minecraft"; + if (mc.level != null) { + if (mc.getCurrentServer() != null) { + // Multiplayer - use server name + serverName = mc.getCurrentServer().name; + } else if (mc.getSingleplayerServer() != null) { + // Singleplayer - use world name + serverName = mc.getSingleplayerServer() + .getWorldData() + .getLevelSettings() + .levelName(); + } + } + footer.addProperty("text", serverName); + embed.add("footer", footer); + + JsonObject payload = new JsonObject(); + com.google.gson.JsonArray embeds = new com.google.gson.JsonArray(); + embeds.add(embed); + payload.add("embeds", embeds); + + String jsonPayload = payload.toString(); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(webhookUrl)) + .timeout(Duration.ofSeconds(10)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(jsonPayload, StandardCharsets.UTF_8)) + .build(); + + // Send asynchronously + HTTP_CLIENT.sendAsync(request, HttpResponse.BodyHandlers.ofString()) + .thenApply(HttpResponse::statusCode) + .thenAccept(statusCode -> { + if (statusCode >= 200 && statusCode < 300) { + ChatNotify.LOG.info("Successfully sent Discord webhook message"); + } else if (statusCode == 429) { + ChatNotify.LOG.warn("Discord webhook rate limited (HTTP {})", statusCode); + } else { + ChatNotify.LOG.error( + "Failed to send Discord webhook message (HTTP {})", + statusCode + ); + } + }) + .exceptionally(throwable -> { + ChatNotify.LOG.error("Error sending Discord webhook message", throwable); + return null; + }); + } + + /** + * Validates that a webhook URL is a valid Discord webhook URL. + * + * @param webhookUrl the URL to validate + * @return true if the URL is a valid Discord webhook URL + */ + private static boolean isValidWebhookUrl(String webhookUrl) { + try { + URI uri = URI.create(webhookUrl); + String scheme = uri.getScheme(); + String host = uri.getHost(); + String path = uri.getPath(); + + // Discord webhook URLs must use HTTPS for secure transmission + // Format: https://discord.com/api/webhooks/... + // or: https://discordapp.com/api/webhooks/... + return "https".equals(scheme) + && (host != null + && (host.equals("discord.com") + || host.equals("discordapp.com"))) + && path != null && path.startsWith("/api/webhooks/"); + } catch (Exception e) { + return false; + } + } +} diff --git a/common/src/main/java/dev/terminalmc/chatnotify/util/ResponseUtil.java b/common/src/main/java/dev/terminalmc/chatnotify/util/ResponseUtil.java index 8e7be3f4..155a6131 100644 --- a/common/src/main/java/dev/terminalmc/chatnotify/util/ResponseUtil.java +++ b/common/src/main/java/dev/terminalmc/chatnotify/util/ResponseUtil.java @@ -60,6 +60,12 @@ public static void tickResponses(Minecraft mc) { if (res.sendingString != null && !res.sendingString.isBlank()) { if (res.type.equals(Response.Type.COMMANDKEYS)) { CommandKeysWrapper.trySend(res.sendingString); + } else if (res.type.equals(Response.Type.DISCORD)) { + DiscordWebhookHandler.sendAsync( + res.webhookUrl, + res.sendingString, + res.triggerMessage + ); } else { sending.add(res.sendingString); } diff --git a/common/src/main/java/dev/terminalmc/chatnotify/util/text/MessageUtil.java b/common/src/main/java/dev/terminalmc/chatnotify/util/text/MessageUtil.java index 2de8df97..4760203e 100644 --- a/common/src/main/java/dev/terminalmc/chatnotify/util/text/MessageUtil.java +++ b/common/src/main/java/dev/terminalmc/chatnotify/util/text/MessageUtil.java @@ -314,7 +314,7 @@ private static String checkOwner(String cleanStr) { // Send response messages Matcher subsMatcher = trig.type == Trigger.Type.REGEX ? matcher : null; - sendResponses(notif, subsMatcher); + sendResponses(notif, subsMatcher, msg); // Restyle msg = StyleUtil.restyle(msg, cleanStr, trig, matcher, notif.textStyle, restyleAll); @@ -633,10 +633,26 @@ private static void copyClipboardMsg(Notification notif, Component msg, Matcher * @param notif the Notification. */ private static void sendResponses(Notification notif, @Nullable Matcher matcher) { + sendResponses(notif, matcher, null); + } + + /** + * Sends all response messages of the specified notification, if the relevant control is + * enabled. + * + * @param notif the Notification. + * @param triggerMessage the original message that triggered the notification + */ + private static void sendResponses( + Notification notif, + @Nullable Matcher matcher, + @Nullable Component triggerMessage + ) { if (notif.responseEnabled) { int totalDelay = 0; for (Response msg : notif.responses) { msg.sendingString = msg.string; + msg.triggerMessage = triggerMessage; if (msg.type.equals(Response.Type.REGEX) && matcher != null && matcher.find(0)) { // Capturing group substitution for (int i = 0; i <= matcher.groupCount(); i++) { diff --git a/common/src/main/resources/assets/chatnotify/lang/en_us.json b/common/src/main/resources/assets/chatnotify/lang/en_us.json index 1ce5b19f..2d840097 100644 --- a/common/src/main/resources/assets/chatnotify/lang/en_us.json +++ b/common/src/main/resources/assets/chatnotify/lang/en_us.json @@ -172,8 +172,11 @@ "option.chatnotify.notif.response.list.tooltip.warning": "Warning: Can crash the game, use with caution.", "option.chatnotify.notif.response.time.tooltip": "Time in ticks to wait (after the previous message, if any) before sending.", "option.chatnotify.notif.response.type.COMMANDKEYS.tooltip": "CommandKeys response.\nIf the CommandKeys mod is loaded (version 2.3.1 or later), this response will trigger all macros with keybinds matching the key IDs you set.", + "option.chatnotify.notif.response.type.DISCORD.tooltip": "Discord webhook response.\nSends a message to the specified Discord webhook URL when this notification is activated.", "option.chatnotify.notif.response.type.NORMAL.tooltip": "Normal response.", "option.chatnotify.notif.response.type.REGEX.tooltip": "Regex response.\nUse (1), (2) etc in the message to access regex capturing groups from the trigger.", + "option.chatnotify.notif.response.discord.message": "Message", + "option.chatnotify.notif.response.discord.webhook_url": "Webhook URL", "option.chatnotify.notif.sound": "Sound", "option.chatnotify.notif.sound.field.tooltip": "Notification Sound", "option.chatnotify.notif.sound.open.minecraft_volume.tooltip": "Open Minecraft's volume settings", diff --git a/gradle.properties b/gradle.properties index 27b5b09b..238ff3d1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,7 +10,7 @@ template_version=10 # Mod Version -mod_version=2.6.6 +mod_version=2.6.7 # 'STABLE', 'BETA' or 'ALPHA' mod_version_type=STABLE