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