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..1474ff11 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, @@ -440,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, @@ -555,6 +560,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 +909,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 +1165,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 +1581,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 +1733,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 +1883,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 +2113,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..4af4bde6 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.", @@ -285,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", @@ -563,6 +569,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..140f0cbf --- /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.getModesForNodeType(NodeType.ALERT).length); + } +}