Add eslint, prettier and update node-version

This commit is contained in:
kimpure 2026-05-21 17:53:04 +00:00
parent ebb786b3ed
commit fcefc5a41f
No known key found for this signature in database
50 changed files with 4575 additions and 1851 deletions

View file

@ -1 +1 @@
v24.13.0
v26.2.0

6
.prettierrc Normal file
View file

@ -0,0 +1,6 @@
{
"tabWidth": 2,
"semi": true,
"singleQuote": false,
"trailingComma": "all"
}

3
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
}

14
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,14 @@
{
"[typescript]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}

View file

@ -5,7 +5,7 @@ use std::{collections::HashMap, path::PathBuf};
pub mod api;
pub mod tts;
use tts::{TtsOpts, TtsPool, load_text_to_speech, load_voice_style};
use tts::{TtsOpts, TtsPool, load_text_to_speech};
use crate::tts::engine::load_voice_style_map;
@ -18,7 +18,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
std::env::var("SUPERTONIC_MODEL_DIR").unwrap_or_else(|_| "./assets".to_string());
let voice_style_path = std::env::var("SUPERTONIC_VOICE_STYLE")
.unwrap_or_else(|_| format!("F1={model_dir}/voice_styles/F1.json"));
let lang = std::env::var("SUPERTONIC_LANG").unwrap_or_else(|_| "en".to_string());
let _lang = std::env::var("SUPERTONIC_LANG").unwrap_or_else(|_| "en".to_string());
let total_step: usize = std::env::var("SUPERTONIC_TOTAL_STEP")
.ok()
.and_then(|v| v.parse().ok())

View file

@ -0,0 +1,18 @@
// @ts-check
import js from "@eslint/js";
import { defineConfig } from "eslint/config";
import tseslint from "typescript-eslint";
export default defineConfig({
extends: [js.configs.recommended, tseslint.configs.recommended],
basePath: "packages",
files: ["**/*.ts"],
linterOptions: {
reportUnusedDisableDirectives: "off", // Or use false / 0
},
rules: {
"@typescript-eslint/no-namespace": "off",
"@typescript-eslint/no-explicit-any": "off",
},
});

1289
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,15 +1,19 @@
{
"main": "dist",
"type": "module",
"scripts": {
"prisma:push": "prisma db push",
"format:prisma": "prisma format",
"format": "npm run format:prisma",
"prisma:format": "prisma format",
"check": "npm run check:tsc && npm run check:eslint && npm run check:format",
"check:eslint": "eslint",
"check:tsc": "./node_modules/typescript/bin/tsc --noEmit",
"check:format": "prettier --check .",
"fix:format": "prettier --write .",
"build:prisma": "prisma generate",
"build:tsc": "tsc",
"build:tsc": "./node_modules/typescript/bin/tsc",
"build": "npm run build:prisma && npm run build:tsc",
"start": "prisma migrate deploy && node .",
"start:no-db": "npm run build:tsc && node .",
"dev": "npm run build && npm run start"
"start": "prisma migrate deploy && node --import=extensionless/register .",
"dev": "npm run build && node --import=extensionless/register ."
},
"dependencies": {
"@discordjs/opus": "^0.10.0",
@ -19,18 +23,23 @@
"@snazzah/davey": "^0.1.9",
"discord.js": "^14.25.1",
"dotenv": "^17.2.3",
"extensionless": "^2.0.6",
"fastify": "^5.7.1",
"libsodium-wrappers": "^0.8.0",
"opusscript": "^0.0.8",
"pg": "^8.17.1",
"play-dl": "^1.9.7",
"prism-media": "^1.3.5",
"prisma": "^7.2.0",
"typescript": "^5.9.3"
"prisma": "^7.2.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/ffprobe-static": "^2.0.3",
"@types/node": "25.0.9",
"@types/pg": "^8.16.0"
"@types/pg": "^8.16.0",
"eslint": "^10.4.0",
"prettier": "^3.8.3",
"typescript-eslint": "^8.59.4",
"typescript": "^5.9.3"
}
}

View file

@ -1,7 +1,7 @@
import { setUserCanTypecast } from "./db";
export const AdminUsers = [ "858173387775148073", "367946917197381644" ];
export const AdminUsers = ["858173387775148073", "367946917197381644"];
AdminUsers.forEach(userid => {
setUserCanTypecast(userid, true);
AdminUsers.forEach((userid) => {
setUserCanTypecast(userid, true);
});

View file

@ -1,41 +1,56 @@
import { ChatInputCommandInteraction, SlashCommandBuilder, SlashCommandOptionsOnlyBuilder, SlashCommandSubcommandBuilder } from "discord.js";
import {
ChatInputCommandInteraction,
SlashCommandBuilder,
type SlashCommandOptionsOnlyBuilder,
SlashCommandSubcommandBuilder,
} from "discord.js";
import { join } from "node:path";
import { requireDirectorySync } from "../utils/requireDirectory";
import { requireDirectory } from "../utils/requireDirectory";
import { AdminUsers } from "./admin";
export type DiscordCommandData = SlashCommandBuilder | SlashCommandOptionsOnlyBuilder | SlashCommandSubcommandBuilder
export type DiscordCommandExecute = (interaction: ChatInputCommandInteraction) => Promise<any>
export type DiscordCommandData =
| SlashCommandBuilder
| SlashCommandOptionsOnlyBuilder
| SlashCommandSubcommandBuilder;
export type DiscordCommandExecute = (
interaction: ChatInputCommandInteraction,
) => Promise<any>;
export interface DiscordCommand {
data: DiscordCommandData
execute: DiscordCommandExecute
data: DiscordCommandData;
execute: DiscordCommandExecute;
}
export function defineCommand(
data: DiscordCommandData,
execute: DiscordCommandExecute,
isAdminCommand=false,
data: DiscordCommandData,
execute: DiscordCommandExecute,
isAdminCommand = false,
): DiscordCommand {
if (isAdminCommand) {
return {
data: data,
execute: async (interaction: ChatInputCommandInteraction): Promise<any> => {
if (AdminUsers.includes(interaction.user.id)) {
execute(interaction);
} else {
interaction.reply("당신은 어드민이 아닙니다");
}
}
}
}
if (isAdminCommand) {
return {
data: data,
execute: execute,
}
data: data,
execute: async (
interaction: ChatInputCommandInteraction,
): Promise<any> => {
if (AdminUsers.includes(interaction.user.id)) {
execute(interaction);
} else {
interaction.reply("당신은 어드민이 아닙니다");
}
},
};
}
return {
data: data,
execute: execute,
};
}
const commandDirectory = join(__dirname, "commands");
export const defineCommands = requireDirectorySync<DiscordCommand>(commandDirectory);
const commandDirectory = join(import.meta.dirname, "commands");
export const defineCommands =
await requireDirectory<DiscordCommand>(commandDirectory);
export const commandExecuteNameHashMap: {
[key: string]: DiscordCommandExecute
} = Object.fromEntries(defineCommands.map(command => [command.data.name, command.execute]));
[key: string]: DiscordCommandExecute;
} = Object.fromEntries(
defineCommands.map((command) => [command.data.name, command.execute]),
);

View file

@ -1,28 +1,35 @@
import { ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder } from "discord.js";
import { defineCommand } from "../command";
import { getGuildProfile } from "../db";
import {
ChatInputCommandInteraction,
MessageFlags,
SlashCommandBuilder,
} from "discord.js";
import { defineCommand } from "../command.js";
import { getGuildProfile } from "../db.js";
export default defineCommand(
new SlashCommandBuilder()
.setName("읽는채널")
.setDescription("예주가 읽어주는 채널들을 말해줘요"),
async (interaction: ChatInputCommandInteraction): Promise<any> => {
await interaction.deferReply({
flags: [MessageFlags.Ephemeral]
});
new SlashCommandBuilder()
.setName("읽는채널")
.setDescription("예주가 읽어주는 채널들을 말해줘요"),
async (interaction: ChatInputCommandInteraction): Promise<any> => {
await interaction.deferReply({
flags: [MessageFlags.Ephemeral],
});
const guildId = interaction.guildId;
const guildId = interaction.guildId;
if (guildId == null)
return await interaction.editReply("알수없는 서버에요!");
if (guildId == null)
return await interaction.editReply("알수없는 서버에요!");
try {
const guildProfile = await getGuildProfile(guildId);
const readChannel = guildProfile.readChannel;
try {
const guildProfile = await getGuildProfile(guildId);
const readChannel = guildProfile.readChannel;
await interaction.editReply(readChannel.map(channel => `<#${channel}>`).join("\n") || "아무 채널도 읽지 않아요!");
} catch {
await interaction.editReply("서버 프로필을 가져오는데 문제가 생겼어요");
}
await interaction.editReply(
readChannel.map((channel: any) => `<#${channel}>`).join("\n") ||
"아무 채널도 읽지 않아요!",
);
} catch {
await interaction.editReply("서버 프로필을 가져오는데 문제가 생겼어요");
}
)
},
);

View file

@ -1,22 +1,26 @@
import { AttachmentBuilder, ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder } from "discord.js";
import {
AttachmentBuilder,
ChatInputCommandInteraction,
SlashCommandBuilder,
} from "discord.js";
import { defineCommand } from "../command";
import { OutputHandler } from "../../utils/outputHandler";
export default defineCommand(
new SlashCommandBuilder()
.setName("상태")
.setDescription("예주의 상태를 확인해요"),
async (interaction: ChatInputCommandInteraction): Promise<any> => {
if (interaction.guild == null)
return interaction.reply("올바르지 않은 서버에요");
new SlashCommandBuilder()
.setName("상태")
.setDescription("예주의 상태를 확인해요"),
async (interaction: ChatInputCommandInteraction): Promise<any> => {
if (interaction.guild == null)
return interaction.reply("올바르지 않은 서버에요");
const buffer = Buffer.from(await OutputHandler.getErrorLog(), 'utf-8');
const attachment = new AttachmentBuilder(buffer, { name: 'result.txt' });
const buffer = Buffer.from(await OutputHandler.getErrorLog(), "utf-8");
const attachment = new AttachmentBuilder(buffer, { name: "result.txt" });
await interaction.reply({
content: "제 상태에요",
files: [attachment]
});
},
true
)
await interaction.reply({
content: "제 상태에요",
files: [attachment],
});
},
true,
);

View file

@ -1,48 +1,56 @@
import { Channel, ChannelType, ChatInputCommandInteraction, GuildMember, MessageFlags, SlashCommandBuilder } from "discord.js";
import {
type Channel,
ChannelType,
ChatInputCommandInteraction,
GuildMember,
MessageFlags,
SlashCommandBuilder,
} from "discord.js";
import { defineCommand } from "../command";
import { joinVoiceChannel } from "@discordjs/voice";
export default defineCommand(
new SlashCommandBuilder()
.setName("등장")
.setDescription("예주가 등장해요")
.addChannelOption(option =>
option
.setName("channel")
.setDescription("예주가 등장할 채널이에요")
.addChannelTypes(ChannelType.GuildVoice)
),
async (interaction: ChatInputCommandInteraction): Promise<any> => {
await interaction.deferReply({
flags: [MessageFlags.Ephemeral]
});
new SlashCommandBuilder()
.setName("등장")
.setDescription("예주가 등장해요")
.addChannelOption((option) =>
option
.setName("channel")
.setDescription("예주가 등장할 채널이에요")
.addChannelTypes(ChannelType.GuildVoice),
),
async (interaction: ChatInputCommandInteraction): Promise<any> => {
await interaction.deferReply({
flags: [MessageFlags.Ephemeral],
});
const member = interaction.member as GuildMember | null;
if (member == null)
return;
const member = interaction.member as GuildMember | null;
if (member == null) return;
if (interaction.guild == null)
return interaction.reply({
content: "올바르지 않은 서버에요",
ephemeral: true,
});
if (interaction.guild == null)
return interaction.reply({
content: "올바르지 않은 서버에요",
ephemeral: true,
});
const channel = interaction.options.getChannel("channel") as Channel || member.voice.channel;
const channel =
(interaction.options.getChannel("channel") as Channel) ||
member.voice.channel;
if (channel == null)
return interaction.reply({
content: "통화방에 들어가거나 채널을 지정해주세요!",
ephemeral: true,
});
if (channel == null)
return interaction.reply({
content: "통화방에 들어가거나 채널을 지정해주세요!",
ephemeral: true,
});
joinVoiceChannel({
channelId: channel.id,
guildId: interaction.guild.id,
adapterCreator: interaction.guild.voiceAdapterCreator,
selfDeaf: false,
selfMute: false,
});
joinVoiceChannel({
channelId: channel.id,
guildId: interaction.guild.id,
adapterCreator: interaction.guild.voiceAdapterCreator,
selfDeaf: false,
selfMute: false,
});
await interaction.editReply("등장했어요!");
}
)
await interaction.editReply("등장했어요!");
},
);

View file

@ -1,25 +1,27 @@
import { ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder } from "discord.js";
import { defineCommand, DiscordCommand } from "../command";
import {
ChatInputCommandInteraction,
MessageFlags,
SlashCommandBuilder,
} from "discord.js";
import { defineCommand } from "../command";
import { getVoiceConnection } from "@discordjs/voice";
export default defineCommand(
new SlashCommandBuilder()
.setName("퇴장")
.setDescription("예주가 퇴장해요"),
async (interaction: ChatInputCommandInteraction): Promise<any> => {
await interaction.deferReply({
flags: [MessageFlags.Ephemeral]
});
new SlashCommandBuilder().setName("퇴장").setDescription("예주가 퇴장해요"),
async (interaction: ChatInputCommandInteraction): Promise<any> => {
await interaction.deferReply({
flags: [MessageFlags.Ephemeral],
});
if (interaction.guild == null)
return interaction.editReply("올바르지 않은 서버에요");
if (interaction.guild == null)
return interaction.editReply("올바르지 않은 서버에요");
const connection = getVoiceConnection(interaction.guild.id);
if (!connection)
return interaction.editReply("예주는 통화방에 존제하지 않아요");
const connection = getVoiceConnection(interaction.guild.id);
if (!connection)
return interaction.editReply("예주는 통화방에 존제하지 않아요");
connection.disconnect();
connection.disconnect();
await interaction.editReply("퇴장했어요!");
}
)
await interaction.editReply("퇴장했어요!");
},
);

View file

@ -1,38 +1,42 @@
import { ChatInputCommandInteraction, MessageFlags, SlashCommandSubcommandBuilder } from "discord.js";
import {
ChatInputCommandInteraction,
MessageFlags,
SlashCommandSubcommandBuilder,
} from "discord.js";
import { defineCommand } from "../command";
import { playVoice } from "../tts";
import { getUserProfile } from "../db";
export default defineCommand(
new SlashCommandSubcommandBuilder()
.setName("말")
.setDescription("구구가가")
.addStringOption(option =>
option
.setName("content")
.setDescription("예주가 말해준데요")
.setRequired(true)
),
async (interaction: ChatInputCommandInteraction): Promise<any> => {
await interaction.deferReply({
flags: [MessageFlags.Ephemeral]
});
new SlashCommandSubcommandBuilder()
.setName("말")
.setDescription("구구가가")
.addStringOption((option) =>
option
.setName("content")
.setDescription("예주가 말해준데요")
.setRequired(true),
),
async (interaction: ChatInputCommandInteraction): Promise<any> => {
await interaction.deferReply({
flags: [MessageFlags.Ephemeral],
});
if (interaction.guild == null)
return await interaction.editReply("올바르지 않은 서버에요");
if (interaction.guild == null)
return await interaction.editReply("올바르지 않은 서버에요");
try {
const userProfile = await getUserProfile(interaction.user.id);
await playVoice(
interaction.guild,
userProfile,
userProfile.voice,
interaction.options.getString("content") as string
);
try {
const userProfile = await getUserProfile(interaction.user.id);
await playVoice(
interaction.guild,
userProfile,
userProfile.voice,
interaction.options.getString("content") as string,
);
await interaction.editReply("말했어요!");
} catch {
await interaction.editReply("오늘따라 말이 꼬이네요 ㅜ.ㅜ");
}
await interaction.editReply("말했어요!");
} catch {
await interaction.editReply("오늘따라 말이 꼬이네요 ㅜ.ㅜ");
}
)
},
);

View file

@ -1,36 +1,42 @@
import { Channel, ChannelType, ChatInputCommandInteraction, MessageFlags, SlashCommandSubcommandBuilder } from "discord.js";
import { defineCommand, DiscordCommand } from "../command";
import {
type Channel,
ChannelType,
ChatInputCommandInteraction,
MessageFlags,
SlashCommandSubcommandBuilder,
} from "discord.js";
import { defineCommand } from "../command";
import { insertGuildReadChannel } from "../db";
export default defineCommand(
new SlashCommandSubcommandBuilder()
.setName("읽어")
.setDescription("예주가 해당 채널을 읽어줘요")
.addChannelOption(option =>
option
.setName("channel")
.addChannelTypes(ChannelType.GuildText, ChannelType.GuildVoice)
.setDescription("예주가 읽을 채널이에요")
.setRequired(true)
),
async (interaction: ChatInputCommandInteraction): Promise<any> => {
await interaction.deferReply({
flags: [MessageFlags.Ephemeral]
});
new SlashCommandSubcommandBuilder()
.setName("읽어")
.setDescription("예주가 해당 채널을 읽어줘요")
.addChannelOption((option) =>
option
.setName("channel")
.addChannelTypes(ChannelType.GuildText, ChannelType.GuildVoice)
.setDescription("예주가 읽을 채널이에요")
.setRequired(true),
),
async (interaction: ChatInputCommandInteraction): Promise<any> => {
await interaction.deferReply({
flags: [MessageFlags.Ephemeral],
});
const channel = interaction.options.getChannel("channel") as Channel;
const guildId = interaction.guildId;
const channel = interaction.options.getChannel("channel") as Channel;
const guildId = interaction.guildId;
if (guildId == null)
return await interaction.editReply("알수없는 서버에요!");
if (guildId == null)
return await interaction.editReply("알수없는 서버에요!");
try {
await insertGuildReadChannel(guildId, channel.id);
} catch {
return await interaction.editReply("읽는대 너무 어려워요..");
}
try {
await insertGuildReadChannel(guildId, channel.id);
} catch {
return await interaction.editReply("읽는대 너무 어려워요..");
}
await interaction.editReply("예주가 이제 이 채널을 읽어요!");
},
true
)
await interaction.editReply("예주가 이제 이 채널을 읽어요!");
},
true,
);

View file

@ -1,19 +1,21 @@
import { ChatInputCommandInteraction, GuildMember, MessageFlags, SlashCommandBuilder } from "discord.js";
import {
ChatInputCommandInteraction,
MessageFlags,
SlashCommandBuilder,
} from "discord.js";
import { defineCommand } from "../command";
import { getUserProfile, setUserNya } from "../db";
export default defineCommand(
new SlashCommandBuilder()
.setName("냥")
.setDescription("???"),
async (interaction: ChatInputCommandInteraction): Promise<any> => {
await interaction.deferReply({
flags: [MessageFlags.Ephemeral]
});
new SlashCommandBuilder().setName("냥").setDescription("???"),
async (interaction: ChatInputCommandInteraction): Promise<any> => {
await interaction.deferReply({
flags: [MessageFlags.Ephemeral],
});
const profile = await getUserProfile(interaction.user.id);
await setUserNya(interaction.user.id, !profile.nya);
const profile = await getUserProfile(interaction.user.id);
await setUserNya(interaction.user.id, !profile.nya);
await interaction.editReply(profile.nya ? "냐앙..." : "냐앙!!");
}
)
await interaction.editReply(profile.nya ? "냐앙..." : "냐앙!!");
},
);

View file

@ -1,29 +1,31 @@
import { ChatInputCommandInteraction, MessageFlags, SlashCommandSubcommandBuilder } from "discord.js";
import {
ChatInputCommandInteraction,
MessageFlags,
SlashCommandSubcommandBuilder,
} from "discord.js";
import { defineCommand } from "../command";
import { setUserSupertonicStyle } from "../db";
import { SUPERTONIC_STYLE_LIST } from "../../env";
export default defineCommand(
new SlashCommandSubcommandBuilder()
.setName("슈퍼토닉목소리")
.setDescription("예주의 슈퍼토닉 목소리 스타일을 설정해요")
.addStringOption(option =>
option
.setName("style")
.setDescription("사용할수 있는 목소리들이에요")
.setRequired(true)
.addChoices(
...SUPERTONIC_STYLE_LIST
)
),
async (interaction: ChatInputCommandInteraction): Promise<any> => {
await interaction.deferReply({
flags: [MessageFlags.Ephemeral]
});
new SlashCommandSubcommandBuilder()
.setName("슈퍼토닉목소리")
.setDescription("예주의 슈퍼토닉 목소리 스타일을 설정해요")
.addStringOption((option) =>
option
.setName("style")
.setDescription("사용할수 있는 목소리들이에요")
.setRequired(true)
.addChoices(...SUPERTONIC_STYLE_LIST),
),
async (interaction: ChatInputCommandInteraction): Promise<any> => {
await interaction.deferReply({
flags: [MessageFlags.Ephemeral],
});
const style = interaction.options.getString("style") as string;
await setUserSupertonicStyle(interaction.user.id, style);
const style = interaction.options.getString("style") as string;
await setUserSupertonicStyle(interaction.user.id, style);
await interaction.editReply("예주의 목소리 스타일을 변경했어요!");
}
)
await interaction.editReply("예주의 목소리 스타일을 변경했어요!");
},
);

View file

@ -1,31 +1,35 @@
import { ChatInputCommandInteraction, MessageFlags, SlashCommandSubcommandBuilder } from "discord.js";
import {
ChatInputCommandInteraction,
MessageFlags,
SlashCommandSubcommandBuilder,
} from "discord.js";
import { defineCommand } from "../command";
import { Voice } from "../../db/generated/prisma/enums";
import { setUserVoice } from "../db";
export default defineCommand(
new SlashCommandSubcommandBuilder()
.setName("목소리")
.setDescription("예주의 목소리를 설정해요")
.addStringOption(option =>
option
.setName("voice")
.setDescription("사용할수 있는 목소리들이에요")
.setRequired(true)
.addChoices(
{ name: "TypeCast", value: "TypeCast" },
{ name: "Papago", value: "Papago" },
{ name: "Supertonic", value: "Supertonic" }
)
new SlashCommandSubcommandBuilder()
.setName("목소리")
.setDescription("예주의 목소리를 설정해요")
.addStringOption((option) =>
option
.setName("voice")
.setDescription("사용할수 있는 목소리들이에요")
.setRequired(true)
.addChoices(
{ name: "TypeCast", value: "TypeCast" },
{ name: "Papago", value: "Papago" },
{ name: "Supertonic", value: "Supertonic" },
),
async (interaction: ChatInputCommandInteraction): Promise<any> => {
await interaction.deferReply({
flags: [MessageFlags.Ephemeral]
});
),
async (interaction: ChatInputCommandInteraction): Promise<any> => {
await interaction.deferReply({
flags: [MessageFlags.Ephemeral],
});
const voice = interaction.options.getString("voice") as Voice;
await setUserVoice(interaction.user.id, voice);
const voice = interaction.options.getString("voice") as Voice;
await setUserVoice(interaction.user.id, voice);
await interaction.editReply("예주의 목소리를 변경했어요!");
}
)
await interaction.editReply("예주의 목소리를 변경했어요!");
},
);

View file

@ -1,25 +1,29 @@
import { ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder } from "discord.js";
import {
ChatInputCommandInteraction,
MessageFlags,
SlashCommandBuilder,
} from "discord.js";
import { defineCommand } from "../command";
import { skipCurrentVoice } from "../tts";
export default defineCommand(
new SlashCommandBuilder()
.setName("스킵")
.setDescription("실행중인 보이스를 건너뜁니다"),
async (interaction: ChatInputCommandInteraction): Promise<any> => {
await interaction.deferReply({
flags: [MessageFlags.Ephemeral]
});
new SlashCommandBuilder()
.setName("스킵")
.setDescription("실행중인 보이스를 건너뜁니다"),
async (interaction: ChatInputCommandInteraction): Promise<any> => {
await interaction.deferReply({
flags: [MessageFlags.Ephemeral],
});
if (!interaction.guild) {
await interaction.editReply("서버에서만 사용할 수 있어요");
return;
}
if (await skipCurrentVoice(interaction.guild)) {
await interaction.editReply("스킵 되었어요");
} else {
await interaction.editReply("실행중인 보이스가 없어요");
}
if (!interaction.guild) {
await interaction.editReply("서버에서만 사용할 수 있어요");
return;
}
)
if (await skipCurrentVoice(interaction.guild)) {
await interaction.editReply("스킵 되었어요");
} else {
await interaction.editReply("실행중인 보이스가 없어요");
}
},
);

View file

@ -1,36 +1,42 @@
import { Channel, ChannelType, ChatInputCommandInteraction, MessageFlags, SlashCommandSubcommandBuilder } from "discord.js";
import { defineCommand, DiscordCommand } from "../command";
import { insertGuildReadChannel, removeGuildReadChannel } from "../db";
import {
type Channel,
ChannelType,
ChatInputCommandInteraction,
MessageFlags,
SlashCommandSubcommandBuilder,
} from "discord.js";
import { defineCommand } from "../command";
import { removeGuildReadChannel } from "../db";
export default defineCommand(
new SlashCommandSubcommandBuilder()
.setName("읽지마")
.setDescription("예주가 해당 채널을 더이상 읽지 않아요")
.addChannelOption(option =>
option
.setName("channel")
.addChannelTypes(ChannelType.GuildText, ChannelType.GuildVoice)
.setDescription("예주가 더이상 읽지 않을 채널이에요")
.setRequired(true)
),
async (interaction: ChatInputCommandInteraction): Promise<any> => {
await interaction.deferReply({
flags: [MessageFlags.Ephemeral]
});
new SlashCommandSubcommandBuilder()
.setName("읽지마")
.setDescription("예주가 해당 채널을 더이상 읽지 않아요")
.addChannelOption((option) =>
option
.setName("channel")
.addChannelTypes(ChannelType.GuildText, ChannelType.GuildVoice)
.setDescription("예주가 더이상 읽지 않을 채널이에요")
.setRequired(true),
),
async (interaction: ChatInputCommandInteraction): Promise<any> => {
await interaction.deferReply({
flags: [MessageFlags.Ephemeral],
});
const channel = interaction.options.getChannel("channel") as Channel;
const guildId = interaction.guildId;
const channel = interaction.options.getChannel("channel") as Channel;
const guildId = interaction.guildId;
if (guildId == null)
return await interaction.editReply("알수없는 서버에요!");
if (guildId == null)
return await interaction.editReply("알수없는 서버에요!");
try {
await removeGuildReadChannel(guildId, channel.id);
} catch {
return await interaction.editReply("읽지 않는것을 실패했어요 ?ㄴ");
}
try {
await removeGuildReadChannel(guildId, channel.id);
} catch {
return await interaction.editReply("읽지 않는것을 실패했어요 ?ㄴ");
}
await interaction.editReply(`예주가 <#${channel.id}> 채널을 읽지않아요!`);
},
true
)
await interaction.editReply(`예주가 <#${channel.id}> 채널을 읽지않아요!`);
},
true,
);

View file

@ -1,149 +1,176 @@
import { prisma } from "../db/prisma";
import { DiscordUserProfile, DiscordGuildProfile, Voice } from "../db/generated/prisma/client";
import {
type DiscordUserProfile,
type DiscordGuildProfile,
Voice,
} from "../db/generated/prisma/client";
export function getUserProfile(userId: string): Promise<DiscordUserProfile> {
return prisma.discordUserProfile.upsert({
where: {
userId: userId
},
update: {},
create: {
userId: userId,
},
});
return prisma.discordUserProfile.upsert({
where: {
userId: userId,
},
update: {},
create: {
userId: userId,
},
});
}
export async function setUserProfile(userId: string, profile: DiscordUserProfile): Promise<void> {
await prisma.discordUserProfile.upsert({
where: {
userId: userId
},
update: {
voice: profile.voice,
nya: profile.nya
},
create: {
userId: userId,
}
});
export async function setUserProfile(
userId: string,
profile: DiscordUserProfile,
): Promise<void> {
await prisma.discordUserProfile.upsert({
where: {
userId: userId,
},
update: {
voice: profile.voice,
nya: profile.nya,
},
create: {
userId: userId,
},
});
}
export async function setUserNya(userId: string, nya: boolean): Promise<void> {
await prisma.discordUserProfile.upsert({
where: {
userId: userId
},
update: {
nya: nya
},
create: {
userId: userId,
}
});
await prisma.discordUserProfile.upsert({
where: {
userId: userId,
},
update: {
nya: nya,
},
create: {
userId: userId,
},
});
}
export async function setUserSupertonicStyle(userId: string, style: string): Promise<void> {
await prisma.discordUserProfile.upsert({
where: {
userId: userId
},
update: {
userSupertonicStyle: style
},
create: {
userId: userId,
userSupertonicStyle: style
}
});
export async function setUserSupertonicStyle(
userId: string,
style: string,
): Promise<void> {
await prisma.discordUserProfile.upsert({
where: {
userId: userId,
},
update: {
userSupertonicStyle: style,
},
create: {
userId: userId,
userSupertonicStyle: style,
},
});
}
export async function setUserVoice(userId: string, voice: Voice): Promise<void> {
await prisma.discordUserProfile.upsert({
where: {
userId: userId
},
update: {
voice: voice
},
create: {
userId: userId,
voice: voice
}
});
export async function setUserVoice(
userId: string,
voice: Voice,
): Promise<void> {
await prisma.discordUserProfile.upsert({
where: {
userId: userId,
},
update: {
voice: voice,
},
create: {
userId: userId,
voice: voice,
},
});
}
export async function setUserCanTypecast(userId: string, canTypecast: boolean): Promise<void> {
await prisma.discordUserProfile.upsert({
where: {
userId: userId
},
update: {
canTypecast: canTypecast
},
create: {
userId: userId,
}
});
export async function setUserCanTypecast(
userId: string,
canTypecast: boolean,
): Promise<void> {
await prisma.discordUserProfile.upsert({
where: {
userId: userId,
},
update: {
canTypecast: canTypecast,
},
create: {
userId: userId,
},
});
}
export function getGuildProfile(guildId: string): Promise<DiscordGuildProfile> {
return prisma.discordGuildProfile.upsert({
where: { guildId: guildId },
update: {},
create: {
guildId: guildId,
},
return prisma.discordGuildProfile.upsert({
where: { guildId: guildId },
update: {},
create: {
guildId: guildId,
},
});
}
export async function hasGuildReadChannel(
guildId: string,
channelId: string,
): Promise<boolean> {
return (
(await prisma.discordGuildProfile.findFirst({
where: {
guildId: guildId,
readChannel: { has: channelId },
},
})) != null
);
}
export async function removeGuildReadChannel(
guildId: string,
channelId: string,
): Promise<void> {
const guildProfile = await prisma.discordGuildProfile.findUnique({
where: {
guildId: guildId,
},
});
if (guildProfile) {
await prisma.discordGuildProfile.update({
where: {
guildId: guildId,
},
data: {
readChannel: guildProfile.readChannel.filter(
(channel) => channel != channelId,
),
},
});
}
}
export async function hasGuildReadChannel(guildId: string, channelId: string): Promise<boolean> {
return (
await prisma.discordGuildProfile.findFirst({
where: {
guildId: guildId,
readChannel: { has: channelId }
}
})
) != null;
}
export async function removeGuildReadChannel(guildId: string, channelId: string): Promise<void> {
const guildProfile = await prisma.discordGuildProfile.findUnique({
where: {
guildId: guildId
},
});
if (guildProfile) {
await prisma.discordGuildProfile.update({
where: {
guildId: guildId
},
data: {
readChannel: guildProfile.readChannel.filter(channel => channel != channelId),
},
});
}
}
export async function insertGuildReadChannel(guildId: string, channelId: string): Promise<void> {
await prisma.discordGuildProfile.upsert({
where: {
guildId: guildId,
NOT: {
readChannel: {
has: channelId,
},
}
},
update: {
readChannel: {
push: channelId,
},
},
create: {
guildId: guildId,
readChannel: [channelId],
},
});
export async function insertGuildReadChannel(
guildId: string,
channelId: string,
): Promise<void> {
await prisma.discordGuildProfile.upsert({
where: {
guildId: guildId,
NOT: {
readChannel: {
has: channelId,
},
},
},
update: {
readChannel: {
push: channelId,
},
},
create: {
guildId: guildId,
readChannel: [channelId],
},
});
}

View file

@ -1,21 +1,22 @@
import { ClientEvents } from "discord.js";
import { type ClientEvents } from "discord.js";
import { join } from "node:path";
import { requireDirectorySync } from "../utils/requireDirectory";
import { requireDirectory } from "../utils/requireDirectory";
export interface DiscordEvent<Event extends keyof ClientEvents> {
event: Event
callback: (...args: ClientEvents[Event]) => Promise<void>
event: Event;
callback: (...args: ClientEvents[Event]) => Promise<void>;
}
export function defineEvent<Event extends keyof ClientEvents>(
event: Event,
callback: (...args: ClientEvents[Event]) => Promise<void>
event: Event,
callback: (...args: ClientEvents[Event]) => Promise<void>,
): DiscordEvent<Event> {
return {
event: event,
callback: callback,
}
return {
event: event,
callback: callback,
};
}
export const eventDirectory = join(__dirname, "events");
export const eventMap = requireDirectorySync<DiscordEvent<any>>(eventDirectory);
export const eventDirectory = join(import.meta.dirname, "events");
export const eventMap =
await requireDirectory<DiscordEvent<any>>(eventDirectory);

View file

@ -1,72 +1,69 @@
import { getOrCreateVoiceConnection } from "../util";
import { getUserProfile, hasGuildReadChannel } from "../db";
import { defineEvent } from "../event";
import { playVoice, PlayVoiceOptions } from "../tts";
import { playVoice, type PlayVoiceOptions } from "../tts";
import { Voice } from "../../db/generated/prisma/enums";
import { SUPERTONIC_DEFAULT_VOICE } from "../../env";
export default defineEvent("messageCreate", async (message) => {
if (message.author.bot)
return;
if (message.author.bot) return;
const guild = message.guild;
if (guild == null)
return;
const guild = message.guild;
if (guild == null) return;
const hasChannel = await hasGuildReadChannel(guild.id, message.channelId);
if (!hasChannel)
return;
const hasChannel = await hasGuildReadChannel(guild.id, message.channelId);
if (!hasChannel) return;
const profile = await getUserProfile(message.author.id);
const profile = await getUserProfile(message.author.id);
let content = message.cleanContent;
let voice: Voice | null = null
let options: PlayVoiceOptions = {};
let matched: RegExpMatchArray | null = null;
if (content.startsWith("$t ")) {
voice = "TypeCast";
} else if (content.startsWith("$p ")) {
voice = "Papago";
} else if (matched = content.match(/^\$s(\S*) /)) {
voice = "Supertonic";
if (matched[1].length) {
options.supertonicStyleId = matched[1]
}
} else if (content.match(/^\$\s/)) {
return;
let content = message.cleanContent;
let voice: Voice | null = null;
const options: PlayVoiceOptions = {};
// eslint-disable-next-line no-useless-assignment
let matched: RegExpMatchArray | null = null;
if (content.startsWith("$t ")) {
voice = "TypeCast";
} else if (content.startsWith("$p ")) {
voice = "Papago";
} else if ((matched = content.match(/^\$s(\S*) /))) {
voice = "Supertonic";
if (matched[1]?.length) {
options.supertonicStyleId = matched[1];
}
} else if (content.match(/^\$\s/)) {
return;
}
if (profile.userSupertonicStyle.length) {
options.supertonicStyleId ??= profile.userSupertonicStyle;
}
options.supertonicStyleId ??= SUPERTONIC_DEFAULT_VOICE;
if (voice) {
content = content.replace(/^\$\S+\s+/, "");
} else {
voice = profile.voice;
}
try {
if (!(await getOrCreateVoiceConnection(guild))) return;
if (!guild.members.me?.voice.channel) return;
if (message.content === "") {
content =
message.attachments.size > 0
? `${message.attachments.size} 개의 첨부파일`
: "알수없는 메시지";
if (message.attachments.size == 1 && Math.random() < 0.05) {
content = "어이, 유저. 일개의 첨부파일이 뭘 할 수 있지?";
}
}
if (profile.userSupertonicStyle.length) {
options.supertonicStyleId ??= profile.userSupertonicStyle;
}
options.supertonicStyleId ??= SUPERTONIC_DEFAULT_VOICE;
if (voice) {
content = content.replace(/^\$\S+\s+/, "")
} else {
voice = profile.voice;
}
try {
if (!await getOrCreateVoiceConnection(guild))
return;
if (!guild.members.me?.voice.channel)
return;
if (message.content === "") {
content = message.attachments.size > 0
? `${message.attachments.size} 개의 첨부파일`
: "알수없는 메시지"
if (message.attachments.size == 1 && Math.random() < 0.05) {
content = "어이, 유저. 일개의 첨부파일이 뭘 할 수 있지?"
}
}
await playVoice(guild, profile, voice, content, options);
} catch(err) {
message.reply("말이 꼬이네요 ㅜ.ㅜ");
console.log("playVoice failed. ", err);
}
})
await playVoice(guild, profile, voice, content, options);
} catch (err) {
message.reply("말이 꼬이네요 ㅜ.ㅜ");
console.log("playVoice failed. ", err);
}
});

View file

@ -1,11 +1,10 @@
import { commandExecuteNameHashMap, DiscordCommand, DiscordCommandExecute } from "../command";
import { commandExecuteNameHashMap } from "../command";
import { defineEvent } from "../event";
export default defineEvent("interactionCreate", async (interaction) => {
if (!interaction.isChatInputCommand())
return;
if (!interaction.isChatInputCommand()) return;
if (commandExecuteNameHashMap[interaction.commandName]) {
await commandExecuteNameHashMap[interaction.commandName](interaction);
}
})
if (commandExecuteNameHashMap[interaction.commandName]) {
await commandExecuteNameHashMap[interaction.commandName]?.(interaction);
}
});

View file

@ -1,5 +1,5 @@
import { Client, Events, GatewayIntentBits, REST, Routes } from "discord.js";
import { commandExecuteNameHashMap, defineCommands, DiscordCommand } from "./command";
import { commandExecuteNameHashMap, defineCommands } from "./command";
import { eventMap } from "./event";
import { OutputHandler } from "../utils/outputHandler";
import { APPLICATION_ID } from "../env";
@ -8,93 +8,92 @@ import { join } from "node:path";
import { cwd } from "node:process";
export class DiscordBot {
rest: REST;
client: Client;
rest: REST;
client: Client;
constructor(token: string) {
this.client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.MessageContent,
],
});
this.rest = new REST({ version: "10" }).setToken(token);
}
constructor(token: string) {
this.client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.MessageContent,
],
});
this.rest = new REST({ version: "10" }).setToken(token);
}
public async registerCommands(): Promise<void> {
try {
if (!this.client.isReady()) {
await this.client.once(Events.ClientReady, () => {});
}
public async registerCommands(): Promise<void> {
try {
if (!this.client.isReady()) {
await this.client.once(Events.ClientReady, () => {});
}
const commandsCachePath = join(cwd(), "cache", "commands");
await mkdir(commandsCachePath, {
recursive: true
});
let commandsCache: { [key: string]: string }
try {
commandsCache = JSON.parse(
await readFile(join(commandsCachePath, "list.json"), "utf-8")
);
} catch {
commandsCache = {};
}
for (const [command, id] of Object.entries(commandsCache)) {
if (commandExecuteNameHashMap[command]) {
continue;
}
console.log(`sync(delete) command: ${command}(${id})`);
await this.deleteCommand(id);
}
await this.rest.put(
Routes.applicationCommands(APPLICATION_ID),
{
body: defineCommands.map((command) => command.data.toJSON()),
}
);
await writeFile(
join(commandsCachePath, "list.json"),
JSON.stringify(
(
(await this.rest.get(Routes.applicationCommands(APPLICATION_ID))) as {
name: string,
id: string
}[]
).reduce<Record<string, string>>((acc, cur) => {
acc[cur.name] = cur.id;
return acc;
}, {}),
null,
2
)
);
} catch(err) {
OutputHandler.errorLog("[Command Register Error]", err);
}
}
public async deleteCommand(commandId: string): Promise<void> {
if (!this.client.isReady())
throw new Error("Client is not ready");
await this.rest.delete(
Routes.applicationCommand(this.client.application.id, commandId)
const commandsCachePath = join(cwd(), "cache", "commands");
await mkdir(commandsCachePath, {
recursive: true,
});
let commandsCache: { [key: string]: string };
try {
commandsCache = JSON.parse(
await readFile(join(commandsCachePath, "list.json"), "utf-8"),
);
}
} catch {
commandsCache = {};
}
public registerEvents() {
try {
for (let index = 0; index < eventMap.length; index++) {
const event = eventMap[index];
this.client.on(event.event, event.callback);
}
} catch(err) {
OutputHandler.errorLog("[Event Register Error]", err);
for (const [command, id] of Object.entries(commandsCache)) {
if (commandExecuteNameHashMap[command]) {
continue;
}
console.log(`sync(delete) command: ${command}(${id})`);
await this.deleteCommand(id);
}
await this.rest.put(Routes.applicationCommands(APPLICATION_ID), {
body: defineCommands.map((command) => command.data.toJSON()),
});
await writeFile(
join(commandsCachePath, "list.json"),
JSON.stringify(
(
(await this.rest.get(
Routes.applicationCommands(APPLICATION_ID),
)) as {
name: string;
id: string;
}[]
).reduce<Record<string, string>>((acc, cur) => {
acc[cur.name] = cur.id;
return acc;
}, {}),
null,
2,
),
);
} catch (err) {
OutputHandler.errorLog("[Command Register Error]", err);
}
}
public async deleteCommand(commandId: string): Promise<void> {
if (!this.client.isReady()) throw new Error("Client is not ready");
await this.rest.delete(
Routes.applicationCommand(this.client.application.id, commandId),
);
}
public registerEvents() {
try {
for (let index = 0; index < eventMap.length; index++) {
const event = eventMap[index];
if (!event) continue;
this.client.on(event.event, event.callback);
}
} catch (err) {
OutputHandler.errorLog("[Event Register Error]", err);
}
}
}

View file

@ -1,81 +1,83 @@
import { AudioPlayerStatus, AudioResource, createAudioPlayer, createAudioResource, VoiceConnection } from "@discordjs/voice";
import { stream as createStream } from "play-dl";
import {
AudioPlayerStatus,
AudioResource,
createAudioPlayer,
createAudioResource,
VoiceConnection,
} from "@discordjs/voice";
import { Guild } from "discord.js";
import { getOrCreateVoiceConnection } from "./util";
import { OutputHandler } from "../utils/outputHandler";
import play from "play-dl";
namespace InitPlayDl {
let initialized = false;
export async function init() {
if (initialized)
return;
let initialized = false;
export async function init() {
if (initialized) return;
await play.getFreeClientID();
initialized = true;
}
await play.getFreeClientID();
initialized = true;
}
}
class MusicQueue {
private connection: VoiceConnection;
private list: AudioResource[];
constructor(connection: VoiceConnection) {
this.connection = connection;
this.list = [];
}
public static fromConnection(connection: VoiceConnection): MusicQueue {
return (connection as any).queue ??= new MusicQueue(connection);
}
private play() {
if (!this.list[0]) return;
const player = createAudioPlayer();
this.connection.subscribe(player);
player.once(AudioPlayerStatus.Idle, this.next.bind(this));
player.play(this.list[0]);
}
public enqueue(resource: AudioResource) {
this.list.push(resource);
if (this.list.length == 1) {
this.play();
return;
}
}
public next() {
this.list.shift();
this.play();
private connection: VoiceConnection;
private list: AudioResource[];
constructor(connection: VoiceConnection) {
this.connection = connection;
this.list = [];
}
public static fromConnection(connection: VoiceConnection): MusicQueue {
return ((connection as any).queue ??= new MusicQueue(connection));
}
private play() {
if (!this.list[0]) return;
const player = createAudioPlayer();
this.connection.subscribe(player);
player.once(AudioPlayerStatus.Idle, this.next.bind(this));
player.play(this.list[0]);
}
public enqueue(resource: AudioResource) {
this.list.push(resource);
if (this.list.length == 1) {
this.play();
return;
}
}
public next() {
this.list.shift();
this.play();
}
}
// TODO: 토큰 설정하고 플레이돼게 만들기
export async function playMusic(guild: Guild, url: string) {
try {
const connection = await getOrCreateVoiceConnection(guild);
if (!connection)
throw new Error("Yaeju is not joined VoiceChat");
try {
const connection = await getOrCreateVoiceConnection(guild);
if (!connection) throw new Error("Yaeju is not joined VoiceChat");
await InitPlayDl.init();
await InitPlayDl.init();
const validation = play.yt_validate(url);
const validation = play.yt_validate(url);
if (validation !== "video" && validation !== "playlist")
throw new Error("Invalid YouTube URL: " + validation);
if (validation !== "video" && validation !== "playlist")
throw new Error("Invalid YouTube URL: " + validation);
const stream = await play.stream(url);
MusicQueue.fromConnection(connection).enqueue(
createAudioResource(stream.stream, {
inputType: stream.type
})
);
} catch(err) {
OutputHandler.errorLog("[PlayMusic Error]", err);
throw err;
}
const stream = await play.stream(url);
MusicQueue.fromConnection(connection).enqueue(
createAudioResource(stream.stream, {
inputType: stream.type,
}),
);
} catch (err) {
OutputHandler.errorLog("[PlayMusic Error]", err);
throw err;
}
}
export async function skipMusic(guild: Guild) {
const connection = await getOrCreateVoiceConnection(guild);
if (!connection)
throw new Error("Yaeju is not joined VoiceChat");
const connection = await getOrCreateVoiceConnection(guild);
if (!connection) throw new Error("Yaeju is not joined VoiceChat");
MusicQueue.fromConnection(connection).next();
MusicQueue.fromConnection(connection).next();
}

View file

@ -1,130 +1,145 @@
import { AudioPlayer, AudioPlayerStatus, AudioResource, createAudioPlayer, VoiceConnection } from "@discordjs/voice";
import {
AudioPlayer,
AudioPlayerStatus,
AudioResource,
createAudioPlayer,
VoiceConnection,
} from "@discordjs/voice";
import { Voice } from "../db/generated/prisma/enums";
import TTSTypecastModel from "../tts/typecast";
import TTSPapagoModel from "../tts/papago";
import { Guild } from "discord.js";
import { getOrCreateVoiceConnection } from "./util";
import TTSModelBase from "../tts";
import { DiscordUserProfile } from "../db/generated/prisma/client";
import { type DiscordUserProfile } from "../db/generated/prisma/client";
import { nyaize } from "../utils/nyaize";
import { OutputHandler } from "../utils/outputHandler";
import TTSSupertonicModel from "../tts/supertonic";
class VoiceQueue {
private connection: VoiceConnection;
private list: AudioResource[];
private currentPlayer?: AudioPlayer;
constructor(connection: VoiceConnection) {
this.connection = connection;
this.list = [];
}
public static fromConnection(connection: VoiceConnection): VoiceQueue {
return (connection as any).queue ??= new VoiceQueue(connection);
}
private play() {
if (!this.list[0]) return;
const player = this.currentPlayer = createAudioPlayer();
this.connection.subscribe(player);
player.once(AudioPlayerStatus.Idle, this.next.bind(this));
player.play(this.list[0]);
}
public enqueue(resource: AudioResource) {
this.list.push(resource);
if (this.list.length == 1) {
this.play();
return;
}
}
public next() {
this.currentPlayer?.removeAllListeners(AudioPlayerStatus.Idle);
this.list.shift();
this.play();
}
public hasNext(): boolean {
return !!this.list[0];
private connection: VoiceConnection;
private list: AudioResource[];
private currentPlayer?: AudioPlayer;
constructor(connection: VoiceConnection) {
this.connection = connection;
this.list = [];
}
public static fromConnection(connection: VoiceConnection): VoiceQueue {
return ((connection as any).queue ??= new VoiceQueue(connection));
}
private play() {
if (!this.list[0]) return;
const player = (this.currentPlayer = createAudioPlayer());
this.connection.subscribe(player);
player.once(AudioPlayerStatus.Idle, this.next.bind(this));
player.play(this.list[0]);
}
public enqueue(resource: AudioResource) {
this.list.push(resource);
if (this.list.length == 1) {
this.play();
return;
}
}
public next() {
this.currentPlayer?.removeAllListeners(AudioPlayerStatus.Idle);
this.list.shift();
this.play();
}
public hasNext(): boolean {
return !!this.list[0];
}
}
export type PlayVoiceOptions = {
supertonicStyleId?: string,
supertonicStyleId?: string;
};
export async function playVoice(
guild: Guild,
profile: DiscordUserProfile,
voice: Voice,
text: string,
options?: PlayVoiceOptions,
guild: Guild,
profile: DiscordUserProfile,
voice: Voice,
text: string,
options?: PlayVoiceOptions,
) {
if (profile.nya)
text = nyaize(text);
if (profile.nya) text = nyaize(text);
try {
let connection = await getOrCreateVoiceConnection(guild);
if (!connection)
throw new Error("Yaeju is not joined VoiceChat");
try {
const connection = await getOrCreateVoiceConnection(guild);
if (!connection) throw new Error("Yaeju is not joined VoiceChat");
if (voice == "TypeCast" && !profile.canTypecast) {
throw new Error(`the user ${profile.userId} is can't use typecast voice`);
}
let voiceBufferList: Buffer[]
if (voice == "TypeCast") {
const content = TTSTypecastModel.instance.ttsify(text);
if (!content.length)
throw new Error("Empty content");
voiceBufferList = await Promise.all(content.split("\n").map(
(content) => TTSTypecastModel.instance.getMemcachedVoice(
TTSTypecastModel.instance.createRequestId(content)
)
));
} else if (voice == "Supertonic") {
const content = TTSSupertonicModel.instance.ttsify(text);
if (!content.length)
throw new Error("Empty content");
voiceBufferList = await Promise.all(content.split("\n").map(
(content) => TTSSupertonicModel.instance.getMemcachedVoice(
TTSSupertonicModel.instance.createRequestId(content, options?.supertonicStyleId)
)
));
} else if (voice == "Papago") {
const content = TTSPapagoModel.instance.ttsify(text);
if (!content.length)
throw new Error("Empty content");
voiceBufferList = await Promise.all(content.split("\n").map(
(content) => TTSPapagoModel.instance.getMemcachedVoice(
TTSPapagoModel.instance.createRequestId(content)
)
));
} else {
throw new Error(`Unknown voice type: ${voice}`);
}
for (const voiceBuffer of voiceBufferList) {
VoiceQueue.fromConnection(connection).enqueue(
TTSModelBase.bufferToAudioResource(voiceBuffer)
);
}
} catch(err) {
OutputHandler.errorLog("[PlayVoice Error]", err);
throw new Error(err as any);
if (voice == "TypeCast" && !profile.canTypecast) {
throw new Error(`the user ${profile.userId} is can't use typecast voice`);
}
let voiceBufferList: Buffer[];
if (voice == "TypeCast") {
const content = TTSTypecastModel.instance.ttsify(text);
if (!content.length) throw new Error("Empty content");
voiceBufferList = await Promise.all(
content
.split("\n")
.map((content) =>
TTSTypecastModel.instance.getMemcachedVoice(
TTSTypecastModel.instance.createRequestId(content),
),
),
);
} else if (voice == "Supertonic") {
const content = TTSSupertonicModel.instance.ttsify(text);
if (!content.length) throw new Error("Empty content");
voiceBufferList = await Promise.all(
content
.split("\n")
.map((content) =>
TTSSupertonicModel.instance.getMemcachedVoice(
TTSSupertonicModel.instance.createRequestId(
content,
options?.supertonicStyleId,
),
),
),
);
} else if (voice == "Papago") {
const content = TTSPapagoModel.instance.ttsify(text);
if (!content.length) throw new Error("Empty content");
voiceBufferList = await Promise.all(
content
.split("\n")
.map((content) =>
TTSPapagoModel.instance.getMemcachedVoice(
TTSPapagoModel.instance.createRequestId(content),
),
),
);
} else {
throw new Error(`Unknown voice type: ${voice}`);
}
for (const voiceBuffer of voiceBufferList) {
VoiceQueue.fromConnection(connection).enqueue(
TTSModelBase.bufferToAudioResource(voiceBuffer),
);
}
} catch (err) {
OutputHandler.errorLog("[PlayVoice Error]", err);
throw new Error(err as any, { cause: err });
}
}
export async function skipCurrentVoice(guild: Guild): Promise<boolean> {
let connection = await getOrCreateVoiceConnection(guild);
if (!connection)
throw new Error("YaejuNyang is not joined VoiceChat");
const connection = await getOrCreateVoiceConnection(guild);
if (!connection) throw new Error("YaejuNyang is not joined VoiceChat");
const vqueue = VoiceQueue.fromConnection(connection);
if (vqueue.hasNext()) {
vqueue.next();
return true;
}
return false;
const vqueue = VoiceQueue.fromConnection(connection);
if (vqueue.hasNext()) {
vqueue.next();
return true;
}
return false;
}

View file

@ -1,25 +1,30 @@
import { getVoiceConnection as defaultGetVoiceConnection, EndBehaviorType, joinVoiceChannel, VoiceConnection } from "@discordjs/voice";
import {
getVoiceConnection as defaultGetVoiceConnection,
joinVoiceChannel,
VoiceConnection,
} from "@discordjs/voice";
import { Guild } from "discord.js";
export async function getOrCreateVoiceConnection(guild: Guild): Promise<VoiceConnection | undefined> {
let connection = defaultGetVoiceConnection(guild.id);
export async function getOrCreateVoiceConnection(
guild: Guild,
): Promise<VoiceConnection | undefined> {
let connection = defaultGetVoiceConnection(guild.id);
if (!connection) {
if (!guild.members.me?.voice.channel)
return;
if (!connection) {
if (!guild.members.me?.voice.channel) return;
const channel = guild.members.me.voice.channel;
const channel = guild.members.me.voice.channel;
connection = joinVoiceChannel({
channelId: channel.id,
guildId: channel.guild.id,
adapterCreator: channel.guild.voiceAdapterCreator,
selfDeaf: false,
selfMute: false,
});
connection = joinVoiceChannel({
channelId: channel.id,
guildId: channel.guild.id,
adapterCreator: channel.guild.voiceAdapterCreator,
selfDeaf: false,
selfMute: false,
});
await new Promise((resolve) => setTimeout(resolve, 2000));
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
return connection;
return connection;
}

View file

@ -12,6 +12,8 @@
import * as process from 'node:process'
import * as path from 'node:path'
import { fileURLToPath } from 'node:url'
globalThis['__dirname'] = path.dirname(fileURLToPath(import.meta.url))
import * as runtime from "@prisma/client/runtime/client"
import * as $Enums from "./enums"

View file

@ -37,10 +37,10 @@ async function decodeBase64AsWasm(wasmBase64: string): Promise<WebAssembly.Modul
}
config.compilerWasm = {
getRuntime: async () => await import("@prisma/client/runtime/query_compiler_fast_bg.postgresql.js"),
getRuntime: async () => await import("@prisma/client/runtime/query_compiler_fast_bg.postgresql.mjs"),
getQueryCompilerWasmModule: async () => {
const { wasm } = await import("@prisma/client/runtime/query_compiler_fast_bg.postgresql.wasm-base64.js")
const { wasm } = await import("@prisma/client/runtime/query_compiler_fast_bg.postgresql.wasm-base64.mjs")
return await decodeBase64AsWasm(wasm)
},

View file

@ -1,19 +1,20 @@
import { config } from "dotenv"
import { config } from "dotenv";
config({ quiet: true });
export const DISCORD_TOKEN = process.env.DISCORD_TOKEN as string;
export const APPLICATION_ID = process.env.APPLICATION_ID as string;
export const GUILD_ID = process.env.GUILD_ID as string;
export const TYPECAST_TOKENS = (process.env.TYPECAST_TOKEN as string).split(",");
export const TYPECAST_TOKENS = (process.env.TYPECAST_TOKEN as string).split(
",",
);
export const DATABASE_URL = process.env.DATABASE_URL as string;
export const SUPERTONIC_DEFAULT_VOICE = (process.env.SUPERTONIC_DEFAULT_VOICE as string | undefined) ?? "Q1";
export const SUPERTONIC_STYLE_LIST: { name: string, value: string }[] = (()=>{
const defaultValue = [
{ name: "여성1", value: "F1" },
];
try {
return JSON.parse(process.env.SUPERTONIC_STYLE_LIST ?? "null") as any ?? defaultValue
} catch {}
return defaultValue;
export const SUPERTONIC_DEFAULT_VOICE =
(process.env.SUPERTONIC_DEFAULT_VOICE as string | undefined) ?? "Q1";
export const SUPERTONIC_STYLE_LIST: { name: string; value: string }[] = (() => {
return (
(JSON.parse(process.env.SUPERTONIC_STYLE_LIST ?? "null") as any) ?? [
{ name: "여성1", value: "F1" },
]
);
})();

View file

@ -5,15 +5,19 @@ import { APPLICATION_ID, DISCORD_TOKEN } from "./env";
export const bot = new DiscordBot(DISCORD_TOKEN);
bot.client.once("clientReady", async (client) => {
await bot.registerCommands();
await bot.registerEvents();
await bot.registerCommands();
await bot.registerEvents();
console.log(
"registerCommands: \n| " +
(
(await client.rest.get(Routes.applicationCommands(APPLICATION_ID))) as any[]
).map(info => `name: ${info.name} id: ${info.id}`).join("\n| ")
);
console.log(
"registerCommands: \n| " +
(
(await client.rest.get(
Routes.applicationCommands(APPLICATION_ID),
)) as any[]
)
.map((info) => `name: ${info.name} id: ${info.id}`)
.join("\n| "),
);
});
bot.client.login(DISCORD_TOKEN);

View file

@ -1,6 +1,10 @@
import { writeFile, mkdir, stat, readFile } from "fs/promises";
import { writeFile, mkdir, readFile } from "fs/promises";
import { dirname } from "path";
import { AudioResource, createAudioResource, StreamType } from "@discordjs/voice";
import {
AudioResource,
createAudioResource,
StreamType,
} from "@discordjs/voice";
import { Readable } from "stream";
import { createHash } from "node:crypto";
import { join } from "node:path";
@ -8,88 +12,84 @@ import { existsSync } from "node:fs";
import { saferKorean } from "../utils/saferKorean";
export abstract class TTSModelBase<RequestId> {
public ttsify(input: string): string {
return saferKorean(
input.replace(/:[^:]+:/g, (text: string): string => (TTSModelBase.EMOJI_MAP[text] ?? "이모지"))
);
public ttsify(input: string): string {
return saferKorean(
input.replace(
/:[^:]+:/g,
(text: string): string => TTSModelBase.EMOJI_MAP[text] ?? "이모지",
),
);
}
public abstract createRequestId(text: string): RequestId;
public abstract getVoiceBuffer(id: RequestId): Promise<ArrayBuffer>;
public abstract getVoicePath(id: RequestId): string;
/**
* id로
*
*/
public async createVoice(id: RequestId, audioPath?: string): Promise<Buffer> {
const voiceBuffer = await this.getVoiceBuffer(id);
audioPath ??= this.getVoicePath(id);
const buffer = Buffer.from(voiceBuffer);
await mkdir(dirname(audioPath), { recursive: true });
await writeFile(audioPath, buffer);
return buffer;
}
/**
* id로
*/
public async getVoice(id: RequestId, audioPath?: string): Promise<Buffer> {
audioPath ??= this.getVoicePath(id);
if (existsSync(audioPath)) {
const buffer = await readFile(audioPath);
return buffer;
}
public abstract createRequestId(text: string): RequestId
public abstract getVoiceBuffer(id: RequestId): Promise<ArrayBuffer>
public abstract getVoicePath(id: RequestId): string
/**
* id로
*
*/
public async createVoice(id: RequestId, audioPath?: string): Promise<Buffer> {
const voiceBuffer = await this.getVoiceBuffer(id);
audioPath ??= this.getVoicePath(id);
const buffer = Buffer.from(voiceBuffer);
return this.createVoice(id, audioPath);
}
/**
* id로 ,
* ,
*/
protected abstract cachedVoice: Map<string, Promise<Buffer>>;
public async getMemcachedVoice(id: RequestId): Promise<Buffer> {
const path = this.getVoicePath(id);
await mkdir(dirname(audioPath), { recursive: true });
await writeFile(audioPath, buffer);
return buffer;
const cached = this.cachedVoice.get(path);
if (cached) {
return cached;
}
/**
* id로
*/
public async getVoice(id: RequestId, audioPath?: string): Promise<Buffer> {
audioPath ??= this.getVoicePath(id);
if (existsSync(audioPath)) {
const buffer = await readFile(audioPath);
return buffer;
}
return this.createVoice(id, audioPath);
}
/**
* id로 ,
* ,
*/
protected abstract cachedVoice: Map<String, Promise<Buffer>>
public async getMemcachedVoice(id: RequestId): Promise<Buffer> {
const path = this.getVoicePath(id);
const cached = this.cachedVoice.get(path);
if (cached) {
return cached;
}
const waitter = this.getVoice(id);
this.cachedVoice.set(path, waitter);
setTimeout(
() => this.cachedVoice.delete(path),
TTSModelBase.MemCacheTTL
);
return await waitter;
}
const waitter = this.getVoice(id);
this.cachedVoice.set(path, waitter);
setTimeout(() => this.cachedVoice.delete(path), TTSModelBase.MemCacheTTL);
return await waitter;
}
}
export namespace TTSModelBase {
export const EMOJI_MAP: { [key: string]: string } = {
":heart:": "하트",
":huck:": "헉헉!",
":star:": "초롱초롱!"
}
export const AudioCachePath = join(
process.cwd(),
"cache",
"audio",
);
export function bufferToAudioResource(buf: Buffer): AudioResource {
const stream = Readable.from(buf);
const resource = createAudioResource(stream, {
inlineVolume: true,
inputType: StreamType.Arbitrary,
});
export const EMOJI_MAP: { [key: string]: string } = {
":heart:": "하트",
":huck:": "헉헉!",
":star:": "초롱초롱!",
};
export const AudioCachePath = join(process.cwd(), "cache", "audio");
export function bufferToAudioResource(buf: Buffer): AudioResource {
const stream = Readable.from(buf);
const resource = createAudioResource(stream, {
inlineVolume: true,
inputType: StreamType.Arbitrary,
});
resource.volume?.setVolume(0.3);
return resource;
}
export function hashAudioFile(audio: string, suffix: string = ""): string {
return createHash("md5").update(audio).digest("hex") + suffix + ".mp3";
}
export const MemCacheTTL = 60 * 60 * 1000
resource.volume?.setVolume(0.3);
return resource;
}
export function hashAudioFile(audio: string, suffix: string = ""): string {
return createHash("md5").update(audio).digest("hex") + suffix + ".mp3";
}
export const MemCacheTTL = 60 * 60 * 1000;
}
export default TTSModelBase;

View file

@ -4,101 +4,116 @@ import fetch from "../utils/fetch";
import TTSModelBase from ".";
export class TTSPapagoModel extends TTSModelBase<TTSPapagoModel.RequestId> {
protected cachedVoice: Map<String, Promise<Buffer>>
constructor() {
super()
this.cachedVoice = new Map();
}
ttsify(input: string): string {
return super.ttsify(input)
}
public getVoicePath(id: TTSPapagoModel.RequestId): string {
const audioFileName = TTSModelBase.hashAudioFile(id.text, `.${id.speaker}.${id.speed.replace(/\-/g, "_")}`);
const audioPath = join(
TTSPapagoModel.PapagoAudioCachePath,
audioFileName
);
return audioPath;
}
async getVoiceBuffer(id: TTSPapagoModel.RequestId, voiceId?: string): Promise<ArrayBuffer> {
voiceId ??= await TTSPapagoModel.getVoiceId(id)
const response = await fetch(`https://papago.naver.com/apis/tts/${voiceId}`);
return await response.arrayBuffer();
}
createRequestId(text: string, speaker?: string, speed?: string): TTSPapagoModel.RequestId {
return {
text,
speed: speed ?? "-1",
speaker: speaker ?? "kyuri",
};
}
protected cachedVoice: Map<string, Promise<Buffer>>;
constructor() {
super();
this.cachedVoice = new Map();
}
ttsify(input: string): string {
return super.ttsify(input);
}
public getVoicePath(id: TTSPapagoModel.RequestId): string {
const audioFileName = TTSModelBase.hashAudioFile(
id.text,
`.${id.speaker}.${id.speed.replace(/-/g, "_")}`,
);
const audioPath = join(TTSPapagoModel.PapagoAudioCachePath, audioFileName);
return audioPath;
}
async getVoiceBuffer(
id: TTSPapagoModel.RequestId,
voiceId?: string,
): Promise<ArrayBuffer> {
voiceId ??= await TTSPapagoModel.getVoiceId(id);
const response = await fetch(
`https://papago.naver.com/apis/tts/${voiceId}`,
);
return await response.arrayBuffer();
}
createRequestId(
text: string,
speaker?: string,
speed?: string,
): TTSPapagoModel.RequestId {
return {
text,
speed: speed ?? "-1",
speaker: speaker ?? "kyuri",
};
}
}
export namespace TTSPapagoModel {
export const instance = new TTSPapagoModel();
export type RequestId = {
speaker: string;
speed: string;
text: string;
export const instance = new TTSPapagoModel();
export type RequestId = {
speaker: string;
speed: string;
text: string;
};
export const GenerateTokenKey = "v1.9.3_3bdf0438a8";
export function hmacMD5(key: string, plaintext: string) {
const hmac = createHmac("md5", key);
const data = hmac.update(plaintext);
return data.digest("base64");
}
export function generateToken(time: number) {
const e = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (e) => {
const t = ((time + 16 * Math.random()) % 16) | 0;
return (
(time = Math.floor(time / 16)),
("x" === e ? t : (3 & t) | 8).toString(16)
);
});
const plain = `${e}\n${"https://papago.naver.com/apis/tts/makeID"}\n${time}`;
return `PPG ${e}:${hmacMD5(GenerateTokenKey, plain)}`;
}
export async function getVoiceId(id: RequestId): Promise<string> {
const input = {
alpha: "0",
pitch: "0",
speaker: id.speaker,
speed: id.speed,
text: id.text,
};
export const GenerateTokenKey = "v1.9.3_3bdf0438a8";
export function hmacMD5(key: string, plaintext: string) {
const hmac = createHmac("md5", key);
const data = hmac.update(plaintext);
return data.digest("base64");
const time = new Date().getTime();
const token = TTSPapagoModel.generateToken(time);
const reqbody = new URLSearchParams(Object.entries(input)).toString();
const response = await fetch("https://papago.naver.com/apis/tts/makeID", {
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
Accept: "application/json",
"Accept-Language": "en",
"Sec-GPC": "1",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "no-cors",
"Sec-Fetch-Site": "same-origin",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
Authorization: token,
Timestamp: time.toString(),
Pragma: "no-cache",
"Cache-Control": "no-cache",
},
referrer: "https://papago.naver.com/",
body: reqbody,
method: "POST",
});
if (!response.ok) {
throw new Error(
`TTS makeID request failed: ${response.status}: ${await response.text()}`,
);
}
export function generateToken(time: number) {
const e = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (e) => {
var t = (time + 16 * Math.random()) % 16 | 0;
return (time = Math.floor(time / 16)), ("x" === e ? t : (3 & t) | 8).toString(16);
});
const plain = `${e}\n${"https://papago.naver.com/apis/tts/makeID"}\n${time}`;
return `PPG ${e}:${hmacMD5(GenerateTokenKey, plain)}`;
}
export async function getVoiceId(id: RequestId): Promise<string> {
const input = {
alpha: "0",
pitch: "0",
speaker: id.speaker,
speed: id.speed,
text: id.text,
};
const time = new Date().getTime();
const token = TTSPapagoModel.generateToken(time);
const reqbody = new URLSearchParams(Object.entries(input)).toString();
const response = await fetch("https://papago.naver.com/apis/tts/makeID", {
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
Accept: "application/json",
"Accept-Language": "en",
"Sec-GPC": "1",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "no-cors",
"Sec-Fetch-Site": "same-origin",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
Authorization: token,
Timestamp: time.toString(),
Pragma: "no-cache",
"Cache-Control": "no-cache",
},
referrer: "https://papago.naver.com/",
body: reqbody,
method: "POST",
});
if (!response.ok) {
throw new Error(`TTS makeID request failed: ${response.status}: ${await response.text()}`);
}
return ((await response.json()) as any).id;
}
export const PapagoAudioCachePath = join(
TTSModelBase.AudioCachePath,
"papago"
);
return ((await response.json()) as any).id;
}
export const PapagoAudioCachePath = join(
TTSModelBase.AudioCachePath,
"papago",
);
}
export default TTSPapagoModel;

View file

@ -3,60 +3,67 @@ import fetch from "../utils/fetch";
import TTSModelBase from ".";
export class TTSSupertonicModel extends TTSModelBase<TTSSupertonicModel.RequestId> {
protected override cachedVoice: Map<String, Promise<Buffer>>
constructor() {
super()
this.cachedVoice = new Map();
}
override ttsify(input: string): string {
return super.ttsify(input);
}
private async getSupertonicResponse(voiceId: TTSSupertonicModel.RequestId) {
const payload = {
text: voiceId.text,
lang: "ko",
style_id: voiceId.styleId
};
protected override cachedVoice: Map<string, Promise<Buffer>>;
constructor() {
super();
this.cachedVoice = new Map();
}
override ttsify(input: string): string {
return super.ttsify(input);
}
private async getSupertonicResponse(voiceId: TTSSupertonicModel.RequestId) {
const payload = {
text: voiceId.text,
lang: "ko",
style_id: voiceId.styleId,
};
if (!process.env.SUPERTONIC_API_URL) {
throw Error("process.env.SUPERTONIC_API_URL not set");
}
if (!process.env.SUPERTONIC_API_URL) {
throw Error("process.env.SUPERTONIC_API_URL not set");
}
return await fetch(process.env.SUPERTONIC_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
}
async getVoiceBuffer(voiceId: TTSSupertonicModel.RequestId): Promise<ArrayBuffer> {
let response: Response | undefined;
return await fetch(process.env.SUPERTONIC_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
}
async getVoiceBuffer(
voiceId: TTSSupertonicModel.RequestId,
): Promise<ArrayBuffer> {
const response: Response | undefined = (await this.getSupertonicResponse(
voiceId,
)) as Response;
if (response.ok) return await response.arrayBuffer();
response = await this.getSupertonicResponse(voiceId) as Response;
if (response.ok)
return await response.arrayBuffer();
throw new Error(`invalid supertonic response ${await response.text()}`);
}
public getVoicePath(id: TTSSupertonicModel.RequestId): string {
const audioFileName = TTSModelBase.hashAudioFile(id.text + id.styleId);
const audioPath = join(
TTSSupertonicModel.SupertonicAudioCachePath,
audioFileName
);
return audioPath;
}
public createRequestId(text: string, styleId?: string): TTSSupertonicModel.RequestId {
return {
text,
styleId: styleId ?? "F1"
};
}
throw new Error(`invalid supertonic response ${await response.text()}`);
}
public getVoicePath(id: TTSSupertonicModel.RequestId): string {
const audioFileName = TTSModelBase.hashAudioFile(id.text + id.styleId);
const audioPath = join(
TTSSupertonicModel.SupertonicAudioCachePath,
audioFileName,
);
return audioPath;
}
public createRequestId(
text: string,
styleId?: string,
): TTSSupertonicModel.RequestId {
return {
text,
styleId: styleId ?? "F1",
};
}
}
export namespace TTSSupertonicModel {
export const instance = new TTSSupertonicModel();
export type RequestId = { text: string, styleId: string };
export const SupertonicAudioCachePath = join(TTSModelBase.AudioCachePath, "supertonic");
export const instance = new TTSSupertonicModel();
export type RequestId = { text: string; styleId: string };
export const SupertonicAudioCachePath = join(
TTSModelBase.AudioCachePath,
"supertonic",
);
}
export default TTSSupertonicModel;

View file

@ -6,89 +6,108 @@ import { readFileSync, writeFileSync } from "fs";
import { cwd } from "process";
export class TTSTypecastModel extends TTSModelBase<TTSTypecastModel.RequestId> {
protected cachedVoice: Map<String, Promise<Buffer>>
private lastUseApiKeyPath: string
constructor() {
super()
this.cachedVoice = new Map();
this.lastUseApiKeyPath = join(cwd(), "cache", "typecast", "lastUseApiToken");
}
ttsify(input: string): string {
return super.ttsify(
input
.replace(/ㅜㅜ/g, "눙물")
.replace(/빵/g, "빵 크크")
.replace(/[\?]+ *ㄴ/g, "물음표ㄴ")
)
}
private async getTypecastResponse(apiKey: string, voiceId: TTSTypecastModel.RequestId) {
const payload = {
text: voiceId.text,
model: "ssfm-v21",
voice_id: voiceId.voiceId,
language: "kor",
prompt: {
emotion_preset: "happy", // Options: normal, happy, sad, angry, tonemid, toneup
emotion_intensity: 1 // Range: 0.0 to 2.0
},
output: {
volume: 45, // Range: 0 to 200
audio_pitch: 1, // Range: -12 to +12 semitones
audio_tempo: 1, // Range: 0.5x to 2.0x
audio_format: "mp3" // Options: wav, mp3
},
seed: 22 // For reproducible results
};
protected cachedVoice: Map<string, Promise<Buffer>>;
private lastUseApiKeyPath: string;
constructor() {
super();
this.cachedVoice = new Map();
this.lastUseApiKeyPath = join(
cwd(),
"cache",
"typecast",
"lastUseApiToken",
);
}
ttsify(input: string): string {
return super.ttsify(
input
.replace(/ㅜㅜ/g, "눙물")
.replace(/빵/g, "빵 크크")
.replace(/[?]+ *ㄴ/g, "물음표ㄴ"),
);
}
private async getTypecastResponse(
apiKey: string,
voiceId: TTSTypecastModel.RequestId,
) {
const payload = {
text: voiceId.text,
model: "ssfm-v21",
voice_id: voiceId.voiceId,
language: "kor",
prompt: {
emotion_preset: "happy", // Options: normal, happy, sad, angry, tonemid, toneup
emotion_intensity: 1, // Range: 0.0 to 2.0
},
output: {
volume: 45, // Range: 0 to 200
audio_pitch: 1, // Range: -12 to +12 semitones
audio_tempo: 1, // Range: 0.5x to 2.0x
audio_format: "mp3", // Options: wav, mp3
},
seed: 22, // For reproducible results
};
return await fetch(TTSTypecastModel.TypecastApiUrl, {
method: "POST",
headers: {
"X-API-KEY": apiKey,
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
}
async getVoiceBuffer(voiceId: TTSTypecastModel.RequestId): Promise<ArrayBuffer> {
let response: Response | undefined;
return await fetch(TTSTypecastModel.TypecastApiUrl, {
method: "POST",
headers: {
"X-API-KEY": apiKey,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
}
async getVoiceBuffer(
voiceId: TTSTypecastModel.RequestId,
): Promise<ArrayBuffer> {
let response: Response | undefined;
for (let i = 0; i < TYPECAST_TOKENS.length; i++) {
response = await this.getTypecastResponse(readFileSync(this.lastUseApiKeyPath, "utf-8"), voiceId) as Response;
for (let i = 0; i < TYPECAST_TOKENS.length; i++) {
response = (await this.getTypecastResponse(
readFileSync(this.lastUseApiKeyPath, "utf-8"),
voiceId,
)) as Response;
if (response.ok)
return await response.arrayBuffer();;
if (response.ok) return await response.arrayBuffer();
if (response.status === 402) {
writeFileSync(this.lastUseApiKeyPath, TYPECAST_TOKENS[i]);
} else {
throw new Error(`TTS makeID request failed: ${response.status}: ${await response.text()}`);
}
}
throw new Error("Typecast Api use all credit");
}
public getVoicePath(id: TTSTypecastModel.RequestId): string {
const audioFileName = TTSModelBase.hashAudioFile(id.text);
const audioPath = join(
TTSTypecastModel.TypecastAudioCachePath,
id.voiceId,
audioFileName
if (response.status === 402) {
writeFileSync(this.lastUseApiKeyPath, TYPECAST_TOKENS[i]!);
} else {
throw new Error(
`TTS makeID request failed: ${response.status}: ${await response.text()}`,
);
return audioPath;
}
public createRequestId(text: string, voiceId?: string): TTSTypecastModel.RequestId {
return {
text,
voiceId: voiceId ?? TTSTypecastModel.DefaultVoiceId,
};
}
}
throw new Error("Typecast Api use all credit");
}
public getVoicePath(id: TTSTypecastModel.RequestId): string {
const audioFileName = TTSModelBase.hashAudioFile(id.text);
const audioPath = join(
TTSTypecastModel.TypecastAudioCachePath,
id.voiceId,
audioFileName,
);
return audioPath;
}
public createRequestId(
text: string,
voiceId?: string,
): TTSTypecastModel.RequestId {
return {
text,
voiceId: voiceId ?? TTSTypecastModel.DefaultVoiceId,
};
}
}
export namespace TTSTypecastModel {
export const instance = new TTSTypecastModel();
export type RequestId = { text: string, voiceId: string };
export const TypecastAudioCachePath = join(TTSModelBase.AudioCachePath, "typecast");
export const TypecastApiUrl = "https://api.typecast.ai/v1/text-to-speech";
export const DefaultVoiceId = "tc_6731b292d944a485bc406efb";
export const instance = new TTSTypecastModel();
export type RequestId = { text: string; voiceId: string };
export const TypecastAudioCachePath = join(
TTSModelBase.AudioCachePath,
"typecast",
);
export const TypecastApiUrl = "https://api.typecast.ai/v1/text-to-speech";
export const DefaultVoiceId = "tc_6731b292d944a485bc406efb";
}
export default TTSTypecastModel;

View file

@ -1,29 +1,28 @@
export default class CallingNumberKorean {
// 개, 살 이 붙는 경우 발음법
static SecondDigit = [
"", "열", "스물", "서른", "마흔", "쉰",
"예순", "일흔", "여든", "아흔",
]
static FirstDigit = [
"", "한", "두", "세", "네", "다섯",
"여섯", "일곱", "여덟", "아홉", "열",
]
static canConvert(num: number): boolean {
return num < 100 && num >= 0 && Number.isInteger(num)
// 개, 살 이 붙는 경우 발음법
// prettier-ignore
static SecondDigit = [
"", "열", "스물", "서른", "마흔", "쉰",
"예순", "일흔", "여든", "아흔",
];
// prettier-ignore
static FirstDigit = [
"", "한", "두", "세", "네", "다섯",
"여섯", "일곱", "여덟", "아홉", "열",
];
static canConvert(num: number): boolean {
return num < 100 && num >= 0 && Number.isInteger(num);
}
static convert(num: number): string {
const firstDigit = num % 10;
const secondDigit = Math.floor(num / 10);
let result = this.SecondDigit[secondDigit]! + this.FirstDigit[firstDigit]!;
if (!result.length) {
result = "영";
}
static convert(num: number): string {
const firstDigit = num % 10;
const secondDigit = Math.floor(num / 10);
let result = (
this.SecondDigit[secondDigit]
+ this.FirstDigit[firstDigit]
);
if (!result.length) {
result = "영"
}
return result;
}
return result;
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,16 @@
export default async function(url: URL | RequestInfo, request: RequestInit={}, time: number=5000): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), time);
export default async function (
url: URL | RequestInfo,
request: RequestInit = {},
time: number = 5000,
): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), time);
request.signal ??= controller.signal;
request.signal ??= controller.signal;
try {
return await fetch(url, request);;
} finally {
clearTimeout(timeout);
}
try {
return await fetch(url, request);
} finally {
clearTimeout(timeout);
}
}

View file

@ -1,16 +1,14 @@
export default class FloatKorean {
static Digits = [
"영", "일", "이", "삼", "사", "오", "육", "칠", "팔", "구"
];
static convert(num: string): string {
const buf = new Array(num.length);
for (let idx = 0; idx < num.length; idx++) {
if (num[idx] == ".") {
buf[idx] = "쩜";
} else {
buf[idx] = this.Digits[+num[idx]];
}
}
return buf.join("");
static Digits = ["영", "일", "이", "삼", "사", "오", "육", "칠", "팔", "구"];
static convert(num: string): string {
const buf = new Array(num.length);
for (let idx = 0; idx < num.length; idx++) {
if (num[idx] == ".") {
buf[idx] = "쩜";
} else {
buf[idx] = this.Digits[+(num[idx] ?? "0")];
}
}
return buf.join("");
}
}

View file

@ -1,110 +1,141 @@
export default class IntegerKorean {
static DigitName = [ "영", "일", "이", "삼", "사", "오", "육", "칠", "팔", "구" ];
static DigitModifier = ["", "십", "백", "천"];
static Unit = [ "", "만", "억", "조", "경", "해", "자", "양", "구", "간", "정", "재", "극", "항하사", "아승기", "나유타", "불가사의", "무량대수" ];
static DigitName = [
"영",
"일",
"이",
"삼",
"사",
"오",
"육",
"칠",
"팔",
"구",
];
static DigitModifier = ["", "십", "백", "천"];
static Unit = [
"",
"만",
"억",
"조",
"경",
"해",
"자",
"양",
"구",
"간",
"정",
"재",
"극",
"항하사",
"아승기",
"나유타",
"불가사의",
"무량대수",
];
private static stringifyKDigits(
first: number, second: number, third: number, forth: number
): string {
const buf = [];
private static stringifyKDigits(
first: number,
second: number,
third: number,
forth: number,
): string {
const buf = [];
if (forth) {
if (forth >= 2) buf.push(this.DigitName[forth]);
buf.push(this.DigitModifier[3]);
}
if (third) {
if (third >= 2) buf.push(this.DigitName[third]);
buf.push(this.DigitModifier[2]);
}
if (second) {
if (second >= 2) buf.push(this.DigitName[second]);
buf.push(this.DigitModifier[1]);
}
if (first || (!forth && !third && !second)) {
buf.push(this.DigitName[first]);
}
return buf.join("");
if (forth) {
if (forth >= 2) buf.push(this.DigitName[forth]);
buf.push(this.DigitModifier[3]);
}
private static parseKDigitsFromNumber(num: number): string {
const first = num % 10;
const second = Math.floor(num / 10) % 10;
const third = Math.floor(num / 100) % 10;
const forth = Math.floor(num / 1000) % 10;
return this.stringifyKDigits(first, second, third, forth);
if (third) {
if (third >= 2) buf.push(this.DigitName[third]);
buf.push(this.DigitModifier[2]);
}
private static parseKDigitsFromString(num: string, offset: number): string {
const first = +num[offset];
const second = offset >= 1 ? +num[offset - 1] : 0;
const third = offset >= 2 ? +num[offset - 2] : 0;
const forth = offset >= 3 ? +num[offset - 3] : 0;
return this.stringifyKDigits(first, second, third, forth);
if (second) {
if (second >= 2) buf.push(this.DigitName[second]);
buf.push(this.DigitModifier[1]);
}
if (first || (!forth && !third && !second)) {
buf.push(this.DigitName[first]);
}
static convertFromString(num: string): string {
num = num.replace(/,/g, "");
let isNegative = false;
if (num.startsWith("-")) {
num = num.slice(1, -1);
isNegative = true;
}
if (num == "0") {
return isNegative ? "마이너스영" : "영";
}
return buf.join("");
}
private static parseKDigitsFromNumber(num: number): string {
const first = num % 10;
const second = Math.floor(num / 10) % 10;
const third = Math.floor(num / 100) % 10;
const forth = Math.floor(num / 1000) % 10;
const unitStack = [];
let offset = num.length - 1;
while (offset >= 0) {
unitStack.push(this.parseKDigitsFromString(num, offset));
offset -= 4;
}
return this.stringifyKDigits(first, second, third, forth);
}
private static parseKDigitsFromString(num: string, offset: number): string {
const first = +num[offset]!;
const second = offset >= 1 ? +num[offset - 1]! : 0;
const third = offset >= 2 ? +num[offset - 2]! : 0;
const forth = offset >= 3 ? +num[offset - 3]! : 0;
const buf = [];
if (isNegative) buf.push("마이너스");
for (let i = unitStack.length - 1; i >= 0; i--) {
const currUnit = this.Unit[i];
let currKDigits = unitStack[i];
return this.stringifyKDigits(first, second, third, forth);
}
if (currKDigits == "영") continue;
if (i == 1 && currKDigits == "일")
currKDigits = "";
buf.push(currKDigits + currUnit);
}
return buf.join("");
static convertFromString(num: string): string {
num = num.replace(/,/g, "");
let isNegative = false;
if (num.startsWith("-")) {
num = num.slice(1, -1);
isNegative = true;
}
static convertFromNumber(num: number): string {
let isNegative = false;
if (num < 0) {
isNegative = true;
num *= -1;
}
if (num == 0) {
return "영";
}
const unitStack = [];
while (num) {
unitStack.push(this.parseKDigitsFromNumber(num));
num = Math.floor(num / 10000);
}
const buf = [];
if (isNegative) buf.push("마이너스");
for (let i = unitStack.length - 1; i >= 0; i--) {
const currUnit = this.Unit[i];
let currKDigits = unitStack[i];
if (currKDigits == "영") continue;
if (i == 1 && currKDigits == "일")
currKDigits = "";
buf.push(currKDigits + currUnit);
}
return buf.join("");
if (num == "0") {
return isNegative ? "마이너스영" : "영";
}
const unitStack = [];
let offset = num.length - 1;
while (offset >= 0) {
unitStack.push(this.parseKDigitsFromString(num, offset));
offset -= 4;
}
const buf = [];
if (isNegative) buf.push("마이너스");
for (let i = unitStack.length - 1; i >= 0; i--) {
const currUnit = this.Unit[i] ?? "";
let currKDigits = unitStack[i] ?? "";
if (currKDigits == "영") continue;
if (i == 1 && currKDigits == "일") currKDigits = "";
buf.push(currKDigits + currUnit);
}
return buf.join("");
}
static convertFromNumber(num: number): string {
let isNegative = false;
if (num < 0) {
isNegative = true;
num *= -1;
}
if (num == 0) {
return "영";
}
const unitStack = [];
while (num) {
unitStack.push(this.parseKDigitsFromNumber(num));
num = Math.floor(num / 10000);
}
const buf = [];
if (isNegative) buf.push("마이너스");
for (let i = unitStack.length - 1; i >= 0; i--) {
const currUnit = this.Unit[i] ?? "";
let currKDigits = unitStack[i] ?? "";
if (currKDigits == "영") continue;
if (i == 1 && currKDigits == "일") currKDigits = "";
buf.push(currKDigits + currUnit);
}
return buf.join("");
}
}

View file

@ -1,137 +1,173 @@
export const nyaWords = {
'나': "냐",
'낙': "냑",
'낚': "냒",
'낛': "냓",
'난': "냔",
'낝': "냕",
'낞': "냖",
'낟': "냗",
'날': "냘",
'낡': "냙",
'낢': "냚",
'낣': "냛",
'낤': "냜",
'낥': "냝",
'낦': "냞",
'낧': "냟",
'남': "냠",
'납': "냡",
'낪': "냢",
'낫': "냣",
'났': "냤",
'낭': "냥",
'낮': "냦",
'낯': "냧",
'낰': "냨",
'낱': "냩",
'낲': "냪",
'낳': "냫",
: "냐",
: "냑",
: "냒",
: "냓",
: "냔",
: "냕",
: "냖",
: "냗",
: "냘",
: "냙",
: "냚",
: "냛",
: "냜",
: "냝",
: "냞",
: "냟",
: "냠",
: "냡",
: "냢",
: "냣",
: "냤",
: "냥",
: "냦",
: "냧",
: "냨",
: "냩",
: "냪",
: "냫",
};
export const nyaWords2 = {
'내': "냥",
'넹': "냥",
'넴': "냥",
'넵': "냥",
'냐': "냥",
'님': "냥",
'니': "냥",
'다': "다냥",
'까': "까냥",
'네': "네냥",
'야': "야냥",
'꺼': "꺼냥",
'래': "래냥",
'해': "해냥",
'지': "지냥",
'라': "라냥",
'요': "요냥",
'가': "가냥",
'데': "데냥",
'돼': "돼냥",
'줘': "줘냥",
'마': "마냥",
'와': "와냥",
'어': "어냥",
'자': "자냥",
'죠': "죠냥",
'서': "서냥",
'게': "게냥",
: "냥",
: "냥",
: "냥",
: "냥",
: "냥",
: "냥",
: "냥",
: "다냥",
: "까냥",
: "네냥",
: "야냥",
: "꺼냥",
: "래냥",
: "해냥",
: "지냥",
: "라냥",
: "요냥",
: "가냥",
: "데냥",
: "돼냥",
: "줘냥",
: "마냥",
: "와냥",
: "어냥",
: "자냥",
: "죠냥",
: "서냥",
: "게냥",
};
function replacePunctuation(input: string): string {
return input.replace(/(^|\s)([?!,.;~^@()]+)/g, (match, p1, p2) => {
const firstChar = p2[0];
let transformed;
return input.replace(/(^|\s)([?!,.;~^@()]+)/g, (match, p1, p2) => {
const firstChar = p2[0];
let transformed;
if (firstChar === '?') transformed = '냥?';
else if (firstChar === '!') transformed = '냥!';
else if (firstChar === ',') transformed = '냥,';
else if (firstChar === '.') transformed = '냥.';
else if (firstChar === ';') transformed = '냥;';
else if (firstChar === '~') transformed = '냥~';
else if (firstChar === '^') transformed = '냥^';
else if (firstChar === '@') transformed = '냥@';
else if (firstChar === '(') transformed = '냥(';
else if (firstChar === ')') transformed = '냥)';
else transformed = p2;
if (firstChar === "?") transformed = "냥?";
else if (firstChar === "!") transformed = "냥!";
else if (firstChar === ",") transformed = "냥,";
else if (firstChar === ".") transformed = "냥.";
else if (firstChar === ";") transformed = "냥;";
else if (firstChar === "~") transformed = "냥~";
else if (firstChar === "^") transformed = "냥^";
else if (firstChar === "@") transformed = "냥@";
else if (firstChar === "(") transformed = "냥(";
else if (firstChar === ")") transformed = "냥)";
else transformed = p2;
return p1 + transformed + p2.slice(1);
});
return p1 + transformed + p2.slice(1);
});
}
function addNyangAtMWord(sentence: string): string {
return sentence.split(' ').map((word) => {
const match = word.match(/^([가-힣]+)([?!,.;~^@()]*)$/);
return sentence
.split(" ")
.map((word) => {
const match = word.match(/^([가-힣]+)([?!,.;~^@()]*)$/);
if (!match) return word;
if (!match) return word;
const baseWord = match[1];
const punctuation = match[2];
const baseWord = match[1] ?? "";
const punctuation = match[2] ?? "";
const lastChar = baseWord[baseWord.length - 1];
const charCode = lastChar.charCodeAt(0);
const lastChar = baseWord[baseWord.length - 1] ?? "";
const charCode = lastChar.charCodeAt(0);
if (charCode >= 0xAC00 && charCode <= 0xD7A3) {
const baseCode = charCode - 0xAC00;
const jongseong = baseCode % 28;
if (charCode >= 0xac00 && charCode <= 0xd7a3) {
const baseCode = charCode - 0xac00;
const jongseong = baseCode % 28;
if (jongseong === 16) {
return baseWord + "냥" + punctuation;
}
if (jongseong === 16) {
return baseWord + "냥" + punctuation;
}
}
return word;
}).join(' ');
return word;
})
.join(" ");
}
export function nyaize(text: string): string {
for (let key in nyaWords2) {
text = text.replaceAll(key + ".", nyaWords2[key as keyof typeof nyaWords2] + "."); // yeah I gotta optimize these
text = text.replaceAll(key + ",", nyaWords2[key as keyof typeof nyaWords2] + ",");
text = text.replaceAll(key + "?", nyaWords2[key as keyof typeof nyaWords2] + "?");
text = text.replaceAll(key + "!", nyaWords2[key as keyof typeof nyaWords2] + "!");
text = text.replaceAll(key + ";", nyaWords2[key as keyof typeof nyaWords2] + ";");
text = text.replaceAll(key + "~", nyaWords2[key as keyof typeof nyaWords2] + "~");
text = text.replaceAll(key + "^", nyaWords2[key as keyof typeof nyaWords2] + "^");
text = text.replaceAll(key + "@", nyaWords2[key as keyof typeof nyaWords2] + "@");
text = text.replaceAll(key + "(", nyaWords2[key as keyof typeof nyaWords2] + "(");
text = text.replaceAll(key + ")", nyaWords2[key as keyof typeof nyaWords2] + ")");
text = text.replaceAll(key + " ", nyaWords2[key as keyof typeof nyaWords2] + " ");
for (const key in nyaWords2) {
text = text.replaceAll(
key + ".",
nyaWords2[key as keyof typeof nyaWords2] + ".",
); // yeah I gotta optimize these
text = text.replaceAll(
key + ",",
nyaWords2[key as keyof typeof nyaWords2] + ",",
);
text = text.replaceAll(
key + "?",
nyaWords2[key as keyof typeof nyaWords2] + "?",
);
text = text.replaceAll(
key + "!",
nyaWords2[key as keyof typeof nyaWords2] + "!",
);
text = text.replaceAll(
key + ";",
nyaWords2[key as keyof typeof nyaWords2] + ";",
);
text = text.replaceAll(
key + "~",
nyaWords2[key as keyof typeof nyaWords2] + "~",
);
text = text.replaceAll(
key + "^",
nyaWords2[key as keyof typeof nyaWords2] + "^",
);
text = text.replaceAll(
key + "@",
nyaWords2[key as keyof typeof nyaWords2] + "@",
);
text = text.replaceAll(
key + "(",
nyaWords2[key as keyof typeof nyaWords2] + "(",
);
text = text.replaceAll(
key + ")",
nyaWords2[key as keyof typeof nyaWords2] + ")",
);
text = text.replaceAll(
key + " ",
nyaWords2[key as keyof typeof nyaWords2] + " ",
);
if (text.endsWith(key)) {
text = text.slice(0, -1) + nyaWords2[key as keyof typeof nyaWords2];
}
if (text.endsWith(key)) {
text = text.slice(0, -1) + nyaWords2[key as keyof typeof nyaWords2];
}
}
for (let key in nyaWords) {
text = text.replaceAll(key, nyaWords[key as keyof typeof nyaWords]);
}
for (const key in nyaWords) {
text = text.replaceAll(key, nyaWords[key as keyof typeof nyaWords]);
}
text = replacePunctuation(text);
text = replacePunctuation(text);
text = addNyangAtMWord(text);
text = addNyangAtMWord(text);
return text;
return text;
}

View file

@ -1,42 +1,37 @@
import { dirname, join } from "path";
import { mkdir, open, readFile } from "fs/promises"
import { mkdir, open, readFile } from "fs/promises";
export namespace OutputHandler {
export const LogCachePath = join(
process.cwd(),
"cache",
"log",
);
export const ErrorLogPath = join(LogCachePath, "error.log" );
export function getErrorOutput(...args: any[]) {
const timestamp = new Date().toISOString();
export const LogCachePath = join(process.cwd(), "cache", "log");
export const ErrorLogPath = join(LogCachePath, "error.log");
export function getErrorOutput(...args: any[]) {
const timestamp = new Date().toISOString();
const message = args
.map(arg => {
if (arg instanceof Error) {
return `${arg.name}: ${arg.message}\n${arg.stack}`;
}
if (typeof arg === 'object') {
return JSON.stringify(arg);
}
return String(arg);
})
.join(' ');
const message = args
.map((arg) => {
if (arg instanceof Error) {
return `${arg.name}: ${arg.message}\n${arg.stack}`;
}
if (typeof arg === "object") {
return JSON.stringify(arg);
}
return String(arg);
})
.join(" ");
return `[${timestamp}] ${message}`;
}
export async function errorLog(...args: any[]): Promise<void> {
const output = getErrorOutput(...args);
console.log(output);
return `[${timestamp}] ${message}`;
}
export async function errorLog(...args: any[]): Promise<void> {
const output = getErrorOutput(...args);
console.log(output);
await mkdir(dirname(ErrorLogPath), { recursive: true });
await mkdir(dirname(ErrorLogPath), { recursive: true });
const fileHandle = await open(ErrorLogPath, "a");
fileHandle.write(output + "\n");
fileHandle.close();
}
export async function getErrorLog(): Promise<string> {
return (await readFile(ErrorLogPath)).toString();
}
const fileHandle = await open(ErrorLogPath, "a");
fileHandle.write(output + "\n");
fileHandle.close();
}
export async function getErrorLog(): Promise<string> {
return (await readFile(ErrorLogPath)).toString();
}
}

View file

@ -1,15 +1,17 @@
export default class PhoneNumberKorean {
static DigitName = [ "공", "일", "이", "삼", "사", "오", "육", "칠", "팔", "구" ];
static Dash = " ";
// prettier-ignore
static DigitName = [
"공", "일", "이", "삼", "사",
"오", "육", "칠", "팔", "구",
];
static Dash = " ";
static convert(phone: string): string {
return phone.replace(/[\d\- \+]/g, (char: string) => {
if (char == "-") return PhoneNumberKorean.Dash;
if (char == " ") return " ";
if (char == "+") return "플러스";
return PhoneNumberKorean.DigitName[
parseInt(char) as number
] ?? "";
})
}
static convert(phone: string): string {
return phone.replace(/[\d\- +]/g, (char: string) => {
if (char == "-") return PhoneNumberKorean.Dash;
if (char == " ") return " ";
if (char == "+") return "플러스";
return PhoneNumberKorean.DigitName[parseInt(char) as number] ?? "";
});
}
}

View file

@ -1,13 +1,13 @@
import { readdirSync } from "fs";
import { readdir } from "fs/promises";
import { join } from "path";
export async function requireDirectory<T>(directory: string): Promise<T[]> {
const requireFiles = (await readdir(directory)).filter(file => file.endsWith(".js"));
return requireFiles.map(file => require(join(directory, file)).default as T);
}
export function requireDirectorySync<T>(directory: string): T[] {
const requireFiles = readdirSync(directory).filter(file => file.endsWith(".js"));
return requireFiles.map(file => require(join(directory, file)).default as T).filter(x=>x);
const requireFiles = (await readdir(directory)).filter((file) =>
file.endsWith(".js"),
);
return await Promise.all(
requireFiles.map(
async (file) => (await import(join(directory, file))).default as T,
),
);
}

View file

@ -1,388 +1,393 @@
import CallingNumberKorean from "./callingNumberKorean";
import FloatKorean from "./floatKorean";
import IntegerKorean from "./integerKorean";
import PhoneNumberKorean from "./phoneNumberKorean";
import CallingNumberKorean from "./callingNumberKorean.js";
import FloatKorean from "./floatKorean.js";
import IntegerKorean from "./integerKorean.js";
import PhoneNumberKorean from "./phoneNumberKorean.js";
import EmojiDescriptions from "./emoji-descriptions.json" with { type: "json" };
export const IsolatedSymbolMap = {
"?": "물음표",
"!": "느낌표",
"'": "쿼트",
"\"": "더블쿼트",
}
"?": "물음표",
"!": "느낌표",
"'": "쿼트",
'"': "더블쿼트",
};
export const SymbolMap = {
"%": "퍼센트",
"$": "달러",
"^": "캐럿",
"&": "엔드",
"*": "스타",
"#": "샵",
"@": "엣",
".": "쩜",
"-": "마이너스",
"+": "플러스",
"_": "언더바",
"=": "이퀄",
"/": "슬래쉬",
"~": "물결표",
"\\": "역슬래쉬",
"♡": "하트 ",
"|": "",
">": "",
"<": "",
":": "콜론",
";": "세미콜론"
"%": "퍼센트",
$: "달러",
"^": "캐럿",
"&": "엔드",
"*": "스타",
"#": "샵",
"@": "엣",
".": "쩜",
"-": "마이너스",
"+": "플러스",
_: "언더바",
"=": "이퀄",
"/": "슬래쉬",
"~": "물결표",
"\\": "역슬래쉬",
"♡": "하트 ",
"|": "",
">": "",
"<": "",
":": "콜론",
";": "세미콜론",
};
export const VersionPostfix = {
"a": "알파",
"b": "베타",
a: "알파",
b: "베타",
};
export const LangPrefixes = {
"typescript": "타입스크립트",
"javascript": "자바스크립트",
"java": "자바",
"kotlin": "코틀린",
"rust": "러스트",
"lua": "루아",
"json": "제이슨",
"yaml": "야믈",
"yml": "야믈",
"toml": "토믈",
"xml": "엑스엠엘",
"julia": "줄리아",
"matlab": "매트랩",
"erlang": "얼랭",
"elxir": "엘릭서",
"zig": "지그",
"txt": "텍스트",
"vim": "빔",
"perl": "펄",
"php": "피에이치피",
"lisp": "리스프",
"postscript": "포스트스크립트",
"ghostscript": "고스트스크립트",
"fortran": "포트란",
"algol": "알골",
"scala": "스칼라",
"haskell": "하스켈",
"basic": "베이직",
typescript: "타입스크립트",
javascript: "자바스크립트",
java: "자바",
kotlin: "코틀린",
rust: "러스트",
lua: "루아",
json: "제이슨",
yaml: "야믈",
yml: "야믈",
toml: "토믈",
xml: "엑스엠엘",
julia: "줄리아",
matlab: "매트랩",
erlang: "얼랭",
elxir: "엘릭서",
zig: "지그",
txt: "텍스트",
vim: "빔",
perl: "펄",
php: "피에이치피",
lisp: "리스프",
postscript: "포스트스크립트",
ghostscript: "고스트스크립트",
fortran: "포트란",
algol: "알골",
scala: "스칼라",
haskell: "하스켈",
basic: "베이직",
"cpp": "씨플플",
"c++": "씨플플",
"csharp": "씨샵",
"cs": "씨샵",
"c#": "씨샵",
"c": "씨",
"h": "헤더",
cpp: "씨플플",
"c++": "씨플플",
csharp: "씨샵",
cs: "씨샵",
"c#": "씨샵",
c: "씨",
h: "헤더",
"d": "디",
"awk": "에이더블류케이",
"pl": "펄",
"pwsh": "파워쉘",
"powershell": "파워쉘",
"cmd": "씨엠디",
"sh": "쉘",
"ps1": "파워셀",
"bat": "배치파일",
"bash": "베시스크립트",
"tex": "텍",
"dart": "다트",
"go": "고랭",
"python": "파이썬",
"swift": "스위프트",
"css": "씨에스에스",
"html": "에이치티엠엘",
d: "디",
awk: "에이더블류케이",
pl: "펄",
pwsh: "파워쉘",
powershell: "파워쉘",
cmd: "씨엠디",
sh: "쉘",
ps1: "파워셀",
bat: "배치파일",
bash: "베시스크립트",
tex: "텍",
dart: "다트",
go: "고랭",
python: "파이썬",
swift: "스위프트",
css: "씨에스에스",
html: "에이치티엠엘",
"latex": "레이텍",
"md": "마크다운",
"markdown": "마크다운",
latex: "레이텍",
md: "마크다운",
markdown: "마크다운",
"py": "파이썬",
"hs": "하스켈",
"rs": "러스트",
"kt": "코틀린",
"js": "자스",
"ts": "타스",
"tsx": "리액트 타입스크립트",
"jsx": "리액트 자바스크립트",
"an": "에이엔",
"parlance": "팔렌스",
py: "파이썬",
hs: "하스켈",
rs: "러스트",
kt: "코틀린",
js: "자스",
ts: "타스",
tsx: "리액트 타입스크립트",
jsx: "리액트 자바스크립트",
an: "에이엔",
parlance: "팔렌스",
};
export const LangPrefixMaxLength = (()=>{
let max = 0;
for (const key in LangPrefixes) {
max = Math.max(key.length, max);
}
return max;
export const LangPrefixMaxLength = (() => {
let max = 0;
for (const key in LangPrefixes) {
max = Math.max(key.length, max);
}
return max;
})();
export const ChoseongMap = {
"ㄱ": "기역",
"ㄴ": "니은",
"ㄷ": "디귿",
"ㄹ": "리을",
"ㅁ": "미음",
"ㅂ": "비읍",
"ㅅ": "시옷",
"ㅇ": "이응",
"ㅈ": "지읒",
"ㅊ": "치읓",
"ㅋ": "키읔",
"ㅌ": "티읕",
"ㅍ": "피읖",
"ㅎ": "히읗",
"ㄲ": "쌍기역",
"ㄸ": "쌍디귿",
"ㅃ": "쌍비읍",
"ㅆ": "쌍시옷",
"ㅉ": "쌍지읒",
: "기역",
: "니은",
: "디귿",
: "리을",
: "미음",
: "비읍",
: "시옷",
: "이응",
: "지읒",
: "치읓",
: "키읔",
: "티읕",
: "피읖",
: "히읗",
: "쌍기역",
: "쌍디귿",
: "쌍비읍",
: "쌍시옷",
: "쌍지읒",
};
export const SIPrefix = {
"k": "킬로",
"ki": "키비",
"m": "메가",
"mi": "메비",
"g": "기가",
"gi": "기비",
"t": "테라",
"ti": "테비",
"p": "페타",
"pi": "페비",
"e": "엑사",
"ei": "엑시",
"z": "제타",
"zi": "제비",
"y": "요타",
"yi": "요비",
k: "킬로",
ki: "키비",
m: "메가",
mi: "메비",
g: "기가",
gi: "기비",
t: "테라",
ti: "테비",
p: "페타",
pi: "페비",
e: "엑사",
ei: "엑시",
z: "제타",
zi: "제비",
y: "요타",
yi: "요비",
};
export const LiterPrefix = {
"m": "밀리",
"": "",
m: "밀리",
"": "",
};
export const MeterPrefix = {
"m": "밀리",
"c": "센치",
"": "",
"k": "킬로",
m: "밀리",
c: "센치",
"": "",
k: "킬로",
};
export const GIFMap = {
"tenor.com/view/majo-no-tabitabi-the-journey-of-elaina-elaina-windy-hair-gif-19187698": "화난 일레이나",
"tenor.com/view/majo-no-tabitabi-the-journey-of-elaina-elaina-sparkle-amazed-gif-18827847": "일레이나 반짝반짝!",
"images-ext-1.discordapp.net/external/C3xPFuUxs16jY25AR3NvsIDezaOtib9wozhLBWejZk4/https/media.tenor.com/bUd8mk4ufwsAAAPo/anime-disappointment.mp4": "일레이나 절래절래",
"images-ext-1.discordapp.net/external/SXv4qgpy2r1Gx-dNxhcfJle6AXDaH_SToRjEBYYaup0/https/media.tenor.com/nDDxJc4FDwEAAAPo/cute.mp4": "일레이나 끄덕",
"tenor.com/view/majo-no-tabitabi-the-journey-of-elaina-elaina-what-gif-19011602": "당황한 일레이나",
"images-ext-1.discordapp.net/external/2R41WcvNJwYMD69UKls2cDa_hEL-rzCRCFvOi2DDOVo/https/media.tenor.com/sU3RCOixDbgAAAPo/majo-no-tabitabi-the-journey-of-elaina.mp4": "일레이나 손짓",
"tenor.com/view/majo-no-tabitabi-the-journey-of-elaina-elaina-windy-hair-gif-19187698":
"화난 일레이나",
"tenor.com/view/majo-no-tabitabi-the-journey-of-elaina-elaina-sparkle-amazed-gif-18827847":
"일레이나 반짝반짝!",
"images-ext-1.discordapp.net/external/C3xPFuUxs16jY25AR3NvsIDezaOtib9wozhLBWejZk4/https/media.tenor.com/bUd8mk4ufwsAAAPo/anime-disappointment.mp4":
"일레이나 절래절래",
"images-ext-1.discordapp.net/external/SXv4qgpy2r1Gx-dNxhcfJle6AXDaH_SToRjEBYYaup0/https/media.tenor.com/nDDxJc4FDwEAAAPo/cute.mp4":
"일레이나 끄덕",
"tenor.com/view/majo-no-tabitabi-the-journey-of-elaina-elaina-what-gif-19011602":
"당황한 일레이나",
"images-ext-1.discordapp.net/external/2R41WcvNJwYMD69UKls2cDa_hEL-rzCRCFvOi2DDOVo/https/media.tenor.com/sU3RCOixDbgAAAPo/majo-no-tabitabi-the-journey-of-elaina.mp4":
"일레이나 손짓",
};
export const UnicodeEmojis = {
"㎢": "제곱킬로미터",
"㎡": "제곱미터",
"↑": "위쪽 화살표", "↓": "아래쪽 화살표",
"←": "왼쪽 화살표", "→": "오른쪽 화살표",
"↔": "좌우 화살표",
"↖": "왼쪽 위 화살표", "↗": "오른쪽 위 화살표",
"↘": "오른쪽 아래 화살표", "↙": "왼쪽 아래 화살표",
"🎀": "리본", "🐱": "고양이", "✨": "반짝임", "🍞": "빵",
"🧸": "인형", "🍓": "딸기", "🌸": "벚꽃", "🍰": "조각 케이크",
"🐾": "발자국", "👑": "왕관", "🦄": "유니콘", "🐰": "토끼",
"🦊": "여우", "🐻": "곰", "🐼": "판다", "🐥": "아기 병아리",
"🦋": "나비", "🌹": "장미", "🌷": "튤립", "🍀": "네잎클로버",
"🍁": "단풍잎", "🌙": "초승달", "⭐": "별", "🌈": "무지개",
"🌋": "화산", "🌊": "파도", "🔮": "수정구슬", "🍬": "사탕",
"🍭": "막대사탕", "🍫": "초콜릿", "🍩": "도넛", "🍪": "쿠키",
"🍨": "아이스크림", "🥞": "팬케이크", "🍎": "빨간 사과",
"🍒": "체리", "🍑": "복숭아", "🍇": "포도", "🧁": "컵케이크",
"🍋": "레몬", "🍌": "바나나", "🥑": "아보카도", "🥕": "당근",
"🍕": "피자", "🍔": "햄버거", "🍟": "감자튀김", "🍿": "팝콘",
"🧂": "소금", "🎈": "풍선", "🎉": "폭죽", "🎬": "슬레이트",
"🎁": "선물", "🎫": "티켓", "🏆": "트로피", "🎨": "팔레트",
"🎤": "마이크", "📱": "휴대전화", "🎼": "높은음자리표",
"🎸": "기타", "🎧": "헤드폰", "🎹": "키보드", "💻": "노트북",
"⌚": "시계", "📷": "카메라", "🔍": "돋보기", "💡": "전구",
"🕯️": "양초", "📜": "두루마리", "🔑": "열쇠", "🔒": "자물쇠",
"🔔": "종", "📣": "메가폰", "📦": "상자", "✉️": "편지",
"📌": "압정", "✂️": "가위", "🩹": "반창고", "🧬": "DNA",
"🧪": "시험관", "🔭": "망원경", "🚀": "로켓", "🛸": "UFO",
"🚲": "자전거", "🛹": "스케이트보드", "⚓": "닻", "⛺": "텐트",
"🧭": "나침반", "🗺️": "세계지도", "🏡": "집", "🏰": "성",
"🎡": "관람차", "🎠": "회전목마", "⛲": "분수", "💎": "보석",
"🪞": "거울", "💄": "립스틱",
}
export const UnicodeEmojisRegex = new RegExp(
"(" +
Object.keys(UnicodeEmojis).join(")|(")
+ ")", "g"
export const UnicodeSymbols = {
"㎢": "제곱킬로미터",
"㎡": "제곱미터",
"↑": "위쪽 화살표",
"↓": "아래쪽 화살표",
"←": "왼쪽 화살표",
"→": "오른쪽 화살표",
"↔": "좌우 화살표",
"↖": "왼쪽 위 화살표",
"↗": "오른쪽 위 화살표",
"↘": "오른쪽 아래 화살표",
"↙": "왼쪽 아래 화살표",
};
export const UnicodeSymbolsRegex = new RegExp(
"[" + Object.keys(UnicodeSymbols).join() + "]",
"gu",
);
export function processDots(input: string): string {
return input.replace(/[\.,]+$/, "")
.replace(/[\.,]{2,}/g, "")
.replace(/[\.,]\s/g, " ");
return input
.replace(/[.,]+$/, "")
.replace(/[.,]{2,}/g, "")
.replace(/[.,]\s/g, " ");
}
export function saferKorean(input: string): string {
return processDots(input.normalize() + " ")
// Process isolated symbols
.replace(/^[\?\!\'\"]+ $/, (total)=>(
[...total].map(element => IsolatedSymbolMap[
element as keyof typeof IsolatedSymbolMap
]).join("")
))
.replace(/\s\|\|\s/g, " 오얼 ")
.replace(/\s\&\&\s/g, " 엔드 ")
return (
processDots(input.normalize() + " ")
// Process isolated symbols
.replace(/^[?!'"]+ $/, (total) =>
[...total]
.map(
(element) =>
IsolatedSymbolMap[element as keyof typeof IsolatedSymbolMap],
)
.join(""),
)
.replace(/\s\|\|\s/g, " 오얼 ")
.replace(/\s&&\s/g, " 엔드 ")
// Process codeblock
.replace(/\`\`\`([\s\S]*?)\`\`\`/g, (_, content: string)=>{
const code = content.substring(0, LangPrefixMaxLength).toLowerCase();
let lang = "";
for (const [key, value] of Object.entries(LangPrefixes)) {
if (code.startsWith(key + "\n")) {
lang = value + " ";
break;
}
}
return lang + "코드블럭";
})
// Process codeblock
.replace(/```([\s\S]*?)```/g, (_, content: string) => {
const code = content.substring(0, LangPrefixMaxLength).toLowerCase();
let lang = "";
for (const [key, value] of Object.entries(LangPrefixes)) {
if (code.startsWith(key + "\n")) {
lang = value + " ";
break;
}
}
return lang + "코드블럭";
})
// Process link
.replace(/[hH][tT]{2}[pP][sS]?:\/\/(\S+)/g, (_, url: string) => {
const mapped = GIFMap[url as keyof typeof GIFMap] as (string | undefined);
if (mapped) return mapped;
// Process link
.replace(/[hH][tT]{2}[pP][sS]?:\/\/(\S+)/g, (_, url: string) => {
const mapped = GIFMap[url as keyof typeof GIFMap] as string | undefined;
if (mapped) return mapped;
if (url.startsWith("tenor.com/view")) {
return "움짤!";
}
return "링크";
})
if (url.startsWith("tenor.com/view")) {
return "움짤!";
}
return "링크";
})
// Process koreans
.replace(/[아ㅏ]{3,}/g, "아아아")
.replace(/ㄹㅇ/g, (content: string) => {
return "리얼".repeat(
Math.min(Math.floor(content.length / 2), 2)
// Process koreans
.replace(/[아ㅏ]{3,}/g, "아아아")
.replace(/ㄹㅇ/g, (content: string) => {
return "리얼".repeat(Math.min(Math.floor(content.length / 2), 2));
})
.replace(/(ㅇㄴ)+/g, (content: string) => {
return "아니".repeat(Math.min(Math.floor(content.length / 2), 2));
})
.replace(/(ㅇㅎ)+/g, (content: string) => {
return "아하".repeat(Math.min(Math.floor(content.length / 2), 2));
})
.replace(/(ㅇㅋ)+/g, (content: string) => {
return "오키".repeat(Math.min(Math.floor(content.length / 2), 2));
})
.replace(/(ㅊㅋ)+/g, (content: string) => {
return "추카".repeat(Math.min(Math.floor(content.length / 2), 2));
})
.replace(/ㄱ+/g, (content: string) => {
if (content.length == 2) {
return "고고";
} else if (content.length == 3) {
return "고고고";
}
return content;
})
.replace(/ㅋ{2,}/g, (content) => "크".repeat(content.length))
.replace(/ㅌ{2,}/g, "틔틔")
.replace(/ㄷ{2,}/g, "덜덜")
.replace(/ㄴ{2,}/g, "노노")
.replace(/ㅇ{2,}/g, "응응")
.replace(/ㅊ{2,}/g, "추추")
.replace(/ㅠ{2,}/g, "유유")
.replace(/ㅜ{2,}/g, "우우")
.replace(
/[ㄱ-ㅎㄲㄸㅃㅆㅉ]/g,
(char: string) => ChoseongMap[char as keyof typeof ChoseongMap],
)
// Process number, unit
.replace(
/(\+\d+[\s-]+)?([\d-]+)/g,
(_, prefix: string | undefined, phone: string) => {
const all = (prefix ?? "") + phone;
if (!phone.includes("-")) return all;
return PhoneNumberKorean.convert(all);
},
)
.replace(
/([\d,]+)([kKMmgGtTpPeEzZyY][iI]?)[bB]/g,
(_, num: string, mod: string) => {
// 10kib => 십키비바이트
num = IntegerKorean.convertFromString(num);
mod = SIPrefix[mod.toLowerCase() as keyof typeof SIPrefix];
return `${num} ${mod}바이트 `;
},
)
.replace(/([\d,]+)([m]?)[lL]\s/g, (_, num: string, mod: string) => {
// 10l => 십리터
num = IntegerKorean.convertFromString(num);
mod = LiterPrefix[mod as keyof typeof LiterPrefix];
return `${num} ${mod}리터 `;
})
.replace(/([\d,]+)([mck]?)m\s/g, (_, num: string, mod: string) => {
// 10m => 십미터
num = IntegerKorean.convertFromString(num);
mod = MeterPrefix[mod as keyof typeof MeterPrefix];
return `${num} ${mod}미터 `;
})
.replace(
/([\d.]+)\s*([개살시평명])/g,
(_, num: string, postfix: string) => {
// 10명 => 열명
if (num.includes(".")) {
return num + postfix;
}
const intNum = parseInt(num);
if (CallingNumberKorean.canConvert(intNum)) {
return CallingNumberKorean.convert(intNum) + postfix;
} else {
return IntegerKorean.convertFromString(num) + postfix;
}
},
)
.replace(/[\d,]+/g, (num: string) => {
// 1,000 원 => 천원
if (!num.includes(",")) return num;
return IntegerKorean.convertFromString(num);
})
.replace(
/(v?)([\d.]+)([ab]?)/g,
(_, suffix: string, num: string, postfix: string) => {
const dotCount = [...num.matchAll(/\./g)].length;
const hasNoSuffix = suffix == "";
if (hasNoSuffix && dotCount == 0) {
// 일반 숫자는 인트로 읽음
return IntegerKorean.convertFromString(num) + postfix;
} else if (hasNoSuffix && dotCount == 1) {
// 소수는 . 앞은 인트로, 뒤는 플로트로 읽음
const [intPart, floatPart] = num.split(/\./);
return (
IntegerKorean.convertFromString(intPart ?? "") +
"쩜" +
FloatKorean.convert(floatPart ?? "") +
postfix
);
})
.replace(/(ㅇㄴ)+/g, (content: string) => {
return "아니".repeat(
Math.min(Math.floor(content.length / 2), 2)
} else if ((suffix == "v" || postfix.length) && dotCount > 1) {
// 버전표기는 버전을 붙여서
return (
"버전" +
FloatKorean.convert(num) +
(VersionPostfix[postfix as keyof typeof VersionPostfix] ?? "")
);
})
.replace(/(ㅇㅎ)+/g, (content: string) => {
return "아하".repeat(
Math.min(Math.floor(content.length / 2), 2)
);
})
.replace(/(ㅇㅋ)+/g, (content: string) => {
return "오키".repeat(
Math.min(Math.floor(content.length / 2), 2)
);
})
.replace(/(ㅊㅋ)+/g, (content: string) => {
return "추카".repeat(
Math.min(Math.floor(content.length / 2), 2)
);
})
.replace(/ㄱ+/g, (content: string) => {
if (content.length == 2) {
return "고고";
} else if (content.length == 3) {
return "고고고";
}
return content;
})
.replace(/ㅋ{2,}/g, (content) => "크".repeat(content.length))
.replace(/ㅌ{2,}/g, "틔틔")
.replace(/ㄷ{2,}/g, "덜덜")
.replace(/ㄴ{2,}/g, "노노")
.replace(/ㅇ{2,}/g, "응응")
.replace(/ㅊ{2,}/g, "추추")
.replace(/ㅠ{2,}/g, "유유")
.replace(/ㅜ{2,}/g, "우우")
.replace(/[ㄱ-ㅎㄲㄸㅃㅆㅉ]/g, (char: string) => ChoseongMap[char as keyof typeof ChoseongMap])
} else {
// 모든 경우에 속하지 않으면 영일이삼사 형태로 읽음
// (예: 111.111.111.111 ip address)
return FloatKorean.convert(num) + postfix;
}
},
)
// Process number, unit
.replace(/(\+\d+[\s\-]+)?([\d\-]+)/g, (_, prefix: string | undefined, phone: string) => {
const all = (prefix ?? "") + phone;
if (!phone.includes("-")) return all;
return PhoneNumberKorean.convert(all);
})
.replace(/([\d,]+)([kKMmgGtTpPeEzZyY][iI]?)[bB]/g, (_, num: string, mod: string) => {
// 10kib => 십키비바이트
num = IntegerKorean.convertFromString(num);
mod = SIPrefix[mod.toLowerCase() as keyof typeof SIPrefix];
return `${num} ${mod}바이트 `;
})
.replace(/([\d,]+)([m]?)[lL]\s/g, (_, num: string, mod: string) => {
// 10l => 십리터
num = IntegerKorean.convertFromString(num);
mod = LiterPrefix[mod as keyof typeof LiterPrefix];
return `${num} ${mod}리터 `;
})
.replace(/([\d,]+)([mck]?)m\s/g, (_, num: string, mod: string) => {
// 10m => 십미터
num = IntegerKorean.convertFromString(num);
mod = MeterPrefix[mod as keyof typeof MeterPrefix];
return `${num} ${mod}미터 `;
})
.replace(/([\d\.]+)\s*([개살시평명])/g, (_, num: string, postfix: string)=>{
// 10명 => 열명
if (num.includes(".")) {
return num + postfix;
}
const intNum = parseInt(num)
if (CallingNumberKorean.canConvert(intNum)) {
return CallingNumberKorean.convert(intNum) + postfix;
} else {
return IntegerKorean.convertFromString(num) + postfix;
}
})
.replace(/[\d,]+/g, (num: string) => {
// 1,000 원 => 천원
if (!num.includes(",")) return num;
return IntegerKorean.convertFromString(num);
})
.replace(/(v?)([\d\.]+)([ab]?)/g, (_, suffix: string, num: string, postfix: string) => {
const dotCount = [...num.matchAll(/\./g)].length;
const hasNoSuffix = suffix == "";
// Process symbol
.replace(
/[%^&*#@.\-+_=/\\♡$|:;><]/g,
(t) => SymbolMap[t as keyof typeof SymbolMap],
)
.replace(/([?!]+)/g, (_, content: string): string => content[0] ?? "")
.replace(/[ \t\f\r]+/g, " ")
if (hasNoSuffix && dotCount == 0) {
// 일반 숫자는 인트로 읽음
return IntegerKorean.convertFromString(num) + postfix;
} else if (hasNoSuffix && dotCount == 1) {
// 소수는 . 앞은 인트로, 뒤는 플로트로 읽음
const [intPart, floatPart] = num.split(/\./);
return (
IntegerKorean.convertFromString(intPart)
+ "쩜"
+ FloatKorean.convert(floatPart)
+ postfix
)
} else if ((suffix == "v" || postfix.length) && dotCount > 1) {
// 버전표기는 버전을 붙여서
return (
"버전"
+ FloatKorean.convert(num)
+ (VersionPostfix[
postfix as keyof typeof VersionPostfix
] ?? "")
);
} else {
// 모든 경우에 속하지 않으면 영일이삼사 형태로 읽음
// (예: 111.111.111.111 ip address)
return FloatKorean.convert(num) + postfix;
}
})
// Process symbol
.replace(/[\%\^\&\*\#\@\.\-\+\_\=\/\\♡\$\|\:\;\>\<]/g, (t) => (
SymbolMap[t as keyof typeof SymbolMap]
))
.replace(/([\?\!]+)/g, (_, content: string) => content[0])
.replace(/[ \t\f\r]+/g, " ")
// Process emoji
.replace(UnicodeEmojisRegex, (content: string) => (UnicodeEmojis[content as keyof typeof UnicodeEmojis] ?? content))
.replace(/\p{Emoji}/u, " ")
.trim()
// Process emoji
.replace(
UnicodeSymbolsRegex,
(content: string) =>
UnicodeSymbols[content as keyof typeof UnicodeSymbols] ?? content,
)
.replace(/\p{Extended_Pictographic}/gu, (content: string) => {
return (
EmojiDescriptions[content as keyof typeof EmojiDescriptions] ??
content
);
})
.replace(/\p{Emoji}/u, " ")
.trim()
);
}

View file

@ -4,11 +4,11 @@ import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});

View file

@ -1,14 +1,33 @@
{
"include": ["packages/**/*.ts", "packages/**/*.json"],
"compilerOptions": {
"target": "ES6",
"module": "commonjs",
"lib": [ "ES2021", "DOM", "DOM.Iterable" ],
"moduleResolution": "node",
"outDir": "dist",
"outDir": "./dist",
"rootDir": "./packages",
"resolveJsonModule": true,
"module": "esnext",
"target": "es2023",
"moduleDetection": "force",
"moduleResolution": "bundler",
"composite": true, // For incremental build
"esModuleInterop": true,
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"noUncheckedSideEffectImports": true,
"types": [],
// Other Outputs
"declaration": true, // .d.ts
"declarationMap": true, // source map
"sourceMap": true,
// Stricter Typechecking Options
"strict": true,
"exactOptionalPropertyTypes": false,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
},
"include": ["./packages/**/*"]
"buildOptions": {
"incremental": true
}
}

1
tsconfig.tsbuildinfo Normal file

File diff suppressed because one or more lines are too long