Skip to content
Open
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:
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.
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
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 All @@ -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.
*/
Expand All @@ -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("🪝");

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change to "D" for discord


public final String icon;

Expand All @@ -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) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Response(boolean enabled, String string, Type type, int delayTicks, int cooldownTicks, String webhookUrl) {

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
Expand All @@ -126,6 +150,8 @@ public Response() {
Response validate() {
if (delayTicks < 0)
delayTicks = delayTicksDefault;
if (webhookUrl == null)
webhookUrl = webhookUrlDefault;
return this;
}

Expand Down Expand Up @@ -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(

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return new Response(enabled, string, type, delayTicks, cooldownTicks, webhookUrl).validate();

enabled,
string,
type,
delayTicks,
cooldownTicks,
webhookUrl
).validate();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
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
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Loading