initial commit, there are some pending bugs
This commit is contained in:
commit
6533e85d92
|
@ -0,0 +1,13 @@
|
|||
|
||||
|
||||
export class AudioPlayer {
|
||||
audio?: AsyncIterableIterator<Uint8Array>;
|
||||
playing = false;
|
||||
|
||||
play() {
|
||||
if(this.playing) {
|
||||
return;
|
||||
}
|
||||
this.playing = true;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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.`
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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
|
||||
};
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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"
|
||||
}*/
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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";
|
|
@ -0,0 +1,2 @@
|
|||
# VS code settings
|
||||
.vscode
|
|
@ -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.
|
|
@ -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")) {
|
||||
...
|
||||
}
|
||||
```
|
|
@ -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";
|
|
@ -0,0 +1 @@
|
|||
export * from "./src/mod.ts";
|
|
@ -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
|
||||
};
|
||||
}
|
|
@ -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));
|
||||
//});
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
export * from "./audio-source.ts";
|
||||
export * from "./universal.ts";
|
||||
export * from "./youtube.ts";
|
||||
export * from "./local.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));
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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();
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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),
|
||||
};
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./types.ts";
|
||||
export * from "./queue-player.ts";
|
||||
export * from "./raw-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<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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
export type Player = {
|
||||
playing: boolean;
|
||||
play(): void;
|
||||
pause(): void;
|
||||
stop(): void;
|
||||
clear(): void;
|
||||
interrupt(audio: AsyncIterableIterator<Uint8Array>): void;
|
||||
};
|
|
@ -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;
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./discover.ts";
|
||||
export * from "./speaking.ts";
|
||||
export * from "./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}`);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
|
@ -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() {}
|
|
@ -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;
|
||||
};
|
||||
}
|
|
@ -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();
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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();
|
||||
// }
|
|
@ -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);
|
||||
// }
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./iter-utils.ts";
|
||||
export * from "./push-iter.ts";
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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";
|
|
@ -0,0 +1,3 @@
|
|||
export function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
|
@ -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());
|
||||
},
|
||||
});
|
|
@ -0,0 +1 @@
|
|||
export type Arr = readonly unknown[];
|
|
@ -0,0 +1,5 @@
|
|||
export function wait(time: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, time);
|
||||
});
|
||||
}
|
|
@ -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()));*/
|
|
@ -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;
|
||||
}
|
|
@ -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..."
|
||||
}
|
||||
};
|
Loading…
Reference in New Issue