rewrite na js bo rust jest zjebany

This commit is contained in:
Patryk Koreń
2025-12-26 18:31:14 +01:00
parent 7b72b15caa
commit 375779de9c
23 changed files with 1259 additions and 3827 deletions

View File

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

View File

@@ -1 +0,0 @@
pub mod downloader;

19
src/commands/help.ts Normal file
View 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
View 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
View 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
}

View File

@@ -1 +0,0 @@
pub mod play;

20
src/commands/ping.ts Normal file
View 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
}

View File

@@ -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
View 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
View File

@@ -0,0 +1,5 @@
type Command = {
name: string,
register: () => any,
execute: (interaction: ChatInputCommandInteraction<CacheType>) => Promise<void>,
}

View File

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