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
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'}));
|
|
}
|
|
}
|
|
}
|
|
|