A more complete re-engineering of the voice stuff

This commit is contained in:
2025-12-13 22:04:41 -05:00
parent 7d8e252b79
commit 4329fe30d7
5 changed files with 190 additions and 20 deletions

View File

@@ -2,14 +2,15 @@ import dotenv from "dotenv"
dotenv.config() 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") throw new Error("Missing environment variables")
} }
export const config = { export const config = {
DISCORD_TOKEN, DISCORD_TOKEN,
DISCORD_CLIENT_ID, DISCORD_CLIENT_ID,
DB_MODE DB_MODE,
MEDIA_VOICE_FOLDER
} }

View File

@@ -1,22 +1,22 @@
import { User } from "discord.js"; import { GuildMember, User } from "discord.js";
import { Repository } from "typeorm"; import { Repository } from "typeorm";
import { DBUser } from "../storage/entities/DBUser"; import { DBUser } from "../storage/entities/DBUser";
export async function doesUserExist(db: Repository<DBUser>, user: User): Promise<boolean> { export async function doesUserExist(db: Repository<DBUser>, user: User | GuildMember | string): Promise<boolean> {
return (await db.findOne({"where": {user_snowflake: user.id}})) != null return (await db.findOne({"where": {user_snowflake: user instanceof User || user instanceof GuildMember ? user.id : user }})) != null
} }
export async function insertUser(db: Repository<DBUser>, user: User): Promise<DBUser | null> { export async function insertUser(db: Repository<DBUser>, user: User | GuildMember | DBUser): Promise<DBUser | null> {
const alreadyExists: boolean = await doesUserExist(db, user) const alreadyExists: boolean = await doesUserExist(db, user instanceof DBUser ? user.user_snowflake : user)
if(alreadyExists) { if(alreadyExists) {
return null return await db.findOne({"where": {user_snowflake: user instanceof DBUser ? user.user_snowflake : user.id }})
} }
try { try {
const newUser: DBUser = db.create({ const newUser: DBUser = user instanceof DBUser ? user : db.create({
user_snowflake: user.id, user_snowflake: user.id,
user_name: user.username, user_name: user instanceof User ? user.username : user.displayName,
user_displayname: user.displayName user_displayname: user.displayName
}) })

View File

@@ -1,6 +1,8 @@
import { VoiceBasedChannel } from "discord.js"; import { VoiceBasedChannel } from "discord.js";
import { IsNull, Repository } from "typeorm"; import { IsNull, Repository } from "typeorm";
import { DBCall } from "../storage/entities/DBCall"; import { DBCall } from "../storage/entities/DBCall";
import { DBCallUsers } from "../storage/entities/DBCallUsers";
import { DBUser } from "../storage/entities/DBUser";
export async function breadbotInCall(db: Repository<DBCall>, channel: VoiceBasedChannel) : Promise<boolean> { export async function breadbotInCall(db: Repository<DBCall>, channel: VoiceBasedChannel) : Promise<boolean> {
return (await db.findOne({ return (await db.findOne({
@@ -11,7 +13,7 @@ export async function breadbotInCall(db: Repository<DBCall>, channel: VoiceBased
})) != null })) != null
} }
export async function getExistingCallID(db: Repository<DBCall>, channel: VoiceBasedChannel) : Promise<Number | undefined> { export async function getExistingCallID(db: Repository<DBCall>, channel: VoiceBasedChannel) : Promise<number | undefined> {
return (await db.findOne({ return (await db.findOne({
where: { where: {
channel: {channel_snowflake: channel.id}, channel: {channel_snowflake: channel.id},
@@ -19,3 +21,72 @@ export async function getExistingCallID(db: Repository<DBCall>, channel: VoiceBa
} }
}))?.call_id }))?.call_id
} }
export async function returnOrCreateNewCallID(db: Repository<DBCall>, channel: VoiceBasedChannel) : Promise<number> {
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<DBCall>, channel: VoiceBasedChannel) : Promise<DBCall | null> {
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<DBCallUsers>, call: DBCall | number) : Promise<number> {
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<DBCallUsers>, call: DBCall | number, user: DBUser) : Promise<DBCallUsers> {
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<DBCallUsers>, call: DBCall | number, user: DBUser | string) : Promise<DBCallUsers | null> {
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)
}

View File

@@ -1,20 +1,118 @@
import { Client, Events, VoiceState } from "discord.js" import { Client, Events, GuildMember, VoiceState } from "discord.js"
import { Repository } from "typeorm" import { Repository } from "typeorm"
import { DBCall } from "../storage/entities/DBCall" 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<DBCall>) { export function setupVoice(client: Client, callDB: Repository<DBCall>, channelDB: Repository<DBChannel>, userDB: Repository<DBUser>, callUserDB: Repository<DBCallUsers>) {
client.on(Events.VoiceStateUpdate, (oldState: VoiceState, newState: VoiceState) => { client.on(Events.VoiceStateUpdate, async (oldState: VoiceState, newState: VoiceState) => {
if(oldState.channel == null && newState.channel != null) { if(oldState.channel == null && newState.channel != null) {
// TODO Null Type Safety Risk? // TODO Null Type Safety Risk?
if (newState.member?.id == client.user?.id) { if (newState.member?.id == client.user?.id) {
return 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`)
})
} }
async function processCallJoin(db: Repository<DBCall>, voiceState: VoiceState) { } 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)
}
}
}
})
} }

View File

@@ -30,5 +30,5 @@ export class DBCall {
transcriptions: DBCallTranscriptions[] | null transcriptions: DBCallTranscriptions[] | null
@OneToMany(() => DBCallUsers, (callUser: DBCallUsers) => callUser.call, {nullable: true}) @OneToMany(() => DBCallUsers, (callUser: DBCallUsers) => callUser.call, {nullable: true})
participants: DBCallUsers | null participants: DBCallUsers[] | null
} }