Skip to content

feat: Add full Discord bot for slash-command requesting (and more!)#231

Open
NichCodes wants to merge 19 commits into
kikootwo:mainfrom
NichCodes:feature/discord-bot-requesting
Open

feat: Add full Discord bot for slash-command requesting (and more!)#231
NichCodes wants to merge 19 commits into
kikootwo:mainfrom
NichCodes:feature/discord-bot-requesting

Conversation

@NichCodes

@NichCodes NichCodes commented Jun 18, 2026

Copy link
Copy Markdown

Add Discord bot for slash-command requesting (and more!)

Summary

Adds an optional discord.js gateway bot so users can search, request, track, and delete titles directly from Discord — mapped to their existing ReadMeABook account — alongside the Web UI. Includes an in-Discord admin approval flow, a Discord settings tab, and per-user account mapping.

The bot is a process-wide singleton gated entirely on configuration: if it isn't enabled, nothing loads and the rest of the app is unaffected. Care was taken to mirror the existing app's behavior, styling, and conventions throughout — the bot reuses the same request lifecycle as the Web UI rather than introducing a parallel one.

Motivation

ReadMeABook currently talks to Discord outbound only (webhook notifications); all requesting happens in the Web UI. This adds inbound Discord support so users can search, request, check status, and delete without leaving Discord, while keeping a single source of truth for the request lifecycle.

This Discord bot also acts as an alternative method of granting access to your RMAB instance to any server owners who don't want to expose their RMAB's Web UI to the wider internet, letting both users and admins privately make and review requests from wherever they can access Discord -- whether it be in the desktop client, web tab, or mobile app. Which is really nice since RMAB doesn't have a mobile app. (Yet.)

New features

Commands

  • /request <audiobook|ebook> [query] — searches Audible → result dropdown (with details) → confirmation card (cover thumbnail) → creates the request as the mapped user. An optional query skips the search modal and goes straight to results. Audiobooks reuse createRequestForUser; e-books reuse the existing sidecar rule (the audiobook must already be in the library), surfaced as a friendly message when it isn't.
  • /status — paginated rich embeds of the invoker's outstanding requests (admins see all): cover art, author, type, narrator, series, and status footer. Includes a "Cancel a request…" dropdown for cancellable items; cancelling re-renders the page, updates live request cards, and cleans up pending approval messages.
  • /delete — paginated rich embeds with a dropdown of deletable requests (in-flight + completed). Selecting a title shows a two-step confirmation: an enriched preview embed (Duration, Series, Format, Genre, File Size) with Confirm Delete / Cancel, with the dropdown left open so the user can switch titles. Only Confirm commits the cascading soft-delete (removes files from disk, deletes the library item from Audiobookshelf/Plex, and handles download-client torrents/NZBs respecting seeding config). Permission level is configurable (see Configuration).

Both /status and /delete paginate (Previous / Page X of Y / Next) past 10 items. Page and scope are encoded in stateless custom IDs, so pagination survives restarts.

Approval flow

  • When a request needs approval, the bot posts a rich embed (title, author, year, series, cover) to a configured channel, pings a configured admin role, and shows Approve / Deny buttons.
  • Authorized for RMAB admins or holders of the configured admin role. Approving runs the same logic as the Web UI and DMs the requester.
  • Decided messages are locked (buttons disabled); stale/duplicate clicks are handled. Cancelling an awaiting_approval request (from Discord or Web UI) rewrites the embed to 🚫 Request Cancelled with a "Cancelled by" mention and removes the buttons.

Live request cards

  • /request posts a persistent, auto-updating card (cover, description, detail fields, status footer, and a Cancel Request button while in flight). Status changes refresh the card via a hook in send-notification.processor.ts. Message refs are persisted on Request.discordCards (JSON). Placement is configurable (public / dm / both).
  • Actor identity (Requested By / Approved by / Denied by / Cancelled by) renders as a clickable <@id> mention; release year is folded into the embed title (Lonesome Dove (2025)); audiobook-only fields (Narrator, Duration, Format) are omitted for ebooks.

User mapping

  • New User.discordUserId (unique, nullable, indexed) maps a Discord account to an RMAB user.
  • Admins set it per user in the Users page edit modal. Unlinked users get a clear "ask an admin to link your account" message in Discord.

Settings

  • New Discord tab: enable toggle, bot token (encrypted, with Test Token), guild/channel/admin-role IDs, optional separate notify channel, and Resolve Names to fetch human-readable server/channel/role names for confirmation.
  • After a successful token test, a bot-identity pill (avatar + username) links to the bot's Developer Portal page; Test Token and Resolve Names auto-run on tab load when configured.
  • Saving restarts the bot at runtime — no container restart. The enable/disable toggle fully tears the bot up/down (client.destroy() on disable; short-circuits on the disabled gate at start).

Screenshots

New Discord settings tab

Discord Settings Discord Settings (continued) User Role Mangement Discord User Mapping

/request flow

Text Command Dropdown Search Confirmation

Admin approval notification

Admin Approval

/status command

/status command

/delete command

/delete command

Richer Discord webhook notifications

Enriched Discord webhook Notifications

API changes

All additive; no breaking changes to existing endpoints.

  • DELETE /api/requests/:id — replaced the old admin-only hard delete with the full cascading soft-delete service, now enforcing ownership (users delete their own, admins delete any) and returning detailed cleanup results. Added to API_TOKEN_ALLOWED_ENDPOINTS so rmab_ API tokens can manage the full request lifecycle (search → create → delete). Addresses kikootwo/ReadMeABook#228.
  • GET /api/admin/requests/pending-approval and POST /api/admin/requests/:id/approve — added to API_TOKEN_ALLOWED_ENDPOINTS so admin API tokens can list pending requests and approve/deny them programmatically.
  • PUT /api/admin/users/[id] — accepts discordUserId; the select now includes it.
  • Admin settings routes for Discord config: save, test-token, resolve-names (server/channel/role/guild).

Shared services (refactors, no Web UI behavior change)

To guarantee the Web UI and Discord behave identically, two pieces of route-embedded logic were extracted into reusable services; the original routes now call them:

  • processRequestApprovalsrc/lib/services/request-approval.service.ts (used by the approve route and the Discord Approve/Deny buttons).
  • createEbookRequestForUsersrc/lib/services/ebook-request-creator.service.ts (used by the fetch-ebook route and Discord /request ebook).

Architecture

  • Transport: persistent discord.js gateway (WebSocket), not an HTTP interactions endpoint — the app is always-on, so this avoids a public signature-verified URL and lets the bot send proactive pings/DMs.
  • Startup: getDiscordBotService().start() in src/app/api/init/route.ts (once-per-container init, same process as the Bull workers). Idempotent; no-op when unconfigured.
  • Server-only & lazy-loaded: discord.js is in serverExternalPackages, aliased out of the client bundle, and loaded via dynamic import() inside start(). A disabled bot pulls in nothing from discord.js — "off" means zero footprint.
  • Command registration: guild-scoped (instant propagation), re-registered idempotently on each ready.
  • Interaction state: encoded entirely in component customIds (≤100 chars) — no server-side session map, so flows survive restarts.
  • Logging: every handler logs actor context { discordUserId, discordUsername, rmabUserId }.

Database / dependencies

  • Adds users.discord_user_id (TEXT, unique, indexed) via prisma/migrations/20260616000000_add_discord_user_id/. Additive, nullable, applied automatically on startup.
  • Adds discord.js (^14).

Configuration (new discord category)

Key Notes
discord.enabled 'true'/'false'
discord.bot_token encrypted (AES-256-GCM)
discord.guild_id server ID
discord.request_channel_id channel for approval embeds
discord.admin_role_id pinged for approvals; grants Approve/Deny authority
discord.admin_notify_channel_id optional; approval pings go here, else request channel
discord.request_card_mode public (default) / dm / both
discord.requester_role_id optional; restricts who may /request (blank = any linked user)
discord.delete_permission own_only (default) / anyone_any / admin_only / disabled

The bot requires the Server Members privileged intent enabled in the Discord Developer Portal.

/delete permission levels: own_only (users delete own, admins delete all), anyone_any (all linked users delete any), admin_only, disabled.

Additional fixes to the base app

Bugs and UX gaps found and fixed while building this feature, independent of the bot:

Discord webhook notifications (bot-independent)

  • Webhook embeds (Settings → Notifications) now match the bot's visual standard: cover-art thumbnail, year folded into the title, description, and inline Author / Narrator / Duration / Series / Genre fields (Narrator + Duration omitted for ebooks). Metadata is sourced purely from the DB via a new enrichBookMeta helper, so embeds stay rich even with the bot disabled — the provider builds embed JSON with no discord.js import.
  • Added a "Test all event types" checkbox beside Send Test to fire one sample per event type at once.

Correctness / security

  • Self-role-change guard now compares the target's current DB role, not the JWT, so an admin demoted in another session can't re-promote with a stale token.
  • Concurrency-safe approval — request approval claims the awaiting_approval → transition atomically via conditional updateMany, preventing double-enqueued jobs/notifications when two admins approve at once.
  • Ebook notifications render as ebooksrequestType is now threaded through approval/creation so approved/pending ebook notifications no longer show audiobook fields.

UX / styling cleanup

  • Modal backdrops — fixed a Tailwind v4 regression where the removed bg-opacity-* rendered backdrops fully opaque; all modals now use bg-black/50 (Modal, Users edit, Notifications, Jobs, BookDate RecommendationCard).
  • Themed scrollbars — transparent track and theme-aware thumb applied to the Settings tab nav and main page body; fixed the WebKit viewport scrollbar not theming by targeting html instead of body.
  • Audiobookshelf settings — the "Trigger library scan after import" checkbox is now wrapped in a bordered card to match the other sections.

Documentation

All new features, API changes, configuration keys, and fixes above are fully documented in the in-app / repo documentation, following the project's token-efficient format — primarily documentation/integrations/discord-bot.md, with updates to the TABLEOFCONTENTS, database, request-approval, and settings-pages docs.

Testing

  • Build: docker compose build readmeabook succeeds (runs prisma generate + next build).
  • Type-check: tsc --noEmit clean.
  • Tests: full suite green (2595 passed, 4 skipped, 0 failed). New tests/discord/ coverage for the custom-id codec, user/admin resolution, and processRequestApproval parity; updated tests for the additive users API change; new organize-files.processor card-refresh cases.

Separate courtesy fix (unrelated to this feature): a batch of pre-existing React/jsdom tests were already failing under vitest 4 + jsdom 27, which expose localStorage without the Storage API. Resolved with a self-disabling in-memory Storage polyfill in tests/setup.ts so the suite runs clean.

Files of interest

  • Bot core: src/lib/services/discord/ (bot service, interaction router, command definitions, custom-id codec, embeds/, REST helper, handlers).
  • Shared services: src/lib/services/request-approval.service.ts, src/lib/services/ebook-request-creator.service.ts.
  • Webhook notifications: src/lib/services/notification/notification-enrichment.ts, .../providers/discord.provider.ts, src/lib/processors/send-notification.processor.ts.
  • Settings: src/app/admin/settings/tabs/DiscordTab/, src/app/api/admin/settings/discord/.
  • Docs: documentation/integrations/discord-bot.md (+ TOC, database, request-approval, settings-pages updates).

NichCodes added 18 commits June 16, 2026 20:48
Add a persistent discord.js gateway bot so linked users can request and
manage titles from Discord, mapped to their existing RMAB user.

Commands:
- /checkout <audiobook|ebook>: search Audible, pick from a dropdown, confirm,
  and create a request (ebooks reuse the sidecar "must already own" rule).
- /status: list outstanding requests (admins see all).
- /delete: remove a request (own only; admins any).

Approval: requests needing approval post a role-pinged embed with Approve/Deny
buttons in a configured channel; approving notifies the requester via DM.

Supporting changes:
- User.discordUserId (unique) + migration; admins set it on the Users page.
- Extract processRequestApproval and createEbookRequestForUser into shared
  services so the Web UI and Discord run identical code paths.
- New Discord settings tab (bot token + test, guild/channel/role IDs with
  name resolution); bot starts at app init and restarts on settings save.
- Actor-aware logging (Discord ID + display name + RMAB user).
- Docs: integrations/discord-bot.md + TOC/database/approval/settings updates.

Per-user library tagging is intentionally out of scope for this change.
Channel name resolution used GET /channels/{id}, which requires per-channel
View permission and returned 50001 Missing Access for restricted approval
channels, even though the bot was in the guild. Resolve via the guild-level
GET /guilds/{id}/channels listing (consistent with role resolution), falling
back to the direct fetch when no guildId is available.
The log appended 'd' to the action token, producing 'Request denyd via
Discord' for denials. Derive proper past tense (approved/denied) instead.
Discord request cards & approval embeds:
- Cancel awaiting-approval requests now rewrite the approval embed to
  Cancelled (with @mention) and drop the Approve/Deny buttons
- Render actor identity (Requested By / Approved / Denied / Cancelled by)
  as clickable @mentions in field values; status-only decision titles
- Add Genre field (up to two)
- Move release year into the title in parentheses, e.g. 'Lonesome Dove (2025)'
- Omit Narrator/Duration/Format for ebooks (audiobook-only)
- Show only the top-listed Author and Narrator

Admin UI / styling:
- Fix Tailwind v4 opaque-backdrop regression (bg-opacity-* -> bg-black/50)
  across all modals
- Add theme-aware scrollbars (transparent track, white thumb in dark mode)
  on the settings nav and main page scroll
- Widen the 'Link Discord Usernames' button so its label no longer wraps
- Wrap the Audiobookshelf 'scan after import' checkbox in a bordered card
Add a bot identity pill (avatar + username + external-link icon) next to
the Test Token button that links to the bot's Developer Portal page.
Auto-run Test Token and Resolve Names when the Discord tab loads so
admins see results immediately. Resolve Guild ID to show the server name.
Add a clickable Developer Portal link in the Bot Token helper text
(Input.helperText widened to ReactNode). Fix bot re-enable failure by
clearing the config service cache before restart — raw Prisma upserts
bypassed cache invalidation, so a disable/re-enable within the 60s TTL
read stale config and the bot never reconnected.
…ELETE API

- /delete now shows completed requests (available/downloaded) in addition
  to in-flight ones, so users can signal they are done with a book and
  trigger full cleanup (files, ABS/Plex library item, download client).

- New discord.delete_permission setting (own_only/anyone_any/admin_only/
  disabled) controls who may use /delete, with a matching dropdown in the
  Discord settings tab. Enforced at both command and select-menu time.

- DELETE /api/requests/:id now uses the cascading deleteRequest service
  instead of a raw hard delete, with ownership enforcement (users delete
  own, admins delete any). Added to the API token allowlist so third-party
  integrations with rmab_ tokens can manage the full request lifecycle.
… and cancel-from-status

- /status and /delete now show paginated rich embeds (cover art, author, narrator, series, status footer) instead of flat text lists, with Previous/Next buttons for lists over 10
- /status includes a "Cancel a request…" select dropdown that cancels and re-renders the page
- /delete shows a rich confirmation embed with the deleted title's metadata
- /request accepts an optional query parameter to skip the search modal
Discord webhook notifications now match the bot's request-card look without
requiring the bot to be loaded:

- Add optional book metadata (cover, narrator, series, year, genres,
  duration, description) to NotificationPayload
- New notification-enrichment.ts: DB-only enrichment (Audiobook +
  AudibleCache) wired into the send-notification processor, so all existing
  call sites are unchanged
- Rewrite the Discord provider embed: cover thumbnail, year folded into the
  title, description, and inline Author/Narrator/Duration/Series/Genre fields
  (narrator + duration suppressed for ebooks). Still raw embed JSON — no
  discord.js dependency
- Add a "Test all event types" checkbox beside Send Test that fires one
  richly-formatted sample per event; test fixture uses a real public cover

Also fix the test suite: jsdom 27 under vitest 4 exposed localStorage as an
empty object, breaking all .tsx tests — add an in-memory Storage polyfill in
tests/setup.ts (self-disabling if the runtime ever provides a real one).
…hardening

Correctness/security:
- Compare self-role-change guard against the DB role, not the JWT, so a
  stale admin token can't re-promote itself (admin user PUT route).
- Claim the approval transition atomically (conditional updateMany gated on
  status) so concurrent approvals can't double-enqueue jobs/notifications.
- Pass requestType to notifications so approved/pending ebook requests render
  ebook-typed embeds (approval service + both request creators).
- Build the live request card from the request's live DB status to close the
  card-ref persistence race.
- Reject empty/negative/non-integer pages in the custom-id decoder.
- Store null (not a synthetic "discord:<id>") as deletedBy when a Discord
  admin-role holder has no linked RMAB account.

Efficiency/cleanup:
- Reuse one REST client across resolveMembersByIds so discord.js throttles
  the batch through a single set of rate-limit buckets.
- Split embeds.ts (over the file-size cap) into embeds/ (book-fields,
  request-cards, approval, lists + barrel) and route list/confirm embeds
  and the request-select menus through shared helpers.

Docs updated for the concurrency-safe approval, the embeds module layout,
and the relationship between the bot approval message and webhook
notifications. Tests updated/added for the atomic claim, ebook notification
typing, the DB-role guard, and the custom-id page guard.

@kikootwo kikootwo left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First, credit where it's due: this is an impressive PR. I went through the security surfaces, both service extractions (diffed line by line against the old route logic), the customId codec, and the bot lifecycle. The authorization model is done right (every handler re-resolves the actor and re-checks permissions at click time instead of trusting the customId), the extractions genuinely match the old behavior, the zero-footprint-when-disabled claim holds up, token handling is clean (encrypted at rest, masked on read, never echoed back), and Discord failures never block the core request pipeline. Docs are exactly the format this project wants.

That said, there is one real lifecycle bug and a few things I want fixed before this merges. Full details are inline on the relevant lines; the short version:

Critical

  1. processRequestApproval can approve a soft-deleted (cancelled) request and enqueue a real download for it. Neither the fetch nor the two atomic claims filter deletedAt: null, and this PR adds several new ways to cancel an awaiting_approval request while the approval embed keeps live Approve/Deny buttons.

Important
2. The atomic claim now flips status to downloading (and nulls selectedTorrent) before enqueuing the job. If enqueue throws, the request is stuck in downloading with no job and the torrent choice is lost. Needs a compensating rollback.
3. Deleting a request from the web UI or via API token never rewrites the Discord request card or the approval embed, so live buttons linger forever. This is also what makes issue 1 reachable in practice. Hook the rewrites into deleteRequest itself so every surface converges.
4. restart() races an in-flight start(): settings can save but silently not apply.
5. The bot singleton uses a module-level let instead of the globalThis pattern we use in src/lib/db.ts, so dev-mode HMR can spawn duplicate gateway connections that handle every interaction twice.

Smaller things I still want addressed
6. The Discord settings PUT writes config in a non-transactional loop; a mid-loop failure leaves mixed old/new config and skips the bot restart.
7. The fetch-ebook retry path now returns 201 where the old route returned 200. Harmless, but it is the one deviation from the "mirrors exactly" claim, so either restore it or call it out.
8. Deny never DMs the requester even though the PR description says decisions do.

None of this dents the overall design, the bones here are really good. Fix 1 through 5, sweep the smaller ones, and I'm happy to merge.


try {
// Fetch the request
const existingRequest = await prisma.request.findUnique({

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the one that has to be fixed before merge. Neither this fetch nor the two atomic claims below (lines 108 and 272) filter on deletedAt: null, and our soft delete intentionally leaves status untouched, so a cancelled awaiting_approval request still matches { id, status: 'awaiting_approval' }.

That gap technically existed before, but this PR makes it a realistic path: users can now cancel from the web UI, /status, /delete, or the card button, while the approval embed keeps live Approve/Deny buttons (web-side cancels never rewrite it). An admin clicking Approve afterwards claims the row and enqueues a real search/download for a request the user already deleted. And since the web pending-approval list does filter deletedAt: null, nobody ever sees it to clean up.

Add deletedAt: null to this findUnique and to both updateMany claims, and treat a soft-deleted row as not_found.

// concurrent approvals (e.g. the Discord Approve button and the Web UI, or two admins) can't
// both pass the status check and double-enqueue download/search jobs + notifications. Only the
// actor whose conditional update actually flips the row proceeds; the loser bails as stale.
const claim = await prisma.request.updateMany({

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The atomic claim is a good fix for the double-approve race, but it swapped the ordering from the old route. We used to enqueue the download job first and flip status second, so an enqueue failure (Redis down, Bull hiccup) left the request in awaiting_approval and the admin could just click Approve again. Now we flip to downloading and null out selectedTorrent first; if addDownloadJob or addStartDirectDownloadJob throws after that, the catch returns reason: 'error' but the row is stuck in downloading with no job attached and the torrent choice gone.

Please compensate on enqueue failure inside the claimed branch: restore awaiting_approval (and the torrent) before returning the error, so the admin can retry.


const { id } = await params;
const { deleteRequest } = await import('@/lib/services/request-delete.service');
const result = await deleteRequest(id, req.user.id);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deleting here (web UI or API token) never touches the Discord side: the request card keeps a live Cancel Request button forever (clicking it says the request no longer exists, and the card still is not rewritten), and the approval embed keeps live Approve/Deny buttons. That lingering embed is exactly what makes the missing deletedAt guard in the approval service exploitable in practice.

Rather than patching each surface individually, hook editRequestCards(..., 'cancelled') and cancelApprovalMessage into deleteRequest itself (gated and dynamically imported, same pattern as the processor hooks) so every deletion path converges. And when a button handler discovers the request is gone, rewrite the card instead of leaving the dead button behind.


/** Restart with the latest config (e.g. after a settings change). */
async restart(): Promise<void> {
await this.stop();

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This races an in-flight start(). If a start is mid-login (starting is true, client still null, since it is only assigned after login() resolves), stop() finds nothing to destroy and the follow-up start() no-ops on the starting guard. The in-flight start then completes with the old config and assigns this.client. Net result: an admin saves settings and they silently do not apply. Container init racing an admin save, or two quick saves, both hit this.

A generation counter checked after login() (bail and destroy if the epoch moved), or awaiting the in-flight start before stopping, would close it.

}

// Process-wide singleton
let instance: DiscordBotService | null = null;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We stash singletons on globalThis in src/lib/db.ts specifically because Next dev-mode re-evaluates modules on HMR. A module-level let here means a dev reload can construct a second bot service and a second gateway connection, and then every interaction gets handled twice (duplicate requests and approvals while developing). Production is fine, but please use the same globalThis pattern as db.ts for consistency and sane local dev.

{ key: DISCORD_CONFIG_KEYS.deletePermission, value: asDeletePermission(deletePermission) },
];

for (const { key, value } of plainUpdates) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These upserts plus the token write below run as independent statements, so a mid-loop failure leaves the config half old and half new, and then the bot restart below never runs. Wrap the writes in a prisma.$transaction so a save is all-or-nothing.

requestId: result.requestId,
needsApproval: result.needsApproval,
},
{ status: 201 }

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tiny parity note: the old route returned 200 for the retry-existing-request path and 201 only for a fresh create; this wrapper returns 201 for every success. The web UI only checks res.ok so nothing breaks, but this is the one true deviation I found from the "mirrors the original route exactly" claim. Either restore the 200 for the retry path or note the deviation deliberately.


// Notify the requester via DM (best-effort)
if (action === 'approve') {
await notifyRequester(interaction.client, requestId).catch(() => undefined);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description says the requester gets a DM when a decision is made, but notifyRequester only fires on approve; deny sends nothing. The card footer covers it when cards are enabled, but a plain deny with cards off is silent for the requester. Either DM on deny too or adjust the description to match.

@NichCodes

Copy link
Copy Markdown
Author

Thanks for looking at it. I'll work through each of these issues and ping you for a re-review once they're (hopefully) fixed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants