commit 6533e85d92b6db4f6b0bfe51868bf9c4f8048207 Author: Lexie Love Date: Sun Jan 1 13:49:51 2023 -0600 initial commit, there are some pending bugs diff --git a/audio_player.ts b/audio_player.ts new file mode 100644 index 0000000..a746261 --- /dev/null +++ b/audio_player.ts @@ -0,0 +1,13 @@ + + +export class AudioPlayer { + audio?: AsyncIterableIterator; + playing = false; + + play() { + if(this.playing) { + return; + } + this.playing = true; + } +} \ No newline at end of file diff --git a/commands.ts b/commands.ts new file mode 100644 index 0000000..637bd7a --- /dev/null +++ b/commands.ts @@ -0,0 +1,58 @@ +import { Bot, Interaction } from "./deps.ts"; +import { help } from "./commands/help.ts"; +import { invalidCommand } from "./commands/invalid_command.ts"; +import { leave } from "./commands/leave.ts"; +import { loop } from "./commands/loop.ts" +import { np } from "./commands/np.ts"; +import { pause } from "./commands/pause.ts"; +import { play } from "./commands/play.ts"; +import { skip } from "./commands/skip.ts"; +import { unloop } from "./commands/unloop.ts"; + +import { red } from "https://deno.land/std@0.161.0/fmt/colors.ts"; + +export async function parseCommand(bot: Bot, interaction: Interaction) { + if(!interaction.data) { + console.log(red("invalid interaction data was passed through somehow:")); + console.log(interaction); + return; + } + switch(interaction.data.name) { + case "help": { + await help(bot, interaction); + break; + } + case "leave": { + await leave(bot, interaction); + break; + } + case "loop": { + await loop(bot, interaction); + break; + } + case "np": { + await np(bot, interaction); + break; + } + case "pause": { + await pause(bot, interaction); + break; + } + case "play": { + await play(bot, interaction); + break; + } + case "skip": { + await skip(bot, interaction); + break; + } + case "unloop": { + await unloop(bot, interaction); + break; + } + default: { + await invalidCommand(bot, interaction); + break; + } + } +} diff --git a/commands/help.ts b/commands/help.ts new file mode 100644 index 0000000..bd6089a --- /dev/null +++ b/commands/help.ts @@ -0,0 +1,62 @@ +import { + Bot, + Interaction, + sendPrivateInteractionResponse, + type ApplicationCommandOption, + type ApplicationCommandOptionChoice, + type CreateSlashApplicationCommand, + type InteractionResponse +} from "../deps.ts"; + +const helpChoices = [ + { + name: "play", + value: "play" + } +]; + +const helpResponse = { + type: 4, // ChannelMessageWithSource + data: { + content: `/help: displays this message\n/play: plays a song` + } +} + +const playResponse = { + type: 4, // ChannelMessageWithSource + data: { + content: `/play: Add a song or playlist to the queue and starts the music if it's not already playing + **Parameters:** + url: A URL or video ID of the song or playlist to play` + } +} + +export const helpCommand = { + name: "help", + description: "Lists the bot's commands and describes how to use them", + dmPermission: false, + options: [ + { + type: 3, // string + name: "command", + description: "Displays additional info about a particular command", + choices: helpChoices, + required: false + }, + ] +}; + +export async function help(bot: Bot, interaction: Interaction) { + if (!interaction.guildId) return; + + if(interaction.data.options) { + switch(interaction.data.options[0].value) { + case "play": { + await sendPrivateInteractionResponse(bot, interaction.id, interaction.token, playResponse); + break; + } + } + } else { + await sendPrivateInteractionResponse(bot, interaction.id, interaction.token, helpResponse); + } +} diff --git a/commands/invalid_command.ts b/commands/invalid_command.ts new file mode 100644 index 0000000..581c806 --- /dev/null +++ b/commands/invalid_command.ts @@ -0,0 +1,18 @@ +import { + Bot, + Interaction, + sendPrivateInteractionResponse, + type InteractionResponse +} from "../deps.ts"; + +export async function invalidCommand(bot: Bot, interaction: Interaction) { + if (!interaction.guildId) return; + await sendPrivateInteractionResponse(bot, interaction.id, interaction.token, invalidCommandResponse); +} + +const invalidCommandResponse = { + type: 4, // ChannelMessageWithSource + data: { + content: `Either you somehow sent an invalid command or waves didn't understand the command for some reason. Try again or poke sykora about it.` + } +} \ No newline at end of file diff --git a/commands/leave.ts b/commands/leave.ts new file mode 100644 index 0000000..400e8b2 --- /dev/null +++ b/commands/leave.ts @@ -0,0 +1,34 @@ +import { + Bot, + editOriginalInteractionResponse, + getConnectionData, + Interaction, + leaveVoiceChannel, + sendInteractionResponse, + type CreateSlashApplicationCommand +} from "../deps.ts"; + +import { formatCallbackData, waitingForResponse } from "../utils.ts"; + +const notInVoiceResponse = formatCallbackData(`Permanent Waves isn't currently in a voice channel.`); + +const leftResponse = formatCallbackData(`Left channel.`); + +export const leaveCommand = { + name: "leave", + description: "Makes the bot leave the current voice channel", + dmPermission: false, +}; + +export async function leave(bot: Bot, interaction: Interaction) { + if (!interaction.guildId) return; + const conn = getConnectionData(bot.id, interaction.guildId); + await sendInteractionResponse(bot, interaction.id, interaction.token, waitingForResponse); + + if(!conn.connectInfo.endpoint) { + await editOriginalInteractionResponse(bot, interaction.token, notInVoiceResponse); + } else { + await leaveVoiceChannel(bot, interaction.guildId); + await editOriginalInteractionResponse(bot, interaction.token, leftResponse); + } +} \ No newline at end of file diff --git a/commands/loop.ts b/commands/loop.ts new file mode 100644 index 0000000..9204bba --- /dev/null +++ b/commands/loop.ts @@ -0,0 +1,45 @@ +import { + Bot, + editOriginalInteractionResponse, + Interaction, + sendInteractionResponse, + type CreateSlashApplicationCommand +} from "../deps.ts"; + +import { ensureVoiceConnection, formatCallbackData, waitingForResponse } from "../utils.ts"; + +function alreadyLoopingResponse(bot: Bot, interaction: Interaction) { + const player = bot.helpers.getPlayer(interaction.guildId); + return formatCallbackData(`Looping is already enabled. + Currently playing: **${player.nowPlaying.title}**, added by ${player.nowPlaying.added_by}`); +} + +const nothingToLoopResponse = formatCallbackData(`The queue is empty.`); + +function loopEnabledResponse(bot: Bot, interaction: Interaction) { + const player = bot.helpers.getPlayer(interaction.guildId); + return formatCallbackData(`Looping has been enabled. + Currently playing: **${player.nowPlaying.title}**, added by ${player.nowPlaying.added_by}`); +} + +export const loopCommand = { + name: "loop", + description: "Loops the currently playijng song. All other songs remain in the queue", + dmPermission: false, +}; + +export async function loop(bot: Bot, interaction: Interaction) { + if (!interaction.guildId) return; + await ensureVoiceConnection(bot, interaction.guildId); + const player = bot.helpers.getPlayer(interaction.guildId); + await sendInteractionResponse(bot, interaction.id, interaction.token, waitingForResponse); + + if(!player.nowPlaying) { + await editOriginalInteractionResponse(bot, interaction.token, nothingToLoopResponse); + } else if(!player.looping){ + await player.loop(true); + await editOriginalInteractionResponse(bot, interaction.token, loopEnabledResponse(bot, interaction)); + } else { + await editOriginalInteractionResponse(bot, interaction.token, alreadyLoopingResponse(bot, interaction)); + } +} \ No newline at end of file diff --git a/commands/np.ts b/commands/np.ts new file mode 100644 index 0000000..40bf41a --- /dev/null +++ b/commands/np.ts @@ -0,0 +1,76 @@ +import { + Bot, + Interaction, + InteractionResponse, + sendInteractionResponse, + sendMessage, + type CreateMessage, + type CreateSlashApplicationCommand, + type Embed, + type InteractionCallbackData +} from "../deps.ts"; + +import { configs } from "../configs.ts" + +import { bot } from "../main.ts"; +import { getAllowedTextChannel } from "../utils.ts"; +import { ConnectionData } from "../discordeno-audio-plugin-main/mod.ts"; + +export async function np(bot: Bot, interaction: Interaction) { + await sendInteractionResponse(bot, interaction.id, interaction.token, nowPlayingResponse(bot, interaction)); +} + +function formatQueue(bot: Bot, interaction: Interaction) { + const player = bot.helpers.getPlayer(interaction.guildId); + let formattedText = ""; + + if(!player.nowPlaying) { + return "Nothing is currently in the queue."; + } else { + formattedText = `Now playing: **${player.nowPlaying.title}**, added by ${player.nowPlaying.added_by}` + } + + formattedText = formattedText.concat(`\nUp next:`); + + for(let audioSource of player.upcoming()) { + formattedText = formattedText.concat(`\n- **${audioSource.title}**, added by ${audioSource.added_by}`) + } + + return formattedText; +} + +function nowPlayingResponse(bot: Bot, interaction: Interaction) { + return { + type: 4, + data: + { + content: "", + embeds: [{ + title: "In the queue", + color: configs.embed_color, + description: formatQueue(bot, interaction) + }] + } + } +} + +function nowPlayingMessage(bot: Bot, guildId: number) { + const player = bot.helpers.getPlayer(guildId); + return { + embeds: [{ + color: configs.embed_color, + description: `Now playing: **${player.nowPlaying.title}**, added by ${player.nowPlaying.added_by}` + }] + } +} + +export async function nowPlayingCallback(connectionData: ConnectionData) { + const channel = await getAllowedTextChannel(bot, connectionData.guildId); + await sendMessage(bot, channel.id, nowPlayingMessage(bot, connectionData.guildId)); +} + +export const npCommand = { + name: "np", + description: "Shows the currently-playing song along with the next five songs in the queue", + dmPermission: false +}; diff --git a/commands/pause.ts b/commands/pause.ts new file mode 100644 index 0000000..5775243 --- /dev/null +++ b/commands/pause.ts @@ -0,0 +1,46 @@ +import { + Bot, + editOriginalInteractionResponse, + Interaction, + sendInteractionResponse, + type CreateSlashApplicationCommand +} from "../deps.ts"; + +import { ensureVoiceConnection, formatCallbackData, waitingForResponse } from "../utils.ts"; + +const alreadyPausedResponse = formatCallbackData(`The player is already paused.`); + +const emptyQueueResponse = formatCallbackData(`There's nothing in the queue right now.`); + +const nowPausedResponse = formatCallbackData(`The player has been paused.`); + +export const pauseCommand = { + name: "pause", + description: "Pauses the player", + dmPermission: false, +}; + +export const stopCommand = { + name: "stop", + description: "Pauses the player, alias for /pause", + dmPermission: false, +}; + +export async function pause(bot: Bot, interaction: Interaction) { + if (!interaction.guildId) return; + await ensureVoiceConnection(bot, interaction.guildId); + const player = bot.helpers.getPlayer(interaction.guildId); + await sendInteractionResponse(bot, interaction.id, interaction.token, waitingForResponse); + + if(player.playing && !player.waiting) { + if(player.nowPlaying) { + await player.pause(); + await editOriginalInteractionResponse(bot, interaction.token, nowPausedResponse); + } else { + await editOriginalInteractionResponse(bot, interaction.token, emptyQueueResponse); + } + } else { + await editOriginalInteractionResponse(bot, interaction.token, alreadyPausedResponse); + } + +} \ No newline at end of file diff --git a/commands/play.ts b/commands/play.ts new file mode 100644 index 0000000..2278252 --- /dev/null +++ b/commands/play.ts @@ -0,0 +1,146 @@ +import { + Bot, + editOriginalInteractionResponse, + Interaction, + sendInteractionResponse, + type ApplicationCommandOption, + type CreateSlashApplicationCommand +} from "../deps.ts"; + +import { ensureVoiceConnection, formatCallbackData, waitingForResponse } from "../utils.ts"; + +function addedToQueueResponse(interaction: Interaction, title: string) { + return formatCallbackData(`${interaction.user.username} added [**${title}**](${interaction.data.options[0].value}) to the queue.`, "Added to queue"); +} + +function alreadyPlayingResponse(bot: Bot, interaction: Interaction) { + const player = bot.helpers.getPlayer(interaction.guildId); + return formatCallbackData(`The bot is already playing. + Currently playing: **${player.nowPlaying.title}**, added by ${player.nowPlaying.added_by}`); +} +const emptyQueueResponse = formatCallbackData(`There's nothing in the queue to play right now.`); + +function nowPlayingResponse(bot: Bot, interaction: Interaction) { + const player = bot.helpers.getPlayer(interaction.guildId); + return formatCallbackData(`The bot has started playing again. + Currently playing: **${player.nowPlaying.title}**, added by ${player.nowPlaying.added_by}`); +} + +export const playCommand = { + name: "play", + description: "Adds a song or playlist to the queue and starts the music if it's not already playing", + dmPermission: false, + options: [ + { + type: 3, // string + name: "url", + description: "The URL or video ID of the song or playlist to play", + required: false + } + ] +}; + + +// todo it crashes when a timestamp is offered +export async function play(bot: Bot, interaction: Interaction, _args?) { + if (!interaction.guildId) return; + await ensureVoiceConnection(bot, interaction.guildId); + const player = bot.helpers.getPlayer(interaction.guildId); + await sendInteractionResponse(bot, interaction.id, interaction.token, waitingForResponse); + + if(interaction.data.options) { + const parsed_url = new URL(interaction.data.options[0].value); + let href; + if(parsed_url.href.indexOf("?t=") !== -1) { + href = parsed_url.href.substring(0, parsed_url.href.indexOf("?")) + } else { + href = parsed_url.href; + } + // TODO: maybe switch to ytdl and not have to use the deno youtube library? + const result = await player.pushQuery(interaction.user.username, href); + if(parsed_url.href.indexOf("youtube.com") !== -1 || parsed_url.href.indexOf("youtu.be") !== -1 && result[0].title) { + await editOriginalInteractionResponse(bot, interaction.token, addedToQueueResponse(interaction, result[0].title)); + } + } else { + // restart the player if there's no url + if(player.waiting || !player.playing) { + if(player.nowPlaying) { + await player.play(); + await editOriginalInteractionResponse(bot, interaction.token, nowPlayingResponse(bot, interaction)); + } else { + await editOriginalInteractionResponse(bot, interaction.token, emptyQueueResponse); + } + } else { + await editOriginalInteractionResponse(bot, interaction.token, alreadyPlayingResponse(bot, interaction)); + } + } +} + +/*import { exists } from "https://deno.land/std@0.161.0/fs/mod.ts"; + +import { configs } from "../configs.ts"; +import { Bot } from "../deps.ts"; +import { download, ensureVoiceConnection } from "../utils.ts"; +import { type Command } from "../types.ts"; + +export async function play(bot: Bot, command: Command) { + await ensureVoiceConnection(bot, command.guildId); + const player = bot.helpers.getPlayer(command.guildId); + await player.pushQuery(command.params); + await player.play(); +}*/ + +/*const parsed_url = new URL(url); +let video_id = ""; + +if(parsed_url.href.indexOf("youtube.com") !== -1) { + video_id = parsed_url.search.substring(3); +} else if(parsed_url.href.indexOf("youtu.be") === -1) { + video_id = parsed_url.pathname.substring(1); +} else { + return { + status: false, + message: "This URL is invalid" + }; +} + +if (!(await exists(`${configs.project_root}/music/`))) { + await download(video_id); +}*/ + + +// single video +/* +URL { + href: "https://www.youtube.com/watch?v=DhobsmmyGFs", + origin: "https://www.youtube.com", + protocol: "https:", + username: "", + password: "", + host: "www.youtube.com", + hostname: "www.youtube.com", + port: "", + pathname: "/watch", + hash: "", + search: "?v=DhobsmmyGFs" +} +{ + endpoint: "stockholm3048.discord.media:443", + sessionId: "74d4d31a8f5c507f9852278867d42c05", + token: "08b9f3bc65a233d5" +}*/ + +// playlist +/*URL { + href: "https://www.youtube.com/playlist?list=PLvNazUnle2rTZO7OVYhhRdzFb9W4xSpNk", + origin: "https://www.youtube.com", + protocol: "https:", + username: "", + password: "", + host: "www.youtube.com", + hostname: "www.youtube.com", + port: "", + pathname: "/playlist", + hash: "", + search: "?list=PLvNazUnle2rTZO7OVYhhRdzFb9W4xSpNk" +}*/ \ No newline at end of file diff --git a/commands/skip.ts b/commands/skip.ts new file mode 100644 index 0000000..24b0836 --- /dev/null +++ b/commands/skip.ts @@ -0,0 +1,33 @@ +import { + Bot, + editOriginalInteractionResponse, + Interaction, + sendInteractionResponse, + type CreateSlashApplicationCommand, +} from "../deps.ts"; + +import { ensureVoiceConnection, formatCallbackData, waitingForResponse } from "../utils.ts"; + +const nothingToSkipResponse = formatCallbackData(`The queue is empty.`); + +const skippedResponse = formatCallbackData(`The song has been skipped.`); + +export const skipCommand = { + name: "skip", + description: "Skips the current song", + dmPermission: false, +}; + +export async function skip(bot: Bot, interaction: Interaction) { + if (!interaction.guildId) return; + await ensureVoiceConnection(bot, interaction.guildId); + const player = bot.helpers.getPlayer(interaction.guildId); + await sendInteractionResponse(bot, interaction.id, interaction.token, waitingForResponse); + + if(!player.nowPlaying) { + await editOriginalInteractionResponse(bot, interaction.token, nothingToSkipResponse); + } else { + await player.skip(); + await editOriginalInteractionResponse(bot, interaction.token, skippedResponse); + } +} \ No newline at end of file diff --git a/commands/unloop.ts b/commands/unloop.ts new file mode 100644 index 0000000..5bdf7e2 --- /dev/null +++ b/commands/unloop.ts @@ -0,0 +1,45 @@ +import { + Bot, + editOriginalInteractionResponse, + Interaction, + sendInteractionResponse, + type CreateSlashApplicationCommand +} from "../deps.ts"; + +import { ensureVoiceConnection, formatCallbackData, waitingForResponse } from "../utils.ts"; + +function notLoopingResponse(bot: Bot, interaction: Interaction) { + const player = bot.helpers.getPlayer(interaction.guildId); + return formatCallbackData(`Looping is already disabled. + Currently playing: **${player.nowPlaying.title}**, added by ${player.nowPlaying.added_by}`); +} + +const nothingToLoopResponse = formatCallbackData(`The queue is empty.`); + +function loopDisabledResponse(bot: Bot, interaction: Interaction) { + const player = bot.helpers.getPlayer(interaction.guildId); + return formatCallbackData(`Looping the current song has been disabled. + Currently playing: **${player.nowPlaying.title}**, added by ${player.nowPlaying.added_by}`); +} + +export const unloopCommand = { + name: "unloop", + description: "Disables looping the current song. At the current song's end, the queue will proceed as normal.", + dmPermission: false, +}; + +export async function unloop(bot: Bot, interaction: Interaction) { + if (!interaction.guildId) return; + await ensureVoiceConnection(bot, interaction.guildId); + const player = bot.helpers.getPlayer(interaction.guildId); + await sendInteractionResponse(bot, interaction.id, interaction.token, waitingForResponse); + + if(!player.nowPlaying) { + await editOriginalInteractionResponse(bot, interaction.token, nothingToLoopResponse); + } else if(player.looping){ + await player.loop(false); + await editOriginalInteractionResponse(bot, interaction.token, loopDisabledResponse(bot, interaction)); + } else { + await editOriginalInteractionResponse(bot, interaction.token, notLoopingResponse(bot, interaction)); + } +} \ No newline at end of file diff --git a/deps.ts b/deps.ts new file mode 100644 index 0000000..8cb52a0 --- /dev/null +++ b/deps.ts @@ -0,0 +1,23 @@ +export { ApplicationCommandOptionTypes, createBot, Intents, startBot } from "https://deno.land/x/discordeno@17.0.1/mod.ts"; +export { + createGlobalApplicationCommand, + editGlobalApplicationCommand, + getChannel, + getChannels, + getGlobalApplicationCommands, + getGuild, + sendFollowupMessage, + sendMessage, + upsertGlobalApplicationCommands +} from "https://deno.land/x/discordeno@17.0.1/helpers/mod.ts"; +export { type BigString, type CreateApplicationCommand, type CreateSlashApplicationCommand, type InteractionCallbackData } from "https://deno.land/x/discordeno@17.0.1/types/mod.ts"; +export { type CreateMessage } from "https://deno.land/x/discordeno@17.0.1/helpers/messages/mod.ts"; +export { type InteractionResponse } from "https://deno.land/x/discordeno@17.0.1/types/discordeno.ts"; +export { editOriginalInteractionResponse, sendInteractionResponse } from "https://deno.land/x/discordeno@17.0.1/helpers/interactions/mod.ts"; +export { sendPrivateInteractionResponse } from "https://deno.land/x/discordeno@17.0.1/plugins/mod.ts"; +export { type Channel } from "https://deno.land/x/discordeno@17.0.1/transformers/channel.ts"; +export { type Bot } from "https://deno.land/x/discordeno@17.0.1/bot.ts"; +export { type Interaction } from "https://deno.land/x/discordeno@17.0.1/transformers/interaction.ts"; +export { type ApplicationCommandOption, type ApplicationCommandOptionChoice, type Embed } from "https://deno.land/x/discordeno@17.0.1/transformers/mod.ts"; +export { leaveVoiceChannel } from "https://deno.land/x/discordeno@17.0.1/helpers/guilds/mod.ts"; +export { getConnectionData } from "./discordeno-audio-plugin-main/src/connection-data.ts"; \ No newline at end of file diff --git a/discordeno-audio-plugin-main/.gitignore b/discordeno-audio-plugin-main/.gitignore new file mode 100644 index 0000000..0f1a1b5 --- /dev/null +++ b/discordeno-audio-plugin-main/.gitignore @@ -0,0 +1,2 @@ +# VS code settings +.vscode \ No newline at end of file diff --git a/discordeno-audio-plugin-main/LICENSE b/discordeno-audio-plugin-main/LICENSE new file mode 100644 index 0000000..76ba2f3 --- /dev/null +++ b/discordeno-audio-plugin-main/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 JasperVanEsveld + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/discordeno-audio-plugin-main/README.md b/discordeno-audio-plugin-main/README.md new file mode 100644 index 0000000..e640bb1 --- /dev/null +++ b/discordeno-audio-plugin-main/README.md @@ -0,0 +1,96 @@ +# Discordeno Audio Plugin + +This plugin enables your bot to send and receive audio. +Play either local files or stream straight from YouTube using [ytdl-core](https://github.com/DjDeveloperr/ytdl_core). +Or create your own audio sources! + +No external plugins like FFMPEG required! + +IMPORTANT: The `--unstable` flag is required as the unstable [datagram](https://doc.deno.land/deno/unstable/~/Deno.listenDatagram) implementation is used. + +## Enable Audio usage + +Enabling the plugin is similar to the cache plugin. + +After that just connect to a channel and play your songs! + + +```js +import { enableAudioPlugin } from "https://deno.land/x/discordeno_audio_plugin/mod.ts"; + +const baseBot = createBot({}); // Create your bot +const bot = enableAudioPlugin(baseBot); // Enable the plugin +await startBot(bot); + +// Connect to a channel like normal +bot.helpers.connectToVoiceChannel( + "your-guildid", + "channel-id" +); + +// Play music :) +const player = bot.helpers.getPlayer("your-guildid"); +player.pushQuery("Obi-Wan - Hello there."); +``` + +## Playing sound + +Sound can be enqueued using the helper functions that have been added by `enableAudioPlugin`. +Sounds are managed using a `QueuePlayer`, allowing for multiple to be queued, shuffled, etc. + +```js +const player = bot.helpers.getPlayer("your-guildid"); + +// Pushes a song to the end of the queue +// In this case it will stream directly from youtube +player.pushQuery("Obi-Wan - Hello there."); + +// Local files have to be raw pcm files with data in the following format: +// 2 channel, 16bit Little Endian @48kHz +player.pushQuery("./hello/world.pcm"); + +// Interrupts the current sound, resumes when done +player.interruptQuery("rEq1Z0bjdwc"); +``` + + +## Playing your own source + +Given that you have an audio source in the correct format you can play from any source that you want. + +IMPORTANT: +The data needs to be Opus encoded 2 channel 16bit Little Endian @48kHz. +You can use `encodePCMAudio` to encode PCM as Opus. +```js +// Create and play your own source! +const source = createAudioSource("Title", () => AsyncIterableIterator) +player.push(source); +``` +Or pass in your own `loadSource` function when enabling the plugin: +```js +const loadSource = (query: string) => { + return createAudioSource("Title", () => AsyncIterableIterator); +} +const bot = enableAudioPlugin(baseBot, loadSource); +player.pushQuery("Query to pass to loadSource"); +``` + + + +## Listening + +While receiving audio is not officially supported by Discord, it does work for now. + +```js +// Audio received is not a single stream! +// Each user is received separately +// Decoded audio is again 2 channel 16bit LE 48kHz pcm data +for await (const { user, decoded } from bot.helpers.onAudio("guildId")) { + ... +} + +// You can also filter out one or more users +for await (const { decoded } from bot.helpers.onAudio("guildId", "userid")) { + ... +} +``` diff --git a/discordeno-audio-plugin-main/deps.ts b/discordeno-audio-plugin-main/deps.ts new file mode 100644 index 0000000..03d4a1c --- /dev/null +++ b/discordeno-audio-plugin-main/deps.ts @@ -0,0 +1,12 @@ +export * from "https://deno.land/x/discordeno@17.1.0/mod.ts"; +export * from "https://deno.land/x/discordeno@17.1.0/plugins/cache/mod.ts"; +export * as opus from "https://unpkg.com/@evan/wasm@0.0.95/target/opus/deno.js"; +export * from "https://unpkg.com/@evan/wasm@0.0.95/target/nacl/deno.js"; +export { + default as ytdl, + getInfo, + downloadFromInfo, +} from "https://deno.land/x/ytdl_core@v0.1.1/mod.ts"; +export { ytDownload } from "https://deno.land/x/yt_download@1.1/mod.ts"; +export type { VideoFormat } from "https://deno.land/x/ytdl_core@v0.1.0/src/types.ts"; +export { default as YouTube } from "https://deno.land/x/youtube_sr@v4.3.4-deno/mod.ts"; diff --git a/discordeno-audio-plugin-main/mod.ts b/discordeno-audio-plugin-main/mod.ts new file mode 100644 index 0000000..a1ca67a --- /dev/null +++ b/discordeno-audio-plugin-main/mod.ts @@ -0,0 +1 @@ +export * from "./src/mod.ts"; diff --git a/discordeno-audio-plugin-main/src/audio-source/audio-source.ts b/discordeno-audio-plugin-main/src/audio-source/audio-source.ts new file mode 100644 index 0000000..9baf50f --- /dev/null +++ b/discordeno-audio-plugin-main/src/audio-source/audio-source.ts @@ -0,0 +1,35 @@ +let lastId = -1n; + +export type AudioSource = { + id: bigint; + title: string; + data: () => + | Promise> + | AsyncIterableIterator; + added_by?: string; +}; + +async function* empty() {} + +export function createAudioSource( + title: string, + data: () => + | Promise> + | AsyncIterableIterator, + added_by?: string +): AudioSource { + lastId++; + return { + id: lastId, + title, + data: () => { + try { + return data(); + } catch (error) { + console.error(error); + return empty(); + } + }, + added_by + }; +} diff --git a/discordeno-audio-plugin-main/src/audio-source/local.ts b/discordeno-audio-plugin-main/src/audio-source/local.ts new file mode 100644 index 0000000..9cf7161 --- /dev/null +++ b/discordeno-audio-plugin-main/src/audio-source/local.ts @@ -0,0 +1,14 @@ +import { createAudioSource } from "./audio-source.ts"; +import { encodePCMAudio } from "../encoding.ts"; +import { streamAsyncIterator } from "../../utils/mod.ts"; + +export function getLocalSources(...queries: string[]) { + return queries.map((query) => getLocalSource(query)); +} + +export function getLocalSource(query: string) { + //return createAudioSource(query, async () => { + //const file = await Deno.open(query, { read: true }); + //return encodePCMAudio(streamAsyncIterator(file.readable)); + //}); +} diff --git a/discordeno-audio-plugin-main/src/audio-source/mod.ts b/discordeno-audio-plugin-main/src/audio-source/mod.ts new file mode 100644 index 0000000..c3bf449 --- /dev/null +++ b/discordeno-audio-plugin-main/src/audio-source/mod.ts @@ -0,0 +1,4 @@ +export * from "./audio-source.ts"; +export * from "./universal.ts"; +export * from "./youtube.ts"; +export * from "./local.ts"; diff --git a/discordeno-audio-plugin-main/src/audio-source/universal.ts b/discordeno-audio-plugin-main/src/audio-source/universal.ts new file mode 100644 index 0000000..e237821 --- /dev/null +++ b/discordeno-audio-plugin-main/src/audio-source/universal.ts @@ -0,0 +1,13 @@ +import { getLocalSources } from "./local.ts"; +import { getYoutubeSources } from "./youtube.ts"; + +export type LoadSource = typeof loadLocalOrYoutube; + +export function loadLocalOrYoutube(query: string, added_by?: string) { + const local = query.startsWith("./"); + if (local) { + //return getLocalSources(query); + } else { + return getYoutubeSources(query, String(added_by)); + } +} diff --git a/discordeno-audio-plugin-main/src/audio-source/youtube.ts b/discordeno-audio-plugin-main/src/audio-source/youtube.ts new file mode 100644 index 0000000..cec747e --- /dev/null +++ b/discordeno-audio-plugin-main/src/audio-source/youtube.ts @@ -0,0 +1,47 @@ +import { YouTube, getInfo, downloadFromInfo, VideoFormat, ytdl, ytDownload } from "../../deps.ts"; +import { bufferIter } from "../../utils/mod.ts"; +import { demux } from "../demux/mod.ts"; +import { createAudioSource } from "./audio-source.ts"; + +function supportedFormatFilter(format: { + codecs: string; + container: string; + audioSampleRate?: string; +}) { + return ( + format.codecs === "opus" && + format.container === "webm" && + format.audioSampleRate === "48000" + ); +} + +export async function getYoutubeSources(added_by?: string, ...queries: string[]) { + const sources = queries.map((query) => getYoutubeSource(query, added_by)); + const awaitedSources = await Promise.all(sources); + return awaitedSources + .filter((source) => source !== undefined) + .map((source) => source!); +} + +export async function getYoutubeSource(query: string, added_by?: string) { + //const results = await YouTube.getVideo(query); + try { + const result = await ytdl(query, { filter: "audio" }); + const info = await getInfo(query!); + if (result) { + const title = info.player_response.videoDetails.title; + return createAudioSource(title!, async () => { + //const audio = await ytDownload(info.videoDetails.videoId.toString(), { + // mimeType: `audio/webm; codecs="opus"`, + //}); + const audio = await downloadFromInfo(info, { + filter: supportedFormatFilter, + }); + return bufferIter(demux(audio)); + }, added_by); + } + } catch(err) { + console.error(err); + return undefined; + } +} diff --git a/discordeno-audio-plugin-main/src/connection-data.ts b/discordeno-audio-plugin-main/src/connection-data.ts new file mode 100644 index 0000000..d325627 --- /dev/null +++ b/discordeno-audio-plugin-main/src/connection-data.ts @@ -0,0 +1,160 @@ +import { Bot } from "../deps.ts"; +import { EventSource } from "../utils/mod.ts"; +import { AudioSource, LoadSource, UdpArgs } from "./mod.ts"; +import { QueuePlayer } from "./player/mod.ts"; +import { sendAudioPacket } from "./udp/packet.ts"; + +export type BotData = { + bot: Bot; + guildData: Map; + udpSource: EventSource<[UdpArgs]>; + bufferSize: number; + loadSource: (query: string, added_by?: string) => AudioSource[] | Promise; +}; + +export type ConnectionData = { + player: QueuePlayer; + audio: EventSource<[Uint8Array]>; + guildId: bigint; + udpSocket: Deno.DatagramConn; + udpStream: () => AsyncIterableIterator; + ssrcToUser: Map; + usersToSsrc: Map; + context: { + ssrc: number; + ready: boolean; + speaking: boolean; + sequence: number; + timestamp: number; + }; + connectInfo: { + endpoint?: string; + sessionId?: string; + token?: string; + }; + stopHeart: () => void; + remote?: { port: number; hostname: string }; + ws?: WebSocket; + secret?: Uint8Array; + mode?: string; + resume?: boolean; +}; + +const connectionData = new Map(); + +/** + * Returns a random number that is in the range of n bits. + * + * @param n - The number of bits + */ +function randomNBit(n: number) { + return Math.floor(Math.random() * 2 ** n); +} + +export function createBotData( + bot: Bot, + udpSource: EventSource<[UdpArgs]>, + loadSource: LoadSource, + bufferSize = 10 +) { + const botData: BotData = { + bot, + guildData: new Map(), + udpSource, + bufferSize, + loadSource, + }; + connectionData.set(bot.id, botData); + return botData; +} + +export function getConnectionData(botId: bigint, guildId: bigint) { + const botData = connectionData.get(botId); + if (botData === undefined) { + throw "Bot first needs to be connected!"; + } + let data = botData.guildData.get(guildId); + if (data === undefined) { + let currentPort = 5000; + let listening = false; + let udpSocket: Deno.DatagramConn | undefined; + while (!listening) { + try { + udpSocket = Deno.listenDatagram({ + hostname: "0.0.0.0", + port: currentPort, + transport: "udp", + }); + listening = true; + } catch (_err) { + if (_err instanceof TypeError) { + throw new Error("Please enable unstable by adding --unstable."); + } + currentPort++; + } + } + udpSocket = udpSocket as Deno.DatagramConn; + const udpReceive = new EventSource<[Uint8Array]>(); + data = { + player: undefined as unknown as QueuePlayer, + guildId, + udpSocket, + udpStream: () => udpReceive.iter().map(([packet]) => packet), + context: { + ssrc: 1, + ready: false, + speaking: false, + sequence: randomNBit(16), + timestamp: randomNBit(32), + }, + connectInfo: {}, + audio: new EventSource<[Uint8Array]>(), + ssrcToUser: new Map(), + usersToSsrc: new Map(), + stopHeart: () => {}, + }; + data.player = new QueuePlayer(data, botData.loadSource); + botData.guildData.set(guildId, data); + connectSocketToSource( + botData.bot, + guildId, + udpSocket, + botData.udpSource, + udpReceive + ); + connectAudioIterable(data); + } + return data; +} + +async function connectAudioIterable(conn: ConnectionData) { + for await (const [chunk] of conn.audio.iter()) { + await sendAudioPacket(conn, chunk); + } +} + +export function getUserSSRC(conn: ConnectionData, user: bigint) { + return conn.usersToSsrc.get(user); +} + +export function getUserBySSRC(conn: ConnectionData, ssrc: number) { + return conn.ssrcToUser.get(ssrc); +} + +export function setUserSSRC(conn: ConnectionData, user: bigint, ssrc: number) { + conn.usersToSsrc.set(user, ssrc); + conn.ssrcToUser.set(ssrc, user); +} + +async function connectSocketToSource( + bot: Bot, + guildId: bigint, + socket: Deno.DatagramConn, + source: EventSource<[UdpArgs]>, + localSource: EventSource<[Uint8Array]> +) { + for await (const [data, _address] of socket) { + source.trigger({ bot, guildId, data }); + localSource.trigger(data); + } +} diff --git a/discordeno-audio-plugin-main/src/demux/demux-worker.ts b/discordeno-audio-plugin-main/src/demux/demux-worker.ts new file mode 100644 index 0000000..c5135ac --- /dev/null +++ b/discordeno-audio-plugin-main/src/demux/demux-worker.ts @@ -0,0 +1,20 @@ +import { WebmBaseDemuxer } from "./prism.js"; + +const demuxer = new WebmBaseDemuxer(); + +// @ts-ignore: Worker stuff +self.onmessage = (event) => { + const data = event.data; + demux(data).then((demuxed) => { + // @ts-ignore: Worker stuff + self.postMessage(demuxed); + }); +}; + +async function demux(value: Uint8Array) { + const result: Uint8Array[] = []; + for await (const demuxed of demuxer.demux(value)) { + result.push(demuxed); + } + return result; +} \ No newline at end of file diff --git a/discordeno-audio-plugin-main/src/demux/mod.ts b/discordeno-audio-plugin-main/src/demux/mod.ts new file mode 100644 index 0000000..e60480e --- /dev/null +++ b/discordeno-audio-plugin-main/src/demux/mod.ts @@ -0,0 +1,23 @@ +const workerUrl = new URL("./demux-worker.ts", import.meta.url).href; + +export async function* demux(source: AsyncIterable) { + const worker = new Worker(workerUrl, { + type: "module", + }); + try { + for await (const value of source) { + const nextValue = new Promise((resolve) => { + worker.onmessage = (e) => { + resolve(e.data); + }; + }); + worker.postMessage(value); + for (const demuxed of await nextValue) { + yield demuxed; + } + } + } catch (error) { + console.error(error); + } + worker.terminate(); +} \ No newline at end of file diff --git a/discordeno-audio-plugin-main/src/demux/prism.js b/discordeno-audio-plugin-main/src/demux/prism.js new file mode 100644 index 0000000..94ca54c --- /dev/null +++ b/discordeno-audio-plugin-main/src/demux/prism.js @@ -0,0 +1,232 @@ +import { arrayEquals } from "../../utils/mod.ts"; +const encoder = new TextEncoder(); +const OPUS_HEAD = encoder.encode("OpusHead"); + +/** + * This is a port of the demuxer found in prism-media + * Most of the code is similar but made compatible with Deno + * Instead of extending Transform stream it uses a generator function. + */ +export class WebmBaseDemuxer { + _remainder = null; + _length = 0; + _count = 0; + _skipUntil = null; + _track = null; + _incompleteTrack = {}; + _ebmlFound = false; + + *demux(chunk, headCB = () => {}) { + this._length += chunk.length; + if (this._remainder) { + chunk = new Uint8Array([...this._remainder, ...chunk]); + this._remainder = null; + } + let offset = 0; + if (this._skipUntil && this._length > this._skipUntil) { + offset = this._skipUntil - this._count; + this._skipUntil = null; + } else if (this._skipUntil) { + this._count += chunk.length; + return; + } + let result; + while (result !== TOO_SHORT) { + try { + result = this._readTag(chunk, offset); + if (result.data) { + yield result.data; + } + if (result.head) { + headCB(result.head); + } + } catch (error) { + console.log(error); + return; + } + if (result === TOO_SHORT) break; + if (result._skipUntil) { + this._skipUntil = result._skipUntil; + break; + } + if (result.offset) offset = result.offset; + else break; + } + this._count += offset; + this._remainder = chunk.slice(offset); + } + + /** + * Reads an EBML ID from a buffer. + * @private + * @param {Buffer} chunk the buffer to read from. + * @param {number} offset the offset in the buffer. + * @returns {Object|Symbol} contains an `id` property (buffer) and the new `offset` (number). + * Returns the TOO_SHORT symbol if the data wasn't big enough to facilitate the request. + */ + _readEBMLId(chunk, offset) { + const idLength = vintLength(chunk, offset); + if (idLength === TOO_SHORT) return TOO_SHORT; + return { + id: chunk.slice(offset, offset + idLength), + offset: offset + idLength, + }; + } + + /** + * Reads a size variable-integer to calculate the length of the data of a tag. + * @private + * @param {Buffer} chunk the buffer to read from. + * @param {number} offset the offset in the buffer. + * @returns {Object|Symbol} contains property `offset` (number), `dataLength` (number) and `sizeLength` (number). + * Returns the TOO_SHORT symbol if the data wasn't big enough to facilitate the request. + */ + _readTagDataSize(chunk, offset) { + const sizeLength = vintLength(chunk, offset); + if (sizeLength === TOO_SHORT) return TOO_SHORT; + const dataLength = expandVint(chunk, offset, offset + sizeLength); + return { offset: offset + sizeLength, dataLength, sizeLength }; + } + + /** + * Takes a buffer and attempts to read and process a tag. + * @private + * @param {Buffer} chunk the buffer to read from. + * @param {number} offset the offset in the buffer. + * @returns {Object|Symbol} contains the new `offset` (number) and optionally the `_skipUntil` property, + * indicating that the stream should ignore any data until a certain length is reached. + * Returns the TOO_SHORT symbol if the data wasn't big enough to facilitate the request. + */ + _readTag(chunk, offset) { + const idData = this._readEBMLId(chunk, offset); + if (idData === TOO_SHORT) return TOO_SHORT; + const ebmlID = [...idData.id] + .map((x) => x.toString(16).padStart(2, "0")) + .join(""); + if (!this._ebmlFound) { + if (ebmlID === "1a45dfa3") this._ebmlFound = true; + else throw Error("Did not find the EBML tag at the start of the stream"); + } + offset = idData.offset; + const sizeData = this._readTagDataSize(chunk, offset); + if (sizeData === TOO_SHORT) return TOO_SHORT; + const { dataLength } = sizeData; + offset = sizeData.offset; + // If this tag isn't useful, tell the stream to stop processing data until the tag ends + if (typeof TAGS[ebmlID] === "undefined") { + if (chunk.length > offset + dataLength) { + return { offset: offset + dataLength }; + } + return { offset, _skipUntil: this._count + offset + dataLength }; + } + + const tagHasChildren = TAGS[ebmlID]; + if (tagHasChildren) { + return { offset }; + } + + if (offset + dataLength > chunk.length) return TOO_SHORT; + const data = chunk.slice(offset, offset + dataLength); + if (!this._track) { + if (ebmlID === "ae") this._incompleteTrack = {}; + if (ebmlID === "d7") this._incompleteTrack.number = data[0]; + if (ebmlID === "83") this._incompleteTrack.type = data[0]; + if ( + this._incompleteTrack.type === 2 && + typeof this._incompleteTrack.number !== "undefined" + ) { + this._track = this._incompleteTrack; + } + } + const result = { offset: offset + dataLength }; + if (ebmlID === "63a2") { + this._checkHead(data); + result.head = data; + } else if (ebmlID === "a3") { + if (!this._track) throw Error("No audio track in this webm!"); + if ((data[0] & 0xf) === this._track.number) { + result.data = data.slice(4); + } + } + return result; + } + + _destroy(err, cb) { + this._cleanup(); + return cb ? cb(err) : undefined; + } + + _final(cb) { + this._cleanup(); + cb(); + } + + /** + * Cleans up the demuxer when it is no longer required. + * @private + */ + _cleanup() { + this._remainder = null; + this._incompleteTrack = {}; + } + _checkHead(data) { + if (!arrayEquals(data.slice(0, 8), OPUS_HEAD)) { + throw Error("Audio codec is not Opus!"); + } + } +} + +/** + * A symbol that is returned by some functions that indicates the buffer it has been provided is not large enough + * to facilitate a request. + * @name WebmBaseDemuxer#TOO_SHORT + * @memberof core + * @private + * @type {Symbol} + */ +const TOO_SHORT = (WebmBaseDemuxer.TOO_SHORT = Symbol("TOO_SHORT")); + +/** + * A map that takes a value of an EBML ID in hex string form, with the value being a boolean that indicates whether + * this tag has children. + * @name WebmBaseDemuxer#TAGS + * @memberof core + * @private + * @type {Object} + */ +const TAGS = (WebmBaseDemuxer.TAGS = { + // value is true if the element has children + "1a45dfa3": true, // EBML + 18538067: true, // Segment + "1f43b675": true, // Cluster + "1654ae6b": true, // Tracks + ae: true, // TrackEntry + d7: false, // TrackNumber + 83: false, // TrackType + a3: false, // SimpleBlock + "63a2": false, +}); + +function vintLength(buffer, index) { + if (index < 0 || index > buffer.length - 1) { + return TOO_SHORT; + } + let i = 0; + for (; i < 8; i++) if ((1 << (7 - i)) & buffer[index]) break; + i++; + if (index + i > buffer.length) { + return TOO_SHORT; + } + return i; +} + +function expandVint(buffer, start, end) { + const length = vintLength(buffer, start); + if (end > buffer.length || length === TOO_SHORT) return TOO_SHORT; + let mask = (1 << (8 - length)) - 1; + let value = buffer[start] & mask; + for (let i = start + 1; i < end; i++) { + value = (value << 8) + buffer[i]; + } + return value; +} diff --git a/discordeno-audio-plugin-main/src/encoding.ts b/discordeno-audio-plugin-main/src/encoding.ts new file mode 100644 index 0000000..649ca4d --- /dev/null +++ b/discordeno-audio-plugin-main/src/encoding.ts @@ -0,0 +1,51 @@ +import { opus } from "../deps.ts"; +import { CHANNELS, FRAME_SIZE, SAMPLE_RATE } from "./sample-consts.ts"; + +const encoder = new opus.Encoder({ + channels: CHANNELS, + sample_rate: SAMPLE_RATE, + application: "audio", +}); + +export function createAudioDecoder() { + const decoder = new opus.Decoder({ + channels: CHANNELS, + sample_rate: SAMPLE_RATE, + }); + let lastTimestamp = 0; + let last: Uint8Array; + return (audio: Uint8Array, timestamp: number) => { + if (lastTimestamp !== timestamp) { + lastTimestamp = timestamp; + last = decoder.decode(audio); + } + return last; + }; +} + +const decoders = new Map< + number, + (opus: Uint8Array, timestamp: number) => Uint8Array +>(); + +function getDecoder(ssrc: number) { + const decoder = decoders.get(ssrc) || createAudioDecoder(); + decoders.set(ssrc, decoder); + return decoder; +} + +export function decodeAudio( + audio: Uint8Array, + ssrc: number, + timestamp: number +) { + const decoder = getDecoder(ssrc); + return decoder(audio, timestamp); +} + +export async function* encodePCMAudio(audio: AsyncIterator) { + const gen = encoder.encode_pcm_stream(FRAME_SIZE, audio); + for await (const frame of gen) { + yield frame as Uint8Array; + } +} diff --git a/discordeno-audio-plugin-main/src/listen.ts b/discordeno-audio-plugin-main/src/listen.ts new file mode 100644 index 0000000..420b062 --- /dev/null +++ b/discordeno-audio-plugin-main/src/listen.ts @@ -0,0 +1,91 @@ +import { Bot } from "../deps.ts"; +import { asArray } from "../utils/array.ts"; +import { EventSource } from "../utils/event-source.ts"; +import { IterExpanded } from "../utils/iterator/util/iter-utils.ts"; +import { getConnectionData } from "./connection-data.ts"; +import { decodeAudio } from "./encoding.ts"; +import { stripRTP } from "./mod.ts"; + +type ReceivedAudio = { + bot: Bot; + user: bigint; + guildId: bigint; + raw: Uint8Array; + packet: { + rtp: { + version: number; + type: number; + sequence: number; + timestamp: number; + ssrc: number; + }; + nonce: Uint8Array; + data: Uint8Array; + }; + decoded: Uint8Array; +}; + +export function createOnAudio( + source: EventSource< + [ + { + bot: Bot; + guildId: bigint; + data: Uint8Array; + } + ] + > +) { + return ( + guild: bigint | bigint[], + user?: bigint | bigint[] + ): IterExpanded => { + const guilds = asArray(guild); + const users = asArray(user); + return source + .iter() + .map(([udp]) => udp) + .filter(({ data }) => { + return data[1] === 120; + }) + .filter(({ guildId }) => guilds.includes(guildId)) + .map((payload) => { + const conn = getConnectionData(payload.bot.id, payload.guildId); + try { + const packet = stripRTP(conn, payload.data); + const user = conn.ssrcToUser.get(packet.rtp.ssrc); + if (users.length > 0 && user && !users.includes(user)) { + return undefined; + } + const decoded = decodeAudio( + packet.data, + packet.rtp.ssrc, + packet.rtp.timestamp + ); + return { + bot: payload.bot, + user, + guildId: payload.guildId, + raw: payload.data, + packet, + decoded, + } as ReceivedAudio; + } catch { + console.log("Something is wrong..."); + return undefined; + } + }) + .filter((data) => data !== undefined) + .map((data) => data!) + .filter((data) => !silence(...data.decoded)); + }; +} + +function silence(...values: number[]) { + for (const value of values) { + if (value !== 0) { + return false; + } + } + return true; +} diff --git a/discordeno-audio-plugin-main/src/mod.ts b/discordeno-audio-plugin-main/src/mod.ts new file mode 100644 index 0000000..aac34ef --- /dev/null +++ b/discordeno-audio-plugin-main/src/mod.ts @@ -0,0 +1,91 @@ +import { asArray, EventSource } from "../utils/mod.ts"; +import { createBotData, getConnectionData } from "./connection-data.ts"; +import { QueuePlayer } from "./player/mod.ts"; +import { connectWebSocket } from "./websocket/mod.ts"; +import { Bot } from "../deps.ts"; +import { createOnAudio } from "./listen.ts"; +import { loadLocalOrYoutube, LoadSource } from "./audio-source/universal.ts"; + +export * from "./connection-data.ts"; +export * from "./websocket/mod.ts"; +export * from "./udp/mod.ts"; +export * from "./encoding.ts"; +export * from "./demux/mod.ts"; +export * from "./player/mod.ts"; +export * from "./audio-source/mod.ts"; + +export type UdpArgs = { + bot: Bot; + guildId: bigint; + data: Uint8Array; +}; + +export type AudioBot = T & { + helpers: ReturnType; +}; + +export function enableAudioPlugin( + bot: T, + loadSource = loadLocalOrYoutube +): AudioBot { + Object.assign(bot.helpers, createAudioHelpers(bot, loadSource)); + return bot as AudioBot; +} + +function createAudioHelpers(bot: Bot, loadSource: LoadSource) { + const udpSource = new EventSource<[UdpArgs]>(); + createBotData(bot, udpSource, loadSource); + const resetPlayer = (guildId: bigint) => { + const conn = getConnectionData(bot.id, guildId); + const oldPlayer = conn.player; + const oldQueue = asArray(oldPlayer.current()); + oldQueue.push(...oldPlayer.upcoming()); + conn.player = new QueuePlayer(conn, loadSource); + conn.player.push(...oldQueue); + oldPlayer.stopInterrupt(); + oldPlayer.stop(); + conn.player.loop(oldPlayer.looping); + return conn.player; + }; + const oldStateListener = bot.events.voiceStateUpdate; + bot.events.voiceStateUpdate = (bot, event) => { + const { guildId, userId, sessionId } = event; + if (bot.id === userId && guildId) { + const conn = getConnectionData(bot.id, guildId); + conn.connectInfo.sessionId = sessionId; + connectWebSocket(conn, bot.id, guildId); + } + oldStateListener(bot, event); + }; + const oldServerListener = bot.events.voiceServerUpdate; + bot.events.voiceServerUpdate = (bot, event) => { + const { guildId, endpoint, token } = event; + const conn = getConnectionData(bot.id, guildId); + if ( + conn.connectInfo.endpoint === endpoint && + conn.connectInfo.token === token + ) { + return; + } + conn.connectInfo.endpoint = endpoint; + conn.connectInfo.token = token; + connectWebSocket(conn, bot.id, guildId); + oldServerListener(bot, event); + }; + return { + getPlayer: (guildId: bigint) => { + const conn = getConnectionData(bot.id, guildId); + return conn.player; + }, + resetPlayer, + fixAudio: (guildId: bigint) => { + resetPlayer(guildId); + const conn = getConnectionData(bot.id, guildId); + conn.ws?.close(); + }, + /** + * Creates an async iterable with decoded audio packets + */ + onAudio: createOnAudio(udpSource), + }; +} diff --git a/discordeno-audio-plugin-main/src/player/mod.ts b/discordeno-audio-plugin-main/src/player/mod.ts new file mode 100644 index 0000000..d865d1c --- /dev/null +++ b/discordeno-audio-plugin-main/src/player/mod.ts @@ -0,0 +1,3 @@ +export * from "./types.ts"; +export * from "./queue-player.ts"; +export * from "./raw-player.ts"; diff --git a/discordeno-audio-plugin-main/src/player/queue-player.ts b/discordeno-audio-plugin-main/src/player/queue-player.ts new file mode 100644 index 0000000..c424e58 --- /dev/null +++ b/discordeno-audio-plugin-main/src/player/queue-player.ts @@ -0,0 +1,104 @@ +import { Queue } from "../../utils/mod.ts"; +import { AudioSource, LoadSource } from "../audio-source/mod.ts"; +import { ConnectionData } from "../connection-data.ts"; +import { RawPlayer } from "./raw-player.ts"; +import { Player } from "./types.ts"; + +import { nowPlayingCallback } from "../../../commands/np.ts"; + +export class QueuePlayer extends Queue implements Player { + playing = false; + looping = false; + playingSince?: number; + nowPlaying?: AudioSource; + #rawPlayer: RawPlayer; + #loadSource: LoadSource; + + constructor(conn: ConnectionData, loadSource: LoadSource) { + super(); + this.#loadSource = loadSource; + this.#rawPlayer = new RawPlayer(conn); + this.#startQueue(); + this.playing = true; + super.waiting = true; + } + + async #setSong(song: AudioSource) { + this.playingSince = Date.now(); + this.nowPlaying = song; + this.#rawPlayer.setAudio(await song.data()); + await nowPlayingCallback(this.#rawPlayer.conn); + await this.#rawPlayer.onDone(); + if (this.looping) { + this.#setSong(song); + } else { + this.triggerNext(); + } + } + + async #startQueue() { + for await (const [song] of this.stream()) { + await this.#setSong(song); + } + } + + play() { + this.#rawPlayer.play(); + this.playing = true; + return Promise.resolve(); + } + + pause() { + this.playing = false; + this.#rawPlayer.pause(); + } + + stop() { + this.playingSince = undefined; + this.#rawPlayer.clear(); + this.pause(); + } + + skip() { + this.#rawPlayer.clear(); + } + + loop(value: boolean) { + this.looping = value; + } + + stopInterrupt() { + this.#rawPlayer.interrupt(undefined); + } + + interrupt(audio: AsyncIterableIterator) { + this.#rawPlayer.interrupt(audio); + } + + /** + * Interrupts the current song, resumes when finished + * @param query Loads a universal song (local file or youtube search) + */ + async interruptQuery(query: string) { + const sources = await this.#loadSource(query as string); + this.#rawPlayer.interrupt(await sources[0].data()); + } + + async pushQuery(added_by?: string, ...queries: string[]) { + const sources = []; + for (const query of queries) { + sources.push(...(await this.#loadSource(String(added_by), query as string))); + this.push(...sources); + } + return sources; + } + + async unshiftQuery(...queries: string[]) { + const sources = []; + for (const query of queries) { + sources.push(...(await this.#loadSource(query as string))); + this.unshift(...sources); + } + return sources; + } +} diff --git a/discordeno-audio-plugin-main/src/player/raw-player.ts b/discordeno-audio-plugin-main/src/player/raw-player.ts new file mode 100644 index 0000000..1619718 --- /dev/null +++ b/discordeno-audio-plugin-main/src/player/raw-player.ts @@ -0,0 +1,85 @@ +import { EventSource, wait } from "../../utils/mod.ts"; +import { ConnectionData } from "../connection-data.ts"; +import { FRAME_DURATION } from "../sample-consts.ts"; +import { Player } from "./types.ts"; +import { setDriftlessInterval, clearDriftless } from "npm:driftless"; + +export class RawPlayer implements Player { + #audio?: AsyncIterableIterator; + #interrupt?: AsyncIterableIterator; + playing = false; + conn: ConnectionData; + #doneSource = new EventSource<[]>(); + #nextSource = new EventSource<[]>(); + #onNext = () => this.#nextSource.iter().nextValue(); + + constructor(conn: ConnectionData) { + this.conn = conn; + this.play(); + } + + setAudio(audio: AsyncIterableIterator) { + this.#audio = audio; + this.#nextSource.trigger(); + } + + interrupt(audio?: AsyncIterableIterator) { + this.#interrupt = audio; + if (this.#audio === undefined) { + this.#nextSource.trigger(); + } + } + + play() { + try { + if (this.playing) { + return; + } + this.playing = true; + + const inter = setDriftlessInterval(async () => { + if (this.playing === false) { + clearDriftless(inter); + } + if (this.#interrupt) { + const { done, value } = await this.#interrupt.next(); + if (done) { + this.#interrupt = undefined; + } else { + this.conn.audio.trigger(value); + return; + } + } + const nextAudioIter = await this.#audio?.next(); + if (nextAudioIter === undefined || nextAudioIter.done) { + this.#audio = undefined; + this.#doneSource.trigger(); + await this.#onNext(); + return; + } + this.conn.audio.trigger(nextAudioIter.value); + }, FRAME_DURATION); + } catch(err) { + console.log("error!!"); + console.error(err); + } + } + + pause() { + this.playing = false; + } + + stop() { + this.pause(); + this.clear(); + } + + clear() { + this.#audio = undefined; + this.#interrupt = undefined; + } + + async onDone() { + await this.#doneSource.iter().nextValue(); + } +} diff --git a/discordeno-audio-plugin-main/src/player/types.ts b/discordeno-audio-plugin-main/src/player/types.ts new file mode 100644 index 0000000..d9610c7 --- /dev/null +++ b/discordeno-audio-plugin-main/src/player/types.ts @@ -0,0 +1,8 @@ +export type Player = { + playing: boolean; + play(): void; + pause(): void; + stop(): void; + clear(): void; + interrupt(audio: AsyncIterableIterator): void; +}; diff --git a/discordeno-audio-plugin-main/src/sample-consts.ts b/discordeno-audio-plugin-main/src/sample-consts.ts new file mode 100644 index 0000000..2d43494 --- /dev/null +++ b/discordeno-audio-plugin-main/src/sample-consts.ts @@ -0,0 +1,4 @@ +export const SAMPLE_RATE = 48000; +export const FRAME_DURATION = 20; +export const CHANNELS = 2; +export const FRAME_SIZE = (SAMPLE_RATE * FRAME_DURATION) / 1000; diff --git a/discordeno-audio-plugin-main/src/udp/code-and-crypt.ts b/discordeno-audio-plugin-main/src/udp/code-and-crypt.ts new file mode 100644 index 0000000..2c17c4a --- /dev/null +++ b/discordeno-audio-plugin-main/src/udp/code-and-crypt.ts @@ -0,0 +1,23 @@ +import { secretbox } from "../../deps.ts"; + +export function encryptPacket( + secret: Uint8Array, + packet: Uint8Array, + nonce: Uint8Array +) { + if (!secret) { + throw "Secret is not known!"; + } + return secretbox.seal(packet, secret, nonce); +} + +export function decryptPacket( + secret: Uint8Array, + packet: Uint8Array, + nonce: Uint8Array +) { + if (!secret) { + throw "Secret is not known!"; + } + return secretbox.open(packet, secret, nonce); +} diff --git a/discordeno-audio-plugin-main/src/udp/discover.ts b/discordeno-audio-plugin-main/src/udp/discover.ts new file mode 100644 index 0000000..715da1b --- /dev/null +++ b/discordeno-audio-plugin-main/src/udp/discover.ts @@ -0,0 +1,32 @@ +import { ConnectionData } from "../connection-data.ts"; + +export async function discoverIP( + conn: ConnectionData, + ssrc: string, + hostname: string, + port: number +) { + const discover = createDiscoverPacket(ssrc); + await conn.udpSocket.send(discover, { + hostname, + port, + transport: "udp", + }); + const { value } = await conn.udpStream().next(); + const decoder = new TextDecoder(); + const localIp = decoder.decode(value.slice(8, value.indexOf(0, 8))); + const localPort = new DataView(value.buffer).getUint16(72, false); + return { ip: localIp, port: localPort }; +} + +function createDiscoverPacket(ssrc: string): Uint8Array { + const buffer = new ArrayBuffer(74); + const header_data = new DataView(buffer); + let offset = 0; + header_data.setInt16(offset, 1, false); + offset += 2; + header_data.setInt16(offset, 70, false); + offset += 2; + header_data.setInt32(offset, Number.parseInt(ssrc), false); + return new Uint8Array(buffer); +} diff --git a/discordeno-audio-plugin-main/src/udp/mod.ts b/discordeno-audio-plugin-main/src/udp/mod.ts new file mode 100644 index 0000000..8902015 --- /dev/null +++ b/discordeno-audio-plugin-main/src/udp/mod.ts @@ -0,0 +1,3 @@ +export * from "./discover.ts"; +export * from "./speaking.ts"; +export * from "./packet.ts"; diff --git a/discordeno-audio-plugin-main/src/udp/packet.ts b/discordeno-audio-plugin-main/src/udp/packet.ts new file mode 100644 index 0000000..7c520de --- /dev/null +++ b/discordeno-audio-plugin-main/src/udp/packet.ts @@ -0,0 +1,84 @@ +import { ConnectionData } from "../connection-data.ts"; +import { FRAME_SIZE } from "../sample-consts.ts"; +import { decryptPacket, encryptPacket } from "./code-and-crypt.ts"; + +export function stripRTP(conn: ConnectionData, packet: Uint8Array) { + const dataView = new DataView(packet.buffer); + const rtp = { + version: dataView.getUint8(0), + type: dataView.getUint8(1), + sequence: dataView.getUint16(2, false), + timestamp: dataView.getUint32(4, false), + ssrc: dataView.getUint32(8, false), + }; + if (!conn.secret) { + throw "Secret is not known!"; + } + + const nonce = new Uint8Array([...packet.slice(0, 12), ...new Uint8Array(12)]); + const encrypted = packet.slice(12, packet.length); + let data = decryptPacket(conn.secret, encrypted, nonce); + + const view = new DataView(data.buffer); + if (data[0] === 0xbe && data[1] === 0xde && data.length > 4) { + const length = view.getUint16(2, false); + // let offset = 4; + // for (let i = 0; i < length; i++) { + // const byte = view.getUint8(offset); + // if (byte === 0) continue; + // offset += 1 + (byte & 15); + // } + if (length === 1) { + data = data.slice(8); // IDK when actually calculating it should be 6 offset but it is 8... + } + } + return { rtp, nonce, data }; +} + +export function addRTP(conn: ConnectionData, packet: Uint8Array) { + const { + secret, + context: { sequence, timestamp, ssrc }, + } = conn; + if (!ssrc) { + throw "SSRC is not known!"; + } + if (!secret) { + throw "Secret is not known!"; + } + const rtp = new Uint8Array(12); + const rtpView = new DataView(rtp.buffer); + rtpView.setUint8(0, 0x80); + rtpView.setUint8(1, 0x78); + rtpView.setUint16(2, sequence, false); + rtpView.setUint32(4, timestamp, false); + rtpView.setUint32(8, ssrc, false); + + const nonce = new Uint8Array([...rtp, ...new Uint8Array(12)]); + + const encrypted = encryptPacket(secret, packet, nonce); + return new Uint8Array([...rtp, ...encrypted]); +} + +export function incrementAudioMetaData(context: ConnectionData["context"]) { + context.sequence++; + context.timestamp += FRAME_SIZE; + context.sequence %= 2 ** 16; + context.timestamp %= 2 ** 32; +} + +export async function sendAudioPacket(conn: ConnectionData, audio: Uint8Array) { + if (!conn || !conn.udpSocket || !conn.remote || !conn.context.ready) { + return; + } + incrementAudioMetaData(conn.context); + try { + const packet = addRTP(conn, audio); + const result = await conn.udpSocket.send(packet, { + ...conn.remote, + transport: "udp", + }); + } catch (error) { + console.log(`Packet not send, ${error}`); + } +} diff --git a/discordeno-audio-plugin-main/src/udp/speaking.ts b/discordeno-audio-plugin-main/src/udp/speaking.ts new file mode 100644 index 0000000..feb0985 --- /dev/null +++ b/discordeno-audio-plugin-main/src/udp/speaking.ts @@ -0,0 +1,22 @@ +import { VoiceOpcodes } from "../../deps.ts"; +import { ConnectionData } from "../connection-data.ts"; + +export function setSpeaking( + { ws, context }: ConnectionData, + speaking: boolean +) { + if (context.speaking === speaking || ws?.readyState !== WebSocket.OPEN) { + return; + } + context.speaking = speaking; + ws?.send( + JSON.stringify({ + op: VoiceOpcodes.Speaking, + d: { + speaking: speaking ? 1 : 0, + delay: 0, + ssrc: context.ssrc, + }, + }) + ); +} diff --git a/discordeno-audio-plugin-main/src/websocket/handlers.ts b/discordeno-audio-plugin-main/src/websocket/handlers.ts new file mode 100644 index 0000000..2d0d53d --- /dev/null +++ b/discordeno-audio-plugin-main/src/websocket/handlers.ts @@ -0,0 +1,90 @@ +import { VoiceOpcodes } from "../../deps.ts"; +import { ConnectionData, setUserSSRC } from "../connection-data.ts"; +import { discoverIP } from "../udp/discover.ts"; +import { setSpeaking } from "../udp/speaking.ts"; +import { sendHeart } from "./heartbeat.ts"; + +export enum ServerVoiceOpcodes { + /** Complete the websocket handshake. */ + Ready = VoiceOpcodes.Ready, + /** Describe the session. */ + SessionDescription = VoiceOpcodes.SessionDescription, + /** Indicate which users are speaking. */ + Speaking = VoiceOpcodes.Speaking, + /** Sent to acknowledge a received client heartbeat. */ + HeartbeatACK = VoiceOpcodes.HeartbeatACK, + /** Time to wait between sending heartbeats in milliseconds. */ + Hello = VoiceOpcodes.Hello, + /** Acknowledge a successful session resume. */ + Resumed = VoiceOpcodes.Resumed, + /** A client has disconnected from the voice channel */ + ClientDisconnect = VoiceOpcodes.ClientDisconnect, + /** Weird OP code containing video and audio codecs */ + Undocumented = 14, +} + +export const socketHandlers: Record< + ServerVoiceOpcodes, + (connectionData: ConnectionData, d: any) => void +> = { + [ServerVoiceOpcodes.Ready]: ready, + [ServerVoiceOpcodes.SessionDescription]: sessionDescription, + [ServerVoiceOpcodes.Speaking]: speaking, + [ServerVoiceOpcodes.HeartbeatACK]: heartbeatACK, + [ServerVoiceOpcodes.Hello]: hello, + [ServerVoiceOpcodes.Resumed]: resumed, + [ServerVoiceOpcodes.ClientDisconnect]: clientDisconnect, + [ServerVoiceOpcodes.Undocumented]: undocumented, +}; + +function hello(conn: ConnectionData, d: { heartbeat_interval: number }) { + conn.stopHeart = sendHeart(conn.ws!, d.heartbeat_interval); +} + +function ready(conn: ConnectionData, d: any) { + const { ssrc, port, ip } = d; + conn.context.ssrc = ssrc; + conn.remote = { port, hostname: ip }; + discoverIP(conn, ssrc, ip, port).then((info) => { + if (conn.ws?.readyState === WebSocket.OPEN) { + conn.ws.send( + JSON.stringify({ + op: VoiceOpcodes.SelectProtocol, + d: { + protocol: "udp", + data: { + address: info.ip, + port: info.port, + mode: "xsalsa20_poly1305", + }, + }, + }) + ); + } + }); +} + +function resumed(conn: ConnectionData) { + console.log("Resumed success"); + conn.context.ready = true; +} + +function sessionDescription(conn: ConnectionData, d: any) { + const secret = d.secret_key; + const mode = d.mode; + conn.secret = new Uint8Array(secret); + conn.mode = mode; + conn.context.ready = true; + setSpeaking(conn, true); +} + +function speaking(conn: ConnectionData, d: any) { + const user_id = BigInt(d.user_id); + const ssrc = Number(d.ssrc); + setUserSSRC(conn, user_id, ssrc); +} +function heartbeatACK() {} + +function clientDisconnect() {} + +function undocumented() {} diff --git a/discordeno-audio-plugin-main/src/websocket/heartbeat.ts b/discordeno-audio-plugin-main/src/websocket/heartbeat.ts new file mode 100644 index 0000000..ab49463 --- /dev/null +++ b/discordeno-audio-plugin-main/src/websocket/heartbeat.ts @@ -0,0 +1,33 @@ +import { VoiceOpcodes } from "../../deps.ts"; +import { setDriftlessTimeout } from "npm:driftless"; + +function createHeartBeat(time: number) { + return JSON.stringify({ + op: VoiceOpcodes.Heartbeat, + d: time, + }); +} + +export function sendHeart(ws: WebSocket, interval: number) { + let last = Date.now(); + let timestamp = 0; + const heartbeat = createHeartBeat(timestamp); + if (ws.readyState === WebSocket.OPEN) { + ws.send(heartbeat); + } + let done = false; + const repeatBeat = () => { + if (done || ws.readyState !== WebSocket.OPEN) { + return; + } + timestamp += interval; + const heartbeat = createHeartBeat(timestamp); + last = Date.now(); + ws.send(heartbeat); + setDriftlessTimeout(repeatBeat, interval + (last - Date.now())); + }; + setDriftlessTimeout(repeatBeat, interval + (last - Date.now())); + return () => { + done = true; + }; +} diff --git a/discordeno-audio-plugin-main/src/websocket/mod.ts b/discordeno-audio-plugin-main/src/websocket/mod.ts new file mode 100644 index 0000000..b4907f3 --- /dev/null +++ b/discordeno-audio-plugin-main/src/websocket/mod.ts @@ -0,0 +1,105 @@ +import { VoiceOpcodes } from "../../deps.ts"; +import { ConnectionData } from "../connection-data.ts"; +import { ServerVoiceOpcodes, socketHandlers } from "./handlers.ts"; + +export function connectWebSocket( + conn: ConnectionData, + userId: bigint, + guildId: bigint +) { + console.log("trying to connect the web socket"); + conn.context.ready = false; + const { token, sessionId, endpoint } = conn.connectInfo; + if ( + token === undefined || + sessionId === undefined || + endpoint === undefined + ) { + return; + } + const ws = new WebSocket(`wss://${endpoint}?v=4`); + conn.ws = ws; + const identifyRequest = JSON.stringify({ + op: VoiceOpcodes.Identify, + d: { + server_id: guildId.toString(), + user_id: userId.toString(), + session_id: sessionId, + token, + }, + }); + const resumeRequest = JSON.stringify({ + op: VoiceOpcodes.Resume, + d: { + server_id: guildId.toString(), + session_id: sessionId, + token, + }, + }); + const open = () => { + handleOpen(conn, identifyRequest, resumeRequest); + }; + const message = (event: MessageEvent) => { + handleMessage(conn, event); + }; + const error = () => handleError(conn); + const close = (event: CloseEvent) => { + ws.removeEventListener("open", open); + ws.removeEventListener("message", message); + ws.removeEventListener("close", close); + ws.removeEventListener("error", error); + handleClose(conn, event, userId, guildId); + }; + ws.addEventListener("open", open); + ws.addEventListener("message", message); + ws.addEventListener("close", close); + ws.addEventListener("error", error); +} + +function handleOpen( + conn: ConnectionData, + identifyRequest: string, + resumeRequest: string +) { + if (conn.ws?.readyState !== WebSocket.OPEN) { + return; + } + conn.ws.send(conn.resume ? resumeRequest : identifyRequest); + conn.resume = false; +} + +function handleMessage(conn: ConnectionData, ev: MessageEvent) { + const data = JSON.parse(ev.data); + socketHandlers[data.op as ServerVoiceOpcodes](conn, data.d); +} + +function handleClose( + conn: ConnectionData, + event: CloseEvent, + userId: bigint, + guildId: bigint +) { + conn.stopHeart(); + conn.context.ready = false; + if (event.code < 4000) { + console.log("The socket lost its connection for some reason"); + conn.resume = true; + connectWebSocket(conn, userId, guildId); + } else if (event.code === 4014) { + conn.context.speaking = false; + } else if (event.code === 4006) { + conn.context.speaking = false; + } +} + +/** + * Errors are scary just drop connection. + * Hope it reconnects😬 + * @param event + * @param conn + * @param userId + * @param guildId + */ +function handleError(conn: ConnectionData) { + conn.ws?.close(); +} diff --git a/discordeno-audio-plugin-main/utils/array.ts b/discordeno-audio-plugin-main/utils/array.ts new file mode 100644 index 0000000..4c356c8 --- /dev/null +++ b/discordeno-audio-plugin-main/utils/array.ts @@ -0,0 +1,97 @@ +import { clamp } from "./number.ts"; + +export function arrayEquals(a: T, b: J) { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; + } + } + return true; +} + +export function asArray(value?: T | T[]): T[] { + if (value === undefined) { + return []; + } + return Array.isArray(value) ? [...value] : [value]; +} + +export function arrayMove( + array: T[] | undefined, + from: number, + to: number +): T[] { + const result = spread(array); + const min = 0; + const max = result.length; + clamp(from, min, max); + clamp(from, min, max); + result.splice(to, 0, result.splice(from, 1)[0]); + return result; +} + +export function arrayShuffle(array: T[]) { + let currentIndex = array.length, + randomIndex; + while (currentIndex != 0) { + randomIndex = Math.floor(Math.random() * currentIndex); + currentIndex--; + [array[currentIndex], array[randomIndex]] = [ + array[randomIndex], + array[currentIndex], + ]; + } + return array; +} + +export function spread(array: T[] | undefined, ...additional: T[]): T[] { + return [...(array || []), ...additional]; +} + +export function combine(array: T[] | undefined, add: T[] | undefined): T[] { + return [...(array || []), ...(add || [])]; +} + +export function repeat(amount: number, ...value: T[]): T[] { + let result: T[] = []; + while (amount > 0) { + amount--; + result = [...result, ...value]; + } + return result; +} + +export function remove(array: T[] | undefined, ...toRemove: T[]): T[] { + const result = spread(array); + for (const entry of toRemove) { + const index = result.indexOf(entry); + result.splice(index, 1); + } + return result; +} + +export function findRemove( + array: T[] | undefined, + equals: (first: T, second: T) => boolean, + ...toRemove: T[] +): T[] { + const result = spread(array); + for (const entry of toRemove) { + const index = result.findIndex((other) => equals(entry, other)); + result.splice(index, 1); + } + return result; +} + +export function diff(first: T[], second: T[]): T[] { + const result = []; + for (const item of second) { + if (!first.includes(item)) { + result.push(item); + } + } + return result; +} diff --git a/discordeno-audio-plugin-main/utils/buffer.ts b/discordeno-audio-plugin-main/utils/buffer.ts new file mode 100644 index 0000000..8b8a77d --- /dev/null +++ b/discordeno-audio-plugin-main/utils/buffer.ts @@ -0,0 +1,34 @@ +export async function* bufferIter( + iterator: AsyncIterableIterator, + size = 60 +) { + const buffer: T[] = []; + for (let i = 0; i < size; i++) { + const result = await iterator.next(); + if (result.done) { + break; + } else { + buffer.push(result.value); + } + } + let done = false; + while (!done || buffer.length > 0) { + if (buffer.length > 0) { + yield buffer.shift()!; + iterator.next().then((result) => { + if (result.done) { + done = true; + } else { + buffer.push(result.value); + } + }); + } else { + const result = await iterator.next(); + if (result.done) { + return; + } else { + yield result.value; + } + } + } +} diff --git a/discordeno-audio-plugin-main/utils/event-source.ts b/discordeno-audio-plugin-main/utils/event-source.ts new file mode 100644 index 0000000..21f90a2 --- /dev/null +++ b/discordeno-audio-plugin-main/utils/event-source.ts @@ -0,0 +1,36 @@ +import { IterSource, fromCallback } from "./iterator/mod.ts"; +import { Arr } from "./types.ts"; + +type Listener = (...arg: T) => void; + +export class EventSource { + listeners: Listener[] = []; + iter: IterSource["iterator"]; + disconnect: IterSource["disconnect"]; + + constructor() { + const { iterator, disconnect } = fromCallback((listener) => + this.addListener(listener) + ); + this.iter = iterator; + this.disconnect = disconnect; + } + + trigger(...arg: T) { + for (const listener of this.listeners) { + listener(...arg); + } + } + + addListener(listener: Listener) { + this.listeners.push(listener); + return () => { + this.removeListener(listener); + }; + } + + removeListener(listener: Listener) { + const index = this.listeners.indexOf(listener); + this.listeners.splice(index, 1); + } +} diff --git a/discordeno-audio-plugin-main/utils/file.ts b/discordeno-audio-plugin-main/utils/file.ts new file mode 100644 index 0000000..3e551af --- /dev/null +++ b/discordeno-audio-plugin-main/utils/file.ts @@ -0,0 +1,14 @@ +export async function* streamAsyncIterator(stream: ReadableStream) { + // Get a lock on the stream + const reader = stream.getReader(); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) return; + yield value; + } + } finally { + reader.releaseLock(); + } +} diff --git a/discordeno-audio-plugin-main/utils/iterator/mod.ts b/discordeno-audio-plugin-main/utils/iterator/mod.ts new file mode 100644 index 0000000..b47acdd --- /dev/null +++ b/discordeno-audio-plugin-main/utils/iterator/mod.ts @@ -0,0 +1,56 @@ +import { Arr } from "../types.ts"; +import { pushIter, addIterUtils } from "./util/mod.ts"; + +type Listener = { push: (arg: T) => void; done: () => void }; +export type IterSource = ReturnType>; + +export function fromCallback( + source: (listener: (...values: T) => void) => void, + disconnect?: () => void +) { + let isDone = false; + let listeners: Listener[] = []; + + function trigger(...values: T) { + if (isDone) { + return; + } + for (const listener of listeners) { + listener.push(values); + } + } + source(trigger); + + function done() { + disconnect?.(); + if (isDone) { + return; + } + for (const listener of listeners) { + listener.done(); + } + listeners = []; + isDone = true; + } + + function addListener(listener: Listener) { + listeners.push(listener); + return () => { + removeListener(listener); + }; + } + + function removeListener(listener: Listener) { + const index = listeners.indexOf(listener); + listeners.splice(index, 1); + } + + return { + iterator: () => { + const { push, done, getIterator } = pushIter(); + addListener({ push, done }); + return addIterUtils(getIterator()); + }, + disconnect: done, + }; +} diff --git a/discordeno-audio-plugin-main/utils/iterator/util/combine.ts b/discordeno-audio-plugin-main/utils/iterator/util/combine.ts new file mode 100644 index 0000000..8337adc --- /dev/null +++ b/discordeno-audio-plugin-main/utils/iterator/util/combine.ts @@ -0,0 +1,20 @@ +// import { EventSource } from "../event-source.ts"; + +// type CombineReturn = J extends AsyncIterableIterator[] +// ? I +// : unknown; + +// export function combineIter[]>( +// ...iterators: J +// ) { +// const source = new EventSource>(); +// const handler = (value: CombineReturn) => { +// source.trigger(value); +// }; +// iterators.forEach(async (iterator) => { +// for await (const value of iterator) { +// handler(value); +// } +// }); +// return source.stream(); +// } diff --git a/discordeno-audio-plugin-main/utils/iterator/util/iter-utils.ts b/discordeno-audio-plugin-main/utils/iterator/util/iter-utils.ts new file mode 100644 index 0000000..06f8422 --- /dev/null +++ b/discordeno-audio-plugin-main/utils/iterator/util/iter-utils.ts @@ -0,0 +1,42 @@ + +export type IterExpanded = ReturnType>; + +export function addIterUtils(values: AsyncIterableIterator) { + return Object.assign(values, { map, filter, nextValue }); +} + +async function nextValue(this: AsyncIterableIterator): Promise { + const result = await this.next(); + return result.value; +} + +function map(this: AsyncIterableIterator, map: (from: T) => J) { + const mapGen = async function* (this: AsyncIterableIterator) { + for await (const value of this) { + yield map(value); + } + }; + return addIterUtils(mapGen.bind(this)()); +} + +function filter( + this: AsyncIterableIterator, + filter: (value: T) => boolean +) { + const filterGen = async function* (this: AsyncIterableIterator) { + for await (const value of this) { + if (filter(value)) { + yield value; + } + } + }; + const boundFilter = filterGen.bind(this); + return addIterUtils(boundFilter()); +} + +// function combine[]>( +// this: AsyncIterableIterator, +// ...others: J +// ) { +// return combineIter(this, ...others); +// } diff --git a/discordeno-audio-plugin-main/utils/iterator/util/mod.ts b/discordeno-audio-plugin-main/utils/iterator/util/mod.ts new file mode 100644 index 0000000..c72a5c0 --- /dev/null +++ b/discordeno-audio-plugin-main/utils/iterator/util/mod.ts @@ -0,0 +1,2 @@ +export * from "./iter-utils.ts"; +export * from "./push-iter.ts"; diff --git a/discordeno-audio-plugin-main/utils/iterator/util/push-iter.ts b/discordeno-audio-plugin-main/utils/iterator/util/push-iter.ts new file mode 100644 index 0000000..d15f5fd --- /dev/null +++ b/discordeno-audio-plugin-main/utils/iterator/util/push-iter.ts @@ -0,0 +1,59 @@ +export type GetNext = () => Promise, void>>; + +export function pushIter() { + type Value = IteratorResult; + const values: Value[] = []; + let resolvers: ((value: Value) => void)[] = []; + const pushValue = (value: T) => { + if (resolvers.length <= 0) { + values.push({ done: false, value }); + } + for (const resolve of resolvers) { + resolve({ done: false, value }); + } + resolvers = []; + }; + const pushDone = () => { + if (resolvers.length <= 0) { + values.push({ done: true, value: undefined }); + } + for (const resolve of resolvers) { + resolve({ done: true, value: undefined }); + } + resolvers = []; + }; + const pullValue = () => { + return new Promise((resolve) => { + if (values.length > 0) { + const value = values.shift(); + resolve(value!); + } else { + resolvers.push(resolve); + } + }); + }; + return { + push: pushValue, + done: pushDone, + getIterator: () => iterFromPull(pullValue), + getIterNext: (): GetNext => { + const iter = iterFromPull(pullValue); + return () => { + return iter.next(); + }; + }, + }; +} + +async function* iterFromPull( + pullValue: () => Promise> +) { + let done; + while (!done) { + const result = await pullValue(); + done = result.done; + if (!result.done) { + yield result.value; + } + } +} diff --git a/discordeno-audio-plugin-main/utils/mod.ts b/discordeno-audio-plugin-main/utils/mod.ts new file mode 100644 index 0000000..315b29b --- /dev/null +++ b/discordeno-audio-plugin-main/utils/mod.ts @@ -0,0 +1,9 @@ +export * from "./iterator/mod.ts"; +export * from "./array.ts"; +export * from "./event-source.ts"; +export * from "./queue.ts"; +export * from "./number.ts"; +export * from "./wait.ts"; +export * from "./file.ts"; +export * from "./buffer.ts"; +// export * from "./buffered-iterator.ts"; diff --git a/discordeno-audio-plugin-main/utils/number.ts b/discordeno-audio-plugin-main/utils/number.ts new file mode 100644 index 0000000..0210f95 --- /dev/null +++ b/discordeno-audio-plugin-main/utils/number.ts @@ -0,0 +1,3 @@ +export function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} diff --git a/discordeno-audio-plugin-main/utils/queue.ts b/discordeno-audio-plugin-main/utils/queue.ts new file mode 100644 index 0000000..e7891b9 --- /dev/null +++ b/discordeno-audio-plugin-main/utils/queue.ts @@ -0,0 +1,88 @@ +import { assertEquals } from "https://deno.land/std@0.104.0/testing/asserts.ts"; +import { arrayMove, arrayShuffle } from "./array.ts"; +import { EventSource } from "./event-source.ts"; + +export class Queue { + #current: T | undefined; + #queue: T[] = []; + #source = new EventSource<[T]>(); + waiting = false; + + clear() { + this.#queue = []; + } + + current() { + return this.#current; + } + + upcoming() { + return [...this.#queue]; + } + + push(...values: T[]) { + this.#queue.push(...values); + if (this.waiting) { + this.triggerNext(); + this.waiting = false; + } + } + + unshift(...values: T[]) { + this.#queue.unshift(...values); + } + + shuffle() { + this.#queue = arrayShuffle([...this.#queue]); + } + + remove(equals: (first: T) => boolean) { + const original = this.#queue.length; + this.#queue = [...this.#queue.filter((track) => !equals(track))]; + return original !== this.#queue.length; + } + + move(position: number, equals: (first: T) => boolean) { + const queue = this.#queue; + const from = queue.findIndex((entry) => equals(entry)); + if (from !== -1) { + this.#queue = arrayMove(queue, from, position); + return true; + } + return false; + } + + triggerNext() { + const value = this.#queue.shift(); + this.#current = value; + if (value === undefined) { + this.waiting = true; + } else { + this.#source.trigger(value); + } + } + + stream() { + return this.#source.iter(); + } +} + +Deno.test({ + name: "Test", + fn: async () => { + const queue = new Queue(); + queue.push("Hello"); + queue.push("World!"); + const messages = queue.stream(); + queue.triggerNext(); + queue.triggerNext(); + queue.triggerNext(); + queue.triggerNext(); + queue.triggerNext(); + assertEquals("Hello", await messages.nextValue()); + assertEquals("World!", await messages.nextValue()); + assertEquals(undefined, await messages.nextValue()); + assertEquals(undefined, await messages.nextValue()); + assertEquals(undefined, await messages.nextValue()); + }, +}); diff --git a/discordeno-audio-plugin-main/utils/types.ts b/discordeno-audio-plugin-main/utils/types.ts new file mode 100644 index 0000000..824ad40 --- /dev/null +++ b/discordeno-audio-plugin-main/utils/types.ts @@ -0,0 +1 @@ +export type Arr = readonly unknown[]; diff --git a/discordeno-audio-plugin-main/utils/wait.ts b/discordeno-audio-plugin-main/utils/wait.ts new file mode 100644 index 0000000..ad7fcde --- /dev/null +++ b/discordeno-audio-plugin-main/utils/wait.ts @@ -0,0 +1,5 @@ +export function wait(time: number) { + return new Promise((resolve) => { + setTimeout(resolve, time); + }); +} diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..a3ddf2c --- /dev/null +++ b/main.ts @@ -0,0 +1,60 @@ +import { configs } from "./configs.ts"; +import { + Bot, + createBot, + Intents, + startBot, + upsertGlobalApplicationCommands +} from "./deps.ts"; +import { parseCommand } from "./commands.ts"; +import { cyan, yellow } from "https://deno.land/std@0.161.0/fmt/colors.ts"; +import { helpCommand } from "./commands/help.ts"; +import { leaveCommand } from "./commands/leave.ts"; +import { loopCommand } from "./commands/loop.ts"; +import { npCommand } from "./commands/np.ts"; +import { pauseCommand } from "./commands/pause.ts"; +import { playCommand } from "./commands/play.ts"; +import { skipCommand } from "./commands/skip.ts"; +import { unloopCommand } from "./commands/unloop.ts"; + +import { enableAudioPlugin } from "./discordeno-audio-plugin-main/mod.ts"; + +let sessionId = ""; + +const baseBot = createBot({ + token: configs.discord_token, + intents: Intents.Guilds | Intents.GuildMessages | Intents.GuildVoiceStates, +}); + +export const bot = enableAudioPlugin(baseBot); + +bot.events.ready = async function (bot, payload) { + //await registerCommands(bot); + console.log(`${cyan("permanent waves")} is ready to go with session id ${yellow(payload.sessionId)}`); + sessionId = payload.sessionId; +} + +// Another way to do events +bot.events.interactionCreate = async function (bot, interaction) { + await parseCommand(bot, interaction); +}; + +await startBot(bot); + +async function registerCommands(bot: Bot) { + console.log(await upsertGlobalApplicationCommands(bot, [helpCommand, leaveCommand, loopCommand, npCommand, pauseCommand, playCommand, skipCommand, unloopCommand])); +} + +/* +import ytdl from "https://deno.land/x/ytdl_core/mod.ts"; + +const stream = await ytdl("DhobsmmyGFs", { filter: "audio" }); + +const chunks: Uint8Array[] = []; + +for await (const chunk of stream) { + chunks.push(chunk); +} + +const blob = new Blob(chunks); +await Deno.writeFile("video.mp3", new Uint8Array(await blob.arrayBuffer()));*/ \ No newline at end of file diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..bf83823 --- /dev/null +++ b/types.ts @@ -0,0 +1,16 @@ +import { type BigString } from "./deps.ts"; + +export interface Command { + /** The name of this command. */ + name: string; + /** What does this command do? */ + description: string; + /** The type of command this is. */ + type: ApplicationCommandTypes; + /** Whether or not this command is for the dev server only. */ + devOnly?: boolean; + /** The options for this command */ + options?: ApplicationCommandOption[]; + /** This will be executed when the command is run. */ + execute: (bot: Bot, interaction: Interaction) => unknown; +} \ No newline at end of file diff --git a/utils.ts b/utils.ts new file mode 100644 index 0000000..5912371 --- /dev/null +++ b/utils.ts @@ -0,0 +1,96 @@ +import { ytdl } from "https://deno.land/x/ytdl_core@v0.1.1/mod.ts"; +import { Bot } from "https://deno.land/x/discordeno@17.0.1/bot.ts"; +import { connectToVoiceChannel } from "https://deno.land/x/discordeno@17.0.1/helpers/guilds/mod.ts"; +import { configs } from "./configs.ts"; +import { getChannel, getChannels, getGuild, type BigString, type Embed, type InteractionCallbackData, type InteractionResponse } from "./deps.ts"; + + +export function channelIsAllowed(guild: string, channel: string) { + if(`${guild}:${channel}` in configs.allowed_text_channels) { + return true; + } + + return false; +} + +export async function download(url: string) { + try { + const stream = await ytdl(url, { filter: "audio" }); + + const chunks: Uint8Array[] = []; + + for await (const chunk of stream) { + chunks.push(chunk); + } + + const videoDetails = stream.videoInfo.videoDetails; + const blob = new Blob(chunks); + const result = await Deno.writeFile(`${configs.project_root}/music/${videoDetails.videoId}.mp3`, new Uint8Array(await blob.arrayBuffer())); + return { + result: true, + message: `Now playing **${videoDetails.title}**` + }; + } catch(err) { + console.error(err); + } + + return false; +} + +export async function ensureVoiceConnection(bot: Bot, guildId: BigString) { + const channels = await getChannels(bot, guildId); + const guild = await getGuild(bot, guildId); + let channelId = ""; + for(let [id, channel] of channels) { + if(channel.type == 2 && configs.allowed_voice_channels.includes(`${guild.name.toLowerCase()}:${channel.name.toLowerCase()}`)) {// voice channel + channelId = id; + } + } + + const channel = await getChannel(bot, channelId); + try { + const connection = await bot.helpers.connectToVoiceChannel(guildId, channelId); + } catch(err) { + console.error(err); + } +} + +export function formatCallbackData(text: string, title?: string) { + if(title) { + return { + title: title, + content: "", + embeds: [{ + color: configs.embed_color, + description: text + }] + } + } + return { + content: "", + embeds: [{ + color: configs.embed_color, + description: text + }] + } +} + +export async function getAllowedTextChannel(bot: Bot, guildId: bigint) { + const channels = await getChannels(bot, guildId); + const guild = await getGuild(bot, guildId); + let channelId = BigInt(0); + for(let [id, channel] of channels) { + if(channel.type == 0 && configs.allowed_text_channels.includes(`${guild.name.toLowerCase()}:${channel.name.toLowerCase()}`)) {// text channel + channelId = id; + } + } + + return await getChannel(bot, channelId); +} + +export const waitingForResponse = { + type: 4, + data: { + content: "waiting for response..." + } +}; \ No newline at end of file