Scenario generator for vore roleplay and story ideas. https://scenario-generator.deliciousreya.net/responses
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

588 lines
17 KiB

import {
type ApplicationCommandOptionLimitedString,
type AutocompleteChoice,
AutocompleteContext,
CommandContext,
CommandOptionType,
ComponentContext,
SlashCommand,
type SlashCreator
} from 'slash-create/web';
import { type Database } from '../db/database.js';
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.js';
import {
generatedContentsToString, generatedStateToString,
MAX_IDENTIFIER_LENGTH,
MAX_NAME_LENGTH,
MAX_RESULT_LENGTH,
MAX_URL_LENGTH,
type RollTableAuthor
} from '../../common/rolltable.js';
import markdownEscape from 'markdown-escape';
const tableOption: Omit<ApplicationCommandOptionLimitedString, 'name' | 'description'> = {
type: CommandOptionType.STRING,
autocomplete: true,
max_length: MAX_IDENTIFIER_LENGTH
};
const resultOption: Omit<ApplicationCommandOptionLimitedString, 'name' | 'description'> = {
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<void> {
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<RollTableAuthor | null> {
return await this.db.getDiscordAuthor(ctx.user.id);
}
private async onSet(ctx: CommandContext): Promise<RollTableAuthor | null> {
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<RollTableAuthor | null> {
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<AutocompleteChoice[]> {
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<AutocompleteChoice[]> {
const results = await this.db.autocompleteText(setSnowflake, tableIdentifier, partialText, includeGlobal);
return results.map(({ text }) => ({
name: text,
value: text
}));
}
async autocomplete(ctx: AutocompleteContext): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
try {
await ctx.delete(ctx.messageID);
} catch (error) {
await ctx.send(generateErrorMessageFor({error, context: 'delete this scenario'}));
}
}
}