@went.tf/discord-bot-framework 2.0.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.
Files changed (271) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +550 -0
  3. package/build/.tsbuildinfo +1 -0
  4. package/build/api-client/api-client.d.ts +13 -0
  5. package/build/api-client/api-client.d.ts.map +1 -0
  6. package/build/api-client/api-client.js +147 -0
  7. package/build/api-client/api-client.js.map +1 -0
  8. package/build/api-client/api-http-exception.d.ts +7 -0
  9. package/build/api-client/api-http-exception.d.ts.map +1 -0
  10. package/build/api-client/api-http-exception.js +12 -0
  11. package/build/api-client/api-http-exception.js.map +1 -0
  12. package/build/api-client/index.d.ts +4 -0
  13. package/build/api-client/index.d.ts.map +1 -0
  14. package/build/api-client/index.js +4 -0
  15. package/build/api-client/index.js.map +1 -0
  16. package/build/api-client/types.d.ts +105 -0
  17. package/build/api-client/types.d.ts.map +1 -0
  18. package/build/api-client/types.js +8 -0
  19. package/build/api-client/types.js.map +1 -0
  20. package/build/client/create-bot-client.d.ts +14 -0
  21. package/build/client/create-bot-client.d.ts.map +1 -0
  22. package/build/client/create-bot-client.js +19 -0
  23. package/build/client/create-bot-client.js.map +1 -0
  24. package/build/client/create-shard-manager.d.ts +17 -0
  25. package/build/client/create-shard-manager.d.ts.map +1 -0
  26. package/build/client/create-shard-manager.js +26 -0
  27. package/build/client/create-shard-manager.js.map +1 -0
  28. package/build/client/index.d.ts +3 -0
  29. package/build/client/index.d.ts.map +1 -0
  30. package/build/client/index.js +3 -0
  31. package/build/client/index.js.map +1 -0
  32. package/build/commands/build-application-commands-body.d.ts +39 -0
  33. package/build/commands/build-application-commands-body.d.ts.map +1 -0
  34. package/build/commands/build-application-commands-body.js +127 -0
  35. package/build/commands/build-application-commands-body.js.map +1 -0
  36. package/build/commands/fixed-reply-command-factory.d.ts +10 -0
  37. package/build/commands/fixed-reply-command-factory.d.ts.map +1 -0
  38. package/build/commands/fixed-reply-command-factory.js +19 -0
  39. package/build/commands/fixed-reply-command-factory.js.map +1 -0
  40. package/build/commands/index.d.ts +4 -0
  41. package/build/commands/index.d.ts.map +1 -0
  42. package/build/commands/index.js +4 -0
  43. package/build/commands/index.js.map +1 -0
  44. package/build/commands/registration.d.ts +24 -0
  45. package/build/commands/registration.d.ts.map +1 -0
  46. package/build/commands/registration.js +69 -0
  47. package/build/commands/registration.js.map +1 -0
  48. package/build/commands/schema/application-command-leaf-option.schema.d.ts +230 -0
  49. package/build/commands/schema/application-command-leaf-option.schema.d.ts.map +1 -0
  50. package/build/commands/schema/application-command-leaf-option.schema.js +129 -0
  51. package/build/commands/schema/application-command-leaf-option.schema.js.map +1 -0
  52. package/build/commands/schema/application-command-leaf-option.schema.json +258 -0
  53. package/build/commands/schema/application-command-option-choice.schema.d.ts +18 -0
  54. package/build/commands/schema/application-command-option-choice.schema.d.ts.map +1 -0
  55. package/build/commands/schema/application-command-option-choice.schema.js +12 -0
  56. package/build/commands/schema/application-command-option-choice.schema.js.map +1 -0
  57. package/build/commands/schema/application-command-option-choice.schema.json +23 -0
  58. package/build/commands/schema/application-command-option.schema.d.ts +12 -0
  59. package/build/commands/schema/application-command-option.schema.d.ts.map +1 -0
  60. package/build/commands/schema/application-command-option.schema.js +13 -0
  61. package/build/commands/schema/application-command-option.schema.js.map +1 -0
  62. package/build/commands/schema/application-command-option.schema.json +14 -0
  63. package/build/commands/schema/application-command-subcommand-group.schema.d.ts +28 -0
  64. package/build/commands/schema/application-command-subcommand-group.schema.d.ts.map +1 -0
  65. package/build/commands/schema/application-command-subcommand-group.schema.js +22 -0
  66. package/build/commands/schema/application-command-subcommand-group.schema.js.map +1 -0
  67. package/build/commands/schema/application-command-subcommand-group.schema.json +26 -0
  68. package/build/commands/schema/application-command-subcommand.schema.d.ts +29 -0
  69. package/build/commands/schema/application-command-subcommand.schema.d.ts.map +1 -0
  70. package/build/commands/schema/application-command-subcommand.schema.js +23 -0
  71. package/build/commands/schema/application-command-subcommand.schema.js.map +1 -0
  72. package/build/commands/schema/application-command-subcommand.schema.json +26 -0
  73. package/build/commands/schema/application-command-type.schema.d.ts +6 -0
  74. package/build/commands/schema/application-command-type.schema.d.ts.map +1 -0
  75. package/build/commands/schema/application-command-type.schema.js +6 -0
  76. package/build/commands/schema/application-command-type.schema.js.map +1 -0
  77. package/build/commands/schema/application-command-type.schema.json +9 -0
  78. package/build/commands/schema/application-integration-type.schema.d.ts +6 -0
  79. package/build/commands/schema/application-integration-type.schema.d.ts.map +1 -0
  80. package/build/commands/schema/application-integration-type.schema.js +6 -0
  81. package/build/commands/schema/application-integration-type.schema.js.map +1 -0
  82. package/build/commands/schema/application-integration-type.schema.json +8 -0
  83. package/build/commands/schema/channel-type.schema.d.ts +6 -0
  84. package/build/commands/schema/channel-type.schema.d.ts.map +1 -0
  85. package/build/commands/schema/channel-type.schema.js +6 -0
  86. package/build/commands/schema/channel-type.schema.js.map +1 -0
  87. package/build/commands/schema/channel-type.schema.json +16 -0
  88. package/build/commands/schema/chat-input-command.schema.d.ts +45 -0
  89. package/build/commands/schema/chat-input-command.schema.d.ts.map +1 -0
  90. package/build/commands/schema/chat-input-command.schema.js +34 -0
  91. package/build/commands/schema/chat-input-command.schema.js.map +1 -0
  92. package/build/commands/schema/chat-input-command.schema.json +46 -0
  93. package/build/commands/schema/commands-file.schema.d.ts +18 -0
  94. package/build/commands/schema/commands-file.schema.d.ts.map +1 -0
  95. package/build/commands/schema/commands-file.schema.js +16 -0
  96. package/build/commands/schema/commands-file.schema.js.map +1 -0
  97. package/build/commands/schema/commands-file.schema.json +14 -0
  98. package/build/commands/schema/context-menu-command.schema.d.ts +40 -0
  99. package/build/commands/schema/context-menu-command.schema.d.ts.map +1 -0
  100. package/build/commands/schema/context-menu-command.schema.js +32 -0
  101. package/build/commands/schema/context-menu-command.schema.js.map +1 -0
  102. package/build/commands/schema/context-menu-command.schema.json +40 -0
  103. package/build/commands/schema/context-menu-name.schema.d.ts +7 -0
  104. package/build/commands/schema/context-menu-name.schema.d.ts.map +1 -0
  105. package/build/commands/schema/context-menu-name.schema.js +7 -0
  106. package/build/commands/schema/context-menu-name.schema.js.map +1 -0
  107. package/build/commands/schema/context-menu-name.schema.json +6 -0
  108. package/build/commands/schema/default-member-permissions.schema.d.ts +7 -0
  109. package/build/commands/schema/default-member-permissions.schema.d.ts.map +1 -0
  110. package/build/commands/schema/default-member-permissions.schema.js +7 -0
  111. package/build/commands/schema/default-member-permissions.schema.js.map +1 -0
  112. package/build/commands/schema/default-member-permissions.schema.json +6 -0
  113. package/build/commands/schema/index.d.ts +77 -0
  114. package/build/commands/schema/index.d.ts.map +1 -0
  115. package/build/commands/schema/index.js +48 -0
  116. package/build/commands/schema/index.js.map +1 -0
  117. package/build/commands/schema/interaction-context-type.schema.d.ts +6 -0
  118. package/build/commands/schema/interaction-context-type.schema.d.ts.map +1 -0
  119. package/build/commands/schema/interaction-context-type.schema.js +6 -0
  120. package/build/commands/schema/interaction-context-type.schema.js.map +1 -0
  121. package/build/commands/schema/interaction-context-type.schema.json +9 -0
  122. package/build/commands/schema/option-name.schema.d.ts +7 -0
  123. package/build/commands/schema/option-name.schema.d.ts.map +1 -0
  124. package/build/commands/schema/option-name.schema.js +7 -0
  125. package/build/commands/schema/option-name.schema.js.map +1 -0
  126. package/build/commands/schema/option-name.schema.json +6 -0
  127. package/build/commands/schema/parse-commands-file.d.ts +19 -0
  128. package/build/commands/schema/parse-commands-file.d.ts.map +1 -0
  129. package/build/commands/schema/parse-commands-file.js +19 -0
  130. package/build/commands/schema/parse-commands-file.js.map +1 -0
  131. package/build/db/create-postgres-prisma-db.d.ts +20 -0
  132. package/build/db/create-postgres-prisma-db.d.ts.map +1 -0
  133. package/build/db/create-postgres-prisma-db.js +17 -0
  134. package/build/db/create-postgres-prisma-db.js.map +1 -0
  135. package/build/db/index.d.ts +2 -0
  136. package/build/db/index.d.ts.map +1 -0
  137. package/build/db/index.js +2 -0
  138. package/build/db/index.js.map +1 -0
  139. package/build/dev/create-handler-watcher.d.ts +23 -0
  140. package/build/dev/create-handler-watcher.d.ts.map +1 -0
  141. package/build/dev/create-handler-watcher.js +55 -0
  142. package/build/dev/create-handler-watcher.js.map +1 -0
  143. package/build/dev/create-source-reloader.d.ts +37 -0
  144. package/build/dev/create-source-reloader.d.ts.map +1 -0
  145. package/build/dev/create-source-reloader.js +42 -0
  146. package/build/dev/create-source-reloader.js.map +1 -0
  147. package/build/dev/index.d.ts +3 -0
  148. package/build/dev/index.d.ts.map +1 -0
  149. package/build/dev/index.js +3 -0
  150. package/build/dev/index.js.map +1 -0
  151. package/build/dev/reload-loader.d.ts +11 -0
  152. package/build/dev/reload-loader.d.ts.map +1 -0
  153. package/build/dev/reload-loader.js +28 -0
  154. package/build/dev/reload-loader.js.map +1 -0
  155. package/build/env/define-env.d.ts +25 -0
  156. package/build/env/define-env.d.ts.map +1 -0
  157. package/build/env/define-env.js +24 -0
  158. package/build/env/define-env.js.map +1 -0
  159. package/build/env/helpers.d.ts +9 -0
  160. package/build/env/helpers.d.ts.map +1 -0
  161. package/build/env/helpers.js +9 -0
  162. package/build/env/helpers.js.map +1 -0
  163. package/build/env/index.d.ts +3 -0
  164. package/build/env/index.d.ts.map +1 -0
  165. package/build/env/index.js +3 -0
  166. package/build/env/index.js.map +1 -0
  167. package/build/i18n/create-command-localizer.d.ts +27 -0
  168. package/build/i18n/create-command-localizer.d.ts.map +1 -0
  169. package/build/i18n/create-command-localizer.js +40 -0
  170. package/build/i18n/create-command-localizer.js.map +1 -0
  171. package/build/i18n/create-i18n-initializer.d.ts +18 -0
  172. package/build/i18n/create-i18n-initializer.d.ts.map +1 -0
  173. package/build/i18n/create-i18n-initializer.js +34 -0
  174. package/build/i18n/create-i18n-initializer.js.map +1 -0
  175. package/build/i18n/index.d.ts +3 -0
  176. package/build/i18n/index.d.ts.map +1 -0
  177. package/build/i18n/index.js +3 -0
  178. package/build/i18n/index.js.map +1 -0
  179. package/build/index.d.ts +8 -0
  180. package/build/index.d.ts.map +1 -0
  181. package/build/index.js +12 -0
  182. package/build/index.js.map +1 -0
  183. package/build/interactions/custom-id.d.ts +9 -0
  184. package/build/interactions/custom-id.d.ts.map +1 -0
  185. package/build/interactions/custom-id.js +15 -0
  186. package/build/interactions/custom-id.js.map +1 -0
  187. package/build/interactions/dispatch.d.ts +35 -0
  188. package/build/interactions/dispatch.d.ts.map +1 -0
  189. package/build/interactions/dispatch.js +87 -0
  190. package/build/interactions/dispatch.js.map +1 -0
  191. package/build/interactions/handle-interaction-error.d.ts +22 -0
  192. package/build/interactions/handle-interaction-error.d.ts.map +1 -0
  193. package/build/interactions/handle-interaction-error.js +65 -0
  194. package/build/interactions/handle-interaction-error.js.map +1 -0
  195. package/build/interactions/index.d.ts +7 -0
  196. package/build/interactions/index.d.ts.map +1 -0
  197. package/build/interactions/index.js +7 -0
  198. package/build/interactions/index.js.map +1 -0
  199. package/build/interactions/registry.d.ts +32 -0
  200. package/build/interactions/registry.d.ts.map +1 -0
  201. package/build/interactions/registry.js +49 -0
  202. package/build/interactions/registry.js.map +1 -0
  203. package/build/interactions/router.d.ts +28 -0
  204. package/build/interactions/router.d.ts.map +1 -0
  205. package/build/interactions/router.js +48 -0
  206. package/build/interactions/router.js.map +1 -0
  207. package/build/interactions/types.d.ts +37 -0
  208. package/build/interactions/types.d.ts.map +1 -0
  209. package/build/interactions/types.js +2 -0
  210. package/build/interactions/types.js.map +1 -0
  211. package/build/logger/create-logger.d.ts +27 -0
  212. package/build/logger/create-logger.d.ts.map +1 -0
  213. package/build/logger/create-logger.js +36 -0
  214. package/build/logger/create-logger.js.map +1 -0
  215. package/build/logger/dev-null-logger.d.ts +11 -0
  216. package/build/logger/dev-null-logger.d.ts.map +1 -0
  217. package/build/logger/dev-null-logger.js +19 -0
  218. package/build/logger/dev-null-logger.js.map +1 -0
  219. package/build/logger/discord-webhook-batcher.d.ts +38 -0
  220. package/build/logger/discord-webhook-batcher.d.ts.map +1 -0
  221. package/build/logger/discord-webhook-batcher.js +87 -0
  222. package/build/logger/discord-webhook-batcher.js.map +1 -0
  223. package/build/logger/discord-webhook-transport.d.ts +9 -0
  224. package/build/logger/discord-webhook-transport.d.ts.map +1 -0
  225. package/build/logger/discord-webhook-transport.js +20 -0
  226. package/build/logger/discord-webhook-transport.js.map +1 -0
  227. package/build/logger/index.d.ts +5 -0
  228. package/build/logger/index.d.ts.map +1 -0
  229. package/build/logger/index.js +5 -0
  230. package/build/logger/index.js.map +1 -0
  231. package/build/logger/logger.d.ts +30 -0
  232. package/build/logger/logger.d.ts.map +1 -0
  233. package/build/logger/logger.js +84 -0
  234. package/build/logger/logger.js.map +1 -0
  235. package/build/logger/pino-pretty-options.d.ts +12 -0
  236. package/build/logger/pino-pretty-options.d.ts.map +1 -0
  237. package/build/logger/pino-pretty-options.js +17 -0
  238. package/build/logger/pino-pretty-options.js.map +1 -0
  239. package/build/logger/types.d.ts +6 -0
  240. package/build/logger/types.d.ts.map +1 -0
  241. package/build/logger/types.js +2 -0
  242. package/build/logger/types.js.map +1 -0
  243. package/build/utils/discord-lookups.d.ts +16 -0
  244. package/build/utils/discord-lookups.d.ts.map +1 -0
  245. package/build/utils/discord-lookups.js +39 -0
  246. package/build/utils/discord-lookups.js.map +1 -0
  247. package/build/utils/get-git-data.d.ts +8 -0
  248. package/build/utils/get-git-data.d.ts.map +1 -0
  249. package/build/utils/get-git-data.js +18 -0
  250. package/build/utils/get-git-data.js.map +1 -0
  251. package/build/utils/index.d.ts +7 -0
  252. package/build/utils/index.d.ts.map +1 -0
  253. package/build/utils/index.js +7 -0
  254. package/build/utils/index.js.map +1 -0
  255. package/build/utils/messaging.d.ts +15 -0
  256. package/build/utils/messaging.d.ts.map +1 -0
  257. package/build/utils/messaging.js +71 -0
  258. package/build/utils/messaging.js.map +1 -0
  259. package/build/utils/promises.d.ts +6 -0
  260. package/build/utils/promises.d.ts.map +1 -0
  261. package/build/utils/promises.js +13 -0
  262. package/build/utils/promises.js.map +1 -0
  263. package/build/utils/run-attempts.d.ts +6 -0
  264. package/build/utils/run-attempts.d.ts.map +1 -0
  265. package/build/utils/run-attempts.js +16 -0
  266. package/build/utils/run-attempts.js.map +1 -0
  267. package/build/utils/strings.d.ts +7 -0
  268. package/build/utils/strings.d.ts.map +1 -0
  269. package/build/utils/strings.js +39 -0
  270. package/build/utils/strings.js.map +1 -0
  271. package/package.json +146 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 WentTheFox
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,550 @@
1
+ # @went.tf/discord-bot-framework
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@went.tf/discord-bot-framework.svg)](https://www.npmjs.com/package/@went.tf/discord-bot-framework)
4
+
5
+ Shared building blocks for discord.js-based Discord bots: a nestable console
6
+ logger, zod-based env validation, a generic HTTP API client, a slash-command
7
+ interaction dispatcher, command registration helpers, and thin client/shard
8
+ bootstrap wrappers — plus optional Postgres (Prisma) and i18next helpers for
9
+ bots that want them.
10
+
11
+ Extracted from [HammerTimeBot](https://github.com/WentTheFox/HammerTimeBot),
12
+ [Fantastick](https://github.com/WentTheFox/Fantastick), and
13
+ [PennyCurve](https://github.com/MLP-VectorClub/PennyCurve), which had each
14
+ independently reimplemented the same architecture. See `CLAUDE.md` for the
15
+ design rationale and module-to-source mapping.
16
+
17
+ ## Install
18
+
19
+ ```sh
20
+ pnpm add @went.tf/discord-bot-framework zod discord.js @discordjs/rest discord-api-types
21
+ ```
22
+
23
+ `zod` and `ajv` are real dependencies of this package but must also be
24
+ listed by consumers directly (peer resolution quirk of subpath-only usage)
25
+ if you use `defineEnv` or compile your own commands schema at your own top
26
+ level. `prisma`/`@prisma/client`/`@prisma/adapter-pg` and
27
+ `i18next`/`i18next-fs-backend` are **optional** peers — only install them if
28
+ you import `@went.tf/discord-bot-framework/db` or `/i18n`.
29
+
30
+ ## Subpaths
31
+
32
+ Everything is available from the package root **except** `./db`, `./i18n`, and
33
+ `./dev`. `./db`/`./i18n` are kept as separate subpaths so bots that don't use
34
+ Postgres/Prisma or i18next never need to install those peer dependencies.
35
+ `./dev` is excluded for a different reason — it has no extra peer
36
+ dependencies, but it's dev-only tooling that shouldn't leak into every
37
+ consumer's root import surface.
38
+
39
+ ### `@went.tf/discord-bot-framework/logger`
40
+
41
+ Backed by [pino](https://getpino.io). Plain `new Logger(prefix)` /
42
+ `Logger.fromShardInfo(...)` stay simple, console-only, worker-thread-free
43
+ constructors:
44
+
45
+ ```ts
46
+ import { Logger, NestableLogger, DevNullLogger } from '@went.tf/discord-bot-framework/logger';
47
+
48
+ const logger = new Logger('Bot');
49
+ const interactionLogger = logger.nest(`Interaction#${interaction.id}`);
50
+ const shardLogger = Logger.fromShardInfo(process.env.SHARDS);
51
+ ```
52
+
53
+ To additionally fan logs out to a Discord webhook (in batches, respecting
54
+ Discord's per-webhook rate limits), use `createLogger` instead — it builds one
55
+ pino instance with the requested transport targets (console + optional
56
+ webhook), and `nest()` on the result shares that same instance rather than
57
+ spawning a new worker thread per call:
58
+
59
+ ```ts
60
+ import { createLogger } from '@went.tf/discord-bot-framework/logger';
61
+
62
+ const logger = createLogger({
63
+ prefix: 'Bot',
64
+ discordWebhook: {
65
+ url: env.LOG_WEBHOOK_URL,
66
+ level: 'warn', // only warn/error/fatal are sent to Discord; default 'warn'
67
+ },
68
+ });
69
+ ```
70
+
71
+ ### `@went.tf/discord-bot-framework/env`
72
+
73
+ ```ts
74
+ import { defineEnv, boolFromString } from '@went.tf/discord-bot-framework/env';
75
+ import { z } from 'zod';
76
+
77
+ export const env = defineEnv({
78
+ DISCORD_BOT_TOKEN: z.string().min(1),
79
+ API_URL: z.string().url(),
80
+ LOCAL: boolFromString().default(false),
81
+ SUPPORT_SERVER_ID: z.string().optional().default(''),
82
+ });
83
+ ```
84
+
85
+ Throws one formatted `Error` listing every failing key. Pass `{ dotenv: false }`
86
+ to skip loading a `.env` file, or `{ source }` to validate a fixture object
87
+ (useful in tests).
88
+
89
+ ### `@went.tf/discord-bot-framework/api-client`
90
+
91
+ ```ts
92
+ import { ApiClient, ApiAuthType } from '@went.tf/discord-bot-framework/api-client';
93
+
94
+ const apiClient = new ApiClient(logger, {
95
+ baseUrl: `${env.API_URL}/api`,
96
+ authentication: { type: ApiAuthType.AUTHORIZATION_HEADER, getValue: () => env.API_TOKEN },
97
+ userAgent: env.UA_STRING,
98
+ });
99
+
100
+ const { response } = await apiClient.request({
101
+ path: '/things',
102
+ validator: typia.createValidate<Thing[]>(), // optional; omit for `response: unknown`
103
+ });
104
+ ```
105
+
106
+ ### `@went.tf/discord-bot-framework/interactions`
107
+
108
+ Commands/components/modals are self-describing — put the name/id directly on
109
+ the object (as `name` or `id`) and pass an array to a `createXRegistry()`
110
+ helper instead of hand-writing a `Record<Enum, Handler>` map. The registry
111
+ derives the literal name/id union from the array itself (TS 5 `const` type
112
+ params), so there's no separate enum to keep in sync, and `registry.byName`
113
+ is a drop-in `commands`/`components`/`modals` value for
114
+ `createInteractionRouter`/the `dispatch*` functions below.
115
+
116
+ A command's *wire definition* (name, description, options, permissions) no
117
+ longer lives on this object — it lives in your `commands.json` file, see
118
+ `./commands` below. This object is purely the handler side:
119
+
120
+ ```ts
121
+ import { createChatInputCommandRegistry, createComponentRegistry, createInteractionRouter, handleInteractionError } from '@went.tf/discord-bot-framework/interactions';
122
+
123
+ const pingCommand = { name: 'ping', handle: (interaction) => interaction.reply('pong') };
124
+
125
+ const chatInputCommandRegistry = createChatInputCommandRegistry([pingCommand /* , ... */]);
126
+ const componentRegistry = createComponentRegistry([/* ... */]);
127
+
128
+ const router = createInteractionRouter({
129
+ commands: chatInputCommandRegistry.byName,
130
+ components: componentRegistry.byName,
131
+ buildContext: async (interaction, baseContext) => ({ ...baseContext, t: await buildT(interaction) }),
132
+ onError: (interaction, context, error) =>
133
+ handleInteractionError(interaction, context, { buildMessage: () => context.t('errors.unexpected') }),
134
+ });
135
+
136
+ client.on(Events.InteractionCreate, (interaction) => router(interaction, baseContext));
137
+ ```
138
+
139
+ Bots that need to run logic between a command handler and error handling
140
+ (e.g. telemetry) can call `dispatchChatInputCommand`/`dispatchAutocomplete`/
141
+ `dispatchComponent`/`dispatchModal`/`dispatchContextMenu` directly instead of
142
+ the combined router — both take the same `registry.byName` maps.
143
+
144
+ There's also `createContextMenuCommandRegistry`/`createModalRegistry` for the
145
+ other two interaction kinds, and `flattenCommandModals(chatInputRegistry)`
146
+ for bots that nest a `.modal` map directly on the owning chat-input command
147
+ (rather than registering modals as a standalone top-level registry) — it
148
+ synthesizes a flat `Registry<string, BotModal<Ctx>>` view so `dispatchModal`
149
+ can consume it unchanged.
150
+
151
+ ### `@went.tf/discord-bot-framework/commands`
152
+
153
+ **Every command's wire definition (name, description, options, permissions)
154
+ lives in one `commands.json` file per bot** — a flat array mirroring
155
+ Discord's bulk-overwrite PUT body exactly, so it's directly postable to
156
+ Discord's API as-is (translations aside, see below). This replaces the old
157
+ per-command `getDefinition()` function: handler objects (`{ name, handle,
158
+ autocomplete?, modal? }`) no longer describe their own wire shape at all.
159
+
160
+ 1. Author `commands.json`, validated against your own JSON Schema composed
161
+ over this package's generic fragments (see "JSON Schema fragments"
162
+ below):
163
+
164
+ ```json
165
+ [
166
+ { "type": 1, "name": "ping", "description": "Replies with pong" },
167
+ {
168
+ "type": 1,
169
+ "name": "search",
170
+ "description": "Search for something",
171
+ "options": [
172
+ { "type": 3, "name": "query", "description": "Query string", "required": true }
173
+ ]
174
+ }
175
+ ]
176
+ ```
177
+
178
+ 2. Parse and validate it before doing anything else with it:
179
+
180
+ ```ts
181
+ import { Ajv } from 'ajv';
182
+ import { parseCommandsFile, registerFrameworkSchemas } from '@went.tf/discord-bot-framework/commands/schema';
183
+ import myCommandsSchema from './commands.schema.json' with { type: 'json' };
184
+ import commandsData from './commands.json' with { type: 'json' };
185
+
186
+ const ajv = new Ajv({ allErrors: true, allowUnionTypes: true });
187
+ registerFrameworkSchemas(ajv);
188
+ const validate = ajv.compile(myCommandsSchema);
189
+
190
+ const commandsFile = parseCommandsFile(commandsData, { validate });
191
+ ```
192
+
193
+ 3. Build the Discord-ready body and register it — unchanged from before,
194
+ just fed by `commandsFile` + your handler registries instead of
195
+ `getDefinition()`:
196
+
197
+ ```ts
198
+ import { buildApplicationCommandsBody, createCommandRegistrar, fixedReplyCommandFactory } from '@went.tf/discord-bot-framework/commands';
199
+
200
+ const registrar = createCommandRegistrar({ rest, applicationId: env.DISCORD_CLIENT_ID, logger });
201
+
202
+ const commandBodies = buildApplicationCommandsBody(
203
+ commandsFile,
204
+ { chatInput: chatInputCommandRegistry, contextMenu: contextMenuCommandRegistry },
205
+ { sharedMetadata: { integration_types: [...], contexts: [...] } },
206
+ );
207
+ await registrar.updateGlobalCommands(commandBodies);
208
+
209
+ const pingCommand = { name: 'ping', ...fixedReplyCommandFactory('pong') };
210
+ ```
211
+
212
+ `buildApplicationCommandsBody` walks `commandsFile` (its order drives the
213
+ output order, not registry insertion order), matches each entry to a handler
214
+ by name, applies `registerCondition` filtering, merges `sharedMetadata`
215
+ (the commands.json entry's own fields win on conflict), and stably sorts
216
+ every options array (including nested subcommand/subcommand-group options)
217
+ so required options precede optional ones, matching Discord's API
218
+ requirement automatically. It also enforces two invariants **before ever
219
+ calling Discord's API**, each collecting every offender into one thrown
220
+ error rather than failing on the first: every `commands.json` entry must
221
+ have a matching handler, every handler must have a matching `commands.json`
222
+ entry, and every command/option must end up with a non-empty `description`
223
+ (from the file directly, or via `resolveDescription` — see "Localizing
224
+ command names/descriptions" below).
225
+
226
+ `fixedReplyCommandFactory(content, ephemeral?)` now only returns `{ handle }`
227
+ — pair it with a registry entry that supplies `name`, and a `commands.json`
228
+ entry that supplies `name`/`description`.
229
+
230
+ #### JSON Schema fragments
231
+
232
+ This package ships only the **generic, reusable JSON Schema building
233
+ blocks** mirroring `discord-api-types`' command/option shapes — it does not
234
+ dictate one rigid schema for your whole `commands.json` file. Compose your
235
+ own schema on top via `$ref`/`allOf`, e.g. to narrow `name` to an enum of
236
+ your bot's actual command names:
237
+
238
+ ```json
239
+ {
240
+ "$id": "https://schema.your-bot.example/commands-file.json",
241
+ "type": "array",
242
+ "items": {
243
+ "oneOf": [
244
+ {
245
+ "allOf": [
246
+ { "$ref": "https://schema.went.tf/discord-bot-framework/chat-input-command.json" },
247
+ { "properties": { "name": { "enum": ["ping", "search"] } } }
248
+ ]
249
+ },
250
+ { "$ref": "https://schema.went.tf/discord-bot-framework/context-menu-command.json" }
251
+ ]
252
+ }
253
+ }
254
+ ```
255
+
256
+ Call `registerFrameworkSchemas(ajv)` before compiling your own schema so its
257
+ `$ref`s resolve. The fragments this package ships (all under
258
+ `@went.tf/discord-bot-framework/commands/schema`, and as real standalone
259
+ `.json` files under `build/commands/schema/` for non-TS tooling):
260
+ `commands-file`, `chat-input-command`, `context-menu-command`,
261
+ `application-command-option` (and its `application-command-leaf-option`/
262
+ `application-command-subcommand`/`application-command-subcommand-group`
263
+ building blocks), `application-command-option-choice`,
264
+ `default-member-permissions`, `option-name`, `context-menu-name`,
265
+ `application-command-type`, `interaction-context-type`,
266
+ `application-integration-type`, `channel-type`.
267
+
268
+ Base fragments use `additionalProperties: false` for strictness — if your
269
+ bot needs a genuinely new top-level field per command entry, you'll need
270
+ `unevaluatedProperties`-based composition instead of `allOf`, since
271
+ `additionalProperties: false` only evaluates a schema's own declared
272
+ properties, not fields declared on sibling `allOf` members.
273
+
274
+ Command/option **names are required** in `commands.json`, but
275
+ **descriptions are optional** — a description can be authored directly in
276
+ the file, or left out and filled in at submission time (see below). Nothing
277
+ in `commands.json` is ever localized by hand: no `name_localizations`/
278
+ `description_localizations` fields exist in this schema at all.
279
+
280
+ #### Localizing command names/descriptions
281
+
282
+ `createCommandLocalizer` (from `@went.tf/discord-bot-framework/i18n`)
283
+ generically resolves descriptions and builds `name_localizations`/
284
+ `description_localizations` dictionaries from an i18next `TFunction`, keyed
285
+ by the same path convention `buildApplicationCommandsBody` uses internally
286
+ (`commands.<name>.description`, `commands.<name>.options.<option>.description`,
287
+ and one level deeper for subcommand options):
288
+
289
+ ```ts
290
+ import { createCommandLocalizer } from '@went.tf/discord-bot-framework/i18n';
291
+
292
+ const localizer = createCommandLocalizer({ locales: SUPPORTED_LANGUAGES, baseLocale: DEFAULT_LANGUAGE, t: i18nextInstance.t });
293
+
294
+ const commandBodies = buildApplicationCommandsBody(commandsFile, registries, {
295
+ resolveDescription: localizer.resolveDescription,
296
+ localizeNames: localizer.localizeName,
297
+ localizeDescriptions: localizer.localizeDescription,
298
+ });
299
+ ```
300
+
301
+ If a command/option has no `description` in `commands.json` **and** no
302
+ `resolveDescription` hook is wired in (or the hook can't find a translation
303
+ either), `buildApplicationCommandsBody` throws before anything is sent to
304
+ Discord — it never silently registers a command with a missing description.
305
+
306
+ To derive TS types from your own composed schema, install
307
+ [`json-schema-to-ts`](https://www.npmjs.com/package/json-schema-to-ts)
308
+ yourself (it's a devDependency of this package, type-only, not re-exported)
309
+ and use its `FromSchema` the same way this package's own
310
+ `commands/schema/index.ts` does — pass every `$ref`-ed fragment (yours and
311
+ this package's) in the `references` option.
312
+
313
+ ### `@went.tf/discord-bot-framework/client`
314
+
315
+ Sharding is entirely opt-in. Most bots — anything single-guild or otherwise
316
+ small enough not to need multiple discord.js shards — should just use
317
+ `createBotClient` and never touch `createShardManager` or anything
318
+ shard-related at all:
319
+
320
+ ```ts
321
+ import { createBotClient } from '@went.tf/discord-bot-framework/client';
322
+
323
+ const client = await createBotClient({ intents: [GatewayIntentBits.Guilds], token, onInteraction });
324
+ ```
325
+
326
+ Only reach for `createShardManager` if your bot actually runs across
327
+ multiple discord.js shards (large multi-guild bots). It's a separate,
328
+ independent function — pulling it in doesn't require any sharding-specific
329
+ config elsewhere in the framework:
330
+
331
+ ```ts
332
+ import { createShardManager } from '@went.tf/discord-bot-framework/client';
333
+
334
+ const manager = await createShardManager({
335
+ token, botScriptPath, logger,
336
+ beforeSpawn: () => startupCommandsUpdate(logger),
337
+ });
338
+ ```
339
+
340
+ ### `@went.tf/discord-bot-framework/dev`
341
+
342
+ Live-reloads compiled command/interaction handler *implementations* during
343
+ local development, without restarting the process or re-registering commands
344
+ with Discord for every code change. `createHandlerWatcher` is a small,
345
+ dependency-free primitive built on native `fs.watch` — it only watches paths,
346
+ debounces/coalesces filesystem events per file, and invokes your `onChange`
347
+ callback (catching and logging anything it throws so a bad reload never
348
+ crashes the bot). It deliberately does not know how to re-import a module or
349
+ merge it into a registry, since that depends on each bot's own file layout:
350
+
351
+ ```ts
352
+ import { createHandlerWatcher } from '@went.tf/discord-bot-framework/dev';
353
+ import { pathToFileURL } from 'node:url';
354
+ import { basename, extname } from 'node:path';
355
+
356
+ if (env.DEV_WATCH) {
357
+ const watcher = createHandlerWatcher({
358
+ paths: ['./build/commands'],
359
+ logger,
360
+ onChange: async (filePath) => {
361
+ const commandName = basename(filePath, extname(filePath));
362
+ if (!chatInputCommandRegistry.isKnown(commandName)) return;
363
+ // The `?t=` query busts Node's ESM module cache, which keys on the
364
+ // resolved URL — deriving the registry key and writing it back into
365
+ // `byName` is bot-side glue, not something this package standardizes.
366
+ const fresh = await import(`${pathToFileURL(filePath).href}?t=${Date.now()}`);
367
+ chatInputCommandRegistry.byName[commandName] = fresh.default;
368
+ logger.log(`Reloaded command handler: ${commandName}`);
369
+ },
370
+ });
371
+
372
+ process.on('SIGINT', () => {
373
+ watcher.close();
374
+ process.exit(0);
375
+ });
376
+ }
377
+ ```
378
+
379
+ This works because `dispatch*`/`createInteractionRouter` always read
380
+ `registry.byName[key]` live on every interaction — mutating an entry in place
381
+ is picked up on the very next interaction with no other wiring.
382
+
383
+ **Limitations:** this only reloads handler implementations already sitting in
384
+ a registry's `byName`. It does **not** re-run command registration — changing
385
+ a command's `commands.json` entry (name, description, options schema) still
386
+ requires re-running `buildApplicationCommandsBody` + `createCommandRegistrar`
387
+ and a full process restart, and a brand-new command file that wasn't in the
388
+ registry at startup isn't picked up without one either. It also assumes a parallel `tsc --watch` (or equivalent) process
389
+ is running, since this package has no bundler and watches compiled `build/`
390
+ output, not `src/`. Gate it behind your own dev-only flag (e.g. a `DEV_WATCH`
391
+ env var via `boolFromString()`) — this package intentionally has no built-in
392
+ concept of a dev/prod mode.
393
+
394
+ If your `onChange` callback re-imports only the *one file that changed* (as
395
+ in the example above), it correctly picks up edits to a command file itself,
396
+ but **not** edits to a shared module that command statically imports — a
397
+ modal handler, a util, anything under a second file. Node's ESM cache keys on
398
+ resolved URL: giving the changed file a fresh cache-busted URL doesn't affect
399
+ how its own `import './some-util.js'` statement resolves, so that nested
400
+ import still returns the stale cached instance. Reimporting one small
401
+ aggregator module that pulls in your whole command/component tree (its
402
+ `registry.byName` values in particular) instead of one file at a time avoids
403
+ this — see `createSourceReloader` below.
404
+
405
+ #### `createSourceReloader`
406
+
407
+ Re-imports a module — and everything it transitively imports from under a
408
+ given root directory — as brand-new instances on every call, without
409
+ restarting the process. Unlike the single-file `?t=` trick above, this
410
+ correctly picks up changes to *any* file in the reloaded subtree, not just
411
+ the one directly re-imported, by tagging every module resolved under
412
+ `rootDir` with a shared epoch via a `module.register()` hook, and bumping
413
+ that epoch before each `reimport()`:
414
+
415
+ ```ts
416
+ import { createHandlerWatcher, createSourceReloader } from '@went.tf/discord-bot-framework/dev';
417
+ import { join } from 'node:path';
418
+
419
+ if (env.DEV_WATCH) {
420
+ const reloader = createSourceReloader({ rootDir: currentFolder, logger });
421
+ const interactionsPath = join(currentFolder, 'utils', 'interactions.ts');
422
+
423
+ const watcher = createHandlerWatcher({
424
+ paths: [join(currentFolder, 'commands'), join(currentFolder, 'components'), join(currentFolder, 'utils')],
425
+ filter: filePath => filePath.endsWith('.ts'),
426
+ logger,
427
+ onChange: async () => {
428
+ const fresh = await reloader.reimport(interactionsPath);
429
+ // `registry.byName` is what dispatch reads live — merge into the existing
430
+ // registry object in place; the binding in the module that declared it
431
+ // (and everything that imported it) can't be swapped from out here.
432
+ Object.assign(chatInputCommandRegistry.byName, fresh.chatInputCommandRegistry.byName);
433
+ Object.assign(componentRegistry.byName, fresh.componentRegistry.byName);
434
+ },
435
+ });
436
+
437
+ process.on('SIGINT', () => watcher.close());
438
+ }
439
+ ```
440
+
441
+ Anything resolved **outside** `rootDir` — `node_modules`, this framework,
442
+ compiled output elsewhere — is left completely alone, on Node's normal
443
+ module cache. That's the property that makes this safe to use for a Discord
444
+ bot: as long as your gateway client and DB pool are created (and imported
445
+ from) outside `rootDir` — true for the shard-script shape shown under
446
+ `createShardManager` below, where the client is built directly in `bot.ts`
447
+ and never re-imported by the reloaded `interactions.ts` subtree — a reload
448
+ never reconnects the client or reopens the pool. Reloading a module *with*
449
+ top-level side effects (one that opens a connection, starts a timer) will
450
+ duplicate those side effects on every call; keep whatever you reload
451
+ side-effect-free (a thin aggregator of plain object exports, like
452
+ `interactions.ts` above).
453
+
454
+ `reimport()`'s epoch tag lives on a `SharedArrayBuffer`, so it needs
455
+ `--allow-worker` under Node's permission model, same as `module.register()`
456
+ itself.
457
+
458
+ **Combining with `createShardManager`:** the example above assumes the
459
+ process calling `createHandlerWatcher` is also the one holding the
460
+ registries — true for `createBotClient` bots, and true for a
461
+ `createShardManager` bot's *shard* process (the file at `botScriptPath`),
462
+ **not** the top-level process that calls `createShardManager` itself. Put the
463
+ `if (env.DEV_WATCH) { ... }` block in the shard script, after the client is
464
+ created, not in the file that spawns the `ShardingManager`.
465
+
466
+ If you skip `tsc --watch` and instead run the shard script directly from
467
+ source (`tsx`/`ts-node`/similar) to avoid a separate compile step, two things
468
+ that are easy to get wrong:
469
+
470
+ - `botScriptPath` must point at the actual file being executed (e.g. `bot.ts`),
471
+ not a `build/`-compiled path that was never written.
472
+ - discord.js's `ShardingManager` does **not** inherit the parent process's
473
+ CLI flags for spawned shards — both `'process'` (`child_process.fork`) and
474
+ `'worker'` (`worker_threads.Worker`) modes are given an explicit
475
+ `execArgv: []` unless you pass your own `execArgv` to `createShardManager`.
476
+ If the parent process is only able to run TypeScript because of loader
477
+ flags injected by a tool like `tsx` (visible in `process.execArgv`), those
478
+ flags are silently dropped for every shard unless you forward them
479
+ yourself — the shard process/thread then fails to load a `.ts` entry file
480
+ at all. Forward them explicitly:
481
+
482
+ ```ts
483
+ const isTsDevMode = process.env.npm_lifecycle_script?.includes('.ts') ?? false;
484
+ await createShardManager({
485
+ token, logger,
486
+ botScriptPath: `${currentFolder}/bot.${isTsDevMode ? 'ts' : 'js'}`,
487
+ mode: isTsDevMode ? 'worker' : 'process',
488
+ execArgv: isTsDevMode ? process.execArgv : undefined,
489
+ beforeSpawn: () => startupCommandsUpdate(logger),
490
+ });
491
+ ```
492
+
493
+ This has no effect on watched paths: `createHandlerWatcher`'s `filter`
494
+ option still needs updating to match `.ts` instead of the default
495
+ `.js`/`.mjs`/`.cjs`, since there's no `build/` output to watch in this mode.
496
+
497
+ ### `@went.tf/discord-bot-framework/utils`
498
+
499
+ `runAttempts`, `getGitData`, `queueLazyPromises`, `condenseStringArray`,
500
+ `sendMessageSlices`, `loadAllMessages`, `getUserIdentifier`,
501
+ `stringifyChannelName`, `stringifyOptionsData`, and generic guild/member/role/
502
+ channel lookups (`getServer`, `findServerTextChannelByName`,
503
+ `findServerRoleByName`, `findServerMember`, `getServerMemberRole`,
504
+ `serverMemberHasRole`, `isSameObject`).
505
+
506
+ ### `@went.tf/discord-bot-framework/db` (optional)
507
+
508
+ Requires `@prisma/client` and `@prisma/adapter-pg` (Postgres only).
509
+
510
+ ```ts
511
+ import { createPostgresPrismaDb } from '@went.tf/discord-bot-framework/db';
512
+ import { PrismaClient } from './generated/prisma/client.js';
513
+
514
+ export const db = createPostgresPrismaDb(PrismaClient, { connectionString: env.DATABASE_URL });
515
+ ```
516
+
517
+ Bots that only talk to an externally-managed database (or no database at
518
+ all) never need to import this subpath or install its peer dependencies.
519
+
520
+ ### `@went.tf/discord-bot-framework/i18n` (optional)
521
+
522
+ Requires `i18next` and `i18next-fs-backend`.
523
+
524
+ ```ts
525
+ import { createI18nInitializer } from '@went.tf/discord-bot-framework/i18n';
526
+
527
+ const initI18next = createI18nInitializer({
528
+ localesDir: './src/locales',
529
+ supportedLngs: SUPPORTED_LANGUAGES,
530
+ fallbackLng: DEFAULT_LANGUAGE,
531
+ debug: env.DEBUG_I18N,
532
+ });
533
+
534
+ const i18nextInstance = await initI18next(logger);
535
+ ```
536
+
537
+ Locale file content, translation-credit generation, and any custom eslint
538
+ i18n-key-validation rules stay entirely bot-side.
539
+
540
+ `createCommandLocalizer` also lives here — see "Localizing command
541
+ names/descriptions" under `./commands` above.
542
+
543
+ ## Development
544
+
545
+ ```sh
546
+ pnpm install
547
+ pnpm test
548
+ pnpm run lint
549
+ pnpm run build
550
+ ```