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