basic vore scenario generator

main
Mari 11 months ago
parent 16c66b5460
commit df17f6f5c3
  1. 13
      .idea/dataSources.xml
  2. 55
      README.md
  3. BIN
      example.png
  4. 14
      migrations/0000_initialize_responses_table.sql
  5. 1570
      migrations/0001_add_initial_values.sql
  6. 67
      package-lock.json
  7. 8
      package.json
  8. 376
      src/commands.ts
  9. 180
      src/dbAccess.ts
  10. 233
      src/generated.ts
  11. 126
      src/index.ts
  12. 43
      src/rolltable.ts
  13. 62
      wrangler.toml

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="D1 JDBC Data Source" uuid="0d7460e5-a5b0-427c-ba12-108e8ff70b34">
<driver-ref>java.sql.Driver</driver-ref>
<synchronize>true</synchronize>
<configured-by-url>true</configured-by-url>
<jdbc-driver>org.isaacmcfadyen.D1Driver</jdbc-driver>
<jdbc-url>jdbc:d1://d09d3c74-c75f-4418-8f1b-2fe7f21637e6</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

@ -0,0 +1,55 @@
# Vore Scenario Generator
![Example scenario, reading:
The action takes place at a sports event.
The encounter is themed around voreception.
The action begins when a threat is made.
Things are more difficult because someone is not prepared
Partway through, unexpectedly, the prey nearly escapes
The vore scene is focused on the aftermath
The word of the day is Millionaire](example.png)
It does exactly what it says on the tin: it generates random vore scenarios. For you!
[The list of default responses are here if you want to check it in advance.](https://scenario-generator.deliciousreya.net/responses)
## Usage
### Installation
* Visit [this Discord OAuth link](https://discord.com/api/oauth2/authorize?client_id=1192326191189864458&permissions=0&scope=applications.commands)
and give permission to use application commands in your server. Share it with the admin of your server if you don't
have permission yourself!
* Don't worry - not only does this bot not do anything nefarious with your messages, but it actually can't read them at
all. It only reads the commands you give it and its own messages! If you're concerned, though, please go through the
code. Kink stuff is very personal and private! Use caution when running random horny software!
### Generation
* Run the `/generate` command to generate a random scenario.
* Don't like what you got? Pick the components you don't like from the select box,
then click "Reroll Selected" to generate fresh ones.
* Think the scenario is beyond saving? Click "Trash it." to delete the message.
* Satisfied with the results? Click "Looks good!" to remove the reroll commands.
### Customization
Note that changes to custom responses will be sent to your current channel and will be visible to the admin (that's me)
and everyone in the server, as well as anyone who knows or can guess your server ID. Assume your responses are not
private.
* Run `/response add [table] [text]` to add a new custom response. Use the table listing in the Discord command.
* Run `/response delete [table] [text]` to remove a custom response. Give the response text exactly!
* Run `/response edit [table] [old text] [new text]` to modify a custom response. Give the old response text exactly!
* Run `/response list` to get a link to a list of all responses that will be given in the current server, including both
default and custom responses.
## Credits
* Icon source: [obsid1an on DeviantArt](https://www.deviantart.com/obsid1an/art/Slot-Machine-Game-Icon-341475642)
* Writing for default responses by [Ssublissive](https://aryion.com/g4/gallery/Ssublissive), with additional writing by
[DeliciousReya](https://aryion.com/g4/gallery/DeliciousReya), [Seina](https://aryion.com/g4/user/RediQ) and 1 other.
* Development by [DeliciousReya](https://aryion.com/g4/gallery/DeliciousReya), using
[slash-create](https://slash-create.js.org/#/) and
[discord-snowflake](https://github.com/ianmitchell/interaction-kit/tree/main/packages/discord-snowflake).
* Hosted on [CloudFlare Workers](https://developers.cloudflare.com/workers/)
with [D1](https://developers.cloudflare.com/d1/).

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

@ -0,0 +1,14 @@
-- Migration number: 0000 2024-01-04T08:37:46.641Z
CREATE TABLE IF NOT EXISTS responses (
id INTEGER PRIMARY KEY ASC NOT NULL,
tableId INTEGER NOT NULL CHECK (tableId IN (0, 1, 2, 3, 4, 5, 6)),
text TEXT NOT NULL,
timestamp INTEGER NOT NULL,
userSnowflake TEXT NOT NULL,
serverSnowflake TEXT NULL,
access INTEGER NOT NULL CHECK (access IN (0, 1, 2))
) STRICT;
CREATE UNIQUE INDEX IF NOT EXISTS responses_unique_global ON responses (tableId, text) WHERE access = 0;
CREATE UNIQUE INDEX IF NOT EXISTS responses_unique_by_server ON responses (serverSnowflake, tableId, text) WHERE access = 1;
CREATE UNIQUE INDEX IF NOT EXISTS responses_unique_by_dm ON responses (userSnowflake, tableId, text) WHERE access = 2;

File diff suppressed because it is too large Load Diff

67
package-lock.json generated

@ -7,6 +7,11 @@
"": {
"name": "ncc-gen",
"version": "0.0.0",
"dependencies": {
"discord-snowflake": "^2.0.0",
"jose": "^5.2.0",
"slash-create": "^6.0.2"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20231218.0",
"typescript": "^5.0.4",
@ -498,7 +503,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.0.tgz",
"integrity": "sha512-+KpH+QxZU7O4675t3mnkQKcZZg56u+K/Ct2K+N2AZYNVK8kyeo/bI18tI8aPm3tvNNRyTWfj6s5tnGNlcbQRsA==",
"dev": true,
"engines": {
"node": ">=14"
}
@ -685,6 +689,14 @@
}
}
},
"node_modules/discord-snowflake": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/discord-snowflake/-/discord-snowflake-2.0.0.tgz",
"integrity": "sha512-MFWier0d78ajvk1lAXUCm7wbNnY2bD7/rqvtemuhoebkBBS9AnA0OwQI7SpYqiy/01GR1raLPTsIOwWCj8Pb+w==",
"engines": {
"node": ">=18"
}
},
"node_modules/esbuild": {
"version": "0.17.19",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.19.tgz",
@ -740,6 +752,11 @@
"integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==",
"dev": true
},
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="
},
"node_modules/exit-hook": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz",
@ -848,6 +865,19 @@
"node": ">=0.12.0"
}
},
"node_modules/jose": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.2.0.tgz",
"integrity": "sha512-oW3PCnvyrcm1HMvGTzqjxxfnEs9EoFOFWi2HsEGhlFVOXxTE3K9GKWVMFoFw06yPUqwpvEWic1BmtUZBI/tIjw==",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="
},
"node_modules/magic-string": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
@ -1034,6 +1064,35 @@
"node": ">=10"
}
},
"node_modules/slash-create": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/slash-create/-/slash-create-6.0.2.tgz",
"integrity": "sha512-V2gqs7Ov3C8hjS2aODwzGUM/4WqpRiNXSPh69Bk3soyL7Kvkc/75HfwOgz9fTNNU+H/u5EMtvjR4hJgey0HKHA==",
"dependencies": {
"eventemitter3": "^5.0.1",
"lodash.isequal": "^4.5.0",
"tweetnacl": "^1.0.3",
"undici": "^5.28.2"
},
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/Snazzah"
},
"peerDependencies": {
"express": "^4",
"fastify": "^3 || ^4"
},
"peerDependenciesMeta": {
"express": {
"optional": true
},
"fastify": {
"optional": true
}
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@ -1088,6 +1147,11 @@
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
"dev": true
},
"node_modules/tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="
},
"node_modules/typescript": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz",
@ -1105,7 +1169,6 @@
"version": "5.28.2",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.28.2.tgz",
"integrity": "sha512-wh1pHJHnUeQV5Xa8/kyQhO7WFa8M34l026L5P/+2TYiakvGy5Rdc8jWZVyG7ieht/0WgJLEd3kcU5gKx+6GC8w==",
"dev": true,
"dependencies": {
"@fastify/busboy": "^2.0.0"
},

@ -1,6 +1,6 @@
{
"name": "ncc-gen",
"version": "0.0.0",
"name": "vore-scenario-gen",
"version": "1.0.0",
"private": true,
"scripts": {
"deploy": "wrangler deploy",
@ -11,5 +11,9 @@
"@cloudflare/workers-types": "^4.20231218.0",
"typescript": "^5.0.4",
"wrangler": "^3.0.0"
},
"dependencies": {
"discord-snowflake": "^2.0.0",
"slash-create": "^6.0.2"
}
}

@ -0,0 +1,376 @@
import { CommandContext, CommandOptionType, ComponentContext, SlashCommand, type SlashCreator } from 'slash-create/web';
import {
calculateUnlockedValues,
DELETE_ID,
DONE_ID,
generateErrorMessageFor,
generateFieldFor,
generateMessageFor,
generateValuesFor,
getEmbedFrom,
loadEmbed,
populateLocksFor,
REROLL_ID,
RollTableNames,
SELECT_ID,
selectUnlockedFrom
} from './generated.js';
import { type DbAccess, DeleteResult, UpdateResult } from './dbAccess.js';
import { isTable, RollTableOrder, ValueAccess } from './rolltable.js';
import { getTimestamp, isSnowflake, type Snowflake } from 'discord-snowflake';
export class ResponseCommand extends SlashCommand {
private readonly db: DbAccess
private readonly baseUrl: string;
constructor(creator: SlashCreator, db: DbAccess, 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: "add",
description: "Adds a new response to the generator.",
options: [
{
type: CommandOptionType.INTEGER,
name: "table",
description: "The table to insert the response into.",
choices: RollTableOrder.map(v => ({name: RollTableNames[v], value: v})),
required: true,
},
{
type: CommandOptionType.STRING,
name: "text",
description: "The text to use as the response.",
required: true,
}
]
},
{
type: CommandOptionType.SUB_COMMAND,
name: "list",
description: "Lists responses that will appear in /generate in the current context."
},
{
type: CommandOptionType.SUB_COMMAND,
name: "edit",
description: "Modifies a response that was previously created.",
options: [
{
type: CommandOptionType.INTEGER,
name: "table",
description: "The table to update the response from.",
choices: RollTableOrder.map(v => ({name: RollTableNames[v], value: v})),
required: true,
},
{
type: CommandOptionType.STRING,
name: "old_text",
description: "The text of the response to edit.",
required: true,
},
{
type: CommandOptionType.STRING,
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: [
{
type: CommandOptionType.INTEGER,
name: "table",
description: "The table to delete the response from.",
choices: RollTableOrder.map(v => ({name: RollTableNames[v], value: v})),
required: true,
},
{
type: CommandOptionType.STRING,
name: "text",
description: "The text of the response to delete.",
required: true,
},
]
},
]
});
this.baseUrl = baseUrl
this.db = db
}
async run(ctx: CommandContext): Promise<void> {
switch (ctx.subcommands[0]) {
case "add":
try {
await this.onAdd(ctx)
} catch (e) {
await ctx.send(generateErrorMessageFor(e, "add a new response"))
}
break
case "list":
try {
await this.onList(ctx)
} catch (e) {
await ctx.send(generateErrorMessageFor(e, "get the list URL"))
}
break
case "edit":
try {
await this.onEdit(ctx)
} catch (e) {
await ctx.send(generateErrorMessageFor(e, "edit a response"))
}
break
case "delete":
try {
await this.onDelete(ctx)
} catch (e) {
await ctx.send(generateErrorMessageFor(e, "delete a response"))
}
break
default:
await ctx.send(generateErrorMessageFor(Error("I don't know what command you want"), "manage responses"))
break
}
}
private async onAdd(ctx: CommandContext): Promise<void> {
const guildId = ctx.guildID ?? null
const userId = ctx.user.id
const id = ctx.interactionID
if (!isSnowflake(id)) {
throw Error("the snowflake wasn't a snowflake")
}
const timestamp = getTimestamp(id)
const table = ctx.options['add']['table']
if (!isTable(table)) {
throw Error(`there's no table number ${table}`)
}
const text = ctx.options['add']['text']
const { timestamp: insertedTimestamp, access, inserted } = await this.db.putResponse(timestamp, table, text, userId, guildId, guildId === null ? ValueAccess.CreatorDM : ValueAccess.Server)
await ctx.send({
embeds: [{
title: `${inserted ? 'Your new' : 'An existing'}${access === ValueAccess.Global ? " global" : ""} response`,
fields: [generateFieldFor(table, text)],
timestamp: new Date(insertedTimestamp),
}],
ephemeral: !inserted,
})
}
private async onList(ctx: CommandContext) {
if (ctx.guildID) {
await ctx.send({
embeds: [{
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: [{
title: `Response list for DMs`,
description: "It's not supported right now, so please just hang tight."
}]
})
}
}
private async onEdit(ctx: CommandContext): Promise<void> {
const guildId = ctx.guildID ?? null
const userId = ctx.user.id
const id = ctx.interactionID
if (!isSnowflake(id)) {
throw Error("the snowflake wasn't a snowflake")
}
const timestamp = getTimestamp(id)
const table = ctx.options['edit']['table']
if (!isTable(table)) {
throw Error(`there's no table number ${table}`)
}
const oldText = ctx.options['edit']['old_text']
const newText = ctx.options['edit']['new_text']
const result = await this.db.updateResponse(timestamp, table, oldText, newText, userId, guildId, guildId === null ? ValueAccess.CreatorDM : ValueAccess.Server)
switch (result.result) {
case UpdateResult.Updated:
await ctx.send({
embeds: [{
title: `Your updated response`,
fields: [generateFieldFor(table, oldText), generateFieldFor(table, newText)],
timestamp: new Date(timestamp).toISOString()
}],
})
break
case UpdateResult.NewConflict:
await ctx.send({
embeds: [{
title: `An existing${result.access === ValueAccess.Global ? " global" : ""} response`,
fields: [generateFieldFor(table, newText)],
timestamp: new Date(result.timestamp).toISOString(),
}],
ephemeral: true,
})
break
case UpdateResult.NoOldText:
await ctx.send({
embeds: [{
title: `A nonexistent response`,
fields: [generateFieldFor(table, oldText)],
}],
ephemeral: true,
})
break
case UpdateResult.OldGlobal:
await ctx.send({
embeds: [{
title: `An uneditable global response`,
fields: [generateFieldFor(table, oldText)],
}],
ephemeral: true,
})
break
}
}
private async onDelete(ctx: CommandContext): Promise<void> {
const guildId = ctx.guildID ?? null
const userId = ctx.user.id
const id = ctx.interactionID
if (!isSnowflake(id)) {
throw Error("the snowflake wasn't a snowflake")
}
const timestamp = getTimestamp(id)
const table = ctx.options['delete']['table']
if (!isTable(table)) {
throw Error(`there's no table number ${table}`)
}
const text = ctx.options['delete']['text']
const result = await this.db.deleteResponse(table, text, userId, guildId, guildId === null ? ValueAccess.CreatorDM : ValueAccess.Server)
switch (result.result) {
case DeleteResult.Deleted:
await ctx.send({
embeds: [{
title: 'Your deleted response',
fields: [generateFieldFor(table, text)],
timestamp: new Date(result.timestamp).toISOString(),
}]
})
break
case DeleteResult.OldGlobal:
await ctx.send({
embeds: [{
title: 'An undeletable global response',
fields: [generateFieldFor(table, text)],
timestamp: new Date(result.timestamp).toISOString(),
}],
ephemeral: true
})
break
case DeleteResult.NoOldText:
await ctx.send({
embeds: [{
title: 'A nonexistent response',
fields: [generateFieldFor(table, text)],
}],
ephemeral: true
})
break
}
}
}
export class GenerateCommand extends SlashCommand {
private readonly db: DbAccess
constructor(creator: SlashCreator, db: DbAccess, 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 tables = calculateUnlockedValues()
const responses = await (ctx.guildID
? this.db.getResponsesInServer(ctx.guildID)
: this.db.getResponsesInDMWith(ctx.user.id))
const values = generateValuesFor(tables, responses)
const locks = populateLocksFor(values)
await ctx.send(generateMessageFor(values, locks))
} catch (e) {
await ctx.send(generateErrorMessageFor(e, "generate a scenario for you"))
}
}
async onSelect(ctx: ComponentContext): Promise<void> {
try {
const oldEmbed = getEmbedFrom(ctx.message)
const {values, locked: oldLocks} = loadEmbed(oldEmbed)
const newLocks = selectUnlockedFrom(ctx.values, oldLocks)
await ctx.editParent(generateMessageFor(values, newLocks))
} catch (e) {
await ctx.send(generateErrorMessageFor(e, "change the selected components"))
}
}
async onDone(ctx: ComponentContext): Promise<void> {
try {
const oldEmbed = getEmbedFrom(ctx.message)
const { values } = loadEmbed(oldEmbed)
await ctx.editParent(generateMessageFor(values, undefined))
} catch (e) {
await ctx.send(generateErrorMessageFor(e, "finish this scenario"))
}
}
async onReroll(ctx: ComponentContext): Promise<void> {
try {
const oldEmbed = getEmbedFrom(ctx.message)
const { values: oldValues, locked: locks } = loadEmbed(oldEmbed)
const selected = calculateUnlockedValues(oldValues, locks)
const responses = await (ctx.guildID
? this.db.getResponsesInServer(ctx.guildID)
: this.db.getResponsesInDMWith(ctx.user.id))
const newValues = generateValuesFor(selected, responses, oldValues)
await ctx.editParent(generateMessageFor(newValues, locks))
} catch (e) {
await ctx.send(generateErrorMessageFor(e, "reroll this scenario"))
throw e
}
}
async onDelete(ctx: ComponentContext): Promise<void> {
try {
await ctx.delete(ctx.messageID)
} catch (e) {
await ctx.send(generateErrorMessageFor(e, "delete this scenario"))
}
}
}

@ -0,0 +1,180 @@
import { type RollableTables, RollTable, RollTableOrder, ValueAccess } from './rolltable.js';
interface DbResponse {
tableId: RollTable,
text: string,
}
export enum UpdateResult {
Updated = 0,
NewConflict = 1,
OldGlobal = 2,
NoOldText = 3,
}
export enum DeleteResult {
Deleted = 0,
OldGlobal = 2,
NoOldText = 3,
}
function buildRollableTables(responses: DbResponse[]): RollableTables {
const out: {[key in RollTable]?: string[]} = {}
for (const table of RollTableOrder) {
out[table] = []
}
for (const { tableId, text } of responses) {
out[tableId]?.push(text)
}
return out as RollableTables
}
export class DbAccess {
private readonly getResponsesInServerQuery: D1PreparedStatement
private readonly getResponsesInDMQuery: D1PreparedStatement;
private readonly putResponseQuery: D1PreparedStatement;
private readonly checkResponseAlreadyExistsQuery: D1PreparedStatement;
private readonly getResponsesGlobal: D1PreparedStatement;
private readonly updateResponseQuery: D1PreparedStatement;
private readonly deleteResponseQuery: D1PreparedStatement;
constructor(db: D1Database) {
this.getResponsesGlobal = db.prepare(
`SELECT DISTINCT tableId, text FROM responses
WHERE access = ${ValueAccess.Global}`)
this.getResponsesInServerQuery = db.prepare(
`SELECT DISTINCT tableId, text FROM responses
WHERE access = ${ValueAccess.Global}
OR (access = ${ValueAccess.Server} AND serverSnowflake = ?);`)
this.getResponsesInDMQuery = db.prepare(
`SELECT DISTINCT tableId, text FROM responses
WHERE access = ${ValueAccess.Global}
OR (access = ${ValueAccess.CreatorDM} AND userSnowflake = ?);`)
this.putResponseQuery = db.prepare(
`INSERT OR IGNORE INTO responses (id, tableId, text, timestamp, userSnowflake, serverSnowflake, access) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING timestamp, access;`)
this.updateResponseQuery = db.prepare(
`UPDATE responses SET text = ?3, timestamp = ?4, userSnowflake = ?5, serverSnowflake = ?6
WHERE tableId = ?1 AND text = ?2
AND ((?7 = ${ValueAccess.CreatorDM} AND access = ${ValueAccess.CreatorDM} AND userSnowflake = ?5)
OR (?7 = ${ValueAccess.Server} AND access = ${ValueAccess.Server} AND serverSnowflake = ?6))
RETURNING timestamp, access;`)
this.deleteResponseQuery = db.prepare(
`DELETE FROM responses
WHERE tableId = ?1 AND text = ?2
AND ((?5 = ${ValueAccess.CreatorDM} AND access = ${ValueAccess.CreatorDM} AND userSnowflake = ?3)
OR (?5 = ${ValueAccess.Server} AND access = ${ValueAccess.Server} AND serverSnowflake = ?4))
RETURNING timestamp, access;`)
this.checkResponseAlreadyExistsQuery = db.prepare(
`SELECT timestamp, access FROM responses
WHERE tableId = ?1 AND text = ?2
AND (access = ${ValueAccess.Global}
OR (?3 = ${ValueAccess.CreatorDM} AND access = ${ValueAccess.CreatorDM} AND userSnowflake = ?4)
OR (?3 = ${ValueAccess.Server} AND access = ${ValueAccess.Server} AND serverSnowflake = ?4));`)
}
async getGlobalResponses(): Promise<RollableTables> {
const {results} = await this.getResponsesGlobal.all<DbResponse>()
return buildRollableTables(results)
}
async getResponsesInServer(inServerSnowflake: string): Promise<RollableTables> {
const statement = this.getResponsesInServerQuery.bind(inServerSnowflake)
const {results} = await statement.all<DbResponse>()
return buildRollableTables(results)
}
async getResponsesInDMWith(withUserSnowflake: string): Promise<RollableTables> {
const statement = this.getResponsesInDMQuery.bind(withUserSnowflake)
const {results} = await statement.all<DbResponse>()
return buildRollableTables(results)
}
async putResponse(requestTimestamp: number, table: RollTable, text: string, fromUserSnowflake: string, inServerSnowflake: string|null, access?: ValueAccess): Promise<{
timestamp: number,
access: ValueAccess,
inserted: boolean
}> {
const effectiveAccess = access ?? (inServerSnowflake ? ValueAccess.Server : ValueAccess.CreatorDM)
const relevantSnowflake = access === ValueAccess.Server ? inServerSnowflake : access === ValueAccess.CreatorDM ? fromUserSnowflake : null
const existingResponseStatement = this.checkResponseAlreadyExistsQuery.bind(table, text, effectiveAccess, relevantSnowflake)
const existingResponse = await existingResponseStatement.first<{timestamp: number, access: ValueAccess}>()
if (existingResponse) {
return {
timestamp: existingResponse.timestamp,
access: existingResponse.access,
inserted: false,
}
}
const statement = this.putResponseQuery.bind(
requestTimestamp, table, text, requestTimestamp, fromUserSnowflake, inServerSnowflake, effectiveAccess)
const result = await statement.first<{timestamp: number, access: ValueAccess}>()
if (!result) {
throw Error("no response from insert")
}
return {
timestamp: result.timestamp,
access: result.access,
inserted: true
}
}
async updateResponse(timestamp: number, table: RollTable, oldText: string, newText: string, userId: string, guildId: string | null, access?: ValueAccess): Promise<{result: UpdateResult.NoOldText|UpdateResult.Updated} | {result: UpdateResult.NewConflict, timestamp: number, access: ValueAccess} | {result: UpdateResult.OldGlobal, timestamp: number, access: ValueAccess.Global}> {
const effectiveAccess = access ?? (guildId ? ValueAccess.Server : ValueAccess.CreatorDM)
const relevantSnowflake = access === ValueAccess.Server ? guildId : access === ValueAccess.CreatorDM ? userId : null
const existingOldResponseStatement = this.checkResponseAlreadyExistsQuery.bind(table, oldText, effectiveAccess, relevantSnowflake)
const existingOldResponse = await existingOldResponseStatement.first<{timestamp: number, access: ValueAccess}>()
if (!existingOldResponse) {
return {
result: UpdateResult.NoOldText
}
} else if (existingOldResponse.access === ValueAccess.Global) {
return {
timestamp: existingOldResponse.timestamp,
access: existingOldResponse.access,
result: UpdateResult.OldGlobal,
}
}
const existingNewResponseStatement = this.checkResponseAlreadyExistsQuery.bind(table, newText, effectiveAccess, relevantSnowflake)
const existingNewResponse = await existingNewResponseStatement.first<{timestamp: number, access: ValueAccess}>()
if (existingNewResponse) {
return {
result: UpdateResult.NewConflict,
timestamp: existingNewResponse.timestamp,
access: existingNewResponse.access,
}
}
const statement = this.updateResponseQuery.bind(
table, oldText, newText, timestamp, userId, guildId, effectiveAccess)
await statement.run()
return {result: UpdateResult.Updated}
}
async deleteResponse(table: RollTable, text: string, userId: string, guildId: string | null, access?: ValueAccess): Promise<
{result: DeleteResult.Deleted, timestamp: number, access: ValueAccess} |
{result: DeleteResult.NoOldText} |
{result: DeleteResult.OldGlobal, timestamp: number, access: ValueAccess.Global}
> {
const effectiveAccess = access ?? (guildId ? ValueAccess.Server : ValueAccess.CreatorDM)
const relevantSnowflake = access === ValueAccess.Server ? guildId : access === ValueAccess.CreatorDM ? userId : null
const existingOldResponseStatement = this.checkResponseAlreadyExistsQuery.bind(table, text, effectiveAccess, relevantSnowflake)
const existingOldResponse = await existingOldResponseStatement.first<{timestamp: number, access: ValueAccess}>()
if (!existingOldResponse) {
return {
result: DeleteResult.NoOldText
}
} else if (existingOldResponse.access === ValueAccess.Global) {
return {
timestamp: existingOldResponse.timestamp,
access: existingOldResponse.access,
result: DeleteResult.OldGlobal,
}
}
const statement = this.deleteResponseQuery.bind(
table, text, userId, guildId, effectiveAccess)
const deleted = await statement.first<{timestamp: number, access: ValueAccess}>()
if (!deleted) {
throw Error("no response from delete")
}
return {result: DeleteResult.Deleted, timestamp: deleted.timestamp, access: deleted.access}
}
}

@ -0,0 +1,233 @@
import { type RollableTables, rollOn, RollTable, RollTableOrder } from './rolltable.js';
import {
ButtonStyle,
type ComponentActionRow,
type ComponentButton,
type ComponentSelectMenu,
type ComponentSelectOption,
ComponentType,
EmbedField,
MessageEmbed, type MessageEmbedOptions, type MessageOptions
} from 'slash-create/web';
export type ComponentValues = { [key in RollTable]?: string }
export type ComponentLocks = { [Key in RollTable]?: boolean }
export interface GeneratedMessage {
values: ComponentValues
locked?: ComponentLocks
}
export const RollTableEmoji = {
[RollTable.Setting]: '\u{1f3d9}\ufe0f',
[RollTable.Theme]: '\u{1f4d4}',
[RollTable.Start]: '\u25b6\ufe0f',
[RollTable.Challenge]: '\u{1f613}',
[RollTable.Twist]: '\u{1f500}',
[RollTable.Focus]: '\u{1f444}',
[RollTable.Word]: '\u{2728}'
} as const satisfies {readonly [key in RollTable]: string}
export const RollTableEmbedTitles = {
[RollTable.Setting]: 'The action takes place...',
[RollTable.Theme]: 'The encounter is themed around...',
[RollTable.Start]: 'The action begins when...',
[RollTable.Challenge]: 'Things are more difficult because...',
[RollTable.Twist]: 'Partway through, unexpectedly...',
[RollTable.Focus]: 'The vore scene is focused on...',
[RollTable.Word]: 'The word of the day is...'
} as const satisfies {readonly [key in RollTable]: string}
export const RollTableNames = {
[RollTable.Setting]: 'Setting',
[RollTable.Theme]: 'Theme',
[RollTable.Start]: 'Inciting Incident',
[RollTable.Challenge]: 'Challenge',
[RollTable.Twist]: 'Twist',
[RollTable.Focus]: 'Vore Scene Focus',
[RollTable.Word]: 'Word of the Day'
} as const satisfies {readonly [key in RollTable]: string}
export const RollTableEmbedsReversed = {
"\u{1f3d9}\ufe0f The action takes place...": RollTable.Setting,
"\u{1f4d4} The encounter is themed around...": RollTable.Theme,
"\u25b6\ufe0f The action begins when...": RollTable.Start,
"\u{1f613} Things are more difficult because...": RollTable.Challenge,
"\u{1f500} Partway through, unexpectedly...": RollTable.Twist,
"\u{1f444} The vore scene is focused on...": RollTable.Focus,
"\u{2728} The word of the day is...": RollTable.Word,
} as const satisfies {readonly [key in RollTable as `${typeof RollTableEmoji[key]} ${typeof RollTableEmbedTitles[key]}`]: key} & {[other: string]: RollTable}
export function calculateUnlockedValues(original?: ComponentValues|undefined, locks?: ComponentLocks|undefined): RollTable[] {
if (!original && !locks) {
return RollTableOrder
}
const existingItems = original ? RollTableOrder.filter(v => typeof original[v] !== "undefined") : RollTableOrder
return locks ? existingItems.filter(v => locks[v] !== true) : existingItems
}
export function generateValuesFor(selected: readonly RollTable[], tables: RollableTables, original: ComponentValues = {}): ComponentValues {
const result: ComponentValues = Object.assign({}, original)
for (const table of selected) {
result[table] = rollOn(table, tables)
}
return result
}
export const LOCK_SUFFIX = " \u{1f512}"
export const UNLOCK_SUFFIX = " \u{1f513}"
export function generateFieldFor(field: RollTable, value: string, lock: boolean|null = null) {
return {
name: RollTableEmoji[field] + " " + RollTableEmbedTitles[field] + (lock !== null ? (lock ? LOCK_SUFFIX : UNLOCK_SUFFIX) : ""),
value,
}
}
export function generateEmbedFor(values: ComponentValues, locks: ComponentLocks|undefined): MessageEmbedOptions {
const fields: EmbedField[] = []
const usableLocks = locks ?? {}
for (const field of RollTableOrder) {
const value = values[field]
if (value) {
fields.push(generateFieldFor(field, value, usableLocks.hasOwnProperty(field) ? usableLocks[field] : null))
}
}
return {
title: 'Your generated scenario',
fields,
timestamp: new Date().toISOString()
}
}
export function getEmbedFrom({embeds}: {embeds?: MessageEmbed[]|undefined}): MessageEmbed {
const result = embeds && embeds.length >= 1 ? embeds[0] : null
if (!result) {
throw Error("there were no embeds on the message to read")
}
return result
}
export function loadEmbed(embed: MessageEmbed): GeneratedMessage {
const result: {values: ComponentValues, locked: ComponentLocks} = {
values: {},
locked: {},
}
if (!embed.fields || embed.fields.length === 0) {
throw Error("there were no fields on the embed to read")
}
for (const field of embed.fields!) {
let locked: boolean|undefined,
name = field.name
if (name.endsWith(LOCK_SUFFIX)) {
locked = true
name = name.substring(0, name.length - LOCK_SUFFIX.length)
} else if (name.endsWith(UNLOCK_SUFFIX)) {
locked = false
name = name.substring(0, name.length - UNLOCK_SUFFIX.length)
} else {
throw Error(`there was no lock or unlock suffix on ${name}`)
}
const value = field.value
if (RollTableEmbedsReversed.hasOwnProperty(name)) {
const table = RollTableEmbedsReversed[name as keyof typeof RollTableEmbedsReversed]
if (typeof locked !== "undefined") {
result.locked[table] = locked
}
result.values[table] = value
} else {
throw Error(`I don't know a field named ${name}`)
}
}
return result
}
export function populateLocksFor(values: ComponentValues, original?: ComponentLocks|undefined): ComponentLocks {
const result = Object.assign({}, original)
for (const table of RollTableOrder) {
if (typeof values[table] !== "undefined") {
result[table] = result[table] ?? true
}
}
return result
}
export function selectUnlockedFrom(values: string[], oldLocks?: ComponentLocks | undefined): ComponentLocks {
const result = Object.assign({}, oldLocks ?? {})
for (const table of RollTableOrder) {
if (result.hasOwnProperty(table)) {
result[table] = !values.includes(`${table}`)
}
}
return result
}
export const SELECT_ID = "selected"
export const REROLL_ID = "reroll"
export const DONE_ID = "done"
export const DELETE_ID = "delete"
export function generateActionsFor(values: ComponentValues, locks: ComponentLocks|undefined): ComponentActionRow[] {
if (!locks) {
return []
}
const items = RollTableOrder.filter((v) => values.hasOwnProperty(v))
const lockedItems = items.filter((v) => locks[v] === true)
const selectOptions: ComponentSelectOption[] = items.map((v) => ({
default: !(locks[v] ?? false),
value: `${v}`,
label: RollTableNames[v],
emoji: {name: RollTableEmoji[v]}
}))
if (selectOptions.length === 0) {
return []
}
const select: ComponentSelectMenu = {
type: ComponentType.STRING_SELECT,
custom_id: SELECT_ID,
disabled: false,
max_values: selectOptions.length,
min_values: 0,
options: selectOptions,
placeholder: 'Components to reroll'
}
const selectRow: ComponentActionRow = { type: ComponentType.ACTION_ROW, components: [ select ] }
const rerollButton: ComponentButton = {
type: ComponentType.BUTTON,
custom_id: REROLL_ID,
disabled: lockedItems.length === items.length,
emoji: {name: '\u{1f3b2}'},
label: (lockedItems.length === 0 ? "Reroll ALL" : "Reroll Selected"),
style: ButtonStyle.PRIMARY
}
const doneButton: ComponentButton = {
type: ComponentType.BUTTON,
custom_id: DONE_ID,
disabled: false,
emoji: { name: '\u{1f44d}' },
label: 'Looks good!',
style: ButtonStyle.SUCCESS,
}
const deleteButton: ComponentButton = {
type: ComponentType.BUTTON,
custom_id: DELETE_ID,
disabled: false,
emoji: { name: '\u{1f5d1}\ufe0f' },
label: 'Trash it.',
style: ButtonStyle.DESTRUCTIVE,
}
const buttonRow: ComponentActionRow = { type: ComponentType.ACTION_ROW, components: [rerollButton, doneButton, deleteButton] }
return [selectRow, buttonRow]
}
export function generateMessageFor(values: ComponentValues, locks: ComponentLocks|undefined): MessageOptions {
return { embeds: [generateEmbedFor(values, locks)], components: generateActionsFor(values, locks), ephemeral: false }
}
export function generateErrorMessageFor(e: unknown, context?: string): MessageOptions {
console.error(`Error when trying to ${context ?? "do something (unknown context)"}`, e)
return {
content: `I wasn't able to ${context ?? "do that"}. Thing is, ${e}...`,
ephemeral: true,
}
}

@ -8,25 +8,121 @@
* Learn more at https://developers.cloudflare.com/workers/
*/
import { CloudflareWorkerServer, SlashCreator } from 'slash-create/web';
import { GenerateCommand, ResponseCommand } from './commands.js';
import { DbAccess } from './dbAccess.js';
import { isSnowflake, type Snowflake } from 'discord-snowflake';
import { RollTableOrder } from './rolltable.js';
import { RollTableEmbedTitles, RollTableEmoji, RollTableNames } from './generated.js';
export interface Env {
// Example binding to KV. Learn more at https://developers.cloudflare.com/workers/runtime-apis/kv/
// MY_KV_NAMESPACE: KVNamespace;
//
// Example binding to Durable Object. Learn more at https://developers.cloudflare.com/workers/runtime-apis/durable-objects/
// MY_DURABLE_OBJECT: DurableObjectNamespace;
//
// Example binding to R2. Learn more at https://developers.cloudflare.com/workers/runtime-apis/r2/
// MY_BUCKET: R2Bucket;
//
// Example binding to a Service. Learn more at https://developers.cloudflare.com/workers/runtime-apis/service-bindings/
// MY_SERVICE: Fetcher;
//
// Example binding to a Queue. Learn more at https://developers.cloudflare.com/queues/javascript-apis/
// MY_QUEUE: Queue;
BASE_URL: string;
DISCORD_APP_ID: string
DISCORD_APP_SECRET: string
DISCORD_PUBLIC_KEY: string
DISCORD_DEV_GUILD_IDS: string
DB: D1Database
}
function getHandler(env: Env, token?: string) {
const dbAccess = new DbAccess(env.DB)
const server = new CloudflareWorkerServer()
const creator = new SlashCreator({
allowedMentions: {everyone: false, roles: false, users: false},
applicationID: env.DISCORD_APP_ID,
componentTimeouts: true,
defaultImageSize: 0,
disableTimeouts: false,
endpointPath: '/discord/interactions',
handleCommandsManually: false,
publicKey: env.DISCORD_PUBLIC_KEY,
unknownCommandResponse: true,
token: token,
})
const withGuilds: Snowflake[] = env.DISCORD_DEV_GUILD_IDS ? env.DISCORD_DEV_GUILD_IDS.split(",").flatMap(v => isSnowflake(v) ? [v] : []) : []
creator.withServer(server)
creator.registerCommand(new GenerateCommand(creator, dbAccess))
creator.registerCommand(new ResponseCommand(creator, dbAccess, env.BASE_URL))
creator.registerCommand(new GenerateCommand(creator, dbAccess, withGuilds))
creator.registerCommand(new ResponseCommand(creator, dbAccess, env.BASE_URL, withGuilds))
return {
fetch: server.fetch.bind(server),
syncCommands: creator.syncCommands.bind(creator),
db: dbAccess,
}
}
function getAuthorization(username: string, password: string): string {
return btoa(username + ":" + password)
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
return new Response('Hello World!');
const tokenRequest = new Request(`https://discord.com/api/v10/oauth2/token`, {
headers: new Headers({
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": `Basic ${getAuthorization(env.DISCORD_APP_ID, env.DISCORD_APP_SECRET)}`,
}),
body: new URLSearchParams({"grant_type": "client_credentials", "scope": "applications.commands.update"}),
method: "POST"
})
const tokenResponse = await fetch(tokenRequest)
if (tokenResponse.status !== 200) {
const text = await tokenResponse.text()
console.error(`Failed getting token`, text)
return new Response(`Could not sync commands: Failed getting token: ${tokenResponse.status} ${tokenResponse.statusText}\n${text}`, {status: 500})
}
const json = await tokenResponse.json() as {access_token: string}
const handler = getHandler(env, "Bearer " + json.access_token)
const url = new URL(request.url)
if (url.pathname === "/discord/interactions") {
try {
return handler.fetch(request, env, ctx)
} catch (e) {
console.error("Failed to respond to interactions endpoint", e);
return new Response(`Could not respond to interaction: ${e}`, {
status: 500
})
}
} else if (url.pathname === "/discord/sync") {
try {
await handler.syncCommands({
deleteCommands: true,
syncGuilds: true,
})
} catch (e) {
console.error("Failed to respond to sync endpoint", e)
return new Response(`Could not sync commands: ${e}`, {
status: 500,
})
}
return new Response(`Synced commands!`, {
status: 200,
})
} else if (url.pathname === "/responses") {
try {
const response = []
const server = url.searchParams.get("server")
const tables = await (server === null
? handler.db.getGlobalResponses()
: handler.db.getResponsesInServer(server))
for (const table of RollTableOrder) {
response.push(`${RollTableNames[table]} - ${RollTableEmoji[table]} ${RollTableEmbedTitles[table]}`)
for (const value of tables[table]) {
response.push(` * ${value}`)
}
response.push('')
}
return new Response(response.join('\n'), {status: 200})
} catch (e) {
console.error("Failed to respond to list endpoint", e)
return new Response(`Could not list responses: ${e}`, {
status: 500,
})
}
} else {
return new Response(`Invalid path ${url.pathname}`, {status: 404})
}
},
};

@ -0,0 +1,43 @@
export enum RollTable {
Setting = 0,
Theme = 1,
Start = 2,
Challenge = 3,
Twist = 4,
Focus = 5,
Word = 6,
}
export enum ValueAccess {
Global = 0,
Server = 1,
CreatorDM = 2,
}
export const RollTableOrder =
[RollTable.Setting, RollTable.Theme, RollTable.Start, RollTable.Challenge, RollTable.Twist, RollTable.Focus, RollTable.Word] as const satisfies RollTable[]
export const RollTableOrdinals =
{
[RollTable.Setting]: 0,
[RollTable.Theme]: 1,
[RollTable.Start]: 2,
[RollTable.Challenge]: 3,
[RollTable.Twist]: 4,
[RollTable.Focus]: 5,
[RollTable.Word]: 6,
} as const satisfies {[key in RollTable]: number} & {[key in Extract<keyof typeof RollTableOrder, number> as typeof RollTableOrder[key]]: key}
export type RollableTables = {readonly [key in RollTable]: readonly string[]}
export function isTable(val: number): val is RollTable {
return RollTableOrdinals.hasOwnProperty(val)
}
export function rollOn(table: RollTable, tables: RollableTables): string {
const values = tables[table]
if (values.length === 0) {
throw Error(`no possible options for table ${table}`)
}
return values[Math.floor(values.length * Math.random())]
}

@ -1,51 +1,17 @@
name = "ncc-gen"
name = "vore-scenario-generator"
main = "src/index.ts"
compatibility_date = "2023-12-18"
# Variable bindings. These are arbitrary, plaintext strings (similar to environment variables)
# Note: Use secrets to store sensitive data.
# Docs: https://developers.cloudflare.com/workers/platform/environment-variables
# [vars]
# MY_VARIABLE = "production_value"
# Bind a KV Namespace. Use KV as persistent storage for small key-value pairs.
# Docs: https://developers.cloudflare.com/workers/runtime-apis/kv
# [[kv_namespaces]]
# binding = "MY_KV_NAMESPACE"
# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# Bind an R2 Bucket. Use R2 to store arbitrarily large blobs of data, such as files.
# Docs: https://developers.cloudflare.com/r2/api/workers/workers-api-usage/
# [[r2_buckets]]
# binding = "MY_BUCKET"
# bucket_name = "my-bucket"
# Bind a Queue producer. Use this binding to schedule an arbitrary task that may be processed later by a Queue consumer.
# Docs: https://developers.cloudflare.com/queues/get-started
# [[queues.producers]]
# binding = "MY_QUEUE"
# queue = "my-queue"
# Bind a Queue consumer. Queue Consumers can retrieve tasks scheduled by Producers to act on them.
# Docs: https://developers.cloudflare.com/queues/get-started
# [[queues.consumers]]
# queue = "my-queue"
# Bind another Worker service. Use this binding to call another Worker without network overhead.
# Docs: https://developers.cloudflare.com/workers/platform/services
# [[services]]
# binding = "MY_SERVICE"
# service = "my-service"
# Bind a Durable Object. Durable objects are a scale-to-zero compute primitive based on the actor model.
# Durable Objects can live for as long as needed. Use these when you need a long-running "server", such as in realtime apps.
# Docs: https://developers.cloudflare.com/workers/runtime-apis/durable-objects
# [[durable_objects.bindings]]
# name = "MY_DURABLE_OBJECT"
# class_name = "MyDurableObject"
# Durable Object migrations.
# Docs: https://developers.cloudflare.com/workers/learning/using-durable-objects#configure-durable-object-classes-with-migrations
# [[migrations]]
# tag = "v1"
# new_classes = ["MyDurableObject"]
[vars]
# BASE_URL
# DISCORD_APP_ID
# DISCORD_APP_SECRET
# DISCORD_PUBLIC_KEY
# DISCORD_DEV_GUILD_IDS
[[d1_databases]]
binding = "DB" # i.e. available in your Worker on env.DB
database_name = "production-ncc-gen"
database_id = "d09d3c74-c75f-4418-8f1b-2fe7f21637e6"
migrations_table = "d1_migrations"
migrations_dir = "migrations"

Loading…
Cancel
Save