import { type ApplicationCommandOptionLimitedString, type AutocompleteChoice, AutocompleteContext, CommandContext, CommandOptionType, ComponentContext, SlashCommand, type SlashCreator } from 'slash-create/web'; import { type Database } from '../db/database'; import { type Snowflake } from 'discord-snowflake'; import { DELETE_ID, DONE_ID, FAILURE_COLOR, generateAuthorForResult, generateEmbedForResult, generateErrorMessageFor, generateFieldForResult, generateFooterForResult, generateMessageFor, getEmbedFrom, loadEmbed, recordError, REROLL_ID, SELECT_ID, SUCCESS_COLOR, WARNING_COLOR } from './embed'; import { generatedContentsToString, generatedStateToString, MAX_IDENTIFIER_LENGTH, MAX_NAME_LENGTH, MAX_RESULT_LENGTH, MAX_URL_LENGTH, type RollTableAuthor } from '../../common/rolltable'; import markdownEscape from 'markdown-escape'; const tableOption: Omit = { type: CommandOptionType.STRING, autocomplete: true, max_length: MAX_IDENTIFIER_LENGTH }; const resultOption: Omit = { type: CommandOptionType.STRING, max_length: MAX_RESULT_LENGTH }; export class AuthorCommand extends SlashCommand { private readonly db: Database; constructor(creator: SlashCreator, db: Database, forGuilds?: Snowflake | Snowflake[]) { super(creator, { name: 'author', description: 'Modifies the attribution of responses you contribute to the generator.', nsfw: false, guildIDs: forGuilds, dmPermission: true, options: [ { type: CommandOptionType.SUB_COMMAND, name: 'show', description: 'Shows the attribution currently associated with your contributed responses.' }, { type: CommandOptionType.SUB_COMMAND, name: 'set', description: 'Sets your contributed responses to be associated with a name and optional URL.', options: [ { name: 'name', description: 'The name to associate with the responses you create.', required: true, type: CommandOptionType.STRING, max_length: MAX_NAME_LENGTH }, { name: 'url', description: 'The URL to associate with your name on the responses you create.', type: CommandOptionType.STRING, max_length: MAX_URL_LENGTH } ] }, { type: CommandOptionType.SUB_COMMAND, name: 'anonymous', description: 'Sets your contributed responses to be anonymous again.' } ] }); this.db = db; } async run(ctx: CommandContext): Promise { let author: RollTableAuthor | null; switch (ctx.subcommands[0]) { case 'show': try { author = await this.onShow(ctx); } catch (error) { await ctx.send(generateErrorMessageFor({ error, context: 'get your current authorship' })); return; } break; case 'set': try { author = await this.onSet(ctx); } catch (error) { await ctx.send(generateErrorMessageFor({ error, context: 'set your authorship' })); return; } break; case 'anonymous': try { author = await this.onAnonymous(ctx); } catch (error) { await ctx.send(generateErrorMessageFor({ error, context: 'reset your authorship to anonymous' })); return; } break; default: await ctx.send(generateErrorMessageFor({ error: Error('I don\'t know what command you want'), context: 'manage authorship' })); return; } if (author) { await ctx.send({ embeds: [{ title: 'Your responses are credited as...', description: `${markdownEscape(author.relation)} ${author.url ? '[' : ''}${markdownEscape(author.name)}${author.url ? '](' : ''}${markdownEscape(author.url ?? '')}${author.url ? ')' : ''}`, color: SUCCESS_COLOR }], ephemeral: true }); } else { await ctx.send({ embeds: [{ title: 'Your responses are credited as...', description: 'Hey, wait, _what_ responses? I don\'t know anything about you because you haven\'t done anything yet. Come back here when you\'ve contributed a response with /response add or /response edit or used /author set or /author anonymous to tell me about yourself.', color: FAILURE_COLOR }], ephemeral: true }); } } private async onShow(ctx: CommandContext): Promise { return await this.db.getDiscordAuthor(ctx.user.id); } private async onSet(ctx: CommandContext): Promise { return await this.db.setDiscordAuthor(ctx.user.id, ctx.user.username, ctx.options['set']['name'], ctx.options['set']['url']); } private async onAnonymous(ctx: CommandContext): Promise { return await this.db.setDiscordAuthor(ctx.user.id, ctx.user.username, null, null); } } export class ResponseCommand extends SlashCommand { private readonly db: Database; private readonly baseUrl: string; constructor(creator: SlashCreator, db: Database, baseUrl: string, forGuilds?: Snowflake | Snowflake[]) { super(creator, { name: 'response', description: 'Modifies the responses available in the generator.', nsfw: false, guildIDs: forGuilds, dmPermission: true, options: [ { type: CommandOptionType.SUB_COMMAND, name: 'list', description: 'Provides a link to the list of responses that will appear in /generate in the current context.' }, { type: CommandOptionType.SUB_COMMAND, name: 'show', description: 'Shows details about a response that was previously created.', options: [ { ...tableOption, name: 'table', description: 'The table to show the response from.', required: true }, { ...resultOption, name: 'text', description: 'The text of the response to show.', autocomplete: true, required: true } ] }, { type: CommandOptionType.SUB_COMMAND, name: 'add', description: 'Adds a new response to the generator.', options: [ { ...tableOption, name: 'table', description: 'The table to insert the response into.', required: true }, { ...resultOption, name: 'text', description: 'The text to use as the response.', required: true } ] }, { type: CommandOptionType.SUB_COMMAND, name: 'edit', description: 'Modifies a response that was previously created.', options: [ { ...tableOption, name: 'table', description: 'The table to update the response from.', required: true }, { ...resultOption, name: 'old_text', description: 'The text of the response to edit.', autocomplete: true, required: true }, { ...resultOption, name: 'new_text', description: 'The text to replace the response with.', required: true } ] }, { type: CommandOptionType.SUB_COMMAND, name: 'delete', description: 'Deletes a response that was previously created.', options: [ { ...tableOption, name: 'table', description: 'The table to delete the response from.', required: true }, { ...resultOption, name: 'text', description: 'The text of the response to delete.', autocomplete: true, required: true } ] } ] }); this.baseUrl = baseUrl; this.db = db; } async autocompleteTable(tableName: string): Promise { const results = await this.db.autocompleteTable(tableName); return results.map(({ name, identifier, emoji }) => ({ name: `${emoji} ${name}`, value: identifier })); } async autocompleteResultText(setSnowflake: string, tableIdentifier: string, partialText: string, includeGlobal: boolean): Promise { const results = await this.db.autocompleteText(setSnowflake, tableIdentifier, partialText, includeGlobal); return results.map(({ text }) => ({ name: text, value: text })); } async autocomplete(ctx: AutocompleteContext): Promise { try { const subcommand = ctx.subcommands[0]; switch (subcommand) { case 'add': switch (ctx.focused) { case 'table': await ctx.sendResults(await this.autocompleteTable(ctx.options['add']['table'])); return; } break; case 'edit': switch (ctx.focused) { case 'table': await ctx.sendResults(await this.autocompleteTable(ctx.options['edit']['table'])); return; case 'old_text': await ctx.sendResults(await this.autocompleteResultText( ctx.guildID ?? ctx.user.id, ctx.options['edit']['table'], ctx.options['edit']['old_text'], false)); return; } break; case 'delete': switch (ctx.focused) { case 'table': await ctx.sendResults(await this.autocompleteTable(ctx.options['delete']['table'])); return; case 'text': await ctx.sendResults(await this.autocompleteResultText( ctx.guildID ?? ctx.user.id, ctx.options['delete']['table'], ctx.options['delete']['text'], false)); return; } break; case 'show': switch (ctx.focused) { case 'table': await ctx.sendResults(await this.autocompleteTable(ctx.options['show']['table'])); return; case 'text': await ctx.sendResults(await this.autocompleteResultText( ctx.guildID ?? ctx.user.id, ctx.options['show']['table'], ctx.options['show']['text'], true)); return; } break; } await ctx.sendResults([]); } catch (e) { recordError({ context: 'trying to autocomplete response commands', error: e }) await ctx.sendResults([]); } } async run(ctx: CommandContext): Promise { switch (ctx.subcommands[0]) { case 'list': try { await this.onList(ctx); } catch (error) { await ctx.send(generateErrorMessageFor({ error, context: 'get the list URL' })); } break; case 'show': try { await this.onShow(ctx); } catch (error) { await ctx.send(generateErrorMessageFor({ error, context: 'show that response' })); } break; case 'add': try { await this.onAdd(ctx); } catch (error) { await ctx.send(generateErrorMessageFor({ error, context: 'add that new response' })); } break; case 'edit': try { await this.onEdit(ctx); } catch (error) { await ctx.send(generateErrorMessageFor({ error, context: 'edit that response' })); } break; case 'delete': try { await this.onDelete(ctx); } catch (error) { await ctx.send(generateErrorMessageFor({ error, context: 'delete that response' })); } break; default: await ctx.send(generateErrorMessageFor({ error: Error('I don\'t know what command you want'), context: 'manage responses' })); break; } } private async onList(ctx: CommandContext) { if (ctx.guildID) { await ctx.send({ embeds: [{ color: SUCCESS_COLOR, title: `Response list for this server`, description: 'Shows all global and server-local responses.', url: `${this.baseUrl}/responses?server=${ctx.guildID}` }] }); } else { await ctx.send({ embeds: [{ color: FAILURE_COLOR, title: `Response list for DMs`, description: 'This is not supported right now, so please just hang tight.' }] }); } } private async onShow(ctx: CommandContext) { const setId = ctx.guildID ?? ctx.user.id; const table = ctx.options['show']['table']; const text = ctx.options['show']['text']; const result = await this.db.getResponseFromDiscord(table, text, setId); switch (result.status) { case 'nonexistent': await ctx.send(generateErrorMessageFor({ error: `couldn't find a response with that text`, context: `show the response with the text ${text} from the ${table} table` })); break; case 'existent': await ctx.send({ embeds: [generateEmbedForResult('Your requested response', SUCCESS_COLOR, result)] }); break; } } private async onAdd(ctx: CommandContext): Promise { const userId = ctx.user.id; const setId = ctx.guildID ?? userId; const timestamp = ctx.invokedAt; const table = ctx.options['add']['table'] as string | number; const text = ctx.options['add']['text']; const result = await this.db.addResponseFromDiscord(timestamp, table, text, userId, ctx.user.username, setId); await ctx.send({ embeds: [generateEmbedForResult(`${result.status === 'added' ? 'Your new' : 'An existing'} response`, result.status === 'added' ? SUCCESS_COLOR : WARNING_COLOR, result)], ephemeral: result.status === 'existed' }); } private async onEdit(ctx: CommandContext): Promise { const userId = ctx.user.id; const setId = ctx.guildID ?? userId; const timestamp = ctx.invokedAt; const table = ctx.options['edit']['table']; const oldText = ctx.options['edit']['old_text']; const newText = ctx.options['edit']['new_text']; const result = await this.db.editResponseFromDiscord(timestamp, table, oldText, newText, userId, ctx.user.username, setId); switch (result.status) { case 'nonexistent': await ctx.send(generateErrorMessageFor({ error: `couldn't find a response with that text`, context: `alter the response with the text ${oldText} from the ${table} table` })); break; case 'noneditable': await ctx.send({ embeds: [generateEmbedForResult('A non-editable response (unchanged)', FAILURE_COLOR, result.old)], ephemeral: true }); break; case 'conflict': await ctx.send({ embeds: [generateEmbedForResult('The old response (still existing)', WARNING_COLOR, result.old), generateEmbedForResult('A conflicting response', FAILURE_COLOR, result.new)], ephemeral: true }); break; case 'updated': await ctx.send({ embeds: [generateEmbedForResult('The old response (now gone)', SUCCESS_COLOR, result.old), generateEmbedForResult('Your updated response', SUCCESS_COLOR, result.new)] }); break; } } private async onDelete(ctx: CommandContext): Promise { const setId = ctx.guildID ?? ctx.user.id; const table = ctx.options['delete']['table']; const text = ctx.options['delete']['text']; const result = await this.db.deleteResponseFromDiscord(table, text, setId); switch (result.status) { case 'nonexistent': await ctx.send(generateErrorMessageFor({ error: `couldn't find a response with that text`, context: `remove the response with the text ${text} from the ${table} table` })); break; case 'noneditable': await ctx.send({ embeds: [generateEmbedForResult( `A non-editable response (still existing)`, FAILURE_COLOR, result.old)], ephemeral: true }); break; case 'deleted': await ctx.send({ embeds: [generateEmbedForResult( `The response you deleted`, SUCCESS_COLOR, result.old)] }); break; } } } export class GenerateCommand extends SlashCommand { private readonly db: Database; constructor(creator: SlashCreator, db: Database, forGuilds?: Snowflake | Snowflake[]) { super(creator, { name: 'generate', description: 'Generates a new scenario to play with and sends it to the current channel.', nsfw: false, dmPermission: true, guildIDs: forGuilds, throttling: { duration: 5, usages: 1 } }); this.db = db; if (!forGuilds) { creator.registerGlobalComponent(DONE_ID, this.onDone.bind(this)); creator.registerGlobalComponent(REROLL_ID, this.onReroll.bind(this)); creator.registerGlobalComponent(SELECT_ID, this.onSelect.bind(this)); creator.registerGlobalComponent(DELETE_ID, this.onDelete.bind(this)); } } async run(ctx: CommandContext): Promise { try { const state = await this.db.generateFromDiscordSet(ctx.guildID ?? ctx.user.id); await ctx.send(generateMessageFor(state)); } catch (error) { await ctx.send(generateErrorMessageFor({error, context: 'generate a scenario'})); } } async onSelect(ctx: ComponentContext): Promise { try { const oldEmbed = getEmbedFrom(ctx.message); const oldContents = loadEmbed(oldEmbed, false); const newContents = { ...oldContents, selected: new Set(ctx.values) }; const final = await this.db.expandFromDiscordSet(ctx.guildID ?? ctx.user.id, newContents); await ctx.editParent(generateMessageFor(final)); } catch (error) { await ctx.send(generateErrorMessageFor({error, context: 'change the selected components'})); } } async onDone(ctx: ComponentContext): Promise { try { const embed = getEmbedFrom(ctx.message); const finalContents = loadEmbed(embed, false); const finalState = await this.db.finalizeFromDiscordSet(ctx.guildID ?? ctx.user.id, finalContents); await ctx.editParent(generateMessageFor(finalState)); } catch (error) { await ctx.send(generateErrorMessageFor({error, context: 'finish this scenario'})); } } async onReroll(ctx: ComponentContext): Promise { try { const embed = getEmbedFrom(ctx.message); const oldContents = loadEmbed(embed, false); const nextState = await this.db.rerollFromDiscordSet(ctx.guildID ?? ctx.user.id, oldContents); await ctx.editParent(generateMessageFor(nextState)); } catch (error) { await ctx.send(generateErrorMessageFor({error, context: 'reroll this scenario'})); } } async onDelete(ctx: ComponentContext): Promise { try { await ctx.delete(ctx.messageID); } catch (error) { await ctx.send(generateErrorMessageFor({error, context: 'delete this scenario'})); } } }