feat: external video player flow and discord playback link improvements
This commit is contained in:
22
.env.example
Normal file
22
.env.example
Normal 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
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
135
README.md
Normal file
135
README.md
Normal 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 분리용, 권장)
|
||||||
10
docker-compose.lavalink.yml
Normal file
10
docker-compose.lavalink.yml
Normal 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
24
lavalink/application.yml
Normal 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
|
||||||
BIN
lavalink/plugins/youtube-plugin-1.17.0.jar
Normal file
BIN
lavalink/plugins/youtube-plugin-1.17.0.jar
Normal file
Binary file not shown.
2281
package-lock.json
generated
Normal file
2281
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
Normal file
36
package.json
Normal 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
40
prisma/schema.prisma
Normal 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
45
scripts/bump-patch.mjs
Normal 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
30
src/commands/index.ts
Normal 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
33
src/commands/info/news.ts
Normal 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 : "알 수 없는 오류"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
88
src/commands/info/summarize.ts
Normal file
88
src/commands/info/summarize.ts
Normal 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 : "알 수 없는 오류"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
38
src/commands/info/translate.ts
Normal file
38
src/commands/info/translate.ts
Normal 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 : "알 수 없는 오류"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
24
src/commands/manage/warn.ts
Normal file
24
src/commands/manage/warn.ts
Normal 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}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
21
src/commands/manage/warnings.ts
Normal file
21
src/commands/manage/warnings.ts
Normal 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}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
23
src/commands/music/play.ts
Normal file
23
src/commands/music/play.ts
Normal 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 : "알 수 없는 오류"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
17
src/commands/music/queue.ts
Normal file
17
src/commands/music/queue.ts
Normal 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")}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
19
src/commands/music/skip.ts
Normal file
19
src/commands/music/skip.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
19
src/commands/music/stop.ts
Normal file
19
src/commands/music/stop.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
16
src/commands/notify/disable.ts
Normal file
16
src/commands/notify/disable.ts
Normal 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}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
19
src/commands/notify/list.ts
Normal file
19
src/commands/notify/list.ts
Normal 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
25
src/commands/notify/schedule.ts
Normal file
25
src/commands/notify/schedule.ts
Normal 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
35
src/core/env.ts
Normal 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
13
src/core/logger.ts
Normal 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
3
src/db/prisma.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
export const prisma = new PrismaClient();
|
||||||
440
src/discord/client.ts
Normal file
440
src/discord/client.ts
Normal 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
21
src/discord/register-commands.ts
Normal file
21
src/discord/register-commands.ts
Normal 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
14
src/discord/types.ts
Normal 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
26
src/index.ts
Normal 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
39
src/queue/notify-queue.ts
Normal 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 },
|
||||||
|
);
|
||||||
|
};
|
||||||
156
src/services/gds/gds-anime-service.ts
Normal file
156
src/services/gds/gds-anime-service.ts
Normal 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 };
|
||||||
149
src/services/gds/gds-movie-service.ts
Normal file
149
src/services/gds/gds-movie-service.ts
Normal 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 };
|
||||||
20
src/services/moderation/moderation-service.ts
Normal file
20
src/services/moderation/moderation-service.ts
Normal 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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
222
src/services/music/music-player.ts
Normal file
222
src/services/music/music-player.ts
Normal 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();
|
||||||
93
src/services/news/news-service.ts
Normal file
93
src/services/news/news-service.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
27
src/services/notify/notify-service.ts
Normal file
27
src/services/notify/notify-service.ts
Normal 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 },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
278
src/services/summarize/summarize-service.ts
Normal file
278
src/services/summarize/summarize-service.ts
Normal 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 };
|
||||||
226
src/services/translate/translate-service.ts
Normal file
226
src/services/translate/translate-service.ts
Normal 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
16
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user