rewrite na js bo rust jest zjebany
This commit is contained in:
@@ -1,28 +0,0 @@
|
||||
use std::env;
|
||||
use std::process::Command;
|
||||
use std::error::Error;
|
||||
|
||||
pub fn get_audio(url: String) -> Result<String, Box<dyn Error>> {
|
||||
let yt_dlp_bin = env::var("YT_DLP_BIN_PATH").unwrap_or("yt-dlp_linux".to_string());
|
||||
let data_dir = env::var("DATA_DIR").unwrap_or("./data".to_string());
|
||||
|
||||
let output = Command::new(&yt_dlp_bin)
|
||||
.args([
|
||||
"-x",
|
||||
"--audio-format", "opus",
|
||||
"--audio-quality", "0",
|
||||
"--no-playlist",
|
||||
"--paths", &data_dir,
|
||||
&url,
|
||||
])
|
||||
.output();
|
||||
|
||||
let cmd = format!("find ./data -type f -iname \"*$({} -x --print id \"{}\")*.opus\" -exec realpath {{}} \\;", &yt_dlp_bin, &url);
|
||||
println!("{}", cmd);
|
||||
let res = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(cmd)
|
||||
.output()?;
|
||||
let path = String::from_utf8_lossy(&res.stdout).to_string();
|
||||
Ok(path)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
pub mod downloader;
|
||||
19
src/commands/help.ts
Normal file
19
src/commands/help.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { CacheType, ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js';
|
||||
|
||||
const name = "help"
|
||||
|
||||
function register() {
|
||||
return new SlashCommandBuilder()
|
||||
.setName(name)
|
||||
.setDescription('Pomoc')
|
||||
}
|
||||
|
||||
async function execute(interaction: ChatInputCommandInteraction<CacheType>) {
|
||||
await interaction.reply('Jak ci się kurwa nie podoba to tutaj proszę o pull requesty https://gitea.papryk.com/Papryk/dj-spangebob');
|
||||
}
|
||||
|
||||
export default {
|
||||
name,
|
||||
register,
|
||||
execute
|
||||
}
|
||||
8
src/commands/index.ts
Normal file
8
src/commands/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import ping from "./ping"
|
||||
import play from "./play"
|
||||
|
||||
export const commands: {[key: string]: Command} = {
|
||||
ping,
|
||||
play,
|
||||
}
|
||||
|
||||
21
src/commands/join.ts
Normal file
21
src/commands/join.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { CacheType, ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js';
|
||||
import { connectToChannel } from '../util/helpers';
|
||||
import { joinVoiceChannel } from '@discordjs/voice';
|
||||
|
||||
const name = "ping"
|
||||
|
||||
function register() {
|
||||
return new SlashCommandBuilder()
|
||||
.setName(name)
|
||||
.setDescription('Replies with Pong!')
|
||||
}
|
||||
|
||||
async function execute(message: ChatInputCommandInteraction<CacheType>) {
|
||||
const channelId = "515300847790325790"
|
||||
}
|
||||
|
||||
export default {
|
||||
name,
|
||||
register,
|
||||
execute
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
pub mod play;
|
||||
20
src/commands/ping.ts
Normal file
20
src/commands/ping.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { CacheType, ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js';
|
||||
|
||||
const name = "ping"
|
||||
|
||||
function register() {
|
||||
return new SlashCommandBuilder()
|
||||
.setName(name)
|
||||
.setDescription('Replies with Pong!')
|
||||
}
|
||||
|
||||
async function execute(interaction: ChatInputCommandInteraction<CacheType>) {
|
||||
console.log(interaction.member)
|
||||
await interaction.reply('Pong!');
|
||||
}
|
||||
|
||||
export default {
|
||||
name,
|
||||
register,
|
||||
execute
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
use serenity::all::{Context, GuildId};
|
||||
use serenity::builder::{CreateCommand, CreateCommandOption};
|
||||
use serenity::model::application::{CommandOptionType, ResolvedOption, ResolvedValue};
|
||||
use songbird::Call;
|
||||
use songbird::driver::opus::ffi;
|
||||
use songbird::input::{self, AudioStream, Input};
|
||||
use std::env;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::audio::downloader::get_audio;
|
||||
|
||||
pub async fn run(options: &[ResolvedOption<'_>], ctx: Context) -> String {
|
||||
if let Some(ResolvedOption {
|
||||
value: ResolvedValue::String(url),
|
||||
..
|
||||
}) = options.first()
|
||||
{
|
||||
let path = get_audio(url.to_string());
|
||||
let manager = songbird::get(&ctx).await.unwrap().clone();
|
||||
|
||||
let guild_id = GuildId::new(
|
||||
env::var("GUILD_ID")
|
||||
.expect("Expected GUILD_ID in environment")
|
||||
.parse()
|
||||
.expect("GUILD_ID must be an integer"),
|
||||
);
|
||||
if let Some(handler_lock) = manager.get(guild_id) {
|
||||
let mut handler = handler_lock.lock().await;
|
||||
let src = ffi
|
||||
let _ = handler.play_input();
|
||||
}
|
||||
|
||||
format!("{}", url)
|
||||
} else {
|
||||
"Please provide a valid url".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register() -> CreateCommand {
|
||||
CreateCommand::new("play")
|
||||
.description("A play command")
|
||||
.add_option(
|
||||
CreateCommandOption::new(CommandOptionType::String, "url", "song to look for")
|
||||
.required(true),
|
||||
)
|
||||
}
|
||||
49
src/commands/play.ts
Normal file
49
src/commands/play.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { CacheType, ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js';
|
||||
import { connectToChannel, playSong } from '../util/helpers';
|
||||
import { player } from '../main';
|
||||
import { get_audio } from '../util/downloader';
|
||||
|
||||
const name = "play"
|
||||
|
||||
function register() {
|
||||
return new SlashCommandBuilder()
|
||||
.setName(name)
|
||||
.setDescription('YT')
|
||||
.addStringOption((option) => option.setName("url")
|
||||
.setDescription("YT url")
|
||||
.setRequired(true))
|
||||
}
|
||||
|
||||
async function execute(interaction: ChatInputCommandInteraction<CacheType>) {
|
||||
try {
|
||||
// await interaction.reply(`${interaction}`);
|
||||
const member = await interaction.guild?.members.fetch(interaction.user.id);
|
||||
if (!member) throw new Error("ale chuj")
|
||||
const voiceChannel = member.voice.channel;
|
||||
if (!voiceChannel) throw new Error("ale chuj 2")
|
||||
|
||||
const connection = await connectToChannel(voiceChannel);
|
||||
connection.subscribe(player);
|
||||
|
||||
await interaction.reply('https://gitea.papryk.com/Papryk/dj-spangebob pull requesty milewidziane XD');
|
||||
const url = interaction.options.getString("url")!;
|
||||
const path = await get_audio(url)
|
||||
// const path = "/home/patryk/Papryk/dbot/data/Kino - Summer is ending ⧸ Кино - Кончится лето [6VqiMQoMXmw].opus"
|
||||
console.log(path)
|
||||
|
||||
await playSong(player, path);
|
||||
|
||||
} catch (error) {
|
||||
/**
|
||||
* The song isn't ready to play for some reason :(
|
||||
*/
|
||||
console.error(error);
|
||||
await interaction.reply('Coś poszło nie tak :/');
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name,
|
||||
register,
|
||||
execute
|
||||
}
|
||||
5
src/global.d.ts
vendored
Normal file
5
src/global.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
type Command = {
|
||||
name: string,
|
||||
register: () => any,
|
||||
execute: (interaction: ChatInputCommandInteraction<CacheType>) => Promise<void>,
|
||||
}
|
||||
92
src/main.rs
92
src/main.rs
@@ -1,92 +0,0 @@
|
||||
mod audio;
|
||||
mod commands;
|
||||
|
||||
use std::env;
|
||||
|
||||
use serenity::all::ChannelId;
|
||||
use serenity::async_trait;
|
||||
use serenity::builder::{CreateInteractionResponse, CreateInteractionResponseMessage};
|
||||
use serenity::model::application::{Command, Interaction};
|
||||
use serenity::model::gateway::Ready;
|
||||
use serenity::model::id::GuildId;
|
||||
use serenity::prelude::*;
|
||||
use songbird::SerenityInit;
|
||||
|
||||
struct Handler;
|
||||
|
||||
#[async_trait]
|
||||
impl EventHandler for Handler {
|
||||
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
|
||||
if let Interaction::Command(command) = interaction {
|
||||
let clone_ctx = ctx.clone();
|
||||
let manager = songbird::get(&clone_ctx).await.unwrap().clone();
|
||||
let guild_id = command.guild_id.expect("Guild ID missing");
|
||||
let user_id = command.user.id;
|
||||
println!("Received command interaction: {command:#?}");
|
||||
// let channel_id = command.channel_id;
|
||||
let channel_id = ChannelId::new(515300847790325790);
|
||||
let join_result = manager.join(guild_id, channel_id).await;
|
||||
|
||||
if join_result.is_ok() {
|
||||
println!("Joined your voice channel!")
|
||||
} else {
|
||||
println!("Failed to join your voice channel")
|
||||
}
|
||||
|
||||
let content = match command.data.name.as_str() {
|
||||
"play" => Some(commands::play::run(&command.data.options(), ctx.clone()).await),
|
||||
_ => Some("not implemented :(".to_string()),
|
||||
};
|
||||
|
||||
if let Some(content) = content {
|
||||
let data = CreateInteractionResponseMessage::new().content(content);
|
||||
let builder = CreateInteractionResponse::Message(data);
|
||||
if let Err(why) = command.create_response(&ctx.http.clone(), builder).await {
|
||||
println!("Cannot respond to slash command: {why}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn ready(&self, ctx: Context, ready: Ready) {
|
||||
// println!("{} is connected!", ready.user.name);
|
||||
|
||||
let guild_id = GuildId::new(
|
||||
env::var("GUILD_ID")
|
||||
.expect("Expected GUILD_ID in environment")
|
||||
.parse()
|
||||
.expect("GUILD_ID must be an integer"),
|
||||
);
|
||||
|
||||
let commands = guild_id
|
||||
.set_commands(&ctx.http, vec![commands::play::register()])
|
||||
.await;
|
||||
|
||||
// println!("I now have the following guild slash commands: {commands:#?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Configure the client with your Discord bot token in the environment.
|
||||
let token = env::var("DISCORD_TOKEN").expect("Expected a token in the environment");
|
||||
// Build our client.
|
||||
|
||||
let intents = GatewayIntents::GUILD_VOICE_STATES
|
||||
| GatewayIntents::GUILD_MESSAGES
|
||||
| GatewayIntents::MESSAGE_CONTENT;
|
||||
|
||||
let mut client = Client::builder(token, intents)
|
||||
.event_handler(Handler)
|
||||
.register_songbird()
|
||||
.await
|
||||
.expect("Error creating client");
|
||||
|
||||
// Finally, start a single shard, and start listening to events.
|
||||
//
|
||||
// Shards will automatically attempt to reconnect, and will perform exponential backoff until
|
||||
// it reconnects.
|
||||
if let Err(why) = client.start().await {
|
||||
println!("Client error: {why:?}");
|
||||
}
|
||||
}
|
||||
68
src/main.ts
Normal file
68
src/main.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
// Require the necessary discord.js classes
|
||||
import { Client, Events, GatewayIntentBits, MessageFlags, REST, Routes } from 'discord.js';
|
||||
import dotenv from 'dotenv';
|
||||
import { commands } from "./commands";
|
||||
import { createAudioPlayer } from '@discordjs/voice';
|
||||
import { connectToChannel, playSong } from './util/helpers';
|
||||
dotenv.config()
|
||||
|
||||
// AUDIO
|
||||
export const player = createAudioPlayer();
|
||||
|
||||
// Create a new client instance
|
||||
const client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.GuildVoiceStates]
|
||||
});
|
||||
|
||||
// When the client is ready, run this code (only once).
|
||||
// The distinction between `client: Client<boolean>` and `readyClient: Client<true>` is important for TypeScript developers.
|
||||
// It makes some properties non-nullable.
|
||||
client.once(Events.ClientReady, (readyClient) => {
|
||||
console.log(`Ready! Logged in as ${readyClient.user.tag}`);
|
||||
registerCommands()
|
||||
});
|
||||
|
||||
async function registerCommands() {
|
||||
const rest = new REST().setToken(process.env.DISCORD_TOKEN!);
|
||||
const registry = Object.values(commands).map((cmd) => cmd.register().toJSON())
|
||||
console.log(registry)
|
||||
const data = await rest.put(Routes.applicationCommands(
|
||||
process.env.DISCORD_APP_ID!,
|
||||
), {
|
||||
body: registry
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Log in to Discord with your client's token
|
||||
client.login(process.env.DISCORD_TOKEN!);
|
||||
|
||||
client.on(Events.InteractionCreate, async (interaction) => {
|
||||
if (!interaction.isChatInputCommand()) return;
|
||||
const cmd = commands[interaction.commandName];
|
||||
if (!cmd) {
|
||||
console.error(`No command matching ${interaction.commandName} was found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await cmd.execute(interaction)
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
if (interaction.replied || interaction.deferred) {
|
||||
await interaction.followUp({
|
||||
content: 'There was an error while executing this command!',
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
} else {
|
||||
await interaction.reply({
|
||||
content: 'There was an error while executing this command!',
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
88
src/util/adapter.ts
Normal file
88
src/util/adapter.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { DiscordGatewayAdapterCreator, DiscordGatewayAdapterLibraryMethods } from '@discordjs/voice';
|
||||
import {
|
||||
Events,
|
||||
GatewayDispatchEvents,
|
||||
type Client,
|
||||
type GatewayVoiceServerUpdateDispatchData,
|
||||
type GatewayVoiceStateUpdateDispatchData,
|
||||
type Guild,
|
||||
type VoiceBasedChannel,
|
||||
type Snowflake,
|
||||
Status,
|
||||
} from 'discord.js';
|
||||
|
||||
const adapters = new Map<Snowflake, DiscordGatewayAdapterLibraryMethods>();
|
||||
const trackedClients = new Set<Client>();
|
||||
const trackedShards = new Map<number, Set<Snowflake>>();
|
||||
|
||||
/**
|
||||
* Tracks a Discord.js client, listening to VOICE_SERVER_UPDATE and VOICE_STATE_UPDATE events
|
||||
*
|
||||
* @param client - The Discord.js Client to track
|
||||
*/
|
||||
function trackClient(client: Client) {
|
||||
if (trackedClients.has(client)) return;
|
||||
|
||||
trackedClients.add(client);
|
||||
|
||||
client.ws.on(GatewayDispatchEvents.VoiceServerUpdate, (payload: GatewayVoiceServerUpdateDispatchData) => {
|
||||
adapters.get(payload.guild_id)?.onVoiceServerUpdate(payload);
|
||||
});
|
||||
|
||||
client.ws.on(GatewayDispatchEvents.VoiceStateUpdate, (payload: GatewayVoiceStateUpdateDispatchData) => {
|
||||
if (payload.guild_id && payload.session_id && payload.user_id === client.user?.id) {
|
||||
adapters.get(payload.guild_id)?.onVoiceStateUpdate(payload);
|
||||
}
|
||||
});
|
||||
|
||||
client.on(Events.ShardDisconnect, (_, shardId) => {
|
||||
const guilds = trackedShards.get(shardId);
|
||||
|
||||
if (guilds) {
|
||||
for (const guildId of guilds.values()) {
|
||||
adapters.get(guildId)?.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
trackedShards.delete(shardId);
|
||||
});
|
||||
}
|
||||
|
||||
function trackGuild(guild: Guild) {
|
||||
let guilds = trackedShards.get(guild.shardId);
|
||||
|
||||
if (!guilds) {
|
||||
guilds = new Set();
|
||||
|
||||
trackedShards.set(guild.shardId, guilds);
|
||||
}
|
||||
|
||||
guilds.add(guild.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an adapter for a Voice Channel.
|
||||
*
|
||||
* @param channel - The channel to create the adapter for
|
||||
*/
|
||||
export function createDiscordJSAdapter(channel: VoiceBasedChannel): DiscordGatewayAdapterCreator {
|
||||
return (methods) => {
|
||||
adapters.set(channel.guild.id, methods);
|
||||
|
||||
trackClient(channel.client);
|
||||
trackGuild(channel.guild);
|
||||
|
||||
return {
|
||||
sendPayload(data) {
|
||||
if (channel.guild.shard.status !== Status.Ready) return false;
|
||||
|
||||
channel.guild.shard.send(data);
|
||||
|
||||
return true;
|
||||
},
|
||||
destroy() {
|
||||
adapters.delete(channel.guild.id);
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
51
src/util/downloader.ts
Normal file
51
src/util/downloader.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
export async function get_audio(url: string): Promise<string> {
|
||||
const ytDlpBin = process.env.YT_DLP_BIN_PATH! ?? "yt-dlp";
|
||||
const dataDir = "./data";
|
||||
|
||||
const idMatch = /[?&]v=([a-zA-Z0-9_-]{11})/.exec(url);
|
||||
if (!idMatch) throw new Error("Cannot extract video ID");
|
||||
|
||||
const id = idMatch[1];
|
||||
console.log(`ID: ${id}`);
|
||||
|
||||
// ---- run yt-dlp ----
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const p = spawn(ytDlpBin, [
|
||||
"-x",
|
||||
"--audio-format", "opus",
|
||||
"--audio-quality", "0",
|
||||
"--no-playlist",
|
||||
"--paths", dataDir,
|
||||
url,
|
||||
]);
|
||||
|
||||
p.stderr.on("data", d => process.stderr.write(d));
|
||||
p.on("close", code => code === 0 ? resolve() : reject(new Error("yt-dlp failed")));
|
||||
});
|
||||
|
||||
// ---- find the file ----
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const find = spawn("find", [
|
||||
dataDir,
|
||||
"-type", "f",
|
||||
"-iname", `*${id}*.opus`,
|
||||
"-exec", "readlink", "-f", "{}", ";",
|
||||
]);
|
||||
|
||||
let out = "";
|
||||
|
||||
find.stdout.on("data", d => out += d.toString());
|
||||
find.stderr.on("data", d => process.stderr.write(d));
|
||||
|
||||
find.on("close", code => {
|
||||
if (code !== 0 || !out.trim()) {
|
||||
reject(new Error("Audio file not found"));
|
||||
} else {
|
||||
resolve(out.trim());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
77
src/util/helpers.ts
Normal file
77
src/util/helpers.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
AudioPlayerStatus,
|
||||
StreamType,
|
||||
VoiceConnectionStatus,
|
||||
createAudioResource,
|
||||
entersState,
|
||||
joinVoiceChannel,
|
||||
type AudioPlayer,
|
||||
} from '@discordjs/voice';
|
||||
import type { VoiceBasedChannel } from 'discord.js';
|
||||
import { createDiscordJSAdapter } from './adapter.js';
|
||||
|
||||
export async function connectToChannel(channel: VoiceBasedChannel) {
|
||||
/**
|
||||
* Here, we try to establish a connection to a voice channel. If we're already connected
|
||||
* to this voice channel, \@discordjs/voice will just return the existing connection for us!
|
||||
*/
|
||||
const connection = joinVoiceChannel({
|
||||
channelId: channel.id,
|
||||
guildId: channel.guild.id,
|
||||
adapterCreator: createDiscordJSAdapter(channel),
|
||||
});
|
||||
|
||||
/**
|
||||
* If we're dealing with a connection that isn't yet Ready, we can set a reasonable
|
||||
* time limit before giving up. In this example, we give the voice connection 30 seconds
|
||||
* to enter the ready state before giving up.
|
||||
*/
|
||||
try {
|
||||
/**
|
||||
* Allow ourselves 30 seconds to join the voice channel. If we do not join within then,
|
||||
* an error is thrown.
|
||||
*/
|
||||
await entersState(connection, VoiceConnectionStatus.Ready, 30_000);
|
||||
/**
|
||||
* At this point, the voice connection is ready within 30 seconds! This means we can
|
||||
* start playing audio in the voice channel. We return the connection so it can be
|
||||
* used by the caller.
|
||||
*/
|
||||
return connection;
|
||||
} catch (error) {
|
||||
/**
|
||||
* At this point, the voice connection has not entered the Ready state. We should make
|
||||
* sure to destroy it, and propagate the error by throwing it, so that the calling function
|
||||
* is aware that we failed to connect to the channel.
|
||||
*/
|
||||
connection.destroy();
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function playSong(player: AudioPlayer, songUrl: string) {
|
||||
/**
|
||||
* Here we are creating an audio resource using a sample song freely available online
|
||||
* (see https://www.soundhelix.com/audio-examples)
|
||||
*
|
||||
* We specify an arbitrary inputType. This means that we aren't too sure what the format of
|
||||
* the input is, and that we'd like to have this converted into a format we can use. If we
|
||||
* were using an Ogg or WebM source, then we could change this value. However, for now we
|
||||
* will leave this as arbitrary.
|
||||
*/
|
||||
const resource = createAudioResource(songUrl, { inputType: StreamType.Arbitrary });
|
||||
|
||||
/**
|
||||
* We will now play this to the audio player. By default, the audio player will not play until
|
||||
* at least one voice connection is subscribed to it, so it is fine to attach our resource to the
|
||||
* audio player this early.
|
||||
*/
|
||||
player.play(resource);
|
||||
|
||||
/**
|
||||
* Here we are using a helper function. It will resolve if the player enters the Playing
|
||||
* state within 5 seconds, otherwise it will reject with an error.
|
||||
*/
|
||||
return entersState(player, AudioPlayerStatus.Playing, 5_000);
|
||||
}
|
||||
Reference in New Issue
Block a user