diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d34ad5f..8df08d47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Unreleased +- Major: Allow external plugins to request Dink webhook notifications. (#666) + ## 1.10.23 - Bugfix: Fire clue notification upon the first completion of a scroll at a given tier. (#662) diff --git a/README.md b/README.md index e14bd3cf..462224fa 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ To use this plugin, a webhook URL is required; you can obtain one from Discord w - [Trades](#trades): Sends a webhook message upon completing a trade with another player (with customizable item value threshold) - [Leagues](#leagues): Sends a webhook message upon completing a Leagues IV task or unlocking a region/relic - [Chat](#chat): Sends a webhook message upon receiving a chat message that matches a user-specified pattern +- [External Plugins](#external-plugins): Sends a webhook message upon request by other plugin hub plugins ## Other Setup @@ -346,6 +347,12 @@ You can customize the message patterns to your liking (`*` is a wildcard), and s `%SENDER%` will be replaced with the chat message sender (or the message category if no player sender is populated). +### External Plugins: + +Other external plugins can request Dink fire webhook notifications. + +You can disable this functionality via `External Plugin Requests > Enable External Plugin Notifications`. + ### Metadata: On login, Dink can submit a character summary containing data that spans multiple notifiers to a custom webhook handler (configurable in the `Advanced` section). This login notification is delayed by at least 5 seconds in order to gather all of the relevant data. diff --git a/docs/external-plugin-messaging.md b/docs/external-plugin-messaging.md new file mode 100644 index 00000000..92ed92d3 --- /dev/null +++ b/docs/external-plugin-messaging.md @@ -0,0 +1,86 @@ +# External Plugin Messaging + +Other Plugin Hub plugins can publish a `PluginMessage` via `EventBus#post` that instructs Dink to submit a webhook request. + +Users can opt-out of this capability by disabling `External Plugin Requests > Enable External Plugin Notifications`. + +Plugins can request that a screenshot is included with the notification, but users can also opt-out by +setting `External Plugin Requests > Send Image` to `Never` (default: send image only when requested by the external plugin). + +Plugins can include urls for the webhook, otherwise Dink will utilize `External Webhook Override` +(or `Primary Webhook URLs` if an external url override is not specified). + +Below we describe the payload structure for how plugins can customize the webhook body and include a full code example to streamline implementation. + +## Payload + +The `namespace` for the `PluginMessage` should be `dink` and the `name` should be `notify`. + +The `Map` that is supplied to `PluginMessage` will be converted into [`ExternalNotificationRequest`](../src/main/java/dinkplugin/domain/ExternalNotificationRequest.java). + +| Field | Required | Type | Description | +| ---------------- | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `text` | Y | String | The body text of the notification. This field supports templating (see `replacements` below) and by default `%USERNAME%` is an available replacement. | +| `sourcePlugin` | Y | String | The human-facing name of the plugin submitting the webhook notification request. | +| `urls` | N | List | A list of `okhttp3.HttpUrl`s that the notification should be sent to. | +| `title` | N | String | The title for the Discord embed. | +| `thumbnail` | N | String | A URL to an image for the thumbnail icon of the Discord embed. | +| `imageRequested` | N | boolean | Whether dink should include a screenshot with the notification. | +| `fields` | N | List | A list of [embed fields](https://discord.com/developers/docs/resources/message#embed-object-embed-field-structure). The contained objects should have `name` and `value` properties. | +| `replacements` | N | Map | A map of strings to be replaced to objects containing `value` (and optionally `richValue`) that indicate what the template string should be replaced with for plain text and rich text. | +| `metadata` | N | Map | A map of strings to any gson-serializable object to be included in the notification body for non-Discord consumers. | + +## Example + +The example below assumes you already have injected RuneLite's eventbus into your plugin like so: `private @Inject EventBus eventBus;` + +```java +Map data = new HashMap<>(); +data.put("sourcePlugin", "My Plugin Name"); +data.put("text", "This is the primary content within the webhook. %USERNAME% will automatically be replaced with the player name and you can define your own template replacements like %XYZ%"); +data.put("replacements", Map.of("%XYZ%", Replacement.ofText("sample replacement"))); +data.put("title", "An optional embed title for your notification"); +data.put("imageRequested", true); +data.put("fields", List.of(new Field("sample key", "sample value"))); +data.put("metadata", Map.of("custom key", "custom value")); +data.put("urls", Arrays.asList(HttpUrl.parse("https://discord.com/api/webhooks/a/b"), HttpUrl.parse("https://discord.com/api/webhooks/c/d"))); + +PluginMessage dinkRequest = new PluginMessage("dink", "notify", data); +eventBus.post(dinkRequest); +``` + +### Useful Classes + +```java +@Value +@AllArgsConstructor +public class Field { + String name; + String value; + Boolean inline; + + public Field(String name, String value) { + this(name, value, null); + } +} +``` + +```java +@Value +public class Replacement { + String value; + String richValue; + + public static Replacement ofText(String value) { + return new Replacement(value, null); + } + + public static Replacement ofLink(String text, String link) { + return new Replacement(text, String.format("[%s](%s)", text, link)); + } + + public static Replacement ofWiki(String text, String searchPhrase) { + return ofLink(text, "https://oldschool.runescape.wiki/w/Special:Search?search=" + UrlEscapers.urlPathSegmentEscaper().escape(searchPhrase)); + } +} +``` diff --git a/docs/json-examples.md b/docs/json-examples.md index c8fea748..87a1e92d 100644 --- a/docs/json-examples.md +++ b/docs/json-examples.md @@ -769,6 +769,24 @@ When `extra.type` is `UNKNOWN`, the `extra.source` value is set to the originati When `extra.type` is `CLAN_CHAT` or `CLAN_GUEST_CHAT` or `CLAN_GIM_CHAT` or `CLAN_MESSAGE` (only for user joins), the `extra.clanTitle` object includes the clan rank `id` (integer) and title `name` (string), corresponding to RuneLite's [`ClanTitle` class](https://static.runelite.net/api/runelite-api/net/runelite/api/clan/ClanTitle.html). +### External Plugins + +JSON for externally-requested notifications: + +```json5 +{ + "type": "EXTERNAL_PLUGIN", + "extra": { + "sourcePlugin": "My External Plugin", + "metadata": { + "hello": "world" + } + } +} +``` + +The contents of `extra.metadata` are dependent on the source plugin, and `extra.metadata` can be null. + ### Metadata JSON for Login Notifications: diff --git a/src/main/java/dinkplugin/DinkPlugin.java b/src/main/java/dinkplugin/DinkPlugin.java index be8abc2a..9ae91fd6 100644 --- a/src/main/java/dinkplugin/DinkPlugin.java +++ b/src/main/java/dinkplugin/DinkPlugin.java @@ -7,6 +7,7 @@ import dinkplugin.notifiers.CombatTaskNotifier; import dinkplugin.notifiers.DeathNotifier; import dinkplugin.notifiers.DiaryNotifier; +import dinkplugin.notifiers.ExternalPluginNotifier; import dinkplugin.notifiers.GambleNotifier; import dinkplugin.notifiers.GrandExchangeNotifier; import dinkplugin.notifiers.GroupStorageNotifier; @@ -50,6 +51,7 @@ import net.runelite.client.events.NotificationFired; import net.runelite.client.events.NpcLootReceived; import net.runelite.client.events.PlayerLootReceived; +import net.runelite.client.events.PluginMessage; import net.runelite.client.events.ProfileChanged; import net.runelite.client.plugins.Plugin; import net.runelite.client.plugins.PluginDescriptor; @@ -98,6 +100,7 @@ public class DinkPlugin extends Plugin { private @Inject MetaNotifier metaNotifier; private @Inject TradeNotifier tradeNotifier; private @Inject ChatNotifier chatNotifier; + private @Inject ExternalPluginNotifier externalNotifier; private final AtomicReference gameState = new AtomicReference<>(); @@ -351,6 +354,13 @@ public void onWidgetClosed(WidgetClosed event) { tradeNotifier.onWidgetClose(event); } + @Subscribe + public void onPluginMessage(PluginMessage event) { + if ("dink".equalsIgnoreCase(event.getNamespace()) && "notify".equalsIgnoreCase(event.getName())) { + externalNotifier.onNotify(event.getData()); + } + } + public void addChatSuccess(String message) { addChatMessage("Success", Utils.GREEN, message); } diff --git a/src/main/java/dinkplugin/DinkPluginConfig.java b/src/main/java/dinkplugin/DinkPluginConfig.java index 1e6a67d6..1fdd25be 100644 --- a/src/main/java/dinkplugin/DinkPluginConfig.java +++ b/src/main/java/dinkplugin/DinkPluginConfig.java @@ -7,6 +7,7 @@ import dinkplugin.domain.CombatAchievementTier; import dinkplugin.domain.ConfigImportPolicy; import dinkplugin.domain.ExceptionalDeath; +import dinkplugin.domain.ExternalScreenshotPolicy; import dinkplugin.domain.FilterMode; import dinkplugin.domain.LeagueTaskDifficulty; import dinkplugin.domain.PlayerLookupService; @@ -180,6 +181,14 @@ public interface DinkPluginConfig extends Config { ) String chatSection = "Custom Chat Messages"; + @ConfigSection( + name = "External Plugin Requests", + description = "Settings for notifying when other plugins request Dink notifications to be fired", + position = 180, + closedByDefault = true + ) + String externalSection = "External Plugin Requests"; + @ConfigSection( name = "Leagues", description = "Settings for notifying when you complete league tasks, unlock areas, and redeem relics", @@ -698,12 +707,23 @@ default String chatWebhook() { return ""; } + @ConfigItem( + keyName = "externalWebhook", + name = "External Webhook Override", + description = "If non-empty, external plugin messages that don't provide a custom URL are by default sent to this URL, instead of the primary URL", + position = -1, + section = webhookSection + ) + default String externalWebhook() { + return ""; + } + @ConfigItem( keyName = "leaguesWebhook", name = "Leagues Webhook Override", description = "If non-empty, Leagues messages are sent to this URL, instead of the primary URL.
" + "Note: this only applies to the Leagues notifier, not every notifier in a seasonal world", - position = -1, + position = 0, section = webhookSection ) default String leaguesWebhook() { @@ -2034,6 +2054,28 @@ default String chatNotifyMessage() { return "%USERNAME% received a chat message:\n\n```\n%MESSAGE%\n```"; } + @ConfigItem( + keyName = "notifyExternal", + name = "Enable External Plugin Notifications", + description = "Enable notifications upon requests by other plugins", + position = 180, + section = externalSection + ) + default boolean notifyExternal() { + return true; // enabled by default, unlike other notifiers + } + + @ConfigItem( + keyName = "externalSendImage", + name = "Send Image", + description = "Controls whether screenshots should be included with the notification", + position = 181, + section = externalSection + ) + default ExternalScreenshotPolicy externalSendImage() { + return ExternalScreenshotPolicy.REQUESTED; + } + @ConfigItem( keyName = "notifyLeagues", name = "Enable Leagues", diff --git a/src/main/java/dinkplugin/domain/ExternalNotificationRequest.java b/src/main/java/dinkplugin/domain/ExternalNotificationRequest.java new file mode 100644 index 00000000..2534b020 --- /dev/null +++ b/src/main/java/dinkplugin/domain/ExternalNotificationRequest.java @@ -0,0 +1,39 @@ +package dinkplugin.domain; + +import dinkplugin.message.Field; +import dinkplugin.message.templating.impl.SimpleReplacement; +import lombok.Data; +import okhttp3.HttpUrl; +import org.jetbrains.annotations.Nullable; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +@Data +public class ExternalNotificationRequest { + + private String sourcePlugin; + private String text; + private boolean imageRequested; + private @Nullable String title; + private @Nullable String thumbnail; + private @Nullable List fields; + private @Nullable Map replacements; + private @Nullable Map metadata; + private @Nullable List urls; + + public String getUrls(Supplier defaultValue) { + return urls != null + ? urls.stream().filter(Objects::nonNull).map(HttpUrl::toString).collect(Collectors.joining("\n")) + : defaultValue.get(); + } + + public List getFields() { + return this.fields != null ? this.fields : Collections.emptyList(); + } + +} diff --git a/src/main/java/dinkplugin/domain/ExternalScreenshotPolicy.java b/src/main/java/dinkplugin/domain/ExternalScreenshotPolicy.java new file mode 100644 index 00000000..0b2d514f --- /dev/null +++ b/src/main/java/dinkplugin/domain/ExternalScreenshotPolicy.java @@ -0,0 +1,17 @@ +package dinkplugin.domain; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum ExternalScreenshotPolicy { + ALWAYS("Always"), + REQUESTED("When requested"), + NEVER("Never"); + + private final String displayName; + + @Override + public String toString() { + return this.displayName; + } +} diff --git a/src/main/java/dinkplugin/message/DiscordMessageHandler.java b/src/main/java/dinkplugin/message/DiscordMessageHandler.java index af169e78..fc699f69 100644 --- a/src/main/java/dinkplugin/message/DiscordMessageHandler.java +++ b/src/main/java/dinkplugin/message/DiscordMessageHandler.java @@ -254,7 +254,10 @@ private NotificationBody enrichBody(NotificationBody mBody, boolean sendIm } if (mBody.getRegionId() == null) { - builder.regionId(WorldUtils.getLocation(client).getRegionID()); + var loc = WorldUtils.getLocation(client); + if (loc != null) { + builder.regionId(loc.getRegionID()); + } } } @@ -376,7 +379,7 @@ private CompletableFuture> captureScreenshot(double sc private static List computeEmbeds(@NotNull NotificationBody body, boolean screenshot, DinkPluginConfig config) { NotificationType type = body.getType(); NotificationData extra = body.getExtra(); - String footerText = config.embedFooterText(); + String footerText = body.getCustomFooter() != null ? body.getCustomFooter() : config.embedFooterText(); String footerIcon = config.embedFooterIcon(); PlayerLookupService playerLookupService = config.playerLookupService(); @@ -389,6 +392,7 @@ private static List computeEmbeds(@NotNull NotificationBody body, bool .text(Utils.truncate(footerText, Embed.MAX_FOOTER_LENGTH)) .iconUrl(StringUtils.isBlank(footerIcon) ? null : footerIcon) .build(); + String title = body.getCustomTitle() != null ? body.getCustomTitle() : type.getTitle(); String thumbnail = body.getThumbnailUrl() != null ? body.getThumbnailUrl() : type.getThumbnail(); @@ -398,7 +402,7 @@ private static List computeEmbeds(@NotNull NotificationBody body, bool Embed.builder() .author(author) .color(config.embedColor()) - .title(body.isSeasonalWorld() ? "[Seasonal] " + type.getTitle() : type.getTitle()) + .title(Utils.truncate(body.isSeasonalWorld() ? "[Seasonal] " + title : title, Embed.MAX_TITLE_LENGTH)) .description(Utils.truncate(body.getText().evaluate(config.discordRichEmbeds()), Embed.MAX_DESCRIPTION_LENGTH)) .image(screenshot ? new Embed.UrlEmbed("attachment://" + type.getScreenshot()) : null) .thumbnail(new Embed.UrlEmbed(thumbnail)) diff --git a/src/main/java/dinkplugin/message/Embed.java b/src/main/java/dinkplugin/message/Embed.java index 524e9838..5e329163 100644 --- a/src/main/java/dinkplugin/message/Embed.java +++ b/src/main/java/dinkplugin/message/Embed.java @@ -20,6 +20,7 @@ public class Embed { // The max size of the image before we rescale it to fit Discords file upload limits https://discord.com/developers/docs/reference#uploading-files public static final int MAX_IMAGE_SIZE = 8_000_000; // 8MB + public static final int MAX_TITLE_LENGTH = 256; public static final int MAX_DESCRIPTION_LENGTH = 4096; public static final int MAX_FOOTER_LENGTH = 2048; public static final int MAX_EMBEDS = 10; diff --git a/src/main/java/dinkplugin/message/Field.java b/src/main/java/dinkplugin/message/Field.java index 41f0a239..f19cf4da 100644 --- a/src/main/java/dinkplugin/message/Field.java +++ b/src/main/java/dinkplugin/message/Field.java @@ -1,9 +1,12 @@ package dinkplugin.message; import dinkplugin.util.MathUtils; +import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Value; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.Setter; import org.apache.commons.lang3.StringUtils; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -13,13 +16,15 @@ * * @see Example */ -@Value +@Data +@Setter(AccessLevel.PRIVATE) @Builder +@NoArgsConstructor @AllArgsConstructor public class Field { - @NotNull String name; - @NotNull String value; - @Nullable Boolean inline; + private @NotNull String name; + private @NotNull String value; + private @Nullable Boolean inline; public Field(String name, String value) { this(name, value, true); diff --git a/src/main/java/dinkplugin/message/NotificationBody.java b/src/main/java/dinkplugin/message/NotificationBody.java index 4283bf23..ec38723f 100644 --- a/src/main/java/dinkplugin/message/NotificationBody.java +++ b/src/main/java/dinkplugin/message/NotificationBody.java @@ -71,6 +71,22 @@ public class NotificationBody { @SerializedName("content") String computedDiscordContent; + /** + * An optional title to override that of {@link NotificationType#getThumbnail} + * within the embed constructed by {@link DiscordMessageHandler#createMessage} + */ + @Nullable + @EqualsAndHashCode.Include + transient String customTitle; + + /** + * An optional footer text to override that of {@link DinkPluginConfig#embedFooterText} + * within the embed constructed by {@link DiscordMessageHandler#createMessage} + */ + @Nullable + @EqualsAndHashCode.Include + transient String customFooter; + /** * An optional thumbnail to override that of {@link NotificationType#getThumbnail} * within the embed constructed by {@link DiscordMessageHandler#createMessage} diff --git a/src/main/java/dinkplugin/message/NotificationType.java b/src/main/java/dinkplugin/message/NotificationType.java index 713323db..18e79a11 100644 --- a/src/main/java/dinkplugin/message/NotificationType.java +++ b/src/main/java/dinkplugin/message/NotificationType.java @@ -32,7 +32,8 @@ public enum NotificationType { LOGOUT("Player Logout", "logout.png", WIKI_IMG_BASE_URL + "Prop_sword.png"), TRADE("Player Trade", "trade.png", WIKI_IMG_BASE_URL + "Inventory.png"), CHAT("Chat Notification", "chat.png", WIKI_IMG_BASE_URL + "Toggle_Chat_effects.png"), - XP_MILESTONE("XP Milestone", "xpImage.png", WIKI_IMG_BASE_URL + "Lamp.png"); + XP_MILESTONE("XP Milestone", "xpImage.png", WIKI_IMG_BASE_URL + "Lamp.png"), + EXTERNAL_PLUGIN("External Plugin", "externalImage.png", "https://raw.githubusercontent.com/runelite/runelite/refs/heads/master/runelite-client/src/main/resources/net/runelite/client/plugins/config/plugin_hub_icon.png"); private final String title; diff --git a/src/main/java/dinkplugin/message/templating/Template.java b/src/main/java/dinkplugin/message/templating/Template.java index e1d9942d..9197048a 100644 --- a/src/main/java/dinkplugin/message/templating/Template.java +++ b/src/main/java/dinkplugin/message/templating/Template.java @@ -38,6 +38,7 @@ private String evaluateSlow(boolean rich) { } private String evaluateFast(boolean rich) { + assert replacementBoundary != null; StringBuilder message = new StringBuilder(template); int i = message.indexOf(replacementBoundary); while (i != -1) { diff --git a/src/main/java/dinkplugin/message/templating/impl/SimpleReplacement.java b/src/main/java/dinkplugin/message/templating/impl/SimpleReplacement.java new file mode 100644 index 00000000..5aa15b43 --- /dev/null +++ b/src/main/java/dinkplugin/message/templating/impl/SimpleReplacement.java @@ -0,0 +1,27 @@ +package dinkplugin.message.templating.impl; + +import dinkplugin.message.templating.Evaluable; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.jetbrains.annotations.Nullable; + +@Data +@Setter(AccessLevel.PRIVATE) +@NoArgsConstructor +@AllArgsConstructor +public class SimpleReplacement implements Evaluable { + + private String value; + + @Nullable + private String richValue; + + @Override + public String evaluate(boolean rich) { + return rich && richValue != null ? richValue : value; + } + +} diff --git a/src/main/java/dinkplugin/notifiers/ExternalPluginNotifier.java b/src/main/java/dinkplugin/notifiers/ExternalPluginNotifier.java new file mode 100644 index 00000000..1270f3a3 --- /dev/null +++ b/src/main/java/dinkplugin/notifiers/ExternalPluginNotifier.java @@ -0,0 +1,108 @@ +package dinkplugin.notifiers; + +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; +import dinkplugin.domain.ExternalNotificationRequest; +import dinkplugin.domain.ExternalScreenshotPolicy; +import dinkplugin.message.NotificationBody; +import dinkplugin.message.NotificationType; +import dinkplugin.message.templating.Replacements; +import dinkplugin.message.templating.Template; +import dinkplugin.notifiers.data.ExternalNotificationData; +import dinkplugin.util.HttpUrlAdapter; +import dinkplugin.util.Utils; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.GameState; +import okhttp3.HttpUrl; + +import javax.inject.Inject; +import javax.inject.Singleton; +import java.util.Map; + +@Slf4j +@Singleton +public class ExternalPluginNotifier extends BaseNotifier { + + private Gson gson; + + @Override + public boolean isEnabled() { + return config.notifyExternal() && super.isEnabled(); + } + + @Override + protected String getWebhookUrl() { + return config.externalWebhook(); + } + + @Inject + void init(Gson gson) { + this.gson = gson.newBuilder() + .registerTypeAdapter(HttpUrl.class, new HttpUrlAdapter()) + .create(); + } + + public void onNotify(Map data) { + if (!isEnabled()) { + log.debug("Skipping requested external dink since notifier is disabled: {}", data); + return; + } + + // parse request + ExternalNotificationRequest input; + try { + input = gson.fromJson(gson.toJsonTree(data), ExternalNotificationRequest.class); + } catch (JsonSyntaxException e) { + log.warn("Failed to parse requested webhook notification from an external plugin: {}", data, e); + return; + } + + // validate request + if (input.getSourcePlugin() == null || input.getSourcePlugin().isBlank()) { + log.info("Skipping externally-requested dink due to missing 'sourcePlugin': {}", data); + return; + } + + if (input.getText() == null || input.getText().isBlank()) { + log.info("Skipping externally-requested dink due to missing 'text': {}", data); + return; + } + + if (input.getThumbnail() != null && HttpUrl.parse(input.getThumbnail()) == null) { + log.debug("Replacing invalid thumbnail url: {}", input.getThumbnail()); + input.setThumbnail(NotificationType.EXTERNAL_PLUGIN.getThumbnail()); + } + + // process request + this.handleNotify(input); + } + + private void handleNotify(ExternalNotificationRequest input) { + var player = Utils.getPlayerName(client); + var template = Template.builder() + .template(input.getText()) + .replacements(input.getReplacements()) + .replacement("%USERNAME%", Replacements.ofText(player)) + .build(); + + var footer = String.format("Sent by %s via Dink", input.getSourcePlugin()); + + var policy = config.externalSendImage(); + boolean image = policy != ExternalScreenshotPolicy.NEVER + && client.getGameState().getState() >= GameState.LOGGING_IN.getState() + && (policy == ExternalScreenshotPolicy.ALWAYS || input.isImageRequested()); + var body = NotificationBody.builder() + .type(NotificationType.EXTERNAL_PLUGIN) + .playerName(player) + .text(template) + .customTitle(input.getTitle()) + .customFooter(footer) + .thumbnailUrl(input.getThumbnail()) + .extra(new ExternalNotificationData(input.getSourcePlugin(), input.getFields(), input.getMetadata())) + .build(); + + var urls = input.getUrls(this::getWebhookUrl); + createMessage(urls, image, body); + } + +} diff --git a/src/main/java/dinkplugin/notifiers/data/ExternalNotificationData.java b/src/main/java/dinkplugin/notifiers/data/ExternalNotificationData.java new file mode 100644 index 00000000..51f2bf99 --- /dev/null +++ b/src/main/java/dinkplugin/notifiers/data/ExternalNotificationData.java @@ -0,0 +1,17 @@ +package dinkplugin.notifiers.data; + +import dinkplugin.message.Field; +import lombok.EqualsAndHashCode; +import lombok.Value; + +import java.util.List; +import java.util.Map; + +@Value +@EqualsAndHashCode(callSuper = false) +public class ExternalNotificationData extends NotificationData { + String sourcePlugin; + @EqualsAndHashCode.Include + transient List fields; + Map metadata; +} diff --git a/src/main/java/dinkplugin/util/HttpUrlAdapter.java b/src/main/java/dinkplugin/util/HttpUrlAdapter.java new file mode 100644 index 00000000..245df6d5 --- /dev/null +++ b/src/main/java/dinkplugin/util/HttpUrlAdapter.java @@ -0,0 +1,20 @@ +package dinkplugin.util; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import okhttp3.HttpUrl; + +import java.io.IOException; + +public class HttpUrlAdapter extends TypeAdapter { + @Override + public void write(JsonWriter out, HttpUrl url) throws IOException { + out.value(url != null ? url.toString() : null); + } + + @Override + public HttpUrl read(JsonReader in) throws IOException { + return in.hasNext() ? HttpUrl.parse(in.nextString()) : null; + } +} diff --git a/src/main/java/dinkplugin/util/Utils.java b/src/main/java/dinkplugin/util/Utils.java index 0c99cc97..213c0c70 100644 --- a/src/main/java/dinkplugin/util/Utils.java +++ b/src/main/java/dinkplugin/util/Utils.java @@ -176,8 +176,10 @@ public boolean containsEither(@NotNull String a, @NotNull String b) { * @param client {@link Client} * @return the name of the local player */ + @Nullable public String getPlayerName(Client client) { - return client.getLocalPlayer().getName(); + var player = client.getLocalPlayer(); + return player != null ? player.getName() : null; } /** diff --git a/src/main/java/dinkplugin/util/WorldUtils.java b/src/main/java/dinkplugin/util/WorldUtils.java index 3af41a0b..144b8f6b 100644 --- a/src/main/java/dinkplugin/util/WorldUtils.java +++ b/src/main/java/dinkplugin/util/WorldUtils.java @@ -9,11 +9,13 @@ import net.runelite.api.Client; import net.runelite.api.Varbits; import net.runelite.api.WorldType; +import net.runelite.api.WorldView; import net.runelite.api.annotations.Varbit; import net.runelite.api.annotations.Varp; import net.runelite.api.coords.WorldPoint; import net.runelite.api.widgets.ComponentID; import net.runelite.api.widgets.Widget; +import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.VisibleForTesting; import java.util.Collections; @@ -62,13 +64,19 @@ public class WorldUtils { */ private final int DRAGON_SLAYER_II_COMPLETED = 215; + @Nullable public static WorldPoint getLocation(Client client) { return getLocation(client, client.getLocalPlayer()); } + @Nullable public static WorldPoint getLocation(Client client, Actor actor) { - if (client.isInInstancedRegion()) - return WorldPoint.fromLocalInstance(client, actor.getLocalLocation()); + if (actor == null) + return null; + + WorldView wv = actor.getWorldView(); + if (wv.isInstance()) + return WorldPoint.fromLocalInstance(client, actor.getLocalLocation(), wv.getPlane()); return actor.getWorldLocation(); } diff --git a/src/test/java/dinkplugin/notifiers/ExternalPluginNotifierTest.java b/src/test/java/dinkplugin/notifiers/ExternalPluginNotifierTest.java new file mode 100644 index 00000000..69274b04 --- /dev/null +++ b/src/test/java/dinkplugin/notifiers/ExternalPluginNotifierTest.java @@ -0,0 +1,238 @@ +package dinkplugin.notifiers; + +import com.google.inject.testing.fieldbinder.Bind; +import dinkplugin.domain.ExternalScreenshotPolicy; +import dinkplugin.message.Field; +import dinkplugin.message.NotificationBody; +import dinkplugin.message.NotificationType; +import dinkplugin.message.templating.Replacements; +import dinkplugin.message.templating.Template; +import dinkplugin.message.templating.impl.SimpleReplacement; +import dinkplugin.notifiers.data.ExternalNotificationData; +import net.runelite.client.events.PluginMessage; +import okhttp3.HttpUrl; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class ExternalPluginNotifierTest extends MockedNotifierTest { + + @Bind + @InjectMocks + ExternalPluginNotifier notifier; + + @Override + @BeforeEach + protected void setUp() { + super.setUp(); + + // update config mocks + when(config.notifyExternal()).thenReturn(true); + when(config.externalSendImage()).thenReturn(ExternalScreenshotPolicy.REQUESTED); + } + + @Test + void testNotify() { + // fire event + plugin.onPluginMessage(new PluginMessage("dink", "notify", samplePayload("https://example.com/"))); + + // verify notification + verifyCreateMessage( + "https://example.com/", + false, + NotificationBody.builder() + .type(NotificationType.EXTERNAL_PLUGIN) + .playerName(PLAYER_NAME) + .customTitle("My Title") + .customFooter("Sent by MyExternalPlugin via Dink") + .text( + Template.builder() + .template("Hello %TARGET% from %USERNAME%") + .replacement("%TARGET%", Replacements.ofText("world")) + .replacement("%USERNAME%", Replacements.ofText(PLAYER_NAME)) + .build() + ) + .extra(new ExternalNotificationData("MyExternalPlugin", List.of(new Field("sample key", "sample value")), Collections.singletonMap("hello", "world"))) + .build() + ); + } + + @Test + void testFallbackUrl() { + // update config mocks + String url = "https://discord.com/example"; + when(config.externalWebhook()).thenReturn(url); + + // fire event + plugin.onPluginMessage(new PluginMessage("dink", "notify", samplePayload(null))); + + // verify notification + verifyCreateMessage( + url, + false, + NotificationBody.builder() + .type(NotificationType.EXTERNAL_PLUGIN) + .playerName(PLAYER_NAME) + .customTitle("My Title") + .customFooter("Sent by MyExternalPlugin via Dink") + .text( + Template.builder() + .template("Hello %TARGET% from %USERNAME%") + .replacement("%TARGET%", Replacements.ofText("world")) + .replacement("%USERNAME%", Replacements.ofText(PLAYER_NAME)) + .build() + ) + .extra(new ExternalNotificationData("MyExternalPlugin", List.of(new Field("sample key", "sample value")), Collections.singletonMap("hello", "world"))) + .build() + ); + } + + @Test + void testImage() { + // update config mocks + when(config.externalSendImage()).thenReturn(ExternalScreenshotPolicy.ALWAYS); + + // fire event + plugin.onPluginMessage(new PluginMessage("dink", "notify", samplePayload(null))); + + // verify notification + verifyCreateMessage( + null, + true, + NotificationBody.builder() + .type(NotificationType.EXTERNAL_PLUGIN) + .playerName(PLAYER_NAME) + .customTitle("My Title") + .customFooter("Sent by MyExternalPlugin via Dink") + .text( + Template.builder() + .template("Hello %TARGET% from %USERNAME%") + .replacement("%TARGET%", Replacements.ofText("world")) + .replacement("%USERNAME%", Replacements.ofText(PLAYER_NAME)) + .build() + ) + .extra(new ExternalNotificationData("MyExternalPlugin", List.of(new Field("sample key", "sample value")), Collections.singletonMap("hello", "world"))) + .build() + ); + } + + @Test + void testRequestImage() { + // prepare payload + var data = samplePayload(null); + data.put("imageRequested", true); + + // fire event + plugin.onPluginMessage(new PluginMessage("dink", "notify", data)); + + // verify notification + verifyCreateMessage( + null, + true, + NotificationBody.builder() + .type(NotificationType.EXTERNAL_PLUGIN) + .playerName(PLAYER_NAME) + .customTitle("My Title") + .customFooter("Sent by MyExternalPlugin via Dink") + .text( + Template.builder() + .template("Hello %TARGET% from %USERNAME%") + .replacement("%TARGET%", Replacements.ofText("world")) + .replacement("%USERNAME%", Replacements.ofText(PLAYER_NAME)) + .build() + ) + .extra(new ExternalNotificationData("MyExternalPlugin", List.of(new Field("sample key", "sample value")), Collections.singletonMap("hello", "world"))) + .build() + ); + } + + @Test + void testRequestImageDenied() { + // update config mocks + when(config.externalSendImage()).thenReturn(ExternalScreenshotPolicy.NEVER); + + // prepare payload + var data = samplePayload(null); + data.put("imageRequested", true); + + // fire event + plugin.onPluginMessage(new PluginMessage("dink", "notify", data)); + + // verify notification + verifyCreateMessage( + null, + false, + NotificationBody.builder() + .type(NotificationType.EXTERNAL_PLUGIN) + .playerName(PLAYER_NAME) + .customTitle("My Title") + .customFooter("Sent by MyExternalPlugin via Dink") + .text( + Template.builder() + .template("Hello %TARGET% from %USERNAME%") + .replacement("%TARGET%", Replacements.ofText("world")) + .replacement("%USERNAME%", Replacements.ofText(PLAYER_NAME)) + .build() + ) + .extra(new ExternalNotificationData("MyExternalPlugin", List.of(new Field("sample key", "sample value")), Collections.singletonMap("hello", "world"))) + .build() + ); + } + + @Test + void testIgnoreNamespace() { + // fire event + plugin.onPluginMessage(new PluginMessage("DANK", "notify", samplePayload("https://example.com/"))); + + // ensure no notification + verify(messageHandler, never()).createMessage(any(), anyBoolean(), any()); + } + + @Test + void testIgnoreName() { + // fire event + plugin.onPluginMessage(new PluginMessage("dink", "donk", samplePayload("https://example.com/"))); + + // ensure no notification + verify(messageHandler, never()).createMessage(any(), anyBoolean(), any()); + } + + @Test + void testDisabled() { + // update config mocks + when(config.notifyExternal()).thenReturn(false); + + // fire event + plugin.onPluginMessage(new PluginMessage("dink", "notify", samplePayload("https://example.com/"))); + + // ensure no notification + verify(messageHandler, never()).createMessage(any(), anyBoolean(), any()); + } + + private static Map samplePayload(String url) { + Map data = new HashMap<>(); + data.put("text", "Hello %TARGET% from %USERNAME%"); + data.put("title", "My Title"); + data.put("thumbnail", "not a url . com"); + data.put("fields", List.of(new Field("sample key", "sample value"))); + data.put("replacements", Map.of("%TARGET%", new SimpleReplacement("world", null))); + data.put("metadata", Map.of("hello", "world")); + data.put("sourcePlugin", "MyExternalPlugin"); + if (url != null) { + data.put("urls", Collections.singletonList(HttpUrl.parse(url))); + } + return data; + } + +} diff --git a/src/test/java/dinkplugin/notifiers/MockedNotifierTest.java b/src/test/java/dinkplugin/notifiers/MockedNotifierTest.java index 38308133..40864b2a 100644 --- a/src/test/java/dinkplugin/notifiers/MockedNotifierTest.java +++ b/src/test/java/dinkplugin/notifiers/MockedNotifierTest.java @@ -126,6 +126,7 @@ protected void setUp() { when(client.getGameState()).thenReturn(GameState.LOGGED_IN); when(localPlayer.getName()).thenReturn(PLAYER_NAME); when(localPlayer.getWorldLocation()).thenReturn(new WorldPoint(2500, 2500, 0)); + when(localPlayer.getWorldView()).thenReturn(worldView); doAnswer(invocation -> { Consumer callback = invocation.getArgument(0); diff --git a/src/test/java/dinkplugin/notifiers/PlayerKillNotifierTest.java b/src/test/java/dinkplugin/notifiers/PlayerKillNotifierTest.java index 99ae9a97..8f1cdbd2 100644 --- a/src/test/java/dinkplugin/notifiers/PlayerKillNotifierTest.java +++ b/src/test/java/dinkplugin/notifiers/PlayerKillNotifierTest.java @@ -257,12 +257,13 @@ void testIgnoreSelf() { verify(messageHandler, never()).createMessage(any(), anyBoolean(), any()); } - private static Player mockPlayer() { + private Player mockPlayer() { Player target = mock(Player.class); when(target.getName()).thenReturn(TARGET); when(target.isDead()).thenReturn(true); when(target.getCombatLevel()).thenReturn(LEVEL); when(target.getWorldLocation()).thenReturn(LOCATION); + when(target.getWorldView()).thenReturn(worldView); PlayerComposition comp = mock(PlayerComposition.class); when(target.getPlayerComposition()).thenReturn(comp); int[] equipment = new int[KitType.values().length];