Skip to content
Open
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
30 changes: 30 additions & 0 deletions common/src/main/java/com/pathmind/data/SettingsManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> presetGroupColors = new LinkedHashMap<>();
Expand Down Expand Up @@ -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;
Expand Down
218 changes: 218 additions & 0 deletions common/src/main/java/com/pathmind/nodes/NodeAlertCommandExecutor.java
Original file line number Diff line number Diff line change
@@ -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<Void> 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<String> 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<Void> future) {
return owner.preprocessAttachedParameter(EnumSet.noneOf(Node.ParameterUsage.class), future);
}

private List<String> getMessageLines() {
return owner.getMessageLines();
}

private void sendNodeErrorMessage(Minecraft client, String message) {
owner.sendNodeErrorMessage(client, message);
}
}
16 changes: 15 additions & 1 deletion common/src/main/java/com/pathmind/nodes/NodeCatalog.java
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ public final class NodeCatalog {
NodeType.EQUIP_HAND,
NodeType.UI_UTILS,
NodeType.MESSAGE,
NodeType.ALERT,
NodeType.STICKY_NOTE);

define(NodeCategory.DATA,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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, ""));
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -2100,7 +2113,8 @@ public enum ExecutionRoute {
INVERT,
COME,
SURFACE,
TUNNEL
TUNNEL,
ALERT
}

private record SidebarGroupDefinition(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ static void execute(Node node, CompletableFuture<Void> 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);
Expand Down
7 changes: 7 additions & 0 deletions common/src/main/java/com/pathmind/nodes/NodeMode.java
Original file line number Diff line number Diff line change
Expand Up @@ -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)"),
Expand Down Expand Up @@ -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];
};
}
Expand All @@ -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;
};
}
Expand Down
4 changes: 2 additions & 2 deletions common/src/main/java/com/pathmind/nodes/NodeTextContent.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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() {
Expand Down
Loading