From 5fb3fdeac1e6e91431acc9c5c5ec8b9cf23f7a48 Mon Sep 17 00:00:00 2001 From: Artur Mostowski Date: Tue, 14 Feb 2023 18:00:31 +0100 Subject: [PATCH] message functions test --- package.json | 11 ++- src/functions2/echo.ts | 5 ++ src/functions2/join.ts | 24 +++++++ src/functions2/leave.ts | 6 ++ src/functions2/nightcore.ts | 25 +++++++ src/functions2/pause.ts | 13 ++++ src/functions2/ping.ts | 5 ++ src/functions2/play.ts | 94 +++++++++++++++++++++++++ src/functions2/queue.ts | 18 +++++ src/functions2/remove.ts | 11 +++ src/functions2/resume.ts | 8 +++ src/functions2/seek.ts | 18 +++++ src/functions2/skip.ts | 7 ++ src/functions2/types.ts | 66 ++++++++++++++++++ src/lib/Bot.ts | 71 ++++++++++++++++--- src/lib/aliases.ts | 4 +- src/lib/checks.ts | 53 +++++++++++++++ src/lib/messageCommands2.ts | 132 ++++++++++++++++++++++++++++++++++++ tsconfig.json | 8 ++- 19 files changed, 562 insertions(+), 17 deletions(-) create mode 100644 src/functions2/echo.ts create mode 100644 src/functions2/join.ts create mode 100644 src/functions2/leave.ts create mode 100644 src/functions2/nightcore.ts create mode 100644 src/functions2/pause.ts create mode 100644 src/functions2/ping.ts create mode 100644 src/functions2/play.ts create mode 100644 src/functions2/queue.ts create mode 100644 src/functions2/remove.ts create mode 100644 src/functions2/resume.ts create mode 100644 src/functions2/seek.ts create mode 100644 src/functions2/skip.ts create mode 100644 src/functions2/types.ts create mode 100644 src/lib/checks.ts create mode 100644 src/lib/messageCommands2.ts diff --git a/package.json b/package.json index 85e1d7b..9df2d50 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "license": "GPL-3.0-only", "private": false, "_moduleAliases": { - "@lib": "dist/lib" + "@lib": "src/lib/index.ts" }, "scripts": { "build": "yarn clean && tsc", @@ -18,15 +18,20 @@ "lint": "eslint --ignore-path .eslintignore --ext .js,.ts .", "prettier": "prettier .", "format": "prettier-eslint --eslint-config-path src/../.eslintrc.js --config src/../.prettierrc \"src/**/*.ts\"", - "check-tsc": "tsc --noEmit" + "check-tsc": "tsc --noEmit", + "webstorm": "ts-node ./src/index.ts" }, "dependencies": { + "@ef-carbon/tspm": "^2.2.5", "@lavaclient/queue": "^2.0.4", "@lavaclient/spotify": "^3.1.0", "discord.js": "^14.3.0", "dotenv": "^10.0.0", "lavaclient": "^4.0.4", - "module-alias": "^2.2.2" + "module-alias": "^2.2.2", + "ramda": "^0.28.0", + "tsconfig-paths": "^4.1.2", + "zod": "^3.20.6" }, "devDependencies": { "@types/node": "^16.10.2", diff --git a/src/functions2/echo.ts b/src/functions2/echo.ts new file mode 100644 index 0000000..32f4f84 --- /dev/null +++ b/src/functions2/echo.ts @@ -0,0 +1,5 @@ +import { CommonParams2, type EchoParams } from './types' + +export async function Echo({ send, msg }: EchoParams): Promise { + await send(msg) +} diff --git a/src/functions2/join.ts b/src/functions2/join.ts new file mode 100644 index 0000000..0705dd2 --- /dev/null +++ b/src/functions2/join.ts @@ -0,0 +1,24 @@ +import { type CommonParams2Validated } from './types' +import { TextChannel, type VoiceBasedChannel, VoiceChannel } from 'discord.js' +import { type Node, type Player } from 'lavaclient' +import { type Bot, type MessageChannel } from '@lib' + +export const createPlayer = async ({ + userTextChannel, + userVc, + bot +}: { + userTextChannel: MessageChannel + userVc: VoiceBasedChannel + bot: Bot +}): Promise> => { + const player = bot.music.createPlayer(userVc.guild.id) + player.queue.channel = userTextChannel + player.connect(userVc.id) + return player +} + +export async function Join({ userVc, bot, userTextChannel, send }: CommonParams2Validated): Promise { + await createPlayer({ userVc, bot, userTextChannel }) + await send(`Joined ${userVc.toString()}`) +} diff --git a/src/functions2/leave.ts b/src/functions2/leave.ts new file mode 100644 index 0000000..1097931 --- /dev/null +++ b/src/functions2/leave.ts @@ -0,0 +1,6 @@ +import { type CommonParams2Validated } from './types' + +export async function Leave({ bot, player }: CommonParams2Validated): Promise { + player.disconnect() + await bot.music.destroyPlayer(player.guildId) +} diff --git a/src/functions2/nightcore.ts b/src/functions2/nightcore.ts new file mode 100644 index 0000000..97934f5 --- /dev/null +++ b/src/functions2/nightcore.ts @@ -0,0 +1,25 @@ +import { CommonParams2Validated, type NightcoreParams } from './types' + +export async function Nightcore({ + send, + speed: speedParam, + pitch: pitchParam, + rate: rateParam, + player +}: NightcoreParams): Promise { + const shouldEnable = !player.nightcore || speedParam != null || pitchParam != null || rateParam != null + if (!shouldEnable) { + await send("Nightcoren't") + player.nightcore = false + player.filters.timescale = undefined + } else { + player.nightcore = true + const speed = speedParam ?? 1.125 + const pitch = pitchParam ?? 1.125 + const rate = rateParam ?? 1 + await send(`Nightcore enabled with speed ${speed}, pitch ${pitch}, and rate ${rate}`) + player.filters.timescale = { speed, pitch, rate } + } + + await player.setFilters() +} diff --git a/src/functions2/pause.ts b/src/functions2/pause.ts new file mode 100644 index 0000000..68479c8 --- /dev/null +++ b/src/functions2/pause.ts @@ -0,0 +1,13 @@ +import { millisecondsToString } from '@lib' +import { type CommonParams2Validated } from './types' + +export async function Pause({ sendIfError, send, player }: CommonParams2Validated): Promise { + const current = player.queue.current + if (current == null) { + await sendIfError("I'm not playing anything bozo") + return + } + await player.pause(true) + const positionHumanReadable = millisecondsToString(player.position ?? 0) + await send(`Paused ${current.title} at ${positionHumanReadable}`) +} diff --git a/src/functions2/ping.ts b/src/functions2/ping.ts new file mode 100644 index 0000000..cec0001 --- /dev/null +++ b/src/functions2/ping.ts @@ -0,0 +1,5 @@ +import { type CommonParams2 } from './types' + +export async function Ping2({ send, bot }: CommonParams2): Promise { + await send(`Pong! **Heartbeat:** *${Math.round(bot.ws.ping)}ms*`) +} diff --git a/src/functions2/play.ts b/src/functions2/play.ts new file mode 100644 index 0000000..2be522b --- /dev/null +++ b/src/functions2/play.ts @@ -0,0 +1,94 @@ +import { SpotifyItemType } from '@lavaclient/spotify' + +import type { Addable } from '@lavaclient/queue' +import { type PlayParams } from './types' + +export const Play = + ({ next }: { next: boolean }) => + async ({ + userVc, + send, + sendIfError, + userTextChannel, + bot, + query, + guild, + player, + requesterId + }: PlayParams): Promise => { + let tracks: Addable[] = [] + let msg = '' + if (query !== '') { + if (bot.music.spotify.isSpotifyUrl(query)) { + const item = await bot.music.spotify.load(query) + switch (item?.type) { + case SpotifyItemType.Track: { + const track = await item.resolveYoutubeTrack() + tracks = [track] + msg = `Queued track [**${item.name}**](${query}).` + break + } + case SpotifyItemType.Artist: { + tracks = await item.resolveYoutubeTracks() + msg = `Queued the **Top ${tracks.length} tracks** for [**${item.name}**](${query}).` + break + } + case SpotifyItemType.Album: + case SpotifyItemType.Playlist: { + tracks = await item.resolveYoutubeTracks() + msg = `Queued **${tracks.length} tracks** from ${SpotifyItemType[item.type].toLowerCase()} [**${ + item.name + }**](${query}).` + break + } + default: { + await sendIfError("Sorry, couldn't find anything :/") + return + } + } + } else { + const results = await bot.music.rest.loadTracks(/^https?:\/\//.test(query) ? query : `ytsearch:${query}`) + + switch (results.loadType) { + case 'LOAD_FAILED': + case 'NO_MATCHES': { + await sendIfError('uh oh something went wrong') + return + } + case 'PLAYLIST_LOADED': { + tracks = results.tracks + msg = `Queued playlist [**${results.playlistInfo.name}**](${query}), it has a total of **${tracks.length}** tracks.` + break + } + case 'TRACK_LOADED': + case 'SEARCH_RESULT': { + const [track] = results.tracks + tracks = [track] + msg = `Queued [**${track.info.title}**](${track.info.uri})` + break + } + } + } + } + /* create a player and/or join the member's userVc. */ + if (!player?.connected) { + player ??= bot.music.createPlayer(guild.id) + player.queue.channel = userTextChannel + player.connect(userVc.id, { deafened: true }) + } + + /* reply with the queued message. */ + const started = player.playing || player.paused + if (msg !== '') { + // TODO: make it better (this checks if play was used to unpause) + await send(msg, next != null ? 'At the top of the queue.' : '', started) + player.queue.add(tracks, { requester: requesterId, next }) + } else { + await send('Resumed playback') + await player.pause(false) + } + /* do queue tings. */ + if (!started) { + await player.queue.start() + } + } diff --git a/src/functions2/queue.ts b/src/functions2/queue.ts new file mode 100644 index 0000000..11851a4 --- /dev/null +++ b/src/functions2/queue.ts @@ -0,0 +1,18 @@ +import { type CommonParams2Validated } from './types' + +const formatIndex = (index: number, size: number): string => + (index + 1).toString().padStart(size.toString().length, '0') + +export async function Queue({ player, send, guild }: CommonParams2Validated): Promise { + const size = player.queue.tracks.length + const str = player.queue.tracks + .map( + (t, idx) => + `\`#${formatIndex(idx, size)}\` [**${t.title}**](${t.uri}) ${ + t.requester !== undefined ? `<@${t.requester}>` : '' + }` + ) + .join('\n') + + await send(`Queue for **${guild.name}**`, str) +} diff --git a/src/functions2/remove.ts b/src/functions2/remove.ts new file mode 100644 index 0000000..ee78042 --- /dev/null +++ b/src/functions2/remove.ts @@ -0,0 +1,11 @@ +import { type RemoveParams } from './types' + +export async function Remove({ player, sendIfError, send, index }: RemoveParams): Promise { + const removedTrack = player.queue.remove(index - 1) + if (removedTrack == null) { + await sendIfError('No tracks were removed.') + return + } + + await send(`The track [**${removedTrack.title}**](${removedTrack.uri}) was removed.`) +} diff --git a/src/functions2/resume.ts b/src/functions2/resume.ts new file mode 100644 index 0000000..6d4df46 --- /dev/null +++ b/src/functions2/resume.ts @@ -0,0 +1,8 @@ +import { millisecondsToString } from '@lib' +import { type CommonParams2Validated } from './types' + +export async function Resume({ player, send, current }: CommonParams2Validated): Promise { + await player.pause(false) + const positionHumanReadable = millisecondsToString(player.position ?? 0) + await send(`Resumed ${current?.title} at ${positionHumanReadable}`) +} diff --git a/src/functions2/seek.ts b/src/functions2/seek.ts new file mode 100644 index 0000000..223cfba --- /dev/null +++ b/src/functions2/seek.ts @@ -0,0 +1,18 @@ +import { millisecondsToString, stringToMilliseconds } from '@lib' + +import { type SeekParams } from './types' + +export const Seek = + ({ add = false, multiplier = 1 }: { add?: boolean, multiplier?: 1 | -1 }) => + async ({ current, sendIfError, send, player, position }: SeekParams): Promise => { + const positionNumerised = (add ? player.position ?? 0 : 0) + multiplier * stringToMilliseconds(position) + if (isNaN(positionNumerised)) { + await sendIfError('Position must be a number') + return + } + await player.seek(positionNumerised) + + const positionHumanReadable = millisecondsToString(positionNumerised) + + await send(`Sought ${current?.title} to ${positionHumanReadable}`) + } diff --git a/src/functions2/skip.ts b/src/functions2/skip.ts new file mode 100644 index 0000000..5c14d69 --- /dev/null +++ b/src/functions2/skip.ts @@ -0,0 +1,7 @@ +import { type CommonParams2Validated } from './types' + +export async function Skip({ player, send, current }: CommonParams2Validated): Promise { + await player.queue.skip() + await player.queue.start() + await send(`Skipped ${current?.title}`) +} diff --git a/src/functions2/types.ts b/src/functions2/types.ts new file mode 100644 index 0000000..a2b400b --- /dev/null +++ b/src/functions2/types.ts @@ -0,0 +1,66 @@ +import { type Guild, type VoiceBasedChannel } from 'discord.js' +import { type Bot, type MessageChannel } from '@lib' +import { z } from 'zod' +import { type Node, type Player } from 'lavaclient' +import { type Song } from '@lavaclient/queue' + +const numericCore = z.string().regex(/^[+-]?(\d*[.])?\d+$/, { message: 'not a number' }) +const intigerishCore = z.string().regex(/^\d+$/, { message: 'not an int' }) +export const numeric = () => numericCore.transform(Number) +export const intigerish = () => intigerishCore.transform((x) => parseInt(x, 10)) + +export const optionalNumeric = () => numericCore.optional().transform((x) => (x == null ? undefined : Number(x))) + +type SendFnType = (text: string, ...rest: any[]) => Promise + +export interface CommonParams2 { + userVc: VoiceBasedChannel | null | undefined + userTextChannel: MessageChannel + bot: Bot + guild: Guild | null | undefined + send: SendFnType + sendIfError: SendFnType + player: Player | undefined + requesterId: string + current: Song | null | undefined +} + +export type CommonParams2Validated = CommonParams2 & { + userVc: VoiceBasedChannel + guild: Guild + player: Player +} + +export type TextChannelCheckParams = Omit + +export const EchoParamsSchema = z.object({ + msg: z.string().min(1) +}) + +export type EchoParams = z.infer & CommonParams2Validated + +export const NightcoreParamsSchema = z.object({ + speed: optionalNumeric(), + pitch: optionalNumeric(), + rate: optionalNumeric() +}) + +export type NightcoreParams = z.infer & CommonParams2Validated + +export const PlayParamsSchema = z.object({ + query: z.string().min(1) +}) + +export type PlayParams = z.infer & CommonParams2Validated + +export const RemoveParamsSchema = z.object({ + index: intigerish() +}) + +export type RemoveParams = z.infer & CommonParams2Validated + +export const SeekParamsSchema = z.object({ + position: z.string().min(1) +}) + +export type SeekParams = z.infer & CommonParams2Validated diff --git a/src/lib/Bot.ts b/src/lib/Bot.ts index d81b1be..b92e7f9 100644 --- a/src/lib/Bot.ts +++ b/src/lib/Bot.ts @@ -5,6 +5,12 @@ import { type Command } from './command/Command' import { aliases } from './aliases' import { createMessageCommands } from './messageCommands' import { type handleMessageType, type MessageCommandParams } from '../functions/types' +import { messageCommands2 } from './messageCommands2' +import { guildCheck, textChannelCheck } from './checks' + +export function isKeyOfObject(key: string | number | symbol, obj: T): key is keyof T { + return key in obj +} export class Bot extends Client { readonly music: Node @@ -19,7 +25,7 @@ export class Bot extends Client { intents: ['Guilds', 'GuildMessages', 'GuildVoiceStates', 'MessageContent'] }) - this.attachMessageCommands() + // this.attachMessageCommands() this.attachMessageCommandsAliases() this.music = new Node({ sendGatewayPayload: (id, payload) => this.guilds.cache.get(id)?.shard?.send(payload), @@ -57,17 +63,60 @@ export class Bot extends Client { const textChannel: TextChannel = maybeChannel as TextChannel const message = await this.getMessage(textChannel, data.id) if (data.content == null) return - if (data.content.startsWith(this.prefix)) { - const commandOrAlias = data.content.split(' ')[0].slice(this.prefix.length) - const command = - commandOrAlias in this.messageCommandsAliases ? this.messageCommandsAliases[commandOrAlias] : commandOrAlias - if (command in this.messageCommands) { - await this.messageCommands[command]({ - data, - textChannel, - message - }) + if (!data.content.startsWith(this.prefix)) return + const contentWithoutPrefix = data.content.slice(this.prefix.length).trimStart() + const commandOrAlias = contentWithoutPrefix.split(' ')[0].toLowerCase() + const args = contentWithoutPrefix.slice(commandOrAlias.length).trimStart() + const command = + commandOrAlias in this.messageCommandsAliases ? this.messageCommandsAliases[commandOrAlias] : commandOrAlias + if (isKeyOfObject(command, messageCommands2)) { + const { + checks = [], + fn, + schema, + regex + } = { + ...{ schema: null, regex: null }, + ...messageCommands2[command] + } + const commonChecks = [guildCheck, textChannelCheck] + const allChecks = [...commonChecks, ...checks] + const guild = this.guilds.cache.get(data.guild_id ?? '') + const sendFn = async (a: any): Promise => { + try { + await message?.reply(a) + } catch (e) { + await textChannel?.send(a) + } + } + const player = this.music.players.get(guild?.id ?? '') + const current = player?.queue.current + const partialParams = { + userVc: guild?.voiceStates.cache.get(data.author?.id ?? '')?.channel, + guild, + bot: this, + userTextChannel: textChannel, + send: sendFn, + sendIfError: sendFn, + player, + requesterId: data.author.id, + current + } + for (const check of allChecks) { + if (!(await check(partialParams))) return + } + const paramsExtension = regex == null ? {} : new RegExp(regex).exec(args)?.groups ?? {} + console.log(paramsExtension) + const parsedParamsExtension = + schema == null ? ({ success: true, data: {} } as const) : schema.safeParse(paramsExtension) + if (!parsedParamsExtension.success) { + await sendFn('Wrong arguments') + return } + await fn({ + ...partialParams, + ...parsedParamsExtension.data + }) } } diff --git a/src/lib/aliases.ts b/src/lib/aliases.ts index eb7690b..6b8d7b7 100644 --- a/src/lib/aliases.ts +++ b/src/lib/aliases.ts @@ -8,5 +8,7 @@ export const aliases: Record = { fs: 'skip', forceskip: 'skip', r: 'remove', - rm: 'remove' + rm: 'remove', + nc: 'nightcore', + piwotop: 'piwonext' } diff --git a/src/lib/checks.ts b/src/lib/checks.ts new file mode 100644 index 0000000..c0fb38b --- /dev/null +++ b/src/lib/checks.ts @@ -0,0 +1,53 @@ +import { type CommonParams2, TextChannelCheckParams } from '../functions2/types' + +export type Check = (params: CommonParams2) => Promise +export const textChannelCheck: Check = async (params) => { + return params.userTextChannel != null +} + +export const guildCheck: Check = async ({ guild, sendIfError }) => { + if (guild == null) { + await sendIfError('Guild not found. This should not happen.') + return false + } + return true +} + +export const voiceChannelCheck: Check = async ({ userVc, guild, bot, sendIfError }) => { + if (userVc?.id == null) { + await sendIfError('You must be in a voice channel to use this command') + return false + } + const botChannel = bot.music.players.get(guild?.id ?? '')?.channelId + if (botChannel == null) return true + if (botChannel !== userVc.id) { + await sendIfError(`I'm already in a voice channel. Join <#${botChannel}>`) + return false + } + return true +} + +export const connectedCheck: Check = async ({ bot, guild, sendIfError }) => { + const player = bot.music.players.get(guild?.id ?? '') + if (player?.connected !== true) { + await sendIfError("I couldn't find a player for this guild.") + return false + } + return true +} + +export const queueCheck: Check = async ({ player, sendIfError }) => { + if (player == null || player.queue.tracks.length === 0) { + await sendIfError('There are no tracks in the queue.') + return false + } + return true +} + +export const currentCheck: Check = async ({ current, sendIfError }) => { + if (current == null) { + await sendIfError('There is no current track.') + return false + } + return true +} diff --git a/src/lib/messageCommands2.ts b/src/lib/messageCommands2.ts new file mode 100644 index 0000000..80d0c5c --- /dev/null +++ b/src/lib/messageCommands2.ts @@ -0,0 +1,132 @@ +import { type z } from 'zod' +import { Ping2 } from '../functions2/ping' +import { type Check, connectedCheck, currentCheck, queueCheck, textChannelCheck, voiceChannelCheck } from './checks' +import { + TextChannelCheckParams, + EchoParamsSchema, + CommonParams2, + NightcoreParamsSchema, + PlayParamsSchema, + RemoveParamsSchema, + SeekParamsSchema +} from '../functions2/types' +import { Echo } from '../functions2/echo' +import { Join } from '../functions2/join' +import { Leave } from '../functions2/leave' +import { Nightcore } from '../functions2/nightcore' +import { Pause } from '../functions2/pause' +import { Play } from '../functions2/play' +import { Queue } from '../functions2/queue' +import { Remove } from '../functions2/remove' +import { Resume } from '../functions2/resume' +import { Seek } from '../functions2/seek' +import { Skip } from '../functions2/skip' + +interface MyCommandBase { + checks?: Check[] + fn: (params: any) => Promise +} + +type MyCommand = + | MyCommandBase + | (MyCommandBase & { + schema: z.ZodSchema + regex: string + }) + +const playGeneric = { + schema: PlayParamsSchema, + checks: [voiceChannelCheck], + regex: '^(?.*)$' +} + +const seekGeneric = { + schema: SeekParamsSchema, + checks: [voiceChannelCheck, connectedCheck, currentCheck], + regex: '^(?\\d+)$' +} + +export const messageCommands2: Record = { + ping: { + fn: Ping2 + }, + echo: { + schema: EchoParamsSchema, + fn: Echo, + regex: '^(?.*)$' + }, + join: { + checks: [voiceChannelCheck], + fn: Join + }, + leave: { + checks: [connectedCheck], + fn: Leave + }, + nightcore: { + schema: NightcoreParamsSchema, + checks: [voiceChannelCheck, connectedCheck], + fn: Nightcore, + regex: '^(?\\S*)\\s(?\\S*)\\s(?\\S*).*$' + }, + pause: { + checks: [voiceChannelCheck, connectedCheck], + fn: Pause + }, + piwo: { + checks: playGeneric.checks, + fn: async (arg: any) => { + await Play({ next: false })({ + query: 'https://www.youtube.com/watch?v=hbsT9OOqvzw', + ...arg + }) + } + }, + piwonext: { + checks: playGeneric.checks, + fn: async (arg: any) => { + await Play({ next: true })({ + query: 'https://www.youtube.com/watch?v=hbsT9OOqvzw', + ...arg + }) + } + }, + play: { + ...playGeneric, + fn: Play({ next: false }) + }, + playnext: { + ...playGeneric, + fn: Play({ next: true }) + }, + queue: { + checks: [connectedCheck, queueCheck], + fn: Queue + }, + remove: { + schema: RemoveParamsSchema, + regex: '^(?\\d+)$', + checks: [connectedCheck], + fn: Remove + }, + resume: { + checks: [connectedCheck, currentCheck], + fn: Resume + }, + seek: { + ...seekGeneric, + fn: Seek({}) + }, + back: { + ...seekGeneric, + fn: Seek({ add: true, multiplier: -1 }) + }, + forward: { + ...seekGeneric, + fn: Seek({ add: true }) + }, + skip: { + checks: [connectedCheck, currentCheck], + fn: Skip + } +} diff --git a/tsconfig.json b/tsconfig.json index 51d0922..73e5409 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,8 @@ { + "ts-node": { + // Do not forget to `npm i -D tsconfig-paths` + "require": ["tsconfig-paths/register"] + }, "compilerOptions": { /* compilation */ "lib": ["ES2020", "ES2020.BigInt", "ES2020.Promise", "ES2020.String"], @@ -39,9 +43,9 @@ "esModuleInterop": true, "paths": { "@lib": ["src/lib/index.ts"], - "@core": ["src/core/*"] + "@core/*": ["src/core/*"] } }, - "exclude": ["./dist", "./node_modules"], + "exclude": ["dist", "node_modules"], "include": ["./src"] }