diff --git a/.dockerignore b/.dockerignore index a5dc1e7..dc6525a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,8 @@ .github .gradle build +data +*.sqlite* .idea .env .env.* diff --git a/.env.example b/.env.example index 4afde4f..4766a32 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,7 @@ YOUTUBE_OAUTH_REFRESH_TOKEN= # Optional: matching pair; set both or leave both blank. YOUTUBE_PO_TOKEN= YOUTUBE_VISITOR_DATA= + +# Optional for standalone runs; defaults to data/polls.sqlite. +# Compose uses /app/data/polls.sqlite in its persistent named volume. +POLL_DATABASE_PATH= diff --git a/.gitignore b/.gitignore index 2ee00c5..2d8cce8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,10 @@ .gradle .idea/ /build/ +/data/ +*.sqlite +*.sqlite-wal +*.sqlite-shm bin/ out/ diff --git a/Dockerfile b/Dockerfile index 391741a..8b2611d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,7 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends curl \ && rm -rf /var/lib/apt/lists/* WORKDIR /app +RUN mkdir -p /app/data && chown 10001:10001 /app/data COPY --from=builder /app/build/libs/InfinityBot-*-all.jar /app/InfinityBot.jar USER 10001:10001 HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ diff --git a/README.md b/README.md index 41b3ed1..07fcf60 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,32 @@ # InfinityBot -A Discord music bot using JDA and Lavaplayer. +A Discord music, timer, and scheduling bot using JDA and Lavaplayer. -Commands: `/play` (URL or search), `/pause`, `/skip`, `/stop`, `/queue`, `/volume` (0–100), and `/timer`. +Commands: `/play` (URL or search), `/pause`, `/skip`, `/stop`, `/queue`, `/volume` (0–100), `/timer`, and `/poll`. + +## Scheduling + +`/poll` opens a form for an event title, inclusive date range, and seven +weekday checkboxes (all checked initially; maximum 25 candidate dates). Dates +accept `M/D` or `MM-DD` with the year inferred, or explicit `YYYY-MM-DD`. +Members check the dates they can attend; unchecked dates count as unavailable. +Responses are editable, and the original message shows a live availability list, +highlighting the strongest dates. Details shows who can attend each date. + +The creator or a server administrator can finalize a date or cancel the poll. +Optional Discord event creation collects time, timezone, duration, and voice +channel/location at finalization. All results and controls live on the original +poll message; deleting it makes the poll inaccessible. `/poll` defaults to +Administrator access, delegatable through Discord command permissions; members +vote through the message buttons. + +Polls persist in SQLite across restarts. Compose mounts a persistent `poll-data` +volume; do not delete it when updating. Poll messages need View Channel, Send +Messages, and Embed Links. Optional events need Create Events, plus View Channel +and Connect for voice events. No additional privileged intents are needed. + +See [scheduling details and live-test instructions](docs/scheduling.md), including +`./scripts/run-local.sh` for a temporary bot test without Docker. ## Run (with yt-cipher) @@ -27,6 +51,7 @@ Images: `ghcr.io/nextinfinity/infinitybot:main` (development), release tags, `sh | Environment variable | Purpose | | --- | --- | | `DISCORD_BOT_TOKEN` | Required Discord bot token. | +| `POLL_DATABASE_PATH` | SQLite poll database; standalone default `data/polls.sqlite`, Compose uses `/app/data/polls.sqlite` in a named volume. | | `YOUTUBE_REMOTE_CIPHER_URL` | Cipher base URL; Compose sets `http://yt-cipher:8001`. | | `YOUTUBE_REMOTE_CIPHER_PASSWORD` | Optional cipher API password; required by the provided Compose stack. | | `YOUTUBE_OAUTH_REFRESH_TOKEN` | Optional YouTube OAuth **refresh token**; enables the TV playback fallback. | diff --git a/TESTING.md b/TESTING.md index 2e41606..a3630b5 100644 --- a/TESTING.md +++ b/TESTING.md @@ -24,6 +24,17 @@ JUnit Jupiter and Mockito test our decisions, not the external services: non-mentionable roles). Scheduled notices and completion callbacks are captured without waiting or contacting Discord. +- Scheduling tests (`polls/`): shorthand date/year inference and rollover, + weekday checkbox defaults, inclusive/filter boundaries, 25-date limits, + availability counts and ranking, vote replacement/withdrawal, closed-poll + rejection, permissions/guild/channel isolation, and modal acknowledgement. + Temporary real SQLite databases cover persistence, concurrent voters, + finalization/cancellation, event claims and crash recovery. Mocked Discord + callbacks cover event creation, failure, reconciliation, and duplicate-request + prevention. A manually driven scheduler checks batched/in-flight message + updates; UI boundary tests check checkbox grouping at 10/20-date boundaries, + maximum-size forms, preselected answers, and paginated details. + Tests inject the loader and activity callback, manually deliver load/playback callbacks, and parse configuration from a map. They never construct a connected bot or authenticate with YouTube. Mockito is supplied as an explicit test JVM @@ -48,3 +59,10 @@ short `/timer` with updates, a mention, and a voice-return destination; verify notice delivery and voice movement with the bot's actual channel permissions. YouTube/IP, cipher, and voice transport issues belong to this operational smoke check, not the deterministic Gradle suite. + +For scheduling, follow the [temporary-bot live-test checklist](docs/scheduling.md#small-live-test-no-docker-required). +`./scripts/run-local.sh` runs the suite, builds a shaded jar, and prompts privately +for a temporary bot token. Use a distinct application so command registration and +live interactions do not interfere with the production bot. Verify forms on both +desktop and mobile: Discord client rendering and real API permission enforcement +are intentionally outside the unit tests. diff --git a/build.gradle b/build.gradle index c917ac1..8a232d2 100644 --- a/build.gradle +++ b/build.gradle @@ -46,7 +46,7 @@ configurations { tasks.named('test', Test) { useJUnitPlatform() - jvmArgs "-javaagent:${configurations.mockitoAgent.singleFile}" + jvmArgs "-javaagent:${configurations.mockitoAgent.singleFile}", '--enable-native-access=ALL-UNNAMED' } dependencies { @@ -77,6 +77,7 @@ dependencies { } } + runtimeOnly 'org.xerial:sqlite-jdbc:3.53.4.0' implementation 'net.dv8tion:JDA:6.7.0' implementation 'club.minnced:jdave-api:0.1.8' runtimeOnly 'club.minnced:jdave-native-linux-x86-64:0.1.8' diff --git a/compose.yaml b/compose.yaml index 8ff8074..162bb45 100644 --- a/compose.yaml +++ b/compose.yaml @@ -4,11 +4,14 @@ services: restart: unless-stopped environment: DISCORD_BOT_TOKEN: ${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env} + POLL_DATABASE_PATH: /app/data/polls.sqlite YOUTUBE_REMOTE_CIPHER_URL: http://yt-cipher:8001 YOUTUBE_OAUTH_REFRESH_TOKEN: ${YOUTUBE_OAUTH_REFRESH_TOKEN:-} YOUTUBE_PO_TOKEN: ${YOUTUBE_PO_TOKEN:-} YOUTUBE_VISITOR_DATA: ${YOUTUBE_VISITOR_DATA:-} YOUTUBE_REMOTE_CIPHER_PASSWORD: ${YOUTUBE_REMOTE_CIPHER_PASSWORD:?Set YOUTUBE_REMOTE_CIPHER_PASSWORD in .env} + volumes: + - poll-data:/app/data depends_on: - yt-cipher @@ -19,3 +22,6 @@ services: API_TOKEN: ${YOUTUBE_REMOTE_CIPHER_PASSWORD:?Set YOUTUBE_REMOTE_CIPHER_PASSWORD in .env} OVERRIDE_PLAYER_VARIANT: IAS # No host port: only services on the Compose network can reach the API. + +volumes: + poll-data: diff --git a/docs/scheduling.md b/docs/scheduling.md new file mode 100644 index 0000000..56631be --- /dev/null +++ b/docs/scheduling.md @@ -0,0 +1,166 @@ +# Scheduling polls + +## Member and admin flow + +1. An admin runs `/poll` in a standard server text channel. A private form + asks for a title, start date, inclusive end date, and seven weekday checkboxes + (Monday–Sunday), all checked by default. Uncheck weekdays to exclude; keep at + least one checked. For weekends only, leave Saturday and Sunday checked. + The resulting poll is public in that channel. There can be at most **25 + candidate dates**, within a range of at most 90 days. + Dates accept `M/D` or `MM-DD` without a year: start resolves to its next + occurrence on or after today **in UTC**, and end resolves on or after start. + For example, `12/28`–`1/3` crosses into the following year. `YYYY-MM-DD` still + works and preserves the explicit year. February 29 must be valid in the inferred + year; otherwise specify an explicit leap year. Resolved years appear in the + published poll. Availability is date-only: members should agree separately + on the usual time. +2. Members click **Respond / Edit**. A private form has checkboxes for the dates + they can attend. On submission, unchecked dates count as unavailable; leaving + everything unchecked explicitly records that no date works. Existing answers + are checked when reopening the form. Discord limits each checkbox group to 10 + dates, so longer polls use up to three groups in one form, submitted together. +3. The original message updates with a chronological availability list such as + **Sat, Jun 6, 2026 — 4 of 6 available**. Stars and bold dates highlight the most + available responses, preserving ties. **Details** displays who is available or + unavailable on a selected date, in pages of 15 members. **Withdraw** removes + your entire response. Members who have not submitted a response (or withdrew + it) do not count as unavailable and are not listed in Details. +4. The creator or a server administrator clicks **Manage**, chooses a candidate + date, and confirms **Finalize date only** or **Finalize + create event**. This + locks voting permanently. **Cancel poll** closes without choosing a date; + cancellation also requires confirmation. Closed polls retain results/details. + +All results and controls are accessed from the original poll message. There is +no results lookup command or visible poll UUID. If the message is deleted, the +poll is inaccessible in Discord; create a new poll if needed. Original-message +updates are batched briefly and serialized per poll, so simultaneous responses +cannot make an older edit overwrite newer results. + +`/poll` defaults to **Administrator** access. Server admins can delegate the +command through Discord's command permissions, just as with `/timer`; delegated +members can create polls and manage their own polls. Voting/details buttons are +available to all members who can view the poll's original channel. Management +buttons and submitted forms independently enforce creator/admin access. All +lookups and interactions enforce the original guild and channel visibility. +If the original channel is deleted, the poll can no longer be accessed in Discord. + +Creation in DMs, threads, forums, and announcement channels is not supported in +v1. There are no reminders, automatic closing deadlines, reopening, individual +candidate-date exclusions, or automatic winner selection. + +## Optional Discord scheduled events + +Event creation is optional at finalization, or later through **Manage → Create / +retry event**. The form asks for: + +- A start time in 24-hour `HH:mm` format. +- An IANA timezone such as `America/New_York`, `Europe/London`, or `UTC`. +- A duration in minutes (1–10080; default 180), supplying the event end time. +- A voice channel ID **from this server**, or an external URL/location. Enable + Discord Developer Mode and right-click the voice channel to copy its ID. + A digits-only entry is interpreted as a voice channel ID; stage events are not + supported. Any other entry becomes the external location. + +The chosen date is interpreted in that timezone. The start must be in the future. +Skipped and ambiguous daylight-saving times are rejected rather than guessed; +choose another local time or express the time in UTC. Invalid form values do not +finalize the poll. Once valid values are submitted, finalization is saved before +attempting event creation. Permission/API failures leave that chosen date intact. +Time, timezone, duration, and location inputs are not saved for future retries; +reenter them when retrying. + +The bot needs **Create Events** permission. For voice events it also needs **View +Channel** and **Connect** in the target voice channel. It doesn't join voice or +subscribe voters to the event. Discord's normal event RSVP remains separate from +poll availability. + +Event creation is claimed in storage to prevent simultaneous duplicate requests. +Retries first retrieve server scheduled events and look for this bot's event with +an exact poll marker at the end of its description. Interrupted claims become +retryable on restart. Leave the marker intact so reconciliation works after an +ambiguous network failure. This is best-effort reconciliation, not a distributed +transaction: manually editing/removing the marker or cancelling an unrecorded +event can defeat it. Successfully recorded events aren't recreated automatically +if someone later deletes them. + +## Persistence and deployment + +SQLite stores poll metadata, candidate dates, user IDs, responses, chosen dates, +and event state. The default standalone path is `data/polls.sqlite`, configurable +with `POLL_DATABASE_PATH`. The parent directory is created on startup. Database +open failures stop startup rather than silently discarding persistence. + +Compose uses `/app/data/polls.sqlite` in the `poll-data` named volume. The image +prepares this directory for its non-root UID/GID 10001. With a bind mount instead, +ensure that UID can write the directory. **Do not use `docker compose down -v` +unless you intend to delete saved polls.** Keep backups; for a simple consistent +backup, stop the bot and copy the data directory (including SQLite's sidecars). +Run only one bot process against a database. Do not copy production member +availability into a temporary development bot unnecessarily. + +Poll updates edit messages through the normal channel API, not expiring +interaction hooks. Persisted message IDs/components continue to work after a +restart. Startup refreshes known poll messages; deleted messages or lost bot +permissions are logged. Restore permissions and restart to refresh messages +that still exist; deleted messages are not recreated. Polls are not automatically +purged: operators should treat the database and backups as member-availability data. + +The published GHCR images don't contain this feature until this branch is merged +and a new image is built/published. To test this branch, use the standalone script +below or build your own image rather than pulling the current release. + +## Small live test (no Docker required) + +Use a **separate temporary Discord application**, not the production bot. Startup +replaces the application's slash command definitions; a separate app also avoids +two processes competing for production interactions. + +1. In the Discord Developer Portal, create an application and bot; obtain its + token. Leave privileged gateway intents disabled. Use a **server/Guild + installation**, with `bot` and `applications.commands` scopes. +2. Invite it to a small test server. Grant **View Channel**, **Send Messages**, + **Embed Links**, and **Create Events**. Add **View Channel/Connect** in a test + voice channel if testing voice events. Bot Administrator is not necessary. +3. Run from this checkout: + + ```sh + ./scripts/run-local.sh + ``` + + This runs tests and builds `build/libs/InfinityBot-local-smoke-all.jar`, then + asks for the token using a **hidden terminal prompt**. The token isn't written + to a file or placed in shell history; do not paste it into chat. An already + exported `DISCORD_BOT_TOKEN` is also supported. A JDK 25 installation is needed; + set `JAVA_HOME` if necessary. On the prepared local machine, the script finds + `$HOME/.cache/infinitybot-jdk25/Contents/Home` automatically. + + Polling needs no YouTube credentials or cipher service. Music playback still + has its usual requirements. The test database defaults to + `data/live-test.sqlite`, isolated from `data/polls.sqlite`. Ctrl-C stops the + process. Rerun the script with the same token/database for the restart check. + Global slash commands may take a little time to appear; refresh Discord if + necessary. If port 8080 is already occupied, stop the other local bot first. + +4. Test with a future date range in a normal text channel: + - Create an all-days poll, a weekends-only poll, and a custom weekday poll. + Check checkbox defaults, inclusive boundaries, shorthand dates/year rollover, + and explicit-year dates. + - Check available dates; verify the live availability list and strongest dates. + - Reopen/edit, uncheck every date, and withdraw. Try a poll with more than 10 + dates to verify all checkbox groups save together. + - Use a second member for totals, visible Details, and denied management. + - Make two polls and verify responses stay attached to the correct message. + - Restart the bot; verify previous votes and old buttons still work. + - Finalize one poll without an event; verify voting is disabled, including a + voting form opened before closing. Cancel another poll. + - Finalize with an external event, then a voice event. Verify date/time/timezone + in Discord, end time, title/location, event link, and no duplicate creation. + - Remove Create Events, finalize another poll with event creation, verify the + chosen date remains, restore permission, and retry via Manage. + - Test a phone as well as desktop, especially unchecked/grouped checkbox values. + - If desired, delete a poll message and verify there is no recovery/lookup + command; a new poll must be created. + +5. Stop the temporary bot and revoke/reset its token when finished. Remove the + test bot from the server and delete its test database if no longer needed. diff --git a/scripts/run-local.sh b/scripts/run-local.sh new file mode 100755 index 0000000..0967f7d --- /dev/null +++ b/scripts/run-local.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Standalone polling smoke test; no Docker, cipher service, or stored token required. +set -euo pipefail +cd "$(dirname "$0")/.." + +if [[ -z "${JAVA_HOME:-}" && -x "$HOME/.cache/infinitybot-jdk25/Contents/Home/bin/java" ]]; then + export JAVA_HOME="$HOME/.cache/infinitybot-jdk25/Contents/Home" +fi +if [[ -n "${JAVA_HOME:-}" ]]; then + export PATH="$JAVA_HOME/bin:$PATH" +fi +if ! command -v java >/dev/null || ! java -version >/dev/null 2>&1; then + echo 'JDK 25 is required. Install it and set JAVA_HOME.' >&2 + exit 1 +fi + +# Build before accepting the secret, so Gradle never needs to inherit the token. +TOKEN="${DISCORD_BOT_TOKEN:-}" +export -n TOKEN +unset DISCORD_BOT_TOKEN +sh ./gradlew --no-daemon -PappVersion=local-smoke check shadowJar +if [[ -z "$TOKEN" ]]; then + if [[ ! -t 0 ]]; then + echo 'Run interactively to enter a token, or set DISCORD_BOT_TOKEN.' >&2 + exit 1 + fi + IFS= read -r -s -p 'Temporary Discord bot token (hidden): ' TOKEN + printf '\n' +fi +if [[ -z "$TOKEN" ]]; then + echo 'No token entered.' >&2 + exit 1 +fi +export DISCORD_BOT_TOKEN="$TOKEN" +unset TOKEN +# Isolate live-test data from a normal standalone deployment. +export POLL_DATABASE_PATH="${POLL_DATABASE_PATH:-data/live-test.sqlite}" +echo 'Starting bot. Ctrl-C to stop. Do not share the token or delete the database between restart tests.' +exec java --enable-native-access=ALL-UNNAMED -jar build/libs/InfinityBot-local-smoke-all.jar diff --git a/src/main/java/net/theinfinitymc/infinitybot/CommandListener.java b/src/main/java/net/theinfinitymc/infinitybot/CommandListener.java index 9513027..028871e 100644 --- a/src/main/java/net/theinfinitymc/infinitybot/CommandListener.java +++ b/src/main/java/net/theinfinitymc/infinitybot/CommandListener.java @@ -6,6 +6,7 @@ import net.dv8tion.jda.api.interactions.commands.build.Commands; import net.dv8tion.jda.api.requests.restaction.CommandListUpdateAction; import org.reflections.Reflections; +import net.theinfinitymc.infinitybot.polls.PollListener; import java.util.HashMap; import java.util.Map; @@ -24,7 +25,8 @@ public void registerCommands(JDA jda, AudioManager audioManager) { Reflections reflections = new Reflections("net.theinfinitymc.infinitybot"); Set> commandClasses = reflections.get(SubTypes.of(Command.class).asClass()); - CommandListUpdateAction commandListUpdateAction = jda.updateCommands(); + // Polls acknowledge their own interactions so /poll can open a modal immediately. + CommandListUpdateAction commandListUpdateAction = jda.updateCommands().addCommands(PollListener.commandData()); for (Class commandClass : commandClasses) { try { diff --git a/src/main/java/net/theinfinitymc/infinitybot/InfinityBot.java b/src/main/java/net/theinfinitymc/infinitybot/InfinityBot.java index 81bd00f..46d374f 100644 --- a/src/main/java/net/theinfinitymc/infinitybot/InfinityBot.java +++ b/src/main/java/net/theinfinitymc/infinitybot/InfinityBot.java @@ -8,6 +8,9 @@ import net.dv8tion.jda.api.entities.Activity; import net.dv8tion.jda.api.requests.GatewayIntent; +import net.theinfinitymc.infinitybot.polls.PollListener; + +import java.nio.file.Path; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -28,11 +31,14 @@ public static void main(String[] args) { // Validate YouTube configuration before opening the Discord connection. this.audioManager = new AudioManager(); CommandListener listener = new CommandListener(); + String pollDatabase = System.getenv("POLL_DATABASE_PATH"); + PollListener polls = new PollListener(Path.of(pollDatabase == null || pollDatabase.isBlank() + ? "data/polls.sqlite" : pollDatabase)); this.jda = JDABuilder.createDefault(System.getenv("DISCORD_BOT_TOKEN"), Collections.singletonList(GatewayIntent.GUILD_VOICE_STATES)) .setAudioModuleConfig(new AudioModuleConfig() .withDaveSessionFactory(new JDaveSessionFactory())) - .addEventListeners(listener) + .addEventListeners(listener, polls) .build(); listener.registerCommands(jda, audioManager); updateActivity(); diff --git a/src/main/java/net/theinfinitymc/infinitybot/polls/Poll.java b/src/main/java/net/theinfinitymc/infinitybot/polls/Poll.java new file mode 100644 index 0000000..def7a77 --- /dev/null +++ b/src/main/java/net/theinfinitymc/infinitybot/polls/Poll.java @@ -0,0 +1,61 @@ +package net.theinfinitymc.infinitybot.polls; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.util.*; + +/** Immutable snapshots: unanswered members are not votes, and omitted dates in a saved vote are No. */ +public record Poll(String id, String guildId, String channelId, String messageId, String creatorId, + String title, List dates, Status status, LocalDate chosenDate, + String eventId, String eventState, Map votes) { + public enum Status { OPEN, FINALIZED, CANCELLED } + public enum Answer { YES, NO } + public record Vote(Set yes) { + public Vote { yes = Set.copyOf(yes); } + public Answer answer(LocalDate date) { return yes.contains(date) ? Answer.YES : Answer.NO; } + } + public record Counts(int yes, int no) {} + + public Poll { + dates = List.copyOf(dates); + votes = Map.copyOf(votes); + } + + public static List candidateDates(LocalDate start, LocalDate end, Set allowedDays) { + Set days = Set.copyOf(allowedDays); + if (days.isEmpty()) throw new IllegalArgumentException("Select at least one allowed weekday."); + if (end.isBefore(start)) throw new IllegalArgumentException("End date must be on or after start date."); + // Bound iteration even for accidentally entered years or sparse weekday selections. + if (end.toEpochDay() - start.toEpochDay() > 90) + throw new IllegalArgumentException("The date range must be at most 90 days."); + List dates = start.datesUntil(end.plusDays(1)) + .filter(d -> days.contains(d.getDayOfWeek())) + .toList(); + if (dates.isEmpty()) throw new IllegalArgumentException("That range contains no selectable dates."); + if (dates.size() > 25) throw new IllegalArgumentException("A poll can contain at most 25 candidate dates. Shorten the range or select fewer weekdays."); + return dates; + } + + public Counts counts(LocalDate date) { + int yes = 0; + for (Vote vote : votes.values()) if (vote.answer(date) == Answer.YES) yes++; + return new Counts(yes, votes.size() - yes); + } + + public Set leadingDates() { + if (votes.isEmpty()) return Set.of(); + int best = dates.stream().mapToInt(date -> counts(date).yes()).max().orElseThrow(); + if (best == 0) return Set.of(); + Set result = new HashSet<>(); + for (LocalDate date : dates) if (counts(date).yes() == best) result.add(date); + return Set.copyOf(result); + } + + public boolean canManage(String userId, boolean administrator) { + return administrator || creatorId.equals(userId); + } + + public String messageUrl() { + return messageId == null ? "" : "https://discord.com/channels/" + guildId + "/" + channelId + "/" + messageId; + } +} diff --git a/src/main/java/net/theinfinitymc/infinitybot/polls/PollDates.java b/src/main/java/net/theinfinitymc/infinitybot/polls/PollDates.java new file mode 100644 index 0000000..782dece --- /dev/null +++ b/src/main/java/net/theinfinitymc/infinitybot/polls/PollDates.java @@ -0,0 +1,25 @@ +package net.theinfinitymc.infinitybot.polls; + +import java.time.*; + +/** Year inference is for creation inputs only; persisted/component dates remain strict ISO. */ +final class PollDates { + private PollDates() {} + + static LocalDate resolve(String input, LocalDate onOrAfter) { + input = input.strip(); + if (input.matches("\\d{4}-\\d{2}-\\d{2}")) return PollListener.parseDate(input); + if (!input.matches("\\d{1,2}[-/]\\d{1,2}")) + throw new IllegalArgumentException("Use M/D, MM-DD, or an explicit YYYY-MM-DD date."); + try { + String[] parts = input.split("[-/]"); + MonthDay day = MonthDay.of(Integer.parseInt(parts[0]), Integer.parseInt(parts[1])); + int year = onOrAfter.getYear() + (day.compareTo(MonthDay.from(onOrAfter)) < 0 ? 1 : 0); + if (!day.isValidYear(year)) + throw new IllegalArgumentException("February 29 is not valid in the inferred year " + year + ". Enter an explicit YYYY-MM-DD date."); + return day.atYear(year); + } catch (DateTimeException e) { + throw new IllegalArgumentException("Invalid month/day. Use M/D, MM-DD, or YYYY-MM-DD."); + } + } +} diff --git a/src/main/java/net/theinfinitymc/infinitybot/polls/PollEventPlan.java b/src/main/java/net/theinfinitymc/infinitybot/polls/PollEventPlan.java new file mode 100644 index 0000000..3c2c20c --- /dev/null +++ b/src/main/java/net/theinfinitymc/infinitybot/polls/PollEventPlan.java @@ -0,0 +1,33 @@ +package net.theinfinitymc.infinitybot.polls; + +import java.time.*; +import java.time.format.DateTimeParseException; +import java.util.List; + +/** Time exists only at event creation; availability itself stays date-only. */ +public record PollEventPlan(OffsetDateTime start, OffsetDateTime end, String location) { + public static PollEventPlan parse(LocalDate date, String time, String zone, String duration, + String location, Clock clock) { + try { + if (!time.matches("\\d{2}:\\d{2}")) throw new IllegalArgumentException("Use 24-hour HH:mm for the start time."); + LocalDateTime local = date.atTime(LocalTime.parse(time)); + ZoneId timezone = ZoneId.of(zone.strip()); + List offsets = timezone.getRules().getValidOffsets(local); + if (offsets.size() != 1) + throw new IllegalArgumentException("That local time is skipped or ambiguous due to daylight saving time. Choose another time or use UTC."); + OffsetDateTime start = local.atOffset(offsets.getFirst()); + int minutes = Integer.parseInt(duration.strip()); + if (minutes < 1 || minutes > 10080) throw new IllegalArgumentException("Duration must be 1–10080 minutes."); + if (!start.toInstant().isAfter(clock.instant())) throw new IllegalArgumentException("Discord events must start in the future."); + location = location.strip(); + if (location.isEmpty() || location.length() > 100) throw new IllegalArgumentException("Provide a voice channel ID or an external location (1–100 characters)."); + return new PollEventPlan(start, start.plusMinutes(minutes), location); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException("Invalid time. Use 24-hour HH:mm."); + } catch (DateTimeException e) { + throw new IllegalArgumentException("Invalid timezone. Use an IANA name such as America/New_York or UTC."); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Duration must be a whole number of minutes."); + } + } +} diff --git a/src/main/java/net/theinfinitymc/infinitybot/polls/PollListener.java b/src/main/java/net/theinfinitymc/infinitybot/polls/PollListener.java new file mode 100644 index 0000000..f5214f8 --- /dev/null +++ b/src/main/java/net/theinfinitymc/infinitybot/polls/PollListener.java @@ -0,0 +1,313 @@ +package net.theinfinitymc.infinitybot.polls; + +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.buttons.Button; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.channel.ChannelType; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent; +import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent; +import net.dv8tion.jda.api.events.session.ReadyEvent; +import net.dv8tion.jda.api.events.session.ShutdownEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import net.dv8tion.jda.api.interactions.Interaction; +import net.dv8tion.jda.api.interactions.InteractionContextType; +import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback; +import net.dv8tion.jda.api.interactions.commands.DefaultMemberPermissions; +import net.dv8tion.jda.api.interactions.commands.build.Commands; +import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData; +import net.dv8tion.jda.api.requests.restaction.ScheduledEventAction; +import net.dv8tion.jda.api.utils.messages.MessageEditData; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.file.Path; +import java.time.*; +import java.time.format.DateTimeParseException; +import java.util.*; +import java.util.stream.Collectors; + +/** Owns poll interactions; music/timer commands retain their existing acknowledgement behavior. */ +public final class PollListener extends ListenerAdapter implements AutoCloseable { + private static final Logger LOG = LoggerFactory.getLogger(PollListener.class); + private final PollStore store; + private final PollUpdates updates; + private volatile JDA jda; + + public PollListener(Path database) { this(new PollStore(database)); } + + PollListener(PollStore store) { + this.store = store; + updates = new PollUpdates(store, (poll, done) -> { + TextChannel channel = jda == null ? null : jda.getTextChannelById(poll.channelId()); + if (channel == null || poll.messageId() == null) { done.run(); return; } + channel.editMessageById(poll.messageId(), MessageEditData.fromCreateData(PollUi.summary(poll))) + .queue(message -> done.run(), error -> { + LOG.warn("Cannot edit poll {}. Restore permissions and restart if the original message still exists.", poll.id(), error); + done.run(); + }); + }); + } + + public static SlashCommandData commandData() { + return Commands.slash("poll", "Create a date-only availability poll in this text channel") + .setContexts(InteractionContextType.GUILD) + .setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.ADMINISTRATOR)); + } + + @Override public void onReady(ReadyEvent event) { + jda = event.getJDA(); + for (String id : store.publishedIds()) updates.request(id); + } + + @Override public void onShutdown(ShutdownEvent event) { close(); } + + @Override public void close() { + updates.close(); + store.close(); + } + + private void refresh(String id) { updates.request(id); } + + @Override public void onSlashCommandInteraction(SlashCommandInteractionEvent event) { + if (!event.getName().equals("poll")) return; + guard(event, () -> { + creationChannel(event); + event.replyModal(PollUi.createModal()).queue(); + }); + } + + @Override public void onButtonInteraction(ButtonInteractionEvent event) { + if (!event.getComponentId().startsWith("poll:")) return; + guard(event, () -> { + String[] parts = event.getComponentId().split(":"); + String action = parts[1]; + Poll poll = accessible(event, parts[2]); + if (Set.of("manage", "cancelconfirm", "cancel", "finalize", "eventform").contains(action)) authorize(event, poll); + switch (action) { + case "respond" -> { + requireOpen(poll); + event.replyModal(PollUi.voteModal(poll, event.getUser().getId())).queue(); + } + case "eventform" -> { + LocalDate date = parseDate(parts[3]); + if (!poll.dates().contains(date) || poll.status() == Poll.Status.CANCELLED + || (poll.status() == Poll.Status.FINALIZED && !date.equals(poll.chosenDate()))) + throw new IllegalArgumentException("That final date is no longer valid."); + if (poll.eventId() != null || poll.eventState().equals("CREATING")) + throw new IllegalArgumentException("An event already exists or creation is in progress."); + event.replyModal(PollUi.eventModal(poll, date)).queue(); + } + case "withdraw" -> { + event.deferReply(true).queue(); + store.withdraw(poll.id(), event.getUser().getId()); + refresh(poll.id()); + tell(event, "Your response was withdrawn. You are now counted as not responded."); + } + case "details" -> event.reply(PollUi.detailsPicker(poll)).setEphemeral(true).queue(); + case "page" -> event.editMessage(MessageEditData.fromCreateData( + PollUi.details(poll, parseDate(parts[3]), Integer.parseInt(parts[4])))).queue(); + case "manage" -> event.reply(PollUi.manage(poll)).setEphemeral(true).queue(); + case "cancelconfirm" -> event.editMessage(MessageEditData.fromCreateData(PollUi.message( + "Cancel **" + PollUi.safe(poll.title()) + "**? This closes voting without selecting a date.", + ActionRow.of(Button.danger(PollUi.id("cancel", poll.id()), "Confirm cancellation")) + ))).queue(); + case "cancel" -> { + event.deferReply(true).queue(); + store.cancel(poll.id()); + refresh(poll.id()); + tell(event, "Poll cancelled."); + } + case "finalize" -> { + event.deferReply(true).queue(); + LocalDate date = parseDate(parts[3]); + store.finalizePoll(poll.id(), date); + refresh(poll.id()); + tell(event, "Finalized for **" + PollUi.day(date) + "**. Voting is closed. You can create an event later through Manage."); + } + default -> throw new IllegalArgumentException("Unknown poll control."); + } + }); + } + + @Override public void onStringSelectInteraction(StringSelectInteractionEvent event) { + if (!event.getComponentId().startsWith("poll:")) return; + guard(event, () -> { + String[] parts = event.getComponentId().split(":"); + Poll poll = accessible(event, parts[2]); + LocalDate date = parseDate(event.getValues().getFirst()); + if (parts[1].equals("finaldate")) { + authorize(event, poll); + event.editMessage(MessageEditData.fromCreateData(PollUi.confirm(poll, date))).queue(); + } else { + event.editMessage(MessageEditData.fromCreateData(PollUi.details(poll, date, 0))).queue(); + } + }); + } + + @Override public void onModalInteraction(ModalInteractionEvent event) { + if (!event.getModalId().startsWith("poll:")) return; + event.deferReply(true).queue(); + guard(event, () -> { + if (event.getModalId().equals("poll:create")) { + TextChannel channel = creationChannel(event); + LocalDate start = PollDates.resolve(value(event, "start"), LocalDate.now(Clock.systemUTC())); + LocalDate end = PollDates.resolve(value(event, "end"), start); + Poll poll = store.create(event.getGuild().getId(), channel.getId(), event.getUser().getId(), value(event, "title"), + start, end, allowedDays(event)); + channel.sendMessage(PollUi.summary(poll)).queue(message -> guard(event, () -> { + store.bindMessage(poll.id(), message.getId()); + refresh(poll.id()); + tell(event, "Poll created: " + store.get(poll.id()).messageUrl()); + }), error -> { + store.deleteUnpublished(poll.id()); + LOG.warn("Cannot publish poll {}", poll.id(), error); + tell(event, "Could not post the poll. Check my View Channel, Send Messages, and Embed Links permissions and try again."); + }); + return; + } + String[] parts = event.getModalId().split(":"); + Poll poll = accessible(event, parts[2]); + switch (parts[1]) { + case "vote" -> { + Set available = new HashSet<>(); + for (int offset = 0; offset < poll.dates().size(); offset += 10) + available.addAll(selectedDates(event, "available" + offset / 10)); + store.vote(poll.id(), event.getUser().getId(), available); + refresh(poll.id()); + tell(event, "Availability saved. Unchecked dates count as unavailable. Use Respond / Edit to change it."); + } + case "event" -> { + authorize(event, poll); + LocalDate date = parseDate(parts[3]); + PollEventPlan plan = PollEventPlan.parse(date, value(event, "time"), value(event, "zone"), + value(event, "duration"), value(event, "location"), Clock.systemUTC()); + store.finalizePoll(poll.id(), date); + refresh(poll.id()); + createEvent(event, store.get(poll.id()), plan); + } + default -> throw new IllegalArgumentException("Unknown poll form."); + } + }); + } + + private void createEvent(IReplyCallback interaction, Poll poll, PollEventPlan plan) { + Guild guild = interaction.getGuild(); + VoiceChannel voice = plan.location().matches("\\d+") ? guild.getVoiceChannelById(plan.location()) : null; + if (plan.location().matches("\\d+") && voice == null) { + tell(interaction, "Date finalized, but that voice channel ID was not found in this server. Use Manage to retry."); + return; + } + if (!guild.getSelfMember().hasPermission(Permission.CREATE_SCHEDULED_EVENTS)) { + tell(interaction, "Date finalized, but I need Create Events permission. Grant it and use Manage to retry."); + return; + } + if (voice != null && !guild.getSelfMember().hasPermission(voice, Permission.VIEW_CHANNEL, Permission.VOICE_CONNECT)) { + tell(interaction, "Date finalized, but I need View Channel and Connect in the event's voice channel. Use Manage to retry."); + return; + } + if (!store.claimEvent(poll.id())) { + tell(interaction, "Date finalized. An event already exists or creation is in progress; check the poll message."); + return; + } + refresh(poll.id()); + String marker = "[InfinityBot poll: " + poll.id() + "]"; + // Reconcile before creation, including after a crash or an ambiguous network failure. + try { + guild.retrieveScheduledEvents().queue(events -> { + try { + var existing = events.stream().filter(e -> e.getDescription() != null && e.getDescription().endsWith(marker) + && interaction.getJDA().getSelfUser().getId().equals(e.getCreatorId())).findFirst(); + if (existing.isPresent()) { + eventCreated(interaction, poll, existing.get().getId()); + return; + } + ScheduledEventAction action = voice == null + ? guild.createScheduledEvent(poll.title(), plan.location(), plan.start(), plan.end()) + : guild.createScheduledEvent(poll.title(), voice, plan.start()).setEndTime(plan.end()); + action.setDescription("Scheduled from an InfinityBot availability poll.\n" + poll.messageUrl() + "\n" + marker) + .queue(created -> guard(interaction, () -> eventCreated(interaction, poll, created.getId())), + error -> eventFailed(interaction, poll, error)); + } catch (Exception error) { eventFailed(interaction, poll, error); } + }, error -> eventFailed(interaction, poll, error)); + } catch (Exception error) { eventFailed(interaction, poll, error); } + } + + private void eventCreated(IReplyCallback interaction, Poll poll, String eventId) { + store.eventCreated(poll.id(), eventId); + refresh(poll.id()); + tell(interaction, "Date finalized. Discord event: https://discord.com/events/" + poll.guildId() + "/" + eventId); + } + + private void eventFailed(IReplyCallback interaction, Poll poll, Throwable error) { + LOG.warn("Event creation was not confirmed for poll {}", poll.id(), error); + store.eventFailed(poll.id()); + refresh(poll.id()); + tell(interaction, "The date is finalized, but event creation was not confirmed. Check my event/channel permissions, then use Manage → Create / retry event. Existing matching events are checked before retrying."); + } + + private static TextChannel creationChannel(Interaction event) { + if (!event.isFromGuild() || event.getChannelType() != ChannelType.TEXT) + throw new IllegalArgumentException("Create polls in a server's standard text channel (not a DM, thread, or forum)."); + TextChannel channel = event.getGuild().getTextChannelById(event.getChannelId()); + if (channel == null || !event.getGuild().getSelfMember().hasPermission(channel, + Permission.VIEW_CHANNEL, Permission.MESSAGE_SEND, Permission.MESSAGE_EMBED_LINKS)) + throw new IllegalArgumentException("I need View Channel, Send Messages, and Embed Links in this channel."); + return channel; + } + + Poll accessible(Interaction event, String id) { + Poll poll = store.get(id); + if (!event.isFromGuild() || !poll.guildId().equals(event.getGuild().getId())) + throw new IllegalArgumentException("That poll is not in this server."); + TextChannel channel = event.getGuild().getTextChannelById(poll.channelId()); + if (channel == null || event.getMember() == null || !event.getMember().hasPermission(channel, Permission.VIEW_CHANNEL)) + throw new IllegalArgumentException("You must be able to view the poll's original channel."); + return poll; + } + + static void authorize(Interaction event, Poll poll) { + if (event.getMember() == null || !poll.canManage(event.getUser().getId(), event.getMember().hasPermission(Permission.ADMINISTRATOR))) + throw new IllegalArgumentException("Only the poll creator or a server administrator can manage this poll."); + } + private static void requireOpen(Poll poll) { + if (poll.status() != Poll.Status.OPEN) throw new IllegalArgumentException("This poll is closed."); + } + private static String value(ModalInteractionEvent event, String id) { + return Objects.requireNonNull(event.getValue(id)).getAsString().strip(); + } + private static Set allowedDays(ModalInteractionEvent event) { + var mapping = event.getValue("days"); + List values = mapping == null ? List.of() : mapping.getAsStringList(); + return values.stream().map(DayOfWeek::valueOf).collect(Collectors.toSet()); + } + private static Set selectedDates(ModalInteractionEvent event, String id) { + var mapping = event.getValue(id); + return mapping == null ? Set.of() : mapping.getAsStringList().stream().map(PollListener::parseDate).collect(Collectors.toSet()); + } + static LocalDate parseDate(String input) { + try { + if (!input.matches("\\d{4}-\\d{2}-\\d{2}")) throw new DateTimeParseException("format", input, 0); + return LocalDate.parse(input); + } catch (DateTimeParseException e) { throw new IllegalArgumentException("Invalid date. Use YYYY-MM-DD, for example 2026-06-14."); } + } + + private static void tell(IReplyCallback event, String text) { + if (event.isAcknowledged()) + event.getHook().editOriginal(text).setEmbeds(List.of()).setComponents(List.of()).setAllowedMentions(List.of()).queue(); + else event.reply(text).setEphemeral(true).setAllowedMentions(List.of()).queue(); + } + private static void guard(IReplyCallback event, Runnable action) { + try { action.run(); } + catch (IllegalArgumentException e) { tell(event, e.getMessage()); } + catch (Exception e) { + LOG.error("Poll interaction failed", e); + tell(event, "Something went wrong. The bot operator should check the logs. Saved responses and chosen dates remain stored."); + } + } +} diff --git a/src/main/java/net/theinfinitymc/infinitybot/polls/PollStore.java b/src/main/java/net/theinfinitymc/infinitybot/polls/PollStore.java new file mode 100644 index 0000000..1ad8409 --- /dev/null +++ b/src/main/java/net/theinfinitymc/infinitybot/polls/PollStore.java @@ -0,0 +1,164 @@ +package net.theinfinitymc.infinitybot.polls; + +import java.nio.file.*; +import java.sql.*; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.util.*; + +/** One synchronized connection; each mutation is committed before Discord is updated. */ +public final class PollStore implements AutoCloseable { + private final Connection db; + + public PollStore(Path path) { + try { + Path parent = path.toAbsolutePath().getParent(); + Files.createDirectories(parent); + db = DriverManager.getConnection("jdbc:sqlite:" + path.toAbsolutePath()); + try (Statement s = db.createStatement()) { + s.execute("PRAGMA foreign_keys = ON"); + s.execute("PRAGMA busy_timeout = 5000"); + s.execute("PRAGMA journal_mode = WAL"); + s.execute(""" + CREATE TABLE IF NOT EXISTS polls ( + id TEXT PRIMARY KEY, guild_id TEXT NOT NULL, channel_id TEXT NOT NULL, + message_id TEXT, creator_id TEXT NOT NULL, title TEXT NOT NULL, dates TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'OPEN', chosen_date TEXT, event_id TEXT, + event_state TEXT NOT NULL DEFAULT 'NONE') + """); + s.execute(""" + CREATE TABLE IF NOT EXISTS poll_votes ( + poll_id TEXT NOT NULL REFERENCES polls(id) ON DELETE CASCADE, + user_id TEXT NOT NULL, yes_dates TEXT NOT NULL, + PRIMARY KEY (poll_id, user_id)) + """); + // A crash may have happened after Discord accepted creation. Reconcile before retrying. + s.execute("UPDATE polls SET event_state = 'RETRY' WHERE event_state = 'CREATING'"); + } + } catch (Exception e) { + throw new IllegalStateException("Cannot open poll database at " + path, e); + } + } + + public synchronized Poll create(String guild, String channel, String creator, String title, + LocalDate start, LocalDate end, Set allowedDays) { + title = title.strip(); + if (title.isEmpty() || title.length() > 100) + throw new IllegalArgumentException("Title must contain 1–100 characters."); + List dates = Poll.candidateDates(start, end, allowedDays); + String id = UUID.randomUUID().toString(); + update("INSERT INTO polls (id,guild_id,channel_id,creator_id,title,dates) VALUES (?,?,?,?,?,?)", + id, guild, channel, creator, title, encode(dates)); + return get(id); + } + + public synchronized Poll get(String id) { + try (PreparedStatement s = db.prepareStatement("SELECT * FROM polls WHERE id = ?")) { + s.setString(1, id); + try (ResultSet r = s.executeQuery()) { + if (!r.next()) throw new IllegalArgumentException("This poll is no longer available."); + Map votes = new HashMap<>(); + try (PreparedStatement v = db.prepareStatement("SELECT * FROM poll_votes WHERE poll_id = ?")) { + v.setString(1, id); + try (ResultSet rows = v.executeQuery()) { + while (rows.next()) votes.put(rows.getString("user_id"), new Poll.Vote( + new HashSet<>(decode(rows.getString("yes_dates"))))); + } + } + String chosen = r.getString("chosen_date"); + return new Poll(id, r.getString("guild_id"), r.getString("channel_id"), r.getString("message_id"), + r.getString("creator_id"), r.getString("title"), decode(r.getString("dates")), + Poll.Status.valueOf(r.getString("status")), chosen == null ? null : LocalDate.parse(chosen), + r.getString("event_id"), r.getString("event_state"), votes); + } + } catch (SQLException e) { throw failure(e); } + } + + public synchronized List publishedIds() { + try (Statement s = db.createStatement(); ResultSet r = s.executeQuery("SELECT id FROM polls WHERE message_id IS NOT NULL")) { + List ids = new ArrayList<>(); + while (r.next()) ids.add(r.getString(1)); + return ids; + } catch (SQLException e) { throw failure(e); } + } + + public synchronized void bindMessage(String id, String messageId) { + update("UPDATE polls SET message_id = ? WHERE id = ?", messageId, id); + } + + public synchronized void deleteUnpublished(String id) { + update("DELETE FROM polls WHERE id = ? AND message_id IS NULL", id); + } + + public synchronized void vote(String id, String user, Set yes) { + Poll poll = requireOpen(id); + Poll.Vote vote = new Poll.Vote(yes); + if (!poll.dates().containsAll(vote.yes())) + throw new IllegalArgumentException("Your response contains dates outside this poll."); + update(""" + INSERT INTO poll_votes (poll_id,user_id,yes_dates) VALUES (?,?,?) + ON CONFLICT(poll_id,user_id) DO UPDATE SET yes_dates=excluded.yes_dates + """, id, user, encode(vote.yes())); + } + + public synchronized void withdraw(String id, String user) { + requireOpen(id); + update("DELETE FROM poll_votes WHERE poll_id = ? AND user_id = ?", id, user); + } + + public synchronized void finalizePoll(String id, LocalDate date) { + Poll poll = get(id); + // A second confirmation of the same date can proceed to optional event creation. + if (poll.status() == Poll.Status.FINALIZED && date.equals(poll.chosenDate())) return; + requireOpen(id); + if (!poll.dates().contains(date)) throw new IllegalArgumentException("Choose one of this poll's candidate dates."); + update("UPDATE polls SET status='FINALIZED', chosen_date=? WHERE id=?", date.toString(), id); + } + + public synchronized void cancel(String id) { + requireOpen(id); + update("UPDATE polls SET status='CANCELLED' WHERE id=?", id); + } + + public synchronized boolean claimEvent(String id) { + Poll poll = get(id); + if (poll.status() != Poll.Status.FINALIZED) throw new IllegalArgumentException("Finalize the poll before creating an event."); + if (poll.eventId() != null || poll.eventState().equals("CREATING")) return false; + update("UPDATE polls SET event_state='CREATING' WHERE id=?", id); + return true; + } + + public synchronized void eventCreated(String id, String eventId) { + update("UPDATE polls SET event_id=?, event_state='CREATED' WHERE id=?", eventId, id); + } + + public synchronized void eventFailed(String id) { + update("UPDATE polls SET event_state='RETRY' WHERE id=? AND event_id IS NULL", id); + } + + private Poll requireOpen(String id) { + Poll poll = get(id); + if (poll.status() != Poll.Status.OPEN) throw new IllegalArgumentException("This poll is closed; responses can no longer change."); + return poll; + } + + private void update(String sql, String... values) { + try (PreparedStatement s = db.prepareStatement(sql)) { + for (int i = 0; i < values.length; i++) s.setString(i + 1, values[i]); + s.executeUpdate(); + } catch (SQLException e) { throw failure(e); } + } + + private static String encode(Collection dates) { + return String.join(",", dates.stream().sorted().map(LocalDate::toString).toList()); + } + private static List decode(String dates) { + return dates.isEmpty() ? List.of() : Arrays.stream(dates.split(",")).map(LocalDate::parse).toList(); + } + private static IllegalStateException failure(SQLException e) { + return new IllegalStateException("Poll storage failed. Please try again or contact the bot operator.", e); + } + @Override public synchronized void close() { + try { db.close(); } catch (SQLException e) { throw failure(e); } + } +} diff --git a/src/main/java/net/theinfinitymc/infinitybot/polls/PollUi.java b/src/main/java/net/theinfinitymc/infinitybot/polls/PollUi.java new file mode 100644 index 0000000..e7e9fe7 --- /dev/null +++ b/src/main/java/net/theinfinitymc/infinitybot/polls/PollUi.java @@ -0,0 +1,161 @@ +package net.theinfinitymc.infinitybot.polls; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.buttons.Button; +import net.dv8tion.jda.api.components.checkboxgroup.CheckboxGroup; +import net.dv8tion.jda.api.components.label.Label; +import net.dv8tion.jda.api.components.selections.StringSelectMenu; +import net.dv8tion.jda.api.components.textinput.TextInput; +import net.dv8tion.jda.api.components.textinput.TextInputStyle; +import net.dv8tion.jda.api.modals.Modal; +import net.dv8tion.jda.api.utils.MarkdownSanitizer; +import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder; +import net.dv8tion.jda.api.utils.messages.MessageCreateData; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.format.TextStyle; +import java.time.format.DateTimeFormatter; +import java.util.*; + +/** Stateless components: even old messages work after a restart. */ +public final class PollUi { + private PollUi() {} + private static final DateTimeFormatter DAY = DateTimeFormatter.ofPattern("EEE, MMM d, uuuu", Locale.ENGLISH); + public static String id(String action, String poll) { return "poll:" + action + ":" + poll; } + public static String day(LocalDate date) { return DAY.format(date); } + public static String safe(String value) { return MarkdownSanitizer.escape(value).replace("\n", " ").replace("\r", " "); } + + public static Modal createModal() { + CheckboxGroup.Builder days = CheckboxGroup.create("days").setRequiredRange(1, 7); + for (DayOfWeek day : DayOfWeek.values()) + days.addOption(day.getDisplayName(TextStyle.FULL, Locale.ENGLISH), day.name(), null, true); + return Modal.create("poll:create", "Create availability poll").addComponents( + input("title", "Event title", "Game night", 100, null), + input("start", "Start date (M/D or YYYY-MM-DD)", "6/1", 10, null) + .withDescription("Without a year: next occurrence on or after today (UTC). MM-DD also works."), + input("end", "End date, inclusive (M/D or YYYY-MM-DD)", "6/14", 10, null) + .withDescription("Without a year: next occurrence on or after the start date."), + Label.of("Allowed weekdays", "Uncheck days to exclude. Maximum 25 candidate dates.", days.build()) + ).build(); + } + + public static Modal voteModal(Poll poll, String user) { + Poll.Vote vote = poll.votes().getOrDefault(user, new Poll.Vote(Set.of())); + Modal.Builder modal = Modal.create(id("vote", poll.id()), "Select days you're available"); + // Discord limits each checkbox group to 10 options; keep all dates in one form. + for (int offset = 0; offset < poll.dates().size(); offset += 10) { + List dates = poll.dates().subList(offset, Math.min(offset + 10, poll.dates().size())); + CheckboxGroup.Builder group = CheckboxGroup.create("available" + offset / 10) + .setRequiredRange(0, dates.size()).setRequired(false); + for (LocalDate date : dates) group.addOption(day(date), date.toString(), null, vote.yes().contains(date)); + String label = poll.dates().size() <= 10 ? "Available days" : "Available days · " + (offset + 1) + "–" + (offset + dates.size()); + modal.addComponents(Label.of(label, "Check every date you can attend. Leave all unchecked if none work.", group.build())); + } + return modal.build(); + } + + public static Modal eventModal(Poll poll, LocalDate date) { + return Modal.create(id("event", poll.id()) + ":" + date, "Event · " + day(date)).addComponents( + input("time", "Start time (24-hour HH:mm)", "19:00", 5, null), + input("zone", "Timezone (IANA name, not abbreviation)", "America/New_York", 60, null), + input("duration", "Duration in minutes", "180", 5, "180"), + input("location", "Voice channel ID, or external location", "Paste voice channel ID, URL, or location", 100, null) + ).build(); + } + + private static Label input(String id, String label, String placeholder, int max, String value) { + TextInput.Builder builder = TextInput.create(id, TextInputStyle.SHORT).setPlaceholder(placeholder).setMaxLength(max); + if (value != null) builder.setValue(value); + return Label.of(label, builder.build()); + } + + public static StringSelectMenu.Builder dates(String id, Poll poll) { + StringSelectMenu.Builder builder = StringSelectMenu.create(id); + for (LocalDate date : poll.dates()) builder.addOption(day(date), date.toString()); + return builder; + } + + public static MessageCreateData summary(Poll poll) { + Set leaders = poll.leadingDates(); + StringBuilder availability = new StringBuilder(); + for (LocalDate date : poll.dates()) { + boolean leading = leaders.contains(date); + availability.append(leading ? "⭐ **" : "• ").append(day(date)).append(leading ? "**" : ""); + if (!poll.votes().isEmpty()) availability.append(" — ").append(poll.counts(date).yes()) + .append(" of ").append(poll.votes().size()).append(" available"); + availability.append("\n"); + } + String state = switch (poll.status()) { + case OPEN -> poll.votes().isEmpty() ? "Open · Waiting for responses" : "Open · " + poll.votes().size() + " responses"; + case FINALIZED -> "Finalized · **" + day(poll.chosenDate()) + "** · " + poll.votes().size() + " responses"; + case CANCELLED -> "Cancelled · " + poll.votes().size() + " responses"; + }; + if (poll.eventId() != null) state += "\n[Discord event](https://discord.com/events/" + poll.guildId() + "/" + poll.eventId() + ")"; + else if (poll.eventState().equals("CREATING")) state += "\nDiscord event creation in progress."; + else if (poll.eventState().equals("RETRY")) state += "\nDiscord event not confirmed. Creator/admin: Manage → Create / retry event."; + EmbedBuilder embed = new EmbedBuilder().setTitle(poll.title()).setColor(poll.status() == Poll.Status.OPEN ? 0x5865F2 : 0x57F287) + .setDescription(state + "\n\n" + availability) + .setFooter("⭐ Most available · Only submitted responses count. Use Details to see who can attend."); + return new MessageCreateBuilder().setEmbeds(embed.build()).setAllowedMentions(List.of()).setComponents( + ActionRow.of( + Button.primary(id("respond", poll.id()), "Respond / Edit").withDisabled(poll.status() != Poll.Status.OPEN), + Button.secondary(id("withdraw", poll.id()), "Withdraw").withDisabled(poll.status() != Poll.Status.OPEN), + Button.secondary(id("details", poll.id()), "Details"), + Button.secondary(id("manage", poll.id()), "Manage") + )).build(); + } + + public static MessageCreateData detailsPicker(Poll poll) { + return message("**" + safe(poll.title()) + "** · Choose a date to see members' responses.", + ActionRow.of(dates(id("detaildate", poll.id()), poll).build())); + } + + public static MessageCreateData details(Poll poll, LocalDate date, int requestedPage) { + if (!poll.dates().contains(date)) throw new IllegalArgumentException("That date is not in this poll."); + List> entries = poll.votes().entrySet().stream() + .sorted(Comparator., Poll.Answer>comparing(e -> e.getValue().answer(date)) + .thenComparing(Map.Entry::getKey)).toList(); + int pages = Math.max(1, (entries.size() + 14) / 15); + int page = Math.max(0, Math.min(requestedPage, pages - 1)); + Poll.Counts c = poll.counts(date); + StringBuilder text = new StringBuilder("**" + safe(poll.title()) + "** · " + day(date) + + "\nAvailable: " + c.yes() + " · Unavailable: " + c.no() + "\n"); + for (var entry : entries.subList(page * 15, Math.min(entries.size(), (page + 1) * 15))) + text.append("\n").append(entry.getValue().answer(date) == Poll.Answer.YES ? "✅ Available" : "❌ Unavailable") + .append(" · <@").append(entry.getKey()).append(">"); + if (entries.isEmpty()) text.append("\nNo responses yet."); + text.append("\n\nPage ").append(page + 1).append("/").append(pages).append(" · Members who haven't responded are not listed."); + return message(text.toString(), + ActionRow.of(dates(id("detaildate", poll.id()), poll).setDefaultValues(date.toString()).build()), + ActionRow.of( + Button.secondary(id("page", poll.id()) + ":" + date + ":" + (page - 1), "Previous").withDisabled(page == 0), + Button.secondary(id("page", poll.id()) + ":" + date + ":" + (page + 1), "Next").withDisabled(page + 1 == pages))); + } + + public static MessageCreateData manage(Poll poll) { + if (poll.status() == Poll.Status.OPEN) + return message("**" + safe(poll.title()) + "** · Choose a final date, or cancel this poll.", + ActionRow.of(dates(id("finaldate", poll.id()), poll).build()), + ActionRow.of(Button.danger(id("cancelconfirm", poll.id()), "Cancel poll"))); + if (poll.status() == Poll.Status.FINALIZED && poll.eventId() == null) + return message("Chosen date: **" + day(poll.chosenDate()) + "**. Voting is closed.", + ActionRow.of(Button.primary(id("eventform", poll.id()) + ":" + poll.chosenDate(), "Create / retry event") + .withDisabled(poll.eventState().equals("CREATING")))); + return message("This poll is " + poll.status().name().toLowerCase(Locale.ROOT) + "." + + (poll.eventId() == null ? "" : "\nEvent: https://discord.com/events/" + poll.guildId() + "/" + poll.eventId())); + } + + public static MessageCreateData confirm(Poll poll, LocalDate date) { + if (poll.status() != Poll.Status.OPEN || !poll.dates().contains(date)) + throw new IllegalArgumentException("This poll is closed or that date is not a candidate."); + return message("Finalize **" + safe(poll.title()) + "** for **" + day(date) + "**? This locks all responses.", + ActionRow.of(Button.success(id("finalize", poll.id()) + ":" + date, "Finalize date only"), + Button.primary(id("eventform", poll.id()) + ":" + date, "Finalize + create event"))); + } + + public static MessageCreateData message(String text, ActionRow... rows) { + return new MessageCreateBuilder().setContent(text).setAllowedMentions(List.of()).setComponents(rows).build(); + } +} diff --git a/src/main/java/net/theinfinitymc/infinitybot/polls/PollUpdates.java b/src/main/java/net/theinfinitymc/infinitybot/polls/PollUpdates.java new file mode 100644 index 0000000..06a6181 --- /dev/null +++ b/src/main/java/net/theinfinitymc/infinitybot/polls/PollUpdates.java @@ -0,0 +1,77 @@ +package net.theinfinitymc.infinitybot.polls; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.*; +import java.util.function.BiConsumer; + +/** Debounce per poll, with at most one Discord edit in flight. Never let an old edit win. */ +public final class PollUpdates implements AutoCloseable { + private static final Logger LOG = LoggerFactory.getLogger(PollUpdates.class); + private static final class State { boolean dirty; } + private final Map pending = new HashMap<>(); + private final PollStore store; + private final ScheduledExecutorService executor; + private final BiConsumer sender; + private boolean closed; + + public PollUpdates(PollStore store, BiConsumer sender) { + this(store, sender, Executors.newSingleThreadScheduledExecutor(r -> { + Thread thread = new Thread(r, "poll-message-updates"); + thread.setDaemon(true); + return thread; + })); + } + + PollUpdates(PollStore store, BiConsumer sender, ScheduledExecutorService executor) { + this.store = store; + this.sender = sender; + this.executor = executor; + } + + public synchronized void request(String id) { + if (closed) return; + State state = pending.get(id); + if (state == null) { + state = new State(); + pending.put(id, state); + schedule(id); + } + state.dirty = true; + } + + private void schedule(String id) { + executor.schedule(() -> flush(id), 750, TimeUnit.MILLISECONDS); + } + + private void flush(String id) { + synchronized (this) { + if (closed) return; + State state = pending.get(id); + state.dirty = false; + } + try { + Poll poll = store.get(id); + sender.accept(poll, () -> completed(id)); + } catch (Exception e) { + LOG.warn("Could not update poll {}", id, e); + completed(id); + } + } + + private synchronized void completed(String id) { + if (closed) return; + State state = pending.get(id); + if (state.dirty) schedule(id); + else pending.remove(id); + } + + @Override public synchronized void close() { + closed = true; + pending.clear(); + executor.shutdownNow(); + } +} diff --git a/src/test/java/net/theinfinitymc/infinitybot/polls/PollDatesTest.java b/src/test/java/net/theinfinitymc/infinitybot/polls/PollDatesTest.java new file mode 100644 index 0000000..cfbc493 --- /dev/null +++ b/src/test/java/net/theinfinitymc/infinitybot/polls/PollDatesTest.java @@ -0,0 +1,37 @@ +package net.theinfinitymc.infinitybot.polls; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.time.LocalDate; + +import static org.junit.jupiter.api.Assertions.*; + +class PollDatesTest { + @ParameterizedTest @CsvSource({ + "10/12,2026-10-12", "10-13,2026-10-13", "1/2,2027-01-02", + "06-05,2027-06-05", "2025-06-05,2025-06-05", "2028-02-29,2028-02-29" + }) + void resolvesShorthandOnOrAfterReferenceAndPreservesExplicitYear(String input, String expected) { + assertEquals(LocalDate.parse(expected), PollDates.resolve(input, LocalDate.of(2026, 10, 12))); + } + + @Test void endIsRelativeToStartAcrossDecemberAndWhenStartIsAlreadyNextYear() { + LocalDate today = LocalDate.of(2026, 10, 12); + LocalDate start = PollDates.resolve("12/28", today); + assertEquals(LocalDate.of(2027, 1, 3), PollDates.resolve("1/3", start)); + start = PollDates.resolve("1/5", today); + assertEquals(LocalDate.of(2027, 1, 10), PollDates.resolve("1/10", start)); + assertEquals(start, PollDates.resolve("1/5", start)); + } + + @ParameterizedTest @CsvSource({"2/30", "13/1", "0/1", "6/0", "06/05/2026", "2026-02-29", "2/29"}) + void rejectsInvalidDatesWithoutSilentlyClampingLeapDay(String input) { + assertThrows(IllegalArgumentException.class, () -> PollDates.resolve(input, LocalDate.of(2026, 1, 1))); + } + + @Test void leapDayIsAcceptedWhenInferredYearIsLeapYear() { + assertEquals(LocalDate.of(2028, 2, 29), PollDates.resolve("2/29", LocalDate.of(2027, 12, 1))); + } +} diff --git a/src/test/java/net/theinfinitymc/infinitybot/polls/PollEventPlanTest.java b/src/test/java/net/theinfinitymc/infinitybot/polls/PollEventPlanTest.java new file mode 100644 index 0000000..30ec2cb --- /dev/null +++ b/src/test/java/net/theinfinitymc/infinitybot/polls/PollEventPlanTest.java @@ -0,0 +1,40 @@ +package net.theinfinitymc.infinitybot.polls; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.time.*; + +import static org.junit.jupiter.api.Assertions.*; + +class PollEventPlanTest { + private final Clock clock = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC); + private final LocalDate date = LocalDate.of(2026, 6, 6); + + @Test void convertsTimezoneAndUsesDurationAcrossMidnight() { + PollEventPlan plan = PollEventPlan.parse(date, "23:30", "America/New_York", "180", " https://example.com ", clock); + assertEquals(Instant.parse("2026-06-07T03:30:00Z"), plan.start().toInstant()); + assertEquals(Instant.parse("2026-06-07T06:30:00Z"), plan.end().toInstant()); + assertEquals("https://example.com", plan.location()); + } + + @ParameterizedTest @CsvSource({"2026-03-08,02:30", "2026-11-01,01:30"}) + void rejectsMissingOrAmbiguousDstTimes(String date, String time) { + assertThrows(IllegalArgumentException.class, () -> PollEventPlan.parse(LocalDate.parse(date), time, + "America/New_York", "60", "Somewhere", clock)); + } + + @ParameterizedTest @CsvSource({ + "24:00,UTC,60", "9:00,UTC,60", "19:00,Invalid/Zone,60", + "19:00,UTC,0", "19:00,UTC,-1", "19:00,UTC,10081", "19:00,UTC,nope" + }) + void rejectsInvalidEventFields(String time, String zone, String duration) { + assertThrows(IllegalArgumentException.class, () -> PollEventPlan.parse(date, time, zone, duration, "Somewhere", clock)); + } + + @Test void rejectsPastStartAndBlankLocation() { + assertThrows(IllegalArgumentException.class, () -> PollEventPlan.parse(LocalDate.of(2025, 12, 31), "19:00", "UTC", "60", "Somewhere", clock)); + assertThrows(IllegalArgumentException.class, () -> PollEventPlan.parse(date, "19:00", "UTC", "60", " ", clock)); + } +} diff --git a/src/test/java/net/theinfinitymc/infinitybot/polls/PollListenerTest.java b/src/test/java/net/theinfinitymc/infinitybot/polls/PollListenerTest.java new file mode 100644 index 0000000..78e3d84 --- /dev/null +++ b/src/test/java/net/theinfinitymc/infinitybot/polls/PollListenerTest.java @@ -0,0 +1,319 @@ +package net.theinfinitymc.infinitybot.polls; + +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.*; +import net.dv8tion.jda.api.entities.channel.ChannelType; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; +import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.interactions.Interaction; +import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback; +import net.dv8tion.jda.api.interactions.modals.ModalMapping; +import net.dv8tion.jda.api.modals.Modal; +import net.dv8tion.jda.api.requests.RestAction; +import net.dv8tion.jda.api.requests.restaction.MessageCreateAction; +import net.dv8tion.jda.api.requests.restaction.ScheduledEventAction; +import net.dv8tion.jda.api.requests.restaction.WebhookMessageEditAction; +import net.dv8tion.jda.api.utils.messages.MessageCreateData; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.nio.file.Path; +import java.time.Clock; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.util.*; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@SuppressWarnings("unchecked") // Mockito's erased RestAction/Consumer types. +class PollListenerTest { + @TempDir Path directory; + private final Guild guild = mock(Guild.class, RETURNS_DEEP_STUBS); + private final Member member = mock(Member.class); + private final TextChannel channel = mock(TextChannel.class); + private final LocalDate date = LocalDate.of(2090, 6, 6); + + private Poll create(PollStore store) { + when(guild.getId()).thenReturn("100"); + when(guild.getTextChannelById("200")).thenReturn(channel); + when(channel.getId()).thenReturn("200"); + when(member.hasPermission(channel, Permission.VIEW_CHANNEL)).thenReturn(true); + return store.create("100", "200", "400", "Game night", date, date.plusDays(1), EnumSet.allOf(DayOfWeek.class)); + } + private void identity(Interaction event, String user) { + when(event.isFromGuild()).thenReturn(true); + when(event.getGuild()).thenReturn(guild); + when(event.getMember()).thenReturn(member); + when(event.getUser().getId()).thenReturn(user); + if (event instanceof IReplyCallback reply) { + WebhookMessageEditAction edit = mock(WebhookMessageEditAction.class, RETURNS_SELF); + when(reply.getHook().editOriginal(anyString())).thenReturn(edit); + } + } + private ModalInteractionEvent eventForm(Poll poll) { + ModalInteractionEvent event = mock(ModalInteractionEvent.class, RETURNS_DEEP_STUBS); + identity(event, "400"); + when(event.isAcknowledged()).thenReturn(true); + when(event.getModalId()).thenReturn(PollUi.id("event", poll.id()) + ":" + date); + input(event, "time", "19:00"); + input(event, "zone", "UTC"); + input(event, "duration", "180"); + input(event, "location", "Somewhere"); + return event; + } + private void input(ModalInteractionEvent event, String key, String value) { + ModalMapping mapping = mock(ModalMapping.class); + when(mapping.getAsString()).thenReturn(value); + when(event.getValue(key)).thenReturn(mapping); + } + private void eventPermission() { + when(guild.getSelfMember().hasPermission(Permission.CREATE_SCHEDULED_EVENTS)).thenReturn(true); + } + private RestAction> eventList(List events) { + RestAction> action = mock(RestAction.class); + when(guild.retrieveScheduledEvents()).thenReturn(action); + doAnswer(call -> { ((Consumer>) call.getArgument(0)).accept(events); return null; }) + .when(action).queue(any(), any()); + return action; + } + + @Test void uncheckedDatesMeanUnavailableAndClosedPollRejectsStaleSubmission() { + try (PollStore store = new PollStore(directory.resolve("polls.sqlite")); PollListener listener = new PollListener(store)) { + Poll poll = create(store); + ModalInteractionEvent event = mock(ModalInteractionEvent.class, RETURNS_DEEP_STUBS); + identity(event, "500"); + when(event.isAcknowledged()).thenReturn(true); + when(event.getModalId()).thenReturn(PollUi.id("vote", poll.id())); + when(event.getValue("available0")).thenReturn(null); + listener.onModalInteraction(event); + assertEquals(new Poll.Counts(0, 1), store.get(poll.id()).counts(date)); + store.finalizePoll(poll.id(), date); + ModalMapping selection = mock(ModalMapping.class); + when(selection.getAsStringList()).thenReturn(List.of(date.toString())); + when(event.getValue("available0")).thenReturn(selection); + listener.onModalInteraction(event); + assertEquals(new Poll.Counts(0, 1), store.get(poll.id()).counts(date)); + } + } + + @Test void votesAggregateAllCheckboxGroupsAndEditsReplaceTheWholeResponse() { + try (PollStore store = new PollStore(directory.resolve("polls.sqlite")); PollListener listener = new PollListener(store)) { + create(store); // Configure guild and channel access. + Poll poll = store.create("100", "200", "400", "Long poll", date, date.plusDays(24), EnumSet.allOf(DayOfWeek.class)); + ModalInteractionEvent event = mock(ModalInteractionEvent.class, RETURNS_DEEP_STUBS); + identity(event, "500"); + when(event.isAcknowledged()).thenReturn(true); + when(event.getModalId()).thenReturn(PollUi.id("vote", poll.id())); + Set selected = Set.of(date, date.plusDays(10), date.plusDays(24)); + for (int group = 0; group < 3; group++) { + ModalMapping mapping = mock(ModalMapping.class); + LocalDate selection = group == 0 ? date : group == 1 ? date.plusDays(10) : date.plusDays(24); + when(mapping.getAsStringList()).thenReturn(List.of(selection.toString())); + when(event.getValue("available" + group)).thenReturn(mapping); + } + listener.onModalInteraction(event); + assertEquals(selected, store.get(poll.id()).votes().get("500").yes()); + when(event.getValue("available1")).thenReturn(null); + when(event.getValue("available2")).thenReturn(null); + listener.onModalInteraction(event); + assertEquals(Set.of(date), store.get(poll.id()).votes().get("500").yes()); + } + } + + @Test void viewingRequiresSameGuildAndOriginalChannelAccess() { + try (PollStore store = new PollStore(directory.resolve("polls.sqlite")); PollListener listener = new PollListener(store)) { + Poll poll = create(store); + ButtonInteractionEvent event = mock(ButtonInteractionEvent.class, RETURNS_DEEP_STUBS); + identity(event, "500"); + assertEquals(poll.id(), listener.accessible(event, poll.id()).id()); + when(member.hasPermission(channel, Permission.VIEW_CHANNEL)).thenReturn(false); + assertThrows(IllegalArgumentException.class, () -> listener.accessible(event, poll.id())); + when(member.hasPermission(channel, Permission.VIEW_CHANNEL)).thenReturn(true); + when(guild.getId()).thenReturn("other"); + assertThrows(IllegalArgumentException.class, () -> listener.accessible(event, poll.id())); + } + } + + @Test void unauthorizedManagementButtonAndModalCannotClosePoll() { + try (PollStore store = new PollStore(directory.resolve("polls.sqlite")); PollListener listener = new PollListener(store)) { + Poll poll = create(store); + ButtonInteractionEvent button = mock(ButtonInteractionEvent.class, RETURNS_DEEP_STUBS); + identity(button, "500"); + when(button.getComponentId()).thenReturn(PollUi.id("finalize", poll.id()) + ":" + date); + listener.onButtonInteraction(button); + assertEquals(Poll.Status.OPEN, store.get(poll.id()).status()); + ModalInteractionEvent event = eventForm(poll); + when(event.getUser().getId()).thenReturn("500"); + listener.onModalInteraction(event); + assertEquals(Poll.Status.OPEN, store.get(poll.id()).status()); + when(member.hasPermission(Permission.ADMINISTRATOR)).thenReturn(true); + listener.onButtonInteraction(button); + assertEquals(Poll.Status.FINALIZED, store.get(poll.id()).status()); + } + } + + @Test void respondOpensModalWithoutDeferring() { + try (PollStore store = new PollStore(directory.resolve("polls.sqlite")); PollListener listener = new PollListener(store)) { + Poll poll = create(store); + ButtonInteractionEvent event = mock(ButtonInteractionEvent.class, RETURNS_DEEP_STUBS); + identity(event, "500"); + when(event.getComponentId()).thenReturn(PollUi.id("respond", poll.id())); + listener.onButtonInteraction(event); + verify(event).replyModal(any(Modal.class)); + verify(event, never()).deferReply(anyBoolean()); + } + } + + @Test void missingEventPermissionsStillFinalizesDateWithoutCallingDiscord() { + try (PollStore store = new PollStore(directory.resolve("polls.sqlite")); PollListener listener = new PollListener(store)) { + Poll poll = create(store); + listener.onModalInteraction(eventForm(poll)); + assertEquals(Poll.Status.FINALIZED, store.get(poll.id()).status()); + assertEquals(date, store.get(poll.id()).chosenDate()); + verify(guild, never()).retrieveScheduledEvents(); + } + } + + @Test void externalEventCreationIsRecordedAndRepeatSubmissionDoesNotDuplicateIt() { + try (PollStore store = new PollStore(directory.resolve("polls.sqlite")); PollListener listener = new PollListener(store)) { + Poll poll = create(store); + eventPermission(); + eventList(List.of()); + ScheduledEventAction action = mock(ScheduledEventAction.class, RETURNS_SELF); + when(guild.createScheduledEvent(eq("Game night"), eq("Somewhere"), any(), any())).thenReturn(action); + ScheduledEvent created = mock(ScheduledEvent.class); + when(created.getId()).thenReturn("700"); + doAnswer(call -> { ((Consumer) call.getArgument(0)).accept(created); return null; }) + .when(action).queue(any(), any()); + ModalInteractionEvent event = eventForm(poll); + listener.onModalInteraction(event); + assertEquals("700", store.get(poll.id()).eventId()); + listener.onModalInteraction(event); + verify(guild, times(1)).createScheduledEvent(eq("Game night"), eq("Somewhere"), any(), any()); + verify(action).setDescription(contains("[InfinityBot poll: " + poll.id() + "]")); + } + } + + @Test void retryReconcilesPreviouslyCreatedEventBeforeCreatingAnother() { + try (PollStore store = new PollStore(directory.resolve("polls.sqlite")); PollListener listener = new PollListener(store)) { + Poll poll = create(store); + store.finalizePoll(poll.id(), date); + eventPermission(); + ModalInteractionEvent event = eventForm(poll); + when(event.getJDA().getSelfUser().getId()).thenReturn("800"); + ScheduledEvent existing = mock(ScheduledEvent.class); + when(existing.getId()).thenReturn("700"); + when(existing.getCreatorId()).thenReturn("800"); + when(existing.getDescription()).thenReturn("Previously created\n[InfinityBot poll: " + poll.id() + "]"); + eventList(List.of(existing)); + listener.onModalInteraction(event); + assertEquals("700", store.get(poll.id()).eventId()); + verify(guild, never()).createScheduledEvent(anyString(), anyString(), any(), any()); + } + } + + @Test void eventApiFailureLeavesChosenDateIntactAndReleasesClaimForRetry() { + try (PollStore store = new PollStore(directory.resolve("polls.sqlite")); PollListener listener = new PollListener(store)) { + Poll poll = create(store); + eventPermission(); + eventList(List.of()); + ScheduledEventAction action = mock(ScheduledEventAction.class, RETURNS_SELF); + when(guild.createScheduledEvent(anyString(), anyString(), any(), any())).thenReturn(action); + doAnswer(call -> { ((Consumer) call.getArgument(1)).accept(new IllegalStateException("Discord unavailable")); return null; }) + .when(action).queue(any(), any()); + listener.onModalInteraction(eventForm(poll)); + assertEquals(date, store.get(poll.id()).chosenDate()); + assertEquals("RETRY", store.get(poll.id()).eventState()); + assertTrue(store.claimEvent(poll.id())); + } + } + + @Test void voiceEventRequiresChannelAccessAndConnect() { + try (PollStore store = new PollStore(directory.resolve("polls.sqlite")); PollListener listener = new PollListener(store)) { + Poll poll = create(store); + eventPermission(); + VoiceChannel voice = mock(VoiceChannel.class); + when(guild.getVoiceChannelById("600")).thenReturn(voice); + ModalInteractionEvent event = eventForm(poll); + input(event, "location", "600"); + listener.onModalInteraction(event); + assertEquals(Poll.Status.FINALIZED, store.get(poll.id()).status()); + verify(guild, never()).retrieveScheduledEvents(); + when(guild.getSelfMember().hasPermission(voice, Permission.VIEW_CHANNEL, Permission.VOICE_CONNECT)).thenReturn(true); + eventList(List.of()); + ScheduledEventAction action = mock(ScheduledEventAction.class, RETURNS_SELF); + when(guild.createScheduledEvent(eq("Game night"), eq(voice), any())).thenReturn(action); + listener.onModalInteraction(event); + verify(guild).createScheduledEvent(eq("Game night"), eq(voice), any()); + verify(action).setEndTime(any()); + } + } + + @Test void delegatedCreatorCanOpenCreationModalWithoutDeferring() { + try (PollStore store = new PollStore(directory.resolve("polls.sqlite")); PollListener listener = new PollListener(store)) { + create(store); + SlashCommandInteractionEvent event = mock(SlashCommandInteractionEvent.class, RETURNS_DEEP_STUBS); + identity(event, "500"); // Not an admin: creation is governed by Discord command permissions. + when(event.getName()).thenReturn("poll"); + when(event.getChannelType()).thenReturn(ChannelType.TEXT); + when(event.getChannelId()).thenReturn("200"); + when(guild.getSelfMember().hasPermission(channel, Permission.VIEW_CHANNEL, Permission.MESSAGE_SEND, + Permission.MESSAGE_EMBED_LINKS)).thenReturn(true); + listener.onSlashCommandInteraction(event); + verify(event).replyModal(any(Modal.class)); + verify(event, never()).deferReply(anyBoolean()); + } + } + + @ParameterizedTest @ValueSource(strings = {"explicit", "inferred"}) + void creationPublishesExactlyOneMessageAndBindsItToPoll(String format) { + try (PollStore store = new PollStore(directory.resolve("polls.sqlite")); PollListener listener = new PollListener(store)) { + create(store); + ModalInteractionEvent event = mock(ModalInteractionEvent.class, RETURNS_DEEP_STUBS); + identity(event, "500"); + when(event.isAcknowledged()).thenReturn(true); + when(event.getModalId()).thenReturn("poll:create"); + when(event.getChannelType()).thenReturn(ChannelType.TEXT); + when(event.getChannelId()).thenReturn("200"); + when(guild.getSelfMember().hasPermission(channel, Permission.VIEW_CHANNEL, Permission.MESSAGE_SEND, + Permission.MESSAGE_EMBED_LINKS)).thenReturn(true); + input(event, "title", "Weekend plan"); + LocalDate start = format.equals("explicit") ? date : LocalDate.now(Clock.systemUTC()).plusDays(2); + LocalDate end = start.plusDays(10); + input(event, "start", format.equals("explicit") ? start.toString() : start.getMonthValue() + "/" + start.getDayOfMonth()); + input(event, "end", format.equals("explicit") ? end.toString() : end.getMonthValue() + "/" + end.getDayOfMonth()); + ModalMapping days = mock(ModalMapping.class); + when(days.getAsStringList()).thenReturn(List.of("SATURDAY", "SUNDAY")); + when(event.getValue("days")).thenReturn(days); + MessageCreateAction action = mock(MessageCreateAction.class, RETURNS_SELF); + when(channel.sendMessage(any(MessageCreateData.class))).thenReturn(action); + Message message = mock(Message.class); + when(message.getId()).thenReturn("300"); + doAnswer(call -> { ((Consumer) call.getArgument(0)).accept(message); return null; }) + .when(action).queue(any(), any()); + listener.onModalInteraction(event); + assertEquals(1, store.publishedIds().size()); + Poll created = store.get(store.publishedIds().getFirst()); + assertEquals("Weekend plan", created.title()); + assertEquals("500", created.creatorId()); + assertEquals("300", created.messageId()); + assertEquals(Poll.candidateDates(start, end, EnumSet.of(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY)), created.dates()); + verify(channel, times(1)).sendMessage(any(MessageCreateData.class)); + } + } + + @Test void slashPollHasNoArgumentsOrSubcommandsAndDelegatableAdministratorAccess() { + var command = PollListener.commandData(); + assertTrue(command.getOptions().isEmpty()); + assertTrue(command.getSubcommands().isEmpty()); + assertEquals(Long.valueOf(Permission.ADMINISTRATOR.getRawValue()), command.getDefaultPermissions().getPermissionsRaw()); + } +} diff --git a/src/test/java/net/theinfinitymc/infinitybot/polls/PollStoreTest.java b/src/test/java/net/theinfinitymc/infinitybot/polls/PollStoreTest.java new file mode 100644 index 0000000..30ed1eb --- /dev/null +++ b/src/test/java/net/theinfinitymc/infinitybot/polls/PollStoreTest.java @@ -0,0 +1,148 @@ +package net.theinfinitymc.infinitybot.polls; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.util.EnumSet; +import java.util.Set; +import java.util.concurrent.*; + +import static org.junit.jupiter.api.Assertions.*; + +class PollStoreTest { + @TempDir Path directory; + private final LocalDate first = LocalDate.of(2026, 6, 5); + private final LocalDate second = first.plusDays(1); + private Poll create(PollStore store) { + return store.create("guild", "channel", "creator", "Game night", first, first.plusDays(2), EnumSet.allOf(DayOfWeek.class)); + } + + @Test void persistsResponsesMessagesFinalizationAndEventAcrossReopen() { + Path path = directory.resolve("nested/polls.sqlite"); + String id; + try (PollStore store = new PollStore(path)) { + Poll poll = create(store); + id = poll.id(); + store.bindMessage(id, "message"); + store.vote(id, "member", Set.of(first, second)); + store.vote(id, "none", Set.of()); + assertEquals(new Poll.Counts(1, 1), store.get(id).counts(first)); + assertEquals(new Poll.Counts(1, 1), store.get(id).counts(second)); + assertEquals(new Poll.Counts(0, 2), store.get(id).counts(first.plusDays(2))); + store.finalizePoll(id, second); + assertTrue(store.claimEvent(id)); + store.eventCreated(id, "event"); + } + try (PollStore store = new PollStore(path)) { + Poll poll = store.get(id); + assertEquals(2, poll.votes().size()); + assertEquals(Poll.Status.FINALIZED, poll.status()); + assertEquals(second, poll.chosenDate()); + assertEquals("event", poll.eventId()); + assertEquals("CREATED", poll.eventState()); + assertEquals("https://discord.com/channels/guild/channel/message", poll.messageUrl()); + assertEquals(Set.of(id), Set.copyOf(store.publishedIds())); + assertFalse(store.claimEvent(id)); + } + } + + @Test void editsReplaceRatherThanAccumulateAndWithdrawRemovesResponse() { + try (PollStore store = new PollStore(directory.resolve("polls.sqlite"))) { + String id = create(store).id(); + store.vote(id, "member", Set.of(first, second)); + store.vote(id, "member", Set.of(second)); + Poll poll = store.get(id); + assertEquals(1, poll.votes().size()); + assertEquals(new Poll.Counts(0, 1), poll.counts(first)); + assertEquals(new Poll.Counts(1, 0), poll.counts(second)); + store.withdraw(id, "member"); + assertTrue(store.get(id).votes().isEmpty()); + } + } + + @Test void invalidVoteDoesNotOverwriteExistingResponse() { + try (PollStore store = new PollStore(directory.resolve("polls.sqlite"))) { + String id = create(store).id(); + store.vote(id, "member", Set.of(first)); + assertThrows(IllegalArgumentException.class, () -> store.vote(id, "member", Set.of(first.minusDays(1)))); + assertEquals(Set.of(first), store.get(id).votes().get("member").yes()); + } + } + + @Test void closedPollRejectsVotesWithdrawalAndDifferentFinalDate() { + try (PollStore store = new PollStore(directory.resolve("polls.sqlite"))) { + String id = create(store).id(); + assertThrows(IllegalArgumentException.class, () -> store.finalizePoll(id, first.minusDays(1))); + store.vote(id, "member", Set.of(first)); + store.finalizePoll(id, first); + store.finalizePoll(id, first); // Same-date finalization permits optional event retries. + assertThrows(IllegalArgumentException.class, () -> store.vote(id, "member", Set.of())); + assertThrows(IllegalArgumentException.class, () -> store.withdraw(id, "member")); + assertThrows(IllegalArgumentException.class, () -> store.finalizePoll(id, second)); + assertThrows(IllegalArgumentException.class, () -> store.cancel(id)); + assertEquals(Set.of(first), store.get(id).votes().get("member").yes()); + } + } + + @Test void cancellationDoesNotPickADateOrAllowEventCreation() { + try (PollStore store = new PollStore(directory.resolve("polls.sqlite"))) { + String id = create(store).id(); + store.cancel(id); + Poll poll = store.get(id); + assertEquals(Poll.Status.CANCELLED, poll.status()); + assertNull(poll.chosenDate()); + assertThrows(IllegalArgumentException.class, () -> store.finalizePoll(id, first)); + assertThrows(IllegalArgumentException.class, () -> store.vote(id, "member", Set.of())); + assertThrows(IllegalArgumentException.class, () -> store.claimEvent(id)); + } + } + + @Test void eventClaimIsExclusiveAndFailedOrInterruptedCreationCanBeRetried() { + Path path = directory.resolve("polls.sqlite"); + String id; + try (PollStore store = new PollStore(path)) { + id = create(store).id(); + String pollId = id; + assertThrows(IllegalArgumentException.class, () -> store.claimEvent(pollId)); + store.finalizePoll(id, first); + assertTrue(store.claimEvent(id)); + assertFalse(store.claimEvent(id)); + store.eventFailed(id); + assertTrue(store.claimEvent(id)); + } + try (PollStore store = new PollStore(path)) { + assertEquals("RETRY", store.get(id).eventState()); + assertTrue(store.claimEvent(id)); + } + } + + @Test void pollsHaveIndependentVotesAndPublishingFailureCleanupIsSafe() { + try (PollStore store = new PollStore(directory.resolve("polls.sqlite"))) { + String one = create(store).id(), two = create(store).id(); + store.vote(one, "member", Set.of(first)); + assertTrue(store.get(two).votes().isEmpty()); + store.bindMessage(one, "message"); + store.deleteUnpublished(one); + assertEquals(1, store.get(one).votes().size()); + store.deleteUnpublished(two); + assertThrows(IllegalArgumentException.class, () -> store.get(two)); + } + } + + @Test void concurrentVotersDoNotLoseEachOthersResponses() throws Exception { + try (PollStore store = new PollStore(directory.resolve("polls.sqlite")); + ExecutorService threads = Executors.newFixedThreadPool(4)) { + String id = create(store).id(); + var futures = new java.util.ArrayList>(); + for (int i = 0; i < 20; i++) { + String user = Integer.toString(i); + futures.add(threads.submit(() -> store.vote(id, user, Set.of(first)))); + } + for (var future : futures) future.get(5, TimeUnit.SECONDS); + assertEquals(new Poll.Counts(20, 0), store.get(id).counts(first)); + } + } +} diff --git a/src/test/java/net/theinfinitymc/infinitybot/polls/PollTest.java b/src/test/java/net/theinfinitymc/infinitybot/polls/PollTest.java new file mode 100644 index 0000000..525f251 --- /dev/null +++ b/src/test/java/net/theinfinitymc/infinitybot/polls/PollTest.java @@ -0,0 +1,57 @@ +package net.theinfinitymc.infinitybot.polls; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; + +class PollTest { + private final LocalDate friday = LocalDate.of(2026, 6, 5); + + @Test void inclusiveDateRangeAndWeekdayFilter() { + Set all = EnumSet.allOf(DayOfWeek.class); + Set weekends = EnumSet.of(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY); + assertEquals(List.of(friday), Poll.candidateDates(friday, friday, all)); + assertEquals(List.of(friday.plusDays(1), friday.plusDays(2)), Poll.candidateDates(friday, friday.plusDays(3), weekends)); + assertEquals(List.of(friday, friday.plusDays(3)), Poll.candidateDates(friday, friday.plusDays(4), + EnumSet.of(DayOfWeek.FRIDAY, DayOfWeek.MONDAY))); + assertEquals(25, Poll.candidateDates(friday, friday.plusDays(24), all).size()); + assertThrows(IllegalArgumentException.class, () -> Poll.candidateDates(friday, friday.plusDays(25), all)); + assertThrows(IllegalArgumentException.class, () -> Poll.candidateDates(friday, friday, weekends)); + assertThrows(IllegalArgumentException.class, () -> Poll.candidateDates(friday, friday, Set.of())); + assertThrows(IllegalArgumentException.class, () -> Poll.candidateDates(friday, friday.minusDays(1), all)); + assertThrows(IllegalArgumentException.class, () -> Poll.candidateDates(friday, friday.plusYears(1), weekends)); + } + + @Test void leadersPreferMostAvailablePreserveTiesAndExcludeAllUnavailable() { + LocalDate saturday = friday.plusDays(1); + var one = new Poll.Vote(Set.of(friday, saturday)); + var two = new Poll.Vote(Set.of(friday)); + assertEquals(Set.of(friday), poll(Map.of("one", one, "two", two)).leadingDates()); + assertEquals(Set.of(friday, saturday), poll(Map.of("one", one)).leadingDates()); + assertEquals(Set.of(), poll(Map.of()).leadingDates()); + assertEquals(Set.of(), poll(Map.of("none", new Poll.Vote(Set.of()))).leadingDates()); + } + + @Test void creatorOrAdministratorCanManage() { + Poll poll = poll(Map.of()); + assertTrue(poll.canManage("creator", false)); + assertTrue(poll.canManage("another", true)); + assertFalse(poll.canManage("another", false)); + } + + @ParameterizedTest @CsvSource({"2026-02-30", "2026-2-01", "06/05/2026", "2026-13-01"}) + void datesAreStrictIso(String input) { + assertThrows(IllegalArgumentException.class, () -> PollListener.parseDate(input)); + } + + private Poll poll(Map votes) { + return new Poll("id", "guild", "channel", "message", "creator", "Title", + List.of(friday, friday.plusDays(1), friday.plusDays(2)), Poll.Status.OPEN, null, null, "NONE", votes); + } +} diff --git a/src/test/java/net/theinfinitymc/infinitybot/polls/PollUiTest.java b/src/test/java/net/theinfinitymc/infinitybot/polls/PollUiTest.java new file mode 100644 index 0000000..55bcecf --- /dev/null +++ b/src/test/java/net/theinfinitymc/infinitybot/polls/PollUiTest.java @@ -0,0 +1,110 @@ +package net.theinfinitymc.infinitybot.polls; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; + +class PollUiTest { + private final LocalDate first = LocalDate.of(2026, 6, 1); + private Poll poll(Poll.Status status, Map votes) { return poll(status, votes, 25); } + private Poll poll(Poll.Status status, Map votes, int days) { + return new Poll(UUID.randomUUID().toString(), "100", "200", "300", "400", "Game night", + Poll.candidateDates(first, first.plusDays(days - 1), EnumSet.allOf(DayOfWeek.class)), status, + status == Poll.Status.FINALIZED ? first : null, null, "NONE", votes); + } + + @ParameterizedTest @ValueSource(ints = {1, 10, 11, 20, 21, 25}) + void checkboxGroupsRespectLimitsAndPreserveSelectionsAcrossBoundaries(int dateCount) { + Set selected = new HashSet<>(); + for (int i = 0; i < dateCount; i += 2) selected.add(first.plusDays(i)); + Poll poll = poll(Poll.Status.OPEN, Map.of("500", new Poll.Vote(selected)), dateCount); + var modal = PollUi.voteModal(poll, "500"); + assertEquals((dateCount + 9) / 10, modal.getComponents().size()); + List allDates = new ArrayList<>(); + Set checked = new HashSet<>(); + for (int i = 0; i < modal.getComponents().size(); i++) { + var group = modal.getComponents().get(i).asLabel().getChild().asCheckboxGroup(); + assertEquals("available" + i, group.getCustomId()); + assertTrue(group.getOptions().size() <= 10); + assertEquals(0, group.getMinValues()); + assertEquals(group.getOptions().size(), group.getMaxValues()); + assertFalse(group.isRequired()); + for (var option : group.getOptions()) { + allDates.add(option.getValue()); + if (option.isDefault()) checked.add(option.getValue()); + } + } + assertEquals(poll.dates().stream().map(LocalDate::toString).toList(), allDates); + assertEquals(Set.copyOf(selected.stream().map(LocalDate::toString).toList()), checked); + } + + @Test void newRespondentHasNoCheckedDatesAndCanSubmitNone() { + var modal = PollUi.voteModal(poll(Poll.Status.OPEN, Map.of()), "500"); + for (var component : modal.getComponents()) { + var group = component.asLabel().getChild().asCheckboxGroup(); + assertFalse(group.isRequired()); + assertEquals(0, group.getMinValues()); + assertTrue(group.getOptions().stream().noneMatch(option -> option.isDefault())); + } + } + + @Test void summaryIsReadableAvailabilityListWithLeadersAndNoVisiblePollId() { + Poll poll = poll(Poll.Status.OPEN, Map.of("500", new Poll.Vote(Set.of(first)), "600", new Poll.Vote(Set.of(first)))); + var embed = PollUi.summary(poll).getEmbeds().getFirst(); + assertTrue(embed.getDescription().length() <= 4096); + assertTrue(embed.getDescription().contains("⭐ **" + PollUi.day(first) + "** — 2 of 2 available")); + assertTrue(embed.getDescription().contains("• " + PollUi.day(first.plusDays(1)) + " — 0 of 2 available")); + assertFalse(embed.getDescription().contains("```")); + assertFalse(embed.getFooter().getText().contains(poll.id())); + assertTrue(PollUi.createModal().getComponents().size() <= 5); + assertTrue(PollUi.eventModal(poll, first).getComponents().size() <= 5); + } + + @Test void emptyPollShowsDatesAndWaitingStateWithoutZeroOfZeroCounts() { + var embed = PollUi.summary(poll(Poll.Status.OPEN, Map.of())).getEmbeds().getFirst(); + assertTrue(embed.getDescription().contains("Waiting for responses")); + assertTrue(embed.getDescription().contains(PollUi.day(first))); + assertFalse(embed.getDescription().contains("0 of 0")); + } + + @Test void creationHasSevenCheckedWeekdaysWithAtLeastOneRequired() { + var days = PollUi.createModal().getComponents().get(3).asLabel().getChild().asCheckboxGroup(); + assertEquals(7, days.getOptions().size()); + assertTrue(days.getOptions().stream().allMatch(option -> option.isDefault())); + assertEquals(Arrays.stream(DayOfWeek.values()).map(DayOfWeek::name).toList(), + days.getOptions().stream().map(option -> option.getValue()).toList()); + assertEquals(1, days.getMinValues()); + assertEquals(7, days.getMaxValues()); + assertTrue(days.isRequired()); + } + + @Test void closedPollDisablesVotingButRetainsDetailsAndManagement() { + for (Poll.Status status : List.of(Poll.Status.FINALIZED, Poll.Status.CANCELLED)) { + var buttons = PollUi.summary(poll(status, Map.of())).getComponents().getFirst().asActionRow().getButtons(); + assertTrue(buttons.get(0).isDisabled()); + assertTrue(buttons.get(1).isDisabled()); + assertFalse(buttons.get(2).isDisabled()); + assertFalse(buttons.get(3).isDisabled()); + } + } + + @Test void detailsArePaginatedAndMentionSuppressed() { + Map votes = new HashMap<>(); + for (int i = 0; i < 32; i++) votes.put("1000000000000000" + i, new Poll.Vote(Set.of(first))); + Poll poll = poll(Poll.Status.OPEN, votes); + var firstPage = PollUi.details(poll, first, 0); + var lastPage = PollUi.details(poll, first, 100); + assertTrue(firstPage.getContent().length() <= 2000); + assertTrue(lastPage.getContent().contains("Page 3/3")); + assertEquals(15, firstPage.getContent().split("<@", -1).length - 1); + assertEquals(2, lastPage.getContent().split("<@", -1).length - 1); + assertTrue(firstPage.getAllowedMentions().isEmpty()); + assertThrows(IllegalArgumentException.class, () -> PollUi.details(poll, first.minusDays(1), 0)); + } +} diff --git a/src/test/java/net/theinfinitymc/infinitybot/polls/PollUpdatesTest.java b/src/test/java/net/theinfinitymc/infinitybot/polls/PollUpdatesTest.java new file mode 100644 index 0000000..d3a475b --- /dev/null +++ b/src/test/java/net/theinfinitymc/infinitybot/polls/PollUpdatesTest.java @@ -0,0 +1,69 @@ +package net.theinfinitymc.infinitybot.polls; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.util.*; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class PollUpdatesTest { + @TempDir Path directory; + + @Test void coalescesBurstAndWaitsForInFlightEditBeforeSendingFreshSnapshot() { + ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class); + Queue tasks = new ArrayDeque<>(); + when(scheduler.schedule(any(Runnable.class), eq(750L), eq(TimeUnit.MILLISECONDS))) + .thenAnswer(call -> { tasks.add(call.getArgument(0)); return null; }); + List sent = new ArrayList<>(); + Queue completions = new ArrayDeque<>(); + try (PollStore store = new PollStore(directory.resolve("polls.sqlite")); + PollUpdates updates = new PollUpdates(store, (poll, done) -> { sent.add(poll); completions.add(done); }, scheduler)) { + LocalDate date = LocalDate.of(2026, 6, 6); + Poll poll = store.create("guild", "channel", "creator", "Title", date, date, EnumSet.allOf(DayOfWeek.class)); + updates.request(poll.id()); + updates.request(poll.id()); + assertEquals(1, tasks.size()); + tasks.remove().run(); + assertEquals(1, sent.size()); + store.vote(poll.id(), "one", Set.of(date)); + updates.request(poll.id()); + store.vote(poll.id(), "two", Set.of(date)); + updates.request(poll.id()); + assertTrue(tasks.isEmpty()); // Old snapshot still in flight. + completions.remove().run(); + assertEquals(1, tasks.size()); + tasks.remove().run(); + assertEquals(2, sent.size()); + assertEquals(2, sent.getLast().votes().size()); + completions.remove().run(); + assertTrue(tasks.isEmpty()); + updates.request(poll.id()); + assertEquals(1, tasks.size()); + } + verify(scheduler).shutdownNow(); + } + + @Test void senderFailureDoesNotPermanentlyBlockFutureUpdates() { + ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class); + Queue tasks = new ArrayDeque<>(); + when(scheduler.schedule(any(Runnable.class), anyLong(), any(TimeUnit.class))) + .thenAnswer(call -> { tasks.add(call.getArgument(0)); return null; }); + try (PollStore store = new PollStore(directory.resolve("polls.sqlite")); + PollUpdates updates = new PollUpdates(store, (poll, done) -> { throw new IllegalStateException("Delivery failed"); }, scheduler)) { + LocalDate date = LocalDate.of(2026, 6, 6); + String id = store.create("guild", "channel", "creator", "Title", date, date, EnumSet.allOf(DayOfWeek.class)).id(); + updates.request(id); + tasks.remove().run(); + updates.request(id); + assertEquals(1, tasks.size()); + } + } +}