@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,646 @@
1
+ import type {
2
+ ChatInputCommandInteraction,
3
+ MessageComponentInteraction,
4
+ ModalMessageModalSubmitInteraction,
5
+ } from "discord.js";
6
+ import type { Clock } from "@/clock";
7
+ import {
8
+ type InteractiveMessageHandle,
9
+ type InteractiveSelect,
10
+ type InteractiveView,
11
+ mountInteractiveMessage,
12
+ type StateStore,
13
+ toMessageEditOptions,
14
+ } from "@/discord/components/interactive-message";
15
+ import {
16
+ popLevel,
17
+ pushLevel,
18
+ ROOT_LEVEL_KEY,
19
+ type SettingsEditorLevel,
20
+ } from "@/discord/components/settings-editor/navigation";
21
+ import {
22
+ createEditorButtons,
23
+ createEditorView,
24
+ SETTINGS_EDITOR_RESET_CONFIRMING,
25
+ type SettingsEditorChrome,
26
+ type SettingsEditorIds,
27
+ type SettingsEditorState,
28
+ } from "@/discord/components/settings-editor/settings-editor.view";
29
+ import {
30
+ createCardEditorView,
31
+ fieldButtonId,
32
+ } from "@/discord/components/settings-editor/settings-editor-card.view";
33
+ import type {
34
+ SettingsEditorGroupSubmission,
35
+ SettingsEditorSubmission,
36
+ } from "@/discord/components/settings-editor/settings-editor-field-modal";
37
+ import {
38
+ promptEditorFieldValue,
39
+ promptEditorGroupValue,
40
+ } from "@/discord/components/settings-editor/settings-editor-field-modal";
41
+ import {
42
+ editorField,
43
+ isEditorField,
44
+ isGroupField,
45
+ isPickerField,
46
+ isValueField,
47
+ type SettingsEditorField,
48
+ type SettingsEditorFieldValue,
49
+ type SettingsEditorGroupField,
50
+ } from "@/discord/components/settings-editor/settings-editor-fields";
51
+ import { SETTINGS_EDITOR_MESSAGES } from "@/discord/i18n";
52
+ import { type Button, type ComponentContext, createButton } from "@/discord/interaction/button";
53
+ import { BusinessError, describeError } from "@/errors";
54
+ import { type Locale, resolveBusinessMessage, type Translator } from "@/i18n";
55
+ import type { Logger } from "@/logger";
56
+
57
+ /**
58
+ * What one write leaves behind: the new subject, and the files the message must
59
+ * now carry.
60
+ *
61
+ * Files, not the caller's own notion of an asset: the editor attaches them and
62
+ * never looks further, so a caller with no uploads at all is not made to carry
63
+ * the concept.
64
+ */
65
+ export interface SettingsEditorWrite<S, A> {
66
+ readonly subject: S;
67
+ readonly assets: readonly A[];
68
+ }
69
+
70
+ export interface SettingsEditorOptions<S, A, F extends string> {
71
+ /**
72
+ * The screen's own custom ids, from `editorComponentIds`. Declared by the
73
+ * screen rather than derived here, so a test — and a log — names the very
74
+ * control an administrator clicked.
75
+ */
76
+ readonly ids: SettingsEditorIds;
77
+ readonly fields: readonly SettingsEditorField<F>[];
78
+ /**
79
+ * The levels below the root, if the screen has any. Omitted by a flat screen,
80
+ * which then never renders a return control.
81
+ */
82
+ readonly levels?: readonly SettingsEditorLevel<F>[];
83
+ readonly initial: SettingsEditorWrite<S, A>;
84
+ /** What the modal for `field` opens on. */
85
+ currentValue(subject: S, field: F): SettingsEditorFieldValue;
86
+ /**
87
+ * Persist one field — its typed value, its upload, or what a picker's select
88
+ * came back with, whichever `submission` carries.
89
+ *
90
+ * A `BusinessError` — an unsupported placeholder, a colour that is not one, an
91
+ * oversized upload — is caught here and shown as a notice: nothing is written,
92
+ * the previous value stands, and the screen stays open saying why in the
93
+ * administrator's language.
94
+ *
95
+ * `submission.interaction` is the modal's own submit interaction, handed over
96
+ * because a screen lives for minutes: a caller whose write is restricted must
97
+ * be able to re-read who is asking *now*, not who opened the screen.
98
+ */
99
+ save(
100
+ subject: S,
101
+ field: F,
102
+ submission: SettingsEditorSubmission,
103
+ ): Promise<SettingsEditorWrite<S, A>>;
104
+ /**
105
+ * Persist a group's whole modal at once — one write, whatever business
106
+ * refusal any of its members can raise refusing before any of it lands.
107
+ * Split across several calls, a refusal partway through would leave the
108
+ * fields before it written while the administrator is told the group
109
+ * failed; the module deciding what "one write" means for its own group is
110
+ * what keeps that from happening (a service already taking a partial patch,
111
+ * most often, the very shape a group's submission already is).
112
+ *
113
+ * Required only of a screen that declares a `group`-kind field — never
114
+ * called otherwise, so a screen with none may leave it out.
115
+ */
116
+ saveGroup?(
117
+ subject: S,
118
+ group: F,
119
+ submissions: SettingsEditorGroupSubmission<F>,
120
+ interaction: ModalMessageModalSubmitInteraction,
121
+ ): Promise<SettingsEditorWrite<S, A>>;
122
+ /**
123
+ * Extra text shown inside a group's modal, above its member fields — a
124
+ * token legend whose content depends on the subject (a guild's own declared
125
+ * request lines) and so cannot be declared statically alongside the group
126
+ * the way its label and hint are. `null`, or the hook left out entirely, is
127
+ * a group with nothing to say there.
128
+ */
129
+ groupLegend?(subject: S, group: F): string | null;
130
+ /** Clear every override of the shown subject, uploads included. */
131
+ reset(subject: S): Promise<SettingsEditorWrite<S, A>>;
132
+ readonly chrome: SettingsEditorChrome<S, A>;
133
+ /**
134
+ * Behaviour for whatever the chrome renders above the field menu — the
135
+ * welcome editor's type picker.
136
+ */
137
+ readonly selects?: InteractiveSelect<SettingsEditorState<S, A>>[];
138
+ /**
139
+ * Behaviour for the buttons the chrome renders beside the editor's own.
140
+ *
141
+ * The counterpart of {@link selects}, and needed for the same reason: a screen
142
+ * whose subject is a list needs controls that add to it and remove from it, and
143
+ * those are buttons. Without this the chrome could draw them and nothing would
144
+ * route the click — which is why the welcome preview's look-alikes are declared
145
+ * permanently disabled rather than merely left unhandled.
146
+ *
147
+ * The editor renders none of them itself: what appears on screen is whatever
148
+ * `chrome.extraRows`/`chrome.extraBlocks` puts there, level by level.
149
+ * Registering a button that no row or block carries is harmless, and is how
150
+ * a control confined to one level works.
151
+ */
152
+ readonly buttons?: Button<SettingsEditorState<S, A>>[];
153
+ /**
154
+ * What one write changes outside the screen — a published panel that names
155
+ * a ticket type, say. Called after every write that actually lands, never
156
+ * after a refusal, since nothing changed for it to report then.
157
+ *
158
+ * This screen has no "done" control any more, and so no reliable moment
159
+ * that says "the administrator has finished" — only the message being
160
+ * dismissed, which nothing here is told about. Republishing used to wait
161
+ * for that moment on purpose, so a member watching the public result never
162
+ * saw the half-finished states an administrator passes through on the way
163
+ * to the setting they meant. That trade is deliberately given up: a public
164
+ * page that is current beats one that is merely correct but stale for
165
+ * however long this administrator takes, or forever if the process
166
+ * restarts before they are done.
167
+ *
168
+ * A throw here never reaches the administrator as a refusal — see
169
+ * `applyChange`'s own doc: by the time this runs, the write it reports on
170
+ * has already succeeded, and telling them otherwise would be a lie. It is
171
+ * logged instead and the screen still confirms.
172
+ */
173
+ onWritten?: () => Promise<void>;
174
+ readonly translator: Translator;
175
+ readonly locale: Locale;
176
+ /** Stamps the screen's own embeds, so a render says when it was last refreshed. */
177
+ readonly clock: Clock;
178
+ readonly logger: Logger;
179
+ }
180
+
181
+ /**
182
+ * The mounted screen, filled in once `mountInteractiveMessage` returns. The
183
+ * controls need their behaviour before the message exists, so they close over
184
+ * this cell — the shape `delete.subcommand.ts` uses for the same reason.
185
+ */
186
+ interface MountedScreen<S, A> {
187
+ screen:
188
+ | (InteractiveMessageHandle & { readonly store: StateStore<SettingsEditorState<S, A>> })
189
+ | null;
190
+ }
191
+
192
+ /**
193
+ * What triggers a field's own round trip, whichever control reached it: the
194
+ * embeds layout's field select, or a card layout's per-entry button. Both
195
+ * {@link ComponentContext} and
196
+ * {@link import("@/discord/components/interactive-message").SelectComponentContext}
197
+ * satisfy this — their `interaction` is
198
+ * `ButtonInteraction`/`AnySelectMenuInteraction` respectively, both assignable
199
+ * to the shared `MessageComponentInteraction` base a modal round trip
200
+ * actually needs.
201
+ */
202
+ interface EditorTriggerContext<S> {
203
+ readonly interaction: MessageComponentInteraction;
204
+ readonly state: S;
205
+ update(next: S): Promise<void>;
206
+ }
207
+
208
+ /**
209
+ * The view, filled in once it is built. A card layout's per-field buttons must
210
+ * exist *before* `createCardEditorView` returns — the card renders their
211
+ * `Section` from them — yet their own `onClick` needs the very view being
212
+ * built to re-render through. This cell is what lets the buttons close over
213
+ * "the view, whenever it exists" instead of a value that is not there yet.
214
+ */
215
+ interface MountedView<S, A> {
216
+ view: InteractiveView<SettingsEditorState<S, A>> | null;
217
+ }
218
+
219
+ /**
220
+ * Mount the editor every customisable ticket embed is edited through: a preview
221
+ * of what members see, a menu of fields, a modal per field, and a confirmed
222
+ * reset.
223
+ *
224
+ * Everything specific to *what* is being edited — where the values are read,
225
+ * where they are written, how the preview is rendered — is supplied by the
226
+ * caller. What lives here is the part both screens would otherwise have written
227
+ * twice: the modal round trip, the read-at-submit-time rule, refusals turned
228
+ * into notices, and re-rendering through the interaction that is still usable.
229
+ *
230
+ * Call it on an already-deferred interaction.
231
+ */
232
+ export async function mountSettingsEditor<S, A, F extends string>(
233
+ interaction: ChatInputCommandInteraction,
234
+ options: SettingsEditorOptions<S, A, F>,
235
+ ): Promise<InteractiveMessageHandle & { readonly store: StateStore<SettingsEditorState<S, A>> }> {
236
+ const { translator, locale } = options;
237
+ const { ids } = options;
238
+ const mounted: MountedScreen<S, A> = { screen: null };
239
+ const mountedView: MountedView<S, A> = { view: null };
240
+ // Every level's fields, flattened: a pick is routed by key, and the key is
241
+ // unique across the screen whichever level offered it.
242
+ const allFields: readonly SettingsEditorField<F>[] = [
243
+ ...options.fields,
244
+ ...(options.levels ?? []).flatMap((level) => level.fields),
245
+ ];
246
+
247
+ /**
248
+ * Run one write and fold its outcome into the state. A refusal is answered
249
+ * identically wherever it comes from: nothing changes but the notice.
250
+ *
251
+ * The `try` covers `write()` alone — a `BusinessError` out of it is a
252
+ * legitimate refusal to show, because nothing landed. `onWritten` runs
253
+ * only once that write has actually succeeded, deliberately outside that
254
+ * `try`: a hook failing after the data is already saved is not a refusal
255
+ * of the write, and folding it into the same catch would tell the
256
+ * administrator their change was rejected when it was not — written and
257
+ * called a refusal is worse than either alone.
258
+ *
259
+ * `onWritten` is a public hook; nothing here trusts it not to throw. Its
260
+ * failure is logged rather than shown or re-thrown: the write already
261
+ * landed, so the administrator is owed the confirmation of what they
262
+ * actually did, and a side effect that failed to keep up (a public panel
263
+ * not yet refreshed, say) is an incident to trace, not a message to hand
264
+ * back to them on a screen that has nothing to do with it.
265
+ */
266
+ async function applyChange(
267
+ state: SettingsEditorState<S, A>,
268
+ write: () => Promise<SettingsEditorWrite<S, A>>,
269
+ notice: string,
270
+ ): Promise<SettingsEditorState<S, A>> {
271
+ let written: SettingsEditorWrite<S, A>;
272
+ try {
273
+ written = await write();
274
+ } catch (error) {
275
+ if (error instanceof BusinessError) {
276
+ // A refusal is the one outcome the screen has to say out loud:
277
+ // nothing was written, so the preview shows no trace of it.
278
+ return {
279
+ ...state,
280
+ notice: { text: resolveBusinessMessage(error, translator, locale), tone: "refusal" },
281
+ };
282
+ }
283
+ throw error;
284
+ }
285
+ try {
286
+ await options.onWritten?.();
287
+ } catch (error) {
288
+ options.logger.error(
289
+ { err: describeError(error) },
290
+ "onWritten failed after a settings editor write had already landed",
291
+ );
292
+ }
293
+ const { subject, assets } = written;
294
+ return { ...state, subject, assets, notice: { text: notice, tone: "confirmation" } };
295
+ }
296
+
297
+ /** Undo one step of where the screen is: leave the level for the one above it. */
298
+ function stepBack(clickCtx: ComponentContext<SettingsEditorState<S, A>>): Promise<void> {
299
+ const { state } = clickCtx;
300
+ return clickCtx.update({
301
+ ...state,
302
+ path: popLevel(state.path),
303
+ notice: null,
304
+ // Whatever a screen's own accessory button was waiting to have confirmed
305
+ // belonged to the level being left; carrying it up would let a click
306
+ // meant for something else fire it.
307
+ confirming: null,
308
+ });
309
+ }
310
+
311
+ function fieldNotice(
312
+ field: SettingsEditorField<F>,
313
+ submission: SettingsEditorSubmission,
314
+ ): string {
315
+ return translator.translate(locale, fieldNoticeKey(field, submission), {
316
+ field: translator.translate(locale, field.labelKey),
317
+ });
318
+ }
319
+
320
+ /**
321
+ * Edit one field: ask for its new value in a modal prefilled with the current
322
+ * one — a text input, an upload, or a select of the picker's own shape — save
323
+ * what comes back, and re-render the preview through the modal's own
324
+ * interaction, since `showModal` has already acknowledged the pick or click.
325
+ *
326
+ * `fieldKey` is the key of the field to edit — the select's own picked value
327
+ * on the embeds layout, the key baked into the button's id on the card
328
+ * layout — so both reach the very same round trip, and this is where they
329
+ * meet back up.
330
+ */
331
+ async function editField(
332
+ ctx: EditorTriggerContext<SettingsEditorState<S, A>>,
333
+ fieldKey: string,
334
+ ): Promise<void> {
335
+ const view = mountedView.view;
336
+ if (view === null || !isEditorField(allFields, fieldKey)) {
337
+ // `view` is unreachable while `null`: nothing can click a control before
338
+ // `mountInteractiveMessage` sends the message it belongs to, and `view` is
339
+ // assigned before that call. An unrecognised key cannot happen from either
340
+ // control either — both are built from `allFields` — but is not worth
341
+ // throwing over.
342
+ return;
343
+ }
344
+ const field = editorField(allFields, fieldKey);
345
+ if (isGroupField(field)) {
346
+ await editGroup(ctx, field);
347
+ return;
348
+ }
349
+ if (!isValueField(field) && !isPickerField(field)) {
350
+ // A level: descending writes nothing, so it answers the click by
351
+ // re-rendering rather than going through `applyChange` — there is no
352
+ // outcome to report.
353
+ const state = mounted.screen?.store.read() ?? ctx.state;
354
+ await ctx.update({
355
+ ...state,
356
+ path: pushLevel(state.path, field.levelKey),
357
+ // The notice belonged to the level being left; carrying it down would
358
+ // credit this one with something that happened elsewhere. Likewise
359
+ // whatever a screen's own accessory button on that level was waiting
360
+ // to have confirmed.
361
+ notice: null,
362
+ confirming: null,
363
+ });
364
+ return;
365
+ }
366
+ const submission = await promptEditorFieldValue(
367
+ ctx.interaction,
368
+ field,
369
+ options.currentValue(ctx.state.subject, fieldKey),
370
+ translator,
371
+ locale,
372
+ );
373
+ if (submission === null) {
374
+ return;
375
+ }
376
+
377
+ // Read at submit time rather than at pick time: a modal can stay open for
378
+ // minutes, during which the screen may have moved on to another subject.
379
+ const state = mounted.screen?.store.read() ?? ctx.state;
380
+ const next = await applyChange(
381
+ state,
382
+ () => options.save(state.subject, fieldKey, submission),
383
+ fieldNotice(field, submission),
384
+ );
385
+ mounted.screen?.store.write(next);
386
+ await submission.interaction.update(
387
+ toMessageEditOptions(
388
+ view.render(next),
389
+ [...submission.interaction.message.attachments.values()],
390
+ next,
391
+ ),
392
+ );
393
+ }
394
+
395
+ /**
396
+ * Edit a group: ask for every member field it declares in one modal,
397
+ * prefilled the same way a single field is, and save the whole thing as one
398
+ * write — `options.saveGroup` is what decides what "one write" means for
399
+ * this group, most often a service that already takes a partial patch.
400
+ */
401
+ async function editGroup(
402
+ ctx: EditorTriggerContext<SettingsEditorState<S, A>>,
403
+ group: SettingsEditorGroupField<F>,
404
+ ): Promise<void> {
405
+ const view = mountedView.view;
406
+ if (view === null) {
407
+ return;
408
+ }
409
+ const prompt = await promptEditorGroupValue(
410
+ ctx.interaction,
411
+ group,
412
+ (memberKey) => options.currentValue(ctx.state.subject, memberKey),
413
+ options.groupLegend?.(ctx.state.subject, group.key) ?? null,
414
+ translator,
415
+ locale,
416
+ );
417
+ if (prompt === null) {
418
+ return;
419
+ }
420
+ const { saveGroup } = options;
421
+ if (saveGroup === undefined) {
422
+ // A screen declaring a `group`-kind field must also declare `saveGroup` —
423
+ // a wiring mistake in the module, not something an administrator caused,
424
+ // so the round trip simply ends here rather than throwing at them for it.
425
+ return;
426
+ }
427
+
428
+ // Read at submit time rather than at pick time, exactly as `editField`
429
+ // does: a modal can stay open for minutes, during which the screen may
430
+ // have moved on to another subject.
431
+ const state = mounted.screen?.store.read() ?? ctx.state;
432
+ const next = await applyChange(
433
+ state,
434
+ () => saveGroup(state.subject, group.key, prompt.values, prompt.interaction),
435
+ translator.translate(locale, SETTINGS_EDITOR_MESSAGES.fieldSaved, {
436
+ field: translator.translate(locale, group.labelKey),
437
+ }),
438
+ );
439
+ mounted.screen?.store.write(next);
440
+ await prompt.interaction.update(
441
+ toMessageEditOptions(
442
+ view.render(next),
443
+ [...prompt.interaction.message.attachments.values()],
444
+ next,
445
+ ),
446
+ );
447
+ }
448
+
449
+ const buttons = createEditorButtons<S, A>(ids, translator, locale, {
450
+ // Nothing is written here. Clearing an override that names an uploaded
451
+ // image destroys the bytes with it, so the click that asks and the click
452
+ // that carries it out are deliberately two different ones.
453
+ onReset: (clickCtx) =>
454
+ clickCtx.update({ ...clickCtx.state, confirming: SETTINGS_EDITOR_RESET_CONFIRMING }),
455
+ onConfirmReset: async (clickCtx) => {
456
+ const next = await applyChange(
457
+ clickCtx.state,
458
+ () => options.reset(clickCtx.state.subject),
459
+ translator.translate(locale, SETTINGS_EDITOR_MESSAGES.resetDone),
460
+ );
461
+ await clickCtx.update({ ...next, confirming: null });
462
+ },
463
+ onCancelReset: (clickCtx) =>
464
+ clickCtx.update({
465
+ ...clickCtx.state,
466
+ notice: {
467
+ text: translator.translate(locale, SETTINGS_EDITOR_MESSAGES.resetCancelled),
468
+ tone: "confirmation",
469
+ },
470
+ confirming: null,
471
+ }),
472
+ onReturn: (clickCtx) => stepBack(clickCtx),
473
+ });
474
+
475
+ const dressing = {
476
+ clock: options.clock,
477
+ // Read from the client rather than asked of the caller: every screen wants
478
+ // the same avatar, and it is the one thing here nobody has to configure.
479
+ iconUrl: interaction.client.user?.displayAvatarURL() ?? null,
480
+ };
481
+
482
+ /**
483
+ * Whether `field` goes somewhere rather than editing anything — the same
484
+ * question `editField` itself answers for its own routing, asked here only
485
+ * to word its button: "Open" for a level, "Edit" for everything else.
486
+ */
487
+ function isLevelField(field: SettingsEditorField<F>): boolean {
488
+ return !isValueField(field) && !isPickerField(field) && !isGroupField(field);
489
+ }
490
+
491
+ /**
492
+ * Which layout the level owning `field` renders its entries with — the
493
+ * root's own fields are always `"sections"`, since the root has no picker
494
+ * above it for a `"buttons"` row to act on. Looked up once per field rather
495
+ * than threaded through as a parameter: `buildFieldButton` is called from
496
+ * one place, over every field up front, and this is what tells it apart
497
+ * from the level a field belongs to without that caller repeating the walk
498
+ * `createCardEditorView` already does over `options.levels`.
499
+ */
500
+ const fieldEntryLayout = new Map<string, "sections" | "buttons">();
501
+ // The root's own layout is the card chrome's `entryLayout` — the embeds
502
+ // layout never reads this map at all, so a screen without a card chrome
503
+ // simply leaves every root field labelled the default it is never asked for.
504
+ const rootEntryLayout =
505
+ options.chrome.layout === "card" ? (options.chrome.entryLayout ?? "sections") : "sections";
506
+ for (const field of options.fields) {
507
+ fieldEntryLayout.set(field.key, rootEntryLayout);
508
+ }
509
+ for (const level of options.levels ?? []) {
510
+ const entryLayout = level.entryLayout ?? "sections";
511
+ for (const field of level.fields) {
512
+ fieldEntryLayout.set(field.key, entryLayout);
513
+ }
514
+ }
515
+
516
+ /**
517
+ * The button a card layout's `Section` carries for one menu entry — what
518
+ * picking it from the old field select used to do, now reached by a click.
519
+ * Built for every field up front, whichever level declares it: a button not
520
+ * on the level currently shown is simply never rendered, exactly as
521
+ * `SettingsEditorButtons.returning` is carried whether or not the current
522
+ * state shows it.
523
+ *
524
+ * A level rendering its entries as a `"buttons"` row has no `Section` text
525
+ * to say what each one does, so its button carries the entry's own label —
526
+ * "Declare a game", "This game" — instead of the generic "Open"/"Edit" a
527
+ * `"sections"` level's accessory button wears.
528
+ */
529
+ function buildFieldButton(field: SettingsEditorField<F>): Button<SettingsEditorState<S, A>> {
530
+ const navigational = isLevelField(field);
531
+ const label =
532
+ fieldEntryLayout.get(field.key) === "buttons"
533
+ ? translator.translate(locale, field.labelKey)
534
+ : translator.translate(
535
+ locale,
536
+ navigational
537
+ ? SETTINGS_EDITOR_MESSAGES.openLevelButton
538
+ : SETTINGS_EDITOR_MESSAGES.editFieldButton,
539
+ );
540
+ return createButton<SettingsEditorState<S, A>>({
541
+ id: fieldButtonId(ids, field.key),
542
+ label,
543
+ // The entry's own icon wins when it declares one — the generic one lies
544
+ // for an entry that opens a modal to create something rather than edit
545
+ // what is already there. Left undeclared, this is exactly the generic
546
+ // this repository always rendered.
547
+ emoji: field.icon ?? (navigational ? "➡️" : "✏️"),
548
+ style: "secondary",
549
+ // Frozen along with the rest of the screen while the editor's own reset is
550
+ // waiting for its second click. A screen's own accessory button — a
551
+ // game's own "remove" — gates itself on whatever *it* is waiting to have
552
+ // confirmed instead; this one only ever means the editor's.
553
+ disabled: (state) => state.confirming === SETTINGS_EDITOR_RESET_CONFIRMING,
554
+ onClick: (clickCtx) => editField(clickCtx, field.key),
555
+ });
556
+ }
557
+
558
+ // The card layout replaces the field select with one button per entry,
559
+ // looked up by the very key `editField` already routes on; the embeds
560
+ // layout keeps the select, and never builds a button nothing would render.
561
+ const fieldButtons: Button<SettingsEditorState<S, A>>[] = [];
562
+ const selects: InteractiveSelect<SettingsEditorState<S, A>>[] = [...(options.selects ?? [])];
563
+ let view: InteractiveView<SettingsEditorState<S, A>>;
564
+
565
+ if (options.chrome.layout === "card") {
566
+ const fieldButtonMap = new Map(allFields.map((field) => [field.key, buildFieldButton(field)]));
567
+ fieldButtons.push(...fieldButtonMap.values());
568
+ view = createCardEditorView(
569
+ options.fields,
570
+ options.levels,
571
+ buttons,
572
+ fieldButtonMap,
573
+ options.chrome,
574
+ translator,
575
+ locale,
576
+ dressing,
577
+ );
578
+ } else {
579
+ view = createEditorView(
580
+ ids,
581
+ options.fields,
582
+ options.levels,
583
+ buttons,
584
+ options.chrome,
585
+ translator,
586
+ locale,
587
+ dressing,
588
+ // Whether *any* group on this screen might open on a legend: `groupLegend`
589
+ // is one hook for the whole screen, not declared per group, so every group
590
+ // here reserves the one component a legend would cost rather than risk a
591
+ // modal that only overflows for the one group that actually gets one.
592
+ options.groupLegend !== undefined,
593
+ );
594
+ selects.unshift({
595
+ id: ids.field,
596
+ onSelect: (pickCtx) => {
597
+ const picked = pickCtx.values[0];
598
+ return picked === undefined ? undefined : editField(pickCtx, picked);
599
+ },
600
+ });
601
+ }
602
+ mountedView.view = view;
603
+
604
+ // The screen's own after the editor's, so a caller cannot shadow a control
605
+ // the editor depends on: `mountInteractiveMessage` routes to the first match.
606
+ const allButtons = [...buttons.all, ...fieldButtons, ...(options.buttons ?? [])];
607
+
608
+ mounted.screen = await mountInteractiveMessage<SettingsEditorState<S, A>>(interaction, {
609
+ initialState: {
610
+ subject: options.initial.subject,
611
+ assets: options.initial.assets,
612
+ notice: null,
613
+ confirming: null,
614
+ path: [ROOT_LEVEL_KEY],
615
+ },
616
+ buttons: allButtons,
617
+ selects,
618
+ view,
619
+ ownerId: interaction.user.id,
620
+ logger: options.logger,
621
+ });
622
+ return mounted.screen;
623
+ }
624
+
625
+ /**
626
+ * Which notice a submission earns, in the vocabulary its field's own kind
627
+ * understands: an upload always says so; a picker field reads its clear from
628
+ * `submission.ids` being `null`, a value field from `submission.value` being
629
+ * `null` — the two never mean the same thing on the other kind of field.
630
+ */
631
+ function fieldNoticeKey<F extends string>(
632
+ field: SettingsEditorField<F>,
633
+ submission: SettingsEditorSubmission,
634
+ ): string {
635
+ if (submission.upload !== null) {
636
+ return SETTINGS_EDITOR_MESSAGES.fieldUploaded;
637
+ }
638
+ if (isPickerField(field)) {
639
+ return submission.ids === null
640
+ ? SETTINGS_EDITOR_MESSAGES.fieldCleared
641
+ : SETTINGS_EDITOR_MESSAGES.fieldSaved;
642
+ }
643
+ return submission.value === null
644
+ ? SETTINGS_EDITOR_MESSAGES.fieldCleared
645
+ : SETTINGS_EDITOR_MESSAGES.fieldSaved;
646
+ }