diff --git a/migrations/20231113125847_counterConfigurations.js b/migrations/20231113125847_counterConfigurations.js new file mode 100644 index 0000000..e9addce --- /dev/null +++ b/migrations/20231113125847_counterConfigurations.js @@ -0,0 +1,22 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function(knex) { + return knex.schema + .createTable('counterConfigurations', table => { + table.string('counter').references('key').inTable('counters').onDelete('CASCADE'); + table.string('guild').notNullable(); + table.string('configName').notNullable(); + table.string('value').nullable(); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function(knex) { + return knex.schema + .dropTable('counterConfigurations'); +}; diff --git a/src/commands/counter.ts b/src/commands/counter.ts index ac711e4..2033136 100644 --- a/src/commands/counter.ts +++ b/src/commands/counter.ts @@ -1,6 +1,6 @@ -import { Interaction, SlashCommandBuilder } from 'discord.js'; +import { AutocompleteInteraction, Interaction, SlashCommandBuilder } from 'discord.js'; import { Counter, CounterUserLink, db } from '../lib/db'; -import { counterAutocomplete, getCounterData, updateCounter } from '../lib/counter'; +import { counterAutocomplete, counterConfigs, getCounterConfigRaw, getCounterData, getOptions, parseConfig, setCounterConfig, toStringConfig, updateCounter } from '../lib/counter'; function extendOption(t: string) { return {name: t, value: t}; @@ -130,6 +130,7 @@ module.exports = { .setName('emoji') .setDescription('An emoji or symbol or something to represent the counter') .setRequired(true) + .setMaxLength(100) ) .addNumberOption(option => option @@ -137,6 +138,33 @@ module.exports = { .setDescription('Initial value to start with') ) ) + .addSubcommand(sub => + sub + .setName('set') + .setDescription('[ADMIN] Configure a counter') + .addStringOption(opt => + opt + .setName('type') + .setDescription('The counter to operate on') + .setRequired(true) + .setAutocomplete(true) + ) + .addStringOption(opt => + opt + .setName('key') + .setDescription('The config name') + .setRequired(true) + .setChoices(...[...counterConfigs.keys()].map(extendOption)) + ) + .addStringOption(opt => + opt + .setName('value') + .setDescription('The new value') + .setRequired(true) + .setAutocomplete(true) + .setMaxLength(100) + ) + ) .addSubcommand(sub => sub .setName('delete') @@ -280,6 +308,32 @@ module.exports = { await interaction.followUp({ content: `<#${channel.id}> has been **enriched** with your new counter. Congratulations!` }); + } else if (subcommand === 'set') { + const type = interaction.options.getString('type')!; + + let counter; + try { + counter = await getCounterData(type); + } catch(err) { + await interaction.followUp({ + content: 'No such counter!' + }); + return; + } + + const config = await getCounterConfigRaw(interaction.options.getString('type') || '', counter); + const key = interaction.options.getString('key', true); + const value = interaction.options.getString('value', true); + + const defaultConfig = counterConfigs.get(key); + if (!defaultConfig) return interaction.followUp(`No config named \`${key}\` exists!`); + + const parsedValue = parseConfig(value, defaultConfig.type); + const restringedValue = toStringConfig(parsedValue, defaultConfig.type); + + await setCounterConfig(type, key, restringedValue); + + await interaction.followUp(`${counter.emoji} \`${key}\` is now \`${restringedValue}\`. (was \`${config.get(key) || toStringConfig(defaultConfig.default, defaultConfig.type)}\`)`); } else if (subcommand === 'delete') { const type = interaction.options.getString('type')!; @@ -308,5 +362,44 @@ module.exports = { } }, - autocomplete: counterAutocomplete + autocomplete: async (interaction: AutocompleteInteraction) => {{ + const focused = interaction.options.getFocused(true); + + if (focused.name === 'type') { + return counterAutocomplete(interaction); + } else if (focused.name === 'value') { + const type = interaction.options.getString('type', true); + const counter = await getCounterData(type); + + const config = await getCounterConfigRaw(type, counter); + const key = interaction.options.getString('key'); + + if (!key) return interaction.respond([]); + + const defaultConfig = counterConfigs.get(key); + if (!defaultConfig) return interaction.respond([]); + + const defaultOptions = getOptions(defaultConfig.type); + + let options = [ + { + value: `${config.get(key) || toStringConfig(defaultConfig.default, defaultConfig.type)}`, + name: `[CURRENT] ${config.get(key) || toStringConfig(defaultConfig.default, defaultConfig.type)}`.slice(0, 99) + }, + ...defaultOptions.filter(s => s.startsWith(focused.value)).map(extendOption) + ]; + + if (focused.value !== '' && !options.find(opt => opt.value === focused.value)) { + options = [ + { + value: focused.value, + name: focused.value + }, + ...options + ]; + } + + await interaction.respond(options); + } + }} }; \ No newline at end of file diff --git a/src/lib/counter.ts b/src/lib/counter.ts index c8ff077..09c05bc 100644 --- a/src/lib/counter.ts +++ b/src/lib/counter.ts @@ -1,6 +1,6 @@ import { Client, CommandInteraction, GuildMember, EmbedBuilder, TextChannel, AutocompleteInteraction } from 'discord.js'; import { getSign } from './util'; -import { Counter, CounterUserLink, db } from './db'; +import { Counter, CounterConfiguration, CounterUserLink, db } from './db'; export async function getCounter(type: string) { const counter = await db('counters') @@ -36,6 +36,138 @@ export async function getCounterData(type: string) { return counter; } +export async function getCounterConfigRaw(type: string, counter: Counter) { + const configs = await db('counterConfigurations') + .select('configName', 'value') + .where('counter', type); + + const config = new Map(); + configs.forEach(({ configName, value }) => { + config.set(configName, value); + }); + + // just the ugly way of life + config.set('emoji', counter.emoji); + config.set('messageTemplate', counter.messageTemplate || (counterConfigs.get('messageTemplate')!.default! as string)); // wow! this line is truly awful + + return config; +} + +export async function getCounterConfig(type: string, key: string) { + const config = await db('counterConfigurations') + .select('value') + .where('counter', type) + .first(); + + const valueStr = config?.value; + let value; + if (valueStr) { + value = parseConfig(valueStr, counterConfigs.get(key)!.type); + } else { + value = counterConfigs.get(key)!.default; + } + + return value; +} + +export async function setCounterConfig(type: string, option: string, value: string) { + // just the ugly way of life + if (option === 'emoji') { + await db('counters') + .update({ + 'emoji': value + }); + return; + } + if (option === 'messageTemplate') { + await db('counters') + .update({ + 'messageTemplate': value + }); + return; + } + + const updated = await db('counterConfigurations') + .update({ + value: value + }) + .where('counter', type) + .where('configName', option); + + if (updated === 0) { + await db('counterConfigurations') + .insert({ + 'counter': type, + 'guild': '0', //TODO + 'configName': option, + 'value': value + }); + } +} + +export enum ConfigType { + Bool, + String, + Number +} + +export function parseConfig(str: string, type: ConfigType.Bool): boolean +export function parseConfig(str: string, type: ConfigType.String): string +export function parseConfig(str: string, type: ConfigType.Number): number +export function parseConfig(str: string, type: ConfigType): boolean | string | number +export function parseConfig(str: string, type: ConfigType) { + switch(type) { + case ConfigType.Bool: + return str === 'true'; + case ConfigType.String: + return str.trim(); + case ConfigType.Number: { + const n = parseInt(str); + if (isNaN(n)) throw 'Not a valid number'; + return n; + } + } +} + +export function getOptions(type: ConfigType): string[] { + switch(type) { + case ConfigType.Bool: + return ['true', 'false']; + case ConfigType.String: + return []; + case ConfigType.Number: + return []; + } +} + +export function toStringConfig(value: boolean | string | number, type: ConfigType): string { + switch(type) { + case ConfigType.Bool: + return value ? 'true' : 'false'; + case ConfigType.String: + return (value as string); + case ConfigType.Number: + return (value as number).toString(); + } +} + +export const counterConfigs = new Map([ + ['anonymous', { + type: ConfigType.Bool, + default: false + }], + + // these ones are fake and are just stand-ins for values defined inside the actual counters table + ['emoji', { + type: ConfigType.String, + default: '' + }], + ['messageTemplate', { + type: ConfigType.String, + default: '**%user** has %action the counter by **%amt**.' + }] +]); + export async function updateCounter(bot: Client, counter: Counter, value: number) { const channel = await bot.channels.fetch(counter.channel) as TextChannel; const messageID = counter.message; @@ -67,18 +199,31 @@ export async function updateCounter(bot: Client, counter: Counter, value: number export async function announceCounterUpdate(bot: Client, member: GuildMember, delta: number, counter: Counter, value: number) { const channel = await bot.channels.fetch(counter.channel) as TextChannel; + const template = counter.messageTemplate || counterConfigs.get('messageTemplate')!.default as string; + const anonymous = await getCounterConfig(counter.key, 'anonymous'); + const embed = new EmbedBuilder() - .setAuthor({ - name: `${member.user.username}#${member.user.discriminator}`, - iconURL: member.user.displayAvatarURL() - }) - .setDescription(`**${member.toString()}** has ${delta > 0 ? 'increased' : 'decreased'} the counter by **${Math.abs(delta)}**.`) - .setColor(member.displayColor) + //.setDescription(`**${member.toString()}** has ${delta > 0 ? 'increased' : 'decreased'} the counter by **${Math.abs(delta)}**.`) + .setDescription( + template + .replaceAll('%user', anonymous ? 'someone' : member.toString()) + .replaceAll('%action', delta > 0 ? 'increased' : 'decreased') + .replaceAll('%amt', Math.abs(delta).toString()) + ) .setTimestamp() .setFooter({ text: `[${counter.emoji}] x${value}` }); + if (!anonymous) { + embed + .setAuthor({ + name: `${member.user.username}#${member.user.discriminator}`, + iconURL: member.user.displayAvatarURL() + }) + .setColor(member.displayColor); + } + await channel.send({ embeds: [embed] }); diff --git a/src/lib/db.ts b/src/lib/db.ts index 634990a..a3f0311 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -39,4 +39,10 @@ export interface CounterUserLink { key: string, user: string, producer: boolean +} +export interface CounterConfiguration { + counter: string, + guild: string, + configName: string, + value: string } \ No newline at end of file