diff --git a/README.md b/README.md index 5f3876e0..3baf2722 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, you'll need to: +1. Create a webhook in your Discord server (Server Settings > Integrations > Webhooks) +2. Copy the webhook URL +3. In ChatNotify, open Detection > Sender Detection and enable Discord Webhook +4. Paste the webhook URL into the Webhook URL 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..7de39ba1 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"; @@ -199,6 +199,18 @@ public enum SenderDetectionMode { public static final Supplier> prefixesDefault = () -> new ArrayList<>(List.of("/shout", "/me", "!")); + /** + * Whether Discord webhook responses are enabled. + */ + public boolean discordWebhookEnabled; + public static final boolean discordWebhookEnabledDefault = false; + + /** + * The global Discord webhook URL for all notifications. + */ + public String discordWebhookUrl; + public static final String discordWebhookUrlDefault = ""; + // Notifications /** @@ -228,6 +240,8 @@ public Config() { SenderDetectionMode.values()[0], checkOwnMessagesDefault, prefixesDefault.get(), + discordWebhookEnabledDefault, + discordWebhookUrlDefault, notificationsDefault.get() ); } @@ -250,6 +264,8 @@ public Config() { SenderDetectionMode senderDetectionMode, boolean checkOwnMessages, List prefixes, + boolean discordWebhookEnabled, + String discordWebhookUrl, List notifications ) { this.debugMode = debugMode; @@ -266,6 +282,8 @@ public Config() { this.senderDetectionMode = senderDetectionMode; this.checkOwnMessages = checkOwnMessages; this.prefixes = prefixes; + this.discordWebhookEnabled = discordWebhookEnabled; + this.discordWebhookUrl = discordWebhookUrl; this.notifications = notifications; } @@ -634,6 +652,20 @@ public Config deserialize( silent ); + boolean discordWebhookEnabled = JsonUtil.getOrDefault( + obj, + "discordWebhookEnabled", + discordWebhookEnabledDefault, + silent + ); + + String discordWebhookUrl = JsonUtil.getOrDefault( + obj, + "discordWebhookUrl", + discordWebhookUrlDefault, + silent + ); + List notifications = JsonUtil.getOrDefault( ctx, obj, @@ -658,6 +690,8 @@ public Config deserialize( senderDetectionMode, checkOwnMessages, prefixes, + discordWebhookEnabled, + discordWebhookUrl, notifications ).validate(); } 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..1f3dc56e 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 /** @@ -83,7 +88,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("D"); public final String icon; 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..e8a515ca 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,9 @@ public ResponseOptions( ? message.string.split("-")[1] : ""); elements.add(keyField2); + } else if (message.type.equals(Response.Type.DISCORD)) { + // Discord webhook - no fields needed, just enabled/disabled + // The message that triggered the notification is sent automatically } 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..e1eea00a 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 { @@ -75,6 +77,7 @@ protected void addEntries() { addEntry(new Entry.SelfNotify(dynEntryX, dynEntryWidth, entryHeight)); addEntry(new Entry.SenderDetection(dynEntryX, dynEntryWidth, entryHeight)); + addEntry(new Entry.DiscordWebhook(dynEntryX, dynEntryWidth, entryHeight)); addEntry(new OptionList.Entry.Text( entryX, @@ -265,6 +268,53 @@ private static class SenderDetection extends Entry { } } + private static class DiscordWebhook extends Entry { + + DiscordWebhook(int x, int width, int height) { + super(); + + int toggleWidth = Math.min(120, (width - SPACE) / 3); + TextField webhookField = new TextField( + x + toggleWidth + SPACE, + 0, + width - toggleWidth - SPACE, + height + ); + webhookField.setMaxLength(256); + webhookField.setValue(Config.get().discordWebhookUrl); + webhookField.setTooltip(Tooltip.create(localized( + "option", + "detection.discord_webhook.url.tooltip" + ))); + webhookField.setTooltipDelay(Duration.ofMillis(500)); + webhookField.setResponder((val) -> Config.get().discordWebhookUrl = val.strip()); + webhookField.setHint(localized("option", "detection.discord_webhook.url").copy()); + webhookField.active = Config.get().discordWebhookEnabled; + elements.add(webhookField); + + elements.add(CycleButton.booleanBuilder( + CommonComponents.OPTION_ON.copy().withStyle(ChatFormatting.GREEN), + CommonComponents.OPTION_OFF.copy().withStyle(ChatFormatting.RED) + ) + .withInitialValue(Config.get().discordWebhookEnabled) + .withTooltip((status) -> Tooltip.create(localized( + "option", + "detection.discord_webhook.tooltip" + ))) + .create( + x, + 0, + toggleWidth, + height, + localized("option", "detection.discord_webhook"), + (button, status) -> { + Config.get().discordWebhookEnabled = status; + webhookField.active = status; + } + )); + } + } + private static class PrefixFieldEntry extends Entry { PrefixFieldEntry(int x, int width, int height, DetectionList list, int index) { 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..aae4dc60 --- /dev/null +++ b/common/src/main/java/dev/terminalmc/chatnotify/util/DiscordWebhookHandler.java @@ -0,0 +1,149 @@ +/* + * 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 + // and should be 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..b6323653 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,16 @@ 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)) { + Config config = Config.get(); + String webhookUrl = config.discordWebhookUrl; + if (config.discordWebhookEnabled && !webhookUrl.isBlank()) { + DiscordWebhookHandler.sendAsync( + 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..467dac52 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,22 @@ 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..48faf915 100644 --- a/common/src/main/resources/assets/chatnotify/lang/en_us.json +++ b/common/src/main/resources/assets/chatnotify/lang/en_us.json @@ -85,6 +85,10 @@ "option.chatnotify.detection.sender.mode.status.SENT_MATCH": "Sent message match", "option.chatnotify.detection.sender.mode.status.SENT_MATCH.tooltip": "Incoming messages will be identified as sent by you if they match a recently-sent message and match a trigger of the first notification.", "option.chatnotify.detection.sender.tooltip": "If your own messages are triggering notifications unexpectedly, try adjusting the options below.", + "option.chatnotify.detection.discord_webhook": "Discord Webhook", + "option.chatnotify.detection.discord_webhook.tooltip": "Enable or disable Discord webhook responses for all notifications.", + "option.chatnotify.detection.discord_webhook.url": "Webhook URL", + "option.chatnotify.detection.discord_webhook.url.tooltip": "Set the global Discord webhook URL for all notifications.", "option.chatnotify.notif": "Notifications", "option.chatnotify.notif.click_edit": "Click to edit.\nRight-click to toggle.", "option.chatnotify.notif.color.field.tooltip": "Text Restyle Color", @@ -172,6 +176,7 @@ "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.\nUses the global Discord webhook settings in Detection.", "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.sound": "Sound", diff --git a/common/src/main/resources/assets/chatnotify/lang/ru_ru.json b/common/src/main/resources/assets/chatnotify/lang/ru_ru.json index fb1101f3..a72f4dde 100644 --- a/common/src/main/resources/assets/chatnotify/lang/ru_ru.json +++ b/common/src/main/resources/assets/chatnotify/lang/ru_ru.json @@ -85,6 +85,10 @@ "option.chatnotify.detection.sender.mode.status.SENT_MATCH": "Совпадение отправленного сообщения", "option.chatnotify.detection.sender.mode.status.SENT_MATCH.tooltip": "Входящие сообщения будут определены как отправленные вами, если они совпадают с недавно отправленным сообщением и соответствуют триггеру первого уведомления.", "option.chatnotify.detection.sender.tooltip": "Если ваши собственные сообщения вызывают уведомления, попробуйте настроить следующие параметры.", + "option.chatnotify.detection.discord_webhook": "Discord Webhook", + "option.chatnotify.detection.discord_webhook.tooltip": "Включить или отключить ответы вебхука Discord для всех уведомлений.", + "option.chatnotify.detection.discord_webhook.url": "URL вебхука", + "option.chatnotify.detection.discord_webhook.url.tooltip": "Установите глобальный URL вебхука Discord для всех уведомлений.", "option.chatnotify.notif": "Параметры уведомлений", "option.chatnotify.notif.click_edit": "Нажмите, чтобы редактировать.\nПКМ для переключения.", "option.chatnotify.notif.color.field.tooltip": "Цвет изменения стиля текста", diff --git a/common/src/main/resources/assets/chatnotify/lang/zh_cn.json b/common/src/main/resources/assets/chatnotify/lang/zh_cn.json index f394d81e..3307346c 100644 --- a/common/src/main/resources/assets/chatnotify/lang/zh_cn.json +++ b/common/src/main/resources/assets/chatnotify/lang/zh_cn.json @@ -85,6 +85,10 @@ "option.chatnotify.detection.sender.mode.status.SENT_MATCH": "已发送消息匹配", "option.chatnotify.detection.sender.mode.status.SENT_MATCH.tooltip": "如果传入消息与最近发送的消息匹配且与第一个通知的触发器匹配,则将其识别为你发送的消息", "option.chatnotify.detection.sender.tooltip": "If your own messages are triggering notifications unexpectedly, try adjusting the options below.", + "option.chatnotify.detection.discord_webhook": "Discord Webhook", + "option.chatnotify.detection.discord_webhook.tooltip": "Enable or disable Discord webhook responses for all notifications.", + "option.chatnotify.detection.discord_webhook.url": "Webhook URL", + "option.chatnotify.detection.discord_webhook.url.tooltip": "Set the global Discord webhook URL for all notifications.", "option.chatnotify.notif": "通知选项", "option.chatnotify.notif.click_edit": "点击编辑\n右键点击切换", "option.chatnotify.notif.color.field.tooltip": "文本重样式颜色", diff --git a/common/src/main/resources/assets/chatnotify/lang/zh_tw.json b/common/src/main/resources/assets/chatnotify/lang/zh_tw.json index ed9c9303..c8f53990 100644 --- a/common/src/main/resources/assets/chatnotify/lang/zh_tw.json +++ b/common/src/main/resources/assets/chatnotify/lang/zh_tw.json @@ -85,6 +85,10 @@ "option.chatnotify.detection.sender.mode.status.SENT_MATCH": "Sent message match", "option.chatnotify.detection.sender.mode.status.SENT_MATCH.tooltip": "Incoming messages will be identified as sent by you if they match a recently-sent message and match a trigger of the first notification.", "option.chatnotify.detection.sender.tooltip": "If your own messages are triggering notifications unexpectedly, try adjusting the options below.", + "option.chatnotify.detection.discord_webhook": "Discord Webhook", + "option.chatnotify.detection.discord_webhook.tooltip": "Enable or disable Discord webhook responses for all notifications.", + "option.chatnotify.detection.discord_webhook.url": "Webhook URL", + "option.chatnotify.detection.discord_webhook.url.tooltip": "Set the global Discord webhook URL for all notifications.", "option.chatnotify.notif": "通知選項", "option.chatnotify.notif.click_edit": "Click to edit.\nRight-click to toggle.", "option.chatnotify.notif.color.field.tooltip": "Text Restyle Color", 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