Files
music_bot_v2/bot/src/classes/RedisClient.ts
tkrmagid c75e42e10e feat(bot): 스포티파이 재생목록을 곡 정보만 즉시 대기열에 넣고 재생 직전에 유튜브로 변환
- 재생목록/앨범: 임베드 페이지 곡 목록(~0.4초)으로 변환 전 항목(spotify-lazy:<id>)을 바로 추가
  (기존: 곡마다 유튜브 매칭 후 5곡씩 이어 붙여 100곡에 ~12초)
- GuildPlayer.resolvePlayable 에서 스포티파이 곡을 재생 직전 유튜브 원곡으로 매칭(다음 곡은 프리페치)
- 매칭 결과 캐시(matchSpotifyTrack)로 예열·재생이 검색을 한 번만 하도록
- 원곡을 못 찾은 곡은 건너뜀, 재생목록 커버는 변환 시 곡 이미지로 교체
- '-s' 섞기를 resolveQuery로 옮겨 섞은 뒤 첫 곡을 예열
- queue_set: JSON으로 깨진 _resolved({}) 제거

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:02:28 +09:00

396 lines
26 KiB
TypeScript
Raw Blame History

import { Redis } from "ioredis";
import { Config } from "../utils/Config";
import { Logger } from "../utils/Logger";
import { YoutubeMusic } from "../utils/api/YoutubeMusic";
import { Spotify } from "../utils/api/Spotify";
import { lavalinkManager } from "../index";
import { getGuildById, getVoiceChannelById } from "../utils/music/Channel";
import { channelJoin } from "../commands/join";
import { GuildPlayer } from "./GuildPlayer";
import { Guild, VoiceChannel } from "discord.js";
import { SongItem } from "../types/Track";
import { DB } from "../utils/Database";
import { buttonRecommend } from "../utils/music/Button";
type SubAction =
"search" |
"player_now" |
"player_play" |
"player_playlist" |
"player_paused" |
"player_skip" |
"player_seek" |
"player_volume" |
"queue_list" |
"queue_set" |
"queue_remove" |
"queue_shuffle" |
"player_recommend";
export const RedisClient = () => {
if (Config.redis.state) return new RedisClientClass();
return null;
}
class RedisClientClass {
public pub: Redis = new Redis({ host: Config.redis.host, port: Config.redis.port });
public sub: Redis = new Redis({ host: Config.redis.host, port: Config.redis.port });
constructor() {
this.pub.on("connect", () => {
Logger.ready(`[Redis Pub] 연결 완료 (말하는 입)`);
});
this.sub.on("connect", () => {
Logger.ready(`[Redis Sub] 연결 완료 (듣는 귀)`);
});
this.sub.subscribe("site-bot", (err, count) => {
if (err) return Logger.error(`[Redis Sub] 구독 실패: ${err.message}`);
Logger.log(`[Redis Sub] 'bot-commands' 채널 구독 중... (현재 구독 채널 수: ${count})`);
});
this.sub.on("message", async (ch, msg): Promise<any> => {
if (ch !== "site-bot") return;
Logger.log(`[Redis Sub] [Message] 수신: {\n 채널: ${ch}\n 내용: ${msg}\n}`);
try {
const data = JSON.parse(msg) as { action: SubAction; requestId: string; userId?: string; [key: string]: any; };
if (data.action === "search") {
const resultKey = `search:${data.requestId}`;
const spotify: SongItem[] = (await Spotify.getSearchFull(data.query) ?? []).slice(0,10);
const youtubeMusic: SongItem[] = (await YoutubeMusic.getSearchFull(data.query) ?? []).slice(0,10);
const youtubeVideo: SongItem[] = (await lavalinkManager.youtubeSearch(data.query) ?? []).slice(0,10).map((video) => ({
videoId: video.info.identifier,
url: `https://www.youtube.com/watch?v=${video.info.identifier}`,
title: video.info.title,
artist: video.info.author,
thumbnail: video.info.artworkUrl ?? "",
duration: video.info.length,
}));
await this.reply(resultKey, 60, JSON.stringify({ spotify, youtubeMusic, youtubeVideo }));
Logger.log(`[Redis Pub] [reply] 결과 저장: (${resultKey})`);
}
if (data.action === "player_play") {
const resultKey = `player:play:${data.requestId}`;
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
const guild = await getGuildById(data.serverId);
if (!guild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
if (!(await this.isMember(guild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
// 곡 해석(검색+Lavalink+yt-dlp 예열)은 음성접속과 무관하므로 먼저 출발시켜 둘을 겹친다.
const t0 = Date.now();
const resolving = lavalinkManager.resolveQuery(data.track.url)
.catch((e) => { Logger.warn(`[player_play] 곡 해석 실패: ${String(e)}`); return null; });
let player = lavalinkManager.getPlayer(guild.id);
const voiceChannel = await getVoiceChannelById(guild, data.userId);
if (!player) {
if (!voiceChannel) { await resolving; return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." })); }
player = (await channelJoin(guild, voiceChannel.id)).player;
} else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) {
await resolving;
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 합니다." }));
}
const tJoined = Date.now();
if (!player) { await resolving; return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." })); }
const resolved = await resolving;
const tResolved = Date.now();
if (!resolved) {
player.errMsg(`노래를 찾을수 없습니다.`);
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "노래를 찾을수 없습니다." }));
}
await lavalinkManager.addResolved(player, resolved, data.userId);
Logger.log(`[player_play] 음성접속 ${tJoined - t0}ms / 곡해석 대기 ${tResolved - tJoined}ms / 큐반영까지 ${Date.now() - t0}ms`);
await this.reply(resultKey, 60, JSON.stringify({ success: true, message: "노래 추가 완료" }));
}
if (data.action === "player_playlist") {
const resultKey = `player:playlist:${data.requestId}`;
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
const guild = await getGuildById(data.serverId);
if (!guild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
if (!(await this.isMember(guild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
// 재생목록 해석도 음성접속과 병렬로 진행한다.
const resolvingPl = lavalinkManager.resolveQuery(data.playlistUrl)
.catch((e) => { Logger.warn(`[player_playlist] 해석 실패: ${String(e)}`); return null; });
let player = lavalinkManager.getPlayer(guild.id);
const voiceChannel = await getVoiceChannelById(guild, data.userId);
if (!player) {
if (!voiceChannel) { await resolvingPl; return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." })); }
player = (await channelJoin(guild, voiceChannel.id)).player;
} else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) {
await resolvingPl;
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 합니다." }));
}
if (!player) { await resolvingPl; return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." })); }
const resolvedPl = await resolvingPl;
if (!resolvedPl) {
player.errMsg(`노래를 찾을수 없습니다.`);
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "노래를 찾을수 없습니다." }));
}
await lavalinkManager.addResolved(player, resolvedPl, data.userId);
await this.reply(resultKey, 60, JSON.stringify({ success: true, message: "플레이리스트 추가 완료" }));
}
if (data.action === "player_now") {
const resultKey = `player:now:${data.requestId}`;
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
const nowGuild = await getGuildById(data.serverId);
if (!nowGuild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
if (!(await this.isMember(nowGuild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
const player = lavalinkManager.getPlayer(data.serverId);
await this.reply(resultKey, 60, JSON.stringify({
success: true,
botPlayer: !!player,
isPlaying: player?.isPlaying,
isPaused: player?.isPaused,
position: player?.position,
volume: player?.volume,
track: player?.nowTrack ?? null,
// 웹 대시보드 표시용: 자동재생 상태, 봇이 들어가 있는 음성채널 이름
recommend: player ? player.isRecommend : (DB.guild.get(data.serverId)?.options.recommend ?? false),
voiceChannelName: player ? (nowGuild.channels.cache.get(player.voiceChannelId)?.name ?? null) : null,
}));
}
if (data.action === "queue_list") {
const resultKey = `queue:list:${data.requestId}`;
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
const qlGuild = await getGuildById(data.serverId);
if (!qlGuild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
if (!(await this.isMember(qlGuild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
const player = lavalinkManager.getPlayer(data.serverId);
await this.reply(resultKey, 60, JSON.stringify({ success: true, queue: player?.queue?.slice(1) ?? [] }));
}
if (data.action === "queue_set") {
const resultKey = `queue:set:${data.requestId}`;
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
if (!data.newQueue) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "newQueue를 찾을수 없습니다." }));
const context = await this.getContext(data.serverId, resultKey, data.userId);
if (!context.ok) return;
const nowTrack = context.player.nowTrack ?? context.player.queue?.[0];
context.player.queue.length = 0;
if (nowTrack) context.player.queue.push(nowTrack);
for (const rawTrack of data.newQueue) {
if (!rawTrack?.encoded) continue;
// _resolved(재생용 리졸브 캐시)는 Promise라 JSON을 거치면 {}로 깨져 온다 → 버리고 재생 시 다시 리졸브
const { _resolved, ...track } = rawTrack;
context.player.queue.push(track);
}
await this.reply(resultKey, 60, JSON.stringify({ success: true }));
context.player.setMsg();
}
if (data.action === "queue_remove") {
const resultKey = `queue:remove:${data.requestId}`;
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
// index 는 number(0 도 유효) — typeof 검증으로 변경.
if (typeof data.index !== "number") return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "index는 number 이어야 합니다." }));
const context = await this.getContext(data.serverId, resultKey, data.userId);
if (!context.ok) return;
const numIndex = Number(data.index);
if (isNaN(numIndex)) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "index 타입이 올바르지 않습니다." }));
if (numIndex < 0) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "index는 0보다 크거나 같아야합니다." }));
// queue[0]은 현재 재생중인 곡이므로 실제 대기열은 queue[1]부터 시작
// numIndex는 대기열(queue[1]~) 기준이므로 실제 splice 위치<EC9C84><ECB998> numIndex+1
if (numIndex >= context.player.queue.length - 1) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "index가 대기열 범위를 초과합니다." }));
// 인덱스 신뢰 대신, 클라이언트가 지우려던 곡(encoded)과 실제 대상이 같은지 확인.
// SSE로 큐가 갱신되는 찰나 인덱스가 밀려 다른 곡이 삭제되는 것을 방지.
const removeTarget = context.player.queue[numIndex + 1];
if (data.encoded && removeTarget?.encoded && removeTarget.encoded !== data.encoded) {
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "대기열이 변경되었습니다. 새로고침 후 다시 시도해주세요." }));
}
const [removedTrack] = context.player.queue.splice(numIndex + 1, 1);
await this.reply(resultKey, 60, JSON.stringify({ success: true, removedTrack }));
context.player.setMsg();
}
if (data.action === "queue_shuffle") {
const resultKey = `queue:shuffle:${data.requestId}`;
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
const context = await this.getContext(data.serverId, resultKey, data.userId);
if (!context.ok) return;
if (context.player.queue.length < 3) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "섞을 곡이 2곡 이상 있어야 합니다." }));
context.player.setShuffle();
await this.reply(resultKey, 60, JSON.stringify({ success: true }));
}
if (data.action === "player_recommend") {
// 자동재생 토글. 재생 중이 아니어도 바꿀 수 있어야 하므로 음성채널 접속(getContext)을 거치지 않는다.
const resultKey = `player:recommend:${data.requestId}`;
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
const recGuild = await getGuildById(data.serverId);
if (!recGuild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
if (!(await this.isMember(recGuild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
const player = lavalinkManager.getPlayer(recGuild.id);
if (player) {
// 재생 중이면 봇과 같은 음성채널에 있는 사람만 바꿀 수 있다(다른 조작과 같은 규칙).
const voiceChannel = await getVoiceChannelById(recGuild, data.userId);
if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) {
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 조작할 수 있습니다." }));
}
player.setRecommend();
} else {
await buttonRecommend(recGuild);
}
const recommend = DB.guild.get(recGuild.id)?.options.recommend ?? false;
await this.reply(resultKey, 60, JSON.stringify({ success: true, recommend }));
this.publishState("player_update", { guildId: recGuild.id });
}
if (data.action === "player_paused") {
const resultKey = `player:paused:${data.requestId}`;
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
// isPaused 는 boolean — false 도 정상 입력. typeof 검증으로 변경.
if (typeof data.isPaused !== "boolean") return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "isPaused는 boolean 이어야 합니다." }));
const context = await this.getContext(data.serverId, resultKey, data.userId);
if (!context.ok) return;
await context.player.setPause();
await this.reply(resultKey, 60, JSON.stringify({ success: true, paused: context.player.isPaused }));
}
if (data.action === "player_skip") {
const resultKey = `player:skip:${data.requestId}`;
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
const context = await this.getContext(data.serverId, resultKey, data.userId);
if (!context.ok) return;
if (context.player.isPlaying) context.player.skip();
await this.reply(resultKey, 60, JSON.stringify({ success: true }));
}
if (data.action === "player_seek") {
const resultKey = `player:seek:${data.requestId}`;
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
// seek 는 number(0 도 유효 — 처음으로 되감기) — typeof 검증으로 변경.
if (typeof data.seek !== "number") return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "seek는 number 이어야 합니다." }));
const context = await this.getContext(data.serverId, resultKey, data.userId);
if (!context.ok) return;
if (!context.player.isPlaying || !context.player.nowTrack) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." }));
const duration = context.player.nowTrack.info.length || 0;
const numSeek = Number(data.seek);
if (isNaN(numSeek)) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "seek 타입이 올바르지 않습니다." }));
if (numSeek < 0) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "seek는 0보다 크거나 같아야합니다." }));
if (numSeek > duration) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "seek가 duration보다 클수 없습니다." }));
context.player.seek(numSeek);
await this.reply(resultKey, 60, JSON.stringify({ success: true }));
}
if (data.action === "player_volume") {
const resultKey = `player:volume:${data.requestId}`;
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
// volume 은 number(0 도 유효 — 음소거) — typeof 검증으로 변경.
if (typeof data.volume !== "number") return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "volume은 number 이어야 합니다." }));
const context = await this.getContext(data.serverId, resultKey, data.userId);
if (!context.ok) return;
if (!context.player.isPlaying || !context.player.nowTrack) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." }));
const numVolume = Number(data.volume);
if (isNaN(numVolume)) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "volume 타입이 올바르지 않습니다." }));
if (numVolume < 0) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "volume은 0보다 크거나 같아야합니다." }));
if (numVolume > 100) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "volume이 100보다 클수 없습니다." }));
context.player.setVolume(numVolume);
await this.reply(resultKey, 60, JSON.stringify({ success: true }));
}
} catch (err) {
Logger.error(`명령어 처리 중 에러: ${String(err)}`);
}
});
this.pub.on("error", (err) => {
Logger.error(`[Redis Pub] [Error] ${err.message}`);
});
this.sub.on("error", (err) => {
Logger.error(`[Redis Sub] [Error] ${err.message}`);
});
}
/**
* 사이트로 보내는 RPC 응답 쓰기.
* 기존 SET+폴링 대신, 사이트가 BRPOP 으로 블로킹 대기하도록 리스트에 LPUSH 한다.
* ttlSec 는 사이트가 이미 타임아웃된 뒤 남는 고아 응답을 자동 정리하기 위한 보험.
* (시그니처를 기존 setex(key, ttl, value) 와 동일하게 맞춰 호출부를 기계적으로 교체)
*/
private async reply(key: string, ttlSec: number, value: string): Promise<void> {
await this.pub.lpush(key, value);
await this.pub.expire(key, ttlSec);
}
public publishState(event: string, data: any) {
const payload = JSON.stringify({
event,
timestamp: Date.now(),
...data,
});
this.pub.publish("bot-site", payload);
Logger.log(`[Redis Pub] bot -> site 전송: ${event}`);
}
/**
* 요청한 userId가 해당 guild의 멤버인지 확인(캐시 우선, 없으면 단건 fetch).
* 대시보드가 보낸 serverId를 그대로 신뢰하지 않기 위한 인가 검증.
*/
private async isMember(guild: Guild, userId: string): Promise<boolean> {
if (guild.members.cache.has(userId)) return true;
const fetched = await guild.members.fetch(userId).catch(() => null);
return !!fetched;
}
private async getContext(guildId: string, resultKey: string, userId: string): Promise<{
ok: true;
guild: Guild;
voiceChannel: VoiceChannel | null;
player: GuildPlayer;
} | { ok: false; }> {
const guild = await getGuildById(guildId);
if (!guild) {
await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
return { ok: false };
}
// 인가: 요청자가 이 서버의 멤버여야 함 (남의 서버 제어 차단)
if (!(await this.isMember(guild, userId))) {
await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
return { ok: false };
}
let player = lavalinkManager.getPlayer(guild.id);
const voiceChannel = await getVoiceChannelById(guild, userId);
if (!player) {
if (!voiceChannel) {
await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." }));
return { ok: false };
}
player = (await channelJoin(guild, voiceChannel.id)).player;
} else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) {
// 이미 재생 중이면 봇과 같은 음성채널에 있는 사람만 조작 가능
await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 조작할 수 있습니다." }));
return { ok: false };
}
if (!player) {
await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "player를 찾을수 없습니다." }));
return { ok: false };
}
return {
ok: true,
guild,
voiceChannel,
player,
};
}
public runTest() {
Logger.debug(`[Redis Test] 3초 뒤에 테스트 통신 시작...`);
setTimeout(() => {
// 1. 봇 -> 사이트(웹) 방향 전송 테스트
this.publishState("TRACK_START", {
author: "테스트",
title: "제목",
duration: 196000,
});
// 2. 사이트(웹) -> 봇 방향 수신 테스트 (가짜 명령을 쏴서 스스로 수신하는지 확인)
setTimeout(() => {
const mockCommand = JSON.stringify({ action: "skip", userId: "12345" });
// 테스트를 위해 본인이 site-bot 채널로 발행해 봅니다.
this.pub.publish("site-bot", mockCommand);
}, 1000);
}, 3000);
}
}