diff --git a/package-lock.json b/package-lock.json index a2f072d..65045b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,12 +23,14 @@ "openai": "^4.77.0", "opusscript": "^0.1.1", "redis": "^4.7.0", + "string-similarity": "^4.0.4", "typescript": "^5.7.2", "wav": "^1.0.2" }, "devDependencies": { "@types/moment-duration-format": "^2.2.6", "@types/node": "^22.10.2", + "@types/string-similarity": "^4.0.2", "@types/wav": "^1.0.4" } }, @@ -851,6 +853,13 @@ "integrity": "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==", "license": "MIT" }, + "node_modules/@types/string-similarity": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/string-similarity/-/string-similarity-4.0.2.tgz", + "integrity": "sha512-LkJQ/jsXtCVMK+sKYAmX/8zEq+/46f1PTQw7YtmQwb74jemS1SlNLmARM2Zml9DgdDTWKAtc5L13WorpHPDjDA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/tedious": { "version": "4.0.14", "resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz", @@ -1686,6 +1695,13 @@ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==" }, + "node_modules/string-similarity": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/string-similarity/-/string-similarity-4.0.4.tgz", + "integrity": "sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "ISC" + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", diff --git a/package.json b/package.json index 8b046fb..1c2c996 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "openai": "^4.77.0", "opusscript": "^0.1.1", "redis": "^4.7.0", + "string-similarity": "^4.0.4", "typescript": "^5.7.2", "wav": "^1.0.2" }, @@ -31,6 +32,7 @@ "devDependencies": { "@types/moment-duration-format": "^2.2.6", "@types/node": "^22.10.2", + "@types/string-similarity": "^4.0.2", "@types/wav": "^1.0.4" }, "scripts": { diff --git a/src/commands/prefixed/fun/typespeed.ts b/src/commands/prefixed/fun/typespeed.ts new file mode 100644 index 0000000..c93a58f --- /dev/null +++ b/src/commands/prefixed/fun/typespeed.ts @@ -0,0 +1,56 @@ +import { Command, CommandClient } from 'detritus-client'; +import { Permissions } from 'detritus-client/lib/constants'; + +import { CommandCategories } from '../../../constants'; +import { editOrReply, Formatter } from '../../../utils'; + +import { BaseCommand } from '../basecommand'; + + +export const COMMAND_NAME = 'typespeed'; + + +export default class TypeSpeed extends BaseCommand { + constructor (client: CommandClient) { + super(client, { + name: COMMAND_NAME, + metadata: { + category: CommandCategories.FUN, + description: 'See who can type the fastest and accurately in 60 seconds', + examples: [ + COMMAND_NAME, + `${COMMAND_NAME} -dates`, + `${COMMAND_NAME} -words`, + ], + id: Formatter.Commands.FunTypeSpeed.COMMAND_ID, + usage: '(-dates) (-words)', + aliases: ['speedtype', 'typerace'], + permissionsClient: [Permissions.READ_MESSAGE_HISTORY], + args: [ + { name: 'dates', aliases: ['t'], type: Boolean }, + { name: 'words', aliases: ['w'], type: Boolean } + ] + }, + }); + } + + onBeforeRun(context: Command.Context, args: Formatter.Commands.FunTypeSpeed.CommandArgs) { + if (args.dates && args.words) { + return false; + } + + return true; + } + + onCancelRun(context: Command.Context, args: Formatter.Commands.FunTypeSpeed.CommandArgs) { + if (args.dates && args.words) { + return editOrReply(context, '⚠ Cannot mix in both words and dates'); + } + + return super.onCancelRun(context, args) + } + + async run(context: Command.Context, args: Formatter.Commands.FunTypeSpeed.CommandArgs) { + return Formatter.Commands.FunTypeSpeed.createMessage(context, args); + } +} \ No newline at end of file diff --git a/src/stores/serverexecutions.ts b/src/stores/serverexecutions.ts index f71c92c..9301c20 100644 --- a/src/stores/serverexecutions.ts +++ b/src/stores/serverexecutions.ts @@ -4,7 +4,12 @@ import { EventSubscription } from 'detritus-utils'; import { Store } from './store'; -export type ServerExecutionsStored = {nick: boolean, prune: boolean, wordcloud: boolean}; +export type ServerExecutionsStored = { + nick: boolean, + prune: boolean, + wordcloud: boolean, + typespeed: boolean, +}; // Stores a server's command execution, for nick mass/pruning/wordcloud class ServerExecutionsStore extends Store { @@ -17,7 +22,7 @@ class ServerExecutionsStore extends Store { if (this.has(key)) { value = this.get(key) as ServerExecutionsStored; } else { - value = {nick: false, prune: false, wordcloud: false}; + value = {nick: false, prune: false, wordcloud: false, typespeed: false}; this.insert(key, value); } return value; diff --git a/src/utils/formatter/commands/fun.typespeed.ts b/src/utils/formatter/commands/fun.typespeed.ts new file mode 100644 index 0000000..84cd0ac --- /dev/null +++ b/src/utils/formatter/commands/fun.typespeed.ts @@ -0,0 +1,147 @@ +import { Command } from 'detritus-client'; +import { Timers } from 'detritus-utils'; + +import { compareTwoStrings } from 'string-similarity'; +import { randomInt } from 'mathjs'; + +import { utilitiesImagescriptV1 } from '../../../api'; +import { editOrReply, randomMultipleFromArray } from '../..'; +import { BooleanEmojis } from '../../../constants'; +import ServerExecutionsStore from '../../../stores/serverexecutions'; + + +export const COMMAND_ID = 'fun.typespeed'; + + +export interface CommandArgs { + dates?: boolean, + words?: boolean, +} + + +interface Winner { + accuracy: string, + time: string, + wpm: string, +} + + +export async function createMessage( + context: Command.Context, + args: CommandArgs, +) { + const store = ServerExecutionsStore.getOrCreate(context.channelId); + if (store.typespeed) { + return editOrReply(context, 'This channel has an ongoing race, wait for it to finish'); + } + + store.typespeed = true; + + let text: string; + if (args.dates) { + text = dates(); + } else if (args.words) { + // cakedan pls upload the stuff below to the cdn + const response = await fetch( + 'https://raw.githubusercontent.com/RazorSh4rk/random-word-api/refs/heads/master/words.json' + ); + text = (randomMultipleFromArray(await response.json(), 20)).join(' ').toLowerCase(); + } else { + // maybe put this in the api? + const response = await fetch('https://dummyjson.com/quotes/random'); + text = (await response.json()).quote; + } + + // didn't see a text-to-image endpoint anywhere, + // so i'm using mscript instead + const code: string = ` + create bg 1024 512 0 0 0 + text text 50 1000 #FFFFFF ${text} + contain text 1024 512 + if texth > 512 resize bg bgh 768 + overlay bg text + render bg + `; + + const image = await utilitiesImagescriptV1(context, { code }); + const filename: string = image.file.filename; + let data: Buffer | string = ( + (image.file.value) + ? Buffer.from(image.file.value, 'base64') + : Buffer.alloc(0) + ); + + const initial = await editOrReply(context, 'Race will begin in 5 seconds...'); + await Timers.sleep(5000); + await initial.edit({ + file: { filename: filename, value: data }, + content: `Type the text below as fast and accurately as you can`, + allowedMentions: { repliedUser: false } + }); + + const start = Date.now(); + const winners: Record = {}; + + const sub = context.client.subscribe('messageCreate', async (msg) => { + const message = msg.message; + + if (message.author.bot) return; + if (message.author.id in winners) return; + if (message.channelId !== context.channelId) return; + + const time: number = (Date.now() - start) / 1000; + const accuracy: number = compareTwoStrings(message.content, text) * 100; + const words: number = message.content.trim().split(/\s+/).length; + const wpm: number = (words * 60) / time; + winners[message.author.id] = { + accuracy: accuracy.toFixed(1), + time: time.toFixed(2), + wpm: wpm.toFixed(1) + }; + + if (message.canReact) await message.react(BooleanEmojis.YES); + }); + + const timeout = setTimeout(async () => { + store.typespeed = false; + sub.remove(); + const options = { + messageReference: { messageId: initial.id }, + allowedMentions: { repliedUser: false } + }; + + if (Object.keys(winners).length === 0) { + return await initial.reply({ + content: `${BooleanEmojis.WARNING} Didn't get a message from anyone`, + ...options + }); + } + + const content: string[] = []; + for (const [id, stats] of Object.entries(winners)) { + content.push(`<@${id}>: ${stats.accuracy}% accuracy, ${stats.wpm} wpm, in ${stats.time}s`); + } + + await initial.reply({ + content: content.join('\n'), + ...options + }) + }, 60000); +} + + +function dates(): string { + const amount: number = randomInt(10, 15); + const dates: string[] = []; + + for (let i = 0; i < amount; i++) { + const time = new Date(+new Date() - Math.floor(Math.random() * 10000000000)); + const mm = String(time.getMonth() + 1).padStart(2, '0'); + const dd = String(time.getDate()).padStart(2, '0'); + const yyyy = time.getFullYear(); + + dates.push(`${mm}/${dd}/${yyyy}`); + } + + return dates.join(', '); +} \ No newline at end of file diff --git a/src/utils/formatter/commands/index.ts b/src/utils/formatter/commands/index.ts index 156fa5c..65a29d8 100644 --- a/src/utils/formatter/commands/index.ts +++ b/src/utils/formatter/commands/index.ts @@ -8,6 +8,7 @@ import * as FunRegional from './fun.regional'; import * as FunReverseText from './fun.reversetext'; import * as FunTextwall from './fun.textwall'; import * as FunTTS from './fun.tts'; +import * as FunTypeSpeed from './fun.typespeed'; import * as InfoAvatar from './info.avatar'; import * as InfoUser from './info.user'; @@ -279,6 +280,7 @@ export { FunReverseText, FunTextwall, FunTTS, + FunTypeSpeed, InfoAvatar, InfoUser, diff --git a/src/utils/tools.ts b/src/utils/tools.ts index adf62c0..2129e8f 100644 --- a/src/utils/tools.ts +++ b/src/utils/tools.ts @@ -2195,6 +2195,20 @@ export function randomFromArray( } +export function randomMultipleFromArray(array: T[], amount: number): T[] { + const arr = [...array]; + const result = []; + + let n = Math.min(amount, arr.length); + for (let i = 0; i < n; i++) { + const index = Math.floor(Math.random() * arr.length); + result.push(arr.splice(index, 1)[0]); + } + + return result; +} + + export function randomFromIterator( size: number, iterator: IterableIterator,