diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 16f74af..d8e5ac8 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -21,10 +21,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - name: Set up JDK 21 + - name: Set up JDK 25 uses: actions/setup-java@v5 with: - java-version: '21' + java-version: '25' distribution: 'temurin' - name: Setup Gradle diff --git a/.gitignore b/.gitignore index f56c3ab..8985df8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,9 @@ build/ !**/src/main/**/build/ !**/src/test/**/build/ -### run-paper ### -# Working directory of the runServer task: a whole Paper install, worlds and logs. -/automessage-loader/run/ +### run-paper / run-velocity / loom ### +# Working directories of the runServer tasks: whole server installs, worlds and logs. +**/run/ ### IntelliJ IDEA ### .idea/ @@ -39,5 +39,15 @@ bin/ ### VS Code ### .vscode/ +### JVM crash and heap dumps ### +# A heap dump is written next to the working directory when a build runs out of memory, and one +# of these is 750 MB - large enough that GitHub refuses the push that carries it. +*.hprof +hs_err_pid*.log +replay_pid*.log + ### Mac OS ### -.DS_Store \ No newline at end of file +.DS_Store + +# Kotlin build sessions +**/.kotlin/ diff --git a/README.md b/README.md index 6eed8e3..6855750 100644 --- a/README.md +++ b/README.md @@ -1,123 +1,677 @@ -# 💬 AutoMessage +
+
+
+ Not another chat rotator. A complete announcement engine.
+ Targeted, localized and event-aware broadcasts for modern Minecraft servers and networks.
+
+ 6 runtimes · 8 notice surfaces · 8 audience rules · 3 event triggers · 3 languages shipped · one render per language · no database +
+ +| + +**🧭 Why AutoMessage** + +- [📣 Broadcasts should earn attention](#broadcasts) +- [⚖️ More than a rotating list](#rotating-list) +- [🎯 Reach exactly who should hear it](#audience-rules) +- [⚡ Fire on an event, not a clock](#event-triggers) +- [🪶 Costs your server almost nothing](#performance) +- [🌐 One engine, six runtimes](#runtimes) + + | ++ +**⚙️ Install and configure** + +- [📥 **Get AutoMessage**](#get-automessage) +- [🚀 Quick start](#quick-start) +- [🛠 Build your first announcement](#first-announcement) +- [🗂 Configuration model](#configuration-model) +- [📋 Platform support](#platform-support) +- [🔨 Building from source](#building-from-source) +- [💛 Support the project](#support) + + | ++ +[**📖 Reference**](#reference) + +- [📡 Channels and selectors](#ref-channels) +- [🎯 Audience rules](#ref-audience) +- [⚡ Event triggers](#ref-triggers) +- [🌍 Languages and fallback](#ref-languages) +- [🎨 MiniMessage and placeholders](#ref-minimessage) +- [⏱ Time format](#ref-time) +- [💬 Discord webhook mirror](#ref-discord) +- [⌨️ Commands and permissions](#commands) + + | +
+
+
+ One announcement set, in game, on one server: a boss bar countdown, a full-screen title and a chat line. +
+ +### A complete announcement, start to finish + +Greet returning veterans three seconds after they join - never on a timer, never the newcomers, and +in their own language. Two files say all of it: -**Automatic broadcasts your players actually read — and your server never feels.** +```yaml +# scheduledMessages.yml - who gets it, and when +messages: + - name: welcome-back + trigger: + type: JOIN + delay: 3s + rules: + - type: PLAYTIME + min: 10h +``` + +```yaml +# lang/en.yml - what it says +announcements: + welcome-back: + - chat: "
+
+
+
+
+
+
+
+
+
+
+
+
.yml` | Localized command replies and the actual MiniMessage announcement payloads |
+| `discordWebhook.yml` | Opt-in Discord webhook, display name and avatar |
+
+
+
+## 📥 Get AutoMessage
+
+| What you need | Where to get it |
+|:--|:--|
+| Current public Bukkit build | [Modrinth](https://modrinth.com/plugin/automessage) · [SpigotMC](https://www.spigotmc.org/resources/automessage.112363/) · [Hangar](https://hangar.papermc.io/imDMK/AutoMessage) |
+| Release history and changelogs | [GitHub Releases](https://github.com/imDMK/AutoMessage/releases) |
+| Multiplatform artifacts from this branch | [Build all six from source](#building-from-source) |
+
+> [!IMPORTANT]
+> **Release status:** this README documents the local multiplatform branch. The current public
+> listings still represent the earlier Bukkit-focused release line. Until a new GitHub release
+> publishes all six artifacts, build the multiplatform jars with `./gradlew dist`.
+
+
+
+## 🚀 Quick start
-[](https://github.com/imDMK/AutoMessage/actions/workflows/gradle.yml)
-
-
-[](https://www.spigotmc.org/resources/automessage.112363/)
-[](https://legacy.curseforge.com/minecraft/bukkit-plugins/auto-messages)
-[](https://hangar.papermc.io/imDMK/AutoMessage)
-[](https://modrinth.com/plugin/automessage)
-[](https://polymart.org/product/7890/automessage-1-17-1-21-5)
-[](https://bstats.org/plugin/bukkit/AutoMessages/19487)
-[](https://paypal.me/dominiksuliga)
+1. Get the artifact built for your runtime. Platform jars are intentionally separate.
+2. Put it in your platform's `plugins/` or `mods/` directory. For Minestom, add the jar to the
+ application classpath and use the builder shown below.
+3. Start the server once. AutoMessage generates annotated configuration plus working example
+ announcements.
+4. Edit `config.yml`, `scheduledMessages.yml` and the files inside `lang/`.
+5. Run `/automessage reload`, then test a message with `/automessage view `.
----
+> [!TIP]
+> Start with the generated files. They are not empty templates: every available field is explained
+> next to a working example, and the contents adapt to the platform that created them.
-Rotating announcements are the cheapest way to keep a server alive — vote reminders, rank perks,
-Discord links, event countdowns. Done badly they spam chat and eat ticks. **AutoMessage** does them
-properly: gradient-styled messages across **chat, action bar, titles, boss bars and sounds**, shown
-to exactly the players who should see them, on a schedule you control down to the millisecond.
+
-One pass over the online players per interval. That is the entire cost.
+## 🛠 Build your first announcement
----
+The example below creates an independent event stream, adds one announcement to it, and renders
+multiple notice types from a single localized payload.
-## ✨ Why AutoMessage
+### 1. Schedule the stream
-| | |
-|------------------------------|------------------------------------------------------------------------------------------------------------------------------------------|
-| 🎨 **Every delivery channel** | Chat, ActionBar, Title & Subtitle, BossBar (color, style, progress, duration) and sounds — mix them freely inside a single message. |
-| 🎯 **Audience rules** | Send to everyone, to a permission, or to a rank. Each message chooses its own audience. |
-| 🖌️ **Full MiniMessage** | Gradients, hover, click, colors — the whole [Kyori Adventure](https://github.com/KyoriPowered/adventure) syntax, no legacy colour codes. |
-| ⏱️ **Timing you control** | Human-readable intervals (`30s`, `1m30s`, `2h`) with a separate startup delay. |
-| 🔀 **Rotation modes** | Sequential or random — and the rotation survives a reload instead of jumping back to the first message. |
-| 👁️ **Preview before you ship**| `/automessage view ` renders a message to you alone, audience rules ignored. |
-| ♻️ **Reload without restart** | Every value re-reads live, the schedule included. |
-| 🪶 **Genuinely lightweight** | No database, no per-tick work, no runtime reflection. |
+```yaml
+# config.yml
+enabled: true
+fallbackLanguage: en
+
+channels:
+ - name: default
+ enabled: true
+ initialDelay: 1m
+ period: 5m
+ selector: SHUFFLE
+
+ - name: events
+ enabled: true
+ initialDelay: 30s
+ period: 15m
+ selector: SEQUENTIAL
+```
+
+### 2. Register the announcement
+
+```yaml
+# scheduledMessages.yml
+messages:
+ - name: event-night
+ channel: events
+```
+
+### 3. Design what players receive
+
+```yaml
+# lang/en.yml
+announcements:
+ event-night:
+ - chat:
+ - "[EVENT] The arena opens in 5 minutes."
+ - "Click Teleport'>here to join."
+ actionbar: "Prepare your loadout - the event starts soon"
+ title: "EVENT NIGHT "
+ subtitle: "The arena opens in 05:00"
+ times: "500ms 3s 500ms"
+ bossbar:
+ message: "Event countdown"
+ duration: 10s
+ color: PURPLE
+ overlay: PROGRESS
+ progress: 0.75
+ sound: "block.note_block.pling MASTER 1.0 1.0"
+```
+
+
+
+
+
+Use only the parts that fit the message. A simple chat announcement can be a single string; a
+showcase announcement can combine every surface above.
+
+
+
+## 📖 Reference
+
+
+
+
+Announcement channels and selectors
----
+Every channel has its own schedule and independent selector state:
-## 🚀 Getting started
+```yaml
+channels:
+ - name: tips
+ enabled: true
+ initialDelay: 1m
+ period: 5m
+ selector: SHUFFLE
+
+ - name: promotions
+ enabled: true
+ initialDelay: 2m
+ period: 20m
+ selector: WEIGHTED
+```
-1. Drop the jar into `plugins/` and start the server.
-2. Edit `scheduledMessages.yml` — a working example is generated for you.
-3. Run `/automessage reload`. That is it.
+| Selector | Behavior |
+|:--|:--|
+| `SHUFFLE` | Randomizes the order and shows every message once before anything repeats |
+| `SEQUENTIAL` | Walks straight through the configured list |
+| `RANDOM` | Draws independently; the same message may appear twice in a row |
+| `WEIGHTED` | Draws randomly in proportion to each message's `weight` |
-### 📦 Requirements
+`SEQUENTIAL` and `SHUFFLE` keep their position across a normal configuration reload. A weight of
+`0` parks a message only in a `WEIGHTED` channel; other selectors do not consult weights.
-- **Java 21** or newer
-- **Spigot / Paper 1.21 – 26.2**
+
----
+
-## ⏱️ Message interval
+
+Audience rules
-The schedule lives in `messagesDispatcher.yml`:
+Top-level rules behave like logical **AND**. Nest `ANY_OF`, `NONE_OF` and `NOT` when the audience
+needs more expressive logic.
+
+| Rule | Matches |
+|:--|:--|
+| `PERMISSION` | Viewers holding a permission node |
+| `GROUP` | Viewers holding `group.`; this is permission-based, not a direct LuckPerms/Vault lookup |
+| `WORLD` | Viewers in one of the configured worlds |
+| `PLAYER_COUNT` | While the live online count is inside a configured range |
+| `PLAYTIME` | Viewers whose native platform playtime is inside a configured duration range |
+| `ANY_OF` | At least one nested rule |
+| `NONE_OF` | None of the nested rules |
+| `NOT` | The inverse of one nested rule |
```yaml
-# How much time passes between two announcements.
-period: 30s
+messages:
+ - name: vip-event
+ channel: events
+ rules:
+ - type: ANY_OF
+ rules:
+ - type: PERMISSION
+ permission: rank.vip
+ - type: PERMISSION
+ permission: rank.staff
+ - type: PLAYER_COUNT
+ min: 10
+ max: 100
+```
+
+Rules still apply to messages sent by an event trigger. Platform-specific rules are generated only
+where the runtime can supply the required data.
+
+
+
+
+
+
+Event triggers
-# How long to wait after startup before the first announcement.
-initialDelay: 10s
+A triggered message leaves its timed channel rotation completely:
+
+```yaml
+messages:
+ - name: welcome-back
+ trigger:
+ type: JOIN
+ delay: 3s
+
+ - name: first-join-welcome
+ trigger:
+ type: FIRST_JOIN
+ delay: 3s
+
+ - name: one-hundred-online
+ trigger:
+ type: PLAYER_COUNT
+ threshold: 100
```
-Every time value carries an explicit unit:
+`PLAYER_COUNT` fires once when the threshold is reached and re-arms only after the online count
+drops below it. `FIRST_JOIN` is unavailable on runtimes that do not expose a persistent first-join
+signal.
+
+> [!NOTE]
+> `/automessage enable` and `/automessage disable` control scheduled channels. Event-triggered
+> announcements operate independently.
+
+
+
+
+
+
+Languages and fallback behavior
+
+AutoMessage ships `en`, `pl` and `de`. Add another language by copying a file to
+`lang/.yml`, translating it and running `/automessage reload`.
+
+For a player using `pt_br`, AutoMessage looks for:
-| Unit | Meaning | Example |
-|------|--------------|---------|
+1. `lang/pt_br.yml`
+2. `lang/pt.yml`
+3. the `fallbackLanguage` from `config.yml`
+
+A missing announcement falls back individually, so a new translation does not have to be complete
+before it is useful.
+
+
+
+
+
+
+MiniMessage, placeholders and notification parts
+
+All text uses [Kyori MiniMessage](https://docs.papermc.io/adventure/minimessage/format/), including colors,
+gradients, decorations, hover text and click actions.
+
+Built-in placeholders:
+
+| Player-scoped | Server-scoped |
+|:--|:--|
+| `{PLAYER}` · `{DISPLAY_NAME}` · `{UUID}` · `{WORLD}` | `{ONLINE}` · `{MAX_PLAYERS}` · `{DATE}` · `{TIME}` |
+
+On Bukkit and Folia, an installed PlaceholderAPI is detected automatically. Standard
+`%placeholder_name%` tokens containing letters, digits and underscores can then be used alongside
+the built-ins.
+
+Available notification keys:
+
+| Key | Value |
+|:--|:--|
+| bare string / `chat` | One chat line or a list of lines |
+| `actionbar` | MiniMessage text shown above the hotbar |
+| `title` / `subtitle` | Title layers; either can be used independently |
+| `times` | Three durations: fade in, stay, fade out |
+| `hideTitle` | Explicitly clear the current title |
+| `bossbar` | Message, duration, color, overlay and optional progress `0.0–1.0` |
+| `sound` | Sound key, or `key source volume pitch` |
+
+Need a visual starting point? Try the external
+[EternalCode Notification Generator](https://eternalcode.pl/notification-generator) and adapt the
+generated notification to the language-file structure above.
+
+
+
+
+
+
+Time format
+
+Every duration accepts an explicit unit, and units can be combined:
+
+| Unit | Meaning | Example |
+|:--:|:--|:--|
| `ms` | milliseconds | `500ms` |
-| `s` | seconds | `30s` |
-| `m` | minutes | `5m` |
-| `h` | hours | `1h` |
+| `s` | seconds | `30s` |
+| `m` | minutes | `5m` |
+| `h` | hours | `2h` |
+| `d` | days | `1d` |
-Units combine (`1m30s`). A plain number is read as **seconds**, so `period: 10` means ten seconds —
-the plugin rewrites it as `10s` on the next load.
+`1m30s` is valid. A plain number is interpreted as seconds. Channel periods shorter than `50ms`
+are normalized, and tick-based runtimes ultimately execute on tick boundaries.
-Both values are applied by `/automessage reload`; a server restart is not required.
+
----
+
-## 🔐 Commands & permissions
+
+Discord webhook mirror
-| Command | Permission | Description |
-|----------------------------|-------------------------------|------------------------------------------|
-| `/automessage enable` | `command.automessage.enable` | Resume automatic broadcasts |
-| `/automessage disable` | `command.automessage.disable` | Pause automatic broadcasts |
-| `/automessage reload` | `command.automessage.reload` | Re-read every configuration file |
-| `/automessage view ` | `command.automessage.view` | Preview one message, with tab-completion |
+Set `enabled: true` and provide a Discord webhook URL in `discordWebhook.yml` to mirror scheduled
+and triggered announcements.
----
+The integration is deliberately conservative:
-## ✨ Showcase
+- only chat parts are mirrored;
+- MiniMessage is flattened to plain text;
+- the configured fallback language is used;
+- server placeholders are resolved, while player-scoped values are removed;
+- automatic Discord mentions are disabled;
+- the webhook must use HTTPS and a Discord webhook host/path;
+- rate-limit responses are retried with backoff.
-
+The webhook is opt-in and makes no outbound request while disabled or missing a valid URL. Restart
+AutoMessage after changing the webhook's enabled state or URL so the connection is rebuilt.
----
+
-## 🔔 Configuring notifications
+
+
+### Commands and permissions
+
+| Command | Permission | Description |
+|:--|:--|:--|
+| `/automessage enable` | `command.automessage.enable` | Resume scheduled announcement channels |
+| `/automessage disable` | `command.automessage.disable` | Pause scheduled announcement channels |
+| `/automessage reload` | `command.automessage.reload` | Reload schedules, message definitions and languages |
+| `/automessage view ` | `command.automessage.view` | Preview one localized message; player-only, with tab completion |
+| `/automessage next` | `command.automessage.next` | Show what each channel will announce next and how long until it fires, without using it up |
+| `/automessage send ` | `command.automessage.send` | Send that channel's next announcement now; its interval restarts from this moment |
+| `/automessage toggle` | `command.automessage.toggle` | A player turns announcements off for themselves, or back on; remembered across restarts |
+| `/automessage stats` | `command.automessage.stats` | Per channel: how much it has carried, when the last one went out and when the next is due, then the same per message |
+
+
+Embedding AutoMessage in Minestom
+
+Minestom has no plugin directory. Initialize it, place AutoMessage on the classpath and enable the
+library after Minestom itself is initialized:
+
+```java
+MinecraftServer.init();
+
+AutoMessageMinestom autoMessage = AutoMessageMinestom.builder()
+ .dataDirectory(Path.of("automessage"))
+ .permissions((sender, node) -> myPermissions.check(sender, node)) // optional
+ .enable();
+
+// During your server shutdown:
+autoMessage.shutdown();
+```
+
+Without a permission callback, AutoMessage uses Minestom's operator level to protect its commands.
+Permission and group audience rules are then omitted because the runtime cannot answer them
+accurately.
+
+
+
+
+
+## 🔨 Building from source
+
+Building every runtime requires **JDK 25**. From the repository root, run:
+
+```bash
+./gradlew dist
+```
-Build message payloads visually with the
-[EternalCode notification generator](https://eternalcode.pl/notification-generator).
+The task runs all checks, builds each platform-specific jar and synchronizes the six installable
+artifacts into `build/dist`:
----
+- Bukkit
+- Folia
+- Sponge
+- Velocity
+- Fabric
+- Minestom
-## 💛 Donate
+If any verification fails, the distribution is not produced. A normal `./gradlew build` performs
+the same project checks but leaves artifacts inside their individual modules.
-AutoMessage is free, open source, and maintained in my own time. If it saves you work — or your
-server earns from the announcements it sends — consider buying me a coffee:
+
-### 👉 **[paypal.me/dominiksuliga](https://paypal.me/dominiksuliga)**
+## 💛 Support the project
-Every contribution goes straight back into new features, faster support, and day-one compatibility
-with each Minecraft release. Not in a position to donate? A ⭐ on GitHub or a review on
-[SpigotMC](https://www.spigotmc.org/resources/automessage.112363/) helps just as much.
+AutoMessage is free, open source and maintained independently.
----
+| Found a problem? | Want to contribute? | Want to support development? |
+|:--|:--|:--|
+| [Open a bug report or feature request](https://github.com/imDMK/AutoMessage/issues/new/choose) | Read the [contribution guide](.github/CONTRIBUTING.md) and open a pull request | [GitHub Sponsors](https://github.com/sponsors/imDMK) · [PayPal](https://paypal.me/dominiksuliga) |
-## 🛠️ Feedback & support
+Please follow the project's [Code of Conduct](.github/CODE_OF_CONDUCT.md) in all community spaces.
+AutoMessage is distributed under the [GNU General Public License v3.0](LICENSE).
-Found a bug? Have an idea? Want to contribute?
-👉 [Open an issue](https://github.com/imDMK/AutoMessage/issues) — feedback is genuinely welcome.
+
+ If AutoMessage makes your server communication better, leave the repository a star.
+ It helps other server owners discover the project.
+
diff --git a/assets/automessage-preview.gif b/assets/automessage-preview.gif
deleted file mode 100644
index 69bc07a..0000000
Binary files a/assets/automessage-preview.gif and /dev/null differ
diff --git a/assets/readme/announcement-ingame.png b/assets/readme/announcement-ingame.png
new file mode 100644
index 0000000..e4e3763
Binary files /dev/null and b/assets/readme/announcement-ingame.png differ
diff --git a/assets/readme/announcement.svg b/assets/readme/announcement.svg
new file mode 100644
index 0000000..f932b8e
--- /dev/null
+++ b/assets/readme/announcement.svg
@@ -0,0 +1,71 @@
+
diff --git a/assets/readme/audience.svg b/assets/readme/audience.svg
new file mode 100644
index 0000000..bf3454c
--- /dev/null
+++ b/assets/readme/audience.svg
@@ -0,0 +1,110 @@
+
diff --git a/assets/readme/hero.svg b/assets/readme/hero.svg
new file mode 100644
index 0000000..850b660
--- /dev/null
+++ b/assets/readme/hero.svg
@@ -0,0 +1,149 @@
+
diff --git a/assets/readme/logo.png b/assets/readme/logo.png
new file mode 100644
index 0000000..db3b8d8
Binary files /dev/null and b/assets/readme/logo.png differ
diff --git a/assets/readme/logo.svg b/assets/readme/logo.svg
new file mode 100644
index 0000000..08c5c4c
--- /dev/null
+++ b/assets/readme/logo.svg
@@ -0,0 +1,61 @@
+
diff --git a/assets/readme/performance.svg b/assets/readme/performance.svg
new file mode 100644
index 0000000..5ccd232
--- /dev/null
+++ b/assets/readme/performance.svg
@@ -0,0 +1,285 @@
+
diff --git a/assets/readme/platform-config.svg b/assets/readme/platform-config.svg
new file mode 100644
index 0000000..6e5393b
--- /dev/null
+++ b/assets/readme/platform-config.svg
@@ -0,0 +1,97 @@
+
diff --git a/assets/readme/platforms.svg b/assets/readme/platforms.svg
new file mode 100644
index 0000000..5c9bde3
--- /dev/null
+++ b/assets/readme/platforms.svg
@@ -0,0 +1,54 @@
+
diff --git a/assets/readme/start-to-finish.svg b/assets/readme/start-to-finish.svg
new file mode 100644
index 0000000..17b57d9
--- /dev/null
+++ b/assets/readme/start-to-finish.svg
@@ -0,0 +1,46 @@
+
diff --git a/assets/readme/triggers.svg b/assets/readme/triggers.svg
new file mode 100644
index 0000000..a4126d8
--- /dev/null
+++ b/assets/readme/triggers.svg
@@ -0,0 +1,46 @@
+
diff --git a/automessage-api/build.gradle.kts b/automessage-api/build.gradle.kts
new file mode 100644
index 0000000..5d96a78
--- /dev/null
+++ b/automessage-api/build.gradle.kts
@@ -0,0 +1,10 @@
+plugins {
+ `automessage-testing`
+}
+
+// What the core needs from whatever server it runs on: interfaces only, and only ones a platform
+// can be expected to implement. Depends on Adventure, because an audience is how a message reaches
+// a reader everywhere, and on nothing else at all - no server API, not even Bukkit's.
+dependencies {
+ api("net.kyori:adventure-api:${Versions.KYORI_ADVENTURE}")
+}
diff --git a/automessage-api/src/main/java/com/github/imdmk/automessage/platform/Platform.java b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/Platform.java
new file mode 100644
index 0000000..7f9935e
--- /dev/null
+++ b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/Platform.java
@@ -0,0 +1,19 @@
+package com.github.imdmk.automessage.platform;
+
+import com.github.imdmk.automessage.platform.capability.Capabilities;
+import com.github.imdmk.automessage.platform.scheduler.TaskScheduler;
+import com.github.imdmk.automessage.platform.viewer.PlaytimeSource;
+import com.github.imdmk.automessage.platform.viewer.ViewerRegistry;
+
+public interface Platform {
+
+ String name();
+
+ Capabilities capabilities();
+
+ ViewerRegistry viewers();
+
+ TaskScheduler scheduler();
+
+ PlaytimeSource playtime();
+}
diff --git a/automessage-api/src/main/java/com/github/imdmk/automessage/platform/capability/Capabilities.java b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/capability/Capabilities.java
new file mode 100644
index 0000000..39e3c22
--- /dev/null
+++ b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/capability/Capabilities.java
@@ -0,0 +1,39 @@
+package com.github.imdmk.automessage.platform.capability;
+
+import org.jetbrains.annotations.Unmodifiable;
+
+import java.util.Arrays;
+import java.util.EnumSet;
+import java.util.Set;
+
+public record Capabilities(@Unmodifiable Set supported) {
+
+ public Capabilities {
+ supported = supported.isEmpty()
+ ? Set.of()
+ : Set.copyOf(EnumSet.copyOf(supported));
+ }
+
+ public static Capabilities of(Capability... capabilities) {
+ return new Capabilities(capabilities.length == 0
+ ? Set.of()
+ : EnumSet.copyOf(Arrays.asList(capabilities)));
+ }
+
+ public static Capabilities all() {
+ return new Capabilities(EnumSet.allOf(Capability.class));
+ }
+
+ // A subtraction on purpose: a capability added later is supported by default on these
+ // platforms and has to be argued out, rather than remembered in.
+ public static Capabilities allExcept(Capability... unsupported) {
+ final EnumSet supported = EnumSet.allOf(Capability.class);
+ supported.removeAll(Arrays.asList(unsupported));
+
+ return new Capabilities(supported);
+ }
+
+ public boolean supports(Capability capability) {
+ return supported.contains(capability);
+ }
+}
diff --git a/automessage-api/src/main/java/com/github/imdmk/automessage/platform/capability/Capability.java b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/capability/Capability.java
new file mode 100644
index 0000000..6efde4b
--- /dev/null
+++ b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/capability/Capability.java
@@ -0,0 +1,24 @@
+package com.github.imdmk.automessage.platform.capability;
+
+public enum Capability {
+
+ WORLD_RULE,
+
+ PLAYTIME_RULE,
+
+ FIRST_JOIN_TRIGGER,
+
+ PERMISSION_RULE,
+
+ GROUP_RULE,
+
+ SOUND_NOTICE,
+
+ BOSSBAR_NOTICE,
+
+ TITLE_NOTICE,
+
+ EXTERNAL_PLACEHOLDERS,
+
+ METRICS
+}
diff --git a/automessage-api/src/main/java/com/github/imdmk/automessage/platform/logger/PluginLogger.java b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/logger/PluginLogger.java
new file mode 100644
index 0000000..0e3928c
--- /dev/null
+++ b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/logger/PluginLogger.java
@@ -0,0 +1,45 @@
+package com.github.imdmk.automessage.platform.logger;
+
+import org.intellij.lang.annotations.PrintFormat;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Locale;
+
+public interface PluginLogger {
+
+ enum Level {
+ INFO,
+ WARN,
+ ERROR
+ }
+
+ // The only method a platform implements. Formatting used to live in each of them instead,
+ // which is how one ended up formatting under the default locale and the others under ROOT.
+ void log(Level level, String message, @Nullable Throwable throwable);
+
+ default void info(@PrintFormat String message, Object... args) {
+ log(Level.INFO, format(message, args), null);
+ }
+
+ default void warn(@PrintFormat String message, Object... args) {
+ log(Level.WARN, format(message, args), null);
+ }
+
+ default void warn(Throwable throwable, @PrintFormat String message, Object... args) {
+ log(Level.WARN, format(message, args), throwable);
+ }
+
+ default void error(@PrintFormat String message, Object... args) {
+ log(Level.ERROR, format(message, args), null);
+ }
+
+ default void error(Throwable throwable, @PrintFormat String message, Object... args) {
+ log(Level.ERROR, format(message, args), throwable);
+ }
+
+ // Passed through untouched when there is nothing to substitute, so a message that happens to
+ // contain a percent sign is not read as a format specifier.
+ private static String format(String message, Object... args) {
+ return args.length == 0 ? message : String.format(Locale.ROOT, message, args);
+ }
+}
diff --git a/automessage-api/src/main/java/com/github/imdmk/automessage/platform/placeholder/ExternalPlaceholderResolver.java b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/placeholder/ExternalPlaceholderResolver.java
new file mode 100644
index 0000000..9deb354
--- /dev/null
+++ b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/placeholder/ExternalPlaceholderResolver.java
@@ -0,0 +1,32 @@
+package com.github.imdmk.automessage.platform.placeholder;
+
+import com.github.imdmk.automessage.platform.viewer.Viewer;
+
+public interface ExternalPlaceholderResolver {
+
+ String resolve(Viewer viewer, String token);
+
+ String resolveWithoutViewer(String token);
+
+ boolean available();
+
+ static ExternalPlaceholderResolver disabled() {
+ return new ExternalPlaceholderResolver() {
+
+ @Override
+ public String resolve(Viewer viewer, String token) {
+ return token;
+ }
+
+ @Override
+ public String resolveWithoutViewer(String token) {
+ return token;
+ }
+
+ @Override
+ public boolean available() {
+ return false;
+ }
+ };
+ }
+}
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/scheduler/PluginTask.java b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/scheduler/PluginTask.java
similarity index 100%
rename from automessage-core/src/main/java/com/github/imdmk/automessage/platform/scheduler/PluginTask.java
rename to automessage-api/src/main/java/com/github/imdmk/automessage/platform/scheduler/PluginTask.java
diff --git a/automessage-api/src/main/java/com/github/imdmk/automessage/platform/scheduler/TaskHandle.java b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/scheduler/TaskHandle.java
new file mode 100644
index 0000000..2640b72
--- /dev/null
+++ b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/scheduler/TaskHandle.java
@@ -0,0 +1,11 @@
+package com.github.imdmk.automessage.platform.scheduler;
+
+@FunctionalInterface
+public interface TaskHandle {
+
+ void cancel();
+
+ static TaskHandle done() {
+ return () -> { };
+ }
+}
diff --git a/automessage-api/src/main/java/com/github/imdmk/automessage/platform/scheduler/TaskScheduler.java b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/scheduler/TaskScheduler.java
new file mode 100644
index 0000000..683556f
--- /dev/null
+++ b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/scheduler/TaskScheduler.java
@@ -0,0 +1,17 @@
+package com.github.imdmk.automessage.platform.scheduler;
+
+import java.time.Duration;
+
+// Deliberately carries only what something actually calls. Every method here is implemented once
+// per platform, on threading models that differ enough to be hard - Folia has no global scheduler
+// and a proxy has no game thread - so an unused method is not free, it is six untested ones.
+public interface TaskScheduler {
+
+ TaskHandle runAsync(Runnable runnable);
+
+ TaskHandle runLaterSync(Runnable runnable, Duration delay);
+
+ TaskHandle runTimerSync(PluginTask task);
+
+ void shutdown();
+}
diff --git a/automessage-api/src/main/java/com/github/imdmk/automessage/platform/viewer/PlaytimeSource.java b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/viewer/PlaytimeSource.java
new file mode 100644
index 0000000..c8496ed
--- /dev/null
+++ b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/viewer/PlaytimeSource.java
@@ -0,0 +1,14 @@
+package com.github.imdmk.automessage.platform.viewer;
+
+import java.time.Duration;
+import java.util.Optional;
+
+@FunctionalInterface
+public interface PlaytimeSource {
+
+ Optional playtimeOf(Viewer viewer);
+
+ static PlaytimeSource unavailable() {
+ return viewer -> Optional.empty();
+ }
+}
diff --git a/automessage-api/src/main/java/com/github/imdmk/automessage/platform/viewer/Viewer.java b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/viewer/Viewer.java
new file mode 100644
index 0000000..8563fea
--- /dev/null
+++ b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/viewer/Viewer.java
@@ -0,0 +1,31 @@
+package com.github.imdmk.automessage.platform.viewer;
+
+import net.kyori.adventure.audience.Audience;
+
+import java.util.Optional;
+import java.util.UUID;
+
+public interface Viewer {
+
+ UUID CONSOLE_ID = new UUID(0L, 0L);
+
+ String name();
+
+ UUID uniqueId();
+
+ String displayName();
+
+ // A string, never a Locale: wrapping Bukkit's "pl_pl" in new Locale(...) puts the whole code
+ // in the language field, which is what silently sent Polish players English text.
+ String locale();
+
+ Optional world();
+
+ boolean hasPermission(String permission);
+
+ boolean isOnline();
+
+ boolean isPlayer();
+
+ Audience audience();
+}
diff --git a/automessage-api/src/main/java/com/github/imdmk/automessage/platform/viewer/ViewerFactory.java b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/viewer/ViewerFactory.java
new file mode 100644
index 0000000..4c4fd97
--- /dev/null
+++ b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/viewer/ViewerFactory.java
@@ -0,0 +1,7 @@
+package com.github.imdmk.automessage.platform.viewer;
+
+@FunctionalInterface
+public interface ViewerFactory {
+
+ Viewer of(S sender);
+}
diff --git a/automessage-api/src/main/java/com/github/imdmk/automessage/platform/viewer/ViewerRegistry.java b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/viewer/ViewerRegistry.java
new file mode 100644
index 0000000..3232817
--- /dev/null
+++ b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/viewer/ViewerRegistry.java
@@ -0,0 +1,15 @@
+package com.github.imdmk.automessage.platform.viewer;
+
+import org.jetbrains.annotations.Unmodifiable;
+
+import java.util.Collection;
+
+public interface ViewerRegistry {
+
+ @Unmodifiable
+ Collection online();
+
+ int onlineCount();
+
+ int maxPlayers();
+}
diff --git a/automessage-api/src/test/java/com/github/imdmk/automessage/platform/logger/PluginLoggerTest.java b/automessage-api/src/test/java/com/github/imdmk/automessage/platform/logger/PluginLoggerTest.java
new file mode 100644
index 0000000..2985444
--- /dev/null
+++ b/automessage-api/src/test/java/com/github/imdmk/automessage/platform/logger/PluginLoggerTest.java
@@ -0,0 +1,77 @@
+package com.github.imdmk.automessage.platform.logger;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class PluginLoggerTest {
+
+ private record Entry(PluginLogger.Level level, String message, Throwable throwable) {
+ }
+
+ private final List written = new ArrayList<>();
+
+ private final PluginLogger logger = (level, message, throwable) ->
+ written.add(new Entry(level, message, throwable));
+
+ @Test
+ @DisplayName("should route each level to the one method a platform implements")
+ void shouldRouteEveryLevel() {
+ logger.info("started");
+ logger.warn("odd");
+ logger.error("broken");
+
+ assertThat(written).extracting(Entry::level).containsExactly(
+ PluginLogger.Level.INFO,
+ PluginLogger.Level.WARN,
+ PluginLogger.Level.ERROR
+ );
+ }
+
+ @Test
+ @DisplayName("should format arguments the same way on every platform")
+ void shouldFormatArguments() {
+ logger.info("Loaded %d language(s): %s.", 2, "en, pl");
+
+ assertThat(written).singleElement()
+ .extracting(Entry::message)
+ .isEqualTo("Loaded 2 language(s): en, pl.");
+ }
+
+ @Test
+ @DisplayName("should leave a message with no arguments exactly as written")
+ void shouldNotFormatWithoutArguments() {
+ // Collapsing the overloads means a call with no arguments now reaches the formatter,
+ // where a stray percent sign would be read as a specifier and throw.
+ logger.warn("Channel 'sale 50% off' is not configured.");
+
+ assertThat(written).singleElement()
+ .extracting(Entry::message)
+ .isEqualTo("Channel 'sale 50% off' is not configured.");
+ }
+
+ @Test
+ @DisplayName("should carry the cause through to the platform")
+ void shouldCarryTheCause() {
+ final Throwable cause = new IllegalStateException("boom");
+
+ logger.error(cause, "Failed to reload %s.", "config.yml");
+
+ assertThat(written).singleElement().satisfies(entry -> {
+ assertThat(entry.throwable()).isSameAs(cause);
+ assertThat(entry.message()).isEqualTo("Failed to reload config.yml.");
+ });
+ }
+
+ @Test
+ @DisplayName("should report no cause when there is none")
+ void shouldReportNoCause() {
+ logger.warn("just a warning");
+
+ assertThat(written).singleElement().extracting(Entry::throwable).isNull();
+ }
+}
diff --git a/automessage-bukkit-common/build.gradle.kts b/automessage-bukkit-common/build.gradle.kts
new file mode 100644
index 0000000..dac7a5a
--- /dev/null
+++ b/automessage-bukkit-common/build.gradle.kts
@@ -0,0 +1,22 @@
+plugins {
+ `automessage-spigot-compat`
+ `automessage-adventure-compat`
+}
+
+// Everything the Bukkit family shares: viewers, the event listener, metrics.
+//
+// A library, not a plugin. It carries no plugin.yml, because both Bukkit and Folia depend on it
+// and a module with an entry point would put a second plugin.yml inside every jar that shaded it,
+// leaving the server to pick one at random.
+dependencies {
+ api(project(":automessage-core"))
+
+ // compileOnly, not compileOnlyApi: folia-api declares the same capability because it is a
+ // fork of this, and exporting spigot-api would make every Folia build a capability conflict.
+ compileOnly("org.spigotmc:spigot-api:${Versions.SPIGOT_API}")
+ compileOnly("me.clip:placeholderapi:${Versions.PLACEHOLDER_API}")
+
+ api("net.kyori:adventure-platform-bukkit:${Versions.KYORI_PLATFORM_BUKKIT}")
+ api("org.bstats:bstats-bukkit:${Versions.BSTATS_BUKKIT}")
+ api("dev.rollczi:litecommands-bukkit:${Versions.LITECOMMANDS}")
+}
diff --git a/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/AutoMessagePlugin.java b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/AutoMessagePlugin.java
new file mode 100644
index 0000000..2766b49
--- /dev/null
+++ b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/AutoMessagePlugin.java
@@ -0,0 +1,84 @@
+package com.github.imdmk.automessage.bukkit;
+
+import com.github.imdmk.automessage.AutoMessage;
+import com.github.imdmk.automessage.bukkit.listener.MessageTriggerListener;
+import com.github.imdmk.automessage.bukkit.placeholder.ExternalPlaceholderResolverFactory;
+import com.github.imdmk.automessage.command.CommandRegistrar;
+import com.github.imdmk.automessage.platform.logger.PluginLogger;
+import com.github.imdmk.automessage.platform.scheduler.TaskScheduler;
+import dev.rollczi.litecommands.LiteCommands;
+import dev.rollczi.litecommands.LiteCommandsBuilder;
+import dev.rollczi.litecommands.bukkit.LiteBukkitFactory;
+import net.kyori.adventure.platform.AudienceProvider;
+import net.kyori.adventure.platform.bukkit.BukkitAudiences;
+import org.bukkit.Server;
+import org.bukkit.command.CommandSender;
+import org.bukkit.event.HandlerList;
+import org.bukkit.plugin.Plugin;
+
+import java.util.function.Consumer;
+
+public final class AutoMessagePlugin {
+
+ private final AutoMessage automessage;
+ private final AudienceProvider audiences;
+ private final MessageTriggerListener triggerListener;
+ private final LiteCommands> liteCommands;
+ private final MetricsService metricsService;
+
+ public AutoMessagePlugin(Plugin plugin, String platformName, TaskScheduler scheduler) {
+ this(plugin, platformName, scheduler, builder -> { });
+ }
+
+ // commands is where a fork of Bukkit adds what only it needs. Folia is the one that does:
+ // LiteCommands runs commands through Bukkit's global scheduler, which Folia does not have, and
+ // says so at startup unless its own extension is installed. That extension is Folia's
+ // dependency, not this module's, so it is handed in rather than reached for.
+ public AutoMessagePlugin(
+ Plugin plugin,
+ String platformName,
+ TaskScheduler scheduler,
+ Consumer> commands
+ ) {
+ final Server server = plugin.getServer();
+ final PluginLogger logger = new BukkitPluginLogger(plugin.getLogger());
+
+ this.audiences = BukkitAudiences.create(plugin);
+
+ final BukkitPlatform platform =
+ new BukkitPlatform(platformName, server, audiences, scheduler);
+
+ this.automessage = new AutoMessage(
+ platform,
+ logger,
+ plugin.getDataFolder(),
+ ExternalPlaceholderResolverFactory.create(server, logger)
+ );
+
+ final BukkitViewerFactory viewers = new BukkitViewerFactory(audiences);
+
+ this.triggerListener = new MessageTriggerListener(viewers, automessage.triggerService());
+ server.getPluginManager().registerEvents(triggerListener, plugin);
+
+ final var commandBuilder = LiteBukkitFactory.builder(AutoMessage.NAME, plugin, server);
+ CommandRegistrar.configure(commandBuilder, automessage, viewers);
+ commands.accept(commandBuilder);
+
+ this.liteCommands = commandBuilder.build();
+
+ this.metricsService = new MetricsService(plugin);
+ }
+
+ public void disable() {
+ // Bukkit clears a plugin's handlers when it is disabled, but this class is also torn down
+ // by the loader on its own, so the listener is detached explicitly.
+ HandlerList.unregisterAll(triggerListener);
+
+ automessage.shutdown();
+
+ liteCommands.unregister();
+ audiences.close();
+ metricsService.shutdown();
+ }
+
+}
diff --git a/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitPlatform.java b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitPlatform.java
new file mode 100644
index 0000000..dae27ee
--- /dev/null
+++ b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitPlatform.java
@@ -0,0 +1,49 @@
+package com.github.imdmk.automessage.bukkit;
+
+import com.github.imdmk.automessage.platform.Platform;
+import com.github.imdmk.automessage.platform.capability.Capabilities;
+import com.github.imdmk.automessage.platform.scheduler.TaskScheduler;
+import com.github.imdmk.automessage.platform.viewer.PlaytimeSource;
+import com.github.imdmk.automessage.platform.viewer.ViewerRegistry;
+import net.kyori.adventure.platform.AudienceProvider;
+import org.bukkit.Server;
+
+public final class BukkitPlatform implements Platform {
+
+ private final String name;
+ private final TaskScheduler scheduler;
+ private final ViewerRegistry viewers;
+ private final PlaytimeSource playtime;
+
+ public BukkitPlatform(String name, Server server, AudienceProvider audiences, TaskScheduler scheduler) {
+ this.name = name;
+ this.scheduler = scheduler;
+ this.viewers = new BukkitViewerRegistry(server, audiences);
+ this.playtime = new BukkitPlaytimeSource();
+ }
+
+ @Override
+ public String name() {
+ return name;
+ }
+
+ @Override
+ public Capabilities capabilities() {
+ return Capabilities.all();
+ }
+
+ @Override
+ public ViewerRegistry viewers() {
+ return viewers;
+ }
+
+ @Override
+ public TaskScheduler scheduler() {
+ return scheduler;
+ }
+
+ @Override
+ public PlaytimeSource playtime() {
+ return playtime;
+ }
+}
diff --git a/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitPlaytimeSource.java b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitPlaytimeSource.java
new file mode 100644
index 0000000..2403ca4
--- /dev/null
+++ b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitPlaytimeSource.java
@@ -0,0 +1,25 @@
+package com.github.imdmk.automessage.bukkit;
+
+import com.github.imdmk.automessage.platform.viewer.PlaytimeSource;
+import com.github.imdmk.automessage.platform.viewer.Viewer;
+import org.bukkit.Statistic;
+import org.bukkit.entity.Player;
+
+import java.time.Duration;
+import java.util.Optional;
+
+public final class BukkitPlaytimeSource implements PlaytimeSource {
+
+ private static final long MILLIS_PER_TICK = 50L;
+
+ @Override
+ public Optional playtimeOf(Viewer viewer) {
+ if (!(viewer instanceof BukkitViewer bukkit) || !(bukkit.sender() instanceof Player player)) {
+ return Optional.empty();
+ }
+
+ return Optional.of(Duration.ofMillis(
+ (long) player.getStatistic(Statistic.PLAY_ONE_MINUTE) * MILLIS_PER_TICK
+ ));
+ }
+}
diff --git a/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitPluginLogger.java b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitPluginLogger.java
new file mode 100644
index 0000000..aee2bfc
--- /dev/null
+++ b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitPluginLogger.java
@@ -0,0 +1,28 @@
+package com.github.imdmk.automessage.bukkit;
+
+import com.github.imdmk.automessage.platform.logger.PluginLogger;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.logging.Logger;
+
+public final class BukkitPluginLogger implements PluginLogger {
+
+ private final Logger logger;
+
+ public BukkitPluginLogger(Logger logger) {
+ this.logger = logger;
+ }
+
+ @Override
+ public void log(Level level, String message, @Nullable Throwable throwable) {
+ logger.log(toJulLevel(level), message, throwable);
+ }
+
+ private static java.util.logging.Level toJulLevel(Level level) {
+ return switch (level) {
+ case INFO -> java.util.logging.Level.INFO;
+ case WARN -> java.util.logging.Level.WARNING;
+ case ERROR -> java.util.logging.Level.SEVERE;
+ };
+ }
+}
diff --git a/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitTaskScheduler.java b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitTaskScheduler.java
new file mode 100644
index 0000000..a2f8a1f
--- /dev/null
+++ b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitTaskScheduler.java
@@ -0,0 +1,66 @@
+package com.github.imdmk.automessage.bukkit;
+
+import com.github.imdmk.automessage.platform.scheduler.PluginTask;
+import com.github.imdmk.automessage.platform.scheduler.TaskHandle;
+import com.github.imdmk.automessage.platform.scheduler.TaskScheduler;
+
+import org.bukkit.plugin.Plugin;
+import org.bukkit.scheduler.BukkitScheduler;
+
+import java.time.Duration;
+
+public final class BukkitTaskScheduler implements TaskScheduler {
+
+ private static final long MILLIS_PER_TICK = 50L;
+
+ private final Plugin plugin;
+ private final BukkitScheduler scheduler;
+
+ public BukkitTaskScheduler(Plugin plugin, BukkitScheduler scheduler) {
+ this.plugin = plugin;
+ this.scheduler = scheduler;
+ }
+
+ @Override
+ public TaskHandle runAsync(Runnable runnable) {
+ return handleOf(scheduler.runTaskAsynchronously(plugin, runnable));
+ }
+
+ @Override
+ public TaskHandle runLaterSync(Runnable runnable, Duration delay) {
+ return handleOf(scheduler.runTaskLater(plugin, runnable, toDelayTicks(delay)));
+ }
+
+ @Override
+ public TaskHandle runTimerSync(PluginTask task) {
+ return handleOf(scheduler.runTaskTimer(
+ plugin,
+ task,
+ toDelayTicks(task.delay()),
+ toPeriodTicks(task.period())
+ ));
+ }
+
+ @Override
+ public void shutdown() {
+ scheduler.cancelTasks(plugin);
+ }
+
+ private static TaskHandle handleOf(org.bukkit.scheduler.BukkitTask task) {
+ return task::cancel;
+ }
+
+ private static long toDelayTicks(Duration duration) {
+ return Math.max(0L, toTicks(duration));
+ }
+
+ // Bukkit reads a period of zero ticks as "every tick", which turns a misconfigured interval
+ // into a flood of messages.
+ private static long toPeriodTicks(Duration duration) {
+ return Math.max(1L, toTicks(duration));
+ }
+
+ private static long toTicks(Duration duration) {
+ return duration.toMillis() / MILLIS_PER_TICK;
+ }
+}
diff --git a/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitViewer.java b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitViewer.java
new file mode 100644
index 0000000..28b126d
--- /dev/null
+++ b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitViewer.java
@@ -0,0 +1,87 @@
+package com.github.imdmk.automessage.bukkit;
+
+import com.github.imdmk.automessage.platform.viewer.Viewer;
+import net.kyori.adventure.audience.Audience;
+import net.kyori.adventure.platform.AudienceProvider;
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Player;
+
+import java.util.Optional;
+import java.util.UUID;
+
+public final class BukkitViewer implements Viewer {
+
+ private static final String CONSOLE_NAME = "CONSOLE";
+
+ private final CommandSender sender;
+ private final AudienceProvider audiences;
+
+ public BukkitViewer(CommandSender sender, AudienceProvider audiences) {
+ this.sender = sender;
+ this.audiences = audiences;
+ }
+
+ public CommandSender sender() {
+ return sender;
+ }
+
+ @Override
+ public String name() {
+ return sender instanceof Player player ? player.getName() : CONSOLE_NAME;
+ }
+
+ @Override
+ public UUID uniqueId() {
+ return sender instanceof Player player ? player.getUniqueId() : CONSOLE_ID;
+ }
+
+ @Override
+ public String displayName() {
+ return sender instanceof Player player ? player.getDisplayName() : CONSOLE_NAME;
+ }
+
+ @Override
+ public String locale() {
+ // The raw client string, never wrapped in a Locale - see Viewer#locale.
+ return sender instanceof Player player ? player.getLocale() : "";
+ }
+
+ @Override
+ public Optional world() {
+ return sender instanceof Player player
+ ? Optional.of(player.getWorld().getName())
+ : Optional.empty();
+ }
+
+ @Override
+ public boolean isPlayer() {
+ return sender instanceof Player;
+ }
+
+ @Override
+ public boolean hasPermission(String permission) {
+ return sender.hasPermission(permission);
+ }
+
+ @Override
+ public boolean isOnline() {
+ return !(sender instanceof Player player) || player.isOnline();
+ }
+
+ @Override
+ public Audience audience() {
+ return sender instanceof Player player
+ ? audiences.player(player.getUniqueId())
+ : audiences.console();
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ return other instanceof BukkitViewer viewer && uniqueId().equals(viewer.uniqueId());
+ }
+
+ @Override
+ public int hashCode() {
+ return uniqueId().hashCode();
+ }
+}
diff --git a/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitViewerFactory.java b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitViewerFactory.java
new file mode 100644
index 0000000..0079617
--- /dev/null
+++ b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitViewerFactory.java
@@ -0,0 +1,20 @@
+package com.github.imdmk.automessage.bukkit;
+
+import com.github.imdmk.automessage.platform.viewer.Viewer;
+import com.github.imdmk.automessage.platform.viewer.ViewerFactory;
+import net.kyori.adventure.platform.AudienceProvider;
+import org.bukkit.command.CommandSender;
+
+public final class BukkitViewerFactory implements ViewerFactory {
+
+ private final AudienceProvider audiences;
+
+ public BukkitViewerFactory(AudienceProvider audiences) {
+ this.audiences = audiences;
+ }
+
+ @Override
+ public Viewer of(CommandSender sender) {
+ return new BukkitViewer(sender, audiences);
+ }
+}
diff --git a/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitViewerRegistry.java b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitViewerRegistry.java
new file mode 100644
index 0000000..1502c80
--- /dev/null
+++ b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitViewerRegistry.java
@@ -0,0 +1,44 @@
+package com.github.imdmk.automessage.bukkit;
+
+import com.github.imdmk.automessage.platform.viewer.Viewer;
+import com.github.imdmk.automessage.platform.viewer.ViewerRegistry;
+import net.kyori.adventure.platform.AudienceProvider;
+import org.bukkit.Server;
+import org.bukkit.entity.Player;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+public final class BukkitViewerRegistry implements ViewerRegistry {
+
+ private final Server server;
+ private final AudienceProvider audiences;
+
+ public BukkitViewerRegistry(Server server, AudienceProvider audiences) {
+ this.server = server;
+ this.audiences = audiences;
+ }
+
+ @Override
+ public Collection online() {
+ final Collection extends Player> players = server.getOnlinePlayers();
+ final List viewers = new ArrayList<>(players.size());
+
+ for (final Player player : players) {
+ viewers.add(new BukkitViewer(player, audiences));
+ }
+
+ return List.copyOf(viewers);
+ }
+
+ @Override
+ public int onlineCount() {
+ return server.getOnlinePlayers().size();
+ }
+
+ @Override
+ public int maxPlayers() {
+ return server.getMaxPlayers();
+ }
+}
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/metrics/MetricsService.java b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/MetricsService.java
similarity index 86%
rename from automessage-core/src/main/java/com/github/imdmk/automessage/platform/metrics/MetricsService.java
rename to automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/MetricsService.java
index d7c66b2..c3f4ff9 100644
--- a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/metrics/MetricsService.java
+++ b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/MetricsService.java
@@ -1,4 +1,4 @@
-package com.github.imdmk.automessage.platform.metrics;
+package com.github.imdmk.automessage.bukkit;
import org.bstats.bukkit.Metrics;
import org.bukkit.plugin.Plugin;
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/trigger/MessageTriggerListener.java b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/listener/MessageTriggerListener.java
similarity index 70%
rename from automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/trigger/MessageTriggerListener.java
rename to automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/listener/MessageTriggerListener.java
index dfd1c81..cbfa16d 100644
--- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/trigger/MessageTriggerListener.java
+++ b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/listener/MessageTriggerListener.java
@@ -1,30 +1,27 @@
-package com.github.imdmk.automessage.scheduled.trigger;
+package com.github.imdmk.automessage.bukkit.listener;
+import com.github.imdmk.automessage.scheduled.trigger.MessageTriggerService;
+
+import com.github.imdmk.automessage.bukkit.BukkitViewerFactory;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
-/**
- * Turns Bukkit events into trigger evaluations.
- *
- *
- * Both handlers run at {@link EventPriority#MONITOR} and change nothing about the event: this
- * listener only observes.
- *
- */
public final class MessageTriggerListener implements Listener {
+ private final BukkitViewerFactory viewers;
private final MessageTriggerService triggerService;
- public MessageTriggerListener(MessageTriggerService triggerService) {
+ public MessageTriggerListener(BukkitViewerFactory viewers, MessageTriggerService triggerService) {
+ this.viewers = viewers;
this.triggerService = triggerService;
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onJoin(PlayerJoinEvent event) {
- triggerService.onJoin(event.getPlayer());
+ triggerService.onJoin(viewers.of(event.getPlayer()), !event.getPlayer().hasPlayedBefore());
// The joining player is already part of the server's collection by the time this fires.
triggerService.onPlayerCountChanged(event.getPlayer().getServer().getOnlinePlayers().size());
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/placeholder/ExternalPlaceholderResolverFactory.java b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/placeholder/ExternalPlaceholderResolverFactory.java
similarity index 63%
rename from automessage-core/src/main/java/com/github/imdmk/automessage/platform/placeholder/ExternalPlaceholderResolverFactory.java
rename to automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/placeholder/ExternalPlaceholderResolverFactory.java
index 82df0ba..014ea86 100644
--- a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/placeholder/ExternalPlaceholderResolverFactory.java
+++ b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/placeholder/ExternalPlaceholderResolverFactory.java
@@ -1,4 +1,6 @@
-package com.github.imdmk.automessage.platform.placeholder;
+package com.github.imdmk.automessage.bukkit.placeholder;
+
+import com.github.imdmk.automessage.platform.placeholder.ExternalPlaceholderResolver;
import com.github.imdmk.automessage.platform.logger.PluginLogger;
import org.bukkit.Server;
@@ -11,15 +13,6 @@ private ExternalPlaceholderResolverFactory() {
throw new UnsupportedOperationException("This is a utility class and cannot be instantiated.");
}
- /**
- * Decides once, at startup, whether PlaceholderAPI is available.
- *
- *
- * The check is deliberately not repeated per message: asking the plugin manager on every
- * broadcast would cost more than the lookup it guards, and a plugin appearing mid-run is
- * handled by {@code /automessage reload} like every other change.
- *
- */
public static ExternalPlaceholderResolver create(Server server, PluginLogger logger) {
if (!server.getPluginManager().isPluginEnabled(PLACEHOLDER_API)) {
return ExternalPlaceholderResolver.disabled();
diff --git a/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/placeholder/PapiPlaceholderResolver.java b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/placeholder/PapiPlaceholderResolver.java
new file mode 100644
index 0000000..567d0e1
--- /dev/null
+++ b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/placeholder/PapiPlaceholderResolver.java
@@ -0,0 +1,50 @@
+package com.github.imdmk.automessage.bukkit.placeholder;
+
+import com.github.imdmk.automessage.platform.placeholder.ExternalPlaceholderResolver;
+
+import me.clip.placeholderapi.PlaceholderAPI;
+import com.github.imdmk.automessage.bukkit.BukkitViewer;
+import org.bukkit.entity.Player;
+import com.github.imdmk.automessage.platform.viewer.Viewer;
+
+final class PapiPlaceholderResolver implements ExternalPlaceholderResolver {
+
+ @Override
+ public String resolve(Viewer viewer, String token) {
+ // PlaceholderAPI is a Bukkit plugin and wants a Bukkit player. A viewer from any other
+ // platform cannot be unwrapped, and the token is handed back untouched rather than
+ // pretending to have resolved it.
+ if (!(viewer instanceof BukkitViewer bukkit) || !(bukkit.sender() instanceof Player player)) {
+ return token;
+ }
+
+ // Guarded for the same reason as below: expansions are written by other people, and one
+ // that throws would otherwise take down the whole broadcast, for every player, not just
+ // the token it could not resolve.
+ try {
+ return PlaceholderAPI.setPlaceholders(player, token);
+ } catch (RuntimeException exception) {
+ return token;
+ }
+ }
+
+ @Override
+ public String resolveWithoutViewer(String token) {
+ // PlaceholderAPI accepts a null player and server-scoped expansions answer anyway, but
+ // third-party expansions are written by other people and a player-scoped one may well
+ // dereference it. A broken expansion must not take an announcement down with it.
+ try {
+ // The cast is not redundant, whatever an IDE says: setPlaceholders is overloaded for
+ // Player and for OfflinePlayer, and a bare null only lands on the Player one because
+ // it happens to be the more specific of the two today.
+ return PlaceholderAPI.setPlaceholders((Player) null, token);
+ } catch (RuntimeException exception) {
+ return token;
+ }
+ }
+
+ @Override
+ public boolean available() {
+ return true;
+ }
+}
diff --git a/automessage-bukkit/build.gradle.kts b/automessage-bukkit/build.gradle.kts
new file mode 100644
index 0000000..6172fd1
--- /dev/null
+++ b/automessage-bukkit/build.gradle.kts
@@ -0,0 +1,28 @@
+plugins {
+ `automessage-platform`
+ `automessage-bukkit-plugin`
+
+ id("xyz.jpenilla.run-paper")
+}
+
+// Spigot, Paper, Purpur and the rest of the Bukkit family. Not Folia - that has its own module.
+dependencies {
+ api(project(":automessage-bukkit-common"))
+
+ compileOnly("org.spigotmc:spigot-api:${Versions.SPIGOT_API}")
+}
+
+automessagePlatform {
+ runPort = 25599
+ platformName = "Bukkit"
+}
+
+bukkit {
+ main = "com.github.imdmk.automessage.bukkit.AutoMessageBukkitPlugin"
+}
+
+tasks {
+ runServer {
+ minecraftVersion("26.2")
+ }
+}
diff --git a/automessage-loader/src/main/java/com/github/imdmk/automessage/AutoMessagePluginLoader.java b/automessage-bukkit/src/main/java/com/github/imdmk/automessage/bukkit/AutoMessageBukkitPlugin.java
similarity index 50%
rename from automessage-loader/src/main/java/com/github/imdmk/automessage/AutoMessagePluginLoader.java
rename to automessage-bukkit/src/main/java/com/github/imdmk/automessage/bukkit/AutoMessageBukkitPlugin.java
index e95d404..b4c66ee 100644
--- a/automessage-loader/src/main/java/com/github/imdmk/automessage/AutoMessagePluginLoader.java
+++ b/automessage-bukkit/src/main/java/com/github/imdmk/automessage/bukkit/AutoMessageBukkitPlugin.java
@@ -1,14 +1,18 @@
-package com.github.imdmk.automessage;
+package com.github.imdmk.automessage.bukkit;
import org.bukkit.plugin.java.JavaPlugin;
-public final class AutoMessagePluginLoader extends JavaPlugin {
+public final class AutoMessageBukkitPlugin extends JavaPlugin {
private AutoMessagePlugin plugin;
@Override
public void onEnable() {
- this.plugin = new AutoMessagePlugin(this);
+ this.plugin = new AutoMessagePlugin(
+ this,
+ "Bukkit",
+ new BukkitTaskScheduler(this, getServer().getScheduler())
+ );
}
@Override
diff --git a/automessage-core/build.gradle.kts b/automessage-core/build.gradle.kts
index 8ab8788..2fdcaf4 100644
--- a/automessage-core/build.gradle.kts
+++ b/automessage-core/build.gradle.kts
@@ -1,36 +1,24 @@
plugins {
- `automessage-spigot-compat`
`automessage-adventure-compat`
+ `automessage-testing`
}
dependencies {
- compileOnlyApi("org.spigotmc:spigot-api:${Versions.SPIGOT_API}")
+ // Core decides when a message is sent and to whom; the notice module decides what and how.
+ api(project(":automessage-api"))
+ api(project(":automessage-notice"))
- compileOnly("me.clip:placeholderapi:${Versions.PLACEHOLDER_API}")
-
- implementation("net.kyori:adventure-platform-bukkit:${Versions.KYORI_PLATFORM_BUKKIT}")
implementation("net.kyori:adventure-text-minimessage:${Versions.KYORI_ADVENTURE}")
// Flattens a rendered component to the words a Discord reader actually sees.
implementation("net.kyori:adventure-text-serializer-plain:${Versions.KYORI_ADVENTURE}")
- implementation("com.eternalcode:multification-bukkit:${Versions.MULTIFICATION}")
- implementation("com.eternalcode:multification-okaeri:${Versions.MULTIFICATION}")
-
api("eu.okaeri:okaeri-configs-yaml-snakeyaml:${Versions.OKAERI_CONFIGS}")
implementation("eu.okaeri:okaeri-configs-serdes-commons:${Versions.OKAERI_CONFIGS}")
- implementation("org.bstats:bstats-bukkit:${Versions.BSTATS_BUKKIT}")
- implementation("dev.rollczi:litecommands-bukkit:${Versions.LITECOMMANDS}")
- implementation("dev.rollczi:litecommands-annotations:${Versions.LITECOMMANDS}")
-
- // compileOnlyApi keeps spigot-api out of the shaded jar, but it also keeps it off the test
- // runtime classpath - and tests that touch Bukkit types need it there to load at all.
- testImplementation("org.spigotmc:spigot-api:${Versions.SPIGOT_API}")
-
- testImplementation("org.junit.jupiter:junit-jupiter:${Versions.JUNIT}")
- testImplementation("org.assertj:assertj-core:${Versions.ASSERTJ}")
- testImplementation("org.mockito:mockito-core:${Versions.MOCKITO}")
- testImplementation("org.mockito:mockito-junit-jupiter:${Versions.MOCKITO}")
- testRuntimeOnly("org.junit.platform:junit-platform-launcher")
+ // The commands live here, not in the platform modules: they take a Viewer rather than a
+ // sender, so nothing in them names a server. Each platform adds only its own binding.
+ api("dev.rollczi:litecommands-annotations:${Versions.LITECOMMANDS}")
+ api("dev.rollczi:litecommands-framework:${Versions.LITECOMMANDS}")
}
+
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/AutoMessage.java b/automessage-core/src/main/java/com/github/imdmk/automessage/AutoMessage.java
new file mode 100644
index 0000000..3cc853e
--- /dev/null
+++ b/automessage-core/src/main/java/com/github/imdmk/automessage/AutoMessage.java
@@ -0,0 +1,216 @@
+package com.github.imdmk.automessage;
+
+import com.github.imdmk.automessage.scheduled.audience.optout.AnnouncementOptOut;
+import com.github.imdmk.automessage.config.ConfigManager;
+import com.github.imdmk.automessage.config.ConfigReloadService;
+import com.github.imdmk.automessage.language.LanguageRegistry;
+import com.github.imdmk.automessage.message.MessageService;
+import com.github.imdmk.automessage.platform.Platform;
+import com.github.imdmk.automessage.platform.discord.DiscordWebhookConfig;
+import com.github.imdmk.automessage.platform.discord.DiscordWebhookService;
+import com.github.imdmk.automessage.platform.logger.PluginLogger;
+import com.github.imdmk.automessage.platform.placeholder.ExternalPlaceholderResolver;
+import com.github.imdmk.automessage.platform.scheduler.TaskScheduler;
+import com.github.imdmk.automessage.platform.viewer.ViewerRegistry;
+import com.github.imdmk.automessage.scheduled.ScheduledMessageRepository;
+import com.github.imdmk.automessage.scheduled.ScheduledMessageSender;
+import com.github.imdmk.automessage.scheduled.ScheduledMessagesConfig;
+import com.github.imdmk.automessage.scheduled.audience.filter.AudienceFilter;
+import com.github.imdmk.automessage.scheduled.audience.rule.AudienceContext;
+import com.github.imdmk.automessage.scheduled.dispatcher.DispatchStatistics;
+import com.github.imdmk.automessage.scheduled.dispatcher.DispatchObserver;
+import com.github.imdmk.automessage.scheduled.dispatcher.MessageDispatcher;
+import com.github.imdmk.automessage.scheduled.dispatcher.MessageDispatcherConfig;
+import com.github.imdmk.automessage.scheduled.dispatcher.MessageDispatcherService;
+import com.github.imdmk.automessage.scheduled.dispatcher.ScheduledMessageDispatcherFactory;
+import com.github.imdmk.automessage.scheduled.selector.MessageSelectorProvider;
+import com.github.imdmk.automessage.scheduled.selector.MessageSelectorType;
+import com.github.imdmk.automessage.scheduled.trigger.MessageTriggerService;
+import com.github.imdmk.automessage.scheduled.trigger.PlayerCountMilestones;
+
+import java.io.File;
+
+public final class AutoMessage {
+
+ public static final String NAME = "AutoMessage";
+ private static final String DONATE_URL = "https://paypal.me/dominiksuliga";
+
+ private final PluginLogger logger;
+ private final Platform platform;
+ private final ConfigManager configManager;
+ private final ConfigReloadService configReloadService;
+
+ private final LanguageRegistry languages;
+ private final MessageService messageService;
+ private final MessageDispatcherConfig dispatcherConfig;
+ private final ScheduledMessageRepository messageRepository;
+ private final ScheduledMessageSender messageSender;
+ private final MessageDispatcherService dispatcherService;
+ private final MessageTriggerService triggerService;
+ private final DispatchObserver dispatchObserver;
+ private final DispatchStatistics statistics;
+ private final AnnouncementOptOut optOut;
+
+ public AutoMessage(
+ Platform platform,
+ PluginLogger logger,
+ File dataFolder,
+ ExternalPlaceholderResolver placeholders
+ ) {
+ this.platform = platform;
+ this.logger = logger;
+
+ // Written for this platform only: a proxy has no worlds and keeps no playtime, so the
+ // options for those are left out of the file rather than written and quietly ignored.
+ this.configManager = new ConfigManager(logger, dataFolder, platform.capabilities());
+
+ // config.yml first: it names the language to fall back to.
+ this.dispatcherConfig = configManager.create(MessageDispatcherConfig.class);
+ final ScheduledMessagesConfig scheduledMessages = configManager.create(ScheduledMessagesConfig.class);
+
+ this.languages = LanguageRegistry.load(configManager, logger, () -> dispatcherConfig.fallbackLanguage);
+ this.configReloadService = new ConfigReloadService(configManager);
+
+ // Registered first, so a message rendered by a later listener already sees the languages
+ // this reload discovered.
+ configReloadService.register(languages);
+
+ final TaskScheduler scheduler = platform.scheduler();
+ final ViewerRegistry viewers = platform.viewers();
+ final AudienceContext audienceContext = AudienceContext.of(viewers, platform.playtime());
+
+ // One filter for both the rotation and the triggers, so a player who turned announcements
+ // off is not greeted by one on the way in.
+ this.optOut = AnnouncementOptOut.load(logger, dataFolder);
+ final AudienceFilter audienceFilter =
+ AudienceFilter.notMuted(optOut).and(AudienceFilter.ruleFilter());
+
+ // A boss bar hides itself on the plugin's own scheduler, which is the only clock the
+ // notice module is given - it has no way to tell the time by itself, by design.
+ this.messageService = new MessageService(
+ languages,
+ (delay, action) -> scheduler.runLaterSync(action, delay)
+ );
+
+ this.messageRepository = ScheduledMessageRepository.config(scheduledMessages, configReloadService);
+ this.messageSender = new ScheduledMessageSender(
+ viewers, logger, messageService, languages, placeholders
+ );
+
+ // The scan it keeps is only valid for the text currently on disk.
+ configReloadService.register(messageSender);
+
+ final DiscordWebhookConfig discordConfig = configManager.create(DiscordWebhookConfig.class);
+
+ // One observer shared by every channel: mirroring is a property of the announcement, not
+ // of the stream it happened to travel on.
+ this.statistics = new DispatchStatistics();
+ this.dispatchObserver = DispatchObserver.of(
+ statistics,
+ DiscordWebhookService.create(viewers, languages, logger, discordConfig)
+ );
+
+ final ScheduledMessageDispatcherFactory dispatcherFactory =
+ selector -> new MessageDispatcher(
+ messageSender,
+ selector,
+ audienceFilter,
+ audienceContext,
+ dispatchObserver
+ );
+
+ this.dispatcherService = new MessageDispatcherService(
+ logger, viewers, scheduler, dispatcherConfig, messageRepository, dispatcherFactory
+ );
+
+ dispatcherService.start();
+ configReloadService.register(dispatcherService);
+
+ // A trigger dispatches the one message its event names, so this dispatcher's selector is
+ // never consulted. It still gets a real one rather than a trap that would only fire if
+ // some later change started rotating through triggered messages.
+ final MessageDispatcher triggerDispatcher = dispatcherFactory.create(
+ new MessageSelectorProvider(() -> MessageSelectorType.SEQUENTIAL)
+ );
+
+ this.triggerService = new MessageTriggerService(
+ viewers,
+ scheduler,
+ messageRepository,
+ triggerDispatcher,
+ audienceFilter,
+ audienceContext,
+ new PlayerCountMilestones()
+ );
+
+ logger.info("%s enabled on %s.", NAME, platform.name());
+ logger.info("Enjoying %s? You can support its development at %s - thank you!", NAME, DONATE_URL);
+ }
+
+ public void shutdown() {
+ close("stop the dispatcher", dispatcherService::stop);
+ close("save who turned announcements off", optOut::save);
+ close("save the configuration", configManager::saveAll);
+ close("release the configuration", configManager::clearAll);
+ close("stop the scheduler", platform.scheduler()::shutdown);
+ close("close the Discord webhook", dispatchObserver::shutdown);
+
+ logger.info("%s disabled successfully.", NAME);
+ }
+
+ private void close(String what, Runnable step) {
+ try {
+ step.run();
+ } catch (RuntimeException exception) {
+ logger.error(exception, "Failed to %s while disabling %s.", what, NAME);
+ }
+ }
+
+ public PluginLogger logger() {
+ return logger;
+ }
+
+ public LanguageRegistry languages() {
+ return languages;
+ }
+
+ public MessageService messageService() {
+ return messageService;
+ }
+
+ public MessageDispatcherConfig dispatcherConfig() {
+ return dispatcherConfig;
+ }
+
+ public ScheduledMessageRepository messageRepository() {
+ return messageRepository;
+ }
+
+ public ScheduledMessageSender messageSender() {
+ return messageSender;
+ }
+
+ public ConfigReloadService configReloadService() {
+ return configReloadService;
+ }
+
+ public MessageTriggerService triggerService() {
+ return triggerService;
+ }
+
+ public MessageDispatcherService dispatcherService() {
+ return dispatcherService;
+ }
+
+ public AnnouncementOptOut optOut() {
+ return optOut;
+ }
+
+ public DispatchStatistics statistics() {
+ return statistics;
+ }
+
+ public TaskScheduler scheduler() {
+ return platform.scheduler();
+ }
+}
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/AutoMessagePlugin.java b/automessage-core/src/main/java/com/github/imdmk/automessage/AutoMessagePlugin.java
deleted file mode 100644
index b2f244a..0000000
--- a/automessage-core/src/main/java/com/github/imdmk/automessage/AutoMessagePlugin.java
+++ /dev/null
@@ -1,171 +0,0 @@
-package com.github.imdmk.automessage;
-
-import com.eternalcode.multification.notice.Notice;
-import com.github.imdmk.automessage.command.dispatcher.DisableCommand;
-import com.github.imdmk.automessage.command.dispatcher.EnableCommand;
-import com.github.imdmk.automessage.command.reload.ReloadCommand;
-import com.github.imdmk.automessage.command.view.ViewCommand;
-import com.github.imdmk.automessage.config.ConfigManager;
-import com.github.imdmk.automessage.config.ConfigReloadService;
-import com.github.imdmk.automessage.language.LanguageRegistry;
-import com.github.imdmk.automessage.message.MessageService;
-import com.github.imdmk.automessage.platform.litecommands.argument.ScheduledMessageArgument;
-import com.github.imdmk.automessage.platform.litecommands.argument.UnknownScheduledMessage;
-import com.github.imdmk.automessage.platform.litecommands.handler.InvalidUsageHandlerImpl;
-import com.github.imdmk.automessage.platform.litecommands.handler.MissingPermissionsHandlerImpl;
-import com.github.imdmk.automessage.platform.litecommands.handler.NoticeResultHandlerImpl;
-import com.github.imdmk.automessage.platform.litecommands.handler.UnknownScheduledMessageHandler;
-import com.github.imdmk.automessage.platform.logger.BukkitPluginLogger;
-import com.github.imdmk.automessage.platform.logger.PluginLogger;
-import com.github.imdmk.automessage.platform.discord.DiscordWebhookConfig;
-import com.github.imdmk.automessage.platform.discord.DiscordWebhookService;
-import com.github.imdmk.automessage.platform.metrics.MetricsService;
-import com.github.imdmk.automessage.platform.placeholder.ExternalPlaceholderResolver;
-import com.github.imdmk.automessage.platform.placeholder.ExternalPlaceholderResolverFactory;
-import com.github.imdmk.automessage.platform.scheduler.BukkitTaskScheduler;
-import com.github.imdmk.automessage.platform.scheduler.TaskScheduler;
-import com.github.imdmk.automessage.scheduled.ScheduledMessage;
-import com.github.imdmk.automessage.scheduled.ScheduledMessageRepository;
-import com.github.imdmk.automessage.scheduled.ScheduledMessageSender;
-import com.github.imdmk.automessage.scheduled.ScheduledMessagesConfig;
-import com.github.imdmk.automessage.scheduled.audience.filter.AudienceFilter;
-import com.github.imdmk.automessage.scheduled.dispatcher.DispatchObserver;
-import com.github.imdmk.automessage.scheduled.dispatcher.MessageDispatcher;
-import com.github.imdmk.automessage.scheduled.dispatcher.MessageDispatcherConfig;
-import com.github.imdmk.automessage.scheduled.dispatcher.MessageDispatcherService;
-import dev.rollczi.litecommands.LiteCommands;
-import dev.rollczi.litecommands.bukkit.LiteBukkitFactory;
-import com.github.imdmk.automessage.scheduled.dispatcher.ScheduledMessageDispatcherFactory;
-import com.github.imdmk.automessage.scheduled.selector.MessageSelectorProvider;
-import com.github.imdmk.automessage.scheduled.selector.MessageSelectorType;
-import com.github.imdmk.automessage.scheduled.trigger.MessageTriggerListener;
-import com.github.imdmk.automessage.scheduled.trigger.MessageTriggerService;
-import com.github.imdmk.automessage.scheduled.trigger.PlayerCountMilestones;
-import org.bukkit.Server;
-import org.bukkit.event.HandlerList;
-import org.bukkit.plugin.Plugin;
-
-final class AutoMessagePlugin {
-
- private static final String PLUGIN_PREFIX = "AutoMessage";
- private static final String DONATE_URL = "https://paypal.me/dominiksuliga";
-
- private final PluginLogger logger;
- private final ConfigManager configManager;
-
- private final MessageService messageService;
- private final TaskScheduler taskScheduler;
- private final MessageDispatcherService dispatcherService;
- private final LiteCommands> liteCommands;
- private final MetricsService metricsService;
- private final MessageTriggerListener triggerListener;
- private final DispatchObserver dispatchObserver;
-
- AutoMessagePlugin(Plugin plugin) {
- final Server server = plugin.getServer();
- this.logger = new BukkitPluginLogger(plugin.getLogger());
-
- this.configManager = new ConfigManager(logger, plugin.getDataFolder());
-
- // config.yml first: it names the languages to write out and the one to fall back to.
- final MessageDispatcherConfig dispatcherConfig = configManager.create(MessageDispatcherConfig.class);
- final ScheduledMessagesConfig scheduledMessagesConfig = configManager.create(ScheduledMessagesConfig.class);
-
- final LanguageRegistry languages =
- LanguageRegistry.load(configManager, logger, dispatcherConfig.fallbackLanguage);
-
- final ConfigReloadService configReloadService = new ConfigReloadService(configManager);
-
- this.messageService = new MessageService(languages, plugin);
- this.taskScheduler = new BukkitTaskScheduler(plugin, server.getScheduler());
-
- final ScheduledMessageRepository messageRepository =
- ScheduledMessageRepository.config(scheduledMessagesConfig, configReloadService);
- final ExternalPlaceholderResolver placeholderResolver =
- ExternalPlaceholderResolverFactory.create(server, logger);
-
- final ScheduledMessageSender messageSender = new ScheduledMessageSender(
- server, logger, messageService, languages, placeholderResolver
- );
-
- final DiscordWebhookConfig discordConfig = configManager.create(DiscordWebhookConfig.class);
-
- // One observer shared by every channel: mirroring is a property of the announcement, not
- // of the stream it happened to travel on.
- this.dispatchObserver = DiscordWebhookService.create(server, languages, logger, discordConfig);
-
- final ScheduledMessageDispatcherFactory dispatcherFactory =
- selector -> new MessageDispatcher(
- messageSender, selector, AudienceFilter.ruleFilter(), dispatchObserver
- );
-
- this.dispatcherService = new MessageDispatcherService(
- logger,
- server,
- taskScheduler,
- dispatcherConfig,
- messageRepository,
- dispatcherFactory
- );
-
- dispatcherService.start();
- configReloadService.register(dispatcherService);
-
- // A trigger dispatches the one message its event names, so this dispatcher's selector is
- // never consulted. It still gets a real one rather than a trap that would only fire if
- // some later change started rotating through triggered messages.
- final MessageDispatcher triggerDispatcher = dispatcherFactory.create(
- new MessageSelectorProvider(() -> MessageSelectorType.SEQUENTIAL)
- );
-
- this.triggerListener = new MessageTriggerListener(new MessageTriggerService(
- server,
- taskScheduler,
- messageRepository,
- triggerDispatcher,
- AudienceFilter.ruleFilter(),
- new PlayerCountMilestones()
- ));
-
- server.getPluginManager().registerEvents(triggerListener, plugin);
-
- this.liteCommands = LiteBukkitFactory.builder(PLUGIN_PREFIX, plugin, server)
- .invalidUsage(new InvalidUsageHandlerImpl(messageService))
- .missingPermission(new MissingPermissionsHandlerImpl(messageService))
- .result(Notice.class, new NoticeResultHandlerImpl(messageService))
- .result(UnknownScheduledMessage.class, new UnknownScheduledMessageHandler(messageService))
-
- .argument(ScheduledMessage.class, new ScheduledMessageArgument(messageRepository))
-
- .commands(
- new DisableCommand(dispatcherConfig, messageService),
- new EnableCommand(dispatcherConfig, messageService),
- new ReloadCommand(logger, configReloadService, taskScheduler, messageService),
- new ViewCommand(messageSender, messageService)
- )
-
- .build();
-
- this.metricsService = new MetricsService(plugin);
-
- logger.info("%s plugin enabled.", PLUGIN_PREFIX);
- logger.info("Enjoying %s? You can support its development at %s - thank you!", PLUGIN_PREFIX, DONATE_URL);
- }
-
- void disable() {
- // Bukkit clears a plugin's handlers when it is disabled, but this class is also torn down
- // by the loader on its own, so the listener is detached explicitly.
- HandlerList.unregisterAll(triggerListener);
-
- dispatcherService.stop();
- configManager.saveAll();
- configManager.clearAll();
- messageService.shutdown();
- taskScheduler.shutdown();
- liteCommands.unregister();
- dispatchObserver.shutdown();
- metricsService.shutdown();
-
- logger.info("%s plugin disabled successfully.", PLUGIN_PREFIX);
- }
-}
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/command/CommandRegistrar.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/CommandRegistrar.java
new file mode 100644
index 0000000..8c035dd
--- /dev/null
+++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/CommandRegistrar.java
@@ -0,0 +1,72 @@
+package com.github.imdmk.automessage.command;
+
+import com.github.imdmk.automessage.command.toggle.ToggleCommand;
+import com.github.imdmk.automessage.AutoMessage;
+import com.github.imdmk.automessage.scheduled.channel.AnnouncementChannel;
+import com.github.imdmk.automessage.command.handler.UnknownAnnouncementChannelHandler;
+import com.github.imdmk.automessage.command.argument.UnknownAnnouncementChannel;
+import com.github.imdmk.automessage.command.argument.AnnouncementChannelArgument;
+import com.github.imdmk.automessage.command.send.SendCommand;
+import com.github.imdmk.automessage.command.next.NextCommand;
+import com.github.imdmk.automessage.command.stats.StatsCommand;
+import com.github.imdmk.automessage.command.argument.ScheduledMessageArgument;
+import com.github.imdmk.automessage.command.argument.UnknownScheduledMessage;
+import com.github.imdmk.automessage.command.dispatcher.DisableCommand;
+import com.github.imdmk.automessage.command.dispatcher.EnableCommand;
+import com.github.imdmk.automessage.command.handler.InvalidUsageHandlerImpl;
+import com.github.imdmk.automessage.command.handler.MissingPermissionsHandlerImpl;
+import com.github.imdmk.automessage.command.handler.NoticeResultHandlerImpl;
+import com.github.imdmk.automessage.command.handler.UnknownScheduledMessageHandler;
+import com.github.imdmk.automessage.command.reload.ReloadCommand;
+import com.github.imdmk.automessage.command.view.ViewCommand;
+import com.github.imdmk.automessage.notice.Notice;
+import com.github.imdmk.automessage.platform.viewer.Viewer;
+import com.github.imdmk.automessage.platform.viewer.ViewerFactory;
+import com.github.imdmk.automessage.scheduled.ScheduledMessage;
+import dev.rollczi.litecommands.LiteCommandsBuilder;
+import dev.rollczi.litecommands.context.ContextResult;
+
+public final class CommandRegistrar {
+
+ private CommandRegistrar() {
+ }
+
+ public static void configure(
+ LiteCommandsBuilder builder,
+ AutoMessage automessage,
+ ViewerFactory viewers
+ ) {
+ builder
+ .context(Viewer.class, invocation -> ContextResult.ok(() -> viewers.of(invocation.sender())))
+
+ .invalidUsage(new InvalidUsageHandlerImpl<>(automessage.messageService(), viewers))
+ .missingPermission(new MissingPermissionsHandlerImpl<>(automessage.messageService(), viewers))
+ .result(Notice.class, new NoticeResultHandlerImpl<>(automessage.messageService(), viewers))
+ .result(UnknownScheduledMessage.class,
+ new UnknownScheduledMessageHandler<>(automessage.messageService(), viewers))
+ .result(UnknownAnnouncementChannel.class,
+ new UnknownAnnouncementChannelHandler<>(automessage.messageService(), viewers))
+
+ .argument(ScheduledMessage.class, new ScheduledMessageArgument<>(automessage.messageRepository()))
+ .argument(AnnouncementChannel.class,
+ new AnnouncementChannelArgument<>(automessage.dispatcherConfig()))
+
+ .commands(
+ new DisableCommand(automessage.dispatcherConfig(), automessage.messageService()),
+ new EnableCommand(automessage.dispatcherConfig(), automessage.messageService()),
+ new ReloadCommand(
+ automessage.logger(),
+ automessage.configReloadService(),
+ automessage.scheduler(),
+ automessage.messageService()
+ ),
+ new ViewCommand(automessage.messageSender(), automessage.messageService()),
+ new NextCommand(automessage.dispatcherService(), automessage.messageService()),
+ new StatsCommand(automessage.statistics(), automessage.dispatcherService(),
+ automessage.messageService()),
+ new SendCommand(automessage.dispatcherService(), automessage.messageService()),
+ new ToggleCommand(automessage.optOut(), automessage.scheduler(),
+ automessage.messageService())
+ );
+ }
+}
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/command/argument/AnnouncementChannelArgument.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/argument/AnnouncementChannelArgument.java
new file mode 100644
index 0000000..9be7d65
--- /dev/null
+++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/argument/AnnouncementChannelArgument.java
@@ -0,0 +1,43 @@
+package com.github.imdmk.automessage.command.argument;
+
+import com.github.imdmk.automessage.scheduled.channel.AnnouncementChannel;
+import com.github.imdmk.automessage.scheduled.dispatcher.MessageDispatcherConfig;
+import dev.rollczi.litecommands.argument.Argument;
+import dev.rollczi.litecommands.argument.parser.ParseResult;
+import dev.rollczi.litecommands.argument.resolver.ArgumentResolver;
+import dev.rollczi.litecommands.invocation.Invocation;
+import dev.rollczi.litecommands.suggestion.SuggestionContext;
+import dev.rollczi.litecommands.suggestion.SuggestionResult;
+
+public final class AnnouncementChannelArgument extends ArgumentResolver {
+
+ private final MessageDispatcherConfig dispatcherConfig;
+
+ public AnnouncementChannelArgument(MessageDispatcherConfig dispatcherConfig) {
+ this.dispatcherConfig = dispatcherConfig;
+ }
+
+ // Read from the configuration on every invocation rather than captured once, so a channel
+ // added by a reload can be named without restarting the server.
+ @Override
+ protected ParseResult parse(
+ Invocation invocation,
+ Argument context,
+ String argument
+ ) {
+ return dispatcherConfig.channels().stream()
+ .filter(channel -> channel.matches(argument))
+ .findFirst()
+ .>map(ParseResult::success)
+ .orElseGet(() -> ParseResult.failure(new UnknownAnnouncementChannel(argument)));
+ }
+
+ @Override
+ public SuggestionResult suggest(
+ Invocation invocation,
+ Argument argument,
+ SuggestionContext context
+ ) {
+ return SuggestionResult.of(dispatcherConfig.channels().stream().map(AnnouncementChannel::name).toList());
+ }
+}
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/litecommands/argument/ScheduledMessageArgument.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/argument/ScheduledMessageArgument.java
similarity index 66%
rename from automessage-core/src/main/java/com/github/imdmk/automessage/platform/litecommands/argument/ScheduledMessageArgument.java
rename to automessage-core/src/main/java/com/github/imdmk/automessage/command/argument/ScheduledMessageArgument.java
index e08f913..6b2d754 100644
--- a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/litecommands/argument/ScheduledMessageArgument.java
+++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/argument/ScheduledMessageArgument.java
@@ -1,4 +1,4 @@
-package com.github.imdmk.automessage.platform.litecommands.argument;
+package com.github.imdmk.automessage.command.argument;
import com.github.imdmk.automessage.scheduled.ScheduledMessage;
import com.github.imdmk.automessage.scheduled.ScheduledMessageRepository;
@@ -8,18 +8,8 @@
import dev.rollczi.litecommands.invocation.Invocation;
import dev.rollczi.litecommands.suggestion.SuggestionContext;
import dev.rollczi.litecommands.suggestion.SuggestionResult;
-import org.bukkit.command.CommandSender;
-/**
- * Resolves a {@link ScheduledMessage} from its configured name and suggests the names currently
- * present in {@code scheduledMessages.yml}.
- *
- *
- * Suggestions are read on every keystroke, so messages added by an administrator become
- * available right after {@code /automessage reload}.
- *
- */
-public final class ScheduledMessageArgument extends ArgumentResolver {
+public final class ScheduledMessageArgument extends ArgumentResolver {
private final ScheduledMessageRepository repository;
@@ -29,7 +19,7 @@ public ScheduledMessageArgument(ScheduledMessageRepository repository) {
@Override
protected ParseResult parse(
- Invocation invocation,
+ Invocation invocation,
Argument context,
String argument
) {
@@ -40,7 +30,7 @@ protected ParseResult parse(
@Override
public SuggestionResult suggest(
- Invocation invocation,
+ Invocation invocation,
Argument argument,
SuggestionContext context
) {
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/command/argument/UnknownAnnouncementChannel.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/argument/UnknownAnnouncementChannel.java
new file mode 100644
index 0000000..a1cd302
--- /dev/null
+++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/argument/UnknownAnnouncementChannel.java
@@ -0,0 +1,4 @@
+package com.github.imdmk.automessage.command.argument;
+
+public record UnknownAnnouncementChannel(String name) {
+}
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/command/argument/UnknownScheduledMessage.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/argument/UnknownScheduledMessage.java
new file mode 100644
index 0000000..96cb80a
--- /dev/null
+++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/argument/UnknownScheduledMessage.java
@@ -0,0 +1,4 @@
+package com.github.imdmk.automessage.command.argument;
+
+public record UnknownScheduledMessage(String name) {
+}
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/command/dispatcher/DisableCommand.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/dispatcher/DisableCommand.java
index 669fcf3..b70b5fc 100644
--- a/automessage-core/src/main/java/com/github/imdmk/automessage/command/dispatcher/DisableCommand.java
+++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/dispatcher/DisableCommand.java
@@ -1,12 +1,12 @@
package com.github.imdmk.automessage.command.dispatcher;
import com.github.imdmk.automessage.message.MessageService;
+import com.github.imdmk.automessage.platform.viewer.Viewer;
import com.github.imdmk.automessage.scheduled.dispatcher.MessageDispatcherConfig;
import dev.rollczi.litecommands.annotations.command.Command;
import dev.rollczi.litecommands.annotations.context.Context;
import dev.rollczi.litecommands.annotations.execute.Execute;
import dev.rollczi.litecommands.annotations.permission.Permission;
-import org.bukkit.command.CommandSender;
@Command(name = "automessage disable")
@Permission("command.automessage.disable")
@@ -15,24 +15,21 @@ public final class DisableCommand {
private final MessageDispatcherConfig dispatcherConfig;
private final MessageService messageService;
- public DisableCommand(
- MessageDispatcherConfig dispatcherConfig,
- MessageService messageService
- ) {
+ public DisableCommand(MessageDispatcherConfig dispatcherConfig, MessageService messageService) {
this.dispatcherConfig = dispatcherConfig;
this.messageService = messageService;
}
@Execute
- void disable(@Context CommandSender sender) {
+ void disable(@Context Viewer viewer) {
if (!dispatcherConfig.isEnabled()) {
- messageService.send(sender, n -> n.commands.dispatcherAlreadyDisabled);
+ messageService.send(viewer, n -> n.commands.dispatcherAlreadyDisabled);
return;
}
dispatcherConfig.setEnabled(false);
dispatcherConfig.save();
- messageService.send(sender, n -> n.commands.dispatcherDisabled);
+ messageService.send(viewer, n -> n.commands.dispatcherDisabled);
}
-}
\ No newline at end of file
+}
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/command/dispatcher/EnableCommand.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/dispatcher/EnableCommand.java
index dedae3a..4ca9886 100644
--- a/automessage-core/src/main/java/com/github/imdmk/automessage/command/dispatcher/EnableCommand.java
+++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/dispatcher/EnableCommand.java
@@ -1,12 +1,12 @@
package com.github.imdmk.automessage.command.dispatcher;
import com.github.imdmk.automessage.message.MessageService;
+import com.github.imdmk.automessage.platform.viewer.Viewer;
import com.github.imdmk.automessage.scheduled.dispatcher.MessageDispatcherConfig;
import dev.rollczi.litecommands.annotations.command.Command;
import dev.rollczi.litecommands.annotations.context.Context;
import dev.rollczi.litecommands.annotations.execute.Execute;
import dev.rollczi.litecommands.annotations.permission.Permission;
-import org.bukkit.command.CommandSender;
@Command(name = "automessage enable")
@Permission("command.automessage.enable")
@@ -15,24 +15,21 @@ public final class EnableCommand {
private final MessageDispatcherConfig dispatcherConfig;
private final MessageService messageService;
- public EnableCommand(
- MessageDispatcherConfig dispatcherConfig,
- MessageService messageService
- ) {
+ public EnableCommand(MessageDispatcherConfig dispatcherConfig, MessageService messageService) {
this.dispatcherConfig = dispatcherConfig;
this.messageService = messageService;
}
@Execute
- void enable(@Context CommandSender sender) {
+ void enable(@Context Viewer viewer) {
if (dispatcherConfig.isEnabled()) {
- messageService.send(sender, n -> n.commands.dispatcherAlreadyEnabled);
+ messageService.send(viewer, n -> n.commands.dispatcherAlreadyEnabled);
return;
}
dispatcherConfig.setEnabled(true);
dispatcherConfig.save();
- messageService.send(sender, n -> n.commands.dispatcherEnabled);
+ messageService.send(viewer, n -> n.commands.dispatcherEnabled);
}
}
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/litecommands/handler/InvalidUsageHandlerImpl.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/handler/InvalidUsageHandlerImpl.java
similarity index 66%
rename from automessage-core/src/main/java/com/github/imdmk/automessage/platform/litecommands/handler/InvalidUsageHandlerImpl.java
rename to automessage-core/src/main/java/com/github/imdmk/automessage/command/handler/InvalidUsageHandlerImpl.java
index 8c0ba75..65abd02 100644
--- a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/litecommands/handler/InvalidUsageHandlerImpl.java
+++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/handler/InvalidUsageHandlerImpl.java
@@ -1,33 +1,36 @@
-package com.github.imdmk.automessage.platform.litecommands.handler;
+package com.github.imdmk.automessage.command.handler;
import com.github.imdmk.automessage.message.MessageService;
+import com.github.imdmk.automessage.platform.viewer.Viewer;
+import com.github.imdmk.automessage.platform.viewer.ViewerFactory;
import dev.rollczi.litecommands.handler.result.ResultHandlerChain;
import dev.rollczi.litecommands.invalidusage.InvalidUsage;
import dev.rollczi.litecommands.invalidusage.InvalidUsageHandler;
import dev.rollczi.litecommands.invocation.Invocation;
import dev.rollczi.litecommands.schematic.Schematic;
-import org.bukkit.command.CommandSender;
-public final class InvalidUsageHandlerImpl implements InvalidUsageHandler {
+public final class InvalidUsageHandlerImpl implements InvalidUsageHandler {
private final MessageService messageService;
+ private final ViewerFactory viewers;
- public InvalidUsageHandlerImpl(MessageService messageService) {
+ public InvalidUsageHandlerImpl(MessageService messageService, ViewerFactory viewers) {
this.messageService = messageService;
+ this.viewers = viewers;
}
@Override
public void handle(
- Invocation invocation,
- InvalidUsage result,
- ResultHandlerChain chain
+ Invocation invocation,
+ InvalidUsage result,
+ ResultHandlerChain chain
) {
- final CommandSender sender = invocation.sender();
+ final Viewer viewer = viewers.of(invocation.sender());
final Schematic schematic = result.getSchematic();
if (schematic.isOnlyFirst()) {
messageService.create()
- .viewer(sender)
+ .viewer(viewer)
.notice(notice -> notice.commands.usageInvalid)
.placeholder("{USAGE}", schematic.first())
.send();
@@ -35,13 +38,13 @@ public void handle(
}
messageService.create()
- .viewer(sender)
+ .viewer(viewer)
.notice(notice -> notice.commands.usageHeader)
.send();
for (final String sch : schematic.all()) {
messageService.create()
- .viewer(sender)
+ .viewer(viewer)
.notice(notice -> notice.commands.usageEntry)
.placeholder("{USAGE}", sch)
.send();
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/litecommands/handler/MissingPermissionsHandlerImpl.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/handler/MissingPermissionsHandlerImpl.java
similarity index 63%
rename from automessage-core/src/main/java/com/github/imdmk/automessage/platform/litecommands/handler/MissingPermissionsHandlerImpl.java
rename to automessage-core/src/main/java/com/github/imdmk/automessage/command/handler/MissingPermissionsHandlerImpl.java
index 1750d94..39127a8 100644
--- a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/litecommands/handler/MissingPermissionsHandlerImpl.java
+++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/handler/MissingPermissionsHandlerImpl.java
@@ -1,28 +1,30 @@
-package com.github.imdmk.automessage.platform.litecommands.handler;
+package com.github.imdmk.automessage.command.handler;
import com.github.imdmk.automessage.message.MessageService;
+import com.github.imdmk.automessage.platform.viewer.ViewerFactory;
import dev.rollczi.litecommands.handler.result.ResultHandlerChain;
import dev.rollczi.litecommands.invocation.Invocation;
import dev.rollczi.litecommands.permission.MissingPermissions;
import dev.rollczi.litecommands.permission.MissingPermissionsHandler;
-import org.bukkit.command.CommandSender;
-public final class MissingPermissionsHandlerImpl implements MissingPermissionsHandler {
+public final class MissingPermissionsHandlerImpl implements MissingPermissionsHandler {
private final MessageService messageService;
+ private final ViewerFactory viewers;
- public MissingPermissionsHandlerImpl(MessageService messageService) {
+ public MissingPermissionsHandlerImpl(MessageService messageService, ViewerFactory viewers) {
this.messageService = messageService;
+ this.viewers = viewers;
}
@Override
public void handle(
- Invocation invocation,
+ Invocation invocation,
MissingPermissions permissions,
- ResultHandlerChain chain
+ ResultHandlerChain chain
) {
messageService.create()
- .viewer(invocation.sender())
+ .viewer(viewers.of(invocation.sender()))
.notice(n -> n.commands.permissionMissing)
.placeholder("{PERMISSIONS}", String.join(", ", permissions.getPermissions()))
.send();
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/command/handler/NoticeResultHandlerImpl.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/handler/NoticeResultHandlerImpl.java
new file mode 100644
index 0000000..b538f01
--- /dev/null
+++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/handler/NoticeResultHandlerImpl.java
@@ -0,0 +1,28 @@
+package com.github.imdmk.automessage.command.handler;
+
+import com.github.imdmk.automessage.message.MessageService;
+import com.github.imdmk.automessage.notice.Notice;
+import com.github.imdmk.automessage.platform.viewer.ViewerFactory;
+import dev.rollczi.litecommands.handler.result.ResultHandler;
+import dev.rollczi.litecommands.handler.result.ResultHandlerChain;
+import dev.rollczi.litecommands.invocation.Invocation;
+
+public final class NoticeResultHandlerImpl implements ResultHandler {
+
+ private final MessageService messageService;
+ private final ViewerFactory viewers;
+
+ public NoticeResultHandlerImpl(MessageService messageService, ViewerFactory viewers) {
+ this.messageService = messageService;
+ this.viewers = viewers;
+ }
+
+ @Override
+ public void handle(
+ Invocation invocation,
+ Notice notice,
+ ResultHandlerChain chain
+ ) {
+ messageService.send(viewers.of(invocation.sender()), n -> notice);
+ }
+}
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/command/handler/UnknownAnnouncementChannelHandler.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/handler/UnknownAnnouncementChannelHandler.java
new file mode 100644
index 0000000..793f862
--- /dev/null
+++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/handler/UnknownAnnouncementChannelHandler.java
@@ -0,0 +1,32 @@
+package com.github.imdmk.automessage.command.handler;
+
+import com.github.imdmk.automessage.command.argument.UnknownAnnouncementChannel;
+import com.github.imdmk.automessage.message.MessageService;
+import com.github.imdmk.automessage.platform.viewer.ViewerFactory;
+import dev.rollczi.litecommands.handler.result.ResultHandler;
+import dev.rollczi.litecommands.handler.result.ResultHandlerChain;
+import dev.rollczi.litecommands.invocation.Invocation;
+
+public final class UnknownAnnouncementChannelHandler implements ResultHandler {
+
+ private final MessageService messageService;
+ private final ViewerFactory viewers;
+
+ public UnknownAnnouncementChannelHandler(MessageService messageService, ViewerFactory viewers) {
+ this.messageService = messageService;
+ this.viewers = viewers;
+ }
+
+ @Override
+ public void handle(
+ Invocation invocation,
+ UnknownAnnouncementChannel result,
+ ResultHandlerChain chain
+ ) {
+ messageService.create()
+ .viewer(viewers.of(invocation.sender()))
+ .notice(notice -> notice.commands.channelNotFound)
+ .placeholder("{CHANNEL}", result.name())
+ .send();
+ }
+}
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/litecommands/handler/UnknownScheduledMessageHandler.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/handler/UnknownScheduledMessageHandler.java
similarity index 55%
rename from automessage-core/src/main/java/com/github/imdmk/automessage/platform/litecommands/handler/UnknownScheduledMessageHandler.java
rename to automessage-core/src/main/java/com/github/imdmk/automessage/command/handler/UnknownScheduledMessageHandler.java
index 173612c..ae63c34 100644
--- a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/litecommands/handler/UnknownScheduledMessageHandler.java
+++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/handler/UnknownScheduledMessageHandler.java
@@ -1,28 +1,30 @@
-package com.github.imdmk.automessage.platform.litecommands.handler;
+package com.github.imdmk.automessage.command.handler;
+import com.github.imdmk.automessage.command.argument.UnknownScheduledMessage;
import com.github.imdmk.automessage.message.MessageService;
-import com.github.imdmk.automessage.platform.litecommands.argument.UnknownScheduledMessage;
+import com.github.imdmk.automessage.platform.viewer.ViewerFactory;
import dev.rollczi.litecommands.handler.result.ResultHandler;
import dev.rollczi.litecommands.handler.result.ResultHandlerChain;
import dev.rollczi.litecommands.invocation.Invocation;
-import org.bukkit.command.CommandSender;
-public final class UnknownScheduledMessageHandler implements ResultHandler {
+public final class UnknownScheduledMessageHandler implements ResultHandler {
private final MessageService messageService;
+ private final ViewerFactory viewers;
- public UnknownScheduledMessageHandler(MessageService messageService) {
+ public UnknownScheduledMessageHandler(MessageService messageService, ViewerFactory viewers) {
this.messageService = messageService;
+ this.viewers = viewers;
}
@Override
public void handle(
- Invocation invocation,
+ Invocation invocation,
UnknownScheduledMessage result,
- ResultHandlerChain chain
+ ResultHandlerChain chain
) {
messageService.create()
- .viewer(invocation.sender())
+ .viewer(viewers.of(invocation.sender()))
.notice(notice -> notice.commands.messageNotFound)
.placeholder("{MESSAGE}", result.name())
.send();
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/command/next/NextCommand.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/next/NextCommand.java
new file mode 100644
index 0000000..743cccb
--- /dev/null
+++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/next/NextCommand.java
@@ -0,0 +1,50 @@
+package com.github.imdmk.automessage.command.next;
+
+import com.github.imdmk.automessage.language.CommandMessages;
+import com.github.imdmk.automessage.message.MessageService;
+import com.github.imdmk.automessage.notice.Notice;
+import com.github.imdmk.automessage.notice.time.DurationFormatter;
+import com.github.imdmk.automessage.platform.viewer.Viewer;
+import com.github.imdmk.automessage.scheduled.dispatcher.ChannelPreview;
+import com.github.imdmk.automessage.scheduled.dispatcher.MessageDispatcherService;
+import dev.rollczi.litecommands.annotations.command.Command;
+import dev.rollczi.litecommands.annotations.context.Context;
+import dev.rollczi.litecommands.annotations.execute.Execute;
+import dev.rollczi.litecommands.annotations.permission.Permission;
+
+@Command(name = "automessage next")
+@Permission("command.automessage.next")
+public final class NextCommand {
+
+ private final MessageDispatcherService dispatcher;
+ private final MessageService messageService;
+
+ public NextCommand(MessageDispatcherService dispatcher, MessageService messageService) {
+ this.dispatcher = dispatcher;
+ this.messageService = messageService;
+ }
+
+ @Execute
+ void next(@Context Viewer viewer) {
+ messageService.send(viewer, notice -> notice.commands.nextHeader);
+
+ for (final ChannelPreview preview : dispatcher.upcoming()) {
+ messageService.create()
+ .viewer(viewer)
+ .notice(notice -> lineFor(notice.commands, preview))
+ .placeholder("{CHANNEL}", preview.channel())
+ .placeholder("{MESSAGE}", preview.message() == null ? "" : preview.message())
+ .placeholder("{DELAY}", preview.due() == null ? "" : DurationFormatter.formatReadable(preview.due()))
+ .send();
+ }
+ }
+
+ private static Notice lineFor(CommandMessages messages, ChannelPreview preview) {
+ return switch (preview.kind()) {
+ case NEXT -> messages.nextEntry;
+ case UNPREDICTABLE -> messages.nextUnpredictable;
+ case DISABLED -> messages.nextDisabled;
+ case EMPTY -> messages.nextEmpty;
+ };
+ }
+}
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/command/reload/ReloadCommand.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/reload/ReloadCommand.java
index 0a0baf8..7b34993 100644
--- a/automessage-core/src/main/java/com/github/imdmk/automessage/command/reload/ReloadCommand.java
+++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/reload/ReloadCommand.java
@@ -4,11 +4,11 @@
import com.github.imdmk.automessage.message.MessageService;
import com.github.imdmk.automessage.platform.logger.PluginLogger;
import com.github.imdmk.automessage.platform.scheduler.TaskScheduler;
+import com.github.imdmk.automessage.platform.viewer.Viewer;
import dev.rollczi.litecommands.annotations.command.Command;
import dev.rollczi.litecommands.annotations.context.Context;
import dev.rollczi.litecommands.annotations.execute.Execute;
import dev.rollczi.litecommands.annotations.permission.Permission;
-import org.bukkit.command.CommandSender;
@Command(name = "automessage reload")
@Permission("command.automessage.reload")
@@ -32,14 +32,14 @@ public ReloadCommand(
}
@Execute
- void reload(@Context CommandSender sender) {
+ void reload(@Context Viewer viewer) {
taskScheduler.runAsync(() -> {
try {
configReloadService.reload();
- messageService.send(sender, n -> n.commands.configReloadedSuccess);
+ messageService.send(viewer, n -> n.commands.configReloadedSuccess);
} catch (Exception e) {
logger.error(e, "Failed to reload plugin config");
- messageService.send(sender, n -> n.commands.configReloadFailed);
+ messageService.send(viewer, n -> n.commands.configReloadFailed);
}
});
}
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/command/send/SendCommand.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/send/SendCommand.java
new file mode 100644
index 0000000..9935e0f
--- /dev/null
+++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/send/SendCommand.java
@@ -0,0 +1,54 @@
+package com.github.imdmk.automessage.command.send;
+
+import com.github.imdmk.automessage.language.CommandMessages;
+import com.github.imdmk.automessage.message.MessageService;
+import com.github.imdmk.automessage.notice.Notice;
+import com.github.imdmk.automessage.notice.time.DurationFormatter;
+import com.github.imdmk.automessage.platform.viewer.Viewer;
+import com.github.imdmk.automessage.scheduled.ScheduledMessage;
+import com.github.imdmk.automessage.scheduled.channel.AnnouncementChannel;
+import com.github.imdmk.automessage.scheduled.dispatcher.ForcedSend;
+import com.github.imdmk.automessage.scheduled.dispatcher.MessageDispatcherService;
+import dev.rollczi.litecommands.annotations.argument.Arg;
+import dev.rollczi.litecommands.annotations.command.Command;
+import dev.rollczi.litecommands.annotations.context.Context;
+import dev.rollczi.litecommands.annotations.execute.Execute;
+import dev.rollczi.litecommands.annotations.permission.Permission;
+
+// Deliberately its own command rather than an argument of /automessage next. That one answers a
+// question; this one broadcasts to everybody online, and the two should not be separated by a
+// single word somebody could tab-complete by accident.
+@Command(name = "automessage send")
+@Permission("command.automessage.send")
+public final class SendCommand {
+
+ private final MessageDispatcherService dispatcher;
+ private final MessageService messageService;
+
+ public SendCommand(MessageDispatcherService dispatcher, MessageService messageService) {
+ this.dispatcher = dispatcher;
+ this.messageService = messageService;
+ }
+
+ @Execute
+ void send(@Context Viewer viewer, @Arg("channel") AnnouncementChannel channel) {
+ final ForcedSend result = dispatcher.forceNext(channel);
+
+ messageService.create()
+ .viewer(viewer)
+ .notice(notice -> lineFor(notice.commands, result))
+ .placeholder("{CHANNEL}", channel.name())
+ .placeholder("{MESSAGE}", result.message().map(ScheduledMessage::name).orElse(""))
+ .placeholder("{DELAY}", result.nextIn() == null ? "" : DurationFormatter.formatReadable(result.nextIn()))
+ .send();
+ }
+
+ private static Notice lineFor(CommandMessages messages, ForcedSend result) {
+ return switch (result.kind()) {
+ case SENT -> messages.sendDone;
+ case NOBODY_ONLINE -> messages.sendNobodyOnline;
+ case NO_MESSAGES -> messages.sendNoMessages;
+ case DISABLED -> messages.sendDisabled;
+ };
+ }
+}
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/command/stats/StatsCommand.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/stats/StatsCommand.java
new file mode 100644
index 0000000..b5a6933
--- /dev/null
+++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/stats/StatsCommand.java
@@ -0,0 +1,112 @@
+package com.github.imdmk.automessage.command.stats;
+
+import com.github.imdmk.automessage.language.CommandMessages;
+import com.github.imdmk.automessage.message.MessageService;
+import com.github.imdmk.automessage.notice.Notice;
+import com.github.imdmk.automessage.notice.time.DurationFormatter;
+import com.github.imdmk.automessage.platform.viewer.Viewer;
+import com.github.imdmk.automessage.scheduled.dispatcher.ChannelPreview;
+import com.github.imdmk.automessage.scheduled.dispatcher.DispatchStatistics;
+import com.github.imdmk.automessage.scheduled.dispatcher.MessageDispatcherService;
+import dev.rollczi.litecommands.annotations.command.Command;
+import dev.rollczi.litecommands.annotations.context.Context;
+import dev.rollczi.litecommands.annotations.execute.Execute;
+import dev.rollczi.litecommands.annotations.permission.Permission;
+
+import java.util.List;
+import java.util.Optional;
+
+@Command(name = "automessage stats")
+@Permission("command.automessage.stats")
+public final class StatsCommand {
+
+ private final DispatchStatistics statistics;
+ private final MessageDispatcherService dispatcher;
+ private final MessageService messageService;
+
+ public StatsCommand(
+ DispatchStatistics statistics,
+ MessageDispatcherService dispatcher,
+ MessageService messageService
+ ) {
+ this.statistics = statistics;
+ this.dispatcher = dispatcher;
+ this.messageService = messageService;
+ }
+
+ @Execute
+ void stats(@Context Viewer viewer) {
+ final long total = statistics.total();
+
+ messageService.create()
+ .viewer(viewer)
+ .notice(notice -> total == 0 ? notice.commands.statsEmpty : notice.commands.statsHeader)
+ .placeholder("{TOTAL}", Long.toString(total))
+ .send();
+
+ sendChannels(viewer);
+ sendMessages(viewer);
+ }
+
+ // Two labelled lists rather than one mixed one. A channel row and a message row used to look
+ // alike, and a reader had no way to tell which kind of name they were looking at.
+ private void sendChannels(Viewer viewer) {
+ final List channels = dispatcher.upcoming();
+
+ if (channels.isEmpty()) {
+ return;
+ }
+
+ messageService.send(viewer, notice -> notice.commands.statsChannelsHeader);
+
+ for (final ChannelPreview preview : channels) {
+ final Optional counted = statistics.channel(preview.channel());
+ final long count = counted.map(DispatchStatistics.Entry::count).orElse(0L);
+
+ messageService.create()
+ .viewer(viewer)
+ .notice(notice -> channelLine(notice.commands, preview, count))
+ .placeholder("{CHANNEL}", preview.channel())
+ .placeholder("{COUNT}", Long.toString(count))
+ .placeholder("{AGO}", counted
+ .map(entry -> DurationFormatter.formatReadable(entry.since()))
+ .orElse(""))
+ .placeholder("{DELAY}", preview.due() == null
+ ? ""
+ : DurationFormatter.formatReadable(preview.due()))
+ .send();
+ }
+ }
+
+ private void sendMessages(Viewer viewer) {
+ final List entries = statistics.snapshot();
+
+ if (entries.isEmpty()) {
+ return;
+ }
+
+ messageService.send(viewer, notice -> notice.commands.statsMessagesHeader);
+
+ for (final DispatchStatistics.Entry entry : entries) {
+ messageService.create()
+ .viewer(viewer)
+ .notice(notice -> notice.commands.statsEntry)
+ .placeholder("{MESSAGE}", entry.name())
+ .placeholder("{CHANNEL}", entry.channel() == null ? "" : entry.channel())
+ .placeholder("{COUNT}", Long.toString(entry.count()))
+ .placeholder("{AGO}", DurationFormatter.formatReadable(entry.since()))
+ .send();
+ }
+ }
+
+ // A channel that is not counting down says why here rather than leaving the reader to run
+ // /automessage next to find out - "not counting down" described the missing timer, which is
+ // this command's problem and not the reader's.
+ private static Notice channelLine(CommandMessages messages, ChannelPreview preview, long count) {
+ return switch (preview.kind()) {
+ case DISABLED -> messages.statsChannelDisabled;
+ case EMPTY -> messages.statsChannelEmpty;
+ case NEXT, UNPREDICTABLE -> count == 0 ? messages.statsChannelPending : messages.statsChannel;
+ };
+ }
+}
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/command/toggle/ToggleCommand.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/toggle/ToggleCommand.java
new file mode 100644
index 0000000..533094c
--- /dev/null
+++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/toggle/ToggleCommand.java
@@ -0,0 +1,45 @@
+package com.github.imdmk.automessage.command.toggle;
+
+import com.github.imdmk.automessage.message.MessageService;
+import com.github.imdmk.automessage.platform.scheduler.TaskScheduler;
+import com.github.imdmk.automessage.platform.viewer.Viewer;
+import com.github.imdmk.automessage.scheduled.audience.optout.AnnouncementOptOut;
+import dev.rollczi.litecommands.annotations.command.Command;
+import dev.rollczi.litecommands.annotations.context.Context;
+import dev.rollczi.litecommands.annotations.execute.Execute;
+import dev.rollczi.litecommands.annotations.permission.Permission;
+
+@Command(name = "automessage toggle")
+@Permission("command.automessage.toggle")
+public final class ToggleCommand {
+
+ private final AnnouncementOptOut optOut;
+ private final TaskScheduler scheduler;
+ private final MessageService messageService;
+
+ public ToggleCommand(AnnouncementOptOut optOut, TaskScheduler scheduler, MessageService messageService) {
+ this.optOut = optOut;
+ this.scheduler = scheduler;
+ this.messageService = messageService;
+ }
+
+ @Execute
+ void toggle(@Context Viewer viewer) {
+ // The console receives announcements from nowhere, and a preference stored against its
+ // fixed id would be a preference nobody set.
+ if (!viewer.isPlayer()) {
+ messageService.send(viewer, notice -> notice.commands.togglePlayerOnly);
+ return;
+ }
+
+ final boolean muted = optOut.toggle(viewer.uniqueId());
+
+ // Answered from memory the moment it is toggled; the file only has to be there after a
+ // restart, so writing it waits for a thread that is allowed to touch a disk.
+ scheduler.runAsync(optOut::save);
+
+ messageService.send(viewer, notice -> muted
+ ? notice.commands.toggleOff
+ : notice.commands.toggleOn);
+ }
+}
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/command/view/ViewCommand.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/view/ViewCommand.java
index eb8c9b8..36e162f 100644
--- a/automessage-core/src/main/java/com/github/imdmk/automessage/command/view/ViewCommand.java
+++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/view/ViewCommand.java
@@ -1,6 +1,7 @@
package com.github.imdmk.automessage.command.view;
import com.github.imdmk.automessage.message.MessageService;
+import com.github.imdmk.automessage.platform.viewer.Viewer;
import com.github.imdmk.automessage.scheduled.ScheduledMessage;
import com.github.imdmk.automessage.scheduled.ScheduledMessageSender;
import dev.rollczi.litecommands.annotations.argument.Arg;
@@ -8,18 +9,7 @@
import dev.rollczi.litecommands.annotations.context.Context;
import dev.rollczi.litecommands.annotations.execute.Execute;
import dev.rollczi.litecommands.annotations.permission.Permission;
-import org.bukkit.command.CommandSender;
-import org.bukkit.entity.Player;
-/**
- * Shows a single scheduled message to the player who ran the command, exactly as the dispatcher
- * would render it.
- *
- *
- * The preview deliberately ignores the audience rules of the message, so staff can check how a
- * message restricted to a permission or a group looks without holding that permission.
- *
- */
@Command(name = "automessage view")
@Permission("command.automessage.view")
public final class ViewCommand {
@@ -27,28 +17,24 @@ public final class ViewCommand {
private final ScheduledMessageSender sender;
private final MessageService messageService;
- public ViewCommand(
- ScheduledMessageSender sender,
- MessageService messageService
- ) {
+ public ViewCommand(ScheduledMessageSender sender, MessageService messageService) {
this.sender = sender;
this.messageService = messageService;
}
@Execute
- void view(
- @Context CommandSender commandSender,
- @Arg("message") ScheduledMessage message
- ) {
- if (!(commandSender instanceof Player player)) {
- messageService.send(commandSender, n -> n.commands.viewPlayerOnly);
+ void view(@Context Viewer viewer, @Arg("message") ScheduledMessage message) {
+ // A title or a boss bar has nowhere to go on a console, so the preview would be a
+ // half-truth at best.
+ if (!viewer.isPlayer()) {
+ messageService.send(viewer, n -> n.commands.viewPlayerOnly);
return;
}
- sender.send(player, message);
+ sender.send(viewer, message);
messageService.create()
- .viewer(player)
+ .viewer(viewer)
.notice(notice -> notice.commands.messagePreviewed)
.placeholder("{MESSAGE}", message.name())
.send();
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/config/CapabilityFilter.java b/automessage-core/src/main/java/com/github/imdmk/automessage/config/CapabilityFilter.java
new file mode 100644
index 0000000..ed51ad1
--- /dev/null
+++ b/automessage-core/src/main/java/com/github/imdmk/automessage/config/CapabilityFilter.java
@@ -0,0 +1,40 @@
+package com.github.imdmk.automessage.config;
+
+import com.github.imdmk.automessage.platform.capability.Capabilities;
+import eu.okaeri.configs.schema.ConfigDeclaration;
+import eu.okaeri.configs.schema.FieldDeclaration;
+
+import java.lang.reflect.Field;
+import java.util.Map;
+
+final class CapabilityFilter {
+
+ private final Capabilities capabilities;
+
+ CapabilityFilter(Capabilities capabilities) {
+ this.capabilities = capabilities;
+ }
+
+ void apply(ConfigSection config) {
+ final ConfigDeclaration declaration = config.getDeclaration();
+ final Map fields = declaration.getFieldMap();
+
+ fields.values().removeIf(this::unsupported);
+
+ // The options are only half of the file; the comments around them are the half an
+ // administrator reads, and they document rules and triggers too.
+ declaration.setHeader(CommentDirectives.apply(declaration.getHeader(), capabilities));
+ fields.values().forEach(field ->
+ field.setComment(CommentDirectives.apply(field.getComment(), capabilities))
+ );
+
+ config.applyCapabilities(capabilities);
+ }
+
+ private boolean unsupported(FieldDeclaration declaration) {
+ final Field field = declaration.getField();
+ final RequiresCapability required = field.getAnnotation(RequiresCapability.class);
+
+ return required != null && !capabilities.supports(required.value());
+ }
+}
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/config/CommentDirectives.java b/automessage-core/src/main/java/com/github/imdmk/automessage/config/CommentDirectives.java
new file mode 100644
index 0000000..4b96b4a
--- /dev/null
+++ b/automessage-core/src/main/java/com/github/imdmk/automessage/config/CommentDirectives.java
@@ -0,0 +1,91 @@
+package com.github.imdmk.automessage.config;
+
+import com.github.imdmk.automessage.platform.capability.Capabilities;
+import com.github.imdmk.automessage.platform.capability.Capability;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+final class CommentDirectives {
+
+ private static final String REQUIRES = "@requires";
+ private static final String END = "@end";
+
+ private CommentDirectives() {
+ }
+
+ static String[] apply(String[] lines, Capabilities capabilities) {
+ if (lines == null || !hasDirective(lines)) {
+ return lines;
+ }
+
+ final List kept = new ArrayList<>(lines.length);
+
+ Capability required = null;
+
+ for (final String line : lines) {
+ final String directive = line.strip();
+
+ if (directive.startsWith(REQUIRES)) {
+ if (required != null) {
+ throw new IllegalStateException(
+ "Nested " + REQUIRES + " in a configuration comment: " + directive
+ );
+ }
+
+ required = capabilityOf(directive);
+ continue;
+ }
+
+ if (directive.equals(END)) {
+ if (required == null) {
+ throw new IllegalStateException(
+ END + " without a matching " + REQUIRES + " in a configuration comment"
+ );
+ }
+
+ required = null;
+ continue;
+ }
+
+ if (required == null || capabilities.supports(required)) {
+ kept.add(line);
+ }
+ }
+
+ if (required != null) {
+ throw new IllegalStateException(
+ REQUIRES + " " + required + " was never closed with " + END
+ );
+ }
+
+ return kept.toArray(String[]::new);
+ }
+
+ private static boolean hasDirective(String[] lines) {
+ for (final String line : lines) {
+ final String directive = line.strip();
+
+ if (directive.startsWith(REQUIRES) || directive.equals(END)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static Capability capabilityOf(String directive) {
+ final String name = directive.substring(REQUIRES.length()).strip().toUpperCase(Locale.ROOT);
+
+ try {
+ return Capability.valueOf(name);
+ } catch (IllegalArgumentException exception) {
+ // A typo here would silently keep documentation nobody can act on, which is the exact
+ // failure this class exists to prevent - so it is an error, not a fallback.
+ throw new IllegalStateException(
+ "Unknown capability '" + name + "' in a configuration comment", exception
+ );
+ }
+ }
+}
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigManager.java b/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigManager.java
index 04a2ef9..e7ec9ef 100644
--- a/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigManager.java
+++ b/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigManager.java
@@ -1,51 +1,44 @@
package com.github.imdmk.automessage.config;
+import com.github.imdmk.automessage.platform.capability.Capabilities;
import com.github.imdmk.automessage.platform.logger.PluginLogger;
-import org.jetbrains.annotations.Unmodifiable;
import java.io.File;
-import java.util.Collections;
-import java.util.List;
-import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
public final class ConfigManager {
private final Set configs = ConcurrentHashMap.newKeySet();
- private final Map, ConfigSection> byType = new ConcurrentHashMap<>();
private final File dataFolder;
private final ConfigFactory factory;
private final ConfigBinder binder;
private final ConfigLifecycle lifecycle;
+ private final CapabilityFilter capabilityFilter;
- public ConfigManager(PluginLogger logger, File dataFolder) {
+ public ConfigManager(PluginLogger logger, File dataFolder, Capabilities capabilities) {
this.dataFolder = dataFolder;
this.factory = new ConfigFactory();
this.binder = new ConfigBinder();
this.lifecycle = new ConfigLifecycle(logger);
+ this.capabilityFilter = new CapabilityFilter(capabilities);
+ }
+
+ public ConfigManager(PluginLogger logger, File dataFolder) {
+ this(logger, dataFolder, Capabilities.all());
}
public C create(Class type) {
final C config = factory.create(type);
initialize(config);
- byType.put(type, config);
return config;
}
- /**
- * Initializes a configuration that was constructed by the caller.
- *
- *
- * Language files all share one class and differ only in the file they are bound to, so there
- * is nothing to look them up by type with - the caller holds them instead.
- *
- */
public C create(C config) {
initialize(config);
return config;
@@ -55,34 +48,19 @@ private void initialize(ConfigSection config) {
final File file = new File(dataFolder, config.getFileName());
binder.bind(config, file);
+
+ // After binding, which is what builds the declaration, and before the file is written.
+ capabilityFilter.apply(config);
+
lifecycle.initialize(config);
configs.add(config);
}
- /** @return the plugin's data folder, so callers can look for files it does not know about */
public File dataFolder() {
return dataFolder;
}
- public void createAll(List> types) {
- types.forEach(this::create);
- }
-
- @SuppressWarnings("unchecked")
- public C get(Class type) {
- return (C) byType.get(type);
- }
-
- public C require(Class type) {
- final C config = get(type);
- if (config == null) {
- throw new IllegalStateException("Config not created: " + type.getName());
- }
-
- return config;
- }
-
public void loadAll() {
configs.forEach(lifecycle::load);
}
@@ -91,14 +69,8 @@ public void saveAll() {
configs.forEach(lifecycle::save);
}
- @Unmodifiable
- public Set getConfigs() {
- return Collections.unmodifiableSet(configs);
- }
-
public void clearAll() {
configs.clear();
- byType.clear();
}
}
\ No newline at end of file
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigReloadListener.java b/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigReloadListener.java
index ec40a87..622285e 100644
--- a/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigReloadListener.java
+++ b/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigReloadListener.java
@@ -1,13 +1,5 @@
package com.github.imdmk.automessage.config;
-/**
- * Notified after every successful reload of the plugin configuration.
- *
- *
- * Implement this to re-apply settings that are read once, outside of the request path —
- * for example a scheduled task whose interval is fixed at scheduling time.
- *
- */
@FunctionalInterface
public interface ConfigReloadListener {
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigReloadService.java b/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigReloadService.java
index 8497811..1b6c15b 100644
--- a/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigReloadService.java
+++ b/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigReloadService.java
@@ -3,17 +3,6 @@
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
-/**
- * Reloads every registered configuration file and lets the rest of the plugin re-apply
- * the freshly loaded values.
- *
- *
- * Reading a configuration field on every use is enough for most settings, but not for values
- * consumed once — such as the dispatcher interval, which Bukkit captures when the task is
- * scheduled. Listeners registered here close that gap, so {@code /automessage reload} applies
- * the whole configuration instead of only part of it.
- *
- */
public final class ConfigReloadService {
private final ConfigManager configManager;
@@ -27,12 +16,6 @@ public void register(ConfigReloadListener listener) {
listeners.add(listener);
}
- /**
- * Loads every configuration file from disk and notifies all listeners.
- *
- * @throws ConfigAccessException when any configuration file could not be loaded;
- * listeners are not notified in that case
- */
public void reload() {
configManager.loadAll();
listeners.forEach(ConfigReloadListener::onConfigReload);
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigRepresenter.java b/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigRepresenter.java
index 0fa0b35..7e3bd48 100644
--- a/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigRepresenter.java
+++ b/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigRepresenter.java
@@ -59,6 +59,7 @@ public Node representData(Object data) {
}
private class RepresentMap implements Represent {
+ @Override
public Node representData(Object data) {
return representMapping(Tag.MAP, (Map, ?>) data, DumperOptions.FlowStyle.BLOCK);
}
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigSection.java b/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigSection.java
index 4c0bf32..841ccaa 100644
--- a/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigSection.java
+++ b/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigSection.java
@@ -1,36 +1,15 @@
package com.github.imdmk.automessage.config;
+import com.github.imdmk.automessage.platform.capability.Capabilities;
import eu.okaeri.configs.OkaeriConfig;
import eu.okaeri.configs.serdes.OkaeriSerdesPack;
-/**
- * Abstract base class for configuration sections.
- *
- *
- * Extends {@link OkaeriConfig} to provide a reusable foundation for plugin
- * configuration sections. Subclasses are required to specify the
- * serialization/deserialization pack and the configuration file name.
- *
- *
- *
- * Supports automatic recursive loading of nested {@link ConfigSection}
- * subclasses declared as fields inside this class.
- *
- */
public abstract class ConfigSection extends OkaeriConfig {
- /**
- * Returns the {@link OkaeriSerdesPack} instance used for serializing and deserializing
- * this configuration section.
- *
- * @return non-null serialization/deserialization pack
- */
+ public void applyCapabilities(Capabilities capabilities) {
+ }
+
public abstract OkaeriSerdesPack getSerdesPack();
- /**
- * Returns the filename (including extension) used to persist this configuration section.
- *
- * @return non-null configuration file name
- */
public abstract String getFileName();
}
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/config/RequiresCapability.java b/automessage-core/src/main/java/com/github/imdmk/automessage/config/RequiresCapability.java
new file mode 100644
index 0000000..8e9ff48
--- /dev/null
+++ b/automessage-core/src/main/java/com/github/imdmk/automessage/config/RequiresCapability.java
@@ -0,0 +1,17 @@
+package com.github.imdmk.automessage.config;
+
+import com.github.imdmk.automessage.platform.capability.Capability;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface RequiresCapability {
+
+ Capability value();
+}
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/language/CommandMessages.java b/automessage-core/src/main/java/com/github/imdmk/automessage/language/CommandMessages.java
index 41380a4..c789c87 100644
--- a/automessage-core/src/main/java/com/github/imdmk/automessage/language/CommandMessages.java
+++ b/automessage-core/src/main/java/com/github/imdmk/automessage/language/CommandMessages.java
@@ -1,17 +1,9 @@
package com.github.imdmk.automessage.language;
-import com.eternalcode.multification.notice.Notice;
+import com.github.imdmk.automessage.notice.Notice;
import eu.okaeri.configs.OkaeriConfig;
import eu.okaeri.configs.annotation.Comment;
-/**
- * Everything the plugin says in reply to a command.
- *
- *
- * One flat section rather than four nested ones: there are thirteen messages in total, and a
- * translator reading the file should not have to remember which subsection a string lives in.
- *
- */
public class CommandMessages extends OkaeriConfig {
@Comment({"#", "# {PERMISSIONS} - the permissions the player is missing.", "#"})
@@ -60,14 +52,110 @@ public class CommandMessages extends OkaeriConfig {
@Comment({"#", "# /automessage view. {MESSAGE} - the message name.", "#"})
public Notice messagePreviewed = Notice.chat(
- "• Previewed scheduled message {MESSAGE}."
+ "• Previewed scheduled message {MESSAGE}."
);
public Notice messageNotFound = Notice.chat(
- "• No scheduled message named {MESSAGE} exists in scheduledMessages.yml."
+ "• No scheduled message named {MESSAGE} exists in scheduledMessages.yml."
);
public Notice viewPlayerOnly = Notice.chat(
"• Only players can preview scheduled messages."
);
+
+ @Comment({"#", "# /automessage next. {CHANNEL} - the channel, {MESSAGE} - the message name.", "#"})
+ @Comment({"#", "# /automessage next. {CHANNEL}, {MESSAGE} and {DELAY} until it fires.", "#"})
+ public Notice nextHeader = Notice.chat("» Next up on each channel");
+
+ public Notice nextEntry = Notice.chat(
+ " • {CHANNEL} · {MESSAGE} · in {DELAY}"
+ );
+
+ public Notice nextUnpredictable = Notice.chat(
+ " • {CHANNEL} · drawn at random · in {DELAY}"
+ );
+
+ public Notice nextDisabled = Notice.chat(
+ " • {CHANNEL} · disabled in config.yml"
+ );
+
+ public Notice nextEmpty = Notice.chat(
+ " • {CHANNEL} · no messages assigned"
+ );
+
+ @Comment({
+ "#",
+ "# /automessage stats. {TOTAL}, {CHANNEL}, {MESSAGE}, {COUNT} and {AGO}.",
+ "# Channels are listed apart from messages, so a name is never ambiguous: a channel",
+ "# row says when the next one is due, a message row names the channel it goes out on.",
+ "#"
+ })
+ public Notice statsHeader = Notice.chat(
+ "» AutoMessage · sent {TOTAL} since startup"
+ );
+
+ public Notice statsEmpty = Notice.chat("» AutoMessage · nothing announced yet");
+
+ public Notice statsChannelsHeader = Notice.chat("» Channels");
+
+ public Notice statsChannel = Notice.chat(
+ " • {CHANNEL} · {COUNT}x · next in {DELAY}"
+ );
+
+ public Notice statsChannelPending = Notice.chat(
+ " • {CHANNEL} · nothing yet · next in {DELAY}"
+ );
+
+ public Notice statsChannelDisabled = Notice.chat(
+ " • {CHANNEL} · {COUNT}x · "
+ + "disabled in config.yml"
+ );
+
+ public Notice statsChannelEmpty = Notice.chat(
+ " • {CHANNEL} · {COUNT}x · "
+ + "no messages assigned"
+ );
+
+ public Notice statsMessagesHeader = Notice.chat("» Messages");
+
+ public Notice statsEntry = Notice.chat(
+ " • {MESSAGE} ({CHANNEL}) · "
+ + "{COUNT}x · {AGO} ago"
+ );
+
+ @Comment({"#", "# /automessage send. {CHANNEL}, {MESSAGE} and {DELAY} until the next one.", "#"})
+ public Notice sendDone = Notice.chat(
+ "• Sent {MESSAGE} on {CHANNEL}. "
+ + "Next one in {DELAY}."
+ );
+
+ public Notice sendNobodyOnline = Notice.chat(
+ "• Nobody is online, so nothing was sent on {CHANNEL}. "
+ + "Its schedule was left alone."
+ );
+
+ public Notice sendNoMessages = Notice.chat(
+ "• Channel {CHANNEL} has no messages assigned to it."
+ );
+
+ public Notice sendDisabled = Notice.chat(
+ "• Channel {CHANNEL} is disabled in config.yml."
+ );
+
+ @Comment({"#", "# /automessage toggle. A player turning announcements off for themselves.", "#"})
+ public Notice toggleOff = Notice.chat(
+ "• Announcements are now off for you"
+ );
+
+ public Notice toggleOn = Notice.chat(
+ "• Announcements are on for you again"
+ );
+
+ public Notice togglePlayerOnly = Notice.chat(
+ "• Only players can turn announcements off"
+ );
+
+ public Notice channelNotFound = Notice.chat(
+ "• No channel named {CHANNEL} exists in config.yml."
+ );
}
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/language/LanguageCode.java b/automessage-core/src/main/java/com/github/imdmk/automessage/language/LanguageCode.java
index 4bd8648..14170e7 100644
--- a/automessage-core/src/main/java/com/github/imdmk/automessage/language/LanguageCode.java
+++ b/automessage-core/src/main/java/com/github/imdmk/automessage/language/LanguageCode.java
@@ -2,24 +2,12 @@
import java.util.Locale;
-/**
- * Normalises the several shapes a language code arrives in.
- *
- *
- * Bukkit reports {@code pl_pl}. Multification wraps that in {@code new Locale("pl_pl")}, which
- * puts the whole string in the language field - so {@link Locale#getLanguage()} answers
- * {@code pl_pl}, not {@code pl}. People write {@code pl}, {@code PL} or {@code pl-PL} in file
- * names and configuration. Everything is folded to lower case with an underscore here so the
- * lookup cannot miss over punctuation, which is exactly how the first version of this went wrong.
- *
- */
public final class LanguageCode {
private LanguageCode() {
throw new UnsupportedOperationException("This is a utility class and cannot be instantiated.");
}
- /** @return the code in lower case with an underscore separator, e.g. {@code pt_br} */
public static String normalize(String code) {
if (code == null || code.isBlank()) {
return "";
@@ -28,20 +16,13 @@ public static String normalize(String code) {
return code.trim().toLowerCase(Locale.ROOT).replace('-', '_');
}
- /**
- * Reads a code out of a {@link Locale} without trusting its accessors.
- *
- *
- * {@code getLanguage()} cannot be relied on here: for the locales Multification builds from
- * Bukkit it returns the whole {@code pl_pl} string. {@code toString()} gives the same text in
- * both cases, so it is the one to normalise.
- *
- */
+ // Built from toString(), never from getLanguage(). Bukkit reports "pl_pl" and Multification
+ // wraps it in new Locale("pl_pl"), which puts the whole string in the language field - so
+ // getLanguage() answers "pl_pl", never "pl", and every Polish player silently gets English.
public static String of(Locale locale) {
return locale == null ? "" : normalize(locale.toString());
}
- /** @return just the language part: {@code pt} for {@code pt_br} */
public static String language(String code) {
final String normalized = normalize(code);
final int separator = normalized.indexOf('_');
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/language/LanguageConfig.java b/automessage-core/src/main/java/com/github/imdmk/automessage/language/LanguageConfig.java
index 66cad46..833a2b7 100644
--- a/automessage-core/src/main/java/com/github/imdmk/automessage/language/LanguageConfig.java
+++ b/automessage-core/src/main/java/com/github/imdmk/automessage/language/LanguageConfig.java
@@ -1,8 +1,7 @@
package com.github.imdmk.automessage.language;
-import com.eternalcode.multification.notice.Notice;
-import com.eternalcode.multification.notice.resolver.NoticeResolverDefaults;
-import com.eternalcode.multification.okaeri.MultificationSerdesPack;
+import com.github.imdmk.automessage.notice.Notice;
+import com.github.imdmk.automessage.notice.NoticeSerializer;
import com.github.imdmk.automessage.config.ConfigSection;
import eu.okaeri.configs.annotation.Comment;
import eu.okaeri.configs.annotation.Header;
@@ -13,15 +12,6 @@
import java.util.List;
import java.util.Map;
-/**
- * One language: every piece of text the plugin can show, in that language.
- *
- *
- * All languages share this one class and differ only in the file they are bound to, which is what
- * makes adding a language a matter of copying a file rather than writing code. The file name is
- * therefore a property of the instance, not of the class.
- *
- */
@Header({
"# ============================================================================",
"# AutoMessage - language file",
@@ -72,24 +62,20 @@ public final class LanguageConfig extends ConfigSection {
"# announcements:",
"# vote-reminder:",
"# - \"Vote for the server!\"",
+ "@requires SOUND_NOTICE",
"# - sound: \"entity.experience_orb.pickup MASTER 1.0 1.0\"",
+ "@end",
+ "@requires TITLE_NOTICE",
"# event-announcement:",
"# - title: \"EVENT\"",
"# subtitle: \"Starting soon!\"",
+ "@end",
"#"
})
public Map> announcements = new LinkedHashMap<>();
- /**
- * File this instance is bound to, relative to the plugin folder.
- *
- *
- * Transient so okaeri does not try to write it into the file it names.
- *
- */
private transient String fileName = "lang/en.yml";
- /** Language code this file provides, lower case, e.g. {@code pl} or {@code pt_br}. */
private transient String code = "en";
public LanguageConfig() {
@@ -108,10 +94,6 @@ public String code() {
return code;
}
- /**
- * @param name message name from scheduledMessages.yml
- * @return the text for that announcement, or null when this language does not translate it
- */
@Nullable
public List announcement(String name) {
final List notices = announcements.get(name);
@@ -122,9 +104,7 @@ public List announcement(String name) {
@Override
public OkaeriSerdesPack getSerdesPack() {
- return registry -> registry.register(
- new MultificationSerdesPack(NoticeResolverDefaults.createRegistry())
- );
+ return registry -> registry.register(new NoticeSerializer());
}
@Override
diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/language/LanguageRegistry.java b/automessage-core/src/main/java/com/github/imdmk/automessage/language/LanguageRegistry.java
index 82a8022..1b63239 100644
--- a/automessage-core/src/main/java/com/github/imdmk/automessage/language/LanguageRegistry.java
+++ b/automessage-core/src/main/java/com/github/imdmk/automessage/language/LanguageRegistry.java
@@ -1,7 +1,8 @@
package com.github.imdmk.automessage.language;
-import com.eternalcode.multification.notice.Notice;
+import com.github.imdmk.automessage.notice.Notice;
import com.github.imdmk.automessage.config.ConfigManager;
+import com.github.imdmk.automessage.config.ConfigReloadListener;
import com.github.imdmk.automessage.platform.logger.PluginLogger;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
@@ -12,77 +13,82 @@
import java.util.List;
import java.util.Locale;
import java.util.Map;
+import java.util.function.Supplier;
-/**
- * Every language the server has, and the rules for choosing between them.
- *
- *