initial commit, there are some pending bugs

This commit is contained in:
Lexie Love 2023-01-01 13:49:51 -06:00
commit 6533e85d92
59 changed files with 2746 additions and 0 deletions

13
audio_player.ts Normal file
View File

@ -0,0 +1,13 @@
export class AudioPlayer {
audio?: AsyncIterableIterator<Uint8Array>;
playing = false;
play() {
if(this.playing) {
return;
}
this.playing = true;
}
}

58
commands.ts Normal file
View File

@ -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;
}
}
}

62
commands/help.ts Normal file
View File

@ -0,0 +1,62 @@
import {
Bot,
Interaction,
sendPrivateInteractionResponse,
type ApplicationCommandOption,
type ApplicationCommandOptionChoice,
type CreateSlashApplicationCommand,
type InteractionResponse
} from "../deps.ts";
const helpChoices = [
<ApplicationCommandOptionChoice>{
name: "play",
value: "play"
}
];
const helpResponse = <InteractionResponse>{
type: 4, // ChannelMessageWithSource
data: {
content: `/help: displays this message\n/play: plays a song`
}
}
const playResponse = <InteractionResponse>{
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 = <CreateSlashApplicationCommand>{
name: "help",
description: "Lists the bot's commands and describes how to use them",
dmPermission: false,
options: [
<ApplicationCommandOption>{
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);
}
}

View File

@ -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 = <InteractionResponse>{
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.`
}
}

34
commands/leave.ts Normal file
View File

@ -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 = <CreateSlashApplicationCommand>{
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);
}
}

45
commands/loop.ts Normal file
View File

@ -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 = <CreateSlashApplicationCommand>{
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));
}
}

76
commands/np.ts Normal file
View File

@ -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 <InteractionResponse>{
type: 4,
data: <InteractionCallbackData>
{
content: "",
embeds: [<Embed>{
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 <CreateMessage>{
embeds: [<Embed>{
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 = <CreateSlashApplicationCommand>{
name: "np",
description: "Shows the currently-playing song along with the next five songs in the queue",
dmPermission: false
};

46
commands/pause.ts Normal file
View File

@ -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 = <CreateSlashApplicationCommand>{
name: "pause",
description: "Pauses the player",
dmPermission: false,
};
export const stopCommand = <CreateSlashApplicationCommand>{
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);
}
}

146
commands/play.ts Normal file
View File

@ -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 = <CreateSlashApplicationCommand>{
name: "play",
description: "Adds a song or playlist to the queue and starts the music if it's not already playing",
dmPermission: false,
options: [
<ApplicationCommandOption>{
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"
}*/

33
commands/skip.ts Normal file
View File

@ -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 = <CreateSlashApplicationCommand>{
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);
}
}

45
commands/unloop.ts Normal file
View File

@ -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 = <CreateSlashApplicationCommand>{
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));
}
}

23
deps.ts Normal file
View File

@ -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";

View File

@ -0,0 +1,2 @@
# VS code settings
.vscode

View File

@ -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.

View File

@ -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<Uint8Array>)
player.push(source);
```
Or pass in your own `loadSource` function when enabling the plugin:
```js
const loadSource = (query: string) => {
return createAudioSource("Title", () => AsyncIterableIterator<Uint8Array>);
}
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")) {
...
}
```

View File

@ -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";

View File

@ -0,0 +1 @@
export * from "./src/mod.ts";

View File

@ -0,0 +1,35 @@
let lastId = -1n;
export type AudioSource = {
id: bigint;
title: string;
data: () =>
| Promise<AsyncIterableIterator<Uint8Array>>
| AsyncIterableIterator<Uint8Array>;
added_by?: string;
};
async function* empty() {}
export function createAudioSource(
title: string,
data: () =>
| Promise<AsyncIterableIterator<Uint8Array>>
| AsyncIterableIterator<Uint8Array>,
added_by?: string
): AudioSource {
lastId++;
return {
id: lastId,
title,
data: () => {
try {
return data();
} catch (error) {
console.error(error);
return empty();
}
},
added_by
};
}

View File

@ -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));
//});
}

View File

@ -0,0 +1,4 @@
export * from "./audio-source.ts";
export * from "./universal.ts";
export * from "./youtube.ts";
export * from "./local.ts";

View File

@ -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));
}
}

View File

@ -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;
}
}

View File

@ -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<bigint, ConnectionData>;
udpSource: EventSource<[UdpArgs]>;
bufferSize: number;
loadSource: (query: string, added_by?: string) => AudioSource[] | Promise<AudioSource[]>;
};
export type ConnectionData = {
player: QueuePlayer;
audio: EventSource<[Uint8Array]>;
guildId: bigint;
udpSocket: Deno.DatagramConn;
udpStream: () => AsyncIterableIterator<Uint8Array>;
ssrcToUser: Map<number, bigint>;
usersToSsrc: Map<bigint, number>;
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<bigint, BotData>();
/**
* 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<number, bigint>(),
usersToSsrc: new Map<bigint, number>(),
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);
}
}

View File

@ -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;
}

View File

@ -0,0 +1,23 @@
const workerUrl = new URL("./demux-worker.ts", import.meta.url).href;
export async function* demux(source: AsyncIterable<Uint8Array>) {
const worker = new Worker(workerUrl, {
type: "module",
});
try {
for await (const value of source) {
const nextValue = new Promise<Uint8Array[]>((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();
}

View File

@ -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;
}

View File

@ -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<Uint8Array>) {
const gen = encoder.encode_pcm_stream(FRAME_SIZE, audio);
for await (const frame of gen) {
yield frame as Uint8Array;
}
}

View File

@ -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<ReceivedAudio> => {
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;
}

View File

@ -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 extends Bot> = T & {
helpers: ReturnType<typeof createAudioHelpers>;
};
export function enableAudioPlugin<T extends Bot>(
bot: T,
loadSource = loadLocalOrYoutube
): AudioBot<T> {
Object.assign(bot.helpers, createAudioHelpers(bot, loadSource));
return bot as AudioBot<T>;
}
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),
};
}

View File

@ -0,0 +1,3 @@
export * from "./types.ts";
export * from "./queue-player.ts";
export * from "./raw-player.ts";

View File

@ -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<AudioSource> 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<Uint8Array>) {
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;
}
}

View File

@ -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<Uint8Array>;
#interrupt?: AsyncIterableIterator<Uint8Array>;
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<Uint8Array>) {
this.#audio = audio;
this.#nextSource.trigger();
}
interrupt(audio?: AsyncIterableIterator<Uint8Array>) {
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();
}
}

View File

@ -0,0 +1,8 @@
export type Player = {
playing: boolean;
play(): void;
pause(): void;
stop(): void;
clear(): void;
interrupt(audio: AsyncIterableIterator<Uint8Array>): void;
};

View File

@ -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;

View File

@ -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);
}

View File

@ -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);
}

View File

@ -0,0 +1,3 @@
export * from "./discover.ts";
export * from "./speaking.ts";
export * from "./packet.ts";

View File

@ -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}`);
}
}

View File

@ -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,
},
})
);
}

View File

@ -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() {}

View File

@ -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;
};
}

View File

@ -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<any>) {
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();
}

View File

@ -0,0 +1,97 @@
import { clamp } from "./number.ts";
export function arrayEquals<T extends any[], J extends any[]>(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<T>(value?: T | T[]): T[] {
if (value === undefined) {
return [];
}
return Array.isArray(value) ? [...value] : [value];
}
export function arrayMove<T>(
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<T>(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<T>(array: T[] | undefined, ...additional: T[]): T[] {
return [...(array || []), ...additional];
}
export function combine<T>(array: T[] | undefined, add: T[] | undefined): T[] {
return [...(array || []), ...(add || [])];
}
export function repeat<T>(amount: number, ...value: T[]): T[] {
let result: T[] = [];
while (amount > 0) {
amount--;
result = [...result, ...value];
}
return result;
}
export function remove<T>(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<T>(
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<T>(first: T[], second: T[]): T[] {
const result = [];
for (const item of second) {
if (!first.includes(item)) {
result.push(item);
}
}
return result;
}

View File

@ -0,0 +1,34 @@
export async function* bufferIter<T>(
iterator: AsyncIterableIterator<T>,
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;
}
}
}
}

View File

@ -0,0 +1,36 @@
import { IterSource, fromCallback } from "./iterator/mod.ts";
import { Arr } from "./types.ts";
type Listener<T extends Arr> = (...arg: T) => void;
export class EventSource<T extends Arr> {
listeners: Listener<T>[] = [];
iter: IterSource<T>["iterator"];
disconnect: IterSource<T>["disconnect"];
constructor() {
const { iterator, disconnect } = fromCallback<T>((listener) =>
this.addListener(listener)
);
this.iter = iterator;
this.disconnect = disconnect;
}
trigger(...arg: T) {
for (const listener of this.listeners) {
listener(...arg);
}
}
addListener(listener: Listener<T>) {
this.listeners.push(listener);
return () => {
this.removeListener(listener);
};
}
removeListener(listener: Listener<T>) {
const index = this.listeners.indexOf(listener);
this.listeners.splice(index, 1);
}
}

View File

@ -0,0 +1,14 @@
export async function* streamAsyncIterator<T>(stream: ReadableStream<T>) {
// 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();
}
}

View File

@ -0,0 +1,56 @@
import { Arr } from "../types.ts";
import { pushIter, addIterUtils } from "./util/mod.ts";
type Listener<T> = { push: (arg: T) => void; done: () => void };
export type IterSource<T extends Arr> = ReturnType<typeof fromCallback<T>>;
export function fromCallback<T extends Arr>(
source: (listener: (...values: T) => void) => void,
disconnect?: () => void
) {
let isDone = false;
let listeners: Listener<T>[] = [];
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<T>) {
listeners.push(listener);
return () => {
removeListener(listener);
};
}
function removeListener(listener: Listener<T>) {
const index = listeners.indexOf(listener);
listeners.splice(index, 1);
}
return {
iterator: () => {
const { push, done, getIterator } = pushIter<T>();
addListener({ push, done });
return addIterUtils<T>(getIterator());
},
disconnect: done,
};
}

View File

@ -0,0 +1,20 @@
// import { EventSource } from "../event-source.ts";
// type CombineReturn<J> = J extends AsyncIterableIterator<infer I>[]
// ? I
// : unknown;
// export function combineIter<J extends AsyncIterableIterator<any>[]>(
// ...iterators: J
// ) {
// const source = new EventSource<CombineReturn<J>>();
// const handler = (value: CombineReturn<J>) => {
// source.trigger(value);
// };
// iterators.forEach(async (iterator) => {
// for await (const value of iterator) {
// handler(value);
// }
// });
// return source.stream();
// }

View File

@ -0,0 +1,42 @@
export type IterExpanded<T> = ReturnType<typeof addIterUtils<T>>;
export function addIterUtils<T>(values: AsyncIterableIterator<T>) {
return Object.assign(values, { map, filter, nextValue });
}
async function nextValue<T>(this: AsyncIterableIterator<T>): Promise<T> {
const result = await this.next();
return result.value;
}
function map<T, J>(this: AsyncIterableIterator<T>, map: (from: T) => J) {
const mapGen = async function* (this: AsyncIterableIterator<T>) {
for await (const value of this) {
yield map(value);
}
};
return addIterUtils<J>(mapGen.bind(this)());
}
function filter<T>(
this: AsyncIterableIterator<T>,
filter: (value: T) => boolean
) {
const filterGen = async function* (this: AsyncIterableIterator<T>) {
for await (const value of this) {
if (filter(value)) {
yield value;
}
}
};
const boundFilter = filterGen.bind(this);
return addIterUtils<T>(boundFilter());
}
// function combine<T, J extends AsyncIterableIterator<any>[]>(
// this: AsyncIterableIterator<T>,
// ...others: J
// ) {
// return combineIter(this, ...others);
// }

View File

@ -0,0 +1,2 @@
export * from "./iter-utils.ts";
export * from "./push-iter.ts";

View File

@ -0,0 +1,59 @@
export type GetNext<T> = () => Promise<IteratorResult<Awaited<T>, void>>;
export function pushIter<T>() {
type Value = IteratorResult<T, undefined>;
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<Value>((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<T> => {
const iter = iterFromPull(pullValue);
return () => {
return iter.next();
};
},
};
}
async function* iterFromPull<T>(
pullValue: () => Promise<IteratorResult<T, undefined>>
) {
let done;
while (!done) {
const result = await pullValue();
done = result.done;
if (!result.done) {
yield result.value;
}
}
}

View File

@ -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";

View File

@ -0,0 +1,3 @@
export function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}

View File

@ -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<T> {
#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<string>();
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());
},
});

View File

@ -0,0 +1 @@
export type Arr = readonly unknown[];

View File

@ -0,0 +1,5 @@
export function wait(time: number) {
return new Promise<void>((resolve) => {
setTimeout(resolve, time);
});
}

60
main.ts Normal file
View File

@ -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()));*/

16
types.ts Normal file
View File

@ -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;
}

96
utils.ts Normal file
View File

@ -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, <BigString>guildId);
let channelId = <BigString>"";
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 <InteractionCallbackData>{
title: title,
content: "",
embeds: [<Embed>{
color: configs.embed_color,
description: text
}]
}
}
return <InteractionCallbackData>{
content: "",
embeds: [<Embed>{
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, <BigString>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 = <InteractionResponse>{
type: 4,
data: {
content: "waiting for response..."
}
};