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 api;
pub mod tts; 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; 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()); std::env::var("SUPERTONIC_MODEL_DIR").unwrap_or_else(|_| "./assets".to_string());
let voice_style_path = std::env::var("SUPERTONIC_VOICE_STYLE") let voice_style_path = std::env::var("SUPERTONIC_VOICE_STYLE")
.unwrap_or_else(|_| format!("F1={model_dir}/voice_styles/F1.json")); .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") let total_step: usize = std::env::var("SUPERTONIC_TOTAL_STEP")
.ok() .ok()
.and_then(|v| v.parse().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", "main": "dist",
"type": "module",
"scripts": { "scripts": {
"prisma:push": "prisma db push", "prisma:push": "prisma db push",
"format:prisma": "prisma format", "prisma:format": "prisma format",
"format": "npm run format:prisma", "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:prisma": "prisma generate",
"build:tsc": "tsc", "build:tsc": "./node_modules/typescript/bin/tsc",
"build": "npm run build:prisma && npm run build:tsc", "build": "npm run build:prisma && npm run build:tsc",
"start": "prisma migrate deploy && node .", "start": "prisma migrate deploy && node --import=extensionless/register .",
"start:no-db": "npm run build:tsc && node .", "dev": "npm run build && node --import=extensionless/register ."
"dev": "npm run build && npm run start"
}, },
"dependencies": { "dependencies": {
"@discordjs/opus": "^0.10.0", "@discordjs/opus": "^0.10.0",
@ -19,18 +23,23 @@
"@snazzah/davey": "^0.1.9", "@snazzah/davey": "^0.1.9",
"discord.js": "^14.25.1", "discord.js": "^14.25.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"extensionless": "^2.0.6",
"fastify": "^5.7.1", "fastify": "^5.7.1",
"libsodium-wrappers": "^0.8.0", "libsodium-wrappers": "^0.8.0",
"opusscript": "^0.0.8", "opusscript": "^0.0.8",
"pg": "^8.17.1", "pg": "^8.17.1",
"play-dl": "^1.9.7", "play-dl": "^1.9.7",
"prism-media": "^1.3.5", "prism-media": "^1.3.5",
"prisma": "^7.2.0", "prisma": "^7.2.0"
"typescript": "^5.9.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1",
"@types/ffprobe-static": "^2.0.3", "@types/ffprobe-static": "^2.0.3",
"@types/node": "25.0.9", "@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

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

View file

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

View file

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

View file

@ -1,4 +1,8 @@
import { AttachmentBuilder, ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder } from "discord.js"; import {
AttachmentBuilder,
ChatInputCommandInteraction,
SlashCommandBuilder,
} from "discord.js";
import { defineCommand } from "../command"; import { defineCommand } from "../command";
import { OutputHandler } from "../../utils/outputHandler"; import { OutputHandler } from "../../utils/outputHandler";
@ -10,13 +14,13 @@ export default defineCommand(
if (interaction.guild == null) if (interaction.guild == null)
return interaction.reply("올바르지 않은 서버에요"); return interaction.reply("올바르지 않은 서버에요");
const buffer = Buffer.from(await OutputHandler.getErrorLog(), 'utf-8'); const buffer = Buffer.from(await OutputHandler.getErrorLog(), "utf-8");
const attachment = new AttachmentBuilder(buffer, { name: 'result.txt' }); const attachment = new AttachmentBuilder(buffer, { name: "result.txt" });
await interaction.reply({ await interaction.reply({
content: "제 상태에요", content: "제 상태에요",
files: [attachment] files: [attachment],
}); });
}, },
true true,
) );

View file

@ -1,4 +1,11 @@
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 { defineCommand } from "../command";
import { joinVoiceChannel } from "@discordjs/voice"; import { joinVoiceChannel } from "@discordjs/voice";
@ -6,20 +13,19 @@ export default defineCommand(
new SlashCommandBuilder() new SlashCommandBuilder()
.setName("등장") .setName("등장")
.setDescription("예주가 등장해요") .setDescription("예주가 등장해요")
.addChannelOption(option => .addChannelOption((option) =>
option option
.setName("channel") .setName("channel")
.setDescription("예주가 등장할 채널이에요") .setDescription("예주가 등장할 채널이에요")
.addChannelTypes(ChannelType.GuildVoice) .addChannelTypes(ChannelType.GuildVoice),
), ),
async (interaction: ChatInputCommandInteraction): Promise<any> => { async (interaction: ChatInputCommandInteraction): Promise<any> => {
await interaction.deferReply({ await interaction.deferReply({
flags: [MessageFlags.Ephemeral] flags: [MessageFlags.Ephemeral],
}); });
const member = interaction.member as GuildMember | null; const member = interaction.member as GuildMember | null;
if (member == null) if (member == null) return;
return;
if (interaction.guild == null) if (interaction.guild == null)
return interaction.reply({ return interaction.reply({
@ -27,7 +33,9 @@ export default defineCommand(
ephemeral: true, 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) if (channel == null)
return interaction.reply({ return interaction.reply({
@ -44,5 +52,5 @@ export default defineCommand(
}); });
await interaction.editReply("등장했어요!"); await interaction.editReply("등장했어요!");
} },
) );

View file

@ -1,14 +1,16 @@
import { ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder } from "discord.js"; import {
import { defineCommand, DiscordCommand } from "../command"; ChatInputCommandInteraction,
MessageFlags,
SlashCommandBuilder,
} from "discord.js";
import { defineCommand } from "../command";
import { getVoiceConnection } from "@discordjs/voice"; import { getVoiceConnection } from "@discordjs/voice";
export default defineCommand( export default defineCommand(
new SlashCommandBuilder() new SlashCommandBuilder().setName("퇴장").setDescription("예주가 퇴장해요"),
.setName("퇴장")
.setDescription("예주가 퇴장해요"),
async (interaction: ChatInputCommandInteraction): Promise<any> => { async (interaction: ChatInputCommandInteraction): Promise<any> => {
await interaction.deferReply({ await interaction.deferReply({
flags: [MessageFlags.Ephemeral] flags: [MessageFlags.Ephemeral],
}); });
if (interaction.guild == null) if (interaction.guild == null)
@ -21,5 +23,5 @@ export default defineCommand(
connection.disconnect(); connection.disconnect();
await interaction.editReply("퇴장했어요!"); await interaction.editReply("퇴장했어요!");
} },
) );

View file

@ -1,4 +1,8 @@
import { ChatInputCommandInteraction, MessageFlags, SlashCommandSubcommandBuilder } from "discord.js"; import {
ChatInputCommandInteraction,
MessageFlags,
SlashCommandSubcommandBuilder,
} from "discord.js";
import { defineCommand } from "../command"; import { defineCommand } from "../command";
import { playVoice } from "../tts"; import { playVoice } from "../tts";
import { getUserProfile } from "../db"; import { getUserProfile } from "../db";
@ -7,15 +11,15 @@ export default defineCommand(
new SlashCommandSubcommandBuilder() new SlashCommandSubcommandBuilder()
.setName("말") .setName("말")
.setDescription("구구가가") .setDescription("구구가가")
.addStringOption(option => .addStringOption((option) =>
option option
.setName("content") .setName("content")
.setDescription("예주가 말해준데요") .setDescription("예주가 말해준데요")
.setRequired(true) .setRequired(true),
), ),
async (interaction: ChatInputCommandInteraction): Promise<any> => { async (interaction: ChatInputCommandInteraction): Promise<any> => {
await interaction.deferReply({ await interaction.deferReply({
flags: [MessageFlags.Ephemeral] flags: [MessageFlags.Ephemeral],
}); });
if (interaction.guild == null) if (interaction.guild == null)
@ -27,12 +31,12 @@ export default defineCommand(
interaction.guild, interaction.guild,
userProfile, userProfile,
userProfile.voice, userProfile.voice,
interaction.options.getString("content") as string interaction.options.getString("content") as string,
); );
await interaction.editReply("말했어요!"); await interaction.editReply("말했어요!");
} catch { } catch {
await interaction.editReply("오늘따라 말이 꼬이네요 ㅜ.ㅜ"); await interaction.editReply("오늘따라 말이 꼬이네요 ㅜ.ㅜ");
} }
} },
) );

View file

@ -1,21 +1,27 @@
import { Channel, ChannelType, ChatInputCommandInteraction, MessageFlags, SlashCommandSubcommandBuilder } from "discord.js"; import {
import { defineCommand, DiscordCommand } from "../command"; type Channel,
ChannelType,
ChatInputCommandInteraction,
MessageFlags,
SlashCommandSubcommandBuilder,
} from "discord.js";
import { defineCommand } from "../command";
import { insertGuildReadChannel } from "../db"; import { insertGuildReadChannel } from "../db";
export default defineCommand( export default defineCommand(
new SlashCommandSubcommandBuilder() new SlashCommandSubcommandBuilder()
.setName("읽어") .setName("읽어")
.setDescription("예주가 해당 채널을 읽어줘요") .setDescription("예주가 해당 채널을 읽어줘요")
.addChannelOption(option => .addChannelOption((option) =>
option option
.setName("channel") .setName("channel")
.addChannelTypes(ChannelType.GuildText, ChannelType.GuildVoice) .addChannelTypes(ChannelType.GuildText, ChannelType.GuildVoice)
.setDescription("예주가 읽을 채널이에요") .setDescription("예주가 읽을 채널이에요")
.setRequired(true) .setRequired(true),
), ),
async (interaction: ChatInputCommandInteraction): Promise<any> => { async (interaction: ChatInputCommandInteraction): Promise<any> => {
await interaction.deferReply({ await interaction.deferReply({
flags: [MessageFlags.Ephemeral] flags: [MessageFlags.Ephemeral],
}); });
const channel = interaction.options.getChannel("channel") as Channel; const channel = interaction.options.getChannel("channel") as Channel;
@ -32,5 +38,5 @@ export default defineCommand(
await interaction.editReply("예주가 이제 이 채널을 읽어요!"); await interaction.editReply("예주가 이제 이 채널을 읽어요!");
}, },
true 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 { defineCommand } from "../command";
import { getUserProfile, setUserNya } from "../db"; import { getUserProfile, setUserNya } from "../db";
export default defineCommand( export default defineCommand(
new SlashCommandBuilder() new SlashCommandBuilder().setName("냥").setDescription("???"),
.setName("냥")
.setDescription("???"),
async (interaction: ChatInputCommandInteraction): Promise<any> => { async (interaction: ChatInputCommandInteraction): Promise<any> => {
await interaction.deferReply({ await interaction.deferReply({
flags: [MessageFlags.Ephemeral] flags: [MessageFlags.Ephemeral],
}); });
const profile = await getUserProfile(interaction.user.id); const profile = await getUserProfile(interaction.user.id);
await setUserNya(interaction.user.id, !profile.nya); await setUserNya(interaction.user.id, !profile.nya);
await interaction.editReply(profile.nya ? "냐앙..." : "냐앙!!"); await interaction.editReply(profile.nya ? "냐앙..." : "냐앙!!");
} },
) );

View file

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

View file

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

View file

@ -1,4 +1,8 @@
import { ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder } from "discord.js"; import {
ChatInputCommandInteraction,
MessageFlags,
SlashCommandBuilder,
} from "discord.js";
import { defineCommand } from "../command"; import { defineCommand } from "../command";
import { skipCurrentVoice } from "../tts"; import { skipCurrentVoice } from "../tts";
@ -8,7 +12,7 @@ export default defineCommand(
.setDescription("실행중인 보이스를 건너뜁니다"), .setDescription("실행중인 보이스를 건너뜁니다"),
async (interaction: ChatInputCommandInteraction): Promise<any> => { async (interaction: ChatInputCommandInteraction): Promise<any> => {
await interaction.deferReply({ await interaction.deferReply({
flags: [MessageFlags.Ephemeral] flags: [MessageFlags.Ephemeral],
}); });
if (!interaction.guild) { if (!interaction.guild) {
@ -21,5 +25,5 @@ export default defineCommand(
} else { } else {
await interaction.editReply("실행중인 보이스가 없어요"); await interaction.editReply("실행중인 보이스가 없어요");
} }
} },
) );

View file

@ -1,21 +1,27 @@
import { Channel, ChannelType, ChatInputCommandInteraction, MessageFlags, SlashCommandSubcommandBuilder } from "discord.js"; import {
import { defineCommand, DiscordCommand } from "../command"; type Channel,
import { insertGuildReadChannel, removeGuildReadChannel } from "../db"; ChannelType,
ChatInputCommandInteraction,
MessageFlags,
SlashCommandSubcommandBuilder,
} from "discord.js";
import { defineCommand } from "../command";
import { removeGuildReadChannel } from "../db";
export default defineCommand( export default defineCommand(
new SlashCommandSubcommandBuilder() new SlashCommandSubcommandBuilder()
.setName("읽지마") .setName("읽지마")
.setDescription("예주가 해당 채널을 더이상 읽지 않아요") .setDescription("예주가 해당 채널을 더이상 읽지 않아요")
.addChannelOption(option => .addChannelOption((option) =>
option option
.setName("channel") .setName("channel")
.addChannelTypes(ChannelType.GuildText, ChannelType.GuildVoice) .addChannelTypes(ChannelType.GuildText, ChannelType.GuildVoice)
.setDescription("예주가 더이상 읽지 않을 채널이에요") .setDescription("예주가 더이상 읽지 않을 채널이에요")
.setRequired(true) .setRequired(true),
), ),
async (interaction: ChatInputCommandInteraction): Promise<any> => { async (interaction: ChatInputCommandInteraction): Promise<any> => {
await interaction.deferReply({ await interaction.deferReply({
flags: [MessageFlags.Ephemeral] flags: [MessageFlags.Ephemeral],
}); });
const channel = interaction.options.getChannel("channel") as Channel; const channel = interaction.options.getChannel("channel") as Channel;
@ -32,5 +38,5 @@ export default defineCommand(
await interaction.editReply(`예주가 <#${channel.id}> 채널을 읽지않아요!`); await interaction.editReply(`예주가 <#${channel.id}> 채널을 읽지않아요!`);
}, },
true true,
) );

View file

@ -1,10 +1,14 @@
import { prisma } from "../db/prisma"; 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> { export function getUserProfile(userId: string): Promise<DiscordUserProfile> {
return prisma.discordUserProfile.upsert({ return prisma.discordUserProfile.upsert({
where: { where: {
userId: userId userId: userId,
}, },
update: {}, update: {},
create: { create: {
@ -13,76 +17,88 @@ export function getUserProfile(userId: string): Promise<DiscordUserProfile> {
}); });
} }
export async function setUserProfile(userId: string, profile: DiscordUserProfile): Promise<void> { export async function setUserProfile(
userId: string,
profile: DiscordUserProfile,
): Promise<void> {
await prisma.discordUserProfile.upsert({ await prisma.discordUserProfile.upsert({
where: { where: {
userId: userId userId: userId,
}, },
update: { update: {
voice: profile.voice, voice: profile.voice,
nya: profile.nya nya: profile.nya,
}, },
create: { create: {
userId: userId, userId: userId,
} },
}); });
} }
export async function setUserNya(userId: string, nya: boolean): Promise<void> { export async function setUserNya(userId: string, nya: boolean): Promise<void> {
await prisma.discordUserProfile.upsert({ await prisma.discordUserProfile.upsert({
where: { where: {
userId: userId userId: userId,
}, },
update: { update: {
nya: nya nya: nya,
}, },
create: { create: {
userId: userId, userId: userId,
} },
}); });
} }
export async function setUserSupertonicStyle(userId: string, style: string): Promise<void> { export async function setUserSupertonicStyle(
userId: string,
style: string,
): Promise<void> {
await prisma.discordUserProfile.upsert({ await prisma.discordUserProfile.upsert({
where: { where: {
userId: userId userId: userId,
}, },
update: { update: {
userSupertonicStyle: style userSupertonicStyle: style,
}, },
create: { create: {
userId: userId, userId: userId,
userSupertonicStyle: style userSupertonicStyle: style,
} },
}); });
} }
export async function setUserVoice(userId: string, voice: Voice): Promise<void> { export async function setUserVoice(
userId: string,
voice: Voice,
): Promise<void> {
await prisma.discordUserProfile.upsert({ await prisma.discordUserProfile.upsert({
where: { where: {
userId: userId userId: userId,
}, },
update: { update: {
voice: voice voice: voice,
}, },
create: { create: {
userId: userId, userId: userId,
voice: voice voice: voice,
} },
}); });
} }
export async function setUserCanTypecast(userId: string, canTypecast: boolean): Promise<void> { export async function setUserCanTypecast(
userId: string,
canTypecast: boolean,
): Promise<void> {
await prisma.discordUserProfile.upsert({ await prisma.discordUserProfile.upsert({
where: { where: {
userId: userId userId: userId,
}, },
update: { update: {
canTypecast: canTypecast canTypecast: canTypecast,
}, },
create: { create: {
userId: userId, userId: userId,
} },
}); });
} }
@ -96,37 +112,48 @@ export function getGuildProfile(guildId: string): Promise<DiscordGuildProfile> {
}); });
} }
export async function hasGuildReadChannel(guildId: string, channelId: string): Promise<boolean> { export async function hasGuildReadChannel(
guildId: string,
channelId: string,
): Promise<boolean> {
return ( return (
await prisma.discordGuildProfile.findFirst({ (await prisma.discordGuildProfile.findFirst({
where: { where: {
guildId: guildId, guildId: guildId,
readChannel: { has: channelId } readChannel: { has: channelId },
} },
}) })) != null
) != null; );
} }
export async function removeGuildReadChannel(guildId: string, channelId: string): Promise<void> { export async function removeGuildReadChannel(
guildId: string,
channelId: string,
): Promise<void> {
const guildProfile = await prisma.discordGuildProfile.findUnique({ const guildProfile = await prisma.discordGuildProfile.findUnique({
where: { where: {
guildId: guildId guildId: guildId,
}, },
}); });
if (guildProfile) { if (guildProfile) {
await prisma.discordGuildProfile.update({ await prisma.discordGuildProfile.update({
where: { where: {
guildId: guildId guildId: guildId,
}, },
data: { data: {
readChannel: guildProfile.readChannel.filter(channel => channel != channelId), readChannel: guildProfile.readChannel.filter(
(channel) => channel != channelId,
),
}, },
}); });
} }
} }
export async function insertGuildReadChannel(guildId: string, channelId: string): Promise<void> { export async function insertGuildReadChannel(
guildId: string,
channelId: string,
): Promise<void> {
await prisma.discordGuildProfile.upsert({ await prisma.discordGuildProfile.upsert({
where: { where: {
guildId: guildId, guildId: guildId,
@ -134,7 +161,7 @@ export async function insertGuildReadChannel(guildId: string, channelId: string)
readChannel: { readChannel: {
has: channelId, has: channelId,
}, },
} },
}, },
update: { update: {
readChannel: { readChannel: {

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import { Client, Events, GatewayIntentBits, REST, Routes } from "discord.js"; 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 { eventMap } from "./event";
import { OutputHandler } from "../utils/outputHandler"; import { OutputHandler } from "../utils/outputHandler";
import { APPLICATION_ID } from "../env"; import { APPLICATION_ID } from "../env";
@ -31,12 +31,12 @@ export class DiscordBot {
const commandsCachePath = join(cwd(), "cache", "commands"); const commandsCachePath = join(cwd(), "cache", "commands");
await mkdir(commandsCachePath, { await mkdir(commandsCachePath, {
recursive: true recursive: true,
}); });
let commandsCache: { [key: string]: string } let commandsCache: { [key: string]: string };
try { try {
commandsCache = JSON.parse( commandsCache = JSON.parse(
await readFile(join(commandsCachePath, "list.json"), "utf-8") await readFile(join(commandsCachePath, "list.json"), "utf-8"),
); );
} catch { } catch {
commandsCache = {}; commandsCache = {};
@ -50,28 +50,27 @@ export class DiscordBot {
await this.deleteCommand(id); await this.deleteCommand(id);
} }
await this.rest.put( await this.rest.put(Routes.applicationCommands(APPLICATION_ID), {
Routes.applicationCommands(APPLICATION_ID),
{
body: defineCommands.map((command) => command.data.toJSON()), body: defineCommands.map((command) => command.data.toJSON()),
} });
);
await writeFile( await writeFile(
join(commandsCachePath, "list.json"), join(commandsCachePath, "list.json"),
JSON.stringify( JSON.stringify(
( (
(await this.rest.get(Routes.applicationCommands(APPLICATION_ID))) as { (await this.rest.get(
name: string, Routes.applicationCommands(APPLICATION_ID),
id: string )) as {
name: string;
id: string;
}[] }[]
).reduce<Record<string, string>>((acc, cur) => { ).reduce<Record<string, string>>((acc, cur) => {
acc[cur.name] = cur.id; acc[cur.name] = cur.id;
return acc; return acc;
}, {}), }, {}),
null, null,
2 2,
) ),
); );
} catch (err) { } catch (err) {
OutputHandler.errorLog("[Command Register Error]", err); OutputHandler.errorLog("[Command Register Error]", err);
@ -79,11 +78,10 @@ export class DiscordBot {
} }
public async deleteCommand(commandId: string): Promise<void> { public async deleteCommand(commandId: string): Promise<void> {
if (!this.client.isReady()) if (!this.client.isReady()) throw new Error("Client is not ready");
throw new Error("Client is not ready");
await this.rest.delete( await this.rest.delete(
Routes.applicationCommand(this.client.application.id, commandId) Routes.applicationCommand(this.client.application.id, commandId),
); );
} }
@ -91,6 +89,7 @@ export class DiscordBot {
try { try {
for (let index = 0; index < eventMap.length; index++) { for (let index = 0; index < eventMap.length; index++) {
const event = eventMap[index]; const event = eventMap[index];
if (!event) continue;
this.client.on(event.event, event.callback); this.client.on(event.event, event.callback);
} }
} catch (err) { } catch (err) {

View file

@ -1,5 +1,10 @@
import { AudioPlayerStatus, AudioResource, createAudioPlayer, createAudioResource, VoiceConnection } from "@discordjs/voice"; import {
import { stream as createStream } from "play-dl"; AudioPlayerStatus,
AudioResource,
createAudioPlayer,
createAudioResource,
VoiceConnection,
} from "@discordjs/voice";
import { Guild } from "discord.js"; import { Guild } from "discord.js";
import { getOrCreateVoiceConnection } from "./util"; import { getOrCreateVoiceConnection } from "./util";
import { OutputHandler } from "../utils/outputHandler"; import { OutputHandler } from "../utils/outputHandler";
@ -8,8 +13,7 @@ import play from "play-dl";
namespace InitPlayDl { namespace InitPlayDl {
let initialized = false; let initialized = false;
export async function init() { export async function init() {
if (initialized) if (initialized) return;
return;
await play.getFreeClientID(); await play.getFreeClientID();
initialized = true; initialized = true;
@ -24,7 +28,7 @@ class MusicQueue {
this.list = []; this.list = [];
} }
public static fromConnection(connection: VoiceConnection): MusicQueue { public static fromConnection(connection: VoiceConnection): MusicQueue {
return (connection as any).queue ??= new MusicQueue(connection); return ((connection as any).queue ??= new MusicQueue(connection));
} }
private play() { private play() {
if (!this.list[0]) return; if (!this.list[0]) return;
@ -50,8 +54,7 @@ class MusicQueue {
export async function playMusic(guild: Guild, url: string) { export async function playMusic(guild: Guild, url: string) {
try { try {
const connection = await getOrCreateVoiceConnection(guild); const connection = await getOrCreateVoiceConnection(guild);
if (!connection) if (!connection) throw new Error("Yaeju is not joined VoiceChat");
throw new Error("Yaeju is not joined VoiceChat");
await InitPlayDl.init(); await InitPlayDl.init();
@ -63,8 +66,8 @@ export async function playMusic(guild: Guild, url: string) {
const stream = await play.stream(url); const stream = await play.stream(url);
MusicQueue.fromConnection(connection).enqueue( MusicQueue.fromConnection(connection).enqueue(
createAudioResource(stream.stream, { createAudioResource(stream.stream, {
inputType: stream.type inputType: stream.type,
}) }),
); );
} catch (err) { } catch (err) {
OutputHandler.errorLog("[PlayMusic Error]", err); OutputHandler.errorLog("[PlayMusic Error]", err);
@ -74,8 +77,7 @@ export async function playMusic(guild: Guild, url: string) {
export async function skipMusic(guild: Guild) { export async function skipMusic(guild: Guild) {
const connection = await getOrCreateVoiceConnection(guild); const connection = await getOrCreateVoiceConnection(guild);
if (!connection) if (!connection) throw new Error("Yaeju is not joined VoiceChat");
throw new Error("Yaeju is not joined VoiceChat");
MusicQueue.fromConnection(connection).next(); MusicQueue.fromConnection(connection).next();
} }

View file

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

View file

@ -1,12 +1,17 @@
import { getVoiceConnection as defaultGetVoiceConnection, EndBehaviorType, joinVoiceChannel, VoiceConnection } from "@discordjs/voice"; import {
getVoiceConnection as defaultGetVoiceConnection,
joinVoiceChannel,
VoiceConnection,
} from "@discordjs/voice";
import { Guild } from "discord.js"; import { Guild } from "discord.js";
export async function getOrCreateVoiceConnection(guild: Guild): Promise<VoiceConnection | undefined> { export async function getOrCreateVoiceConnection(
guild: Guild,
): Promise<VoiceConnection | undefined> {
let connection = defaultGetVoiceConnection(guild.id); let connection = defaultGetVoiceConnection(guild.id);
if (!connection) { if (!connection) {
if (!guild.members.me?.voice.channel) if (!guild.members.me?.voice.channel) return;
return;
const channel = guild.members.me.voice.channel; const channel = guild.members.me.voice.channel;

View file

@ -12,6 +12,8 @@
import * as process from 'node:process' import * as process from 'node:process'
import * as path from 'node:path' 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 runtime from "@prisma/client/runtime/client"
import * as $Enums from "./enums" import * as $Enums from "./enums"

View file

@ -37,10 +37,10 @@ async function decodeBase64AsWasm(wasmBase64: string): Promise<WebAssembly.Modul
} }
config.compilerWasm = { 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 () => { 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) return await decodeBase64AsWasm(wasm)
}, },

View file

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

View file

@ -11,8 +11,12 @@ bot.client.once("clientReady", async (client) => {
console.log( console.log(
"registerCommands: \n| " + "registerCommands: \n| " +
( (
(await client.rest.get(Routes.applicationCommands(APPLICATION_ID))) as any[] (await client.rest.get(
).map(info => `name: ${info.name} id: ${info.id}`).join("\n| ") Routes.applicationCommands(APPLICATION_ID),
)) as any[]
)
.map((info) => `name: ${info.name} id: ${info.id}`)
.join("\n| "),
); );
}); });

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 { dirname } from "path";
import { AudioResource, createAudioResource, StreamType } from "@discordjs/voice"; import {
AudioResource,
createAudioResource,
StreamType,
} from "@discordjs/voice";
import { Readable } from "stream"; import { Readable } from "stream";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { join } from "node:path"; import { join } from "node:path";
@ -10,12 +14,15 @@ import { saferKorean } from "../utils/saferKorean";
export abstract class TTSModelBase<RequestId> { export abstract class TTSModelBase<RequestId> {
public ttsify(input: string): string { public ttsify(input: string): string {
return saferKorean( return saferKorean(
input.replace(/:[^:]+:/g, (text: string): string => (TTSModelBase.EMOJI_MAP[text] ?? "이모지")) input.replace(
/:[^:]+:/g,
(text: string): string => TTSModelBase.EMOJI_MAP[text] ?? "이모지",
),
); );
} }
public abstract createRequestId(text: string): RequestId public abstract createRequestId(text: string): RequestId;
public abstract getVoiceBuffer(id: RequestId): Promise<ArrayBuffer> public abstract getVoiceBuffer(id: RequestId): Promise<ArrayBuffer>;
public abstract getVoicePath(id: RequestId): string public abstract getVoicePath(id: RequestId): string;
/** /**
* id로 * id로
@ -48,7 +55,7 @@ export abstract class TTSModelBase<RequestId> {
* id로 , * id로 ,
* , * ,
*/ */
protected abstract cachedVoice: Map<String, Promise<Buffer>> protected abstract cachedVoice: Map<string, Promise<Buffer>>;
public async getMemcachedVoice(id: RequestId): Promise<Buffer> { public async getMemcachedVoice(id: RequestId): Promise<Buffer> {
const path = this.getVoicePath(id); const path = this.getVoicePath(id);
@ -59,10 +66,7 @@ export abstract class TTSModelBase<RequestId> {
const waitter = this.getVoice(id); const waitter = this.getVoice(id);
this.cachedVoice.set(path, waitter); this.cachedVoice.set(path, waitter);
setTimeout( setTimeout(() => this.cachedVoice.delete(path), TTSModelBase.MemCacheTTL);
() => this.cachedVoice.delete(path),
TTSModelBase.MemCacheTTL
);
return await waitter; return await waitter;
} }
} }
@ -70,13 +74,9 @@ export namespace TTSModelBase {
export const EMOJI_MAP: { [key: string]: string } = { export const EMOJI_MAP: { [key: string]: string } = {
":heart:": "하트", ":heart:": "하트",
":huck:": "헉헉!", ":huck:": "헉헉!",
":star:": "초롱초롱!" ":star:": "초롱초롱!",
} };
export const AudioCachePath = join( export const AudioCachePath = join(process.cwd(), "cache", "audio");
process.cwd(),
"cache",
"audio",
);
export function bufferToAudioResource(buf: Buffer): AudioResource { export function bufferToAudioResource(buf: Buffer): AudioResource {
const stream = Readable.from(buf); const stream = Readable.from(buf);
const resource = createAudioResource(stream, { const resource = createAudioResource(stream, {
@ -90,6 +90,6 @@ export namespace TTSModelBase {
export function hashAudioFile(audio: string, suffix: string = ""): string { export function hashAudioFile(audio: string, suffix: string = ""): string {
return createHash("md5").update(audio).digest("hex") + suffix + ".mp3"; return createHash("md5").update(audio).digest("hex") + suffix + ".mp3";
} }
export const MemCacheTTL = 60 * 60 * 1000 export const MemCacheTTL = 60 * 60 * 1000;
} }
export default TTSModelBase; export default TTSModelBase;

View file

@ -4,28 +4,37 @@ import fetch from "../utils/fetch";
import TTSModelBase from "."; import TTSModelBase from ".";
export class TTSPapagoModel extends TTSModelBase<TTSPapagoModel.RequestId> { export class TTSPapagoModel extends TTSModelBase<TTSPapagoModel.RequestId> {
protected cachedVoice: Map<String, Promise<Buffer>> protected cachedVoice: Map<string, Promise<Buffer>>;
constructor() { constructor() {
super() super();
this.cachedVoice = new Map(); this.cachedVoice = new Map();
} }
ttsify(input: string): string { ttsify(input: string): string {
return super.ttsify(input) return super.ttsify(input);
} }
public getVoicePath(id: TTSPapagoModel.RequestId): string { public getVoicePath(id: TTSPapagoModel.RequestId): string {
const audioFileName = TTSModelBase.hashAudioFile(id.text, `.${id.speaker}.${id.speed.replace(/\-/g, "_")}`); const audioFileName = TTSModelBase.hashAudioFile(
const audioPath = join( id.text,
TTSPapagoModel.PapagoAudioCachePath, `.${id.speaker}.${id.speed.replace(/-/g, "_")}`,
audioFileName
); );
const audioPath = join(TTSPapagoModel.PapagoAudioCachePath, audioFileName);
return audioPath; return audioPath;
} }
async getVoiceBuffer(id: TTSPapagoModel.RequestId, voiceId?: string): Promise<ArrayBuffer> { async getVoiceBuffer(
voiceId ??= await TTSPapagoModel.getVoiceId(id) id: TTSPapagoModel.RequestId,
const response = await fetch(`https://papago.naver.com/apis/tts/${voiceId}`); voiceId?: string,
): Promise<ArrayBuffer> {
voiceId ??= await TTSPapagoModel.getVoiceId(id);
const response = await fetch(
`https://papago.naver.com/apis/tts/${voiceId}`,
);
return await response.arrayBuffer(); return await response.arrayBuffer();
} }
createRequestId(text: string, speaker?: string, speed?: string): TTSPapagoModel.RequestId { createRequestId(
text: string,
speaker?: string,
speed?: string,
): TTSPapagoModel.RequestId {
return { return {
text, text,
speed: speed ?? "-1", speed: speed ?? "-1",
@ -49,8 +58,11 @@ export namespace TTSPapagoModel {
} }
export function generateToken(time: number) { export function generateToken(time: number) {
const e = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (e) => { const e = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (e) => {
var t = (time + 16 * Math.random()) % 16 | 0; const t = ((time + 16 * Math.random()) % 16) | 0;
return (time = Math.floor(time / 16)), ("x" === e ? t : (3 & t) | 8).toString(16); 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}`; const plain = `${e}\n${"https://papago.naver.com/apis/tts/makeID"}\n${time}`;
@ -72,7 +84,8 @@ export namespace TTSPapagoModel {
const reqbody = new URLSearchParams(Object.entries(input)).toString(); const reqbody = new URLSearchParams(Object.entries(input)).toString();
const response = await fetch("https://papago.naver.com/apis/tts/makeID", { const response = await fetch("https://papago.naver.com/apis/tts/makeID", {
headers: { headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0", "User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
Accept: "application/json", Accept: "application/json",
"Accept-Language": "en", "Accept-Language": "en",
"Sec-GPC": "1", "Sec-GPC": "1",
@ -91,14 +104,16 @@ export namespace TTSPapagoModel {
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`TTS makeID request failed: ${response.status}: ${await response.text()}`); throw new Error(
`TTS makeID request failed: ${response.status}: ${await response.text()}`,
);
} }
return ((await response.json()) as any).id; return ((await response.json()) as any).id;
} }
export const PapagoAudioCachePath = join( export const PapagoAudioCachePath = join(
TTSModelBase.AudioCachePath, TTSModelBase.AudioCachePath,
"papago" "papago",
); );
} }
export default TTSPapagoModel; export default TTSPapagoModel;

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -1,10 +1,43 @@
export default class IntegerKorean { export default class IntegerKorean {
static DigitName = [ "영", "일", "이", "삼", "사", "오", "육", "칠", "팔", "구" ]; static DigitName = [
"영",
"일",
"이",
"삼",
"사",
"오",
"육",
"칠",
"팔",
"구",
];
static DigitModifier = ["", "십", "백", "천"]; static DigitModifier = ["", "십", "백", "천"];
static Unit = [ "", "만", "억", "조", "경", "해", "자", "양", "구", "간", "정", "재", "극", "항하사", "아승기", "나유타", "불가사의", "무량대수" ]; static Unit = [
"",
"만",
"억",
"조",
"경",
"해",
"자",
"양",
"구",
"간",
"정",
"재",
"극",
"항하사",
"아승기",
"나유타",
"불가사의",
"무량대수",
];
private static stringifyKDigits( private static stringifyKDigits(
first: number, second: number, third: number, forth: number first: number,
second: number,
third: number,
forth: number,
): string { ): string {
const buf = []; const buf = [];
@ -35,10 +68,10 @@ export default class IntegerKorean {
return this.stringifyKDigits(first, second, third, forth); return this.stringifyKDigits(first, second, third, forth);
} }
private static parseKDigitsFromString(num: string, offset: number): string { private static parseKDigitsFromString(num: string, offset: number): string {
const first = +num[offset]; const first = +num[offset]!;
const second = offset >= 1 ? +num[offset - 1] : 0; const second = offset >= 1 ? +num[offset - 1]! : 0;
const third = offset >= 2 ? +num[offset - 2] : 0; const third = offset >= 2 ? +num[offset - 2]! : 0;
const forth = offset >= 3 ? +num[offset - 3] : 0; const forth = offset >= 3 ? +num[offset - 3]! : 0;
return this.stringifyKDigits(first, second, third, forth); return this.stringifyKDigits(first, second, third, forth);
} }
@ -64,12 +97,11 @@ export default class IntegerKorean {
const buf = []; const buf = [];
if (isNegative) buf.push("마이너스"); if (isNegative) buf.push("마이너스");
for (let i = unitStack.length - 1; i >= 0; i--) { for (let i = unitStack.length - 1; i >= 0; i--) {
const currUnit = this.Unit[i]; const currUnit = this.Unit[i] ?? "";
let currKDigits = unitStack[i]; let currKDigits = unitStack[i] ?? "";
if (currKDigits == "영") continue; if (currKDigits == "영") continue;
if (i == 1 && currKDigits == "일") if (i == 1 && currKDigits == "일") currKDigits = "";
currKDigits = "";
buf.push(currKDigits + currUnit); buf.push(currKDigits + currUnit);
} }
@ -95,12 +127,11 @@ export default class IntegerKorean {
const buf = []; const buf = [];
if (isNegative) buf.push("마이너스"); if (isNegative) buf.push("마이너스");
for (let i = unitStack.length - 1; i >= 0; i--) { for (let i = unitStack.length - 1; i >= 0; i--) {
const currUnit = this.Unit[i]; const currUnit = this.Unit[i] ?? "";
let currKDigits = unitStack[i]; let currKDigits = unitStack[i] ?? "";
if (currKDigits == "영") continue; if (currKDigits == "영") continue;
if (i == 1 && currKDigits == "일") if (i == 1 && currKDigits == "일") currKDigits = "";
currKDigits = "";
buf.push(currKDigits + currUnit); buf.push(currKDigits + currUnit);
} }

View file

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

View file

@ -1,28 +1,23 @@
import { dirname, join } from "path"; import { dirname, join } from "path";
import { mkdir, open, readFile } from "fs/promises" import { mkdir, open, readFile } from "fs/promises";
export namespace OutputHandler { export namespace OutputHandler {
export const LogCachePath = join( export const LogCachePath = join(process.cwd(), "cache", "log");
process.cwd(),
"cache",
"log",
);
export const ErrorLogPath = join(LogCachePath, "error.log"); export const ErrorLogPath = join(LogCachePath, "error.log");
export function getErrorOutput(...args: any[]) { export function getErrorOutput(...args: any[]) {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
const message = args const message = args
.map(arg => { .map((arg) => {
if (arg instanceof Error) { if (arg instanceof Error) {
return `${arg.name}: ${arg.message}\n${arg.stack}`; return `${arg.name}: ${arg.message}\n${arg.stack}`;
} }
if (typeof arg === 'object') { if (typeof arg === "object") {
return JSON.stringify(arg); return JSON.stringify(arg);
} }
return String(arg); return String(arg);
}) })
.join(' '); .join(" ");
return `[${timestamp}] ${message}`; return `[${timestamp}] ${message}`;
} }

View file

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

View file

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

View file

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

View file

@ -1,14 +1,33 @@
{ {
"include": ["packages/**/*.ts", "packages/**/*.json"],
"compilerOptions": { "compilerOptions": {
"target": "ES6", "outDir": "./dist",
"module": "commonjs", "rootDir": "./packages",
"lib": [ "ES2021", "DOM", "DOM.Iterable" ],
"moduleResolution": "node", "resolveJsonModule": true,
"outDir": "dist", "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, "strict": true,
"exactOptionalPropertyTypes": false, "noUncheckedIndexedAccess": true,
"allowSyntheticDefaultImports": true, "exactOptionalPropertyTypes": true
"skipLibCheck": true
}, },
"include": ["./packages/**/*"] "buildOptions": {
"incremental": true
}
} }

1
tsconfig.tsbuildinfo Normal file

File diff suppressed because one or more lines are too long