feat: Add full Discord bot for slash-command requesting (and more!)#231
feat: Add full Discord bot for slash-command requesting (and more!)#231NichCodes wants to merge 19 commits into
Conversation
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
left a comment
There was a problem hiding this comment.
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
processRequestApprovalcan approve a soft-deleted (cancelled) request and enqueue a real download for it. Neither the fetch nor the two atomic claims filterdeletedAt: 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({ |
There was a problem hiding this comment.
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({ |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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 } |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
|
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. |
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 optionalqueryskips the search modal and goes straight to results. Audiobooks reusecreateRequestForUser; 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
/statusand/deletepaginate (Previous / Page X of Y / Next) past 10 items. Page and scope are encoded in stateless custom IDs, so pagination survives restarts.Approval flow
awaiting_approvalrequest (from Discord or Web UI) rewrites the embed to 🚫 Request Cancelled with a "Cancelled by" mention and removes the buttons.Live request cards
/requestposts 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 insend-notification.processor.ts. Message refs are persisted onRequest.discordCards(JSON). Placement is configurable (public/dm/both).<@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
User.discordUserId(unique, nullable, indexed) maps a Discord account to an RMAB user.Settings
client.destroy()on disable; short-circuits on the disabled gate at start).Screenshots
New Discord settings tab
/requestflowAdmin approval notification
/statuscommand/deletecommandRicher 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 toAPI_TOKEN_ALLOWED_ENDPOINTSsormab_API tokens can manage the full request lifecycle (search → create → delete). Addresses kikootwo/ReadMeABook#228.GET /api/admin/requests/pending-approvalandPOST /api/admin/requests/:id/approve— added toAPI_TOKEN_ALLOWED_ENDPOINTSso admin API tokens can list pending requests and approve/deny them programmatically.PUT /api/admin/users/[id]— acceptsdiscordUserId; the select now includes it.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:
processRequestApproval—src/lib/services/request-approval.service.ts(used by the approve route and the Discord Approve/Deny buttons).createEbookRequestForUser—src/lib/services/ebook-request-creator.service.ts(used by the fetch-ebook route and Discord/request ebook).Architecture
getDiscordBotService().start()insrc/app/api/init/route.ts(once-per-container init, same process as the Bull workers). Idempotent; no-op when unconfigured.discord.jsis inserverExternalPackages, aliased out of the client bundle, and loaded via dynamicimport()insidestart(). A disabled bot pulls in nothing from discord.js — "off" means zero footprint.ready.customIds (≤100 chars) — no server-side session map, so flows survive restarts.{ discordUserId, discordUsername, rmabUserId }.Database / dependencies
users.discord_user_id(TEXT, unique, indexed) viaprisma/migrations/20260616000000_add_discord_user_id/. Additive, nullable, applied automatically on startup.discord.js(^14).Configuration (new
discordcategory)discord.enabled'true'/'false'discord.bot_tokendiscord.guild_iddiscord.request_channel_iddiscord.admin_role_iddiscord.admin_notify_channel_iddiscord.request_card_modepublic(default) /dm/bothdiscord.requester_role_id/request(blank = any linked user)discord.delete_permissionown_only(default) /anyone_any/admin_only/disabledThe bot requires the Server Members privileged intent enabled in the Discord Developer Portal.
/deletepermission 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)
enrichBookMetahelper, so embeds stay rich even with the bot disabled — the provider builds embed JSON with nodiscord.jsimport.Correctness / security
awaiting_approval→ transition atomically via conditionalupdateMany, preventing double-enqueued jobs/notifications when two admins approve at once.requestTypeis now threaded through approval/creation so approved/pending ebook notifications no longer show audiobook fields.UX / styling cleanup
bg-opacity-*rendered backdrops fully opaque; all modals now usebg-black/50(Modal, Users edit, Notifications, Jobs, BookDate RecommendationCard).htmlinstead ofbody.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
docker compose build readmeabooksucceeds (runsprisma generate+next build).tsc --noEmitclean.tests/discord/coverage for the custom-id codec, user/admin resolution, andprocessRequestApprovalparity; updated tests for the additive users API change; neworganize-files.processorcard-refresh cases.Files of interest
src/lib/services/discord/(bot service, interaction router, command definitions, custom-id codec,embeds/, REST helper, handlers).src/lib/services/request-approval.service.ts,src/lib/services/ebook-request-creator.service.ts.src/lib/services/notification/notification-enrichment.ts,.../providers/discord.provider.ts,src/lib/processors/send-notification.processor.ts.src/app/admin/settings/tabs/DiscordTab/,src/app/api/admin/settings/discord/.documentation/integrations/discord-bot.md(+ TOC, database, request-approval, settings-pages updates).