Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<img src="https://raw.githubusercontent.com/TerminalMC/ChatNotify/HEAD/assets/images/chat_cropped.png" width="500px">

Expand Down Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -199,6 +199,18 @@ public enum SenderDetectionMode {
public static final Supplier<List<String>> 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

/**
Expand Down Expand Up @@ -228,6 +240,8 @@ public Config() {
SenderDetectionMode.values()[0],
checkOwnMessagesDefault,
prefixesDefault.get(),
discordWebhookEnabledDefault,
discordWebhookUrlDefault,
notificationsDefault.get()
);
}
Expand All @@ -250,6 +264,8 @@ public Config() {
SenderDetectionMode senderDetectionMode,
boolean checkOwnMessages,
List<String> prefixes,
boolean discordWebhookEnabled,
String discordWebhookUrl,
List<Notification> notifications
) {
this.debugMode = debugMode;
Expand All @@ -266,6 +282,8 @@ public Config() {
this.senderDetectionMode = senderDetectionMode;
this.checkOwnMessages = checkOwnMessages;
this.prefixes = prefixes;
this.discordWebhookEnabled = discordWebhookEnabled;
this.discordWebhookUrl = discordWebhookUrl;
this.notifications = notifications;
}

Expand Down Expand Up @@ -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<Notification> notifications = JsonUtil.getOrDefault(
ctx,
obj,
Expand All @@ -658,6 +690,8 @@ public Config deserialize(
senderDetectionMode,
checkOwnMessages,
prefixes,
discordWebhookEnabled,
discordWebhookUrl,
notifications
).validate();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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

/**
Expand Down Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,5 +143,6 @@ private static class Controls2 extends Entry {
));
}
}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Loading