From 4587b701cd276e2fd2ed8403e377851127c41104 Mon Sep 17 00:00:00 2001 From: Gaetarra Date: Sat, 29 Aug 2026 14:08:29 +0200 Subject: [PATCH 1/3] Add Alert node for out-of-game notifications Automation runs while the player is tabbed out or AFK, so a graph needs a way to reach someone who is not looking at the screen. Send Message only reaches chat, which nobody sees while a farm runs unattended. Alert has two modes. Play Sound takes any vanilla sound id plus a volume, and plays it through the sound manager with SimpleSoundInstance.forUI so the alert is not attenuated by where the player happens to be standing. Send Webhook POSTs the node's text to an endpoint configured in settings, which is what reaches a phone when nobody is at the machine. Non-obvious calls made here: - The node completes its future as soon as the notification is dispatched rather than awaiting the response. An alert must never stall the graph it is reporting on, so webhook failures are logged rather than propagated. - Webhook sends are rate limited to one per three seconds. An Alert placed inside Forever would otherwise hammer the endpoint, and Discord kills webhooks that are hammered. - Payload shape is chosen by host: Discord needs a JSON body, ntfy takes the raw text. This is a deliberate two-case heuristic rather than another mode. - The URL is validated once in SettingsManager.sanitizeWebhookUrl and https is required. It is a user-supplied string driving an outbound request from the game client, so it is checked at the boundary instead of at each call site. - Alert reuses the multi-line text fields that Send Message and Calculate already have, by joining NodeTextContent, so message text supports runtime variable interpolation with no new UI. - Toast was considered and left out: it only helps someone already watching the screen, which is not the case this node exists for. Not yet wired: the webhook URL has no settings-popup field, so it is set by editing pathmind/settings.json. That control lives in a Stonecutter source file and is left for a follow-up so this change stays reviewable. Verified: Fabric compiles on 26.2 and NeoForge on 26.1.2, and the generated mc26 source is byte-identical to the authored file, so no string transform was needed. The escaping and URL-validation logic was checked separately against the same inputs as NodeAlertTest. Not verified locally: :common:test and the 1.21.x targets, which need a JDK 21 toolchain; CI covers both. Co-Authored-By: Claude Opus 5 --- .../com/pathmind/data/SettingsManager.java | 30 +++ .../nodes/NodeAlertCommandExecutor.java | 218 ++++++++++++++++++ .../java/com/pathmind/nodes/NodeCatalog.java | 12 +- .../pathmind/nodes/NodeCommandDispatcher.java | 1 + .../java/com/pathmind/nodes/NodeMode.java | 7 + .../com/pathmind/nodes/NodeTextContent.java | 4 +- .../java/com/pathmind/nodes/NodeType.java | 1 + .../resources/assets/pathmind/lang/en_us.json | 4 + .../com/pathmind/nodes/NodeAlertTest.java | 75 ++++++ 9 files changed, 349 insertions(+), 3 deletions(-) create mode 100644 common/src/main/java/com/pathmind/nodes/NodeAlertCommandExecutor.java create mode 100644 common/src/test/java/com/pathmind/nodes/NodeAlertTest.java diff --git a/common/src/main/java/com/pathmind/data/SettingsManager.java b/common/src/main/java/com/pathmind/data/SettingsManager.java index 64628799..529d780f 100644 --- a/common/src/main/java/com/pathmind/data/SettingsManager.java +++ b/common/src/main/java/com/pathmind/data/SettingsManager.java @@ -48,6 +48,7 @@ public static class Settings { public Boolean gotoAllowBreakWhileExecuting = false; public Boolean gotoAllowPlaceWhileExecuting = false; public Boolean keyPressedActivatesInGuis = true; + public String alertWebhookUrl = ""; public Boolean createListUseCustomRadius = false; public Integer createListRadius = 64; public Map presetGroupColors = new LinkedHashMap<>(); @@ -145,6 +146,35 @@ public static boolean shouldShowChatErrors() { return settings.showChatErrors == null || settings.showChatErrors; } + /** + * The configured Alert webhook endpoint, or null when unset or not a usable https URL. + * Validated here rather than at the call site so every caller gets the same trust boundary. + */ + public static String getAlertWebhookUrl() { + return sanitizeWebhookUrl(getCurrent().alertWebhookUrl); + } + + /** + * Returns the URL only when it is a usable https endpoint, otherwise null. Plain http and + * every other scheme are rejected: this string comes from the user and drives an outbound + * request, so it is checked once here rather than at each call site. + */ + public static String sanitizeWebhookUrl(String candidate) { + String raw = candidate == null ? "" : candidate.trim(); + if (raw.isEmpty()) { + return null; + } + try { + java.net.URI uri = java.net.URI.create(raw); + boolean usable = "https".equalsIgnoreCase(uri.getScheme()) + && uri.getHost() != null + && !uri.getHost().isBlank(); + return usable ? raw : null; + } catch (IllegalArgumentException invalid) { + return null; + } + } + public static long getNodeDelayMs() { Settings settings = getCurrent(); int delay = settings.nodeDelayMs == null ? 150 : settings.nodeDelayMs; diff --git a/common/src/main/java/com/pathmind/nodes/NodeAlertCommandExecutor.java b/common/src/main/java/com/pathmind/nodes/NodeAlertCommandExecutor.java new file mode 100644 index 00000000..818bef58 --- /dev/null +++ b/common/src/main/java/com/pathmind/nodes/NodeAlertCommandExecutor.java @@ -0,0 +1,218 @@ +package com.pathmind.nodes; + +import static com.pathmind.util.PathmindI18n.tr; + +import com.pathmind.PathmindCommon; +import com.pathmind.data.SettingsManager; +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; +import java.util.EnumSet; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicLong; +import net.minecraft.client.Minecraft; +import net.minecraft.client.resources.sounds.SimpleSoundInstance; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.Identifier; +import net.minecraft.sounds.SoundEvent; + +/** + * Out-of-game notification. Automation runs while the player is tabbed out or AFK, so an + * alert has to reach someone who is not looking at the screen: a sound for when you are at + * the machine, a webhook for when you are not. + */ +final class NodeAlertCommandExecutor { + /** Discord kills webhooks that are hammered, and an Alert inside Forever would do exactly that. */ + private static final long WEBHOOK_MIN_INTERVAL_MS = 3_000L; + private static final AtomicLong LAST_WEBHOOK_SEND_MS = new AtomicLong(0L); + + private static final String DEFAULT_SOUND_ID = "minecraft:block.note_block.pling"; + private static final Duration WEBHOOK_TIMEOUT = Duration.ofSeconds(10); + + private static volatile HttpClient httpClient; + + private final Node owner; + + NodeAlertCommandExecutor(Node owner) { + this.owner = owner; + } + + void executeAlertCommand(CompletableFuture future) { + if (preprocessAttachedParameter(future) == Node.ParameterHandlingResult.COMPLETE) { + return; + } + + Minecraft client = Minecraft.getInstance(); + String text = resolveAlertText(); + + if (owner.getMode() == NodeMode.ALERT_WEBHOOK) { + sendWebhook(client, text); + } else { + playSound(client); + } + + // An alert must never stall the graph it is reporting on, so the node completes as soon + // as the notification is dispatched rather than waiting on the network. + future.complete(null); + } + + private String resolveAlertText() { + List lines = getMessageLines(); + if (lines == null || lines.isEmpty()) { + return "Pathmind alert"; + } + StringBuilder builder = new StringBuilder(); + for (String raw : lines) { + String line = raw == null ? "" : raw.trim(); + if (line.isEmpty()) { + continue; + } + if (!builder.isEmpty()) { + builder.append('\n'); + } + builder.append(owner.resolveRuntimeVariablesInText(line)); + } + return builder.isEmpty() ? "Pathmind alert" : builder.toString(); + } + + // ---------------------------------------------------------------- sound + + private void playSound(Minecraft client) { + if (client == null) { + return; + } + String soundId = owner.getStringParameter("Sound", DEFAULT_SOUND_ID); + SoundEvent soundEvent = resolveSoundEvent(soundId); + if (soundEvent == null) { + sendNodeErrorMessage(client, tr("pathmind.error.unknownSound", soundId)); + return; + } + float volume = (float) Math.max(0.0, Math.min(1.0, owner.getDoubleParameter("Volume", 1.0))); + if (volume <= 0.0F) { + return; + } + client.execute(() -> { + try { + // forUI keeps the alert at full volume regardless of where the player is standing, + // which is the whole point of an alert. + client.getSoundManager().play(SimpleSoundInstance.forUI(soundEvent, 1.0F, volume)); + } catch (RuntimeException | LinkageError error) { + PathmindCommon.LOGGER.warn("Alert node failed to play a sound", error); + } + }); + } + + private static SoundEvent resolveSoundEvent(String soundId) { + String candidate = soundId == null || soundId.isBlank() ? DEFAULT_SOUND_ID : soundId.trim(); + Identifier identifier = Identifier.tryParse(candidate); + if (identifier == null) { + return null; + } + return BuiltInRegistries.SOUND_EVENT.getOptional(identifier).orElse(null); + } + + // -------------------------------------------------------------- webhook + + private void sendWebhook(Minecraft client, String text) { + String url = SettingsManager.getAlertWebhookUrl(); + if (url == null) { + sendNodeErrorMessage(client, tr("pathmind.error.alertWebhookUnset")); + return; + } + + long now = System.currentTimeMillis(); + long previous = LAST_WEBHOOK_SEND_MS.get(); + if (now - previous < WEBHOOK_MIN_INTERVAL_MS + || !LAST_WEBHOOK_SEND_MS.compareAndSet(previous, now)) { + PathmindCommon.LOGGER.debug("Alert webhook skipped: minimum interval not elapsed"); + return; + } + + HttpRequest request; + try { + request = buildRequest(url, text); + } catch (IllegalArgumentException invalid) { + sendNodeErrorMessage(client, tr("pathmind.error.alertWebhookUnset")); + return; + } + + client().sendAsync(request, HttpResponse.BodyHandlers.discarding()) + .thenAccept(response -> { + if (response.statusCode() >= 300) { + PathmindCommon.LOGGER.warn("Alert webhook returned HTTP {}", response.statusCode()); + } + }) + .exceptionally(error -> { + PathmindCommon.LOGGER.warn("Alert webhook failed to send", error); + return null; + }); + } + + private static HttpRequest buildRequest(String url, String text) { + // ponytail: two payload shapes cover the endpoints people actually use - Discord wants + // JSON, ntfy takes the raw body. Add a Format parameter if a third shape shows up. + boolean discord = URI.create(url).getHost().toLowerCase(java.util.Locale.ROOT).contains("discord"); + String body = discord ? "{\"content\":" + jsonString(text) + "}" : text; + return HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(WEBHOOK_TIMEOUT) + .header("Content-Type", discord ? "application/json" : "text/plain; charset=utf-8") + .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)) + .build(); + } + + static String jsonString(String value) { + String source = value == null ? "" : value; + StringBuilder out = new StringBuilder(source.length() + 2).append('"'); + for (int i = 0; i < source.length(); i++) { + char c = source.charAt(i); + switch (c) { + case '"' -> out.append("\\\""); + case '\\' -> out.append("\\\\"); + case '\n' -> out.append("\\n"); + case '\r' -> out.append("\\r"); + case '\t' -> out.append("\\t"); + default -> { + if (c < 0x20) { + out.append(String.format("\\u%04x", (int) c)); + } else { + out.append(c); + } + } + } + } + return out.append('"').toString(); + } + + private static HttpClient client() { + HttpClient existing = httpClient; + if (existing == null) { + synchronized (NodeAlertCommandExecutor.class) { + existing = httpClient; + if (existing == null) { + existing = HttpClient.newBuilder().connectTimeout(WEBHOOK_TIMEOUT).build(); + httpClient = existing; + } + } + } + return existing; + } + + // ------------------------------------------------------------ delegates + + private Node.ParameterHandlingResult preprocessAttachedParameter(CompletableFuture future) { + return owner.preprocessAttachedParameter(EnumSet.noneOf(Node.ParameterUsage.class), future); + } + + private List getMessageLines() { + return owner.getMessageLines(); + } + + private void sendNodeErrorMessage(Minecraft client, String message) { + owner.sendNodeErrorMessage(client, message); + } +} diff --git a/common/src/main/java/com/pathmind/nodes/NodeCatalog.java b/common/src/main/java/com/pathmind/nodes/NodeCatalog.java index c5997502..d9a4cad6 100644 --- a/common/src/main/java/com/pathmind/nodes/NodeCatalog.java +++ b/common/src/main/java/com/pathmind/nodes/NodeCatalog.java @@ -145,6 +145,7 @@ public final class NodeCatalog { NodeType.EQUIP_HAND, NodeType.UI_UTILS, NodeType.MESSAGE, + NodeType.ALERT, NodeType.STICKY_NOTE); define(NodeCategory.DATA, @@ -555,6 +556,7 @@ public final class NodeCatalog { NodeType.UI_UTILS); sidebar(NodeCategory.INTERFACE, "pathmind.sidebar.group.writingOutput", NodeType.MESSAGE, + NodeType.ALERT, NodeType.WRITE_BOOK, NodeType.WRITE_SIGN); @@ -903,6 +905,9 @@ public final class NodeCatalog { modeParameters(NodeMode.FARM_WAYPOINT, of("Waypoint", ParameterType.STRING, "farm"), of("Range", ParameterType.INTEGER, "10")); + modeParameters(NodeMode.ALERT_SOUND, + of("Sound", ParameterType.STRING, "minecraft:block.note_block.pling"), + of("Volume", ParameterType.DOUBLE, "1.0")); modeParameters(NodeMode.WAIT_SECONDS, of("Duration", ParameterType.DOUBLE, "")); modeParameters(NodeMode.WAIT_TICKS, of("Duration", ParameterType.DOUBLE, "")); modeParameters(NodeMode.WAIT_MINUTES, of("Duration", ParameterType.DOUBLE, "")); @@ -1156,6 +1161,7 @@ public final class NodeCatalog { route(ExecutionRoute.UI_UTILS, NodeType.UI_UTILS); route(ExecutionRoute.WAIT, NodeType.WAIT); route(ExecutionRoute.MESSAGE, NodeType.MESSAGE); + route(ExecutionRoute.ALERT, NodeType.ALERT); route(ExecutionRoute.HOTBAR, NodeType.HOTBAR); route(ExecutionRoute.DROP_ITEM, NodeType.DROP_ITEM); route(ExecutionRoute.DROP_SLOT, NodeType.DROP_SLOT); @@ -1571,6 +1577,7 @@ private static String nameKey(NodeType type) { case WAIT -> "pathmind.node.type.wait"; case STICKY_NOTE -> "pathmind.node.type.stickyNote"; case MESSAGE -> "pathmind.node.type.message"; + case ALERT -> "pathmind.node.type.alert"; case TEMPLATE -> "pathmind.node.type.template"; case STOP_CHAIN -> "pathmind.node.type.stopChain"; case STOP_ALL -> "pathmind.node.type.stopAll"; @@ -1722,6 +1729,7 @@ private static String descriptionKey(NodeType type) { case WAIT -> "pathmind.node.type.wait.desc"; case STICKY_NOTE -> "pathmind.node.type.stickyNote.desc"; case MESSAGE -> "pathmind.node.type.message.desc"; + case ALERT -> "pathmind.node.type.alert.desc"; case TEMPLATE -> "pathmind.node.type.template.desc"; case STOP_CHAIN -> "pathmind.node.type.stopChain.desc"; case STOP_ALL -> "pathmind.node.type.stopAll.desc"; @@ -1871,6 +1879,7 @@ private static int baseColor(NodeType type) { case WAIT -> 0xFF607D8B; case STICKY_NOTE -> 0xFFEBCB5B; case MESSAGE -> 0xFF9E9E9E; + case ALERT -> 0xFFFF8F00; case TEMPLATE -> 0xFF26A69A; case STOP_CHAIN -> 0xFFE53935; case STOP_ALL -> 0xFFE53935; @@ -2100,7 +2109,8 @@ public enum ExecutionRoute { INVERT, COME, SURFACE, - TUNNEL + TUNNEL, + ALERT } private record SidebarGroupDefinition( diff --git a/common/src/main/java/com/pathmind/nodes/NodeCommandDispatcher.java b/common/src/main/java/com/pathmind/nodes/NodeCommandDispatcher.java index 41d0d4c2..036d1599 100644 --- a/common/src/main/java/com/pathmind/nodes/NodeCommandDispatcher.java +++ b/common/src/main/java/com/pathmind/nodes/NodeCommandDispatcher.java @@ -51,6 +51,7 @@ static void execute(Node node, CompletableFuture future) { case UI_UTILS -> new NodeGuiCommandExecutor(node).executeUiUtilsCommand(future); case WAIT -> new NodeFlowCommandExecutor(node).executeWaitCommand(future); case MESSAGE -> new NodeTextIoCommandExecutor(node).executeMessageCommand(future); + case ALERT -> new NodeAlertCommandExecutor(node).executeAlertCommand(future); case HOTBAR -> new NodeInventoryCommandExecutor(node).executeHotbarCommand(future); case DROP_ITEM -> new NodeInventoryCommandExecutor(node).executeDropItemCommand(future); case DROP_SLOT -> new NodeInventoryCommandExecutor(node).executeDropSlotCommand(future); diff --git a/common/src/main/java/com/pathmind/nodes/NodeMode.java b/common/src/main/java/com/pathmind/nodes/NodeMode.java index 870219d6..aedcc0af 100644 --- a/common/src/main/java/com/pathmind/nodes/NodeMode.java +++ b/common/src/main/java/com/pathmind/nodes/NodeMode.java @@ -8,6 +8,9 @@ * Each mode corresponds to a specific behavior within a generalized node type. */ public enum NodeMode { + // ALERT modes + ALERT_SOUND("Play Sound", "Play a sound so you notice while tabbed out or AFK"), + ALERT_WEBHOOK("Send Webhook", "POST the text to the webhook URL set in Pathmind settings"), // GOTO modes GOTO_XYZ("Go to XYZ", "Go to specific X, Y, Z coordinates"), GOTO_XZ("Go to XZ", "Go to X, Z coordinates (Y defaults to surface)"), @@ -176,6 +179,9 @@ public static NodeMode[] getModesForNodeType(NodeType nodeType) { case SENSOR_LOOK_DIRECTION -> new NodeMode[]{ SENSOR_LOOK_YAW, SENSOR_LOOK_PITCH, SENSOR_LOOK_ROTATION }; + case ALERT -> new NodeMode[]{ + ALERT_SOUND, ALERT_WEBHOOK + }; default -> new NodeMode[0]; }; } @@ -200,6 +206,7 @@ public static NodeMode getDefaultModeForNodeType(NodeType nodeType) { case WAIT, PARAM_DURATION -> WAIT_SECONDS; case SENSOR_POSITION_OF -> SENSOR_POSITION_XYZ; case SENSOR_LOOK_DIRECTION -> SENSOR_LOOK_ROTATION; + case ALERT -> ALERT_SOUND; default -> null; }; } diff --git a/common/src/main/java/com/pathmind/nodes/NodeTextContent.java b/common/src/main/java/com/pathmind/nodes/NodeTextContent.java index 2e8d2810..b3864b72 100644 --- a/common/src/main/java/com/pathmind/nodes/NodeTextContent.java +++ b/common/src/main/java/com/pathmind/nodes/NodeTextContent.java @@ -19,7 +19,7 @@ interface Host { this.type = type; this.host = host; this.messageLines = new ArrayList<>(); - if (type == NodeType.MESSAGE || type == NodeType.CALCULATE) { + if (type == NodeType.MESSAGE || type == NodeType.CALCULATE || type == NodeType.ALERT) { this.messageLines.add(getDefaultMessageLineValue()); } this.messageClientSide = false; @@ -129,7 +129,7 @@ String getMessageFieldLabelText(int index) { } boolean hasMessageInputFields() { - return type == NodeType.MESSAGE || type == NodeType.CALCULATE; + return type == NodeType.MESSAGE || type == NodeType.CALCULATE || type == NodeType.ALERT; } boolean hasBookTextInput() { diff --git a/common/src/main/java/com/pathmind/nodes/NodeType.java b/common/src/main/java/com/pathmind/nodes/NodeType.java index 5d929c8b..6854d2f1 100644 --- a/common/src/main/java/com/pathmind/nodes/NodeType.java +++ b/common/src/main/java/com/pathmind/nodes/NodeType.java @@ -150,6 +150,7 @@ public enum NodeType { WAIT, STICKY_NOTE, MESSAGE, + ALERT, TEMPLATE, STOP_CHAIN, STOP_ALL, diff --git a/common/src/main/resources/assets/pathmind/lang/en_us.json b/common/src/main/resources/assets/pathmind/lang/en_us.json index a12e7843..1ecabddb 100644 --- a/common/src/main/resources/assets/pathmind/lang/en_us.json +++ b/common/src/main/resources/assets/pathmind/lang/en_us.json @@ -168,6 +168,8 @@ "pathmind.error.unknownBlockForNode": "Unknown block \"%s\" for %s.", "pathmind.error.unknownItemForNode": "Unknown item \"%s\" for %s.", "pathmind.error.unknownKey": "Unknown key: %s", + "pathmind.error.unknownSound": "Unknown sound: %s", + "pathmind.error.alertWebhookUnset": "Set an https webhook URL in Pathmind settings before using Send Webhook", "pathmind.error.unknownMouseButton": "Unknown mouse button: %s", "pathmind.error.unspecifiedState": "(unspecified state)", "pathmind.error.useCannotActivateArmorSlots": "Use node cannot activate armor slots.", @@ -563,6 +565,8 @@ "pathmind.node.type.look.desc": "Adjusts the player's view direction", "pathmind.node.type.message": "Send Message", "pathmind.node.type.message.desc": "Displays text or sends a message in chat", + "pathmind.node.type.alert": "Alert", + "pathmind.node.type.alert.desc": "Notifies you outside the game: plays a sound, or posts the text to your webhook", "pathmind.node.type.template": "Preset", "pathmind.node.type.template.desc": "References another preset with a live preview", "pathmind.node.type.moveItem": "Move Item", diff --git a/common/src/test/java/com/pathmind/nodes/NodeAlertTest.java b/common/src/test/java/com/pathmind/nodes/NodeAlertTest.java new file mode 100644 index 00000000..a4d6b9ee --- /dev/null +++ b/common/src/test/java/com/pathmind/nodes/NodeAlertTest.java @@ -0,0 +1,75 @@ +package com.pathmind.nodes; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.pathmind.data.SettingsManager; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.Test; + +class NodeAlertTest { + + @Test + void jsonStringEscapesCharactersThatWouldBreakTheDiscordPayload() { + // A message containing a quote or a newline is ordinary user text, and unescaped it + // produces a malformed body that Discord rejects with a 400. + assertEquals("\"plain\"", NodeAlertCommandExecutor.jsonString("plain")); + assertEquals("\"say \\\"hi\\\"\"", NodeAlertCommandExecutor.jsonString("say \"hi\"")); + assertEquals("\"a\\\\b\"", NodeAlertCommandExecutor.jsonString("a\\b")); + assertEquals("\"line1\\nline2\"", NodeAlertCommandExecutor.jsonString("line1\nline2")); + assertEquals("\"tab\\there\"", NodeAlertCommandExecutor.jsonString("tab\there")); + assertEquals("\"\"", NodeAlertCommandExecutor.jsonString(null)); + } + + @Test + void jsonStringEscapesControlCharactersAsUnicode() { + assertEquals("\"\\u0007\"", NodeAlertCommandExecutor.jsonString("\u0007")); + } + + @Test + void jsonStringLeavesNonAsciiTextIntact() { + // The body is sent as UTF-8, so accented and non-Latin text must survive unescaped. + assertEquals("\"raid finí\"", NodeAlertCommandExecutor.jsonString("raid finí")); + } + + @Test + void webhookUrlAcceptsHttpsEndpoints() { + assertNotNull(SettingsManager.sanitizeWebhookUrl("https://ntfy.sh/my-topic")); + assertNotNull(SettingsManager.sanitizeWebhookUrl( + "https://discord.com/api/webhooks/123/abc")); + assertEquals("https://ntfy.sh/t", + SettingsManager.sanitizeWebhookUrl(" https://ntfy.sh/t ")); + } + + @Test + void webhookUrlRejectsAnythingThatIsNotHttps() { + // This string comes from the user and drives an outbound request from the game client, + // so every non-https scheme has to be refused rather than merely discouraged. + List rejected = Arrays.asList( + null, + "", + " ", + "http://ntfy.sh/my-topic", + "ftp://example.com/hook", + "file:///C:/windows/system32", + "javascript:alert(1)", + "not a url at all", + "https://", + "://missing-scheme"); + for (String candidate : rejected) { + assertNull(SettingsManager.sanitizeWebhookUrl(candidate), + "expected rejection for: " + candidate); + } + } + + @Test + void alertIsRegisteredAsARoutedInterfaceNode() { + assertTrue(NodeCatalog.hasExecutionRoute(NodeType.ALERT)); + assertEquals(NodeCategory.INTERFACE, NodeCatalog.category(NodeType.ALERT)); + assertEquals(NodeMode.ALERT_SOUND, NodeMode.getDefaultModeForNodeType(NodeType.ALERT)); + assertEquals(2, NodeMode.getAvailableModesForNodeType(NodeType.ALERT).length); + } +} From 5feaeb452b2dbeeb7640434933734f9649316d6b Mon Sep 17 00:00:00 2001 From: Gaetarra Date: Sat, 29 Aug 2026 14:08:30 +0200 Subject: [PATCH 2/3] Fix NodeAlertTest to call the mode lookup that exists NodeMode has getModesForNodeType, not getAvailableModesForNodeType, so the assertion would not have compiled. Co-Authored-By: Claude Opus 5 --- common/src/test/java/com/pathmind/nodes/NodeAlertTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/test/java/com/pathmind/nodes/NodeAlertTest.java b/common/src/test/java/com/pathmind/nodes/NodeAlertTest.java index a4d6b9ee..140f0cbf 100644 --- a/common/src/test/java/com/pathmind/nodes/NodeAlertTest.java +++ b/common/src/test/java/com/pathmind/nodes/NodeAlertTest.java @@ -70,6 +70,6 @@ void alertIsRegisteredAsARoutedInterfaceNode() { assertTrue(NodeCatalog.hasExecutionRoute(NodeType.ALERT)); assertEquals(NodeCategory.INTERFACE, NodeCatalog.category(NodeType.ALERT)); assertEquals(NodeMode.ALERT_SOUND, NodeMode.getDefaultModeForNodeType(NodeType.ALERT)); - assertEquals(2, NodeMode.getAvailableModesForNodeType(NodeType.ALERT).length); + assertEquals(2, NodeMode.getModesForNodeType(NodeType.ALERT).length); } } From fdd8847fd745c02e1f160937c779153676b7ff2a Mon Sep 17 00:00:00 2001 From: Gaetarra Date: Sat, 29 Aug 2026 16:22:34 +0200 Subject: [PATCH 3/3] Make the Alert node's mode and parameters reachable in the editor Alert shipped unusable: it rendered only its message field, so the Sound and Volume parameters were invisible and there was no way to switch to Send Webhook. Its mode names also displayed as raw keys. Two causes, both missed because the editor has no automated coverage: - NodeGraph.rendersInlineParameters decides whether a node draws its parameter strip, and the mode selector is drawn inside that strip. It is true only for parameter nodes and for types tagged RENDER_INLINE_PARAMETERS. Alert was neither, so the strip that would have held both never rendered. - NodeMode.getDisplayName ignores the display strings in the enum constructor and builds "pathmind.node.mode." as a translation key. Without lang entries the modes rendered as pathmind.node.mode.alert_sound. Verified in a dev client: the mode selector opens, Sound and Volume are editable, and both modes read as English. Co-Authored-By: Claude Opus 5 --- common/src/main/java/com/pathmind/nodes/NodeCatalog.java | 4 ++++ common/src/main/resources/assets/pathmind/lang/en_us.json | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/common/src/main/java/com/pathmind/nodes/NodeCatalog.java b/common/src/main/java/com/pathmind/nodes/NodeCatalog.java index d9a4cad6..1474ff11 100644 --- a/common/src/main/java/com/pathmind/nodes/NodeCatalog.java +++ b/common/src/main/java/com/pathmind/nodes/NodeCatalog.java @@ -441,7 +441,11 @@ public final class NodeCatalog { NodeType.OPEN_INVENTORY, NodeType.CLOSE_GUI); + // This flag is what makes a node draw its parameter strip, and the mode selector lives + // inside that strip (NodeRenderer.renderInlineParameterContent). A node with modes but + // without this tag has no way to reach them in the editor. tag(NodeFlag.RENDER_INLINE_PARAMETERS, + NodeType.ALERT, NodeType.UI_UTILS, NodeType.SENSOR_FABRIC_EVENT, NodeType.SENSOR_ATTRIBUTE_DETECTION, diff --git a/common/src/main/resources/assets/pathmind/lang/en_us.json b/common/src/main/resources/assets/pathmind/lang/en_us.json index 1ecabddb..4af4bde6 100644 --- a/common/src/main/resources/assets/pathmind/lang/en_us.json +++ b/common/src/main/resources/assets/pathmind/lang/en_us.json @@ -287,6 +287,10 @@ "pathmind.node.mode.goal_y.desc": "Set goal to specific Y level", "pathmind.node.mode.goto_block": "Go to Block", "pathmind.node.mode.goto_block.desc": "Go to nearest block of specified type", + "pathmind.node.mode.alert_sound": "Play Sound", + "pathmind.node.mode.alert_webhook": "Send Webhook", + "pathmind.node.mode.alert_sound.desc": "Play a sound so you notice while tabbed out or AFK", + "pathmind.node.mode.alert_webhook.desc": "POST the text to the webhook URL set in Pathmind settings", "pathmind.node.mode.goto_xyz": "Go to XYZ", "pathmind.node.mode.goto_xyz.desc": "Go to specific X, Y, Z coordinates", "pathmind.node.mode.goto_xz": "Go to XZ",