@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
package/package.json
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@azurioh/discord-kernel",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Framework kernel for Discord bots: command DSL, guards, interaction routing, composed components, i18n, ports (Presenter, Logger, DatabaseConnection, Authorizer, Scheduler).",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"discord",
|
|
7
|
+
"discord.js",
|
|
8
|
+
"discord-bot",
|
|
9
|
+
"framework",
|
|
10
|
+
"clean-architecture",
|
|
11
|
+
"hexagonal",
|
|
12
|
+
"typescript",
|
|
13
|
+
"i18n"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "Alan Cunin (https://github.com/Azurioh)",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/Azurioh/discord-kernel.git"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/Azurioh/discord-kernel/issues"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/Azurioh/discord-kernel#readme",
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=22.12.0"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"exports": {
|
|
32
|
+
"./package.json": "./package.json",
|
|
33
|
+
"./errors": {
|
|
34
|
+
"types": "./dist/errors/index.d.ts",
|
|
35
|
+
"default": "./dist/errors/index.js"
|
|
36
|
+
},
|
|
37
|
+
"./i18n": {
|
|
38
|
+
"types": "./dist/i18n/index.d.ts",
|
|
39
|
+
"default": "./dist/i18n/index.js"
|
|
40
|
+
},
|
|
41
|
+
"./discord/components/interactive-message": {
|
|
42
|
+
"types": "./dist/discord/components/interactive-message/index.d.ts",
|
|
43
|
+
"default": "./dist/discord/components/interactive-message/index.js"
|
|
44
|
+
},
|
|
45
|
+
"./discord/components/paginator": {
|
|
46
|
+
"types": "./dist/discord/components/paginator/index.d.ts",
|
|
47
|
+
"default": "./dist/discord/components/paginator/index.js"
|
|
48
|
+
},
|
|
49
|
+
"./discord/components/settings-editor": {
|
|
50
|
+
"types": "./dist/discord/components/settings-editor/index.d.ts",
|
|
51
|
+
"default": "./dist/discord/components/settings-editor/index.js"
|
|
52
|
+
},
|
|
53
|
+
"./*": {
|
|
54
|
+
"types": "./dist/*.d.ts",
|
|
55
|
+
"default": "./dist/*.js"
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"files": [
|
|
59
|
+
"dist",
|
|
60
|
+
"src"
|
|
61
|
+
],
|
|
62
|
+
"scripts": {
|
|
63
|
+
"build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
|
|
64
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
65
|
+
"lint": "biome ci",
|
|
66
|
+
"format": "biome check --write",
|
|
67
|
+
"knip": "knip",
|
|
68
|
+
"test": "vitest run",
|
|
69
|
+
"test:watch": "vitest",
|
|
70
|
+
"prepublishOnly": "npm run build"
|
|
71
|
+
},
|
|
72
|
+
"dependencies": {
|
|
73
|
+
"node-cron": "4.6.0"
|
|
74
|
+
},
|
|
75
|
+
"peerDependencies": {
|
|
76
|
+
"discord.js": "^14.27.0"
|
|
77
|
+
},
|
|
78
|
+
"devDependencies": {
|
|
79
|
+
"@biomejs/biome": "2.5.7",
|
|
80
|
+
"@types/node": "24.13.3",
|
|
81
|
+
"discord.js": "14.27.0",
|
|
82
|
+
"knip": "^6.15.0",
|
|
83
|
+
"tsc-alias": "^1.8.16",
|
|
84
|
+
"typescript": "^5.9.3",
|
|
85
|
+
"vite-tsconfig-paths": "^6.1.1",
|
|
86
|
+
"vitest": "^4.1.8"
|
|
87
|
+
},
|
|
88
|
+
"packageManager": "pnpm@11.20.0"
|
|
89
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** Permission tiers, lowest to highest. `editor` implies `viewer`. */
|
|
2
|
+
export type PermissionLevel = "viewer" | "editor";
|
|
3
|
+
|
|
4
|
+
/** Numeric rank for comparing levels (`editor` ≥ `viewer`). */
|
|
5
|
+
export const LEVEL_RANK: Record<PermissionLevel, number> = {
|
|
6
|
+
viewer: 1,
|
|
7
|
+
editor: 2,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** A single grant: either a role or a user, at a given level. */
|
|
11
|
+
export interface PermissionGrant {
|
|
12
|
+
readonly targetType: "role" | "user";
|
|
13
|
+
readonly targetId: string;
|
|
14
|
+
readonly level: PermissionLevel;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The full set of grants, split by target kind for cheap resolution. */
|
|
18
|
+
export interface PermissionGrants {
|
|
19
|
+
readonly roles: Record<string, PermissionLevel>;
|
|
20
|
+
readonly users: Record<string, PermissionLevel>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Cross-cutting authorization port for feature-level permission tiers (as
|
|
25
|
+
* opposed to Discord's own per-command `defaultMemberPermissions`/guild
|
|
26
|
+
* permissions, which gate *visibility* and native capabilities). A module
|
|
27
|
+
* grants a role or a user a level; a guard checks whether a member meets the
|
|
28
|
+
* level required for an action.
|
|
29
|
+
*
|
|
30
|
+
* Deliberately has no notion of "admin": a bot-wide administrator bypass is a
|
|
31
|
+
* Discord permission concern, composed at the guard level with
|
|
32
|
+
* `anyOf(createPermissionGuard([Administrator]), requireLevel(...))` — keeping
|
|
33
|
+
* this port focused on the one thing it owns, feature-level grants.
|
|
34
|
+
*/
|
|
35
|
+
export interface Authorizer {
|
|
36
|
+
/** Whether the user (directly, or via one of their roles) meets `required`. */
|
|
37
|
+
meetsLevel(
|
|
38
|
+
userId: string,
|
|
39
|
+
roleIds: readonly string[],
|
|
40
|
+
required: PermissionLevel,
|
|
41
|
+
): Promise<boolean>;
|
|
42
|
+
grantRole(roleId: string, level: PermissionLevel): Promise<void>;
|
|
43
|
+
grantUser(userId: string, level: PermissionLevel): Promise<void>;
|
|
44
|
+
/** Returns whether a grant existed to remove. */
|
|
45
|
+
revokeRole(roleId: string): Promise<boolean>;
|
|
46
|
+
/** Returns whether a grant existed to remove. */
|
|
47
|
+
revokeUser(userId: string): Promise<boolean>;
|
|
48
|
+
listGrants(): Promise<PermissionGrants>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Shared resolution used by every adapter: a direct user grant wins over role
|
|
53
|
+
* grants; otherwise the highest matching role level applies. Returns whether
|
|
54
|
+
* the resolved level meets `required`.
|
|
55
|
+
*/
|
|
56
|
+
export function meetsResolvedLevel(
|
|
57
|
+
grants: PermissionGrants,
|
|
58
|
+
userId: string,
|
|
59
|
+
roleIds: readonly string[],
|
|
60
|
+
required: PermissionLevel,
|
|
61
|
+
): boolean {
|
|
62
|
+
const userLevel = grants.users[userId];
|
|
63
|
+
let resolved: PermissionLevel | null = userLevel ?? null;
|
|
64
|
+
if (!resolved) {
|
|
65
|
+
for (const roleId of roleIds) {
|
|
66
|
+
const roleLevel = grants.roles[roleId];
|
|
67
|
+
if (roleLevel && (!resolved || LEVEL_RANK[roleLevel] > LEVEL_RANK[resolved])) {
|
|
68
|
+
resolved = roleLevel;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (!resolved) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
return LEVEL_RANK[resolved] >= LEVEL_RANK[required];
|
|
76
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { BaseInteraction, CommandInteraction } from "discord.js";
|
|
2
|
+
import type { Authorizer, PermissionLevel } from "@/authz/authorizer";
|
|
3
|
+
import type { Guard } from "@/discord/command/guard";
|
|
4
|
+
import type { LocalizedText } from "@/i18n";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Role IDs held by the interacting member, resolved without a network call:
|
|
8
|
+
* `interaction.member.roles` is either the cached `GuildMemberRoleManager` (a
|
|
9
|
+
* cached-guild interaction) or a plain array of IDs (the raw API shape for an
|
|
10
|
+
* uncached one) — both are handled so the guard never has to fetch the member.
|
|
11
|
+
*
|
|
12
|
+
* Typed on the base `Interaction`, not on the command flavour a guard takes: a
|
|
13
|
+
* control clicked inside a message carries the member exactly the same way, and
|
|
14
|
+
* a rule that has to hold on both paths must be able to ask both.
|
|
15
|
+
*/
|
|
16
|
+
export function memberRoleIds(interaction: BaseInteraction): string[] {
|
|
17
|
+
const member = interaction.member;
|
|
18
|
+
if (!member || typeof member !== "object" || !("roles" in member)) {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
const roles = member.roles;
|
|
22
|
+
return Array.isArray(roles) ? roles : Array.from(roles.cache.keys());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Deny unless the member meets `required` on the injected {@link Authorizer}
|
|
27
|
+
* (directly, or via one of their roles). Compose with a Discord-permission
|
|
28
|
+
* guard through `anyOf(...)` when a bot-wide administrator bypass is wanted —
|
|
29
|
+
* this guard alone knows nothing about Discord permissions.
|
|
30
|
+
*/
|
|
31
|
+
export function requireLevel(
|
|
32
|
+
authz: Authorizer,
|
|
33
|
+
required: PermissionLevel,
|
|
34
|
+
denyMessage: LocalizedText,
|
|
35
|
+
): Guard {
|
|
36
|
+
return {
|
|
37
|
+
async check(interaction: CommandInteraction) {
|
|
38
|
+
const ok = await authz.meetsLevel(interaction.user.id, memberRoleIds(interaction), required);
|
|
39
|
+
return ok ? { ok: true } : { ok: false, message: denyMessage };
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
package/src/clock.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Abstraction over the current time. Injecting a clock (instead of calling
|
|
3
|
+
* `new Date()` directly) lets domain/application code be tested deterministically
|
|
4
|
+
* and keeps those layers free of ambient I/O.
|
|
5
|
+
*/
|
|
6
|
+
export interface Clock {
|
|
7
|
+
now(): Date;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The real clock, backed by the system time. */
|
|
11
|
+
export const systemClock: Clock = {
|
|
12
|
+
now: () => new Date(),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** A fixed clock for tests. */
|
|
16
|
+
export function fixedClock(instant: Date): Clock {
|
|
17
|
+
return { now: () => new Date(instant) };
|
|
18
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialises asynchronous work per key while leaving distinct keys concurrent.
|
|
3
|
+
*/
|
|
4
|
+
export interface KeyedQueue {
|
|
5
|
+
/** Run `task` after every task already queued for `key` has settled. */
|
|
6
|
+
run<T>(key: string, task: () => Promise<T>): Promise<T>;
|
|
7
|
+
/** Number of keys with work in flight — exposed so tests can assert no leak. */
|
|
8
|
+
readonly size: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Gateway handlers fire concurrently for the same entity (two rapid events on one
|
|
13
|
+
* channel, a member joining twice, an event replay after a resume), so
|
|
14
|
+
* read-modify-write logic keyed on that entity interleaves and corrupts state.
|
|
15
|
+
* Chaining per key restores ordering without a global lock, which would
|
|
16
|
+
* needlessly serialise unrelated guilds.
|
|
17
|
+
*/
|
|
18
|
+
export function createKeyedQueue(): KeyedQueue {
|
|
19
|
+
const chains = new Map<string, Promise<unknown>>();
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
get size() {
|
|
23
|
+
return chains.size;
|
|
24
|
+
},
|
|
25
|
+
run<T>(key: string, task: () => Promise<T>): Promise<T> {
|
|
26
|
+
const previous = chains.get(key) ?? Promise.resolve();
|
|
27
|
+
// The chain continues on the *settled* tail: a rejected task must not
|
|
28
|
+
// poison the queue for the tasks behind it, while `result` keeps the
|
|
29
|
+
// rejection observable by this caller alone.
|
|
30
|
+
const settledTail = previous.then(
|
|
31
|
+
() => undefined,
|
|
32
|
+
() => undefined,
|
|
33
|
+
);
|
|
34
|
+
const result = settledTail.then(task);
|
|
35
|
+
const tail = result.then(
|
|
36
|
+
() => undefined,
|
|
37
|
+
() => undefined,
|
|
38
|
+
);
|
|
39
|
+
chains.set(key, tail);
|
|
40
|
+
// Only the task that is still the tail may drop the entry, otherwise a
|
|
41
|
+
// later enqueue would be forgotten and the Map would grow unbounded.
|
|
42
|
+
void tail.then(() => {
|
|
43
|
+
if (chains.get(key) === tail) {
|
|
44
|
+
chains.delete(key);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
return result;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs every task in `items` through `worker`, never letting more than `limit`
|
|
3
|
+
* run at once, and resolves once all have settled. Results are returned in the
|
|
4
|
+
* same order as `items`, regardless of completion order.
|
|
5
|
+
*
|
|
6
|
+
* Exists so callers that must fan out many independent async calls (e.g.
|
|
7
|
+
* fetching a reaction's user list per distinct emoji across thousands of
|
|
8
|
+
* messages) can bound how many are in flight without going fully sequential —
|
|
9
|
+
* without pulling in an external dependency for what is, in essence, a small
|
|
10
|
+
* counter and a queue (the same shape of problem `keyed-queue.ts` already
|
|
11
|
+
* solves for *serialised* per-key work, just bounding *parallel* work here
|
|
12
|
+
* instead).
|
|
13
|
+
*/
|
|
14
|
+
export async function runWithConcurrency<T, R>(
|
|
15
|
+
items: readonly T[],
|
|
16
|
+
limit: number,
|
|
17
|
+
worker: (item: T, index: number) => Promise<R>,
|
|
18
|
+
): Promise<R[]> {
|
|
19
|
+
if (items.length === 0) {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// `noUncheckedIndexedAccess` makes every `results[i]` a possible `undefined`
|
|
24
|
+
// at the type level; asserting it away here is correct because every index
|
|
25
|
+
// is written exactly once below, before this array is ever returned.
|
|
26
|
+
const results = new Array<R>(items.length) as R[];
|
|
27
|
+
let nextIndex = 0;
|
|
28
|
+
|
|
29
|
+
async function runNext(): Promise<void> {
|
|
30
|
+
const currentIndex = nextIndex;
|
|
31
|
+
nextIndex += 1;
|
|
32
|
+
if (currentIndex >= items.length) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const item = items[currentIndex] as T;
|
|
36
|
+
results[currentIndex] = await worker(item, currentIndex);
|
|
37
|
+
await runNext();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const workerCount = Math.max(1, Math.min(limit, items.length));
|
|
41
|
+
await Promise.all(Array.from({ length: workerCount }, runNext));
|
|
42
|
+
|
|
43
|
+
return results;
|
|
44
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic, dependency-free helpers for reading and validating environment
|
|
3
|
+
* variables. The concrete application config (which keys exist, their defaults)
|
|
4
|
+
* is assembled by the composition root in `bootstrap/config.ts` — the core only
|
|
5
|
+
* provides the primitives so any bot can declare its own shape.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { InvalidEnvError, MissingEnvError } from "@/config/errors";
|
|
9
|
+
|
|
10
|
+
type Env = Record<string, string | undefined>;
|
|
11
|
+
|
|
12
|
+
/** Read a required variable, throwing {@link MissingEnvError} when absent. */
|
|
13
|
+
export function requireEnv(key: string, env: Env = process.env): string {
|
|
14
|
+
const value = env[key];
|
|
15
|
+
if (value === undefined || value.trim() === "") {
|
|
16
|
+
throw new MissingEnvError(key);
|
|
17
|
+
}
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Read an optional variable, returning `fallback` (or `undefined`) when absent. */
|
|
22
|
+
export function optionalEnv(
|
|
23
|
+
key: string,
|
|
24
|
+
fallback?: string,
|
|
25
|
+
env: Env = process.env,
|
|
26
|
+
): string | undefined {
|
|
27
|
+
const value = env[key];
|
|
28
|
+
if (value === undefined || value.trim() === "") {
|
|
29
|
+
return fallback;
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Read a boolean variable (`true`/`1`/`yes` are truthy). */
|
|
35
|
+
export function boolEnv(key: string, fallback: boolean, env: Env = process.env): boolean {
|
|
36
|
+
const value = env[key]?.trim().toLowerCase();
|
|
37
|
+
if (value === undefined || value === "") {
|
|
38
|
+
return fallback;
|
|
39
|
+
}
|
|
40
|
+
return value === "true" || value === "1" || value === "yes";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Read a variable constrained to a fixed set of values. */
|
|
44
|
+
export function enumEnv<const T extends readonly string[]>(
|
|
45
|
+
key: string,
|
|
46
|
+
allowed: T,
|
|
47
|
+
fallback: T[number],
|
|
48
|
+
env: Env = process.env,
|
|
49
|
+
): T[number] {
|
|
50
|
+
const value = env[key]?.trim();
|
|
51
|
+
if (value === undefined || value === "") {
|
|
52
|
+
return fallback;
|
|
53
|
+
}
|
|
54
|
+
if (!allowed.includes(value)) {
|
|
55
|
+
throw new InvalidEnvError(key, `one of ${allowed.join(", ")}`);
|
|
56
|
+
}
|
|
57
|
+
return value as T[number];
|
|
58
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Failures raised while reading environment variables. They live apart from the
|
|
3
|
+
* reading logic so a consumer can catch or re-map them without pulling in the
|
|
4
|
+
* `env` helpers, and so the helpers file stays focused on behaviour.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Raised when a required environment variable is missing or empty. */
|
|
8
|
+
export class MissingEnvError extends Error {
|
|
9
|
+
constructor(key: string) {
|
|
10
|
+
super(`Missing required environment variable: ${key}`);
|
|
11
|
+
this.name = "MissingEnvError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Raised when an environment variable holds an invalid value. */
|
|
16
|
+
export class InvalidEnvError extends Error {
|
|
17
|
+
constructor(key: string, expected: string) {
|
|
18
|
+
super(`Invalid environment variable ${key}: expected ${expected}`);
|
|
19
|
+
this.name = "InvalidEnvError";
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/datetime.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wall-clock datetime input grammar: `YYYY-MM-DD` with an optional
|
|
3
|
+
* ` HH:MM` / `THH:MM` time (seconds optional). Matches the format documented to
|
|
4
|
+
* users in the slash-command options.
|
|
5
|
+
*/
|
|
6
|
+
const WALL_CLOCK_PATTERN = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/;
|
|
7
|
+
|
|
8
|
+
interface WallClock {
|
|
9
|
+
year: number;
|
|
10
|
+
month: number;
|
|
11
|
+
day: number;
|
|
12
|
+
hour: number;
|
|
13
|
+
minute: number;
|
|
14
|
+
second: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseWallClock(input: string): WallClock | null {
|
|
18
|
+
const match = WALL_CLOCK_PATTERN.exec(input.trim());
|
|
19
|
+
if (!match) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
const [, year, month, day, hour, minute, second] = match;
|
|
23
|
+
const wallClock: WallClock = {
|
|
24
|
+
year: Number(year),
|
|
25
|
+
month: Number(month),
|
|
26
|
+
day: Number(day),
|
|
27
|
+
hour: hour ? Number(hour) : 0,
|
|
28
|
+
minute: minute ? Number(minute) : 0,
|
|
29
|
+
second: second ? Number(second) : 0,
|
|
30
|
+
};
|
|
31
|
+
return isRealCalendarInstant(wallClock) ? wallClock : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Reject impossible values the regex alone allows (month 13, day 32, hour 25)
|
|
36
|
+
* by round-tripping through `Date.UTC` and checking nothing overflowed.
|
|
37
|
+
*/
|
|
38
|
+
function isRealCalendarInstant(wallClock: WallClock): boolean {
|
|
39
|
+
const date = new Date(
|
|
40
|
+
Date.UTC(
|
|
41
|
+
wallClock.year,
|
|
42
|
+
wallClock.month - 1,
|
|
43
|
+
wallClock.day,
|
|
44
|
+
wallClock.hour,
|
|
45
|
+
wallClock.minute,
|
|
46
|
+
wallClock.second,
|
|
47
|
+
),
|
|
48
|
+
);
|
|
49
|
+
return (
|
|
50
|
+
date.getUTCFullYear() === wallClock.year &&
|
|
51
|
+
date.getUTCMonth() === wallClock.month - 1 &&
|
|
52
|
+
date.getUTCDate() === wallClock.day &&
|
|
53
|
+
date.getUTCHours() === wallClock.hour &&
|
|
54
|
+
date.getUTCMinutes() === wallClock.minute &&
|
|
55
|
+
date.getUTCSeconds() === wallClock.second
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Offset, in milliseconds, between the given instant and how that instant reads
|
|
61
|
+
* as a wall clock in `timeZone` (positive when the zone is ahead of UTC).
|
|
62
|
+
*/
|
|
63
|
+
function timeZoneOffsetMs(timestamp: number, timeZone: string): number {
|
|
64
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
65
|
+
timeZone,
|
|
66
|
+
hourCycle: "h23",
|
|
67
|
+
year: "numeric",
|
|
68
|
+
month: "2-digit",
|
|
69
|
+
day: "2-digit",
|
|
70
|
+
hour: "2-digit",
|
|
71
|
+
minute: "2-digit",
|
|
72
|
+
second: "2-digit",
|
|
73
|
+
});
|
|
74
|
+
const parts = new Map<string, number>();
|
|
75
|
+
for (const { type, value } of formatter.formatToParts(new Date(timestamp))) {
|
|
76
|
+
if (type !== "literal") {
|
|
77
|
+
parts.set(type, Number(value));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const read = (key: string): number => {
|
|
81
|
+
const value = parts.get(key);
|
|
82
|
+
if (value === undefined) {
|
|
83
|
+
throw new RangeError(`Missing "${key}" in formatted parts for time zone "${timeZone}"`);
|
|
84
|
+
}
|
|
85
|
+
return value;
|
|
86
|
+
};
|
|
87
|
+
const asUtc = Date.UTC(
|
|
88
|
+
read("year"),
|
|
89
|
+
read("month") - 1,
|
|
90
|
+
read("day"),
|
|
91
|
+
read("hour"),
|
|
92
|
+
read("minute"),
|
|
93
|
+
read("second"),
|
|
94
|
+
);
|
|
95
|
+
return asUtc - timestamp;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Interpret a wall-clock datetime string as local time in `timeZone` and return
|
|
100
|
+
* the matching UTC instant. Discord renders stored timestamps in each viewer's
|
|
101
|
+
* own timezone, so an event entered as "18:00" guild-local must be stored as the
|
|
102
|
+
* equivalent UTC instant — otherwise it drifts by the guild's UTC offset.
|
|
103
|
+
* @throws RangeError When the input does not match the wall-clock grammar.
|
|
104
|
+
*/
|
|
105
|
+
export function zonedWallClockToUtc(input: string, timeZone: string): Date {
|
|
106
|
+
const wallClock = parseWallClock(input);
|
|
107
|
+
if (!wallClock) {
|
|
108
|
+
throw new RangeError(`Invalid wall-clock datetime: "${input}"`);
|
|
109
|
+
}
|
|
110
|
+
const utcGuess = Date.UTC(
|
|
111
|
+
wallClock.year,
|
|
112
|
+
wallClock.month - 1,
|
|
113
|
+
wallClock.day,
|
|
114
|
+
wallClock.hour,
|
|
115
|
+
wallClock.minute,
|
|
116
|
+
wallClock.second,
|
|
117
|
+
);
|
|
118
|
+
// Two passes: the offset read at `utcGuess` can be wrong across a DST
|
|
119
|
+
// transition (the guessed instant may land on the far side of the boundary
|
|
120
|
+
// from the target). Re-reading the offset at the first candidate pins the
|
|
121
|
+
// correct one for every time except the ~1h/year that either does not exist
|
|
122
|
+
// (spring-forward gap) or is ambiguous (fall-back overlap), which resolve
|
|
123
|
+
// deterministically to one side.
|
|
124
|
+
const firstOffset = timeZoneOffsetMs(utcGuess, timeZone);
|
|
125
|
+
const secondOffset = timeZoneOffsetMs(utcGuess - firstOffset, timeZone);
|
|
126
|
+
return new Date(utcGuess - secondOffset);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Whether `input` is a valid wall-clock datetime string. */
|
|
130
|
+
export function isValidWallClock(input: string): boolean {
|
|
131
|
+
return parseWallClock(input) !== null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** `YYYY-MM-DD` of a date-only value, read in UTC (matches `Date`-only options). */
|
|
135
|
+
export function dayString(date: Date): string {
|
|
136
|
+
return date.toISOString().slice(0, 10);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Start of `date`'s calendar day (00:00:00) anchored to `timeZone`. */
|
|
140
|
+
export function startOfGuildDay(date: Date, timeZone: string): Date {
|
|
141
|
+
return zonedWallClockToUtc(`${dayString(date)} 00:00:00`, timeZone);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** End of `date`'s calendar day (23:59:59) anchored to `timeZone`. */
|
|
145
|
+
export function endOfGuildDay(date: Date, timeZone: string): Date {
|
|
146
|
+
return zonedWallClockToUtc(`${dayString(date)} 23:59:59`, timeZone);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Monday 00:00 (guild-local) of the week following `reference`'s week. Used to
|
|
151
|
+
* anchor "next week"/"week after next" ranges to the guild's own calendar
|
|
152
|
+
* rather than to UTC day boundaries, which can disagree with the guild's local
|
|
153
|
+
* week near midnight around the timezone offset.
|
|
154
|
+
*/
|
|
155
|
+
export function startOfNextGuildWeek(reference: Date, timeZone: string): Date {
|
|
156
|
+
// Both the weekday *and* the calendar date must be read in `timeZone`, not
|
|
157
|
+
// UTC: near midnight, the guild-local day can already differ from the UTC
|
|
158
|
+
// day, and mixing the two would misplace the coming Monday by a day.
|
|
159
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
160
|
+
timeZone,
|
|
161
|
+
weekday: "short",
|
|
162
|
+
year: "numeric",
|
|
163
|
+
month: "2-digit",
|
|
164
|
+
day: "2-digit",
|
|
165
|
+
});
|
|
166
|
+
const weekdayIndex: Record<string, number> = {
|
|
167
|
+
Sun: 0,
|
|
168
|
+
Mon: 1,
|
|
169
|
+
Tue: 2,
|
|
170
|
+
Wed: 3,
|
|
171
|
+
Thu: 4,
|
|
172
|
+
Fri: 5,
|
|
173
|
+
Sat: 6,
|
|
174
|
+
};
|
|
175
|
+
const parts = new Map<string, string>();
|
|
176
|
+
for (const { type, value } of formatter.formatToParts(reference)) {
|
|
177
|
+
if (type !== "literal") {
|
|
178
|
+
parts.set(type, value);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const currentDay = weekdayIndex[parts.get("weekday") ?? ""] ?? 0;
|
|
182
|
+
// ISO week starts on Monday: ahead-days is 0 only when already Monday and we
|
|
183
|
+
// still want *next* week's Monday, hence the `=== 1 ? 7` special-case.
|
|
184
|
+
const daysUntilNextMonday = currentDay === 1 ? 7 : (8 - currentDay) % 7;
|
|
185
|
+
// Advance the *calendar date* (a pure Y-M-D operation, DST-agnostic) before
|
|
186
|
+
// re-anchoring to the timezone — advancing the UTC instant instead would
|
|
187
|
+
// drift by an hour across a DST transition inside the date range.
|
|
188
|
+
const year = Number(parts.get("year"));
|
|
189
|
+
const month = Number(parts.get("month"));
|
|
190
|
+
const day = Number(parts.get("day"));
|
|
191
|
+
const nextMondayUtcCalendarDay = new Date(Date.UTC(year, month - 1, day + daysUntilNextMonday));
|
|
192
|
+
return startOfGuildDay(nextMondayUtcCalendarDay, timeZone);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Add `days` guild-local calendar days to `instant`, re-anchoring to
|
|
196
|
+
* `timeZone`'s midnight. `instant` is assumed to already be a guild-local
|
|
197
|
+
* midnight (e.g. the result of {@link startOfGuildDay} or
|
|
198
|
+
* {@link startOfNextGuildWeek}); adding a fixed millisecond offset instead
|
|
199
|
+
* would drift by the DST delta whenever the interval crosses a transition —
|
|
200
|
+
* this reads the calendar date in `timeZone` first, so the result is always
|
|
201
|
+
* that timezone's actual midnight `days` later.
|
|
202
|
+
*/
|
|
203
|
+
export function addGuildDays(instant: Date, days: number, timeZone: string): Date {
|
|
204
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
205
|
+
timeZone,
|
|
206
|
+
year: "numeric",
|
|
207
|
+
month: "2-digit",
|
|
208
|
+
day: "2-digit",
|
|
209
|
+
});
|
|
210
|
+
const parts = new Map<string, string>();
|
|
211
|
+
for (const { type, value } of formatter.formatToParts(instant)) {
|
|
212
|
+
if (type !== "literal") {
|
|
213
|
+
parts.set(type, value);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const year = Number(parts.get("year"));
|
|
217
|
+
const month = Number(parts.get("month"));
|
|
218
|
+
const day = Number(parts.get("day"));
|
|
219
|
+
const shiftedUtcCalendarDay = new Date(Date.UTC(year, month - 1, day + days));
|
|
220
|
+
return startOfGuildDay(shiftedUtcCalendarDay, timeZone);
|
|
221
|
+
}
|