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

@ -1,7 +1,7 @@
import { setUserCanTypecast } from "./db"; 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,41 +1,56 @@
import { ChatInputCommandInteraction, SlashCommandBuilder, SlashCommandOptionsOnlyBuilder, SlashCommandSubcommandBuilder } from "discord.js"; import {
ChatInputCommandInteraction,
SlashCommandBuilder,
type SlashCommandOptionsOnlyBuilder,
SlashCommandSubcommandBuilder,
} from "discord.js";
import { join } from "node:path"; import { 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(
data: DiscordCommandData, data: DiscordCommandData,
execute: DiscordCommandExecute, execute: DiscordCommandExecute,
isAdminCommand=false, isAdminCommand = false,
): DiscordCommand { ): DiscordCommand {
if (isAdminCommand) { if (isAdminCommand) {
return {
data: data,
execute: async (interaction: ChatInputCommandInteraction): Promise<any> => {
if (AdminUsers.includes(interaction.user.id)) {
execute(interaction);
} else {
interaction.reply("당신은 어드민이 아닙니다");
}
}
}
}
return { return {
data: data, data: data,
execute: execute, execute: async (
} interaction: ChatInputCommandInteraction,
): Promise<any> => {
if (AdminUsers.includes(interaction.user.id)) {
execute(interaction);
} else {
interaction.reply("당신은 어드민이 아닙니다");
}
},
};
}
return {
data: data,
execute: execute,
};
} }
const commandDirectory = join(__dirname, "commands"); 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,28 +1,35 @@
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()
.setName("읽는채널") .setName("읽는채널")
.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;
if (guildId == null) if (guildId == null)
return await interaction.editReply("알수없는 서버에요!"); return await interaction.editReply("알수없는 서버에요!");
try { try {
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(
} catch { readChannel.map((channel: any) => `<#${channel}>`).join("\n") ||
await interaction.editReply("서버 프로필을 가져오는데 문제가 생겼어요"); "아무 채널도 읽지 않아요!",
} );
} catch {
await interaction.editReply("서버 프로필을 가져오는데 문제가 생겼어요");
} }
) },
);

View file

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

View file

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

View file

@ -1,25 +1,27 @@
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("퇴장") async (interaction: ChatInputCommandInteraction): Promise<any> => {
.setDescription("예주가 퇴장해요"), await interaction.deferReply({
async (interaction: ChatInputCommandInteraction): Promise<any> => { flags: [MessageFlags.Ephemeral],
await interaction.deferReply({ });
flags: [MessageFlags.Ephemeral]
});
if (interaction.guild == null) if (interaction.guild == null)
return interaction.editReply("올바르지 않은 서버에요"); return interaction.editReply("올바르지 않은 서버에요");
const connection = getVoiceConnection(interaction.guild.id); const connection = getVoiceConnection(interaction.guild.id);
if (!connection) if (!connection)
return interaction.editReply("예주는 통화방에 존제하지 않아요"); return interaction.editReply("예주는 통화방에 존제하지 않아요");
connection.disconnect(); connection.disconnect();
await interaction.editReply("퇴장했어요!"); await interaction.editReply("퇴장했어요!");
} },
) );

View file

@ -1,38 +1,42 @@
import { ChatInputCommandInteraction, MessageFlags, SlashCommandSubcommandBuilder } from "discord.js"; import {
ChatInputCommandInteraction,
MessageFlags,
SlashCommandSubcommandBuilder,
} from "discord.js";
import { defineCommand } from "../command"; import { defineCommand } from "../command";
import { playVoice } from "../tts"; import { playVoice } from "../tts";
import { getUserProfile } from "../db"; import { getUserProfile } from "../db";
export default defineCommand( 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)
return await interaction.editReply("올바르지 않은 서버에요"); return await interaction.editReply("올바르지 않은 서버에요");
try { try {
const userProfile = await getUserProfile(interaction.user.id); const userProfile = await getUserProfile(interaction.user.id);
await playVoice( await playVoice(
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,36 +1,42 @@
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;
const guildId = interaction.guildId; const guildId = interaction.guildId;
if (guildId == null) if (guildId == null)
return await interaction.editReply("알수없는 서버에요!"); return await interaction.editReply("알수없는 서버에요!");
try { try {
await insertGuildReadChannel(guildId, channel.id); await insertGuildReadChannel(guildId, channel.id);
} catch { } catch {
return await interaction.editReply("읽는대 너무 어려워요.."); return await interaction.editReply("읽는대 너무 어려워요..");
} }
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("냥") async (interaction: ChatInputCommandInteraction): Promise<any> => {
.setDescription("???"), await interaction.deferReply({
async (interaction: ChatInputCommandInteraction): Promise<any> => { flags: [MessageFlags.Ephemeral],
await interaction.deferReply({ });
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,29 +1,31 @@
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";
export default defineCommand( 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> => {
), await interaction.deferReply({
async (interaction: ChatInputCommandInteraction): Promise<any> => { flags: [MessageFlags.Ephemeral],
await interaction.deferReply({ });
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,31 +1,35 @@
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";
export default defineCommand( export default defineCommand(
new SlashCommandSubcommandBuilder() new SlashCommandSubcommandBuilder()
.setName("목소리") .setName("목소리")
.setDescription("예주의 목소리를 설정해요") .setDescription("예주의 목소리를 설정해요")
.addStringOption(option => .addStringOption((option) =>
option option
.setName("voice") .setName("voice")
.setDescription("사용할수 있는 목소리들이에요") .setDescription("사용할수 있는 목소리들이에요")
.setRequired(true) .setRequired(true)
.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> => { ),
await interaction.deferReply({ async (interaction: ChatInputCommandInteraction): Promise<any> => {
flags: [MessageFlags.Ephemeral] await interaction.deferReply({
}); 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,25 +1,29 @@
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";
export default defineCommand( export default defineCommand(
new SlashCommandBuilder() new SlashCommandBuilder()
.setName("스킵") .setName("스킵")
.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) {
await interaction.editReply("서버에서만 사용할 수 있어요"); await interaction.editReply("서버에서만 사용할 수 있어요");
return; return;
}
if (await skipCurrentVoice(interaction.guild)) {
await interaction.editReply("스킵 되었어요");
} else {
await interaction.editReply("실행중인 보이스가 없어요");
}
} }
)
if (await skipCurrentVoice(interaction.guild)) {
await interaction.editReply("스킵 되었어요");
} else {
await interaction.editReply("실행중인 보이스가 없어요");
}
},
);

View file

@ -1,36 +1,42 @@
import { Channel, ChannelType, ChatInputCommandInteraction, MessageFlags, SlashCommandSubcommandBuilder } from "discord.js"; import {
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;
const guildId = interaction.guildId; const guildId = interaction.guildId;
if (guildId == null) if (guildId == null)
return await interaction.editReply("알수없는 서버에요!"); return await interaction.editReply("알수없는 서버에요!");
try { try {
await removeGuildReadChannel(guildId, channel.id); await removeGuildReadChannel(guildId, channel.id);
} catch { } catch {
return await interaction.editReply("읽지 않는것을 실패했어요 ?ㄴ"); return await interaction.editReply("읽지 않는것을 실패했어요 ?ㄴ");
} }
await interaction.editReply(`예주가 <#${channel.id}> 채널을 읽지않아요!`); await interaction.editReply(`예주가 <#${channel.id}> 채널을 읽지않아요!`);
}, },
true true,
) );

View file

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

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,72 +1,69 @@
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 = {};
let matched: RegExpMatchArray | null = null; // eslint-disable-next-line no-useless-assignment
if (content.startsWith("$t ")) { let matched: RegExpMatchArray | null = null;
voice = "TypeCast"; if (content.startsWith("$t ")) {
} else if (content.startsWith("$p ")) { voice = "TypeCast";
voice = "Papago"; } else if (content.startsWith("$p ")) {
} else if (matched = content.match(/^\$s(\S*) /)) { voice = "Papago";
voice = "Supertonic"; } else if ((matched = content.match(/^\$s(\S*) /))) {
if (matched[1].length) { voice = "Supertonic";
options.supertonicStyleId = matched[1] if (matched[1]?.length) {
} options.supertonicStyleId = matched[1];
} else if (content.match(/^\$\s/)) { }
return; } else if (content.match(/^\$\s/)) {
return;
}
if (profile.userSupertonicStyle.length) {
options.supertonicStyleId ??= profile.userSupertonicStyle;
}
options.supertonicStyleId ??= SUPERTONIC_DEFAULT_VOICE;
if (voice) {
content = content.replace(/^\$\S+\s+/, "");
} else {
voice = profile.voice;
}
try {
if (!(await getOrCreateVoiceConnection(guild))) return;
if (!guild.members.me?.voice.channel) return;
if (message.content === "") {
content =
message.attachments.size > 0
? `${message.attachments.size} 개의 첨부파일`
: "알수없는 메시지";
if (message.attachments.size == 1 && Math.random() < 0.05) {
content = "어이, 유저. 일개의 첨부파일이 뭘 할 수 있지?";
}
} }
if (profile.userSupertonicStyle.length) { await playVoice(guild, profile, voice, content, options);
options.supertonicStyleId ??= profile.userSupertonicStyle; } catch (err) {
} message.reply("말이 꼬이네요 ㅜ.ㅜ");
options.supertonicStyleId ??= SUPERTONIC_DEFAULT_VOICE; console.log("playVoice failed. ", err);
}
if (voice) { });
content = content.replace(/^\$\S+\s+/, "")
} else {
voice = profile.voice;
}
try {
if (!await getOrCreateVoiceConnection(guild))
return;
if (!guild.members.me?.voice.channel)
return;
if (message.content === "") {
content = message.attachments.size > 0
? `${message.attachments.size} 개의 첨부파일`
: "알수없는 메시지"
if (message.attachments.size == 1 && Math.random() < 0.05) {
content = "어이, 유저. 일개의 첨부파일이 뭘 할 수 있지?"
}
}
await playVoice(guild, profile, voice, content, options);
} catch(err) {
message.reply("말이 꼬이네요 ㅜ.ㅜ");
console.log("playVoice failed. ", err);
}
})

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

View file

@ -1,81 +1,83 @@
import { AudioPlayerStatus, AudioResource, createAudioPlayer, createAudioResource, VoiceConnection } from "@discordjs/voice"; import {
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";
import play from "play-dl"; 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;
} }
} }
class MusicQueue { class MusicQueue {
private connection: VoiceConnection; private connection: VoiceConnection;
private list: AudioResource[]; private list: AudioResource[];
constructor(connection: VoiceConnection) { constructor(connection: VoiceConnection) {
this.connection = connection; this.connection = connection;
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;
const player = createAudioPlayer(); const player = 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]);
} }
public enqueue(resource: AudioResource) { public enqueue(resource: AudioResource) {
this.list.push(resource); this.list.push(resource);
if (this.list.length == 1) { if (this.list.length == 1) {
this.play(); this.play();
return; return;
}
}
public next() {
this.list.shift();
this.play();
} }
}
public next() {
this.list.shift();
this.play();
}
} }
// TODO: 토큰 설정하고 플레이돼게 만들기 // TODO: 토큰 설정하고 플레이돼게 만들기
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();
const validation = play.yt_validate(url); const validation = play.yt_validate(url);
if (validation !== "video" && validation !== "playlist") if (validation !== "video" && validation !== "playlist")
throw new Error("Invalid YouTube URL: " + validation); throw new Error("Invalid YouTube URL: " + validation);
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);
throw err; throw err;
} }
} }
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,130 +1,145 @@
import { AudioPlayer, AudioPlayerStatus, AudioResource, createAudioPlayer, VoiceConnection } from "@discordjs/voice"; import {
AudioPlayer,
AudioPlayerStatus,
AudioResource,
createAudioPlayer,
VoiceConnection,
} from "@discordjs/voice";
import { Voice } from "../db/generated/prisma/enums"; import { 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";
class VoiceQueue { class VoiceQueue {
private connection: VoiceConnection; private connection: VoiceConnection;
private list: AudioResource[]; private list: AudioResource[];
private currentPlayer?: AudioPlayer; private currentPlayer?: AudioPlayer;
constructor(connection: VoiceConnection) { constructor(connection: VoiceConnection) {
this.connection = connection; this.connection = connection;
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]);
} }
public enqueue(resource: AudioResource) { public enqueue(resource: AudioResource) {
this.list.push(resource); this.list.push(resource);
if (this.list.length == 1) { if (this.list.length == 1) {
this.play(); this.play();
return; return;
}
}
public next() {
this.currentPlayer?.removeAllListeners(AudioPlayerStatus.Idle);
this.list.shift();
this.play();
}
public hasNext(): boolean {
return !!this.list[0];
} }
}
public next() {
this.currentPlayer?.removeAllListeners(AudioPlayerStatus.Idle);
this.list.shift();
this.play();
}
public hasNext(): boolean {
return !!this.list[0];
}
} }
export type PlayVoiceOptions = { export type PlayVoiceOptions = {
supertonicStyleId?: string, supertonicStyleId?: string;
}; };
export async function playVoice( export async function playVoice(
guild: Guild, guild: Guild,
profile: DiscordUserProfile, profile: DiscordUserProfile,
voice: Voice, voice: Voice,
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) {
throw new Error(`the user ${profile.userId} is can't use typecast voice`);
}
let voiceBufferList: Buffer[]
if (voice == "TypeCast") {
const content = TTSTypecastModel.instance.ttsify(text);
if (!content.length) if (voice == "TypeCast" && !profile.canTypecast) {
throw new Error("Empty content"); throw new Error(`the user ${profile.userId} is can't use typecast voice`);
voiceBufferList = await Promise.all(content.split("\n").map(
(content) => TTSTypecastModel.instance.getMemcachedVoice(
TTSTypecastModel.instance.createRequestId(content)
)
));
} else if (voice == "Supertonic") {
const content = TTSSupertonicModel.instance.ttsify(text);
if (!content.length)
throw new Error("Empty content");
voiceBufferList = await Promise.all(content.split("\n").map(
(content) => TTSSupertonicModel.instance.getMemcachedVoice(
TTSSupertonicModel.instance.createRequestId(content, options?.supertonicStyleId)
)
));
} else if (voice == "Papago") {
const content = TTSPapagoModel.instance.ttsify(text);
if (!content.length)
throw new Error("Empty content");
voiceBufferList = await Promise.all(content.split("\n").map(
(content) => TTSPapagoModel.instance.getMemcachedVoice(
TTSPapagoModel.instance.createRequestId(content)
)
));
} else {
throw new Error(`Unknown voice type: ${voice}`);
}
for (const voiceBuffer of voiceBufferList) {
VoiceQueue.fromConnection(connection).enqueue(
TTSModelBase.bufferToAudioResource(voiceBuffer)
);
}
} catch(err) {
OutputHandler.errorLog("[PlayVoice Error]", err);
throw new Error(err as any);
} }
let voiceBufferList: Buffer[];
if (voice == "TypeCast") {
const content = TTSTypecastModel.instance.ttsify(text);
if (!content.length) throw new Error("Empty content");
voiceBufferList = await Promise.all(
content
.split("\n")
.map((content) =>
TTSTypecastModel.instance.getMemcachedVoice(
TTSTypecastModel.instance.createRequestId(content),
),
),
);
} else if (voice == "Supertonic") {
const content = TTSSupertonicModel.instance.ttsify(text);
if (!content.length) throw new Error("Empty content");
voiceBufferList = await Promise.all(
content
.split("\n")
.map((content) =>
TTSSupertonicModel.instance.getMemcachedVoice(
TTSSupertonicModel.instance.createRequestId(
content,
options?.supertonicStyleId,
),
),
),
);
} else if (voice == "Papago") {
const content = TTSPapagoModel.instance.ttsify(text);
if (!content.length) throw new Error("Empty content");
voiceBufferList = await Promise.all(
content
.split("\n")
.map((content) =>
TTSPapagoModel.instance.getMemcachedVoice(
TTSPapagoModel.instance.createRequestId(content),
),
),
);
} else {
throw new Error(`Unknown voice type: ${voice}`);
}
for (const voiceBuffer of voiceBufferList) {
VoiceQueue.fromConnection(connection).enqueue(
TTSModelBase.bufferToAudioResource(voiceBuffer),
);
}
} catch (err) {
OutputHandler.errorLog("[PlayVoice Error]", err);
throw new Error(err as any, { cause: err });
}
} }
export async function skipCurrentVoice(guild: Guild): Promise<boolean> { 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()) {
vqueue.next(); vqueue.next();
return true; return true;
} }
return false; return false;
} }

View file

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

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 }[] = (() => {
{ name: "여성1", value: "F1" }, return (
]; (JSON.parse(process.env.SUPERTONIC_STYLE_LIST ?? "null") as any) ?? [
try { { name: "여성1", value: "F1" },
return JSON.parse(process.env.SUPERTONIC_STYLE_LIST ?? "null") as any ?? defaultValue ]
} catch {} );
return defaultValue;
})(); })();

View file

@ -5,15 +5,19 @@ import { APPLICATION_ID, DISCORD_TOKEN } from "./env";
export const bot = new DiscordBot(DISCORD_TOKEN); export const bot = new DiscordBot(DISCORD_TOKEN);
bot.client.once("clientReady", async (client) => { bot.client.once("clientReady", async (client) => {
await bot.registerCommands(); await bot.registerCommands();
await bot.registerEvents(); await bot.registerEvents();
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| "),
);
}); });
bot.client.login(DISCORD_TOKEN); bot.client.login(DISCORD_TOKEN);

View file

@ -1,6 +1,10 @@
import { writeFile, mkdir, stat, readFile } from "fs/promises"; import { writeFile, mkdir, readFile } from "fs/promises";
import { dirname } from "path"; import { 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";
@ -8,88 +12,84 @@ import { existsSync } from "node:fs";
import { saferKorean } from "../utils/saferKorean"; 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 getVoiceBuffer(id: RequestId): Promise<ArrayBuffer>;
public abstract getVoicePath(id: RequestId): string;
/**
* id로
*
*/
public async createVoice(id: RequestId, audioPath?: string): Promise<Buffer> {
const voiceBuffer = await this.getVoiceBuffer(id);
audioPath ??= this.getVoicePath(id);
const buffer = Buffer.from(voiceBuffer);
await mkdir(dirname(audioPath), { recursive: true });
await writeFile(audioPath, buffer);
return buffer;
}
/**
* id로
*/
public async getVoice(id: RequestId, audioPath?: string): Promise<Buffer> {
audioPath ??= this.getVoicePath(id);
if (existsSync(audioPath)) {
const buffer = await readFile(audioPath);
return buffer;
} }
public abstract createRequestId(text: string): RequestId
public abstract getVoiceBuffer(id: RequestId): Promise<ArrayBuffer>
public abstract getVoicePath(id: RequestId): string
/**
* id로
*
*/
public async createVoice(id: RequestId, audioPath?: string): Promise<Buffer> {
const voiceBuffer = await this.getVoiceBuffer(id);
audioPath ??= this.getVoicePath(id);
const buffer = Buffer.from(voiceBuffer);
await mkdir(dirname(audioPath), { recursive: true }); return this.createVoice(id, audioPath);
await writeFile(audioPath, buffer); }
/**
* id로 ,
* ,
*/
protected abstract cachedVoice: Map<string, Promise<Buffer>>;
public async getMemcachedVoice(id: RequestId): Promise<Buffer> {
const path = this.getVoicePath(id);
return buffer; const cached = this.cachedVoice.get(path);
if (cached) {
return cached;
} }
/**
* id로
*/
public async getVoice(id: RequestId, audioPath?: string): Promise<Buffer> {
audioPath ??= this.getVoicePath(id);
if (existsSync(audioPath)) { const waitter = this.getVoice(id);
const buffer = await readFile(audioPath); this.cachedVoice.set(path, waitter);
return buffer; setTimeout(() => this.cachedVoice.delete(path), TTSModelBase.MemCacheTTL);
} return await waitter;
}
return this.createVoice(id, audioPath);
}
/**
* id로 ,
* ,
*/
protected abstract cachedVoice: Map<String, Promise<Buffer>>
public async getMemcachedVoice(id: RequestId): Promise<Buffer> {
const path = this.getVoicePath(id);
const cached = this.cachedVoice.get(path);
if (cached) {
return cached;
}
const waitter = this.getVoice(id);
this.cachedVoice.set(path, waitter);
setTimeout(
() => this.cachedVoice.delete(path),
TTSModelBase.MemCacheTTL
);
return await waitter;
}
} }
export namespace TTSModelBase { 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(), export function bufferToAudioResource(buf: Buffer): AudioResource {
"cache", const stream = Readable.from(buf);
"audio", const resource = createAudioResource(stream, {
); inlineVolume: true,
export function bufferToAudioResource(buf: Buffer): AudioResource { inputType: StreamType.Arbitrary,
const stream = Readable.from(buf); });
const resource = createAudioResource(stream, {
inlineVolume: true,
inputType: StreamType.Arbitrary,
});
resource.volume?.setVolume(0.3); resource.volume?.setVolume(0.3);
return resource; return resource;
} }
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,101 +4,116 @@ 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,
return await response.arrayBuffer(); ): Promise<ArrayBuffer> {
} voiceId ??= await TTSPapagoModel.getVoiceId(id);
createRequestId(text: string, speaker?: string, speed?: string): TTSPapagoModel.RequestId { const response = await fetch(
return { `https://papago.naver.com/apis/tts/${voiceId}`,
text, );
speed: speed ?? "-1", return await response.arrayBuffer();
speaker: speaker ?? "kyuri", }
}; createRequestId(
} text: string,
speaker?: string,
speed?: string,
): TTSPapagoModel.RequestId {
return {
text,
speed: speed ?? "-1",
speaker: speaker ?? "kyuri",
};
}
} }
export namespace TTSPapagoModel { export namespace TTSPapagoModel {
export const instance = new TTSPapagoModel(); export const instance = new TTSPapagoModel();
export type RequestId = { export type RequestId = {
speaker: string; speaker: string;
speed: string; speed: string;
text: string; text: string;
};
export const GenerateTokenKey = "v1.9.3_3bdf0438a8";
export function hmacMD5(key: string, plaintext: string) {
const hmac = createHmac("md5", key);
const data = hmac.update(plaintext);
return data.digest("base64");
}
export function generateToken(time: number) {
const e = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (e) => {
const t = ((time + 16 * Math.random()) % 16) | 0;
return (
(time = Math.floor(time / 16)),
("x" === e ? t : (3 & t) | 8).toString(16)
);
});
const plain = `${e}\n${"https://papago.naver.com/apis/tts/makeID"}\n${time}`;
return `PPG ${e}:${hmacMD5(GenerateTokenKey, plain)}`;
}
export async function getVoiceId(id: RequestId): Promise<string> {
const input = {
alpha: "0",
pitch: "0",
speaker: id.speaker,
speed: id.speed,
text: id.text,
}; };
export const GenerateTokenKey = "v1.9.3_3bdf0438a8";
export function hmacMD5(key: string, plaintext: string) {
const hmac = createHmac("md5", key);
const data = hmac.update(plaintext);
return data.digest("base64"); const time = new Date().getTime();
const token = TTSPapagoModel.generateToken(time);
const reqbody = new URLSearchParams(Object.entries(input)).toString();
const response = await fetch("https://papago.naver.com/apis/tts/makeID", {
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
Accept: "application/json",
"Accept-Language": "en",
"Sec-GPC": "1",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "no-cors",
"Sec-Fetch-Site": "same-origin",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
Authorization: token,
Timestamp: time.toString(),
Pragma: "no-cache",
"Cache-Control": "no-cache",
},
referrer: "https://papago.naver.com/",
body: reqbody,
method: "POST",
});
if (!response.ok) {
throw new Error(
`TTS makeID request failed: ${response.status}: ${await response.text()}`,
);
} }
export function generateToken(time: number) {
const e = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (e) => {
var t = (time + 16 * Math.random()) % 16 | 0;
return (time = Math.floor(time / 16)), ("x" === e ? t : (3 & t) | 8).toString(16);
});
const plain = `${e}\n${"https://papago.naver.com/apis/tts/makeID"}\n${time}`; return ((await response.json()) as any).id;
}
return `PPG ${e}:${hmacMD5(GenerateTokenKey, plain)}`; export const PapagoAudioCachePath = join(
} TTSModelBase.AudioCachePath,
export async function getVoiceId(id: RequestId): Promise<string> { "papago",
const input = { );
alpha: "0",
pitch: "0",
speaker: id.speaker,
speed: id.speed,
text: id.text,
};
const time = new Date().getTime();
const token = TTSPapagoModel.generateToken(time);
const reqbody = new URLSearchParams(Object.entries(input)).toString();
const response = await fetch("https://papago.naver.com/apis/tts/makeID", {
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
Accept: "application/json",
"Accept-Language": "en",
"Sec-GPC": "1",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "no-cors",
"Sec-Fetch-Site": "same-origin",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
Authorization: token,
Timestamp: time.toString(),
Pragma: "no-cache",
"Cache-Control": "no-cache",
},
referrer: "https://papago.naver.com/",
body: reqbody,
method: "POST",
});
if (!response.ok) {
throw new Error(`TTS makeID request failed: ${response.status}: ${await response.text()}`);
}
return ((await response.json()) as any).id;
}
export const PapagoAudioCachePath = join(
TTSModelBase.AudioCachePath,
"papago"
);
} }
export default TTSPapagoModel; export default TTSPapagoModel;

View file

@ -3,60 +3,67 @@ 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 {
return super.ttsify(input); return super.ttsify(input);
} }
private async getSupertonicResponse(voiceId: TTSSupertonicModel.RequestId) { private async getSupertonicResponse(voiceId: TTSSupertonicModel.RequestId) {
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) {
throw Error("process.env.SUPERTONIC_API_URL not set"); throw Error("process.env.SUPERTONIC_API_URL not set");
} }
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> {
const response: Response | undefined = (await this.getSupertonicResponse(
voiceId,
)) as Response;
if (response.ok) return await response.arrayBuffer();
response = await this.getSupertonicResponse(voiceId) as Response; throw new Error(`invalid supertonic response ${await response.text()}`);
if (response.ok) }
return await response.arrayBuffer(); public getVoicePath(id: TTSSupertonicModel.RequestId): string {
const audioFileName = TTSModelBase.hashAudioFile(id.text + id.styleId);
throw new Error(`invalid supertonic response ${await response.text()}`); const audioPath = join(
} TTSSupertonicModel.SupertonicAudioCachePath,
public getVoicePath(id: TTSSupertonicModel.RequestId): string { audioFileName,
const audioFileName = TTSModelBase.hashAudioFile(id.text + id.styleId); );
const audioPath = join( return audioPath;
TTSSupertonicModel.SupertonicAudioCachePath, }
audioFileName public createRequestId(
); text: string,
return audioPath; 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,89 +6,108 @@ 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(),
ttsify(input: string): string { "cache",
return super.ttsify( "typecast",
input "lastUseApiToken",
.replace(/ㅜㅜ/g, "눙물") );
.replace(/빵/g, "빵 크크") }
.replace(/[\?]+ *ㄴ/g, "물음표ㄴ") ttsify(input: string): string {
) return super.ttsify(
} input
private async getTypecastResponse(apiKey: string, voiceId: TTSTypecastModel.RequestId) { .replace(/ㅜㅜ/g, "눙물")
const payload = { .replace(/빵/g, "빵 크크")
text: voiceId.text, .replace(/[?]+ *ㄴ/g, "물음표ㄴ"),
model: "ssfm-v21", );
voice_id: voiceId.voiceId, }
language: "kor", private async getTypecastResponse(
prompt: { apiKey: string,
emotion_preset: "happy", // Options: normal, happy, sad, angry, tonemid, toneup voiceId: TTSTypecastModel.RequestId,
emotion_intensity: 1 // Range: 0.0 to 2.0 ) {
}, const payload = {
output: { text: voiceId.text,
volume: 45, // Range: 0 to 200 model: "ssfm-v21",
audio_pitch: 1, // Range: -12 to +12 semitones voice_id: voiceId.voiceId,
audio_tempo: 1, // Range: 0.5x to 2.0x language: "kor",
audio_format: "mp3" // Options: wav, mp3 prompt: {
}, emotion_preset: "happy", // Options: normal, happy, sad, angry, tonemid, toneup
seed: 22 // For reproducible results emotion_intensity: 1, // Range: 0.0 to 2.0
}; },
output: {
volume: 45, // Range: 0 to 200
audio_pitch: 1, // Range: -12 to +12 semitones
audio_tempo: 1, // Range: 0.5x to 2.0x
audio_format: "mp3", // Options: wav, mp3
},
seed: 22, // For reproducible results
};
return await fetch(TTSTypecastModel.TypecastApiUrl, { 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(
let response: Response | undefined; voiceId: TTSTypecastModel.RequestId,
): Promise<ArrayBuffer> {
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");
}
public getVoicePath(id: TTSTypecastModel.RequestId): string {
const audioFileName = TTSModelBase.hashAudioFile(id.text);
const audioPath = join(
TTSTypecastModel.TypecastAudioCachePath,
id.voiceId,
audioFileName
); );
return audioPath; }
}
public createRequestId(text: string, voiceId?: string): TTSTypecastModel.RequestId {
return {
text,
voiceId: voiceId ?? TTSTypecastModel.DefaultVoiceId,
};
} }
throw new Error("Typecast Api use all credit");
}
public getVoicePath(id: TTSTypecastModel.RequestId): string {
const audioFileName = TTSModelBase.hashAudioFile(id.text);
const audioPath = join(
TTSTypecastModel.TypecastAudioCachePath,
id.voiceId,
audioFileName,
);
return audioPath;
}
public createRequestId(
text: string,
voiceId?: string,
): TTSTypecastModel.RequestId {
return {
text,
voiceId: voiceId ?? TTSTypecastModel.DefaultVoiceId,
};
}
} }
export namespace TTSTypecastModel { export 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(
export const TypecastApiUrl = "https://api.typecast.ai/v1/text-to-speech"; TTSModelBase.AudioCachePath,
export const DefaultVoiceId = "tc_6731b292d944a485bc406efb"; "typecast",
);
export const TypecastApiUrl = "https://api.typecast.ai/v1/text-to-speech";
export const DefaultVoiceId = "tc_6731b292d944a485bc406efb";
} }
export default TTSTypecastModel; export default TTSTypecastModel;

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

@ -1,137 +1,173 @@
export const nyaWords = { export const nyaWords = {
'나': "냐", : "냐",
'낙': "냑", : "냑",
'낚': "냒", : "냒",
'낛': "냓", : "냓",
'난': "냔", : "냔",
'낝': "냕", : "냕",
'낞': "냖", : "냖",
'낟': "냗", : "냗",
'날': "냘", : "냘",
'낡': "냙", : "냙",
'낢': "냚", : "냚",
'낣': "냛", : "냛",
'낤': "냜", : "냜",
'낥': "냝", : "냝",
'낦': "냞", : "냞",
'낧': "냟", : "냟",
'남': "냠", : "냠",
'납': "냡", : "냡",
'낪': "냢", : "냢",
'낫': "냣", : "냣",
'났': "냤", : "냤",
'낭': "냥", : "냥",
'낮': "냦", : "냦",
'낯': "냧", : "냧",
'낰': "냨", : "냨",
'낱': "냩", : "냩",
'낲': "냪", : "냪",
'낳': "냫", : "냫",
}; };
export const nyaWords2 = { export const nyaWords2 = {
'내': "냥", : "냥",
'넹': "냥", : "냥",
'넴': "냥", : "냥",
'넵': "냥", : "냥",
'냐': "냥", : "냥",
'님': "냥", : "냥",
'니': "냥", : "냥",
'다': "다냥", : "다냥",
'까': "까냥", : "까냥",
'네': "네냥", : "네냥",
'야': "야냥", : "야냥",
'꺼': "꺼냥", : "꺼냥",
'래': "래냥", : "래냥",
'해': "해냥", : "해냥",
'지': "지냥", : "지냥",
'라': "라냥", : "라냥",
'요': "요냥", : "요냥",
'가': "가냥", : "가냥",
'데': "데냥", : "데냥",
'돼': "돼냥", : "돼냥",
'줘': "줘냥", : "줘냥",
'마': "마냥", : "마냥",
'와': "와냥", : "와냥",
'어': "어냥", : "어냥",
'자': "자냥", : "자냥",
'죠': "죠냥", : "죠냥",
'서': "서냥", : "서냥",
'게': "게냥", : "게냥",
}; };
function replacePunctuation(input: string): string { function replacePunctuation(input: string): string {
return input.replace(/(^|\s)([?!,.;~^@()]+)/g, (match, p1, p2) => { return input.replace(/(^|\s)([?!,.;~^@()]+)/g, (match, p1, p2) => {
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);
}); });
} }
function addNyangAtMWord(sentence: string): string { function addNyangAtMWord(sentence: string): string {
return sentence.split(' ').map((word) => { return sentence
const match = word.match(/^([가-힣]+)([?!,.;~^@()]*)$/); .split(" ")
.map((word) => {
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) {
return baseWord + "냥" + punctuation; return baseWord + "냥" + punctuation;
}
} }
}
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]);
} }
text = replacePunctuation(text); text = replacePunctuation(text);
text = addNyangAtMWord(text); text = addNyangAtMWord(text);
return text; return text;
} }

View file

@ -1,42 +1,37 @@
import { dirname, join } from "path"; import { 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(), export const ErrorLogPath = join(LogCachePath, "error.log");
"cache", export function getErrorOutput(...args: any[]) {
"log", const timestamp = new Date().toISOString();
);
export const ErrorLogPath = join(LogCachePath, "error.log" );
export function getErrorOutput(...args: any[]) {
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}`;
}
export async function errorLog(...args: any[]): Promise<void> {
const output = getErrorOutput(...args);
console.log(output);
return `[${timestamp}] ${message}`; await mkdir(dirname(ErrorLogPath), { recursive: true });
}
export async function errorLog(...args: any[]): Promise<void> {
const output = getErrorOutput(...args);
console.log(output);
await mkdir(dirname(ErrorLogPath), { recursive: true }); const fileHandle = await open(ErrorLogPath, "a");
fileHandle.write(output + "\n");
const fileHandle = await open(ErrorLogPath, "a"); fileHandle.close();
fileHandle.write(output + "\n"); }
fileHandle.close(); export async function getErrorLog(): Promise<string> {
} return (await readFile(ErrorLogPath)).toString();
export async function getErrorLog(): Promise<string> { }
return (await readFile(ErrorLogPath)).toString();
}
} }

View file

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

View file

@ -1,13 +1,13 @@
import { readdirSync } from "fs";
import { readdir } from "fs/promises"; import { 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,388 +1,393 @@
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 = {
"%": "퍼센트", "%": "퍼센트",
"$": "달러", $: "달러",
"^": "캐럿", "^": "캐럿",
"&": "엔드", "&": "엔드",
"*": "스타", "*": "스타",
"#": "샵", "#": "샵",
"@": "엣", "@": "엣",
".": "쩜", ".": "쩜",
"-": "마이너스", "-": "마이너스",
"+": "플러스", "+": "플러스",
"_": "언더바", _: "언더바",
"=": "이퀄", "=": "이퀄",
"/": "슬래쉬", "/": "슬래쉬",
"~": "물결표", "~": "물결표",
"\\": "역슬래쉬", "\\": "역슬래쉬",
"♡": "하트 ", "♡": "하트 ",
"|": "", "|": "",
">": "", ">": "",
"<": "", "<": "",
":": "콜론", ":": "콜론",
";": "세미콜론" ";": "세미콜론",
}; };
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": "씨플플",
"c++": "씨플플",
"csharp": "씨샵",
"cs": "씨샵",
"c#": "씨샵",
"c": "씨",
"h": "헤더",
"d": "디", cpp: "씨플플",
"awk": "에이더블류케이", "c++": "씨플플",
"pl": "펄", csharp: "씨샵",
"pwsh": "파워쉘", cs: "씨샵",
"powershell": "파워쉘", "c#": "씨샵",
"cmd": "씨엠디", c: "씨",
"sh": "쉘", h: "헤더",
"ps1": "파워셀",
"bat": "배치파일",
"bash": "베시스크립트",
"tex": "텍",
"dart": "다트",
"go": "고랭",
"python": "파이썬",
"swift": "스위프트",
"css": "씨에스에스",
"html": "에이치티엠엘",
"latex": "레이텍", d: "디",
"md": "마크다운", awk: "에이더블류케이",
"markdown": "마크다운", pl: "펄",
pwsh: "파워쉘",
powershell: "파워쉘",
cmd: "씨엠디",
sh: "쉘",
ps1: "파워셀",
bat: "배치파일",
bash: "베시스크립트",
tex: "텍",
dart: "다트",
go: "고랭",
python: "파이썬",
swift: "스위프트",
css: "씨에스에스",
html: "에이치티엠엘",
"py": "파이썬", latex: "레이텍",
"hs": "하스켈", md: "마크다운",
"rs": "러스트", markdown: "마크다운",
"kt": "코틀린",
"js": "자스", py: "파이썬",
"ts": "타스", hs: "하스켈",
"tsx": "리액트 타입스크립트", rs: "러스트",
"jsx": "리액트 자바스크립트", kt: "코틀린",
"an": "에이엔", js: "자스",
"parlance": "팔렌스", ts: "타스",
tsx: "리액트 타입스크립트",
jsx: "리액트 자바스크립트",
an: "에이엔",
parlance: "팔렌스",
}; };
export const LangPrefixMaxLength = (()=>{ export const LangPrefixMaxLength = (() => {
let max = 0; let max = 0;
for (const key in LangPrefixes) { for (const key in LangPrefixes) {
max = Math.max(key.length, max); max = Math.max(key.length, max);
} }
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 (
// Process isolated symbols processDots(input.normalize() + " ")
.replace(/^[\?\!\'\"]+ $/, (total)=>( // Process isolated symbols
[...total].map(element => IsolatedSymbolMap[ .replace(/^[?!'"]+ $/, (total) =>
element as keyof typeof IsolatedSymbolMap [...total]
]).join("") .map(
)) (element) =>
.replace(/\s\|\|\s/g, " 오얼 ") IsolatedSymbolMap[element as keyof typeof IsolatedSymbolMap],
.replace(/\s\&\&\s/g, " 엔드 ") )
.join(""),
)
.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)) {
if (code.startsWith(key + "\n")) { if (code.startsWith(key + "\n")) {
lang = value + " "; lang = value + " ";
break; break;
} }
} }
return lang + "코드블럭"; return lang + "코드블럭";
}) })
// 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")) {
return "움짤!"; return "움짤!";
} }
return "링크"; return "링크";
}) })
// 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) => {
return "아니".repeat(Math.min(Math.floor(content.length / 2), 2));
})
.replace(/(ㅇㅎ)+/g, (content: string) => {
return "아하".repeat(Math.min(Math.floor(content.length / 2), 2));
})
.replace(/(ㅇㅋ)+/g, (content: string) => {
return "오키".repeat(Math.min(Math.floor(content.length / 2), 2));
})
.replace(/(ㅊㅋ)+/g, (content: string) => {
return "추카".repeat(Math.min(Math.floor(content.length / 2), 2));
})
.replace(/ㄱ+/g, (content: string) => {
if (content.length == 2) {
return "고고";
} else if (content.length == 3) {
return "고고고";
}
return content;
})
.replace(/ㅋ{2,}/g, (content) => "크".repeat(content.length))
.replace(/ㅌ{2,}/g, "틔틔")
.replace(/ㄷ{2,}/g, "덜덜")
.replace(/ㄴ{2,}/g, "노노")
.replace(/ㅇ{2,}/g, "응응")
.replace(/ㅊ{2,}/g, "추추")
.replace(/ㅠ{2,}/g, "유유")
.replace(/ㅜ{2,}/g, "우우")
.replace(
/[ㄱ-ㅎㄲㄸㅃㅆㅉ]/g,
(char: string) => ChoseongMap[char as keyof typeof ChoseongMap],
)
// Process number, unit
.replace(
/(\+\d+[\s-]+)?([\d-]+)/g,
(_, prefix: string | undefined, phone: string) => {
const all = (prefix ?? "") + phone;
if (!phone.includes("-")) return all;
return PhoneNumberKorean.convert(all);
},
)
.replace(
/([\d,]+)([kKMmgGtTpPeEzZyY][iI]?)[bB]/g,
(_, num: string, mod: string) => {
// 10kib => 십키비바이트
num = IntegerKorean.convertFromString(num);
mod = SIPrefix[mod.toLowerCase() as keyof typeof SIPrefix];
return `${num} ${mod}바이트 `;
},
)
.replace(/([\d,]+)([m]?)[lL]\s/g, (_, num: string, mod: string) => {
// 10l => 십리터
num = IntegerKorean.convertFromString(num);
mod = LiterPrefix[mod as keyof typeof LiterPrefix];
return `${num} ${mod}리터 `;
})
.replace(/([\d,]+)([mck]?)m\s/g, (_, num: string, mod: string) => {
// 10m => 십미터
num = IntegerKorean.convertFromString(num);
mod = MeterPrefix[mod as keyof typeof MeterPrefix];
return `${num} ${mod}미터 `;
})
.replace(
/([\d.]+)\s*([개살시평명])/g,
(_, num: string, postfix: string) => {
// 10명 => 열명
if (num.includes(".")) {
return num + postfix;
}
const intNum = parseInt(num);
if (CallingNumberKorean.canConvert(intNum)) {
return CallingNumberKorean.convert(intNum) + postfix;
} else {
return IntegerKorean.convertFromString(num) + postfix;
}
},
)
.replace(/[\d,]+/g, (num: string) => {
// 1,000 원 => 천원
if (!num.includes(",")) return num;
return IntegerKorean.convertFromString(num);
})
.replace(
/(v?)([\d.]+)([ab]?)/g,
(_, suffix: string, num: string, postfix: string) => {
const dotCount = [...num.matchAll(/\./g)].length;
const hasNoSuffix = suffix == "";
if (hasNoSuffix && dotCount == 0) {
// 일반 숫자는 인트로 읽음
return IntegerKorean.convertFromString(num) + postfix;
} else if (hasNoSuffix && dotCount == 1) {
// 소수는 . 앞은 인트로, 뒤는 플로트로 읽음
const [intPart, floatPart] = num.split(/\./);
return (
IntegerKorean.convertFromString(intPart ?? "") +
"쩜" +
FloatKorean.convert(floatPart ?? "") +
postfix
); );
}) } else if ((suffix == "v" || postfix.length) && dotCount > 1) {
.replace(/(ㅇㄴ)+/g, (content: string) => { // 버전표기는 버전을 붙여서
return "아니".repeat( return (
Math.min(Math.floor(content.length / 2), 2) "버전" +
FloatKorean.convert(num) +
(VersionPostfix[postfix as keyof typeof VersionPostfix] ?? "")
); );
}) } else {
.replace(/(ㅇㅎ)+/g, (content: string) => { // 모든 경우에 속하지 않으면 영일이삼사 형태로 읽음
return "아하".repeat( // (예: 111.111.111.111 ip address)
Math.min(Math.floor(content.length / 2), 2) return FloatKorean.convert(num) + postfix;
); }
}) },
.replace(/(ㅇㅋ)+/g, (content: string) => { )
return "오키".repeat(
Math.min(Math.floor(content.length / 2), 2)
);
})
.replace(/(ㅊㅋ)+/g, (content: string) => {
return "추카".repeat(
Math.min(Math.floor(content.length / 2), 2)
);
})
.replace(/ㄱ+/g, (content: string) => {
if (content.length == 2) {
return "고고";
} else if (content.length == 3) {
return "고고고";
}
return content;
})
.replace(/ㅋ{2,}/g, (content) => "크".repeat(content.length))
.replace(/ㅌ{2,}/g, "틔틔")
.replace(/ㄷ{2,}/g, "덜덜")
.replace(/ㄴ{2,}/g, "노노")
.replace(/ㅇ{2,}/g, "응응")
.replace(/ㅊ{2,}/g, "추추")
.replace(/ㅠ{2,}/g, "유유")
.replace(/ㅜ{2,}/g, "우우")
.replace(/[ㄱ-ㅎㄲㄸㅃㅆㅉ]/g, (char: string) => ChoseongMap[char as keyof typeof ChoseongMap])
// Process number, unit // Process symbol
.replace(/(\+\d+[\s\-]+)?([\d\-]+)/g, (_, prefix: string | undefined, phone: string) => { .replace(
const all = (prefix ?? "") + phone; /[%^&*#@.\-+_=/\\♡$|:;><]/g,
if (!phone.includes("-")) return all; (t) => SymbolMap[t as keyof typeof SymbolMap],
return PhoneNumberKorean.convert(all); )
}) .replace(/([?!]+)/g, (_, content: string): string => content[0] ?? "")
.replace(/([\d,]+)([kKMmgGtTpPeEzZyY][iI]?)[bB]/g, (_, num: string, mod: string) => { .replace(/[ \t\f\r]+/g, " ")
// 10kib => 십키비바이트
num = IntegerKorean.convertFromString(num);
mod = SIPrefix[mod.toLowerCase() as keyof typeof SIPrefix];
return `${num} ${mod}바이트 `;
})
.replace(/([\d,]+)([m]?)[lL]\s/g, (_, num: string, mod: string) => {
// 10l => 십리터
num = IntegerKorean.convertFromString(num);
mod = LiterPrefix[mod as keyof typeof LiterPrefix];
return `${num} ${mod}리터 `;
})
.replace(/([\d,]+)([mck]?)m\s/g, (_, num: string, mod: string) => {
// 10m => 십미터
num = IntegerKorean.convertFromString(num);
mod = MeterPrefix[mod as keyof typeof MeterPrefix];
return `${num} ${mod}미터 `;
})
.replace(/([\d\.]+)\s*([개살시평명])/g, (_, num: string, postfix: string)=>{
// 10명 => 열명
if (num.includes(".")) {
return num + postfix;
}
const intNum = parseInt(num)
if (CallingNumberKorean.canConvert(intNum)) {
return CallingNumberKorean.convert(intNum) + postfix;
} else {
return IntegerKorean.convertFromString(num) + postfix;
}
})
.replace(/[\d,]+/g, (num: string) => {
// 1,000 원 => 천원
if (!num.includes(",")) return num;
return IntegerKorean.convertFromString(num);
})
.replace(/(v?)([\d\.]+)([ab]?)/g, (_, suffix: string, num: string, postfix: string) => {
const dotCount = [...num.matchAll(/\./g)].length;
const hasNoSuffix = suffix == "";
if (hasNoSuffix && dotCount == 0) { // Process emoji
// 일반 숫자는 인트로 읽음 .replace(
return IntegerKorean.convertFromString(num) + postfix; UnicodeSymbolsRegex,
} else if (hasNoSuffix && dotCount == 1) { (content: string) =>
// 소수는 . 앞은 인트로, 뒤는 플로트로 읽음 UnicodeSymbols[content as keyof typeof UnicodeSymbols] ?? content,
const [intPart, floatPart] = num.split(/\./); )
return ( .replace(/\p{Extended_Pictographic}/gu, (content: string) => {
IntegerKorean.convertFromString(intPart) return (
+ "쩜" EmojiDescriptions[content as keyof typeof EmojiDescriptions] ??
+ FloatKorean.convert(floatPart) content
+ postfix );
) })
} else if ((suffix == "v" || postfix.length) && dotCount > 1) { .replace(/\p{Emoji}/u, " ")
// 버전표기는 버전을 붙여서 .trim()
return ( );
"버전"
+ FloatKorean.convert(num)
+ (VersionPostfix[
postfix as keyof typeof VersionPostfix
] ?? "")
);
} else {
// 모든 경우에 속하지 않으면 영일이삼사 형태로 읽음
// (예: 111.111.111.111 ip address)
return FloatKorean.convert(num) + postfix;
}
})
// Process symbol
.replace(/[\%\^\&\*\#\@\.\-\+\_\=\/\\♡\$\|\:\;\>\<]/g, (t) => (
SymbolMap[t as keyof typeof SymbolMap]
))
.replace(/([\?\!]+)/g, (_, content: string) => content[0])
.replace(/[ \t\f\r]+/g, " ")
// Process emoji
.replace(UnicodeEmojisRegex, (content: string) => (UnicodeEmojis[content as keyof typeof UnicodeEmojis] ?? content))
.replace(/\p{Emoji}/u, " ")
.trim()
} }

View file

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

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