Skip to content

Commit

Permalink
feat: add notifier for external plugins (#666)
Browse files Browse the repository at this point in the history
Co-authored-by: pajlada <[email protected]>
  • Loading branch information
iProdigy and pajlada authored Mar 2, 2025
1 parent f6d93d2 commit 2ec5f6e
Show file tree
Hide file tree
Showing 23 changed files with 685 additions and 14 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
86 changes: 86 additions & 0 deletions docs/external-plugin-messaging.md
Original file line number Diff line number Diff line change
@@ -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<String, Object>` 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<String, Object> 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));
}
}
```
18 changes: 18 additions & 0 deletions docs/json-examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions src/main/java/dinkplugin/DinkPlugin.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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> gameState = new AtomicReference<>();

Expand Down Expand Up @@ -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);
}
Expand Down
44 changes: 43 additions & 1 deletion src/main/java/dinkplugin/DinkPluginConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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.<br/>" +
"Note: this only applies to the Leagues notifier, not every notifier in a seasonal world",
position = -1,
position = 0,
section = webhookSection
)
default String leaguesWebhook() {
Expand Down Expand Up @@ -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",
Expand Down
39 changes: 39 additions & 0 deletions src/main/java/dinkplugin/domain/ExternalNotificationRequest.java
Original file line number Diff line number Diff line change
@@ -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<Field> fields;
private @Nullable Map<String, SimpleReplacement> replacements;
private @Nullable Map<String, Object> metadata;
private @Nullable List<HttpUrl> urls;

public String getUrls(Supplier<String> defaultValue) {
return urls != null
? urls.stream().filter(Objects::nonNull).map(HttpUrl::toString).collect(Collectors.joining("\n"))
: defaultValue.get();
}

public List<Field> getFields() {
return this.fields != null ? this.fields : Collections.emptyList();
}

}
17 changes: 17 additions & 0 deletions src/main/java/dinkplugin/domain/ExternalScreenshotPolicy.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
10 changes: 7 additions & 3 deletions src/main/java/dinkplugin/message/DiscordMessageHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
}

Expand Down Expand Up @@ -376,7 +379,7 @@ private CompletableFuture<Map.Entry<String, byte[]>> captureScreenshot(double sc
private static List<Embed> 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();

Expand All @@ -389,6 +392,7 @@ private static List<Embed> 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();
Expand All @@ -398,7 +402,7 @@ private static List<Embed> 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))
Expand Down
1 change: 1 addition & 0 deletions src/main/java/dinkplugin/message/Embed.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
15 changes: 10 additions & 5 deletions src/main/java/dinkplugin/message/Field.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -13,13 +16,15 @@
*
* @see <a href="https://birdie0.github.io/discord-webhooks-guide/structure/embed/fields.html">Example</a>
*/
@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);
Expand Down
Loading

0 comments on commit 2ec5f6e

Please sign in to comment.