├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .prettierrc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── package.json ├── src ├── ContextMenuBase.ts ├── CustomErrors.ts ├── MiddlewarePipeline.ts ├── OptionTypes.ts ├── Page.ts ├── PageComponents.ts ├── PingableTimedCache.ts ├── SlashCommandBase.ts ├── SlashasaurusClient.ts ├── TemplateModal.ts ├── index.ts └── utilityTypes.ts ├── tsconfig.json └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended" 9 | ], 10 | "parser": "@typescript-eslint/parser", 11 | "parserOptions": { 12 | "ecmaVersion": "latest", 13 | "sourceType": "module" 14 | }, 15 | "plugins": [ 16 | "@typescript-eslint" 17 | ], 18 | "rules": { 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | 4 | .env -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | .prettierrc 3 | .tool-versions 4 | tsconfig.json -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "printWidth": 80 5 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Rodentman87 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
11 | 12 | ## About 13 | 14 | Slashasaurus is a framework built on top of Discord.js. It's inspired by React and Next.JS, so if you've used either before, this will feel kinda familiar to you. 15 | 16 | It is _strongly_ recommended that you use [TypeScript](https://www.typescriptlang.org/) with this library, however, it is not a requirement. The quick start is written in TypeScript, most information should be very similar for vanilla JS. 17 | 18 | ## Installation 19 | 20 | To start a new project with Slashasaurus, you need to install discord.js as well as slashasaurus. 21 | 22 | ```sh 23 | npm install --save discord.js slashasaurus 24 | 25 | # or 26 | 27 | yarn add discord.js slashasaurus 28 | ``` 29 | 30 | Alternatively, you can use [create-slashasaurus-app](https://www.npmjs.com/package/create-slashasaurus-app) to generate the boilerplate for you. 31 | 32 | ```sh 33 | npx create-slashasaurus-app 34 | 35 | # or 36 | 37 | yarn create slashasaurus-app 38 | ``` 39 | 40 | See [discord.js's readme](https://github.com/discordjs/discord.js#optional-packages) for more info about optional packages. 41 | 42 | ## Docs 43 | 44 | [View the docs here!](https://rodentman87.gitbook.io/slashasaurus/) 45 | 46 | ## Latest Changelogs 47 | 48 | Check out the [releases on GitHub](https://github.com/Rodentman87/slashasaurus/releases) for the latest changelogs. 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slashasaurus", 3 | "version": "0.13.0-beta", 4 | "main": "dist/index.js", 5 | "types": "dist/index.d.ts", 6 | "license": "MIT", 7 | "scripts": { 8 | "prebuild": "rimraf ./dist", 9 | "build": "tsc" 10 | }, 11 | "devDependencies": { 12 | "@types/node": "^17.0.21", 13 | "@typescript-eslint/eslint-plugin": "^5.27.0", 14 | "@typescript-eslint/parser": "^5.27.0", 15 | "discord.js": "14.17.3", 16 | "eslint": "^8.16.0", 17 | "prettier": "^3.4.2", 18 | "rimraf": "^3.0.2", 19 | "typescript": "^5.0.2" 20 | }, 21 | "peerDependencies": { 22 | "discord.js": "^14.17.3" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "https://github.com/Rodentman87/slashasaurus.git" 27 | }, 28 | "dependencies": { 29 | "@discordjs/rest": "^2.4.2", 30 | "discord-api-types": "^0.37.36" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/ContextMenuBase.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | MessageContextMenuCommandInteraction, 3 | UserContextMenuCommandInteraction, 4 | } from 'discord.js'; 5 | import type { LocalizationMap } from 'discord-api-types/v10'; 6 | import { SlashasaurusClient } from './SlashasaurusClient'; 7 | 8 | type ContextCommandOptions{ 77 | new (): Page
; 78 | pageId: string; 79 | _client: SlashasaurusClient; 80 | deserializeState: DeserializeStateFn
; 81 | } 82 | 83 | export interface Page
, S = Record ;
85 | render(): RenderedPage | Promise ;
95 | readonly client: SlashasaurusClient;
96 | // eslint-disable-next-line @typescript-eslint/ban-types
97 | handlers: Map ) => Pick (
232 | rows: PageComponentRows,
233 | page: Page ,
234 | ): ActionRowBuilder (
265 | component: PageComponentArray[number],
266 | page: Page ,
267 | pageId: string,
268 | ) {
269 | if ('handler' in component) {
270 | return component.toDjsComponent(
271 | `~${pageId};${page.registerHandler(component.handler)}`,
272 | );
273 | } else {
274 | return component.toDjsComponent();
275 | }
276 | }
277 |
278 | export function compareMessages(
279 | a: MessageComponentInteraction['message'],
280 | b: RenderedPage,
281 | ) {
282 | if (a.content !== (b.content ?? '')) return false;
283 |
284 | const bComponents = b.components?.filter(
285 | (c) => Array.isArray(c) || c instanceof PageActionRow,
286 | );
287 |
288 | // Check Components
289 | if (
290 | a.components &&
291 | bComponents &&
292 | a.components.length === bComponents.length
293 | ) {
294 | // They both have components, lets compare them
295 | const componentsMatch = [...a.components].every((row, index) => {
296 | const bRow = bComponents[index];
297 | const bChildren = bRow instanceof PageActionRow ? bRow.children : bRow;
298 | if (row.components.length !== bChildren.length) return false;
299 | return [...bChildren].every((component, index) => {
300 | return component.compareToComponent(row.components[index]);
301 | });
302 | });
303 | if (!componentsMatch) return false;
304 | } else if (a.components || bComponents) {
305 | // One has components but the other doesn't
306 | return false;
307 | }
308 |
309 | // Check Embeds
310 | if (
311 | a.embeds.filter((e) => e.data.type === 'rich').length !==
312 | (b.embeds ?? []).length
313 | )
314 | return false;
315 | if (a.embeds.length > 0) {
316 | if (
317 | !(b.embeds ?? []).every((bEmbedData, index) => {
318 | const bEmbed = 'data' in bEmbedData ? bEmbedData.data : bEmbedData;
319 | return embedsAreEqual(a.embeds[index], bEmbed);
320 | })
321 | ) {
322 | return false;
323 | }
324 | }
325 |
326 | return true;
327 | }
328 |
329 | function embedsAreEqual(a: Embed, b: APIEmbed) {
330 | if (
331 | a.title !== (b.title ? b.title.trim() : b.title) ||
332 | a.description !== (b.description ? b.description.trim() : b.description) ||
333 | a.url !== b.url ||
334 | (a.color ?? 0) !== b.color
335 | )
336 | return false;
337 |
338 | // Compare timestamps
339 | if (a.timestamp && b.timestamp) {
340 | if (new Date(a.timestamp).getTime() !== new Date(b.timestamp).getTime())
341 | return false;
342 | } else if (a.timestamp || b.timestamp) return false;
343 |
344 | // Compare authors
345 | const headerIconUrl =
346 | a.author && ('iconURL' in a.author ? a.author.iconURL : undefined);
347 | if (
348 | a.author &&
349 | b.author &&
350 | (a.author.name !== b.author.name?.trim() ||
351 | headerIconUrl !== b.author.icon_url ||
352 | a.author.url !== b.author.url)
353 | )
354 | return false;
355 | else if ((a.author && !b.author) || (b.author && !a.author)) return false;
356 |
357 | // Compare footers
358 | const footerIconUrl =
359 | a.footer && ('iconURL' in a.footer ? a.footer.iconURL : undefined);
360 | if (
361 | a.footer &&
362 | b.footer &&
363 | (a.footer?.text !== b.footer?.text?.trim() ||
364 | footerIconUrl !== b.footer?.icon_url)
365 | )
366 | return false;
367 | else if ((a.footer && !b.footer) || (b.footer && !a.footer)) return false;
368 |
369 | // Compare images
370 | if (
371 | (a.image && !b.image) ||
372 | (b.image && !a.image) ||
373 | a.image?.url !== b.image?.url
374 | )
375 | return false;
376 | if (
377 | (a.thumbnail && !b.thumbnail) ||
378 | (b.thumbnail && !a.thumbnail) ||
379 | a.thumbnail?.url !== b.thumbnail?.url
380 | )
381 | return false;
382 |
383 | // Compare fields
384 | const aFields = a.fields ?? [];
385 | const bFields = b.fields ?? [];
386 | if (aFields.length !== bFields.length) return false;
387 | return aFields.every(
388 | (f, i) =>
389 | f.inline === bFields[i].inline &&
390 | f.name === bFields[i].name?.trim() &&
391 | f.value === bFields[i].value?.trim(),
392 | );
393 | }
394 |
--------------------------------------------------------------------------------
/src/PageComponents.ts:
--------------------------------------------------------------------------------
1 | import { SelectMenuBuilder } from '@discordjs/builders';
2 | import {
3 | ButtonBuilder,
4 | ButtonInteraction,
5 | ButtonStyle,
6 | ComponentEmojiResolvable,
7 | ComponentType,
8 | MessageComponentInteraction,
9 | SelectMenuInteraction,
10 | parseEmoji,
11 | SelectMenuOptionBuilder,
12 | UserSelectMenuBuilder,
13 | UserSelectMenuComponentData,
14 | ChannelSelectMenuBuilder,
15 | MentionableSelectMenuBuilder,
16 | RoleSelectMenuBuilder,
17 | RoleSelectMenuComponentData,
18 | UserSelectMenuInteraction,
19 | RoleSelectMenuInteraction,
20 | ChannelSelectMenuComponentData,
21 | ChannelSelectMenuInteraction,
22 | MentionableSelectMenuComponentData,
23 | MentionableSelectMenuInteraction,
24 | ChannelType,
25 | APISelectMenuOption,
26 | } from 'discord.js';
27 |
28 | type NonLinkStyles =
29 | | ButtonStyle.Danger
30 | | ButtonStyle.Secondary
31 | | ButtonStyle.Primary
32 | | ButtonStyle.Success;
33 |
34 | type PageButtonLabelOptions =
35 | | {
36 | label: string;
37 | emoji?: ComponentEmojiResolvable;
38 | }
39 | | {
40 | label?: string;
41 | emoji: ComponentEmojiResolvable;
42 | };
43 |
44 | type PotentialDjsComponent = NonNullable<
45 | MessageComponentInteraction['message']['components']
46 | >[number]['components'][number];
47 |
48 | interface ExportableToDjsComponent {
49 | toDjsComponent(
50 | id: string
51 | ):
52 | | ButtonBuilder
53 | | SelectMenuBuilder
54 | | UserSelectMenuBuilder
55 | | RoleSelectMenuBuilder
56 | | ChannelSelectMenuBuilder
57 | | MentionableSelectMenuBuilder;
58 | }
59 |
60 | export function createInteractable (
61 | component: new (props: P) => unknown | ((props: P) => unknown) | null,
62 | props: P,
63 | ...children: unknown[]
64 | ) {
65 | if (!component) return children;
66 | try {
67 | return new component({
68 | ...props,
69 | children: children.length > 1 ? children : children[0],
70 | });
71 | } catch (e) {
72 | // @ts-expect-error this should work fine, this is like the only way to check that the component is a function
73 | return component({
74 | ...props,
75 | children: children.length > 1 ? children : children[0],
76 | });
77 | }
78 | }
79 |
80 | type PageActionRowChild = PageButton | PageSelect;
81 |
82 | interface PageActionRowProps {
83 | children?: (PageActionRowChild | false) | (PageActionRowChild | false)[];
84 | }
85 |
86 | export class PageActionRow {
87 | children: PageActionRowChild[];
88 |
89 | constructor({ children }: PageActionRowProps) {
90 | if (Array.isArray(children))
91 | this.children = children
92 | .flat()
93 | .filter(
94 | (c): c is PageActionRowChild =>
95 | c instanceof PageInteractableButton ||
96 | c instanceof PageLinkButton ||
97 | c instanceof PageSelect
98 | );
99 | else if (children) this.children = [children];
100 | }
101 | }
102 |
103 | export type PageInteractableButtonOptions = {
104 | handler: (interaction: ButtonInteraction) => void;
105 | style?: NonLinkStyles;
106 | disabled?: boolean;
107 | } & PageButtonLabelOptions;
108 |
109 | export type PageLinkButtonOptions = {
110 | url: string;
111 | disabled?: boolean;
112 | } & PageButtonLabelOptions;
113 |
114 | export class PageInteractableButton implements ExportableToDjsComponent {
115 | type: ComponentType.Button = ComponentType.Button;
116 | handler: (interaction: ButtonInteraction) => void;
117 | style: NonLinkStyles = ButtonStyle.Secondary;
118 | disabled = false;
119 | label?: string;
120 | emoji?: ComponentEmojiResolvable;
121 |
122 | constructor(options: PageInteractableButtonOptions) {
123 | this.handler = options.handler;
124 | if (options.style) this.style = options.style;
125 | if (options.disabled) this.disabled = options.disabled;
126 | if (options.label) this.label = options.label;
127 | if (options.emoji) this.emoji = options.emoji;
128 | }
129 |
130 | toDjsComponent(id: string): ButtonBuilder {
131 | const builder = new ButtonBuilder({
132 | style: this.style,
133 | disabled: this.disabled,
134 | customId: id,
135 | });
136 | if (this.label) builder.setLabel(this.label);
137 | if (this.emoji) builder.setEmoji(this.emoji);
138 | return builder;
139 | }
140 |
141 | compareToComponent(component: PotentialDjsComponent) {
142 | if (!(component.type === ComponentType.Button)) return false;
143 | if ((this.emoji && !component.emoji) || (!this.emoji && component.emoji))
144 | return false;
145 | if (this.emoji && component.emoji) {
146 | if (!compareEmoji(this.emoji, component.emoji)) return false;
147 | }
148 | return (
149 | this.style === component.style &&
150 | this.disabled === (component.disabled ?? false) &&
151 | (this.label ?? null) === component.label
152 | );
153 | }
154 | }
155 |
156 | export class PageLinkButton implements ExportableToDjsComponent {
157 | type: ComponentType.Button = ComponentType.Button;
158 | url: string;
159 | disabled = false;
160 | label?: string;
161 | emoji?: ComponentEmojiResolvable;
162 |
163 | constructor(options: PageLinkButtonOptions) {
164 | this.url = options.url;
165 | if (options.disabled) this.disabled = options.disabled;
166 | if (options.label) this.label = options.label;
167 | if (options.emoji) this.emoji = options.emoji;
168 | }
169 |
170 | toDjsComponent(): ButtonBuilder {
171 | const builder = new ButtonBuilder({
172 | style: ButtonStyle.Link,
173 | disabled: this.disabled,
174 | url: this.url,
175 | });
176 | if (this.label) builder.setLabel(this.label);
177 | if (this.emoji) builder.setEmoji(this.emoji);
178 | return builder;
179 | }
180 |
181 | compareToComponent(component: PotentialDjsComponent) {
182 | if (!(component.type === ComponentType.Button)) return false;
183 | if ((this.emoji && !component.emoji) || (!this.emoji && component.emoji))
184 | return false;
185 | if (this.emoji && component.emoji) {
186 | if (!compareEmoji(this.emoji, component.emoji)) return false;
187 | }
188 | return (
189 | ButtonStyle.Link === component.style &&
190 | this.disabled === component.disabled &&
191 | (this.label ?? null) === component.label &&
192 | this.url === component.url
193 | );
194 | }
195 | }
196 |
197 | export type PageButton = PageInteractableButton | PageLinkButton;
198 |
199 | export interface PageSelectOptions {
200 | handler: (interaction: SelectMenuInteraction) => void;
201 | options: APISelectMenuOption[] | SelectMenuOptionBuilder[];
202 | placeholder?: string;
203 | minValues?: number;
204 | maxValues?: number;
205 | disabled?: boolean;
206 | }
207 |
208 | /**
209 | * @deprecated Use PageStringSelect instead
210 | */
211 | export class PageSelect implements ExportableToDjsComponent {
212 | type: ComponentType.StringSelect = ComponentType.StringSelect;
213 | handler: (interaction: SelectMenuInteraction) => void;
214 | options: SelectMenuOptionBuilder[] = [];
215 | placeholder?: string;
216 | minValues = 1;
217 | maxValues = 1;
218 | disabled = false;
219 |
220 | constructor(options: PageSelectOptions) {
221 | this.handler = options.handler;
222 | // Convert the options to SelectMenuOptionBuilders so that we don't have to deal with emoji weirdness
223 | for (const option of options.options) {
224 | if (option instanceof SelectMenuOptionBuilder) {
225 | this.options.push(option);
226 | } else {
227 | this.options.push(new SelectMenuOptionBuilder(option));
228 | }
229 | }
230 | if (options.placeholder) this.placeholder = options.placeholder;
231 | if (options.minValues) this.minValues = options.minValues;
232 | if (options.maxValues) this.maxValues = options.maxValues;
233 | if (options.disabled) this.disabled = options.disabled;
234 | }
235 |
236 | toDjsComponent(id: string) {
237 | const builder = new SelectMenuBuilder({
238 | min_values: this.minValues,
239 | max_values: this.maxValues,
240 | disabled: this.disabled,
241 | custom_id: id,
242 | });
243 | builder.addOptions(this.options);
244 | if (this.placeholder) builder.setPlaceholder(this.placeholder);
245 | return builder;
246 | }
247 |
248 | compareToComponent(component: PotentialDjsComponent) {
249 | if (!(component.type === ComponentType.StringSelect)) return false;
250 | if (
251 | this.disabled !== component.disabled ||
252 | this.maxValues !== component.maxValues ||
253 | this.minValues !== component.minValues ||
254 | this.placeholder !== component.placeholder
255 | )
256 | return false;
257 | if (this.options.length !== component.options.length) return false;
258 | return this.options.every((option, index) => {
259 | const other = component.options[index];
260 |
261 | if (
262 | other.default !== (option.data.default ?? false) ||
263 | other.description !== (option.data.description ?? null) ||
264 | other.label !== option.data.label ||
265 | other.value !== option.data.value
266 | )
267 | return false;
268 | if (
269 | (option.data.emoji && !other.emoji) ||
270 | (!option.data.emoji && other.emoji)
271 | )
272 | return false;
273 | if (option.data.emoji && other.emoji) {
274 | if (!compareEmoji(option.data.emoji, other.emoji)) return false;
275 | }
276 | return true;
277 | });
278 | }
279 | }
280 |
281 | export class PageStringSelect extends PageSelect {}
282 |
283 | export interface PageUserSelectOptions {
284 | handler: (interaction: UserSelectMenuInteraction) => void;
285 | placeholder?: string;
286 | minValues?: number;
287 | maxValues?: number;
288 | disabled?: boolean;
289 | }
290 |
291 | export class PageUserSelect implements ExportableToDjsComponent {
292 | type: ComponentType.UserSelect = ComponentType.UserSelect;
293 | handler: (interaction: UserSelectMenuInteraction) => void;
294 | placeholder?: string;
295 | minValues = 1;
296 | maxValues = 1;
297 | disabled = false;
298 |
299 | constructor(options: PageUserSelectOptions) {
300 | this.handler = options.handler;
301 | if (options.placeholder) this.placeholder = options.placeholder;
302 | if (options.minValues) this.minValues = options.minValues;
303 | if (options.maxValues) this.maxValues = options.maxValues;
304 | if (options.disabled) this.disabled = options.disabled;
305 | }
306 |
307 | toDjsComponent(id: string) {
308 | const options: UserSelectMenuComponentData = {
309 | type: ComponentType.UserSelect,
310 | minValues: this.minValues,
311 | maxValues: this.maxValues,
312 | disabled: this.disabled,
313 | customId: id,
314 | };
315 | if (this.placeholder) options.placeholder = this.placeholder;
316 | const builder = new UserSelectMenuBuilder(options);
317 | return builder;
318 | }
319 |
320 | compareToComponent(component: PotentialDjsComponent) {
321 | if (!(component.type === ComponentType.UserSelect)) return false;
322 | if (
323 | this.disabled !== component.disabled ||
324 | this.maxValues !== component.maxValues ||
325 | this.minValues !== component.minValues ||
326 | this.placeholder !== component.placeholder
327 | )
328 | return false;
329 | return true;
330 | }
331 | }
332 |
333 | export interface PageRoleSelectOptions {
334 | handler: (interaction: RoleSelectMenuInteraction) => void;
335 | placeholder?: string;
336 | minValues?: number;
337 | maxValues?: number;
338 | disabled?: boolean;
339 | }
340 |
341 | export class PageRoleSelect implements ExportableToDjsComponent {
342 | type: ComponentType.RoleSelect = ComponentType.RoleSelect;
343 | handler: (interaction: RoleSelectMenuInteraction) => void;
344 | placeholder?: string;
345 | minValues = 1;
346 | maxValues = 1;
347 | disabled = false;
348 |
349 | constructor(options: PageRoleSelectOptions) {
350 | this.handler = options.handler;
351 | if (options.placeholder) this.placeholder = options.placeholder;
352 | if (options.minValues) this.minValues = options.minValues;
353 | if (options.maxValues) this.maxValues = options.maxValues;
354 | if (options.disabled) this.disabled = options.disabled;
355 | }
356 |
357 | toDjsComponent(id: string) {
358 | const options: RoleSelectMenuComponentData = {
359 | type: ComponentType.RoleSelect,
360 | minValues: this.minValues,
361 | maxValues: this.maxValues,
362 | disabled: this.disabled,
363 | customId: id,
364 | };
365 | if (this.placeholder) options.placeholder = this.placeholder;
366 | const builder = new RoleSelectMenuBuilder(options);
367 | return builder;
368 | }
369 |
370 | compareToComponent(component: PotentialDjsComponent) {
371 | if (!(component.type === ComponentType.StringSelect)) return false;
372 | if (
373 | this.disabled !== component.disabled ||
374 | this.maxValues !== component.maxValues ||
375 | this.minValues !== component.minValues ||
376 | this.placeholder !== component.placeholder
377 | )
378 | return false;
379 | return true;
380 | }
381 | }
382 |
383 | export interface PageChannelSelectOptions {
384 | handler: (interaction: ChannelSelectMenuInteraction) => void;
385 | placeholder?: string;
386 | minValues?: number;
387 | maxValues?: number;
388 | disabled?: boolean;
389 | channelTypes?: ChannelType[];
390 | }
391 |
392 | export class PageChannelSelect implements ExportableToDjsComponent {
393 | type: ComponentType.ChannelSelect = ComponentType.ChannelSelect;
394 | handler: (interaction: ChannelSelectMenuInteraction) => void;
395 | placeholder?: string;
396 | minValues = 1;
397 | maxValues = 1;
398 | disabled = false;
399 | channelTypes?: ChannelType[];
400 |
401 | constructor(options: PageChannelSelectOptions) {
402 | this.handler = options.handler;
403 | if (options.placeholder) this.placeholder = options.placeholder;
404 | if (options.minValues) this.minValues = options.minValues;
405 | if (options.maxValues) this.maxValues = options.maxValues;
406 | if (options.disabled) this.disabled = options.disabled;
407 | if (options.channelTypes) this.channelTypes = options.channelTypes;
408 | }
409 |
410 | toDjsComponent(id: string) {
411 | const options: ChannelSelectMenuComponentData = {
412 | type: ComponentType.ChannelSelect,
413 | minValues: this.minValues,
414 | maxValues: this.maxValues,
415 | disabled: this.disabled,
416 | customId: id,
417 | };
418 | if (this.channelTypes) options.channelTypes = this.channelTypes;
419 | if (this.placeholder) options.placeholder = this.placeholder;
420 | const builder = new ChannelSelectMenuBuilder(options);
421 | return builder;
422 | }
423 |
424 | compareToComponent(component: PotentialDjsComponent) {
425 | if (!(component.type === ComponentType.ChannelSelect)) return false;
426 | if (
427 | this.disabled !== component.disabled ||
428 | this.maxValues !== component.maxValues ||
429 | this.minValues !== component.minValues ||
430 | this.placeholder !== component.placeholder
431 | )
432 | return false;
433 | return true;
434 | }
435 | }
436 |
437 | export interface PageMentionableSelectOptions {
438 | handler: (interaction: MentionableSelectMenuInteraction) => void;
439 | placeholder?: string;
440 | minValues?: number;
441 | maxValues?: number;
442 | disabled?: boolean;
443 | }
444 |
445 | export class PageMentionableSelect implements ExportableToDjsComponent {
446 | type: ComponentType.MentionableSelect = ComponentType.MentionableSelect;
447 | handler: (interaction: MentionableSelectMenuInteraction) => void;
448 | placeholder?: string;
449 | minValues = 1;
450 | maxValues = 1;
451 | disabled = false;
452 |
453 | constructor(options: PageMentionableSelectOptions) {
454 | this.handler = options.handler;
455 | if (options.placeholder) this.placeholder = options.placeholder;
456 | if (options.minValues) this.minValues = options.minValues;
457 | if (options.maxValues) this.maxValues = options.maxValues;
458 | if (options.disabled) this.disabled = options.disabled;
459 | }
460 |
461 | toDjsComponent(id: string) {
462 | const options: MentionableSelectMenuComponentData = {
463 | type: ComponentType.MentionableSelect,
464 | minValues: this.minValues,
465 | maxValues: this.maxValues,
466 | disabled: this.disabled,
467 | customId: id,
468 | };
469 | if (this.placeholder) options.placeholder = this.placeholder;
470 | const builder = new MentionableSelectMenuBuilder(options);
471 | return builder;
472 | }
473 |
474 | compareToComponent(component: PotentialDjsComponent) {
475 | if (!(component.type === ComponentType.MentionableSelect)) return false;
476 | if (
477 | this.disabled !== component.disabled ||
478 | this.maxValues !== component.maxValues ||
479 | this.minValues !== component.minValues ||
480 | this.placeholder !== component.placeholder
481 | )
482 | return false;
483 | return true;
484 | }
485 | }
486 |
487 | function compareEmoji(
488 | a: ComponentEmojiResolvable,
489 | bEmoji: { id?: string | null; name?: string | null }
490 | ) {
491 | const aEmoji = typeof a === 'string' ? parseEmoji(a) : a;
492 | if (!aEmoji) return false;
493 | if (aEmoji.id) {
494 | return aEmoji.id === bEmoji.id;
495 | } else {
496 | return aEmoji.name === bEmoji.name;
497 | }
498 | }
499 |
--------------------------------------------------------------------------------
/src/PingableTimedCache.ts:
--------------------------------------------------------------------------------
1 | // Create a cache that clears a value after the default ttl
2 | // and will refresh the timer on every get
3 | export class PingableTimedCache (
1127 | page: Page ,
1128 | interaction: MessageComponentInteraction | CommandInteraction,
1129 | ephemeral: boolean,
1130 | ) {
1131 | const messageOptions = await page.render();
1132 | if (ephemeral) {
1133 | // We need to save the interaction instead since it doesn't return a message we can edit
1134 | const message = await interaction.reply({
1135 | ...messageOptions,
1136 | content: messageOptions.content ?? undefined,
1137 | components: messageOptions.components
1138 | ? pageComponentRowsToComponents(messageOptions.components, page)
1139 | : [],
1140 | ephemeral: true,
1141 | fetchReply: true,
1142 | flags: messageOptions.flags as unknown as BitFieldResolvable<
1143 | 'SuppressEmbeds' | 'Ephemeral' | 'SuppressNotifications',
1144 | number
1145 | >,
1146 | });
1147 | page.message = new PageInteractionReplyMessage(
1148 | interaction.webhook,
1149 | message.id,
1150 | );
1151 | const state = await page.serializeState();
1152 | this.storePageState(
1153 | message.id,
1154 | page.constructor.pageId,
1155 | state,
1156 | messageToMessageData(page.message),
1157 | );
1158 | this.activePages.set(message.id, page);
1159 | } else {
1160 | const message = await interaction.reply({
1161 | ...messageOptions,
1162 | content: messageOptions.content ?? undefined,
1163 | components: messageOptions.components
1164 | ? pageComponentRowsToComponents(messageOptions.components, page)
1165 | : [],
1166 | fetchReply: true,
1167 | flags: messageOptions.flags as unknown as BitFieldResolvable<
1168 | 'SuppressEmbeds' | 'Ephemeral' | 'SuppressNotifications',
1169 | number
1170 | >,
1171 | });
1172 | page.message = new PageInteractionReplyMessage(
1173 | interaction.webhook,
1174 | message.id,
1175 | );
1176 | const state = await page.serializeState();
1177 | this.storePageState(
1178 | message.id,
1179 | page.constructor.pageId,
1180 | state,
1181 | messageToMessageData(page.message),
1182 | );
1183 | this.activePages.set(message.id, page);
1184 | }
1185 | page.pageDidSend?.();
1186 | }
1187 |
1188 | async sendPageToChannel (page: Page , channel: SendableChannels) {
1189 | const messageOptions = await page.render();
1190 | const message = await channel.send({
1191 | ...messageOptions,
1192 | content: messageOptions.content ?? undefined,
1193 | components: messageOptions.components
1194 | ? pageComponentRowsToComponents(messageOptions.components, page)
1195 | : [],
1196 | });
1197 | page.message = message;
1198 | const state = await page.serializeState();
1199 | this.storePageState(
1200 | message.id,
1201 | page.constructor.pageId,
1202 | state,
1203 | messageToMessageData(page.message),
1204 | );
1205 | this.activePages.set(message.id, page);
1206 | page.pageDidSend?.();
1207 | }
1208 |
1209 | async sendPageToForumChannel (
1210 | page: Page ,
1211 | postTitle: string,
1212 | channel: ForumChannel,
1213 | ) {
1214 | const messageOptions = await page.render();
1215 | const thread = await channel.threads.create({
1216 | name: postTitle,
1217 | message: {
1218 | ...messageOptions,
1219 | content: messageOptions.content ?? undefined,
1220 | components: messageOptions.components
1221 | ? pageComponentRowsToComponents(messageOptions.components, page)
1222 | : [],
1223 | },
1224 | });
1225 | page.message = thread.lastMessage!;
1226 | const state = await page.serializeState();
1227 | this.storePageState(
1228 | thread.lastMessage!.id,
1229 | page.constructor.pageId,
1230 | state,
1231 | messageToMessageData(page.message),
1232 | );
1233 | this.activePages.set(thread.lastMessage!.id, page);
1234 | }
1235 |
1236 | async updatePage (page: Page , newState: S) {
1237 | if (!page.message)
1238 | throw new Error('You cannot update a page before it has been sent');
1239 | page.state = newState;
1240 | const messageOptions = await page.render();
1241 | const { message } = page;
1242 | if (
1243 | message instanceof PageInteractionReplyMessage &&
1244 | page.latestInteraction &&
1245 | !(page.latestInteraction.deferred || page.latestInteraction.replied)
1246 | ) {
1247 | await page.latestInteraction.update({
1248 | ...messageOptions,
1249 | components: messageOptions.components
1250 | ? pageComponentRowsToComponents(messageOptions.components, page)
1251 | : [],
1252 | flags: messageOptions.flags as any,
1253 | });
1254 | } else {
1255 | await message.edit({
1256 | ...messageOptions,
1257 | components: messageOptions.components
1258 | ? pageComponentRowsToComponents(messageOptions.components, page)
1259 | : [],
1260 | flags: messageOptions.flags as any,
1261 | });
1262 | }
1263 | const state = await page.serializeState();
1264 | this.activePages.set(message.id, page);
1265 | this.storePageState(
1266 | page.message instanceof Message ? page.message.id : page.message.id,
1267 | page.constructor.pageId,
1268 | state,
1269 | messageToMessageData(page.message),
1270 | );
1271 | }
1272 |
1273 | async getPageFromMessage(
1274 | messageOrId: Message | string,
1275 | interaction: MessageComponentInteraction | CommandInteraction,
1276 | ) {
1277 | const id = typeof messageOrId === 'string' ? messageOrId : messageOrId.id;
1278 | const cachedPage = this.activePages.get(id);
1279 | if (!cachedPage) {
1280 | const { pageId, stateString, messageData } = await this.getPageState(id);
1281 | const message =
1282 | messageOrId instanceof Message
1283 | ? messageOrId
1284 | : await this.getMessage(JSON.parse(messageData));
1285 | if (!message)
1286 | throw new Error(
1287 | `Failed to load Page message. ${JSON.stringify(messageData)}`,
1288 | );
1289 | const { page: pageConstructor, deserialize } =
1290 | this.pageMap.get(pageId) ?? {};
1291 | if (!pageConstructor || !deserialize)
1292 | throw new Error(
1293 | `A component tried to load a page type that isn't registered, ${pageId}`,
1294 | );
1295 | const deserialized = await deserialize(stateString, interaction);
1296 | if (!('props' in deserialized)) {
1297 | if (message instanceof Message) {
1298 | await message.delete();
1299 | } else {
1300 | await message.edit({
1301 | content: 'This page has been closed',
1302 | components: [],
1303 | });
1304 | }
1305 | return;
1306 | }
1307 | const { props, state } = deserialized;
1308 | // @ts-expect-error will complain, but we know this is a constructor and JS will complain if we don't do `new`
1309 | const newPage: Page = new pageConstructor(props);
1310 | newPage.state = state;
1311 | newPage.message = message;
1312 | const rendered = await newPage.render();
1313 | if (rendered.components)
1314 | pageComponentRowsToComponents(rendered.components, newPage);
1315 | this.activePages.set(message.id, newPage);
1316 | return newPage;
1317 | }
1318 | return cachedPage;
1319 | }
1320 |
1321 | private async getMessage(messageData: MessageData | InteractionMessageData) {
1322 | if ('guildId' in messageData) {
1323 | try {
1324 | if (messageData.guildId !== 'dm') {
1325 | const guild = await this.guilds.fetch(messageData.guildId);
1326 | const channel = await guild.channels.fetch(messageData.channelId);
1327 | if (!(channel instanceof BaseGuildTextChannel))
1328 | throw new Error(
1329 | `Channel for saved Page was not a text channel, this likely means there's something wrong with the storage. ${messageData.guildId}/${messageData.channelId}/${messageData.messageId}`,
1330 | );
1331 | return channel.messages.fetch(messageData.messageId);
1332 | } else {
1333 | const channel = await this.channels.fetch(messageData.channelId);
1334 | if (!(channel instanceof DMChannel))
1335 | throw new Error(
1336 | `Channel for saved Page was not a DMChannel, this likely means there's something wrong with the storage. ${messageData.guildId}/${messageData.channelId}/${messageData.messageId}`,
1337 | );
1338 | }
1339 | } catch (e) {
1340 | throw new Error(
1341 | `Tried to fetch a message the bot can no longer see: ${messageData.guildId}/${messageData.channelId}/${messageData.messageId}`,
1342 | );
1343 | }
1344 | } else {
1345 | return new PageInteractionReplyMessage(
1346 | new InteractionWebhook(
1347 | this,
1348 | this.application.id,
1349 | messageData.webhookToken,
1350 | ),
1351 | messageData.messageId,
1352 | );
1353 | }
1354 | return;
1355 | }
1356 | }
1357 |
1358 | function messageToMessageData(
1359 | message: Message | PageInteractionReplyMessage,
1360 | ): string {
1361 | if (message instanceof Message) {
1362 | return JSON.stringify({
1363 | messageId: message.id,
1364 | channelId: message.channelId,
1365 | guildId: message.guildId ?? 'dm',
1366 | });
1367 | } else {
1368 | return JSON.stringify({
1369 | webhookToken: message.webhook.token,
1370 | messageId: message.id,
1371 | });
1372 | }
1373 | }
1374 |
--------------------------------------------------------------------------------
/src/TemplateModal.ts:
--------------------------------------------------------------------------------
1 | import { TextInputBuilder } from '@discordjs/builders';
2 | import {
3 | ActionRowBuilder,
4 | ComponentType,
5 | ModalActionRowComponentBuilder,
6 | ModalBuilder,
7 | ModalSubmitInteraction,
8 | TextInputStyle,
9 | } from 'discord.js';
10 |
11 | type ExtractFromDelimiters<
12 | S extends string,
13 | L extends string,
14 | R extends string
15 | > = string extends S
16 | ? string[]
17 | : S extends ''
18 | ? []
19 | : S extends `${infer _T}${L}${infer U}${R}${infer V}`
20 | ? [U, ...ExtractFromDelimiters;
94 | readonly props: Readonly, props: Readonly | S | null)
123 | | (Pick | S | null),
124 | ): Promise