Compare commits
3 Commits
6dece81a93
...
3c1a66eec1
Author | SHA1 | Date |
---|---|---|
paring | 3c1a66eec1 | |
paring | 8b75aef1fe | |
paring | a7dd7869f2 |
|
@ -132,4 +132,5 @@ dist
|
|||
|
||||
config.toml
|
||||
recordings
|
||||
.idea
|
||||
.idea
|
||||
prisma/data.db*
|
|
@ -4,7 +4,8 @@
|
|||
"description": "",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx src/index.ts"
|
||||
"dev": "tsx src/index.ts",
|
||||
"merge": "tsx src/merge.ts"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
@ -23,10 +24,12 @@
|
|||
"@discordjs/rest": "^1.7.1",
|
||||
"@discordjs/voice": "^0.16.0",
|
||||
"@discordjs/ws": "^0.8.3",
|
||||
"@prisma/client": "4.16.1",
|
||||
"discord-api-types": "^0.37.46",
|
||||
"libsodium-wrappers": "^0.7.11",
|
||||
"node-crc": "1.3.2",
|
||||
"prism-media": "2.0.0-alpha.0",
|
||||
"prisma": "^4.16.1",
|
||||
"toml": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,6 +23,9 @@ dependencies:
|
|||
'@discordjs/ws':
|
||||
specifier: ^0.8.3
|
||||
version: 0.8.3
|
||||
'@prisma/client':
|
||||
specifier: 4.16.1
|
||||
version: 4.16.1(prisma@4.16.1)
|
||||
discord-api-types:
|
||||
specifier: ^0.37.46
|
||||
version: 0.37.46
|
||||
|
@ -35,6 +38,9 @@ dependencies:
|
|||
prism-media:
|
||||
specifier: 2.0.0-alpha.0
|
||||
version: 2.0.0-alpha.0
|
||||
prisma:
|
||||
specifier: ^4.16.1
|
||||
version: 4.16.1
|
||||
toml:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
|
@ -379,6 +385,29 @@ packages:
|
|||
dev: true
|
||||
optional: true
|
||||
|
||||
/@prisma/client@4.16.1(prisma@4.16.1):
|
||||
resolution: {integrity: sha512-CoDHu7Bt+NuDo40ijoeHP79EHtECsPBTy3yte5Yo3op8TqXt/kV0OT5OrsWewKvQGKFMHhYQ+ePed3zzjYdGAw==}
|
||||
engines: {node: '>=14.17'}
|
||||
requiresBuild: true
|
||||
peerDependencies:
|
||||
prisma: '*'
|
||||
peerDependenciesMeta:
|
||||
prisma:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@prisma/engines-version': 4.16.0-66.b20ead4d3ab9e78ac112966e242ded703f4a052c
|
||||
prisma: 4.16.1
|
||||
dev: false
|
||||
|
||||
/@prisma/engines-version@4.16.0-66.b20ead4d3ab9e78ac112966e242ded703f4a052c:
|
||||
resolution: {integrity: sha512-tMWAF/qF00fbUH1HB4Yjmz6bjh7fzkb7Y3NRoUfMlHu6V+O45MGvqwYxqwBjn1BIUXkl3r04W351D4qdJjrgvA==}
|
||||
dev: false
|
||||
|
||||
/@prisma/engines@4.16.1:
|
||||
resolution: {integrity: sha512-gpZG0kGGxfemgvK/LghHdBIz+crHkZjzszja94xp4oytpsXrgt/Ice82MvPsWMleVIniKuARrowtsIsim0PFJQ==}
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
|
||||
/@sapphire/async-queue@1.5.0:
|
||||
resolution: {integrity: sha512-JkLdIsP8fPAdh9ZZjrbHWR/+mZj0wvKS5ICibcLrRI1j84UmLMshx5n9QmL8b95d4onJ2xxiyugTgSAX7AalmA==}
|
||||
engines: {node: '>=v14.0.0', npm: '>=7.0.0'}
|
||||
|
@ -803,6 +832,15 @@ packages:
|
|||
duplex-child-process: 1.0.1
|
||||
dev: false
|
||||
|
||||
/prisma@4.16.1:
|
||||
resolution: {integrity: sha512-C2Xm7yxHxjFjjscBEW4tmoraPHH/Vyu/A0XABdbaFtoiOZARsxvOM7rwc2iZ0qVxbh0bGBGBWZUSXO/52/nHBQ==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
requiresBuild: true
|
||||
dependencies:
|
||||
'@prisma/engines': 4.16.1
|
||||
dev: false
|
||||
|
||||
/readable-stream@3.6.2:
|
||||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "Session" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"channel" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "File" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"filename" TEXT NOT NULL,
|
||||
"timestamp" BIGINT NOT NULL,
|
||||
"sessionId" TEXT,
|
||||
CONSTRAINT "File_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- Made the column `sessionId` on table `File` required. This step will fail if there are existing NULL values in that column.
|
||||
|
||||
*/
|
||||
-- RedefineTables
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_File" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"filename" TEXT NOT NULL,
|
||||
"timestamp" BIGINT NOT NULL,
|
||||
"sessionId" TEXT NOT NULL,
|
||||
CONSTRAINT "File_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_File" ("filename", "id", "sessionId", "timestamp") SELECT "filename", "id", "sessionId", "timestamp" FROM "File";
|
||||
DROP TABLE "File";
|
||||
ALTER TABLE "new_File" RENAME TO "File";
|
||||
PRAGMA foreign_key_check;
|
||||
PRAGMA foreign_keys=ON;
|
|
@ -0,0 +1,3 @@
|
|||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "sqlite"
|
|
@ -0,0 +1,27 @@
|
|||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = "file:data.db"
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
channel String
|
||||
files File[]
|
||||
}
|
||||
|
||||
model File {
|
||||
id String @id @default(uuid())
|
||||
filename String
|
||||
timestamp BigInt
|
||||
|
||||
session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
sessionId String
|
||||
}
|
|
@ -4,6 +4,7 @@ import { Client, GatewayIntentBits } from "@discordjs/core"
|
|||
import { config } from "./config.js"
|
||||
import { VoiceStateManager } from "./utils/wrapper/VoiceState.js"
|
||||
import { RecordingSessionManager } from "./utils/sessions.js"
|
||||
import { PrismaClient } from "@prisma/client"
|
||||
|
||||
export const rest = new REST({ version: "10" }).setToken(config.token)
|
||||
|
||||
|
@ -20,3 +21,5 @@ export const client = new Client({ rest, gateway })
|
|||
export const voiceStateManager = new VoiceStateManager()
|
||||
|
||||
export const sessionManager = new RecordingSessionManager()
|
||||
|
||||
export const db = new PrismaClient()
|
||||
|
|
|
@ -34,7 +34,7 @@ export const startCommand = async ({
|
|||
content: "먼저 음성 채널에 들어가주세요!",
|
||||
})
|
||||
|
||||
sessionManager.addSession(data.guild_id, vc, shardId)
|
||||
await sessionManager.addSession(data.guild_id, vc, data.channel_id, shardId)
|
||||
|
||||
await api.interactions.reply(data.id, data.token, {
|
||||
content: "냥냥",
|
||||
|
|
114
src/index.ts
114
src/index.ts
|
@ -1,4 +1,10 @@
|
|||
import { client, gateway, sessionManager, voiceStateManager } from "./api.js"
|
||||
import {
|
||||
client,
|
||||
db,
|
||||
gateway,
|
||||
sessionManager,
|
||||
voiceStateManager,
|
||||
} from "./api.js"
|
||||
|
||||
import {
|
||||
ActivityType,
|
||||
|
@ -13,6 +19,10 @@ import { startCommand } from "./commands/start.js"
|
|||
import { config } from "./config.js"
|
||||
import { syncCommands } from "./utils/commands.js"
|
||||
import { VoiceState } from "./utils/wrapper/VoiceState.js"
|
||||
import { isMe } from "./utils/appid.js"
|
||||
import { mergeAudio } from "./utils/merge.js"
|
||||
import { readFile, rm } from "fs/promises"
|
||||
import path from "path"
|
||||
|
||||
process.on("uncaughtException", (error) => console.error(error))
|
||||
process.on("unhandledRejection", (error) => console.error(error))
|
||||
|
@ -82,20 +92,100 @@ client.on(GatewayDispatchEvents.InteractionCreate, async (args) => {
|
|||
}
|
||||
})
|
||||
|
||||
client.on(GatewayDispatchEvents.VoiceStateUpdate, async (args) => {
|
||||
if (args.data.guild_id) {
|
||||
const adapter = sessionManager.adapters.get(args.data.guild_id)
|
||||
if (adapter) adapter.onVoiceStateUpdate(args.data)
|
||||
const guild = voiceStateManager.guilds.get(args.data.guild_id)
|
||||
client.on(GatewayDispatchEvents.VoiceStateUpdate, async ({ data, api }) => {
|
||||
if (data.guild_id) {
|
||||
const adapter = sessionManager.adapters.get(data.guild_id)
|
||||
if (adapter) adapter.onVoiceStateUpdate(data)
|
||||
const guild = voiceStateManager.guilds.get(data.guild_id)
|
||||
if (guild) {
|
||||
const state = guild.get(args.data.user_id)
|
||||
const state = guild.get(data.user_id)
|
||||
if (state) {
|
||||
state.update(args.data)
|
||||
state.update(data)
|
||||
} else {
|
||||
guild.set(
|
||||
args.data.user_id,
|
||||
new VoiceState(args.data, args.data.guild_id)
|
||||
)
|
||||
guild.set(data.user_id, new VoiceState(data, data.guild_id))
|
||||
}
|
||||
|
||||
const session = sessionManager.sessionMap.get(data.guild_id)
|
||||
|
||||
if (session) {
|
||||
let counter = 0
|
||||
let hasMe = false
|
||||
|
||||
for (const state of guild.values()) {
|
||||
if (state.channelId !== session.voiceChannelId) continue
|
||||
if (isMe(state.id)) {
|
||||
hasMe = true
|
||||
continue
|
||||
}
|
||||
counter++
|
||||
}
|
||||
|
||||
if (counter === 0 || !hasMe) {
|
||||
session.vc.destroy()
|
||||
sessionManager.removeSession(data.guild_id)
|
||||
await api.channels.createMessage(session.textChannelId, {
|
||||
content: `녹화가 종료되었어요! 오디오 파일 합치는 중...`,
|
||||
})
|
||||
setTimeout(async () => {
|
||||
await mergeAudio(session.id, async (code) => {
|
||||
if (code === 0) {
|
||||
let succeeded = false
|
||||
try {
|
||||
await api.channels.createMessage(
|
||||
session.textChannelId,
|
||||
{
|
||||
content:
|
||||
"오디오 파일 합치기가 완료되었어요!",
|
||||
files: [
|
||||
{
|
||||
contentType: "audio/ogg",
|
||||
name: `${session.id}.ogg`,
|
||||
data: await readFile(
|
||||
path.resolve(
|
||||
"recordings",
|
||||
"final",
|
||||
`${session.id}.ogg`
|
||||
)
|
||||
),
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
succeeded = true
|
||||
} catch {
|
||||
await api.channels.createMessage(
|
||||
session.textChannelId,
|
||||
{
|
||||
content: `오디오 파일 합치기가 완료되었...지만 파일 업로드에 실패했어요.\n파일 위치: ${path.join(
|
||||
"recordings",
|
||||
"final",
|
||||
`${session.id}.ogg`
|
||||
)}`,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (succeeded) {
|
||||
await rm(
|
||||
path.resolve("recordings", session.id),
|
||||
{ recursive: true, force: true }
|
||||
)
|
||||
|
||||
await db.session.delete({
|
||||
where: { id: session.id },
|
||||
})
|
||||
}
|
||||
} else {
|
||||
await api.channels.createMessage(
|
||||
session.textChannelId,
|
||||
{
|
||||
content: `ffmpeg 명령어 실행에 실패했어요! (exit code ${code}) ID: ${session.id}`,
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
import { db } from "./api.js"
|
||||
import { mergeAudio } from "./utils/merge.js"
|
||||
|
||||
const id = process.argv[2]
|
||||
|
||||
const session = await db.session.findUnique({ where: { id } })
|
||||
|
||||
if (!session) {
|
||||
throw new Error("session not found")
|
||||
}
|
||||
|
||||
await mergeAudio(id)
|
|
@ -1,3 +1,8 @@
|
|||
import { config } from "../config.js"
|
||||
|
||||
export const getAppIdFromToken = (token: string) => {
|
||||
return atob(token.split(".")[0])
|
||||
}
|
||||
|
||||
export const isMe = (userId: string) =>
|
||||
userId === getAppIdFromToken(config.token)
|
||||
|
|
|
@ -0,0 +1,70 @@
|
|||
import path from "path"
|
||||
import { db } from "../api.js"
|
||||
import { spawn } from "child_process"
|
||||
import { existsSync, mkdirSync } from "fs"
|
||||
|
||||
export const mergeAudio = async (
|
||||
id: string,
|
||||
callback?: (code: number | null) => void
|
||||
) => {
|
||||
const finalDir = "recordings/final"
|
||||
const sourceDir = path.resolve("recordings", id)
|
||||
|
||||
if (!existsSync(finalDir)) {
|
||||
mkdirSync(finalDir, { recursive: true })
|
||||
}
|
||||
|
||||
const files = await db.file.findMany({
|
||||
where: {
|
||||
session: {
|
||||
id,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let inputs: string[] = []
|
||||
|
||||
let filter: string[] = []
|
||||
let trackNames: string[] = []
|
||||
|
||||
let counter = 0
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(sourceDir, file.filename)
|
||||
const index = counter++
|
||||
const trackName = `[c${index}]`
|
||||
|
||||
inputs.push("-i", filePath)
|
||||
|
||||
filter.push(
|
||||
`[${index}:a] adelay=${file.timestamp}|${file.timestamp} ${trackName}`
|
||||
)
|
||||
|
||||
trackNames.push(trackName)
|
||||
}
|
||||
|
||||
filter.push(
|
||||
`${trackNames.join(" ")} amix=inputs=${
|
||||
trackNames.length
|
||||
}:normalize=0 [out]`
|
||||
)
|
||||
|
||||
const args = [
|
||||
...inputs,
|
||||
"-filter_complex",
|
||||
filter.join(";"),
|
||||
"-map",
|
||||
"[out]",
|
||||
"-async",
|
||||
"1",
|
||||
"-y",
|
||||
path.join(finalDir, `${id}.ogg`),
|
||||
]
|
||||
|
||||
const p = spawn("ffmpeg", args).on("exit", (code) => {
|
||||
console.log("ffmpeg exited with code", code)
|
||||
|
||||
callback?.(code)
|
||||
})
|
||||
p.stderr.pipe(process.stderr)
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import { Collection } from "@discordjs/collection"
|
||||
import { gateway, voiceStateManager } from "../api.js"
|
||||
import { db, gateway, voiceStateManager } from "../api.js"
|
||||
import {
|
||||
AudioReceiveStream,
|
||||
DiscordGatewayAdapterLibraryMethods,
|
||||
|
@ -11,9 +11,9 @@ import { Snowflake } from "discord-api-types/v10"
|
|||
import * as prism from "prism-media"
|
||||
import { pipeline } from "stream"
|
||||
import { createWriteStream, existsSync, mkdirSync } from "fs"
|
||||
import { getAppIdFromToken } from "./appid.js"
|
||||
import { config } from "../config.js"
|
||||
import path from "path"
|
||||
import { isMe } from "./appid.js"
|
||||
import { exec, spawn } from "child_process"
|
||||
|
||||
export class RecordingSessionManager {
|
||||
sessionMap = new Collection<Snowflake, RecordingSession>()
|
||||
|
@ -23,11 +23,16 @@ export class RecordingSessionManager {
|
|||
return this.sessionMap.has(guild)
|
||||
}
|
||||
|
||||
addSession(guild: Snowflake, channel: Snowflake, shardId: number) {
|
||||
async addSession(
|
||||
guild: Snowflake,
|
||||
voiceChannel: Snowflake,
|
||||
textChannel: Snowflake,
|
||||
shardId: number
|
||||
) {
|
||||
const session = new RecordingSession(
|
||||
joinVoiceChannel({
|
||||
guildId: guild,
|
||||
channelId: channel,
|
||||
channelId: voiceChannel,
|
||||
adapterCreator: (methods) => {
|
||||
this.adapters.set(guild, methods)
|
||||
return {
|
||||
|
@ -43,22 +48,46 @@ export class RecordingSessionManager {
|
|||
selfDeaf: false,
|
||||
selfMute: true,
|
||||
debug: true,
|
||||
})
|
||||
}),
|
||||
voiceChannel,
|
||||
textChannel
|
||||
)
|
||||
|
||||
await session.init()
|
||||
|
||||
this.sessionMap.set(guild, session)
|
||||
}
|
||||
|
||||
removeSession(guild: Snowflake) {
|
||||
const session = this.sessionMap.get(guild)
|
||||
if (session) session.shutdown()
|
||||
this.sessionMap.delete(guild)
|
||||
}
|
||||
}
|
||||
|
||||
export class RecordingSession {
|
||||
id: string
|
||||
streams = new Collection<Snowflake, UserStream>()
|
||||
startedAt = Date.now()
|
||||
id!: string
|
||||
|
||||
constructor(public vc: VoiceConnection) {
|
||||
this.id = Date.now().toString()
|
||||
vc.receiver.speaking.on("start", (userId) => this.addReceiver(userId))
|
||||
vc.receiver.speaking.on("end", (userId) => this.removeReceiver(userId))
|
||||
constructor(
|
||||
public vc: VoiceConnection,
|
||||
public voiceChannelId: Snowflake,
|
||||
public textChannelId: Snowflake
|
||||
) {}
|
||||
|
||||
async init() {
|
||||
const result = await db.session.create({
|
||||
data: {
|
||||
id: this.id,
|
||||
channel: this.voiceChannelId,
|
||||
},
|
||||
})
|
||||
this.id = result.id
|
||||
this.receiver.speaking.on("start", (userId) => this.addReceiver(userId))
|
||||
this.receiver.speaking.on("end", (userId) =>
|
||||
this.removeReceiver(userId)
|
||||
)
|
||||
}
|
||||
|
||||
get receiver() {
|
||||
|
@ -66,11 +95,9 @@ export class RecordingSession {
|
|||
}
|
||||
|
||||
addReceiver(userId: string) {
|
||||
if (userId === getAppIdFromToken(config.token)) return
|
||||
if (isMe(userId)) return
|
||||
if (this.streams.has(userId)) return
|
||||
|
||||
console.log("Starting record:", userId)
|
||||
|
||||
const receiver = this.receiver.subscribe(userId)
|
||||
|
||||
this.streams.set(userId, new UserStream(this, receiver, userId))
|
||||
|
@ -80,11 +107,12 @@ export class RecordingSession {
|
|||
const stream = this.streams.get(userId)
|
||||
|
||||
if (stream) {
|
||||
console.log("Stopping record:", userId)
|
||||
stream.subscription.push(null)
|
||||
this.streams.delete(userId)
|
||||
}
|
||||
}
|
||||
|
||||
async shutdown() {}
|
||||
}
|
||||
|
||||
class UserStream {
|
||||
|
@ -105,7 +133,9 @@ class UserStream {
|
|||
},
|
||||
})
|
||||
|
||||
const dir = path.resolve("recordings", session.id, userId)
|
||||
const dir = path.resolve("recordings", session.id)
|
||||
const timestamp = Date.now() - session.startedAt
|
||||
const file = `${userId}-${timestamp}.ogg`
|
||||
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, {
|
||||
|
@ -113,20 +143,36 @@ class UserStream {
|
|||
})
|
||||
}
|
||||
|
||||
const filename = path.join(dir, `${Date.now() - session.startedAt}.ogg`)
|
||||
const filename = path.join(dir, file)
|
||||
|
||||
console.log(`Recording ${filename} started`)
|
||||
|
||||
const outputStream = createWriteStream(filename)
|
||||
|
||||
pipeline(subscription, this.oggStream, outputStream, (err) => {
|
||||
pipeline(subscription, this.oggStream, outputStream, async (err) => {
|
||||
outputStream.end()
|
||||
|
||||
if (err) {
|
||||
console.log(`Recording ${userId} ended with error: ${err}`)
|
||||
console.log(`Recording ${filename} ended with error: ${err}`)
|
||||
subscription.destroy(err)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Recording ${userId} ended`)
|
||||
await db.file
|
||||
.create({
|
||||
data: {
|
||||
session: {
|
||||
connect: {
|
||||
id: session.id,
|
||||
},
|
||||
},
|
||||
filename: file,
|
||||
timestamp,
|
||||
},
|
||||
})
|
||||
.catch(console.error)
|
||||
|
||||
console.log(`Recording ${filename} ended`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue