35: Separate alarm definitions from action configuration

This commit is contained in:
2026-08-05 10:12:38 +02:00
parent 7266a8c2dd
commit e59aff95b8
19 changed files with 861 additions and 229 deletions
@@ -1,7 +1,5 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import com.r35157.jupiterperpsalarm.AlarmSeverity;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -58,16 +56,10 @@ public final class AlarmConfigurationParser {
cursor.nextToken("trigger")
);
AlarmSeverity severity = AlarmSeverity.valueOf(
cursor.nextToken("severity").toUpperCase()
);
String note = cursor.nextQuotedString("note");
cursor.skipWhitespace();
if (!cursor.atEnd() && cursor.current() != '#') {
throw new IllegalArgumentException(
"Unexpected text after note: " + cursor.remaining()
"Unexpected text after trigger: " + cursor.remaining()
);
}
@@ -76,9 +68,7 @@ public final class AlarmConfigurationParser {
asset,
conditionExpression,
triggerConfiguration.trigger(),
triggerConfiguration.gracePeriod(),
severity,
note
triggerConfiguration.gracePeriod()
);
}
@@ -159,50 +149,6 @@ public final class AlarmConfigurationParser {
return line.substring(start, position);
}
private String nextQuotedString(String fieldName) {
skipWhitespace();
if (atEnd() || current() != '"') {
throw new IllegalArgumentException(
"Missing quoted " + fieldName + "; expected \"...\""
);
}
position++;
StringBuilder result = new StringBuilder();
boolean escaped = false;
while (!atEnd()) {
char character = current();
position++;
if (escaped) {
result.append(switch (character) {
case 'n' -> '\n';
case 'r' -> '\r';
case 't' -> '\t';
case '"' -> '"';
case '\\' -> '\\';
default -> character;
});
escaped = false;
continue;
}
if (character == '\\') {
escaped = true;
continue;
}
if (character == '"') {
return result.toString();
}
result.append(character);
}
throw new IllegalArgumentException("Unterminated quoted " + fieldName);
}
private void skipWhitespace() {
while (!atEnd() && Character.isWhitespace(current())) {
position++;
@@ -0,0 +1,5 @@
package com.r35157.jupiterperpsalarm.impl.ref;
public interface ConfiguredAlarmAction extends AlarmAction {
String getConfigurationFileName();
}
@@ -1,8 +1,32 @@
package com.r35157.jupiterperpsalarm.impl.ref;
public final class ConsoleAlarmAction implements AlarmAction {
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
public final class ConsoleAlarmAction implements ConfiguredAlarmAction {
public ConsoleAlarmAction(Path configurationDirectory) throws IOException {
Objects.requireNonNull(configurationDirectory, "configurationDirectory");
alarmIds = parseAlarmIds(
configurationDirectory.resolve(getConfigurationFileName())
);
}
@Override
public String getConfigurationFileName() {
return "alarmaction_Console.conf";
}
@Override
public void trigger(OraclePrice price, ResolvedPriceAlarm alarm) {
if (!alarmIds.contains(alarm.id())) {
return;
}
System.err.println();
System.err.println("============================================================");
System.err.printf(
@@ -21,4 +45,45 @@ public final class ConsoleAlarmAction implements AlarmAction {
System.err.println("============================================================");
System.err.println();
}
private static Set<Integer> parseAlarmIds(Path path) throws IOException {
List<String> lines = Files.readAllLines(path);
Set<Integer> result = new LinkedHashSet<>();
for (int lineNumber = 1; lineNumber <= lines.size(); lineNumber++) {
String line = removeComment(lines.get(lineNumber - 1)).trim();
if (line.isEmpty()) {
continue;
}
try {
if (line.chars().anyMatch(Character::isWhitespace)) {
throw new IllegalArgumentException(
"Expected one alarm id, but found: " + line
);
}
int alarmId = Integer.parseInt(line);
if (!result.add(alarmId)) {
throw new IllegalArgumentException(
"Duplicate alarm id: " + alarmId
);
}
} catch (RuntimeException exception) {
throw new IllegalArgumentException(
path + ":" + lineNumber + ": " + exception.getMessage(),
exception
);
}
}
return Set.copyOf(result);
}
private static String removeComment(String line) {
int commentStart = line.indexOf('#');
return commentStart < 0 ? line : line.substring(0, commentStart);
}
private final Set<Integer> alarmIds;
}
@@ -1,7 +1,5 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import com.r35157.cryptowallet.solana.SolanaWallet;
import com.r35157.cryptowallet.solana.impl.ref.SolanaWalletImpl;
import com.r35157.libs.jupiter.perps.JupiterPerpsService;
import com.r35157.libs.jupiter.perps.impl.anchoridl.AnchorIdlJupiterPerpsServiceImpl;
import com.r35157.libs.objcache.ObjectCache;
@@ -82,26 +80,22 @@ public final class JupiterPerpsAlarmImpl {
entryPriceVariableRefreshWatcher.start();
List<AlarmAction> actions = new ArrayList<>();
actions.add(new ConsoleAlarmAction());
SolanaBlockChain sbc = new SolanaBlockChainImpl();
JupiterPerpsService jupiter = new AnchorIdlJupiterPerpsServiceImpl(sbc);
ΩSolanaWalletIdΩ walletId = "vj98roDZ7744EBfxyuDFkKpEGCsKQLr7K8UFRumJNHf";
SolanaWallet wallet = new SolanaWalletImpl(walletId, "evelyn-prod", sbc);
actions.add(new JupiterPerpsPositionIncreaseAlarmAction(wallet, jupiter));
Path alarmConfigurationDirectory = config.alarmConfiguration()
.toAbsolutePath()
.getParent();
if (config.pushoverToken() != null && config.pushoverUserKey() != null) {
actions.add(new PushoverAlarmAction(
config.pushoverToken(),
config.pushoverUserKey()
));
System.out.println("Pushover emergency alarm is enabled.");
} else {
System.out.println(
"Pushover is disabled. Set PUSHOVER_APP_TOKEN and " +
"PUSHOVER_USER_KEY to enable it."
);
}
List<AlarmAction> actions = new ArrayList<>();
actions.add(new ConsoleAlarmAction(alarmConfigurationDirectory));
actions.add(new JupiterPerpsPositionIncreaseAlarmAction(
alarmConfigurationDirectory,
solanaBlockChain,
jupiterPerpsService,
variableResolver
));
actions.add(new PushoverAlarmAction(
alarmConfigurationDirectory,
variableResolver
));
AlarmAction action = new CompositeAlarmAction(actions);
Map<JupiterPerpsAsset, List<PriceAlarmDefinition>> definitionsByAsset =
@@ -145,10 +139,9 @@ public final class JupiterPerpsAlarmImpl {
asset.oracleAccount()
);
assetDefinitions.forEach(definition -> System.out.printf(
" %s USD, %s, severity=%s%n",
" %s USD, %s%n",
definition.conditionExpression(),
definition.trigger(),
definition.severity()
definition.trigger()
));
});
System.out.println("RPC endpoints per asset: " + config.webSocketEndpoints().size());
@@ -208,16 +201,12 @@ public final class JupiterPerpsAlarmImpl {
Environment:
PRICE_ALARMS_CONFIG Alternative default configuration path
SOLANA_WS_URLS Comma-separated RPC WebSocket endpoints
PUSHOVER_APP_TOKEN Pushover application token
PUSHOVER_USER_KEY Pushover user/group key
""");
}
private record Config(
Path alarmConfiguration,
List<URI> webSocketEndpoints,
String pushoverToken,
String pushoverUserKey
List<URI> webSocketEndpoints
) {
private static Config parse(String[] args, Map<String, String> environment) {
Map<String, String> options = Arrays.stream(args)
@@ -257,15 +246,9 @@ public final class JupiterPerpsAlarmImpl {
return new Config(
Path.of(configurationText),
endpoints,
blankToNull(environment.get("PUSHOVER_APP_TOKEN")),
blankToNull(environment.get("PUSHOVER_USER_KEY"))
endpoints
);
}
private static String blankToNull(String value) {
return value == null || value.isBlank() ? null : value;
}
}
private JupiterPerpsAlarmImpl() {
@@ -1,17 +1,35 @@
package com.r35157.jupiterperpsalarm.impl.ref;
public enum JupiterPerpsAsset {
SOL("FYq2BWQ1V5P1WFBqr3qB2Kb5yHVvSv7upzKodgQE5zXh"),
ETH("AFZnHPzy4mvVCffrVwhewHbFc93uTHvDSFrVH7GtfXF1"),
BTC("hUqAT1KQ7eW1i6Csp9CXYtpPfSAvi835V7wKi5fRfmC");
SOL(
"FYq2BWQ1V5P1WFBqr3qB2Kb5yHVvSv7upzKodgQE5zXh",
"So11111111111111111111111111111111111111112"
),
ETH(
"AFZnHPzy4mvVCffrVwhewHbFc93uTHvDSFrVH7GtfXF1",
"7vfCXTUXx5WJV5JADk17DUJ4ksgau7utNKj4b963voxs"
),
BTC(
"hUqAT1KQ7eW1i6Csp9CXYtpPfSAvi835V7wKi5fRfmC",
"3NZ9JMVBmGAqocybic2c7LQCJScmgsAZ6vQqTDzcqmJh"
);
JupiterPerpsAsset(String oracleAccount) {
JupiterPerpsAsset(
String oracleAccount,
ΩSPLMintAddressΩ tradedTokenMint
) {
this.oracleAccount = oracleAccount;
this.tradedTokenMint = tradedTokenMint;
}
public String oracleAccount() {
return oracleAccount;
}
public ΩSPLMintAddressΩ tradedTokenMint() {
return tradedTokenMint;
}
private final String oracleAccount;
private final ΩSPLMintAddressΩ tradedTokenMint;
}
@@ -1,43 +1,64 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import com.r35157.cryptowallet.solana.SolanaWallet;
import com.r35157.libs.jupiter.perps.JupiterPerpsPositionDirection;
import com.r35157.cryptowallet.solana.impl.ref.SolanaWalletImpl;
import com.r35157.libs.jupiter.perps.JupiterPerpsService;
import com.r35157.libs.solana.SolanaBlockChain;
import com.r35157.libs.solana.SolanaSignedTransaction;
import com.r35157.libs.solana.SolanaUnsignedTransaction;
import java.math.BigDecimal;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Objects;
public final class JupiterPerpsPositionIncreaseAlarmAction implements AlarmAction {
public JupiterPerpsPositionIncreaseAlarmAction(SolanaWallet wallet, JupiterPerpsService jupiter) {
this.wallet = wallet;
this.jupiter = jupiter;
public final class JupiterPerpsPositionIncreaseAlarmAction implements ConfiguredAlarmAction {
public JupiterPerpsPositionIncreaseAlarmAction(
Path configurationDirectory,
SolanaBlockChain solanaBlockChain,
JupiterPerpsService jupiter,
AlarmVariableResolver variableResolver
) throws IOException {
Objects.requireNonNull(configurationDirectory, "configurationDirectory");
Objects.requireNonNull(solanaBlockChain, "solanaBlockChain");
this.jupiter = Objects.requireNonNull(jupiter, "jupiter");
this.variableResolver = Objects.requireNonNull(variableResolver, "variableResolver");
configuration = JupiterPerpsPositionIncreaseAlarmActionConfigurationParser.parse(
configurationDirectory.resolve(getConfigurationFileName()),
variableResolver
);
wallet = new SolanaWalletImpl(
configuration.walletId(),
configuration.signerKeyName(),
solanaBlockChain
);
}
@Override
public String getConfigurationFileName() {
return "alarmaction_JupiterPerpsPositionIncreaseAlarmAction.conf";
}
@Override
public void trigger(OraclePrice price, ResolvedPriceAlarm alarm) {
ΩUSDCAmountΩ inputTokenAmount = new ΩUSDCAmountΩ("0.25");
ΩUSDCAmountΩ sizeUsdDelta;
if(alarm.id() == 4) {
// Critical
sizeUsdDelta = new ΩUSDCAmountΩ("2.50"); // Leverage: 10x
} else if(alarm.id() == 5) {
// Info
sizeUsdDelta = new ΩUSDCAmountΩ("6.25"); // Leverage: 25x
} else {
JupiterPerpsPositionIncreaseAlarmActionConfiguration.PositionIncrease definition =
configuration.positionIncreases().get(alarm.id());
if (definition == null) {
return;
}
try {
JupiterPerpsPositionIncreaseAlarmActionConfiguration.ResolvedPositionIncrease
positionIncrease = definition.resolve(variableResolver);
SolanaUnsignedTransaction utrans =
jupiter.buildPositionIncreaseTransaction(
wallet.getAddress(),
SOL_MINT,
JupiterPerpsPositionDirection.LONG,
inputTokenAmount,
sizeUsdDelta,
200
positionIncrease.asset().tradedTokenMint(),
positionIncrease.direction(),
positionIncrease.collateralUsd(),
positionIncrease.sizeUsdDelta(),
positionIncrease.maxSlippageBps()
);
SolanaSignedTransaction strans = wallet.signTransaction(utrans);
ΩSolanaTransactionSignatureΩ signature = jupiter.executePositionIncreaseTransaction(strans);
@@ -47,9 +68,8 @@ public final class JupiterPerpsPositionIncreaseAlarmAction implements AlarmActio
}
}
private static final ΩSPLMintAddressΩ SOL_MINT = "So11111111111111111111111111111111111111112";
private static final ΩSPLMintAddressΩ BTC_MINT = "3NZ9JMVBmGAqocybic2c7LQCJScmgsAZ6vQqTDzcqmJh";
private SolanaWallet wallet;
private JupiterPerpsService jupiter;
private final JupiterPerpsPositionIncreaseAlarmActionConfiguration configuration;
private final SolanaWallet wallet;
private final JupiterPerpsService jupiter;
private final AlarmVariableResolver variableResolver;
}
@@ -0,0 +1,99 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import com.r35157.libs.jupiter.perps.JupiterPerpsPositionDirection;
import java.math.BigDecimal;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
public record JupiterPerpsPositionIncreaseAlarmActionConfiguration(
ΩSolanaWalletIdΩ walletId,
String signerKeyName,
Map<Integer, PositionIncrease> positionIncreases
) {
public JupiterPerpsPositionIncreaseAlarmActionConfiguration {
Objects.requireNonNull(walletId, "walletId");
Objects.requireNonNull(signerKeyName, "signerKeyName");
positionIncreases = Map.copyOf(positionIncreases);
if (walletId.isBlank()) {
throw new IllegalArgumentException("Wallet id cannot be blank");
}
if (signerKeyName.isBlank()) {
throw new IllegalArgumentException("Signer key name cannot be blank");
}
}
public record PositionIncrease(
String assetExpression,
String directionExpression,
String collateralUsdExpression,
String sizeUsdDeltaExpression,
String maxSlippageBpsExpression
) {
public PositionIncrease {
requireNonBlank(assetExpression, "asset");
requireNonBlank(directionExpression, "direction");
requireNonBlank(collateralUsdExpression, "collateral");
requireNonBlank(sizeUsdDeltaExpression, "position size delta");
requireNonBlank(maxSlippageBpsExpression, "maximum slippage");
}
public ResolvedPositionIncrease resolve(AlarmVariableResolver variableResolver) {
Objects.requireNonNull(variableResolver, "variableResolver");
return new ResolvedPositionIncrease(
JupiterPerpsAsset.valueOf(
variableResolver.resolve(assetExpression).toUpperCase(Locale.ROOT)
),
JupiterPerpsPositionDirection.valueOf(
variableResolver.resolve(directionExpression)
.toUpperCase(Locale.ROOT)
),
new ΩUSDCAmountΩ(variableResolver.resolve(collateralUsdExpression)),
new ΩUSDCAmountΩ(variableResolver.resolve(sizeUsdDeltaExpression)),
Integer.parseInt(variableResolver.resolve(maxSlippageBpsExpression))
);
}
private static void requireNonBlank(String value, String fieldName) {
Objects.requireNonNull(value, fieldName);
if (value.isBlank()) {
throw new IllegalArgumentException(fieldName + " cannot be blank");
}
}
}
public record ResolvedPositionIncrease(
JupiterPerpsAsset asset,
JupiterPerpsPositionDirection direction,
ΩUSDCAmountΩ collateralUsd,
ΩUSDCAmountΩ sizeUsdDelta,
int maxSlippageBps
) {
public ResolvedPositionIncrease {
Objects.requireNonNull(asset, "asset");
Objects.requireNonNull(direction, "direction");
Objects.requireNonNull(collateralUsd, "collateralUsd");
Objects.requireNonNull(sizeUsdDelta, "sizeUsdDelta");
if (collateralUsd.signum() <= 0) {
throw new IllegalArgumentException(
"Collateral must be greater than zero: " + collateralUsd
);
}
if (sizeUsdDelta.signum() < 0) {
throw new IllegalArgumentException(
"Position size delta cannot be negative: " + sizeUsdDelta
);
}
if (maxSlippageBps < 0 || maxSlippageBps > 10_000) {
throw new IllegalArgumentException(
"Maximum slippage must be between 0 and 10000 basis points: "
+ maxSlippageBps
);
}
}
}
}
@@ -0,0 +1,124 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
public final class JupiterPerpsPositionIncreaseAlarmActionConfigurationParser {
public static JupiterPerpsPositionIncreaseAlarmActionConfiguration parse(
Path path,
AlarmVariableResolver variableResolver
) throws IOException {
Objects.requireNonNull(variableResolver, "variableResolver");
List<String> lines = Files.readAllLines(path);
Map<Integer, JupiterPerpsPositionIncreaseAlarmActionConfiguration.PositionIncrease>
positionIncreases = new LinkedHashMap<>();
ΩSolanaWalletIdΩ walletId = null;
String signerKeyName = null;
for (int lineNumber = 1; lineNumber <= lines.size(); lineNumber++) {
String line = removeComment(lines.get(lineNumber - 1)).trim();
if (line.isEmpty()) {
continue;
}
try {
String[] columns = line.split("\\s+");
String firstColumn = columns[0].toUpperCase(Locale.ROOT);
if (firstColumn.equals("WALLET_ID")) {
requireColumnCount(columns, 2, "WALLET_ID value");
if (walletId != null) {
throw new IllegalArgumentException("Duplicate WALLET_ID");
}
walletId = variableResolver.resolve(columns[1]);
} else if (firstColumn.equals("SIGNER_KEY_NAME")) {
requireColumnCount(columns, 2, "SIGNER_KEY_NAME value");
if (signerKeyName != null) {
throw new IllegalArgumentException("Duplicate SIGNER_KEY_NAME");
}
signerKeyName = variableResolver.resolve(columns[1]);
} else {
parsePositionIncrease(
columns,
positionIncreases,
variableResolver
);
}
} catch (RuntimeException exception) {
throw new IllegalArgumentException(
path + ":" + lineNumber + ": " + exception.getMessage(),
exception
);
}
}
if (walletId == null) {
throw new IllegalArgumentException("Missing WALLET_ID in " + path);
}
if (signerKeyName == null) {
throw new IllegalArgumentException("Missing SIGNER_KEY_NAME in " + path);
}
return new JupiterPerpsPositionIncreaseAlarmActionConfiguration(
walletId,
signerKeyName,
positionIncreases
);
}
private static void parsePositionIncrease(
String[] columns,
Map<Integer, JupiterPerpsPositionIncreaseAlarmActionConfiguration.PositionIncrease>
positionIncreases,
AlarmVariableResolver variableResolver
) {
requireColumnCount(
columns,
6,
"alarm id, asset, direction, collateral, size delta, and maximum slippage"
);
int alarmId = Integer.parseInt(columns[0]);
if (positionIncreases.containsKey(alarmId)) {
throw new IllegalArgumentException("Duplicate alarm id: " + alarmId);
}
JupiterPerpsPositionIncreaseAlarmActionConfiguration.PositionIncrease positionIncrease =
new JupiterPerpsPositionIncreaseAlarmActionConfiguration.PositionIncrease(
columns[1],
columns[2],
columns[3],
columns[4],
columns[5]
);
positionIncrease.resolve(variableResolver);
positionIncreases.put(alarmId, positionIncrease);
}
private static void requireColumnCount(
String[] columns,
int expected,
String expectedColumns
) {
if (columns.length != expected) {
throw new IllegalArgumentException(
"Expected " + expectedColumns + ", but found " + columns.length + " column(s)"
);
}
}
private static String removeComment(String line) {
int commentStart = line.indexOf('#');
return commentStart < 0 ? line : line.substring(0, commentStart);
}
private JupiterPerpsPositionIncreaseAlarmActionConfigurationParser() {
}
}
@@ -95,27 +95,12 @@ public final class PriceAlarm {
triggerCount++;
lastTriggeredAt = Instant.now();
String note;
try {
note = variableResolver.resolve(definition.note());
} catch (RuntimeException exception) {
System.err.printf(
"Could not resolve note for alarm %d: %s%n",
definition.id(),
exception.getMessage()
);
return;
}
ResolvedPriceAlarm resolvedAlarm = new ResolvedPriceAlarm(
definition.id(),
definition.asset(),
condition,
definition.trigger(),
definition.triggerGracePeriod(),
definition.severity(),
note
definition.triggerGracePeriod()
);
action.trigger(price, resolvedAlarm);
@@ -1,7 +1,5 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import com.r35157.jupiterperpsalarm.AlarmSeverity;
import java.util.Objects;
public record PriceAlarmDefinition(
@@ -9,16 +7,12 @@ public record PriceAlarmDefinition(
JupiterPerpsAsset asset,
String conditionExpression,
AlarmTrigger trigger,
ΩsecondsΩ triggerGracePeriod,
AlarmSeverity severity,
String note
ΩsecondsΩ triggerGracePeriod
) {
public PriceAlarmDefinition {
Objects.requireNonNull(asset, "asset");
Objects.requireNonNull(conditionExpression, "conditionExpression");
Objects.requireNonNull(trigger, "trigger");
Objects.requireNonNull(severity, "severity");
Objects.requireNonNull(note, "note");
if (conditionExpression.isBlank()) {
throw new IllegalArgumentException("Condition expression cannot be blank");
@@ -2,31 +2,57 @@ package com.r35157.jupiterperpsalarm.impl.ref;
import com.r35157.jupiterperpsalarm.AlarmSeverity;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Objects;
public final class PushoverAlarmAction implements AlarmAction {
public final class PushoverAlarmAction implements ConfiguredAlarmAction {
public PushoverAlarmAction(String applicationToken, String userKey) {
this.applicationToken = applicationToken;
this.userKey = userKey;
public PushoverAlarmAction(
Path configurationDirectory,
AlarmVariableResolver variableResolver
) throws IOException {
Objects.requireNonNull(configurationDirectory, "configurationDirectory");
this.variableResolver = Objects.requireNonNull(variableResolver, "variableResolver");
configuration = PushoverAlarmActionConfigurationParser.parse(
configurationDirectory.resolve(getConfigurationFileName()),
variableResolver
);
}
@Override
public String getConfigurationFileName() {
return "alarmaction_Pushover.conf";
}
@Override
public void trigger(OraclePrice price, ResolvedPriceAlarm alarm) {
String title = "Jupiter Perps " + price.asset() + " alarm";
String message = createMessage(price, alarm);
PushoverAlarmActionConfiguration.Notification notification =
configuration.notifications().get(alarm.id());
if (notification == null) {
return;
}
String body = form("token", applicationToken) + "&" +
form("user", userKey) + "&" +
String title = "Jupiter Perps " + price.asset() + " alarm";
String message = createMessage(
price,
alarm,
notification.severity(),
variableResolver.resolve(notification.note())
);
String body = form("token", configuration.applicationToken()) + "&" +
form("user", configuration.userKey()) + "&" +
form("title", title) + "&" +
form("message", message) + "&" +
createPushoverSeverityParameters(alarm.severity());
createPushoverSeverityParameters(notification.severity());
HttpRequest request = HttpRequest.newBuilder(PUSHOVER_URI)
.timeout(Duration.ofSeconds(15))
@@ -48,7 +74,7 @@ public final class PushoverAlarmAction implements AlarmAction {
response.body()
);
} else {
System.out.println("Pushover alarm sent: " + alarm.severity());
System.out.println("Pushover alarm sent: " + notification.severity());
}
});
}
@@ -69,12 +95,17 @@ public final class PushoverAlarmAction implements AlarmAction {
};
}
private static String createMessage(OraclePrice price, ResolvedPriceAlarm alarm) {
private static String createMessage(
OraclePrice price,
ResolvedPriceAlarm alarm,
AlarmSeverity severity,
String note
) {
return String.format(
"%d - %s: %s%n%n%s is %s USD.%nCondition: %s USD.%nOracle time: %s.%nSlot: %d.",
alarm.id(),
alarm.severity(),
alarm.note(),
severity,
note,
price.asset(),
price.priceUsd().toPlainString(),
alarm.condition().expression(),
@@ -84,8 +115,8 @@ public final class PushoverAlarmAction implements AlarmAction {
}
private final HttpClient httpClient = HttpClient.newHttpClient();
private final String applicationToken;
private final String userKey;
private final PushoverAlarmActionConfiguration configuration;
private final AlarmVariableResolver variableResolver;
private static final URI PUSHOVER_URI =
URI.create("https://api.pushover.net/1/messages.json");
@@ -0,0 +1,35 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import com.r35157.jupiterperpsalarm.AlarmSeverity;
import java.util.Map;
import java.util.Objects;
public record PushoverAlarmActionConfiguration(
String applicationToken,
String userKey,
Map<Integer, Notification> notifications
) {
public PushoverAlarmActionConfiguration {
Objects.requireNonNull(applicationToken, "applicationToken");
Objects.requireNonNull(userKey, "userKey");
notifications = Map.copyOf(notifications);
if (applicationToken.isBlank()) {
throw new IllegalArgumentException("Application token cannot be blank");
}
if (userKey.isBlank()) {
throw new IllegalArgumentException("User key cannot be blank");
}
}
public record Notification(
AlarmSeverity severity,
String note
) {
public Notification {
Objects.requireNonNull(severity, "severity");
Objects.requireNonNull(note, "note");
}
}
}
@@ -0,0 +1,210 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import com.r35157.jupiterperpsalarm.AlarmSeverity;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
public final class PushoverAlarmActionConfigurationParser {
public static PushoverAlarmActionConfiguration parse(
Path path,
AlarmVariableResolver variableResolver
) throws IOException {
Objects.requireNonNull(variableResolver, "variableResolver");
List<String> lines = Files.readAllLines(path);
Map<Integer, PushoverAlarmActionConfiguration.Notification> notifications =
new LinkedHashMap<>();
String applicationToken = null;
String userKey = null;
for (int lineNumber = 1; lineNumber <= lines.size(); lineNumber++) {
String line = lines.get(lineNumber - 1);
String trimmed = line.trim();
if (trimmed.isEmpty() || trimmed.startsWith("#")) {
continue;
}
try {
Cursor cursor = new Cursor(line);
String firstToken = cursor.nextToken("setting or alarm id");
switch (firstToken.toUpperCase(Locale.ROOT)) {
case "APPLICATION_TOKEN" -> {
if (applicationToken != null) {
throw new IllegalArgumentException("Duplicate APPLICATION_TOKEN");
}
applicationToken = variableResolver.resolve(
cursor.nextToken("application token")
);
cursor.requireEndOrComment();
}
case "USER_KEY" -> {
if (userKey != null) {
throw new IllegalArgumentException("Duplicate USER_KEY");
}
userKey = variableResolver.resolve(
cursor.nextToken("user key")
);
cursor.requireEndOrComment();
}
default -> parseNotification(
firstToken,
cursor,
notifications,
variableResolver
);
}
} catch (RuntimeException exception) {
throw new IllegalArgumentException(
path + ":" + lineNumber + ": " + exception.getMessage(),
exception
);
}
}
if (applicationToken == null) {
throw new IllegalArgumentException("Missing APPLICATION_TOKEN in " + path);
}
if (userKey == null) {
throw new IllegalArgumentException("Missing USER_KEY in " + path);
}
return new PushoverAlarmActionConfiguration(
applicationToken,
userKey,
notifications
);
}
private static void parseNotification(
String alarmIdText,
Cursor cursor,
Map<Integer, PushoverAlarmActionConfiguration.Notification> notifications,
AlarmVariableResolver variableResolver
) {
int alarmId;
try {
alarmId = Integer.parseInt(alarmIdText);
} catch (NumberFormatException exception) {
throw new IllegalArgumentException(
"Expected APPLICATION_TOKEN, USER_KEY, or alarm id: " + alarmIdText,
exception
);
}
AlarmSeverity severity = AlarmSeverity.valueOf(
variableResolver.resolve(cursor.nextToken("severity"))
.toUpperCase(Locale.ROOT)
);
String note = cursor.nextQuotedString("note");
variableResolver.resolve(note);
cursor.requireEndOrComment();
PushoverAlarmActionConfiguration.Notification previous = notifications.putIfAbsent(
alarmId,
new PushoverAlarmActionConfiguration.Notification(severity, note)
);
if (previous != null) {
throw new IllegalArgumentException("Duplicate alarm id: " + alarmId);
}
}
private static final class Cursor {
private Cursor(String line) {
this.line = line;
}
private String nextToken(String fieldName) {
skipWhitespace();
if (atEnd()) {
throw new IllegalArgumentException("Missing " + fieldName);
}
int start = position;
while (!atEnd() && !Character.isWhitespace(current())) {
position++;
}
return line.substring(start, position);
}
private String nextQuotedString(String fieldName) {
skipWhitespace();
if (atEnd() || current() != '"') {
throw new IllegalArgumentException(
"Missing quoted " + fieldName + "; expected \"...\""
);
}
position++;
StringBuilder result = new StringBuilder();
boolean escaped = false;
while (!atEnd()) {
char character = current();
position++;
if (escaped) {
result.append(switch (character) {
case 'n' -> '\n';
case 'r' -> '\r';
case 't' -> '\t';
case '"' -> '"';
case '\\' -> '\\';
default -> character;
});
escaped = false;
continue;
}
if (character == '\\') {
escaped = true;
continue;
}
if (character == '"') {
return result.toString();
}
result.append(character);
}
throw new IllegalArgumentException("Unterminated quoted " + fieldName);
}
private void requireEndOrComment() {
skipWhitespace();
if (!atEnd() && current() != '#') {
throw new IllegalArgumentException(
"Unexpected text: " + line.substring(position)
);
}
}
private void skipWhitespace() {
while (!atEnd() && Character.isWhitespace(current())) {
position++;
}
}
private boolean atEnd() {
return position >= line.length();
}
private char current() {
return line.charAt(position);
}
private final String line;
private int position;
}
private PushoverAlarmActionConfigurationParser() {
}
}
@@ -1,14 +1,10 @@
package com.r35157.jupiterperpsalarm.impl.ref;
import com.r35157.jupiterperpsalarm.AlarmSeverity;
public record ResolvedPriceAlarm(
int id,
JupiterPerpsAsset asset,
PriceCondition condition,
AlarmTrigger trigger,
ΩsecondsΩ triggerGracePeriod,
AlarmSeverity severity,
String note
ΩsecondsΩ triggerGracePeriod
) {
}