feat: external video player flow and discord playback link improvements

This commit is contained in:
2026-02-22 15:20:31 +09:00
commit 4a3577f8c8
40 changed files with 4747 additions and 0 deletions

22
.env.example Normal file
View File

@@ -0,0 +1,22 @@
DISCORD_TOKEN=
DISCORD_CLIENT_ID=
DISCORD_GUILD_ID=
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/discord_multibot
REDIS_URL=redis://localhost:6379/1
LAVALINK_NODE_NAME=local
LAVALINK_HOST=127.0.0.1
LAVALINK_PORT=2333
LAVALINK_PASSWORD=youshallnotpass
LAVALINK_SECURE=false
GEMINI_API_KEY=
GEMINI_FLASH_MODEL=gemini-2.5-flash
GEMINI_PRO_MODEL=gemini-2.5-pro
DEEPL_API_KEY=
DEEPL_CLI_BIN=deepl
DEEPL_CLI_TIMEOUT_MS=45000
GDS_DVIEWER_BASE_URL=http://127.0.0.1:9099/gds_dviewer/normal/explorer
GDS_DVIEWER_API_KEY=
GDS_DVIEWER_SOURCE_ID=0
EXTERNAL_VIDEO_PLAYER_URL=
IINA_LINK_ICON=🎬
LOG_LEVEL=info

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
.env
.DS_Store

135
README.md Normal file
View File

@@ -0,0 +1,135 @@
# Discord Multi Bot (Music / Manage / Notify)
TypeScript 기반 디스코드 봇 템플릿입니다. 음악 재생은 Lavalink(Shoukaku)로 동작합니다.
## Stack
- TypeScript + Node.js 20+
- discord.js v14
- PostgreSQL + Prisma
- Redis + BullMQ
- Lavalink + Shoukaku
## 1) 환경 변수
```bash
cp .env.example .env
```
`.env` 예시:
```env
DISCORD_TOKEN=...
DISCORD_CLIENT_ID=...
DISCORD_GUILD_ID=... # 개발/테스트 서버 ID
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/discord_multibot
REDIS_URL=redis://localhost:6379/1
LAVALINK_NODE_NAME=local
LAVALINK_HOST=127.0.0.1
LAVALINK_PORT=2333
LAVALINK_PASSWORD=youshallnotpass
LAVALINK_SECURE=false
LOG_LEVEL=info
```
## 2) PostgreSQL 준비
예시:
```bash
docker run -d \
--name discord-postgres \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=discord_multibot \
-p 5432:5432 \
-v discord_postgres_data:/var/lib/postgresql/data \
postgres:16
```
## 3) Lavalink 준비 (Docker)
프로젝트 루트에서:
```bash
docker compose -f docker-compose.lavalink.yml up -d
```
상태 확인:
```bash
docker logs -f discord-lavalink
```
## 4) 의존성/DB 초기화
```bash
npm install
npm run prisma:generate
npm run prisma:push
```
## 5) 실행
```bash
npm run dev
```
정상 로그 예시:
- `Guild commands registered`
- `Bot ready`
## 명령어
### Music (Lavalink)
- `/play query:<검색어 또는 유튜브 URL>`
- `/queue`
- `/skip`
- `/stop`
### Manage
- `/warn user:<user> reason:<string>`
- `/warnings user:<user>`
### Notify
- `/notify_schedule channel:<channel> cron:<expr> message:<text>`
- `/notify_list`
- `/notify_disable rule_id:<id>`
### News
- `/news query:<optional>`
### Summarize (Google AI Studio)
- `/summarize url:<url> mode:<auto|fast|quality>`
### Translate
- `/translate text:<text> source:<optional> target:<optional>`
- 우선순위: `DeepL API` -> `deepl-cli(web)` -> `Google Web v2`
## 실제 사용 예시
1. 봇과 사용자 모두 같은 음성 채널 입장
2. 텍스트 채널에서:
- `/play query:아이유 밤편지`
- `/play query:https://www.youtube.com/watch?v=...`
3. 제어:
- `/queue` (대기열 확인)
- `/skip` (다음 곡)
- `/stop` (정지 + 음성채널 퇴장)
4. 접두사 명령:
- `!play <검색어 또는 URL>`
- `!queue`
- `!skip`
- `!stop`
- `!뉴스 [키워드]`
- `!news [keyword]`
- `!요약 <url> [auto|fast|quality]`
- `!summarize <url> [auto|fast|quality]`
- `!번역 [source->target] <텍스트>` (기본 `auto->ko`)
- `!translate [source->target] <text>`
- `!ani "제목"` (gds_dviewer 애니 검색)
- `!영화 "제목"` / `!movie "title"` (gds_dviewer 영화 검색)
## 참고
- `REDIS_URL`이 없으면 BullMQ 워커는 비활성화됩니다.
- YouTube 재생 안정성은 Lavalink 서버 상태/플러그인에 영향받습니다.
- 요약 기능은 `GEMINI_API_KEY`가 필요합니다.
- 번역 기능은 `DEEPL_API_KEY`가 없으면 `deepl-cli`를 먼저 시도하고, 실패 시 `Google Web v2`를 사용합니다.
- `deepl-cli` 테스트용 설정:
- `DEEPL_CLI_BIN=deepl`
- `DEEPL_CLI_TIMEOUT_MS=45000`
- pyenv 사용 시 예시: `DEEPL_CLI_BIN=/Users/yommi/.pyenv/versions/3.11.0/envs/FF_3.11/bin/deepl`
- `!ani` 기능 환경 변수:
- `GDS_DVIEWER_BASE_URL=http://127.0.0.1:9099/gds_dviewer/normal/explorer`
- `GDS_DVIEWER_API_KEY=...`
- `GDS_DVIEWER_SOURCE_ID=0`
- `EXTERNAL_VIDEO_PLAYER_URL=https://your-domain/player/external_video_player.html` (Discord 노출 URL 분리용, 권장)

View File

@@ -0,0 +1,10 @@
services:
lavalink:
image: ghcr.io/lavalink-devs/lavalink:4
container_name: discord-lavalink
restart: unless-stopped
ports:
- "2333:2333"
volumes:
- ./lavalink/application.yml:/opt/Lavalink/application.yml:ro
- ./lavalink/plugins:/opt/Lavalink/plugins

24
lavalink/application.yml Normal file
View File

@@ -0,0 +1,24 @@
server:
port: 2333
lavalink:
server:
password: "youshallnotpass"
sources:
youtube: false
bandcamp: true
soundcloud: true
twitch: true
vimeo: true
http: true
local: false
plugins:
- dependency: "dev.lavalink.youtube:youtube-plugin:1.17.0"
repository: "https://maven.lavalink.dev/releases"
snapshot: false
logging:
level:
root: INFO
lavalink: INFO

Binary file not shown.

2281
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "discord_multibot",
"version": "0.1.41",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json && node scripts/bump-patch.mjs",
"build:check": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:push": "prisma db push",
"version:minor": "node scripts/bump-patch.mjs minor",
"version:major": "node scripts/bump-patch.mjs major"
},
"dependencies": {
"@prisma/client": "^6.15.0",
"bullmq": "^5.58.0",
"cheerio": "^1.2.0",
"discord.js": "^14.22.1",
"dotenv": "^16.6.1",
"fast-xml-parser": "^5.3.7",
"ioredis": "^5.8.0",
"pino": "^9.9.0",
"shoukaku": "^4.2.0",
"zod": "^4.1.5"
},
"devDependencies": {
"@types/node": "^24.3.0",
"pino-pretty": "^13.1.1",
"prisma": "^6.15.0",
"tsx": "^4.20.5",
"typescript": "^5.9.2"
}
}

40
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,40 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model GuildConfig {
id String @id @default(cuid())
guildId String @unique
logChannelId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Warning {
id String @id @default(cuid())
guildId String
userId String
moderatorId String
reason String
createdAt DateTime @default(now())
@@index([guildId, userId])
}
model NotificationRule {
id String @id @default(cuid())
guildId String
channelId String
cronExpr String
message String
enabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([guildId, enabled])
}

45
scripts/bump-patch.mjs Normal file
View File

@@ -0,0 +1,45 @@
import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
const pkgPath = path.join(root, "package.json");
const lockPath = path.join(root, "package-lock.json");
const target = String(process.argv[2] || "patch").toLowerCase();
if (!["patch", "minor", "major"].includes(target)) {
console.error(`[version] unsupported target: ${target}`);
process.exit(1);
}
function bump(version, mode) {
const parts = String(version || "0.0.0").split(".").map((v) => Number(v) || 0);
const [major, minor, patch] = [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
if (mode === "major") return `${major + 1}.0.0`;
if (mode === "minor") return `${major}.${minor + 1}.0`;
return `${major}.${minor}.${patch + 1}`;
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function writeJson(filePath, obj) {
fs.writeFileSync(filePath, `${JSON.stringify(obj, null, 2)}\n`, "utf8");
}
const pkg = readJson(pkgPath);
const current = String(pkg.version || "0.0.0");
const next = bump(current, target);
pkg.version = next;
writeJson(pkgPath, pkg);
if (fs.existsSync(lockPath)) {
const lock = readJson(lockPath);
lock.version = next;
if (lock.packages && lock.packages[""]) {
lock.packages[""].version = next;
}
writeJson(lockPath, lock);
}
console.log(`[version] ${current} -> ${next} (${target})`);

30
src/commands/index.ts Normal file
View File

@@ -0,0 +1,30 @@
import type { CommandModule } from "../discord/types.js";
import { playCommand } from "./music/play.js";
import { queueCommand } from "./music/queue.js";
import { skipCommand } from "./music/skip.js";
import { stopCommand } from "./music/stop.js";
import { warnCommand } from "./manage/warn.js";
import { warningsCommand } from "./manage/warnings.js";
import { scheduleCommand } from "./notify/schedule.js";
import { notifyListCommand } from "./notify/list.js";
import { notifyDisableCommand } from "./notify/disable.js";
import { newsCommand } from "./info/news.js";
import { summarizeCommand } from "./info/summarize.js";
import { translateCommand } from "./info/translate.js";
export const commands: CommandModule[] = [
playCommand,
queueCommand,
skipCommand,
stopCommand,
warnCommand,
warningsCommand,
scheduleCommand,
notifyListCommand,
notifyDisableCommand,
newsCommand,
summarizeCommand,
translateCommand,
];
export const commandMap = new Map(commands.map((c) => [c.data.name, c]));

33
src/commands/info/news.ts Normal file
View File

@@ -0,0 +1,33 @@
import { SlashCommandBuilder } from "discord.js";
import type { CommandModule } from "../../discord/types.js";
import { newsService } from "../../services/news/news-service.js";
export const newsCommand: CommandModule = {
data: new SlashCommandBuilder()
.setName("news")
.setDescription("구글 뉴스 최신 10개를 보여줍니다")
.addStringOption((opt) =>
opt
.setName("query")
.setDescription("검색 키워드 (생략 시 전체 뉴스)")
.setRequired(false),
),
async execute(interaction) {
const query = interaction.options.getString("query") || "";
await interaction.deferReply();
try {
const items = await newsService.fetchGoogleNews(query, 10);
const title = query ? `구글 뉴스: ${query}` : "구글 뉴스: 최신";
const chunks = newsService.toDiscordMessageChunks(title, items, 1900);
await interaction.editReply({ content: chunks[0], allowedMentions: { parse: [] } });
for (let i = 1; i < chunks.length; i += 1) {
await interaction.followUp({ content: chunks[i], allowedMentions: { parse: [] } });
}
} catch (error) {
await interaction.editReply(
`뉴스 조회 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`,
);
}
},
};

View File

@@ -0,0 +1,88 @@
import { SlashCommandBuilder } from "discord.js";
import type { CommandModule } from "../../discord/types.js";
import {
summarizeService,
type SummarizeEngine,
type SummarizeMode,
} from "../../services/summarize/summarize-service.js";
function chunkText(text: string, maxLen = 1800): string[] {
const chunks: string[] = [];
let rest = String(text || "").trim();
while (rest.length > maxLen) {
const cut = rest.lastIndexOf("\n", maxLen);
const idx = cut > 200 ? cut : maxLen;
chunks.push(rest.slice(0, idx).trim());
rest = rest.slice(idx).trim();
}
if (rest) chunks.push(rest);
return chunks;
}
export const summarizeCommand: CommandModule = {
data: new SlashCommandBuilder()
.setName("summarize")
.setDescription("웹페이지를 요약합니다")
.addStringOption((opt) => opt.setName("url").setDescription("요약할 URL").setRequired(true))
.addStringOption((opt) =>
opt
.setName("mode")
.setDescription("모델 선택 모드")
.setRequired(false)
.addChoices(
{ name: "auto", value: "auto" },
{ name: "fast", value: "fast" },
{ name: "quality", value: "quality" },
),
)
.addStringOption((opt) =>
opt
.setName("engine")
.setDescription("요약 엔진")
.setRequired(false)
.addChoices(
{ name: "ai", value: "ai" },
{ name: "basic", value: "basic" },
{ name: "both", value: "both" },
),
),
async execute(interaction) {
const url = interaction.options.getString("url", true);
const mode = (interaction.options.getString("mode") || "auto") as SummarizeMode;
const engine = (interaction.options.getString("engine") || "basic") as SummarizeEngine | "both";
await interaction.deferReply();
try {
const aiResult =
engine === "ai" || engine === "both"
? await summarizeService.summarizeUrl(url, mode, "ai")
: null;
const basicResult =
engine === "basic" || engine === "both"
? await summarizeService.summarizeUrl(url, mode, "basic")
: null;
const ref = aiResult || basicResult;
const sections: string[] = [];
if (aiResult) {
sections.push(`**[AI 요약]** model=\`${aiResult.model}\` call#${aiResult.callNo}\n${aiResult.summary}`);
}
if (basicResult) {
sections.push(
`**[비AI 요약]** model=\`${basicResult.model}\` call#${basicResult.callNo}\n${basicResult.summary}`,
);
}
const header = `**요약 완료**\n제목: ${ref?.title || "N/A"}\n원문: <${ref?.sourceUrl || url}>`;
const chunks = chunkText(`${header}\n\n${sections.join("\n\n")}`, 1800);
await interaction.editReply(chunks[0]);
for (let i = 1; i < chunks.length; i += 1) {
await interaction.followUp(chunks[i]);
}
} catch (error) {
await interaction.editReply(
`요약 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`,
);
}
},
};

View File

@@ -0,0 +1,38 @@
import { SlashCommandBuilder } from "discord.js";
import type { CommandModule } from "../../discord/types.js";
import { translateService } from "../../services/translate/translate-service.js";
export const translateCommand: CommandModule = {
data: new SlashCommandBuilder()
.setName("translate")
.setDescription("텍스트를 번역합니다 (DeepL 우선, 실패 시 Google Web v2)")
.addStringOption((opt) => opt.setName("text").setDescription("번역할 텍스트").setRequired(true))
.addStringOption((opt) =>
opt
.setName("source")
.setDescription("원문 언어 (예: auto, ja, en)")
.setRequired(false),
)
.addStringOption((opt) =>
opt
.setName("target")
.setDescription("목표 언어 (예: ko, en, ja)")
.setRequired(false),
),
async execute(interaction) {
const text = interaction.options.getString("text", true);
const source = interaction.options.getString("source") || "auto";
const target = interaction.options.getString("target") || "ko";
await interaction.deferReply();
try {
const result = await translateService.translate(text, source, target);
const header = `**번역 완료** engine=\`${result.engine}\` chars=${result.inputChars} chunks=${result.chunkCount}`;
await interaction.editReply(`${header}\n\n${result.translatedText}`);
} catch (error) {
await interaction.editReply(
`번역 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`,
);
}
},
};

View File

@@ -0,0 +1,24 @@
import { PermissionFlagsBits, SlashCommandBuilder } from "discord.js";
import type { CommandModule } from "../../discord/types.js";
import { moderationService } from "../../services/moderation/moderation-service.js";
export const warnCommand: CommandModule = {
data: new SlashCommandBuilder()
.setName("warn")
.setDescription("사용자에게 경고를 부여합니다")
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers)
.addUserOption((opt) => opt.setName("user").setDescription("대상 유저").setRequired(true))
.addStringOption((opt) => opt.setName("reason").setDescription("사유").setRequired(true)),
async execute(interaction) {
if (!interaction.guildId) return;
const user = interaction.options.getUser("user", true);
const reason = interaction.options.getString("reason", true);
const row = await moderationService.warnUser({
guildId: interaction.guildId,
userId: user.id,
moderatorId: interaction.user.id,
reason,
});
await interaction.reply(`경고 등록 완료 (#${row.id}) <@${user.id}> - ${reason}`);
},
};

View File

@@ -0,0 +1,21 @@
import { SlashCommandBuilder } from "discord.js";
import type { CommandModule } from "../../discord/types.js";
import { moderationService } from "../../services/moderation/moderation-service.js";
export const warningsCommand: CommandModule = {
data: new SlashCommandBuilder()
.setName("warnings")
.setDescription("사용자 경고 내역을 확인합니다")
.addUserOption((opt) => opt.setName("user").setDescription("대상 유저").setRequired(true)),
async execute(interaction) {
if (!interaction.guildId) return;
const user = interaction.options.getUser("user", true);
const rows = await moderationService.getWarnings(interaction.guildId, user.id);
if (rows.length === 0) {
await interaction.reply("경고 내역이 없습니다.");
return;
}
const text = rows.map((r, i) => `${i + 1}. ${r.reason} (${r.createdAt.toISOString().slice(0, 10)})`).join("\n");
await interaction.reply(`경고 내역 <@${user.id}>:\n${text}`);
},
};

View File

@@ -0,0 +1,23 @@
import { SlashCommandBuilder } from "discord.js";
import type { CommandModule } from "../../discord/types.js";
import { musicPlayer } from "../../services/music/music-player.js";
export const playCommand: CommandModule = {
data: new SlashCommandBuilder()
.setName("play")
.setDescription("유튜브 URL 또는 검색어를 재생합니다")
.addStringOption((opt) => opt.setName("query").setDescription("URL 또는 검색어").setRequired(true)),
async execute(interaction) {
if (!interaction.guildId) return;
const query = interaction.options.getString("query", true);
await interaction.deferReply();
try {
const message = await musicPlayer.enqueueFromInteraction(interaction, query);
await interaction.editReply(message);
} catch (error) {
await interaction.editReply(
`재생 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`,
);
}
},
};

View File

@@ -0,0 +1,17 @@
import { SlashCommandBuilder } from "discord.js";
import type { CommandModule } from "../../discord/types.js";
import { musicPlayer } from "../../services/music/music-player.js";
export const queueCommand: CommandModule = {
data: new SlashCommandBuilder().setName("queue").setDescription("현재 음악 대기열을 보여줍니다"),
async execute(interaction) {
if (!interaction.guildId) return;
const queue = musicPlayer.list(interaction.guildId);
if (queue.length === 0) {
await interaction.reply("대기열이 비어 있습니다.");
return;
}
const lines = queue.slice(0, 10);
await interaction.reply(`현재 대기열:\n${lines.join("\n")}`);
},
};

View File

@@ -0,0 +1,19 @@
import { SlashCommandBuilder } from "discord.js";
import type { CommandModule } from "../../discord/types.js";
import { musicPlayer } from "../../services/music/music-player.js";
export const skipCommand: CommandModule = {
data: new SlashCommandBuilder().setName("skip").setDescription("현재 트랙을 스킵합니다"),
async execute(interaction) {
if (!interaction.guildId) return;
try {
const message = await musicPlayer.skip(interaction.guildId);
await interaction.reply(message);
} catch (error) {
await interaction.reply({
content: `스킵 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`,
ephemeral: true,
});
}
},
};

View File

@@ -0,0 +1,19 @@
import { SlashCommandBuilder } from "discord.js";
import type { CommandModule } from "../../discord/types.js";
import { musicPlayer } from "../../services/music/music-player.js";
export const stopCommand: CommandModule = {
data: new SlashCommandBuilder().setName("stop").setDescription("대기열을 비웁니다"),
async execute(interaction) {
if (!interaction.guildId) return;
try {
const message = await musicPlayer.stop(interaction.guildId);
await interaction.reply(message);
} catch (error) {
await interaction.reply({
content: `중지 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`,
ephemeral: true,
});
}
},
};

View File

@@ -0,0 +1,16 @@
import { SlashCommandBuilder } from "discord.js";
import type { CommandModule } from "../../discord/types.js";
import { notifyService } from "../../services/notify/notify-service.js";
export const notifyDisableCommand: CommandModule = {
data: new SlashCommandBuilder()
.setName("notify_disable")
.setDescription("알림 규칙 비활성화")
.addStringOption((opt) => opt.setName("rule_id").setDescription("규칙 ID").setRequired(true)),
async execute(interaction) {
if (!interaction.guildId) return;
const id = interaction.options.getString("rule_id", true);
await notifyService.disableRule(id, interaction.guildId);
await interaction.reply(`규칙 비활성화 완료: ${id}`);
},
};

View File

@@ -0,0 +1,19 @@
import { SlashCommandBuilder } from "discord.js";
import type { CommandModule } from "../../discord/types.js";
import { notifyService } from "../../services/notify/notify-service.js";
export const notifyListCommand: CommandModule = {
data: new SlashCommandBuilder().setName("notify_list").setDescription("활성 알림 규칙 목록"),
async execute(interaction) {
if (!interaction.guildId) return;
const rules = await notifyService.listRules(interaction.guildId);
if (rules.length === 0) {
await interaction.reply("활성 알림 규칙이 없습니다.");
return;
}
const msg = rules
.map((r) => `- ${r.id} | <#${r.channelId}> | \`${r.cronExpr}\` | ${r.message.slice(0, 40)}`)
.join("\n");
await interaction.reply(msg);
},
};

View File

@@ -0,0 +1,25 @@
import { SlashCommandBuilder } from "discord.js";
import type { CommandModule } from "../../discord/types.js";
import { notifyService } from "../../services/notify/notify-service.js";
export const scheduleCommand: CommandModule = {
data: new SlashCommandBuilder()
.setName("notify_schedule")
.setDescription("예약 알림 규칙을 만듭니다")
.addChannelOption((opt) => opt.setName("channel").setDescription("알림 채널").setRequired(true))
.addStringOption((opt) => opt.setName("cron").setDescription("크론 식 (예: 0 9 * * *)").setRequired(true))
.addStringOption((opt) => opt.setName("message").setDescription("알림 메시지").setRequired(true)),
async execute(interaction) {
if (!interaction.guildId) return;
const channel = interaction.options.getChannel("channel", true);
const cronExpr = interaction.options.getString("cron", true);
const message = interaction.options.getString("message", true);
const rule = await notifyService.createRule({
guildId: interaction.guildId,
channelId: channel.id,
cronExpr,
message,
});
await interaction.reply(`알림 규칙 생성됨: ${rule.id}`);
},
};

35
src/core/env.ts Normal file
View File

@@ -0,0 +1,35 @@
import "dotenv/config";
import { z } from "zod";
const boolFromEnv = z.preprocess((value) => {
if (typeof value === "boolean") return value;
const text = String(value ?? "").trim().toLowerCase();
return text === "1" || text === "true" || text === "yes" || text === "on";
}, z.boolean());
const EnvSchema = z.object({
DISCORD_TOKEN: z.string().min(1),
DISCORD_CLIENT_ID: z.string().min(1),
DISCORD_GUILD_ID: z.string().min(1).optional(),
DATABASE_URL: z.string().min(1),
REDIS_URL: z.string().min(1).optional(),
LAVALINK_NODE_NAME: z.string().default("local"),
LAVALINK_HOST: z.string().default("127.0.0.1"),
LAVALINK_PORT: z.coerce.number().default(2333),
LAVALINK_PASSWORD: z.string().default("youshallnotpass"),
LAVALINK_SECURE: boolFromEnv.default(false),
GEMINI_API_KEY: z.string().optional(),
GEMINI_FLASH_MODEL: z.string().default("gemini-2.5-flash"),
GEMINI_PRO_MODEL: z.string().default("gemini-2.5-pro"),
DEEPL_API_KEY: z.string().optional(),
DEEPL_CLI_BIN: z.string().default("deepl"),
DEEPL_CLI_TIMEOUT_MS: z.coerce.number().default(45000),
GDS_DVIEWER_BASE_URL: z.string().optional(),
GDS_DVIEWER_API_KEY: z.string().optional(),
GDS_DVIEWER_SOURCE_ID: z.string().default("0"),
EXTERNAL_VIDEO_PLAYER_URL: z.string().optional(),
IINA_LINK_ICON: z.string().default("🎬"),
LOG_LEVEL: z.string().default("info"),
});
export const env = EnvSchema.parse(process.env);

13
src/core/logger.ts Normal file
View File

@@ -0,0 +1,13 @@
import pino from "pino";
import { env } from "./env.js";
export const logger = pino({
level: env.LOG_LEVEL,
transport:
process.env.NODE_ENV === "production"
? undefined
: {
target: "pino-pretty",
options: { colorize: true, translateTime: "SYS:standard" },
},
});

3
src/db/prisma.ts Normal file
View File

@@ -0,0 +1,3 @@
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();

440
src/discord/client.ts Normal file
View File

@@ -0,0 +1,440 @@
import {
ChannelType,
Client,
EmbedBuilder,
Events,
GatewayIntentBits,
type TextChannel,
} from "discord.js";
import { env } from "../core/env.js";
import { logger } from "../core/logger.js";
import { commandMap } from "../commands/index.js";
import { startNotifyWorker } from "../queue/notify-queue.js";
import { musicPlayer } from "../services/music/music-player.js";
import { newsService } from "../services/news/news-service.js";
import {
summarizeService,
type SummarizeEngine,
type SummarizeMode,
} from "../services/summarize/summarize-service.js";
import { translateService } from "../services/translate/translate-service.js";
import { gdsAnimeService } from "../services/gds/gds-anime-service.js";
import { gdsMovieService } from "../services/gds/gds-movie-service.js";
export function createBotClient() {
const PREFIX = "!";
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.MessageContent,
],
});
musicPlayer.init(client);
client.once(Events.ClientReady, () => {
logger.info({ user: client.user?.tag }, "Bot ready");
});
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand()) return;
const cmd = commandMap.get(interaction.commandName);
if (!cmd) {
await interaction.reply({ content: "알 수 없는 명령입니다.", ephemeral: true });
return;
}
try {
await cmd.execute(interaction);
} catch (error) {
logger.error({ error, command: interaction.commandName }, "Command execution failed");
const message = "명령 실행 중 오류가 발생했습니다.";
if (interaction.replied || interaction.deferred) {
await interaction.followUp({ content: message, ephemeral: true });
} else {
await interaction.reply({ content: message, ephemeral: true });
}
}
});
client.on(Events.MessageCreate, async (message) => {
if (message.author.bot) return;
if (!message.guildId) return;
const content = String(message.content || "").trim();
if (!content.startsWith(PREFIX)) return;
const withoutPrefix = content.slice(PREFIX.length).trim();
if (!withoutPrefix) return;
const [rawCommand, ...rest] = withoutPrefix.split(/\s+/);
const command = rawCommand.toLowerCase();
const chunkText = (text: string, maxLen = 1800): string[] => {
const chunks: string[] = [];
let remaining = String(text || "").trim();
while (remaining.length > maxLen) {
const cut = remaining.lastIndexOf("\n", maxLen);
const idx = cut > 200 ? cut : maxLen;
chunks.push(remaining.slice(0, idx).trim());
remaining = remaining.slice(idx).trim();
}
if (remaining) chunks.push(remaining);
return chunks;
};
try {
if (command === "play") {
const query = rest.join(" ").trim();
if (!query) {
await message.reply("사용법: `!play <검색어 또는 URL>`");
return;
}
const result = await musicPlayer.enqueueFromMessage(message, query);
await message.reply(result);
return;
}
if (command === "queue") {
const queue = musicPlayer.list(message.guildId);
if (queue.length === 0) {
await message.reply("대기열이 비어 있습니다.");
return;
}
await message.reply(`현재 대기열:\n${queue.slice(0, 10).join("\n")}`);
return;
}
if (command === "skip") {
const result = await musicPlayer.skip(message.guildId);
await message.reply(result);
return;
}
if (command === "stop") {
const result = await musicPlayer.stop(message.guildId);
await message.reply(result);
return;
}
if (command === "뉴스" || command === "news") {
const query = rest.join(" ").trim();
const items = await newsService.fetchGoogleNews(query, 10);
const title = query ? `구글 뉴스: ${query}` : "구글 뉴스: 최신";
const chunks = newsService.toDiscordMessageChunks(title, items, 1900);
const first = await message.reply({
content: chunks[0],
allowedMentions: { parse: [] },
});
for (let i = 1; i < chunks.length; i += 1) {
await first.channel.send({
content: chunks[i],
allowedMentions: { parse: [] },
});
}
return;
}
if (command === "요약" || command === "summarize") {
if (rest.length === 0) {
await message.reply("사용법: `!요약 <url> [auto|fast|quality] [ai|basic|both]`");
return;
}
const tokens = [...rest];
let engine: SummarizeEngine | "both" = "basic";
const lastToken = String(tokens[tokens.length - 1] || "").toLowerCase();
if (lastToken === "ai" || lastToken === "basic" || lastToken === "both" || lastToken === "비교") {
engine = lastToken === "비교" ? "both" : (lastToken as SummarizeEngine | "both");
tokens.pop();
}
const maybeMode = String(tokens[tokens.length - 1] || "").toLowerCase();
const mode: SummarizeMode =
maybeMode === "fast" || maybeMode === "quality" || maybeMode === "auto"
? (maybeMode as SummarizeMode)
: "auto";
const urlTokens =
maybeMode === "auto" || maybeMode === "fast" || maybeMode === "quality"
? tokens.slice(0, -1)
: tokens;
const url = urlTokens.join(" ").trim();
if (!url) {
await message.reply("사용법: `!요약 <url> [auto|fast|quality] [ai|basic|both]`");
return;
}
const aiResult =
engine === "ai" || engine === "both"
? await summarizeService.summarizeUrl(url, mode, "ai")
: null;
const basicResult =
engine === "basic" || engine === "both"
? await summarizeService.summarizeUrl(url, mode, "basic")
: null;
const ref = aiResult || basicResult;
const sections: string[] = [];
if (aiResult) {
sections.push(`**[AI 요약]** model=\`${aiResult.model}\` call#${aiResult.callNo}\n${aiResult.summary}`);
}
if (basicResult) {
sections.push(
`**[비AI 요약]** model=\`${basicResult.model}\` call#${basicResult.callNo}\n${basicResult.summary}`,
);
}
const header = `**요약 완료**\n제목: ${ref?.title || "N/A"}\n원문: <${ref?.sourceUrl || url}>`;
const chunks = chunkText(`${header}\n\n${sections.join("\n\n")}`, 1800);
const first = await message.reply(chunks[0]);
for (let i = 1; i < chunks.length; i += 1) {
await first.channel.send(chunks[i]);
}
return;
}
if (command === "번역" || command === "translate") {
if (rest.length === 0) {
await message.reply(
`사용법: \`!번역 [source->target] <텍스트>\`\n예: \`!번역 ja->ko こんにちは\`\n기본: auto->ko, 최대 ${translateService.getMaxInputChars()}`,
);
return;
}
let source = "auto";
let target = "ko";
let tokens = [...rest];
const firstToken = String(tokens[0] || "");
if (/^[a-zA-Z-]{2,12}->[a-zA-Z-]{2,12}$/.test(firstToken)) {
const [src, dst] = firstToken.split("->");
source = src.toLowerCase();
target = dst.toLowerCase();
tokens = tokens.slice(1);
}
const text = tokens.join(" ").trim();
if (!text) {
await message.reply("번역할 텍스트를 입력하세요.");
return;
}
const result = await translateService.translate(text, source, target);
const header = `**번역 완료** engine=\`${result.engine}\` chars=${result.inputChars} chunks=${result.chunkCount}`;
const chunks = chunkText(`${header}\n\n${result.translatedText}`, 1800);
const first = await message.reply(chunks[0]);
for (let i = 1; i < chunks.length; i += 1) {
await first.channel.send(chunks[i]);
}
return;
}
if (command === "ani" || command === "영화" || command === "movie") {
const query = rest.join(" ").trim();
if (!query) {
const usage = command === "ani" ? '`!ani "제목"`' : '`!영화 "제목"`';
await message.reply(`사용법: ${usage}`);
return;
}
const isMovie = command === "영화" || command === "movie";
const list = isMovie
? await gdsMovieService.searchByTitle(query, 5)
: await gdsAnimeService.searchByTitle(query, 5);
if (list.length === 0) {
await message.reply(`검색 결과 없음: ${query}`);
return;
}
const trim = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}` : s);
const buildExternalVideoModalLink = (item: {
name?: string;
path?: string;
source_id?: string | number;
stream_url?: string;
meta_poster?: string;
poster?: string;
thumb?: string;
}): string => {
const toUrlSafeBase64 = (input: string): string => {
const b64 = Buffer.from(String(input || ""), "utf8").toString("base64");
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
};
try {
const stream = String(item.stream_url || "").trim();
if (!stream) return "";
const externalPlayer = String(env.EXTERNAL_VIDEO_PLAYER_URL || "").trim();
if (!externalPlayer) return "";
const p = new URL(externalPlayer);
const explorerBase = String(env.GDS_DVIEWER_BASE_URL || "").trim();
let apikey = String(env.GDS_DVIEWER_API_KEY || "").trim();
if (!apikey && item.stream_url) {
try {
const su = new URL(item.stream_url);
apikey = String(su.searchParams.get("apikey") || "").trim();
} catch {}
}
const sid = String(item.source_id ?? env.GDS_DVIEWER_SOURCE_ID ?? "0").trim() || "0";
const autoplayPath = String(item.path || "").trim();
if (explorerBase && autoplayPath) {
p.searchParams.set("b", toUrlSafeBase64(autoplayPath));
p.searchParams.set("s", sid);
if (apikey) p.searchParams.set("k", apikey);
}
return p.toString();
} catch {
return "";
}
};
const resolvePosterUrl = (item: {
path?: string;
source_id?: string | number;
meta_poster?: string;
poster?: string;
thumb?: string;
stream_url?: string;
}): string => {
const isPlaceholderPoster = (url: string): boolean => {
const u = String(url || "").toLowerCase();
if (!u) return true;
return (
u.includes("no_poster") ||
u.includes("no-image") ||
u.includes("no_image") ||
u.includes("placeholder") ||
u.endsWith("/no_poster.png") ||
u.endsWith("/no_poster.svg")
);
};
let poster =
String(item.meta_poster || "").trim() ||
String(item.poster || "").trim() ||
String(item.thumb || "").trim();
if (poster && !isPlaceholderPoster(poster)) {
try {
if (poster.startsWith("//")) return `https:${poster}`;
if (poster.startsWith("/")) {
if (!env.GDS_DVIEWER_BASE_URL) return poster;
const base = new URL(env.GDS_DVIEWER_BASE_URL);
return `${base.origin}${poster}`;
}
return poster;
} catch {
return poster;
}
}
// Fallback 1: path 기반 thumbnail endpoint 사용 (가장 안정적)
try {
const base = String(env.GDS_DVIEWER_BASE_URL || "").trim();
if (base && item.path) {
const bu = new URL(base);
const pkg = (bu.pathname.split("/").filter(Boolean)[0] || "gds_dviewer").trim();
const t = new URL(`${bu.origin}/${pkg}/normal/thumbnail`);
t.searchParams.set("path", String(item.path || ""));
const sid = String(item.source_id ?? env.GDS_DVIEWER_SOURCE_ID ?? "0").trim();
if (sid) t.searchParams.set("source_id", sid);
let apikey = String(env.GDS_DVIEWER_API_KEY || "").trim();
if (!apikey && item.stream_url) {
try {
const su = new URL(item.stream_url);
apikey = String(su.searchParams.get("apikey") || "").trim();
} catch {}
}
if (apikey) t.searchParams.set("apikey", apikey);
t.searchParams.set("w", "400");
return t.toString();
}
} catch {}
// Fallback: stream_url의 bpath로 thumbnail URL 생성
try {
if (!item.stream_url) return "";
const u = new URL(item.stream_url);
const bpath = u.searchParams.get("bpath");
if (!bpath) return "";
const thumb = new URL(u.toString());
thumb.pathname = thumb.pathname.replace(/\/stream$/, "/thumbnail");
thumb.search = "";
thumb.searchParams.set("bpath", bpath);
const sourceId = u.searchParams.get("source_id") || env.GDS_DVIEWER_SOURCE_ID;
if (sourceId) thumb.searchParams.set("source_id", sourceId);
const apikey = u.searchParams.get("apikey") || env.GDS_DVIEWER_API_KEY || "";
if (apikey) thumb.searchParams.set("apikey", apikey);
return thumb.toString();
} catch {
return "";
}
};
const getEpisode = (text: string): string => {
const src = String(text || "");
const patterns = [
/(?:^|[\s._-])(E\d{1,4})(?:$|[\s._-])/i,
/(EP\.?\s*\d{1,4})/i,
/(제\s*\d{1,4}\s*화)/i,
/(\d{1,4}\s*화)/i,
/(\bS\d+\s*E\d+\b)/i,
];
for (const p of patterns) {
const m = src.match(p);
if (m?.[1]) return m[1].replace(/\s+/g, " ").trim().toUpperCase();
}
return "-";
};
const getCleanTitle = (name: string): string => {
let t = String(name || "").replace(/\.[a-z0-9]{2,5}$/i, "");
t = t.replace(/(EP\.?\s*\d{1,4}|제\s*\d{1,4}\s*화|\d{1,4}\s*화|\bS\d+\s*E\d+\b)/gi, "");
t = t.replace(/[\[\(\{].*?[\]\)\}]/g, " ");
t = t.replace(/\s+/g, " ").trim();
return t || String(name || "제목 없음");
};
const summaryEmbed = new EmbedBuilder()
.setColor(0x4f46e5)
.setTitle(isMovie ? "영화 검색 결과" : "ANI 검색 결과")
.setDescription(`query: \`${trim(query, 80)}\`\n총 ${list.length}`)
.setFooter({ text: "gds_dviewer search" })
.setTimestamp(new Date());
const itemEmbeds = list.slice(0, 3).map((item, idx) => {
const poster = resolvePosterUrl(item);
const ep = getEpisode(`${item.name} ${item.path}`);
const cleanTitle = getCleanTitle(item.name || "");
const descLines = [`제목: **${trim(cleanTitle, 100)}**`, `회차: **${ep}**`];
const modalLink = item.stream_url ? buildExternalVideoModalLink(item) : "";
if (modalLink) {
descLines.push(`재생: [외부 플레이어 열기](${modalLink})`);
} else if (item.stream_url) {
descLines.push("외부 플레이어 링크 설정 필요");
} else {
descLines.push("재생 링크 없음");
}
const e = new EmbedBuilder()
.setColor(0x1d4ed8)
.setTitle(trim(`${idx + 1}`, 32))
.setDescription(trim(descLines.join("\n"), 1200));
if (poster) {
e.setThumbnail(poster);
}
(e as any).__modalLink = modalLink;
return e;
});
await message.reply({ embeds: [summaryEmbed, ...itemEmbeds] });
return;
}
} catch (error) {
logger.error({ error, command, from: "prefix" }, "Prefix command execution failed");
await message.reply(
`실행 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`,
);
}
});
startNotifyWorker(async (job) => {
const channel = await client.channels.fetch(job.channelId);
if (!channel || channel.type !== ChannelType.GuildText) return;
await (channel as TextChannel).send(job.message);
});
return {
client,
async start() {
await client.login(env.DISCORD_TOKEN);
},
};
}

View File

@@ -0,0 +1,21 @@
import { REST, Routes } from "discord.js";
import { env } from "../core/env.js";
import { logger } from "../core/logger.js";
import { commands } from "../commands/index.js";
export async function registerCommands() {
const rest = new REST({ version: "10" }).setToken(env.DISCORD_TOKEN);
const body = commands.map((c) => c.data.toJSON());
if (env.DISCORD_GUILD_ID) {
await rest.put(
Routes.applicationGuildCommands(env.DISCORD_CLIENT_ID, env.DISCORD_GUILD_ID),
{ body },
);
logger.info({ guildId: env.DISCORD_GUILD_ID, count: body.length }, "Guild commands registered");
return;
}
await rest.put(Routes.applicationCommands(env.DISCORD_CLIENT_ID), { body });
logger.info({ count: body.length }, "Global commands registered");
}

14
src/discord/types.ts Normal file
View File

@@ -0,0 +1,14 @@
import type {
ChatInputCommandInteraction,
RESTPostAPIChatInputApplicationCommandsJSONBody,
} from "discord.js";
export type SlashLikeBuilder = {
name: string;
toJSON: () => RESTPostAPIChatInputApplicationCommandsJSONBody;
};
export interface CommandModule {
data: SlashLikeBuilder;
execute: (interaction: ChatInputCommandInteraction) => Promise<void>;
}

26
src/index.ts Normal file
View File

@@ -0,0 +1,26 @@
import { prisma } from "./db/prisma.js";
import { logger } from "./core/logger.js";
import { registerCommands } from "./discord/register-commands.js";
import { createBotClient } from "./discord/client.js";
async function bootstrap() {
await prisma.$connect();
await registerCommands();
const bot = createBotClient();
await bot.start();
}
bootstrap().catch(async (error) => {
logger.error({ error }, "Fatal bootstrap error");
try {
await prisma.$disconnect();
} catch {
// noop
}
process.exit(1);
});
process.on("SIGINT", async () => {
await prisma.$disconnect();
process.exit(0);
});

39
src/queue/notify-queue.ts Normal file
View File

@@ -0,0 +1,39 @@
import { Queue, Worker, type JobsOptions } from "bullmq";
import { env } from "../core/env.js";
import { logger } from "../core/logger.js";
export type NotifyJob = {
guildId: string;
channelId: string;
message: string;
};
const hasRedis = Boolean(env.REDIS_URL);
const connection = hasRedis ? { url: env.REDIS_URL! } : null;
export const notifyQueue = hasRedis
? new Queue<NotifyJob, void, "notify">("notify-queue", { connection: connection! })
: null;
export const addNotifyJob = async (payload: NotifyJob, opts?: JobsOptions) => {
if (!notifyQueue) return false;
await notifyQueue.add("notify", payload, opts);
return true;
};
export const startNotifyWorker = (
handler: (job: NotifyJob) => Promise<void>,
): Worker<NotifyJob, void, "notify"> | null => {
if (!connection) {
logger.warn("REDIS_URL not set. BullMQ worker disabled.");
return null;
}
return new Worker<NotifyJob, void, "notify">(
"notify-queue",
async (job) => {
await handler(job.data);
},
{ connection },
);
};

View File

@@ -0,0 +1,156 @@
import { env } from "../../core/env.js";
type GdsAniItem = {
name: string;
path: string;
stream_url?: string;
mtime?: string;
is_dir?: boolean;
meta_poster?: string;
poster?: string;
thumb?: string;
};
type GdsAniSearchResponse = {
ret?: string;
count?: number;
list?: GdsAniItem[];
};
function normalizeBaseUrl(baseUrl: string): string {
return baseUrl.replace(/\/+$/, "");
}
function stripOuterQuotes(input: string): string {
const t = String(input || "").trim();
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
return t.slice(1, -1).trim();
}
return t;
}
function normalizeForMatch(input: string): string {
return String(input || "")
.toLowerCase()
.replace(/[\[\]\(\)\{\}\-_.:]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function scoreItem(query: string, item: GdsAniItem): number {
const q = normalizeForMatch(query);
const name = normalizeForMatch(item.name || "");
const path = normalizeForMatch(item.path || "");
const compactQ = q.replace(/\s+/g, "");
const compactName = name.replace(/\s+/g, "");
const compactPath = path.replace(/\s+/g, "");
const tokens = q.split(" ").filter(Boolean);
let score = 0;
if (compactQ && compactName.includes(compactQ)) score += 120;
if (compactQ && compactPath.includes(compactQ)) score += 60;
for (const t of tokens) {
if (name.includes(t)) score += 18;
if (path.includes(t)) score += 7;
}
if (!item.is_dir) score += 5;
return score;
}
async function fetchSearch(
base: string,
apiKey: string,
query: string,
sourceId: string,
limit: number,
parentPath: string | null,
isDir: boolean,
): Promise<GdsAniItem[]> {
const url = new URL(`${base}/search`);
url.searchParams.set("apikey", apiKey);
url.searchParams.set("query", query);
if (parentPath) {
url.searchParams.set("parent_path", parentPath);
}
url.searchParams.set("recursive", "true");
url.searchParams.set("limit", String(limit));
url.searchParams.set("offset", "0");
url.searchParams.set("is_dir", isDir ? "true" : "false");
// 제목이 path에 있을 수 있어 true로 둔다.
url.searchParams.set("search_in_path", "true");
url.searchParams.set("sort_by", "date");
url.searchParams.set("sort_order", "desc");
url.searchParams.set("video_only", "true");
url.searchParams.set("source_id", sourceId);
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 12000);
try {
const response = await fetch(url.toString(), { signal: ctrl.signal });
if (!response.ok) {
return [];
}
const payload = (await response.json()) as GdsAniSearchResponse;
if (payload?.ret !== "success") {
return [];
}
const list = Array.isArray(payload.list) ? payload.list : [];
return list.filter((item) => Boolean(item?.path));
} finally {
clearTimeout(timer);
}
}
export const gdsAnimeService = {
async searchByTitle(rawQuery: string, limit = 5): Promise<GdsAniItem[]> {
const query = stripOuterQuotes(rawQuery);
if (!query) throw new Error("검색어가 비었습니다.");
if (!env.GDS_DVIEWER_BASE_URL || !env.GDS_DVIEWER_API_KEY) {
throw new Error("GDS_DVIEWER_BASE_URL / GDS_DVIEWER_API_KEY 설정이 필요합니다.");
}
const base = normalizeBaseUrl(env.GDS_DVIEWER_BASE_URL);
const apiKey = env.GDS_DVIEWER_API_KEY;
const max = Math.max(1, Math.min(limit, 10));
const fetchLimit = Math.max(20, Math.min(100, max * 8));
const parentCandidates: Array<string | null> = [
"VIDEO/애니메이션",
"VIDEO/일본 애니메이션",
"VIDEO/방송중/라프텔 애니메이션",
"VIDEO/방송중/OTT 애니메이션",
"VIDEO",
null,
];
const seen = new Set<string>();
const merged: GdsAniItem[] = [];
for (const parentPath of parentCandidates) {
for (const isDir of [false, true]) {
const rows = await fetchSearch(
base,
apiKey,
query,
env.GDS_DVIEWER_SOURCE_ID,
fetchLimit,
parentPath,
isDir,
);
for (const row of rows) {
if (!seen.has(row.path)) {
seen.add(row.path);
merged.push(row);
}
}
}
}
return merged
.map((item) => ({ item, score: scoreItem(query, item) }))
.sort((a, b) => b.score - a.score)
.filter((row) => row.score > 0)
.slice(0, max)
.map((row) => row.item);
},
};
export type { GdsAniItem };

View File

@@ -0,0 +1,149 @@
import { env } from "../../core/env.js";
type GdsMovieItem = {
name: string;
path: string;
stream_url?: string;
mtime?: string;
is_dir?: boolean;
source_id?: string | number;
meta_poster?: string;
poster?: string;
thumb?: string;
};
type GdsMovieSearchResponse = {
ret?: string;
count?: number;
list?: GdsMovieItem[];
};
function normalizeBaseUrl(baseUrl: string): string {
return baseUrl.replace(/\/+$/, "");
}
function stripOuterQuotes(input: string): string {
const t = String(input || "").trim();
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
return t.slice(1, -1).trim();
}
return t;
}
function normalizeForMatch(input: string): string {
return String(input || "")
.toLowerCase()
.replace(/[\[\]\(\)\{\}\-_.:]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function scoreItem(query: string, item: GdsMovieItem): number {
const q = normalizeForMatch(query);
const name = normalizeForMatch(item.name || "");
const path = normalizeForMatch(item.path || "");
const compactQ = q.replace(/\s+/g, "");
const compactName = name.replace(/\s+/g, "");
const compactPath = path.replace(/\s+/g, "");
const tokens = q.split(" ").filter(Boolean);
let score = 0;
if (compactQ && compactName.includes(compactQ)) score += 120;
if (compactQ && compactPath.includes(compactQ)) score += 60;
for (const t of tokens) {
if (name.includes(t)) score += 18;
if (path.includes(t)) score += 7;
}
if (!item.is_dir) score += 5;
return score;
}
async function fetchSearch(
base: string,
apiKey: string,
query: string,
sourceId: string,
limit: number,
parentPath: string | null,
isDir: boolean,
): Promise<GdsMovieItem[]> {
const url = new URL(`${base}/search`);
url.searchParams.set("apikey", apiKey);
url.searchParams.set("query", query);
if (parentPath) url.searchParams.set("parent_path", parentPath);
url.searchParams.set("recursive", "true");
url.searchParams.set("limit", String(limit));
url.searchParams.set("offset", "0");
url.searchParams.set("is_dir", isDir ? "true" : "false");
url.searchParams.set("search_in_path", "true");
url.searchParams.set("sort_by", "date");
url.searchParams.set("sort_order", "desc");
url.searchParams.set("video_only", "true");
url.searchParams.set("source_id", sourceId);
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 12000);
try {
const response = await fetch(url.toString(), { signal: ctrl.signal });
if (!response.ok) return [];
const payload = (await response.json()) as GdsMovieSearchResponse;
if (payload?.ret !== "success") return [];
const list = Array.isArray(payload.list) ? payload.list : [];
return list.filter((item) => Boolean(item?.path));
} finally {
clearTimeout(timer);
}
}
export const gdsMovieService = {
async searchByTitle(rawQuery: string, limit = 5): Promise<GdsMovieItem[]> {
const query = stripOuterQuotes(rawQuery);
if (!query) throw new Error("검색어가 비었습니다.");
if (!env.GDS_DVIEWER_BASE_URL || !env.GDS_DVIEWER_API_KEY) {
throw new Error("GDS_DVIEWER_BASE_URL / GDS_DVIEWER_API_KEY 설정이 필요합니다.");
}
const base = normalizeBaseUrl(env.GDS_DVIEWER_BASE_URL);
const apiKey = env.GDS_DVIEWER_API_KEY;
const max = Math.max(1, Math.min(limit, 10));
const fetchLimit = Math.max(20, Math.min(100, max * 8));
const parentCandidates: Array<string | null> = [
"VIDEO/영화",
"VIDEO/영화/최신",
"VIDEO/영화/제목",
"VIDEO/영화/UHD",
"VIDEO",
null,
];
const seen = new Set<string>();
const merged: GdsMovieItem[] = [];
for (const parentPath of parentCandidates) {
for (const isDir of [false, true]) {
const rows = await fetchSearch(
base,
apiKey,
query,
env.GDS_DVIEWER_SOURCE_ID,
fetchLimit,
parentPath,
isDir,
);
for (const row of rows) {
if (!seen.has(row.path)) {
seen.add(row.path);
merged.push(row);
}
}
}
}
return merged
.map((item) => ({ item, score: scoreItem(query, item) }))
.sort((a, b) => b.score - a.score)
.filter((row) => row.score > 0)
.slice(0, max)
.map((row) => row.item);
},
};
export type { GdsMovieItem };

View File

@@ -0,0 +1,20 @@
import { prisma } from "../../db/prisma.js";
export const moderationService = {
async warnUser(input: {
guildId: string;
userId: string;
moderatorId: string;
reason: string;
}) {
return prisma.warning.create({ data: input });
},
async getWarnings(guildId: string, userId: string) {
return prisma.warning.findMany({
where: { guildId, userId },
orderBy: { createdAt: "desc" },
take: 10,
});
},
};

View File

@@ -0,0 +1,222 @@
import type { Client, CommandInteraction, GuildMember, Message } from "discord.js";
import { Connectors, LoadType, Shoukaku, type Player, type Track } from "shoukaku";
import { env } from "../../core/env.js";
import { logger } from "../../core/logger.js";
type QueueItem = {
track: Track;
requestedBy: string;
};
type GuildState = {
queue: QueueItem[];
textChannelId: string | null;
listenersBound: boolean;
};
class MusicPlayerService {
private shoukaku: Shoukaku | null = null;
private readonly guildState = new Map<string, GuildState>();
init(client: Client): void {
if (this.shoukaku) return;
this.shoukaku = new Shoukaku(
new Connectors.DiscordJS(client),
[
{
name: env.LAVALINK_NODE_NAME,
url: `${env.LAVALINK_HOST}:${env.LAVALINK_PORT}`,
auth: env.LAVALINK_PASSWORD,
secure: env.LAVALINK_SECURE,
},
],
{
resume: true,
resumeTimeout: 60,
reconnectTries: 999,
reconnectInterval: 5,
moveOnDisconnect: true,
},
);
this.shoukaku.on("ready", (name, reconnected) => {
logger.info({ name, reconnected }, "Lavalink node ready");
});
this.shoukaku.on("error", (name, error) => {
logger.error({ name, error }, "Lavalink node error");
});
this.shoukaku.on("disconnect", (name, count) => {
logger.warn({ name, count }, "Lavalink node disconnected");
});
}
private getState(guildId: string): GuildState {
const existing = this.guildState.get(guildId);
if (existing) return existing;
const created: GuildState = { queue: [], textChannelId: null, listenersBound: false };
this.guildState.set(guildId, created);
return created;
}
private getNode() {
const node = this.shoukaku?.getIdealNode();
if (!node) throw new Error("Lavalink node is not ready. Check lavalink server status.");
return node;
}
private bindPlayerEvents(player: Player, guildId: string): void {
const state = this.getState(guildId);
if (state.listenersBound) return;
player.on("end", async (event) => {
if (["finished", "loadFailed", "stopped"].includes(event.reason)) {
await this.playNext(guildId).catch((error) =>
logger.error({ guildId, error }, "Failed to play next track after end"),
);
}
});
player.on("exception", (event) => {
logger.error({ guildId, event }, "Track exception");
});
state.listenersBound = true;
}
private async ensurePlayer(member: GuildMember): Promise<Player> {
if (!this.shoukaku) throw new Error("Music service is not initialized.");
if (!member.voice.channelId) {
throw new Error("먼저 음성 채널에 입장해주세요.");
}
const guildId = member.guild.id;
// Always re-issue voice join to refresh session payload after reconnects.
const player = await this.shoukaku.joinVoiceChannel({
guildId,
channelId: member.voice.channelId,
shardId: member.guild.shardId ?? 0,
deaf: true,
mute: false,
});
await player.setPaused(false);
await player.setGlobalVolume(100);
this.bindPlayerEvents(player, guildId);
return player;
}
private async searchTrack(query: string): Promise<Track[]> {
const node = this.getNode();
const identifier = /^https?:\/\//i.test(query) ? query : `ytsearch:${query}`;
const result = await node.rest.resolve(identifier);
if (!result) return [];
if (result.loadType === LoadType.TRACK) return [result.data];
if (result.loadType === LoadType.SEARCH) return result.data;
if (result.loadType === LoadType.PLAYLIST) return result.data.tracks;
return [];
}
private async playNext(guildId: string): Promise<void> {
if (!this.shoukaku) return;
const player = this.shoukaku.players.get(guildId);
if (!player) return;
const state = this.getState(guildId);
const next = state.queue.shift();
if (!next) return;
await player.playTrack({ track: { encoded: next.track.encoded } });
}
private async enqueueCore(input: {
guildId: string;
channelId: string;
userId: string;
member: GuildMember;
query: string;
}): Promise<string> {
const player = await this.ensurePlayer(input.member);
const tracks = await this.searchTrack(input.query);
if (tracks.length === 0) {
throw new Error("재생 가능한 트랙을 찾지 못했습니다.");
}
const state = this.getState(input.guildId);
state.textChannelId = input.channelId;
const queueItems: QueueItem[] = tracks.map((track) => ({ track, requestedBy: input.userId }));
if (!player.track) {
const [first, ...rest] = queueItems;
await player.playTrack({ track: { encoded: first.track.encoded } });
state.queue.push(...rest);
if (tracks.length > 1) {
return `재생 시작: **${first.track.info.title}** (추가 ${tracks.length - 1}곡)`;
}
return `재생 시작: **${first.track.info.title}**`;
}
state.queue.push(...queueItems);
return `큐에 추가됨: **${tracks[0].info.title}** (대기열 ${state.queue.length}개)`;
}
async enqueueFromInteraction(interaction: CommandInteraction, query: string): Promise<string> {
if (!interaction.guildId || !interaction.guild || !interaction.channelId) {
throw new Error("길드 채널에서만 사용할 수 있습니다.");
}
const member = interaction.member as GuildMember;
return this.enqueueCore({
guildId: interaction.guildId,
channelId: interaction.channelId,
userId: interaction.user.id,
member,
query,
});
}
async enqueueFromMessage(message: Message, query: string): Promise<string> {
if (!message.guildId || !message.guild || !message.channelId) {
throw new Error("길드 채널에서만 사용할 수 있습니다.");
}
const member = message.member ?? (await message.guild.members.fetch(message.author.id));
return this.enqueueCore({
guildId: message.guildId,
channelId: message.channelId,
userId: message.author.id,
member,
query,
});
}
async skip(guildId: string): Promise<string> {
if (!this.shoukaku) throw new Error("Music service is not initialized.");
const player = this.shoukaku.players.get(guildId);
if (!player || !player.track) throw new Error("스킵할 트랙이 없습니다.");
await player.stopTrack();
return "현재 트랙을 스킵했습니다.";
}
async stop(guildId: string): Promise<string> {
if (!this.shoukaku) throw new Error("Music service is not initialized.");
const player = this.shoukaku.players.get(guildId);
if (!player) throw new Error("활성 플레이어가 없습니다.");
const state = this.getState(guildId);
state.queue = [];
await player.stopTrack();
await this.shoukaku.leaveVoiceChannel(guildId);
return "재생을 중지하고 음성 채널에서 나갔습니다.";
}
list(guildId: string): string[] {
const state = this.getState(guildId);
return state.queue.map((q, i) => `${i + 1}. ${q.track.info.title}`);
}
}
export const musicPlayer = new MusicPlayerService();

View File

@@ -0,0 +1,93 @@
import { XMLParser } from "fast-xml-parser";
type NewsItem = {
title: string;
link: string;
pubDate?: string;
};
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "",
trimValues: true,
});
function toArray<T>(value: T | T[] | undefined): T[] {
if (!value) return [];
return Array.isArray(value) ? value : [value];
}
function decodeGoogleNewsLink(link: string): string {
return String(link || "").trim();
}
function escapeTitleForMarkdown(text: string): string {
return String(text || "")
.replace(/\\/g, "\\\\")
.replace(/\[/g, "\\[")
.replace(/\]/g, "\\]")
.replace(/\(/g, "\\(")
.replace(/\)/g, "\\)")
.trim();
}
export const newsService = {
async fetchGoogleNews(keyword?: string, limit = 10): Promise<NewsItem[]> {
const q = String(keyword || "").trim();
const endpoint = q
? `https://news.google.com/rss/search?q=${encodeURIComponent(q)}&hl=ko&gl=KR&ceid=KR:ko`
: "https://news.google.com/rss?hl=ko&gl=KR&ceid=KR:ko";
const response = await fetch(endpoint, {
headers: {
"User-Agent": "discord-multibot-news/0.1",
Accept: "application/rss+xml, application/xml, text/xml;q=0.9, */*;q=0.8",
},
});
if (!response.ok) {
throw new Error(`뉴스 요청 실패: HTTP ${response.status}`);
}
const xml = await response.text();
const parsed = parser.parse(xml);
const channel = parsed?.rss?.channel;
const items = toArray(channel?.item);
return items
.map((item: any) => ({
title: String(item?.title || "").trim(),
link: decodeGoogleNewsLink(String(item?.link || "").trim()),
pubDate: String(item?.pubDate || "").trim(),
}))
.filter((n) => n.title && n.link)
.slice(0, Math.max(1, Math.min(limit, 10)));
},
toDiscordMessageChunks(title: string, items: NewsItem[], maxLen = 1900): string[] {
const safeMax = Math.max(800, Math.min(maxLen, 1990));
if (!items.length) return [`**${title}**\n표시할 뉴스가 없습니다.`];
const lines = items.map((n, i) => {
const compactTitle =
n.title.length > 120 ? `${n.title.slice(0, 117).trim()}...` : n.title;
// Wrap URL with <> to keep clickable link while preventing auto-embed cards.
return `${i + 1}. [${escapeTitleForMarkdown(compactTitle)}](<${n.link}>)`;
});
const chunks: string[] = [];
let current = `**${title}**\n`;
for (const line of lines) {
const next = `${current}${line}\n`;
if (next.length > safeMax) {
chunks.push(current.trimEnd());
current = `${line}\n`;
} else {
current = next;
}
}
if (current.trim()) chunks.push(current.trimEnd());
return chunks;
},
};

View File

@@ -0,0 +1,27 @@
import { prisma } from "../../db/prisma.js";
export const notifyService = {
async createRule(input: {
guildId: string;
channelId: string;
cronExpr: string;
message: string;
}) {
return prisma.notificationRule.create({ data: input });
},
async listRules(guildId: string) {
return prisma.notificationRule.findMany({
where: { guildId, enabled: true },
orderBy: { createdAt: "desc" },
take: 20,
});
},
async disableRule(id: string, guildId: string) {
return prisma.notificationRule.update({
where: { id },
data: { enabled: false },
});
},
};

View File

@@ -0,0 +1,278 @@
import { load } from "cheerio";
import { env } from "../../core/env.js";
import { logger } from "../../core/logger.js";
type SummarizeMode = "auto" | "fast" | "quality";
type SummarizeEngine = "ai" | "basic";
type SummarizeResult = {
summary: string;
model: string;
title: string;
sourceUrl: string;
inputChars: number;
callNo: number;
};
const stats = {
totalCalls: 0,
modelCalls: new Map<string, number>(),
basicCalls: 0,
};
function chooseModel(mode: SummarizeMode, inputChars: number): string {
if (mode === "fast") return env.GEMINI_FLASH_MODEL;
if (mode === "quality") return env.GEMINI_PRO_MODEL;
return inputChars > 8000 ? env.GEMINI_PRO_MODEL : env.GEMINI_FLASH_MODEL;
}
function cleanWhitespace(text: string): string {
return String(text || "").replace(/\s+/g, " ").trim();
}
function extractMainText(html: string): { title: string; text: string } {
const $ = load(html);
$("script,style,noscript,iframe,svg,header,footer,nav,aside,form").remove();
const title = cleanWhitespace($("title").first().text()) || "Untitled";
const collect = (selector: string) =>
$(selector)
.map((_, el) => cleanWhitespace($(el).text()))
.get()
.filter((t) => t.length >= 15);
const articleLines = [...collect("article p"), ...collect("article li")];
const mainLines = [...collect("main p"), ...collect("main li")];
const bodyLines = [...collect("p"), ...collect("li")];
const lines = articleLines.length
? articleLines
: mainLines.length
? mainLines
: bodyLines;
const uniqLines = Array.from(new Set(lines));
let text = uniqLines.join("\n");
if (!text) {
text = cleanWhitespace($("body").text());
}
text = text.slice(0, 20000);
return { title, text };
}
function buildBasicSummary(text: string): string {
const lines = text
.split("\n")
.map((line) => cleanWhitespace(line))
.filter((line) => line.length >= 20);
if (lines.length === 0) {
return "본문에서 유의미한 문장을 찾지 못했습니다.";
}
const uniqLines = Array.from(new Set(lines)).slice(0, 200);
const stopwords = new Set([
"그리고",
"그러나",
"또한",
"에서",
"으로",
"이다",
"있다",
"합니다",
"the",
"and",
"for",
"with",
"that",
"this",
"from",
]);
const tokenize = (input: string): string[] =>
input
.toLowerCase()
.replace(/[^\p{L}\p{N}\s]/gu, " ")
.split(/\s+/)
.filter((tok) => tok.length >= 2 && !stopwords.has(tok));
const freq = new Map<string, number>();
for (const line of uniqLines) {
for (const token of tokenize(line)) {
freq.set(token, (freq.get(token) || 0) + 1);
}
}
const scored = uniqLines.map((line, idx) => {
const tokens = tokenize(line);
const tokenScore =
tokens.length === 0
? 0
: tokens.reduce((sum, tok) => sum + (freq.get(tok) || 0), 0) / tokens.length;
const lengthBonus = Math.min(line.length / 180, 1);
return { line, idx, score: tokenScore + lengthBonus };
});
const top = scored
.sort((a, b) => b.score - a.score)
.slice(0, 5)
.sort((a, b) => a.idx - b.idx)
.map((item) => item.line);
const bullets = top.map((line) => `- ${line}`).join("\n");
const oneLine = top[0] || uniqLines[0];
return `${bullets}\n한줄요약: ${oneLine}`;
}
async function fetchHtml(url: string): Promise<string> {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 12000);
try {
const response = await fetch(url, {
signal: ctrl.signal,
headers: {
"User-Agent": "discord-multibot-summarizer/0.1",
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
},
});
if (!response.ok) {
throw new Error(`본문 요청 실패: HTTP ${response.status}`);
}
return await response.text();
} finally {
clearTimeout(timer);
}
}
async function callGemini(model: string, prompt: string, maxOutputTokens = 1200): Promise<string> {
if (!env.GEMINI_API_KEY) {
throw new Error("GEMINI_API_KEY가 설정되지 않았습니다.");
}
const endpoint = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(env.GEMINI_API_KEY)}`;
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: {
temperature: 0.2,
maxOutputTokens,
},
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Gemini 요청 실패: HTTP ${response.status} ${text.slice(0, 180)}`);
}
const data = (await response.json()) as any;
const text = data?.candidates?.[0]?.content?.parts
?.map((p: any) => String(p?.text || ""))
.join("\n")
.trim();
if (!text) {
throw new Error("Gemini 응답에서 요약 텍스트를 찾지 못했습니다.");
}
return text;
}
function looksTruncated(text: string): boolean {
const t = String(text || "").trim();
if (!t) return true;
if (!t.includes("한줄요약:")) return true;
const ending = t.slice(-1);
return ![".", "!", "?", "”", "\"", "다"].includes(ending);
}
export const summarizeService = {
async summarizeUrl(
url: string,
mode: SummarizeMode = "auto",
engine: SummarizeEngine = "ai",
): Promise<SummarizeResult> {
const cleanUrl = String(url || "").trim();
if (!/^https?:\/\//i.test(cleanUrl)) {
throw new Error("http/https URL만 요약할 수 있습니다.");
}
const html = await fetchHtml(cleanUrl);
const { title, text } = extractMainText(html);
if (!text || text.length < 80) {
throw new Error("요약할 본문을 충분히 추출하지 못했습니다.");
}
if (engine === "basic") {
const summary = buildBasicSummary(text);
stats.basicCalls += 1;
logger.info(
{
type: "summarize-basic",
callNo: stats.basicCalls,
model: "rule-based-v1",
inputChars: text.length,
url: cleanUrl,
},
"Summarize BASIC call",
);
return {
summary,
model: "rule-based-v1",
title,
sourceUrl: cleanUrl,
inputChars: text.length,
callNo: stats.basicCalls,
};
}
const model = chooseModel(mode, text.length);
const prompt = [
"다음 웹페이지 본문을 한국어로 요약하라.",
"요구사항:",
"1) 핵심 요점 5개 이내 불릿",
"2) 사실 위주, 과장 금지",
"3) 마지막 줄에 '한줄요약:' 추가",
"4) 전체 1200자 이내",
"",
`[제목] ${title}`,
"[본문]",
text,
].join("\n");
let summary = await callGemini(model, prompt, 1400);
if (looksTruncated(summary)) {
const retryPrompt = `${prompt}\n\n주의: 응답이 중간에 끊기지 않게 완결된 문장으로 끝내고 반드시 '한줄요약:' 줄을 포함하세요.`;
summary = await callGemini(model, retryPrompt, 1800);
}
stats.totalCalls += 1;
const prev = stats.modelCalls.get(model) || 0;
stats.modelCalls.set(model, prev + 1);
logger.info(
{
type: "summarize",
callNo: stats.totalCalls,
model,
modelCalls: stats.modelCalls.get(model),
inputChars: text.length,
url: cleanUrl,
},
"Summarize API call",
);
return {
summary,
model,
title,
sourceUrl: cleanUrl,
inputChars: text.length,
callNo: stats.totalCalls,
};
},
};
export type { SummarizeEngine, SummarizeMode, SummarizeResult };

View File

@@ -0,0 +1,226 @@
import { env } from "../../core/env.js";
import { logger } from "../../core/logger.js";
import { spawn } from "node:child_process";
type TranslateResult = {
translatedText: string;
engine: "deepl" | "deepl_web_cli" | "google_web_v2";
inputChars: number;
chunkCount: number;
};
const MAX_INPUT_CHARS = 12000;
const CHUNK_SIZE = 1200;
function normalizeText(input: string): string {
return String(input || "").trim();
}
function toErrorInfo(error: unknown): { message: string; stack?: string } {
if (error instanceof Error) {
return { message: error.message, stack: error.stack };
}
return { message: String(error) };
}
function splitText(text: string, maxLen: number): string[] {
const out: string[] = [];
let rest = text;
while (rest.length > maxLen) {
const cut = Math.max(
rest.lastIndexOf("\n", maxLen),
rest.lastIndexOf(". ", maxLen),
rest.lastIndexOf("! ", maxLen),
rest.lastIndexOf("? ", maxLen),
);
const idx = cut > 200 ? cut + 1 : maxLen;
out.push(rest.slice(0, idx).trim());
rest = rest.slice(idx).trim();
}
if (rest) out.push(rest);
return out;
}
async function translateGoogleWebV2(text: string, source: string, target: string): Promise<string> {
const endpoint = new URL("https://translate.google.com/translate_a/single");
endpoint.searchParams.set("q", text);
endpoint.searchParams.set("sl", source);
endpoint.searchParams.set("tl", target);
endpoint.searchParams.set("hl", "ko-KR");
endpoint.searchParams.set("ie", "UTF-8");
endpoint.searchParams.set("oe", "UTF-8");
endpoint.searchParams.set("client", "at");
for (const dt of ["t", "ld", "qca", "rm", "bd", "md", "ss", "ex", "sos"]) {
endpoint.searchParams.append("dt", dt);
}
const response = await fetch(endpoint.toString(), {
headers: {
"User-Agent": "GoogleTranslate/6.27.0.08.415126308 (Linux; Android 7.1.2; Pixel 2 XL)",
Accept: "application/json,text/plain,*/*",
},
});
if (!response.ok) {
throw new Error(`google_web_v2 실패: HTTP ${response.status}`);
}
const data = (await response.json()) as any;
const parts = Array.isArray(data?.[0]) ? data[0] : [];
const translated = parts
.map((item: any) => String(item?.[0] || ""))
.join("")
.trim();
if (!translated) {
throw new Error("google_web_v2 응답 파싱 실패");
}
return translated;
}
async function translateDeepL(text: string, source: string, target: string): Promise<string> {
if (!env.DEEPL_API_KEY) {
throw new Error("DEEPL_API_KEY 없음");
}
const params = new URLSearchParams();
params.append("text", text);
params.append("target_lang", target.toUpperCase());
if (source.toLowerCase() !== "auto") {
params.append("source_lang", source.toUpperCase());
}
const response = await fetch("https://api-free.deepl.com/v2/translate", {
method: "POST",
headers: {
Authorization: `DeepL-Auth-Key ${env.DEEPL_API_KEY}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: params.toString(),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`deepl 실패: HTTP ${response.status} ${body.slice(0, 120)}`);
}
const data = (await response.json()) as any;
const translated = String(data?.translations?.[0]?.text || "").trim();
if (!translated) {
throw new Error("deepl 응답 파싱 실패");
}
return translated;
}
async function runCliWithStdin(
bin: string,
args: string[],
input: string,
timeoutMs: number,
): Promise<string> {
return await new Promise((resolve, reject) => {
const child = spawn(bin, args, {
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let killedByTimeout = false;
const timer = setTimeout(() => {
killedByTimeout = true;
child.kill("SIGKILL");
}, timeoutMs);
child.stdout.on("data", (buf) => {
stdout += String(buf);
});
child.stderr.on("data", (buf) => {
stderr += String(buf);
});
child.on("error", (error) => {
clearTimeout(timer);
reject(error);
});
child.on("close", (code) => {
clearTimeout(timer);
if (killedByTimeout) {
reject(new Error(`deepl-cli timeout ${timeoutMs}ms`));
return;
}
if (code !== 0) {
reject(new Error(`deepl-cli exit=${code} ${stderr.slice(0, 160)}`));
return;
}
resolve(stdout.trim());
});
child.stdin.write(input);
child.stdin.end();
});
}
async function translateDeepLCli(text: string, source: string, target: string): Promise<string> {
if (source === "auto") {
throw new Error("deepl-cli는 source=auto를 지원하지 않습니다. source 언어를 명시하세요.");
}
const args = ["-s", "-F", source, "-T", target, "-t", String(Math.min(env.DEEPL_CLI_TIMEOUT_MS, 30000))];
const output = await runCliWithStdin(env.DEEPL_CLI_BIN, args, text, env.DEEPL_CLI_TIMEOUT_MS);
if (!output) {
throw new Error("deepl-cli 응답 비어있음");
}
return output;
}
export const translateService = {
getMaxInputChars() {
return MAX_INPUT_CHARS;
},
async translate(text: string, source = "auto", target = "ko"): Promise<TranslateResult> {
const input = normalizeText(text);
if (!input) throw new Error("번역할 텍스트가 비었습니다.");
if (input.length > MAX_INPUT_CHARS) {
throw new Error(`입력 길이 초과: ${input.length}자 (최대 ${MAX_INPUT_CHARS}자)`);
}
const sourceNorm = String(source || "auto").toLowerCase();
const targetNorm = String(target || "ko").toLowerCase();
const chunks = splitText(input, CHUNK_SIZE);
const engines: Array<{
name: "deepl" | "deepl_web_cli" | "google_web_v2";
fn: (chunk: string, source: string, target: string) => Promise<string>;
enabled: boolean;
}> = [
{ name: "deepl", fn: translateDeepL, enabled: Boolean(env.DEEPL_API_KEY) },
{ name: "deepl_web_cli", fn: translateDeepLCli, enabled: sourceNorm !== "auto" },
{ name: "google_web_v2", fn: translateGoogleWebV2, enabled: true },
];
for (const engine of engines) {
if (!engine.enabled) continue;
try {
const outputs: string[] = [];
for (const chunk of chunks) {
outputs.push(await engine.fn(chunk, sourceNorm, targetNorm));
}
return {
translatedText: outputs.join("\n").trim(),
engine: engine.name,
inputChars: input.length,
chunkCount: chunks.length,
};
} catch (error) {
const err = toErrorInfo(error);
logger.warn(
{ engine: engine.name, errorMessage: err.message, errorStack: err.stack },
"Translate engine failed, fallback next",
);
}
}
throw new Error("모든 번역 엔진이 실패했습니다.");
},
};
export type { TranslateResult };

16
tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}