@azurioh/discord-kernel 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/dist/authz/authorizer.d.ts +45 -0
- package/dist/authz/authorizer.d.ts.map +1 -0
- package/dist/authz/authorizer.js +31 -0
- package/dist/authz/authorizer.js.map +1 -0
- package/dist/authz/require-level-guard.d.ts +23 -0
- package/dist/authz/require-level-guard.d.ts.map +1 -0
- package/dist/authz/require-level-guard.js +37 -0
- package/dist/authz/require-level-guard.js.map +1 -0
- package/dist/clock.d.ts +13 -0
- package/dist/clock.d.ts.map +1 -0
- package/dist/clock.js +13 -0
- package/dist/clock.js.map +1 -0
- package/dist/concurrency/keyed-queue.d.ts +18 -0
- package/dist/concurrency/keyed-queue.d.ts.map +1 -0
- package/dist/concurrency/keyed-queue.js +37 -0
- package/dist/concurrency/keyed-queue.js.map +1 -0
- package/dist/concurrency/run-with-concurrency.d.ts +15 -0
- package/dist/concurrency/run-with-concurrency.d.ts.map +1 -0
- package/dist/concurrency/run-with-concurrency.js +40 -0
- package/dist/concurrency/run-with-concurrency.js.map +1 -0
- package/dist/config/env.d.ts +17 -0
- package/dist/config/env.d.ts.map +1 -0
- package/dist/config/env.js +49 -0
- package/dist/config/env.js.map +1 -0
- package/dist/config/errors.d.ts +14 -0
- package/dist/config/errors.d.ts.map +1 -0
- package/dist/config/errors.js +25 -0
- package/dist/config/errors.js.map +1 -0
- package/dist/datetime.d.ts +34 -0
- package/dist/datetime.d.ts.map +1 -0
- package/dist/datetime.js +188 -0
- package/dist/datetime.js.map +1 -0
- package/dist/discord/api-errors.d.ts +20 -0
- package/dist/discord/api-errors.d.ts.map +1 -0
- package/dist/discord/api-errors.js +90 -0
- package/dist/discord/api-errors.js.map +1 -0
- package/dist/discord/channel-exporter.d.ts +42 -0
- package/dist/discord/channel-exporter.d.ts.map +1 -0
- package/dist/discord/channel-exporter.js +3 -0
- package/dist/discord/channel-exporter.js.map +1 -0
- package/dist/discord/command/autocomplete-dispatcher.d.ts +26 -0
- package/dist/discord/command/autocomplete-dispatcher.d.ts.map +1 -0
- package/dist/discord/command/autocomplete-dispatcher.js +41 -0
- package/dist/discord/command/autocomplete-dispatcher.js.map +1 -0
- package/dist/discord/command/autocomplete.d.ts +18 -0
- package/dist/discord/command/autocomplete.d.ts.map +1 -0
- package/dist/discord/command/autocomplete.js +65 -0
- package/dist/discord/command/autocomplete.js.map +1 -0
- package/dist/discord/command/context.d.ts +33 -0
- package/dist/discord/command/context.d.ts.map +1 -0
- package/dist/discord/command/context.js +48 -0
- package/dist/discord/command/context.js.map +1 -0
- package/dist/discord/command/cooldown-guard.d.ts +25 -0
- package/dist/discord/command/cooldown-guard.d.ts.map +1 -0
- package/dist/discord/command/cooldown-guard.js +60 -0
- package/dist/discord/command/cooldown-guard.js.map +1 -0
- package/dist/discord/command/create-command.d.ts +100 -0
- package/dist/discord/command/create-command.d.ts.map +1 -0
- package/dist/discord/command/create-command.js +245 -0
- package/dist/discord/command/create-command.js.map +1 -0
- package/dist/discord/command/create-context-menu-command.d.ts +51 -0
- package/dist/discord/command/create-context-menu-command.d.ts.map +1 -0
- package/dist/discord/command/create-context-menu-command.js +128 -0
- package/dist/discord/command/create-context-menu-command.js.map +1 -0
- package/dist/discord/command/errors.d.ts +19 -0
- package/dist/discord/command/errors.d.ts.map +1 -0
- package/dist/discord/command/errors.js +27 -0
- package/dist/discord/command/errors.js.map +1 -0
- package/dist/discord/command/guard.d.ts +30 -0
- package/dist/discord/command/guard.d.ts.map +1 -0
- package/dist/discord/command/guard.js +34 -0
- package/dist/discord/command/guard.js.map +1 -0
- package/dist/discord/command/localization.d.ts +9 -0
- package/dist/discord/command/localization.d.ts.map +1 -0
- package/dist/discord/command/localization.js +13 -0
- package/dist/discord/command/localization.js.map +1 -0
- package/dist/discord/command/options.d.ts +113 -0
- package/dist/discord/command/options.d.ts.map +1 -0
- package/dist/discord/command/options.js +509 -0
- package/dist/discord/command/options.js.map +1 -0
- package/dist/discord/command/permission-guard.d.ts +21 -0
- package/dist/discord/command/permission-guard.d.ts.map +1 -0
- package/dist/discord/command/permission-guard.js +69 -0
- package/dist/discord/command/permission-guard.js.map +1 -0
- package/dist/discord/command/router.d.ts +48 -0
- package/dist/discord/command/router.d.ts.map +1 -0
- package/dist/discord/command/router.js +118 -0
- package/dist/discord/command/router.js.map +1 -0
- package/dist/discord/command/types.d.ts +51 -0
- package/dist/discord/command/types.d.ts.map +1 -0
- package/dist/discord/command/types.js +3 -0
- package/dist/discord/command/types.js.map +1 -0
- package/dist/discord/components/component-router.d.ts +116 -0
- package/dist/discord/components/component-router.d.ts.map +1 -0
- package/dist/discord/components/component-router.js +123 -0
- package/dist/discord/components/component-router.js.map +1 -0
- package/dist/discord/components/interactive-message/index.d.ts +3 -0
- package/dist/discord/components/interactive-message/index.d.ts.map +1 -0
- package/dist/discord/components/interactive-message/index.js +11 -0
- package/dist/discord/components/interactive-message/index.js.map +1 -0
- package/dist/discord/components/interactive-message/interactive-message-collector.d.ts +195 -0
- package/dist/discord/components/interactive-message/interactive-message-collector.d.ts.map +1 -0
- package/dist/discord/components/interactive-message/interactive-message-collector.js +151 -0
- package/dist/discord/components/interactive-message/interactive-message-collector.js.map +1 -0
- package/dist/discord/components/interactive-message/mount-interactive-message.d.ts +30 -0
- package/dist/discord/components/interactive-message/mount-interactive-message.d.ts.map +1 -0
- package/dist/discord/components/interactive-message/mount-interactive-message.js +44 -0
- package/dist/discord/components/interactive-message/mount-interactive-message.js.map +1 -0
- package/dist/discord/components/paginator/index.d.ts +5 -0
- package/dist/discord/components/paginator/index.d.ts.map +1 -0
- package/dist/discord/components/paginator/index.js +15 -0
- package/dist/discord/components/paginator/index.js.map +1 -0
- package/dist/discord/components/paginator/mount-paginator.d.ts +25 -0
- package/dist/discord/components/paginator/mount-paginator.d.ts.map +1 -0
- package/dist/discord/components/paginator/mount-paginator.js +41 -0
- package/dist/discord/components/paginator/mount-paginator.js.map +1 -0
- package/dist/discord/components/paginator/page-state.d.ts +21 -0
- package/dist/discord/components/paginator/page-state.d.ts.map +1 -0
- package/dist/discord/components/paginator/page-state.js +44 -0
- package/dist/discord/components/paginator/page-state.js.map +1 -0
- package/dist/discord/components/paginator/paginator-buttons.d.ts +17 -0
- package/dist/discord/components/paginator/paginator-buttons.d.ts.map +1 -0
- package/dist/discord/components/paginator/paginator-buttons.js +45 -0
- package/dist/discord/components/paginator/paginator-buttons.js.map +1 -0
- package/dist/discord/components/paginator/paginator-view.d.ts +26 -0
- package/dist/discord/components/paginator/paginator-view.d.ts.map +1 -0
- package/dist/discord/components/paginator/paginator-view.js +28 -0
- package/dist/discord/components/paginator/paginator-view.js.map +1 -0
- package/dist/discord/components/persistent-paginator.d.ts +47 -0
- package/dist/discord/components/persistent-paginator.d.ts.map +1 -0
- package/dist/discord/components/persistent-paginator.js +137 -0
- package/dist/discord/components/persistent-paginator.js.map +1 -0
- package/dist/discord/components/settings-editor/index.d.ts +7 -0
- package/dist/discord/components/settings-editor/index.d.ts.map +1 -0
- package/dist/discord/components/settings-editor/index.js +22 -0
- package/dist/discord/components/settings-editor/index.js.map +1 -0
- package/dist/discord/components/settings-editor/mount-settings-editor.d.ts +140 -0
- package/dist/discord/components/settings-editor/mount-settings-editor.d.ts.map +1 -0
- package/dist/discord/components/settings-editor/mount-settings-editor.js +344 -0
- package/dist/discord/components/settings-editor/mount-settings-editor.js.map +1 -0
- package/dist/discord/components/settings-editor/navigation.d.ts +49 -0
- package/dist/discord/components/settings-editor/navigation.d.ts.map +1 -0
- package/dist/discord/components/settings-editor/navigation.js +39 -0
- package/dist/discord/components/settings-editor/navigation.js.map +1 -0
- package/dist/discord/components/settings-editor/settings-editor-card.view.d.ts +56 -0
- package/dist/discord/components/settings-editor/settings-editor-card.view.d.ts.map +1 -0
- package/dist/discord/components/settings-editor/settings-editor-card.view.js +284 -0
- package/dist/discord/components/settings-editor/settings-editor-card.view.js.map +1 -0
- package/dist/discord/components/settings-editor/settings-editor-field-modal.d.ts +69 -0
- package/dist/discord/components/settings-editor/settings-editor-field-modal.d.ts.map +1 -0
- package/dist/discord/components/settings-editor/settings-editor-field-modal.js +353 -0
- package/dist/discord/components/settings-editor/settings-editor-field-modal.js.map +1 -0
- package/dist/discord/components/settings-editor/settings-editor-fields.d.ts +216 -0
- package/dist/discord/components/settings-editor/settings-editor-fields.d.ts.map +1 -0
- package/dist/discord/components/settings-editor/settings-editor-fields.js +66 -0
- package/dist/discord/components/settings-editor/settings-editor-fields.js.map +1 -0
- package/dist/discord/components/settings-editor/settings-editor.view.d.ts +303 -0
- package/dist/discord/components/settings-editor/settings-editor.view.d.ts.map +1 -0
- package/dist/discord/components/settings-editor/settings-editor.view.js +316 -0
- package/dist/discord/components/settings-editor/settings-editor.view.js.map +1 -0
- package/dist/discord/events/create-event.d.ts +5 -0
- package/dist/discord/events/create-event.d.ts.map +1 -0
- package/dist/discord/events/create-event.js +8 -0
- package/dist/discord/events/create-event.js.map +1 -0
- package/dist/discord/events/event-router.d.ts +16 -0
- package/dist/discord/events/event-router.d.ts.map +1 -0
- package/dist/discord/events/event-router.js +51 -0
- package/dist/discord/events/event-router.js.map +1 -0
- package/dist/discord/events/types.d.ts +16 -0
- package/dist/discord/events/types.d.ts.map +1 -0
- package/dist/discord/events/types.js +3 -0
- package/dist/discord/events/types.js.map +1 -0
- package/dist/discord/i18n.d.ts +58 -0
- package/dist/discord/i18n.d.ts.map +1 -0
- package/dist/discord/i18n.js +173 -0
- package/dist/discord/i18n.js.map +1 -0
- package/dist/discord/interaction/button.d.ts +39 -0
- package/dist/discord/interaction/button.d.ts.map +1 -0
- package/dist/discord/interaction/button.js +36 -0
- package/dist/discord/interaction/button.js.map +1 -0
- package/dist/discord/interaction/interaction-router.d.ts +21 -0
- package/dist/discord/interaction/interaction-router.d.ts.map +1 -0
- package/dist/discord/interaction/interaction-router.js +24 -0
- package/dist/discord/interaction/interaction-router.js.map +1 -0
- package/dist/discord/interaction/modal.d.ts +171 -0
- package/dist/discord/interaction/modal.d.ts.map +1 -0
- package/dist/discord/interaction/modal.js +264 -0
- package/dist/discord/interaction/modal.js.map +1 -0
- package/dist/discord/interaction/select-menu.d.ts +104 -0
- package/dist/discord/interaction/select-menu.d.ts.map +1 -0
- package/dist/discord/interaction/select-menu.js +126 -0
- package/dist/discord/interaction/select-menu.js.map +1 -0
- package/dist/discord/permissions.d.ts +32 -0
- package/dist/discord/permissions.d.ts.map +1 -0
- package/dist/discord/permissions.js +40 -0
- package/dist/discord/permissions.js.map +1 -0
- package/dist/discord/placeholder-registry-errors.d.ts +10 -0
- package/dist/discord/placeholder-registry-errors.d.ts.map +1 -0
- package/dist/discord/placeholder-registry-errors.js +18 -0
- package/dist/discord/placeholder-registry-errors.js.map +1 -0
- package/dist/discord/placeholder-registry.d.ts +53 -0
- package/dist/discord/placeholder-registry.d.ts.map +1 -0
- package/dist/discord/placeholder-registry.js +52 -0
- package/dist/discord/placeholder-registry.js.map +1 -0
- package/dist/discord/presenter.d.ts +26 -0
- package/dist/discord/presenter.d.ts.map +1 -0
- package/dist/discord/presenter.js +3 -0
- package/dist/discord/presenter.js.map +1 -0
- package/dist/discord/ui/card.d.ts +114 -0
- package/dist/discord/ui/card.d.ts.map +1 -0
- package/dist/discord/ui/card.js +162 -0
- package/dist/discord/ui/card.js.map +1 -0
- package/dist/discord/ui/color-input-errors.d.ts +12 -0
- package/dist/discord/ui/color-input-errors.d.ts.map +1 -0
- package/dist/discord/ui/color-input-errors.js +21 -0
- package/dist/discord/ui/color-input-errors.js.map +1 -0
- package/dist/discord/ui/color-input.d.ts +37 -0
- package/dist/discord/ui/color-input.d.ts.map +1 -0
- package/dist/discord/ui/color-input.js +166 -0
- package/dist/discord/ui/color-input.js.map +1 -0
- package/dist/discord/ui/colors.d.ts +44 -0
- package/dist/discord/ui/colors.d.ts.map +1 -0
- package/dist/discord/ui/colors.js +45 -0
- package/dist/discord/ui/colors.js.map +1 -0
- package/dist/discord/ui/embed.d.ts +16 -0
- package/dist/discord/ui/embed.d.ts.map +1 -0
- package/dist/discord/ui/embed.js +53 -0
- package/dist/discord/ui/embed.js.map +1 -0
- package/dist/discord/ui/message-attachments.d.ts +40 -0
- package/dist/discord/ui/message-attachments.d.ts.map +1 -0
- package/dist/discord/ui/message-attachments.js +27 -0
- package/dist/discord/ui/message-attachments.js.map +1 -0
- package/dist/errors/business-error.d.ts +41 -0
- package/dist/errors/business-error.d.ts.map +1 -0
- package/dist/errors/business-error.js +39 -0
- package/dist/errors/business-error.js.map +1 -0
- package/dist/errors/describe-error.d.ts +29 -0
- package/dist/errors/describe-error.d.ts.map +1 -0
- package/dist/errors/describe-error.js +49 -0
- package/dist/errors/describe-error.js.map +1 -0
- package/dist/errors/incident-ref.d.ts +7 -0
- package/dist/errors/incident-ref.d.ts.map +1 -0
- package/dist/errors/incident-ref.js +13 -0
- package/dist/errors/incident-ref.js.map +1 -0
- package/dist/errors/index.d.ts +4 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/errors/index.js +15 -0
- package/dist/errors/index.js.map +1 -0
- package/dist/i18n/business-message.d.ts +14 -0
- package/dist/i18n/business-message.d.ts.map +1 -0
- package/dist/i18n/business-message.js +19 -0
- package/dist/i18n/business-message.js.map +1 -0
- package/dist/i18n/catalog.d.ts +27 -0
- package/dist/i18n/catalog.d.ts.map +1 -0
- package/dist/i18n/catalog.js +25 -0
- package/dist/i18n/catalog.js.map +1 -0
- package/dist/i18n/errors.d.ts +10 -0
- package/dist/i18n/errors.d.ts.map +1 -0
- package/dist/i18n/errors.js +18 -0
- package/dist/i18n/errors.js.map +1 -0
- package/dist/i18n/index.d.ts +6 -0
- package/dist/i18n/index.d.ts.map +1 -0
- package/dist/i18n/index.js +16 -0
- package/dist/i18n/index.js.map +1 -0
- package/dist/i18n/locale.d.ts +22 -0
- package/dist/i18n/locale.d.ts.map +1 -0
- package/dist/i18n/locale.js +40 -0
- package/dist/i18n/locale.js.map +1 -0
- package/dist/i18n/translator.d.ts +35 -0
- package/dist/i18n/translator.d.ts.map +1 -0
- package/dist/i18n/translator.js +40 -0
- package/dist/i18n/translator.js.map +1 -0
- package/dist/logger.d.ts +28 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +3 -0
- package/dist/logger.js.map +1 -0
- package/dist/module/module.d.ts +42 -0
- package/dist/module/module.d.ts.map +1 -0
- package/dist/module/module.js +3 -0
- package/dist/module/module.js.map +1 -0
- package/dist/persistence/database.d.ts +24 -0
- package/dist/persistence/database.d.ts.map +1 -0
- package/dist/persistence/database.js +3 -0
- package/dist/persistence/database.js.map +1 -0
- package/dist/resilience/retry.d.ts +36 -0
- package/dist/resilience/retry.d.ts.map +1 -0
- package/dist/resilience/retry.js +78 -0
- package/dist/resilience/retry.js.map +1 -0
- package/dist/scheduler/errors.d.ts +25 -0
- package/dist/scheduler/errors.d.ts.map +1 -0
- package/dist/scheduler/errors.js +45 -0
- package/dist/scheduler/errors.js.map +1 -0
- package/dist/scheduler/scheduler.d.ts +68 -0
- package/dist/scheduler/scheduler.d.ts.map +1 -0
- package/dist/scheduler/scheduler.js +162 -0
- package/dist/scheduler/scheduler.js.map +1 -0
- package/package.json +89 -0
- package/src/authz/authorizer.ts +76 -0
- package/src/authz/require-level-guard.ts +42 -0
- package/src/clock.ts +18 -0
- package/src/concurrency/keyed-queue.ts +50 -0
- package/src/concurrency/run-with-concurrency.ts +44 -0
- package/src/config/env.ts +58 -0
- package/src/config/errors.ts +21 -0
- package/src/datetime.ts +221 -0
- package/src/discord/api-errors.ts +91 -0
- package/src/discord/channel-exporter.ts +48 -0
- package/src/discord/command/autocomplete-dispatcher.ts +54 -0
- package/src/discord/command/autocomplete.ts +76 -0
- package/src/discord/command/context.ts +85 -0
- package/src/discord/command/cooldown-guard.ts +81 -0
- package/src/discord/command/create-command.ts +431 -0
- package/src/discord/command/create-context-menu-command.ts +227 -0
- package/src/discord/command/errors.ts +22 -0
- package/src/discord/command/guard.ts +54 -0
- package/src/discord/command/localization.ts +11 -0
- package/src/discord/command/options.ts +847 -0
- package/src/discord/command/permission-guard.ts +74 -0
- package/src/discord/command/router.ts +142 -0
- package/src/discord/command/types.ts +52 -0
- package/src/discord/components/component-router.ts +213 -0
- package/src/discord/components/interactive-message/index.ts +18 -0
- package/src/discord/components/interactive-message/interactive-message-collector.ts +374 -0
- package/src/discord/components/interactive-message/mount-interactive-message.ts +71 -0
- package/src/discord/components/paginator/index.ts +17 -0
- package/src/discord/components/paginator/mount-paginator.ts +69 -0
- package/src/discord/components/paginator/page-state.ts +45 -0
- package/src/discord/components/paginator/paginator-buttons.ts +60 -0
- package/src/discord/components/paginator/paginator-view.ts +61 -0
- package/src/discord/components/persistent-paginator.ts +197 -0
- package/src/discord/components/settings-editor/index.ts +41 -0
- package/src/discord/components/settings-editor/mount-settings-editor.ts +646 -0
- package/src/discord/components/settings-editor/navigation.ts +67 -0
- package/src/discord/components/settings-editor/settings-editor-card.view.ts +344 -0
- package/src/discord/components/settings-editor/settings-editor-field-modal.ts +538 -0
- package/src/discord/components/settings-editor/settings-editor-fields.ts +290 -0
- package/src/discord/components/settings-editor/settings-editor.view.ts +654 -0
- package/src/discord/events/create-event.ts +7 -0
- package/src/discord/events/event-router.ts +53 -0
- package/src/discord/events/types.ts +17 -0
- package/src/discord/i18n.ts +173 -0
- package/src/discord/interaction/button.ts +75 -0
- package/src/discord/interaction/interaction-router.ts +28 -0
- package/src/discord/interaction/modal.ts +505 -0
- package/src/discord/interaction/select-menu.ts +246 -0
- package/src/discord/permissions.ts +57 -0
- package/src/discord/placeholder-registry-errors.ts +11 -0
- package/src/discord/placeholder-registry.ts +77 -0
- package/src/discord/presenter.ts +26 -0
- package/src/discord/ui/card.ts +303 -0
- package/src/discord/ui/color-input-errors.ts +17 -0
- package/src/discord/ui/color-input.ts +179 -0
- package/src/discord/ui/colors.ts +58 -0
- package/src/discord/ui/embed.ts +64 -0
- package/src/discord/ui/message-attachments.ts +57 -0
- package/src/errors/business-error.ts +49 -0
- package/src/errors/describe-error.ts +73 -0
- package/src/errors/incident-ref.ts +10 -0
- package/src/errors/index.ts +12 -0
- package/src/i18n/business-message.ts +23 -0
- package/src/i18n/catalog.ts +41 -0
- package/src/i18n/errors.ts +11 -0
- package/src/i18n/index.ts +11 -0
- package/src/i18n/locale.ts +41 -0
- package/src/i18n/translator.ts +78 -0
- package/src/logger.ts +28 -0
- package/src/module/module.ts +42 -0
- package/src/persistence/database.ts +23 -0
- package/src/resilience/retry.ts +102 -0
- package/src/scheduler/errors.ts +40 -0
- package/src/scheduler/scheduler.ts +248 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ActionRowBuilder,
|
|
3
|
+
ContainerBuilder,
|
|
4
|
+
MediaGalleryBuilder,
|
|
5
|
+
MediaGalleryItemBuilder,
|
|
6
|
+
SectionBuilder,
|
|
7
|
+
SeparatorBuilder,
|
|
8
|
+
SeparatorSpacingSize,
|
|
9
|
+
TextDisplayBuilder,
|
|
10
|
+
ThumbnailBuilder,
|
|
11
|
+
} from "discord.js";
|
|
12
|
+
import { type Button, createActionRow } from "@/discord/interaction/button";
|
|
13
|
+
import type { AnySelectMenuBuilder } from "@/discord/interaction/select-menu";
|
|
14
|
+
|
|
15
|
+
/** Discord refuses a message carrying more components than this, container included. */
|
|
16
|
+
export const MAX_CARD_COMPONENTS = 40;
|
|
17
|
+
|
|
18
|
+
/** Discord's cap on the characters carried by every `TextDisplay` in a message, summed. */
|
|
19
|
+
export const MAX_CARD_TEXT_CHARACTERS = 4000;
|
|
20
|
+
|
|
21
|
+
/** One image inside a `gallery` block. */
|
|
22
|
+
export interface CardImage {
|
|
23
|
+
/** A URL, `attachment://` included for a file the message itself carries. */
|
|
24
|
+
readonly url: string;
|
|
25
|
+
readonly description?: string;
|
|
26
|
+
readonly spoiler?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The two shapes an `entry` may carry besides its text, plus the bare one with
|
|
31
|
+
* neither.
|
|
32
|
+
*
|
|
33
|
+
* Modelled as a three-way union rather than one shape with two optional
|
|
34
|
+
* fields: Discord's `Section` accepts exactly **one** accessory, so `action`
|
|
35
|
+
* and `thumbnail` together is not a looser version of the block, it is an
|
|
36
|
+
* invalid one. A two-optional-field shape would only catch that at
|
|
37
|
+
* `apply()`, with Discord's own rejection as the first symptom; this union
|
|
38
|
+
* makes the combination fail to compile instead. Each arm carries the other
|
|
39
|
+
* field as `?: never` — without it, TypeScript's leniency on excess
|
|
40
|
+
* properties across a union (a property valid on *any* arm is accepted on
|
|
41
|
+
* all of them) would let both through anyway.
|
|
42
|
+
*/
|
|
43
|
+
export type CardEntryAccessory<S> =
|
|
44
|
+
| { readonly action?: never; readonly thumbnail?: never }
|
|
45
|
+
| { readonly action: Button<S>; readonly thumbnail?: never }
|
|
46
|
+
| { readonly action?: never; readonly thumbnail: string };
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* What a compiled select menu must offer for a card to render it — `apply`
|
|
50
|
+
* only, never `onSelect`.
|
|
51
|
+
*
|
|
52
|
+
* `SelectMenu<B, I>` (the DSL's own compiled shape) is contravariant in `I`,
|
|
53
|
+
* so a `SelectMenu<StringSelectMenuBuilder, StringSelectMenuInteraction>` —
|
|
54
|
+
* what `createStringSelect` hands back — is not assignable to a version
|
|
55
|
+
* fixed at the union `AnySelectMenuInteraction`. A card never reads
|
|
56
|
+
* `onSelect` (that is wired separately, through the module's
|
|
57
|
+
* `ComponentRouter`), so this asks for nothing beyond what it actually
|
|
58
|
+
* calls, and every flavour of compiled select menu satisfies it as-is.
|
|
59
|
+
*/
|
|
60
|
+
export interface CardSelectMenu {
|
|
61
|
+
readonly id: string;
|
|
62
|
+
apply(forceDisabled?: boolean): AnySelectMenuBuilder;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* One piece of a card, declared as data rather than built. `createCard`
|
|
67
|
+
* compiles a list of these into a `ContainerBuilder`, the same split
|
|
68
|
+
* `createButton`/`createModal`/`createStringSelect` keep between a
|
|
69
|
+
* definition and the builder it compiles to.
|
|
70
|
+
*/
|
|
71
|
+
export type CardBlock<S> =
|
|
72
|
+
| { readonly kind: "text"; readonly content: string }
|
|
73
|
+
| ({
|
|
74
|
+
readonly kind: "entry";
|
|
75
|
+
readonly title: string;
|
|
76
|
+
readonly lines?: readonly string[];
|
|
77
|
+
} & CardEntryAccessory<S>)
|
|
78
|
+
| { readonly kind: "controls"; readonly buttons: readonly Button<S>[] }
|
|
79
|
+
| { readonly kind: "select"; readonly menu: CardSelectMenu }
|
|
80
|
+
| {
|
|
81
|
+
readonly kind: "separator";
|
|
82
|
+
readonly spacing?: "small" | "large";
|
|
83
|
+
readonly divider?: boolean;
|
|
84
|
+
}
|
|
85
|
+
| { readonly kind: "gallery"; readonly images: readonly CardImage[] };
|
|
86
|
+
|
|
87
|
+
export interface CardDef<S> {
|
|
88
|
+
/** The container's left-edge accent colour — see `EMBED_COLORS` for the bot's palette. */
|
|
89
|
+
readonly accent?: number;
|
|
90
|
+
/**
|
|
91
|
+
* The card's content, top to bottom.
|
|
92
|
+
*
|
|
93
|
+
* `null` entries are accepted and skipped: a conditional block is declared
|
|
94
|
+
* in place, at the position it would occupy, rather than assembled into a
|
|
95
|
+
* separate array the caller builds beside this one and has to keep in
|
|
96
|
+
* sync.
|
|
97
|
+
*/
|
|
98
|
+
readonly blocks: readonly (CardBlock<S> | null)[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A compiled card: knows how to render itself for a given state. Mirrors
|
|
103
|
+
* `Button<S>.apply` (`src/core/discord/interaction/button.ts`) on purpose —
|
|
104
|
+
* same parameters, same meaning for `forceDisabled` — which is what lets
|
|
105
|
+
* `disabledControls` neutralise a card exactly the way it neutralises a
|
|
106
|
+
* button row: by applying it a second time with `true`, no special case
|
|
107
|
+
* required.
|
|
108
|
+
*/
|
|
109
|
+
export interface Card<S> {
|
|
110
|
+
apply(state: S, forceDisabled?: boolean): ContainerBuilder;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The text an `entry` block renders, whether it ends up alone in a
|
|
115
|
+
* `TextDisplay` or riding along inside a `Section`.
|
|
116
|
+
*
|
|
117
|
+
* Composed as one `TextDisplay` — the title in bold, then each line on its
|
|
118
|
+
* own row — rather than one `TextDisplay` per line: a `Section` holds at
|
|
119
|
+
* most three text children, so a title plus more than two lines would
|
|
120
|
+
* already be out of room, and a single block reads as one paragraph instead
|
|
121
|
+
* of a list of unrelated fragments.
|
|
122
|
+
*/
|
|
123
|
+
function entryContent<S>(block: Extract<CardBlock<S>, { kind: "entry" }>): string {
|
|
124
|
+
const lines = block.lines ?? [];
|
|
125
|
+
return lines.length === 0 ? `**${block.title}**` : `**${block.title}**\n${lines.join("\n")}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** How many characters of the 4000-character `TextDisplay` budget a block spends. */
|
|
129
|
+
function textCharacters<S>(block: CardBlock<S>): number {
|
|
130
|
+
if (block.kind === "text") {
|
|
131
|
+
return block.content.length;
|
|
132
|
+
}
|
|
133
|
+
if (block.kind === "entry") {
|
|
134
|
+
return entryContent(block).length;
|
|
135
|
+
}
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** How many components a block compiles to, container itself excluded. */
|
|
140
|
+
function componentCount<S>(block: CardBlock<S>): number {
|
|
141
|
+
switch (block.kind) {
|
|
142
|
+
case "text":
|
|
143
|
+
return 1;
|
|
144
|
+
case "entry":
|
|
145
|
+
// No accessory: one `TextDisplay`. Either accessory: the `Section`
|
|
146
|
+
// itself, its one `TextDisplay` child, and the accessory — three.
|
|
147
|
+
return block.action !== undefined || block.thumbnail !== undefined ? 3 : 1;
|
|
148
|
+
case "controls":
|
|
149
|
+
// The `ActionRow` plus every button it carries.
|
|
150
|
+
return 1 + block.buttons.length;
|
|
151
|
+
case "select":
|
|
152
|
+
// The `ActionRow` plus the menu itself.
|
|
153
|
+
return 2;
|
|
154
|
+
case "separator":
|
|
155
|
+
return 1;
|
|
156
|
+
case "gallery":
|
|
157
|
+
// Discord counts the gallery as one component; its items are data on
|
|
158
|
+
// it, not components of their own, the same way an embed's fields
|
|
159
|
+
// are not counted individually.
|
|
160
|
+
return 1;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Refuse a card Discord would refuse, naming what is over budget and by how
|
|
166
|
+
* much — the pattern `settings-editor.view.ts` (`assertFits`) already uses
|
|
167
|
+
* for a select menu's option cap. Checked once, at `createCard`, rather than
|
|
168
|
+
* at `apply()`: the block list does not vary with state, so failing here
|
|
169
|
+
* names the declaration instead of surfacing as a rejected message at
|
|
170
|
+
* whichever render happens to trip it first.
|
|
171
|
+
*/
|
|
172
|
+
function assertFitsBudget<S>(blocks: readonly CardBlock<S>[]): void {
|
|
173
|
+
// +1 for the container itself, which is a component in its own right.
|
|
174
|
+
const totalComponents = 1 + blocks.reduce((sum, block) => sum + componentCount(block), 0);
|
|
175
|
+
const totalCharacters = blocks.reduce((sum, block) => sum + textCharacters(block), 0);
|
|
176
|
+
|
|
177
|
+
const problems: string[] = [];
|
|
178
|
+
if (totalComponents > MAX_CARD_COMPONENTS) {
|
|
179
|
+
problems.push(
|
|
180
|
+
`${totalComponents} components (container included), Discord allows ${MAX_CARD_COMPONENTS} — ${
|
|
181
|
+
totalComponents - MAX_CARD_COMPONENTS
|
|
182
|
+
} over`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
if (totalCharacters > MAX_CARD_TEXT_CHARACTERS) {
|
|
186
|
+
problems.push(
|
|
187
|
+
`${totalCharacters} characters across its text, Discord allows ${MAX_CARD_TEXT_CHARACTERS} — ${
|
|
188
|
+
totalCharacters - MAX_CARD_TEXT_CHARACTERS
|
|
189
|
+
} over`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
if (problems.length > 0) {
|
|
193
|
+
throw new Error(`Card exceeds Discord's budget: ${problems.join("; ")}.`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Render one `entry` block: a `Section` with an accessory, or a bare `TextDisplay`. */
|
|
198
|
+
function applyEntry<S>(
|
|
199
|
+
container: ContainerBuilder,
|
|
200
|
+
block: Extract<CardBlock<S>, { kind: "entry" }>,
|
|
201
|
+
state: S,
|
|
202
|
+
forceDisabled: boolean,
|
|
203
|
+
): void {
|
|
204
|
+
const text = new TextDisplayBuilder().setContent(entryContent(block));
|
|
205
|
+
if (block.action !== undefined) {
|
|
206
|
+
container.addSectionComponents(
|
|
207
|
+
new SectionBuilder()
|
|
208
|
+
.addTextDisplayComponents(text)
|
|
209
|
+
.setButtonAccessory(block.action.apply(state, forceDisabled)),
|
|
210
|
+
);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (block.thumbnail !== undefined) {
|
|
214
|
+
container.addSectionComponents(
|
|
215
|
+
new SectionBuilder()
|
|
216
|
+
.addTextDisplayComponents(text)
|
|
217
|
+
.setThumbnailAccessory(new ThumbnailBuilder().setURL(block.thumbnail)),
|
|
218
|
+
);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
container.addTextDisplayComponents(text);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Render one block onto the container being built, for the given state. */
|
|
225
|
+
function applyBlock<S>(
|
|
226
|
+
container: ContainerBuilder,
|
|
227
|
+
block: CardBlock<S>,
|
|
228
|
+
state: S,
|
|
229
|
+
forceDisabled: boolean,
|
|
230
|
+
): void {
|
|
231
|
+
switch (block.kind) {
|
|
232
|
+
case "text":
|
|
233
|
+
container.addTextDisplayComponents(new TextDisplayBuilder().setContent(block.content));
|
|
234
|
+
return;
|
|
235
|
+
case "entry":
|
|
236
|
+
applyEntry(container, block, state, forceDisabled);
|
|
237
|
+
return;
|
|
238
|
+
case "controls":
|
|
239
|
+
container.addActionRowComponents(
|
|
240
|
+
createActionRow([...block.buttons], state, { disableAll: forceDisabled }),
|
|
241
|
+
);
|
|
242
|
+
return;
|
|
243
|
+
case "select":
|
|
244
|
+
container.addActionRowComponents(
|
|
245
|
+
new ActionRowBuilder<AnySelectMenuBuilder>().addComponents(block.menu.apply(forceDisabled)),
|
|
246
|
+
);
|
|
247
|
+
return;
|
|
248
|
+
case "separator": {
|
|
249
|
+
const separator = new SeparatorBuilder();
|
|
250
|
+
if (block.spacing !== undefined) {
|
|
251
|
+
separator.setSpacing(
|
|
252
|
+
block.spacing === "large" ? SeparatorSpacingSize.Large : SeparatorSpacingSize.Small,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
if (block.divider !== undefined) {
|
|
256
|
+
separator.setDivider(block.divider);
|
|
257
|
+
}
|
|
258
|
+
container.addSeparatorComponents(separator);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
case "gallery":
|
|
262
|
+
container.addMediaGalleryComponents(
|
|
263
|
+
new MediaGalleryBuilder().addItems(
|
|
264
|
+
...block.images.map((image) => {
|
|
265
|
+
const item = new MediaGalleryItemBuilder().setURL(image.url);
|
|
266
|
+
if (image.description !== undefined) {
|
|
267
|
+
item.setDescription(image.description);
|
|
268
|
+
}
|
|
269
|
+
if (image.spoiler !== undefined) {
|
|
270
|
+
item.setSpoiler(image.spoiler);
|
|
271
|
+
}
|
|
272
|
+
return item;
|
|
273
|
+
}),
|
|
274
|
+
),
|
|
275
|
+
);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Declare a card: a definition compiling to a `ContainerBuilder`, the
|
|
282
|
+
* Components V2 analogue of an embed. Follows the same split as
|
|
283
|
+
* `createButton`/`createModal`/`createStringSelect` — data in, a compiled
|
|
284
|
+
* value with an `apply` out — so a caller never touches a V2 builder
|
|
285
|
+
* directly.
|
|
286
|
+
*/
|
|
287
|
+
export function createCard<S>(def: CardDef<S>): Card<S> {
|
|
288
|
+
const blocks = def.blocks.filter((block): block is CardBlock<S> => block !== null);
|
|
289
|
+
assertFitsBudget(blocks);
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
apply(state, forceDisabled = false) {
|
|
293
|
+
const container = new ContainerBuilder();
|
|
294
|
+
if (def.accent !== undefined) {
|
|
295
|
+
container.setAccentColor(def.accent);
|
|
296
|
+
}
|
|
297
|
+
for (const block of blocks) {
|
|
298
|
+
applyBlock(container, block, state, forceDisabled);
|
|
299
|
+
}
|
|
300
|
+
return container;
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A bot registered a colour name longer than the field an administrator types
|
|
3
|
+
* it into. Raised while the composition root registers its aliases at boot —
|
|
4
|
+
* the alternative is a name Discord silently truncates, which then never
|
|
5
|
+
* resolves and looks like the parser rejecting a colour it actually knows.
|
|
6
|
+
*/
|
|
7
|
+
export class ColorAliasTooLongError extends Error {
|
|
8
|
+
constructor(
|
|
9
|
+
readonly alias: string,
|
|
10
|
+
readonly maxLength: number,
|
|
11
|
+
) {
|
|
12
|
+
super(
|
|
13
|
+
`Colour alias "${alias}" is ${alias.length} characters, over the ${maxLength} the input field accepts`,
|
|
14
|
+
);
|
|
15
|
+
this.name = "ColorAliasTooLongError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { ColorAliasTooLongError } from "@/discord/ui/color-input-errors";
|
|
2
|
+
import { DISCORD_BLURPLE } from "@/discord/ui/colors";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The generic web colours, at the value the name carries everywhere else — an
|
|
6
|
+
* administrator typing `orange` gets the orange they would get in CSS, not a
|
|
7
|
+
* shade this module invented.
|
|
8
|
+
*/
|
|
9
|
+
const RED = "#ff0000";
|
|
10
|
+
const BLUE = "#0000ff";
|
|
11
|
+
const GREEN = "#008000";
|
|
12
|
+
const YELLOW = "#ffff00";
|
|
13
|
+
const ORANGE = "#ffa500";
|
|
14
|
+
const PURPLE = "#800080";
|
|
15
|
+
const PINK = "#ffc0cb";
|
|
16
|
+
const WHITE = "#ffffff";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Not `#000000`: Discord reads a zero colour as "no colour set" and renders the
|
|
20
|
+
* default grey bar instead, so pure black is the one value an administrator
|
|
21
|
+
* could ask for and never see. One step off zero reads as black.
|
|
22
|
+
*/
|
|
23
|
+
const BLACK = "#010101";
|
|
24
|
+
|
|
25
|
+
const GREY = "#808080";
|
|
26
|
+
const CYAN = "#00ffff";
|
|
27
|
+
const BROWN = "#a52a2a";
|
|
28
|
+
const SILVER = "#c0c0c0";
|
|
29
|
+
const TURQUOISE = "#40e0d0";
|
|
30
|
+
const MAGENTA = "#ff00ff";
|
|
31
|
+
const NAVY = "#000080";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Colour names an administrator may type instead of a hex code, French and
|
|
35
|
+
* English side by side.
|
|
36
|
+
*
|
|
37
|
+
* This table is **input vocabulary, not display text**. Nothing in it is ever
|
|
38
|
+
* shown to anyone, and its keys must never be translated at runtime: a catalog
|
|
39
|
+
* lookup resolves against the reply locale, which would make `rouge` stop
|
|
40
|
+
* working the moment an admin's Discord is set to English — the opposite of the
|
|
41
|
+
* point. Both spellings are accepted from everyone, always, which only a flat
|
|
42
|
+
* table can promise.
|
|
43
|
+
*
|
|
44
|
+
* These are the names the kernel ships. A bot adds its own — the colour it
|
|
45
|
+
* calls by its brand's name — through {@link registerColorAliases}, which is
|
|
46
|
+
* also why this table is not the one lookups read: {@link COLOR_ALIASES} is.
|
|
47
|
+
*/
|
|
48
|
+
const BUILT_IN_COLOR_ALIASES: Readonly<Record<string, string>> = {
|
|
49
|
+
red: RED,
|
|
50
|
+
rouge: RED,
|
|
51
|
+
blue: BLUE,
|
|
52
|
+
bleu: BLUE,
|
|
53
|
+
green: GREEN,
|
|
54
|
+
vert: GREEN,
|
|
55
|
+
yellow: YELLOW,
|
|
56
|
+
jaune: YELLOW,
|
|
57
|
+
orange: ORANGE,
|
|
58
|
+
purple: PURPLE,
|
|
59
|
+
// French for purple, not the CSS `violet` (a pale pink): an admin typing it
|
|
60
|
+
// here is asking for the colour the word means in their own language.
|
|
61
|
+
violet: PURPLE,
|
|
62
|
+
pink: PINK,
|
|
63
|
+
rose: PINK,
|
|
64
|
+
white: WHITE,
|
|
65
|
+
blanc: WHITE,
|
|
66
|
+
black: BLACK,
|
|
67
|
+
noir: BLACK,
|
|
68
|
+
grey: GREY,
|
|
69
|
+
gray: GREY,
|
|
70
|
+
gris: GREY,
|
|
71
|
+
cyan: CYAN,
|
|
72
|
+
brown: BROWN,
|
|
73
|
+
marron: BROWN,
|
|
74
|
+
silver: SILVER,
|
|
75
|
+
argent: SILVER,
|
|
76
|
+
argenté: SILVER,
|
|
77
|
+
turquoise: TURQUOISE,
|
|
78
|
+
magenta: MAGENTA,
|
|
79
|
+
navy: NAVY,
|
|
80
|
+
marine: NAVY,
|
|
81
|
+
blurple: DISCORD_BLURPLE,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Accents must not be a trap: `dore` and `doré` are the same request, and an
|
|
86
|
+
* administrator has no way of knowing which one this file happened to spell.
|
|
87
|
+
* Case and surrounding whitespace are folded for the same reason.
|
|
88
|
+
*/
|
|
89
|
+
function normalizeColorName(raw: string): string {
|
|
90
|
+
return raw
|
|
91
|
+
.trim()
|
|
92
|
+
.toLowerCase()
|
|
93
|
+
.normalize("NFD")
|
|
94
|
+
.replace(/\p{Diacritic}/gu, "");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Every name a lookup answers to: the kernel's own, plus whatever the bot
|
|
99
|
+
* registered. Exposed for inspection — a screen listing the colours it accepts
|
|
100
|
+
* reads this — never for mutation, which goes through
|
|
101
|
+
* {@link registerColorAliases} so the length check cannot be skipped.
|
|
102
|
+
*/
|
|
103
|
+
export const COLOR_ALIASES: Readonly<Record<string, string>> = {
|
|
104
|
+
...BUILT_IN_COLOR_ALIASES,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const COLORS_BY_NORMALIZED_NAME = new Map(
|
|
108
|
+
Object.entries(COLOR_ALIASES).map(([name, hex]) => [normalizeColorName(name), hex]),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
/** The `#rrggbb` a recognised colour name stands for, `null` for anything else. */
|
|
112
|
+
function resolveNamedColor(raw: string): string | null {
|
|
113
|
+
return COLORS_BY_NORMALIZED_NAME.get(normalizeColorName(raw)) ?? null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** `#RRGGBB` — the longest hexadecimal form the parser accepts. */
|
|
117
|
+
const HEX_INPUT_LENGTH = 7;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* What a colour input field may hold.
|
|
121
|
+
*
|
|
122
|
+
* Derived from the built-in table rather than written down, so adding a longer
|
|
123
|
+
* name here cannot leave the field silently truncating it at the client — and
|
|
124
|
+
* fixed rather than recomputed on registration, because field definitions read
|
|
125
|
+
* it once at module load, long before a bot registers anything.
|
|
126
|
+
*/
|
|
127
|
+
export const MAX_COLOR_INPUT_LENGTH = Math.max(
|
|
128
|
+
HEX_INPUT_LENGTH,
|
|
129
|
+
...Object.keys(BUILT_IN_COLOR_ALIASES).map((name) => name.length),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Teach the parser the colours this bot calls by name — its brand gold, its
|
|
134
|
+
* team colours — from the composition root, before any module is constructed.
|
|
135
|
+
*
|
|
136
|
+
* A name over {@link MAX_COLOR_INPUT_LENGTH} is refused rather than accepted:
|
|
137
|
+
* the input field is already sized, so registering a longer name would let an
|
|
138
|
+
* administrator type a colour the client cuts short, which then reads as the
|
|
139
|
+
* parser rejecting a name it actually knows.
|
|
140
|
+
*
|
|
141
|
+
* @throws ColorAliasTooLongError when a name cannot fit the input field.
|
|
142
|
+
*/
|
|
143
|
+
export function registerColorAliases(aliases: Readonly<Record<string, string>>): void {
|
|
144
|
+
for (const [name, hex] of Object.entries(aliases)) {
|
|
145
|
+
if (name.length > MAX_COLOR_INPUT_LENGTH) {
|
|
146
|
+
throw new ColorAliasTooLongError(name, MAX_COLOR_INPUT_LENGTH);
|
|
147
|
+
}
|
|
148
|
+
// Both views are written: the table is what a screen lists, the map is what
|
|
149
|
+
// a lookup reads, and a name present in one but not the other would be a
|
|
150
|
+
// colour the bot advertises and refuses.
|
|
151
|
+
(COLOR_ALIASES as Record<string, string>)[name] = hex;
|
|
152
|
+
COLORS_BY_NORMALIZED_NAME.set(normalizeColorName(name), hex);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Discord accepts `#RGB` and `#RRGGBB`, with or without the leading hash. */
|
|
157
|
+
const HEX_COLOR_PATTERN = /^#?(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Normalize a colour input to `#RRGGBB`, from a hexadecimal code or from one of
|
|
161
|
+
* the names in {@link COLOR_ALIASES} — an administrator picking a colour should
|
|
162
|
+
* not have to look a hex code up. `null` for anything that is neither: the
|
|
163
|
+
* refusal an administrator reads belongs to whichever screen asked, so core
|
|
164
|
+
* reports that it could not read the value and leaves the wording to the caller.
|
|
165
|
+
*/
|
|
166
|
+
export function parseColorInput(raw: string): string | null {
|
|
167
|
+
const value = raw.trim();
|
|
168
|
+
const named = resolveNamedColor(value);
|
|
169
|
+
if (named !== null) {
|
|
170
|
+
return named;
|
|
171
|
+
}
|
|
172
|
+
if (!HEX_COLOR_PATTERN.test(value)) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
const digits = value.replace("#", "").toLowerCase();
|
|
176
|
+
const expanded =
|
|
177
|
+
digits.length === 3 ? [...digits].map((digit) => `${digit}${digit}`).join("") : digits;
|
|
178
|
+
return `#${expanded}`;
|
|
179
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The five roles an embed colour can play. A bot restyles by supplying its own
|
|
3
|
+
* values for these, never by adding a sixth: a role the kernel does not know is
|
|
4
|
+
* a module's own constant, not a theme entry.
|
|
5
|
+
*/
|
|
6
|
+
export interface EmbedColors {
|
|
7
|
+
success: number;
|
|
8
|
+
warning: number;
|
|
9
|
+
danger: number;
|
|
10
|
+
/** The bot's own identity, for embeds that should not read as Discord's. */
|
|
11
|
+
brand: number;
|
|
12
|
+
/** Neutral informational embeds — listings, recaps, previews. */
|
|
13
|
+
neutral: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Convert a `#rrggbb` string to the integer discord.js wants for `setColor`. */
|
|
17
|
+
export function toDiscordColor(hex: string): number {
|
|
18
|
+
return Number.parseInt(hex.slice(1), 16);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Discord's own brand colour, the fallback for a bot that declares no palette. */
|
|
22
|
+
export const DISCORD_BLURPLE = "#5865f2";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* What every embed renders as until the composition root says otherwise. Kept
|
|
26
|
+
* deliberately unbranded: a kernel that shipped one bot's colours would make
|
|
27
|
+
* every other bot start out wearing them.
|
|
28
|
+
*/
|
|
29
|
+
export const DEFAULT_EMBED_COLORS: Readonly<EmbedColors> = {
|
|
30
|
+
success: toDiscordColor("#22c55e"),
|
|
31
|
+
warning: toDiscordColor("#e67e22"),
|
|
32
|
+
danger: toDiscordColor("#ef4444"),
|
|
33
|
+
brand: toDiscordColor(DISCORD_BLURPLE),
|
|
34
|
+
neutral: toDiscordColor(DISCORD_BLURPLE),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The live palette every embed reads, in the kernel and in the modules alike.
|
|
39
|
+
*
|
|
40
|
+
* This is the one piece of module-level state the kernel keeps, and it is a
|
|
41
|
+
* deliberate exception to "DI, no singletons". A colour is read deep inside pure
|
|
42
|
+
* view functions that build an embed from a value object — threading a theme
|
|
43
|
+
* through every one of them would put a parameter nobody reasons about on the
|
|
44
|
+
* whole rendering surface, to express something that is global by nature: a bot
|
|
45
|
+
* has one palette, decided once, for its entire run.
|
|
46
|
+
*
|
|
47
|
+
* Read it, never copy it: destructuring at module load captures the defaults
|
|
48
|
+
* before {@link configureEmbedColors} has run.
|
|
49
|
+
*/
|
|
50
|
+
export const EMBED_COLORS: EmbedColors = { ...DEFAULT_EMBED_COLORS };
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Replace some or all of the palette. Call it from the composition root, before
|
|
54
|
+
* any module is constructed — after that, an embed may already have rendered.
|
|
55
|
+
*/
|
|
56
|
+
export function configureEmbedColors(overrides: Partial<EmbedColors>): void {
|
|
57
|
+
Object.assign(EMBED_COLORS, overrides);
|
|
58
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { EmbedBuilder } from "discord.js";
|
|
2
|
+
|
|
3
|
+
/** Discord hard limit on the number of fields per embed. */
|
|
4
|
+
const MAX_EMBED_FIELDS = 25;
|
|
5
|
+
/** Discord hard limit on a field name length. */
|
|
6
|
+
const MAX_FIELD_NAME = 256;
|
|
7
|
+
/** Discord hard limit on a field value length. */
|
|
8
|
+
const MAX_FIELD_VALUE = 1024;
|
|
9
|
+
/** Discord hard limit on the total character count of an embed. */
|
|
10
|
+
const MAX_EMBED_TOTAL = 6000;
|
|
11
|
+
/** Headroom kept free for footer/title so the total stays under the limit. */
|
|
12
|
+
const EMBED_TOTAL_SAFETY_MARGIN = 256;
|
|
13
|
+
|
|
14
|
+
/** A single embed field. */
|
|
15
|
+
export interface EmbedField {
|
|
16
|
+
name: string;
|
|
17
|
+
value: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Add fields to an embed while respecting Discord's size limits. Truncates
|
|
22
|
+
* over-long names/values, stops before exceeding the 25-field or 6000-character
|
|
23
|
+
* caps, and appends an overflow footer when some fields were dropped — so the
|
|
24
|
+
* embed can always be sent (no `MAX_EMBED_SIZE_EXCEEDED` rejection).
|
|
25
|
+
*/
|
|
26
|
+
export function appendBoundedFields(
|
|
27
|
+
embed: EmbedBuilder,
|
|
28
|
+
fields: EmbedField[],
|
|
29
|
+
overflowFooter: (hiddenCount: number) => string,
|
|
30
|
+
): void {
|
|
31
|
+
let usedChars = (embed.data.title?.length ?? 0) + (embed.data.description?.length ?? 0);
|
|
32
|
+
let shown = 0;
|
|
33
|
+
|
|
34
|
+
for (const field of fields) {
|
|
35
|
+
if (shown >= MAX_EMBED_FIELDS) {
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
const name = truncate(field.name, MAX_FIELD_NAME);
|
|
39
|
+
const value = truncate(field.value, MAX_FIELD_VALUE);
|
|
40
|
+
if (usedChars + name.length + value.length > MAX_EMBED_TOTAL - EMBED_TOTAL_SAFETY_MARGIN) {
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
embed.addFields({ name, value });
|
|
44
|
+
usedChars += name.length + value.length;
|
|
45
|
+
shown += 1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const hidden = fields.length - shown;
|
|
49
|
+
if (hidden > 0) {
|
|
50
|
+
embed.setFooter({ text: overflowFooter(hidden) });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function truncate(text: string, max: number): string {
|
|
55
|
+
if (text.length <= max) {
|
|
56
|
+
return text;
|
|
57
|
+
}
|
|
58
|
+
return `${text.slice(0, max - 1)}…`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Build a plain titled embed. */
|
|
62
|
+
export function buildEmbed(title: string, description: string): EmbedBuilder {
|
|
63
|
+
return new EmbedBuilder().setTitle(title).setDescription(description);
|
|
64
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Attachment } from "discord.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A file a message carries: the bytes, and the name an embed resolves an
|
|
5
|
+
* `attachment://` reference on.
|
|
6
|
+
*
|
|
7
|
+
* `Uint8Array` rather than `Buffer` so a caller can produce one without
|
|
8
|
+
* reaching for a Node-specific type; the conversion happens here, at the edge
|
|
9
|
+
* that hands it to discord.js.
|
|
10
|
+
*/
|
|
11
|
+
export interface MessageFile {
|
|
12
|
+
readonly name: string;
|
|
13
|
+
readonly bytes: Uint8Array;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A file as discord.js takes it on a send or an edit. */
|
|
17
|
+
export interface MessageUpload {
|
|
18
|
+
readonly attachment: Buffer;
|
|
19
|
+
readonly name: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** What an edit has to state for a message to end up carrying exactly what was asked. */
|
|
23
|
+
export interface PlannedAttachments {
|
|
24
|
+
readonly attachments: Attachment[];
|
|
25
|
+
readonly files: MessageUpload[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function toMessageUpload(file: MessageFile): MessageUpload {
|
|
29
|
+
return { attachment: Buffer.from(file.bytes), name: file.name };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Split what a message should carry into what is already up there and what has
|
|
34
|
+
* to be uploaded.
|
|
35
|
+
*
|
|
36
|
+
* Discord reads an edit's `attachments` field as the complete set the message
|
|
37
|
+
* keeps — so `attachments: []` strips whatever it was published with, and
|
|
38
|
+
* re-sending the bytes to hold on to a file would upload the same one again on
|
|
39
|
+
* every unrelated re-render. Existing attachments are matched by name because
|
|
40
|
+
* that is what an embed resolves `attachment://` on, so a file already up there
|
|
41
|
+
* under the wanted name is the very one to keep.
|
|
42
|
+
*
|
|
43
|
+
* A caller carrying no files of its own never reaches this: it states neither
|
|
44
|
+
* field, and Discord leaves the message's own alone.
|
|
45
|
+
*/
|
|
46
|
+
export function planMessageAttachments(
|
|
47
|
+
existing: readonly Attachment[],
|
|
48
|
+
wanted: readonly MessageFile[],
|
|
49
|
+
): PlannedAttachments {
|
|
50
|
+
const kept = existing.filter((attachment) =>
|
|
51
|
+
wanted.some((file) => file.name === attachment.name),
|
|
52
|
+
);
|
|
53
|
+
const uploaded = wanted.filter(
|
|
54
|
+
(file) => !kept.some((attachment) => attachment.name === file.name),
|
|
55
|
+
);
|
|
56
|
+
return { attachments: kept, files: uploaded.map(toMessageUpload) };
|
|
57
|
+
}
|