From 4329fe30d7c9f1601599e3d301a1a00d99c67b4a Mon Sep 17 00:00:00 2001 From: Bradley Bickford Date: Sat, 13 Dec 2025 22:04:41 -0500 Subject: [PATCH] A more complete re-engineering of the voice stuff --- src/config.ts | 7 +- src/utilties/discord/users.ts | 16 ++-- src/utilties/discord/voice.ts | 73 ++++++++++++++- src/utilties/events/voice.ts | 112 ++++++++++++++++++++++-- src/utilties/storage/entities/DBCall.ts | 2 +- 5 files changed, 190 insertions(+), 20 deletions(-) diff --git a/src/config.ts b/src/config.ts index a5621f0..95ecc54 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,14 +2,15 @@ import dotenv from "dotenv" dotenv.config() -const { DISCORD_TOKEN, DISCORD_CLIENT_ID, DB_MODE } = process.env +const { DISCORD_TOKEN, DISCORD_CLIENT_ID, DB_MODE, MEDIA_VOICE_FOLDER } = process.env -if (!DISCORD_TOKEN || !DISCORD_CLIENT_ID || !DB_MODE) { +if (!DISCORD_TOKEN || !DISCORD_CLIENT_ID || !DB_MODE || !MEDIA_VOICE_FOLDER) { throw new Error("Missing environment variables") } export const config = { DISCORD_TOKEN, DISCORD_CLIENT_ID, - DB_MODE + DB_MODE, + MEDIA_VOICE_FOLDER } \ No newline at end of file diff --git a/src/utilties/discord/users.ts b/src/utilties/discord/users.ts index 9ba9235..cf770e6 100644 --- a/src/utilties/discord/users.ts +++ b/src/utilties/discord/users.ts @@ -1,22 +1,22 @@ -import { User } from "discord.js"; +import { GuildMember, User } from "discord.js"; import { Repository } from "typeorm"; import { DBUser } from "../storage/entities/DBUser"; -export async function doesUserExist(db: Repository, user: User): Promise { - return (await db.findOne({"where": {user_snowflake: user.id}})) != null +export async function doesUserExist(db: Repository, user: User | GuildMember | string): Promise { + return (await db.findOne({"where": {user_snowflake: user instanceof User || user instanceof GuildMember ? user.id : user }})) != null } -export async function insertUser(db: Repository, user: User): Promise { - const alreadyExists: boolean = await doesUserExist(db, user) +export async function insertUser(db: Repository, user: User | GuildMember | DBUser): Promise { + const alreadyExists: boolean = await doesUserExist(db, user instanceof DBUser ? user.user_snowflake : user) if(alreadyExists) { - return null + return await db.findOne({"where": {user_snowflake: user instanceof DBUser ? user.user_snowflake : user.id }}) } try { - const newUser: DBUser = db.create({ + const newUser: DBUser = user instanceof DBUser ? user : db.create({ user_snowflake: user.id, - user_name: user.username, + user_name: user instanceof User ? user.username : user.displayName, user_displayname: user.displayName }) diff --git a/src/utilties/discord/voice.ts b/src/utilties/discord/voice.ts index 7174b10..bc3b7ee 100644 --- a/src/utilties/discord/voice.ts +++ b/src/utilties/discord/voice.ts @@ -1,6 +1,8 @@ import { VoiceBasedChannel } from "discord.js"; import { IsNull, Repository } from "typeorm"; import { DBCall } from "../storage/entities/DBCall"; +import { DBCallUsers } from "../storage/entities/DBCallUsers"; +import { DBUser } from "../storage/entities/DBUser"; export async function breadbotInCall(db: Repository, channel: VoiceBasedChannel) : Promise { return (await db.findOne({ @@ -11,7 +13,7 @@ export async function breadbotInCall(db: Repository, channel: VoiceBased })) != null } -export async function getExistingCallID(db: Repository, channel: VoiceBasedChannel) : Promise { +export async function getExistingCallID(db: Repository, channel: VoiceBasedChannel) : Promise { return (await db.findOne({ where: { channel: {channel_snowflake: channel.id}, @@ -19,3 +21,72 @@ export async function getExistingCallID(db: Repository, channel: VoiceBa } }))?.call_id } + +export async function returnOrCreateNewCallID(db: Repository, channel: VoiceBasedChannel) : Promise { + const oldCallID = await getExistingCallID(db, channel) + + if(oldCallID !== undefined) { + return oldCallID + } else { + const newCall : DBCall = await db.create({ + channel: { channel_snowflake: channel.id }, + call_start_time: new Date() + }) + + return (await db.save(newCall)).call_id; + } +} + +export async function setCallEndTime(db: Repository, channel: VoiceBasedChannel) : Promise { + const call: DBCall | null = await db.findOne({ + "where": { + channel: channel, + call_end_time: IsNull() + } + }) + + if (call == null) { + return null + } + + call.call_end_time = new Date() + + return await db.save(call) +} + +export async function numberOfUsersInCall(db: Repository, call: DBCall | number) : Promise { + const activeCallUsers : DBCallUsers[] = await db.find({ + "where": { + call: (call instanceof DBCall) ? call : { call_id: call }, + call_leave_time: IsNull() + } + }) + + return activeCallUsers.length +} + +export async function registerUserInCall(db: Repository, call: DBCall | number, user: DBUser) : Promise { + return await db.save({ + call: (call instanceof DBCall) ? call : {call_id: call}, + user: user, + call_join_time: new Date() + }) +} + +export async function deregisterUserInCall(db: Repository, call: DBCall | number, user: DBUser | string) : Promise { + const callUser : DBCallUsers | null = await db.findOne({ + where: { + call: (call instanceof DBCall) ? call : {call_id: call}, + user: (user instanceof DBUser) ? user : {user_snowflake: user}, + call_leave_time: IsNull() + } + }) + + if(callUser == null) { + return null + } + + callUser.call_leave_time = new Date() + + return await db.save(callUser) +} diff --git a/src/utilties/events/voice.ts b/src/utilties/events/voice.ts index b16498c..59668b8 100644 --- a/src/utilties/events/voice.ts +++ b/src/utilties/events/voice.ts @@ -1,20 +1,118 @@ -import { Client, Events, VoiceState } from "discord.js" +import { Client, Events, GuildMember, VoiceState } from "discord.js" import { Repository } from "typeorm" import { DBCall } from "../storage/entities/DBCall" +import { DBChannel } from "../storage/entities/DBChannel" +import { insertChannel } from "../discord/channels" +import { deregisterUserInCall, getExistingCallID, numberOfUsersInCall, registerUserInCall, returnOrCreateNewCallID, setCallEndTime } from "../discord/voice" +import { createWriteStream, mkdirSync } from 'node:fs' +import { config } from '../../config' +import path from "node:path" +import { EndBehaviorType, entersState, getVoiceConnection, joinVoiceChannel, VoiceConnection, VoiceConnectionStatus } from "@discordjs/voice" +import { OggLogicalBitstream, OpusHead } from "prism-media/dist/opus" +import { DBUser } from "../storage/entities/DBUser" +import { insertUser } from "../discord/users" +import { DBCallUsers } from "../storage/entities/DBCallUsers" -export function setupVoice(client: Client, db: Repository) { - client.on(Events.VoiceStateUpdate, (oldState: VoiceState, newState: VoiceState) => { +export function setupVoice(client: Client, callDB: Repository, channelDB: Repository, userDB: Repository, callUserDB: Repository) { + client.on(Events.VoiceStateUpdate, async (oldState: VoiceState, newState: VoiceState) => { if(oldState.channel == null && newState.channel != null) { // TODO Null Type Safety Risk? if (newState.member?.id == client.user?.id) { return } + await insertChannel(channelDB, newState.channel) + let existingCallID : number | undefined = await getExistingCallID(callDB, newState.channel) + + if (existingCallID === undefined) { + existingCallID = await returnOrCreateNewCallID(callDB, newState.channel) + + mkdirSync(config.MEDIA_VOICE_FOLDER + path.sep + existingCallID, {recursive: true}) + + // TODO NULL armor here is probably just going to blow up the call to joinVoiceChannel with no error catching + const connection : VoiceConnection = joinVoiceChannel({ + channelId: newState.channelId ?? "", + guildId: newState.guild.id, + selfDeaf: false, + selfMute: true, + adapterCreator: newState.guild.voiceAdapterCreator + }) + + try { + await entersState(connection, VoiceConnectionStatus.Ready, 20e3) + const receiver = connection.receiver + + if (receiver.speaking.listenerCount("start") == 0) { + receiver.speaking.on("start", (userID: string) => { + if(!receiver.subscriptions.has(userID)) { + receiver.subscribe(userID, { + end: { + behavior: EndBehaviorType.AfterSilence, + duration: 500 + } + }) + .pipe(new OggLogicalBitstream({ + opusHead: new OpusHead({ + channelCount: 2, + sampleRate: 48000 + }), + pageSizeControl: { + maxPackets: 10 + } + })) + .pipe(createWriteStream( + config.MEDIA_VOICE_FOLDER + path.sep + + existingCallID + path.sep + + `${Date.now()}-${userID}.ogg` + )) + } else { + console.log(`Attempted to create new user subscriber for ${userID} even though one already exist, receiver arm if statement protected against this`) + } + }) + + receiver.speaking.on("end", (userID: string) => { + console.log(`User ${userID} stopped speaking`) + }) + } + + } catch (error) { + //TODO Winston + console.log(error) + } + } + + const member : GuildMember | null = newState.member + + // In theory, member should never be null, because the Gateway Intents necessary + // to make it work are provided + if(member != null) { + const insertedUser: DBUser | null = await insertUser(userDB, member) + + if(insertedUser != null) { + await registerUserInCall(callUserDB, existingCallID, insertedUser) + } + } + + } else if (oldState.channel != null && newState.channel == null) { + if(oldState.member?.id == client.user?.id) { + return // If the user is breadbot, ignore and exit + } + + const existingCall : number | undefined = await getExistingCallID(callDB, oldState.channel) + + if (existingCall !== undefined && oldState.member) { + await deregisterUserInCall(callUserDB, existingCall, oldState.member?.id) + + const usersInCall: number = await numberOfUsersInCall(callUserDB, existingCall) + + if (usersInCall == 0) { + const connection = getVoiceConnection(oldState.guild.id) + connection?.disconnect() + + await setCallEndTime(callDB, oldState.channel) + } + } } }) } - -async function processCallJoin(db: Repository, voiceState: VoiceState) { - -} diff --git a/src/utilties/storage/entities/DBCall.ts b/src/utilties/storage/entities/DBCall.ts index 1424f5b..81c246f 100644 --- a/src/utilties/storage/entities/DBCall.ts +++ b/src/utilties/storage/entities/DBCall.ts @@ -30,5 +30,5 @@ export class DBCall { transcriptions: DBCallTranscriptions[] | null @OneToMany(() => DBCallUsers, (callUser: DBCallUsers) => callUser.call, {nullable: true}) - participants: DBCallUsers | null + participants: DBCallUsers[] | null } \ No newline at end of file