지금까지 내용 커밋
This commit is contained in:
76
bot/src/classes/BotClient.ts
Normal file
76
bot/src/classes/BotClient.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Client, ClientEvents, ColorResolvable, EmbedBuilder, EmbedField, GatewayIntentBits, Message } from "discord.js";
|
||||
import { Config } from "../utils/Config";
|
||||
|
||||
export class BotClient extends Client {
|
||||
public prefix = Config.prefix;
|
||||
public color: ColorResolvable = Config.embedColor;
|
||||
public constructor() {
|
||||
super({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMembers,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.MessageContent,
|
||||
GatewayIntentBits.GuildVoiceStates,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
public async start() {
|
||||
this.login(Config.token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 핸들러 등록
|
||||
*
|
||||
* 지정한 이벤트가 발생했을때 해당 핸들러를 호출함
|
||||
* * 'func'의 내용은 기본적으로 'client.on'을 따름
|
||||
* * 'extra'를 입력할 경우 추가되어 같이 전달
|
||||
*
|
||||
* @example
|
||||
* client.onEvent('ready', (client, info) => {
|
||||
* Logger.ready(client?.user.username, '봇이 준비되었습니다.', info) // 출력: OOO 봇이 준비되었습니다. 추가 정보
|
||||
* }, ['추가 정보']);
|
||||
*
|
||||
* @param event 이벤트명
|
||||
* @param func 이벤트 핸들러 함수
|
||||
* @param extra 추가로 전달할 목록
|
||||
*/
|
||||
public readonly onEvent = (event: keyof ClientEvents, func: Function, ...extra: any[]) => this.on(event, (...args) => func(...args, ...extra));
|
||||
|
||||
public mkembed(data: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
url?: string;
|
||||
image?: string;
|
||||
thumbnail?: string;
|
||||
author?: { name: string, iconURL?: string, url?: string };
|
||||
addFields?: EmbedField[];
|
||||
timestamp?: number | Date | undefined | null;
|
||||
footer?: { text: string, iconURL?: string };
|
||||
color?: ColorResolvable;
|
||||
}): EmbedBuilder {
|
||||
if (!data.color) data.color = this.color;
|
||||
const embed = new EmbedBuilder();
|
||||
if (data.title) embed.setTitle(data.title);
|
||||
if (data.description) embed.setDescription(data.description);
|
||||
if (data.url) embed.setURL(data.url);
|
||||
if (data.image) embed.setImage(data.image);
|
||||
if (data.thumbnail) embed.setThumbnail(data.thumbnail);
|
||||
if (data.author) embed.setAuthor({ name: data.author.name, iconURL: data.author.iconURL, url: data.author.url });
|
||||
if (data.addFields) embed.addFields(data.addFields);
|
||||
if (data.timestamp) embed.setTimestamp(data.timestamp);
|
||||
if (data.footer) embed.setFooter({ text: data.footer.text, iconURL: data.footer.iconURL });
|
||||
if (data.color) embed.setColor(data.color);
|
||||
return embed;
|
||||
}
|
||||
|
||||
public msgDelete(message: Message, time: number, customTime?: boolean): void {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const msg = await message.fetch(true).catch(() => undefined);
|
||||
if (msg?.deletable) msg.delete().catch(() => {});
|
||||
} catch {};
|
||||
}, Math.max(100, time * (customTime ? 1 : 6000)));
|
||||
}
|
||||
}
|
||||
278
bot/src/classes/GuildPlayer.ts
Normal file
278
bot/src/classes/GuildPlayer.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { Guild, Message, TextChannel } from "discord.js";
|
||||
import { LoadType, Player, Track, TrackEndEvent } from "shoukaku";
|
||||
import { client, lavalinkManager } from "../index";
|
||||
import { timeFormat } from "../utils/music/Utils";
|
||||
import { default_content, default_embed, default_image, getButtons } from "../utils/music/Config";
|
||||
import { GuildType } from "../../db/db";
|
||||
import { DB } from "../utils/Database";
|
||||
import { shuffle } from "../utils/Shuffle";
|
||||
import { checkTextChannelAndMsg } from "../utils/music/Channel";
|
||||
|
||||
const DelayAfterErrMs = 1000 * 5;
|
||||
const idleEndTime = 1000 * 60 * 10;
|
||||
|
||||
type QueueTrack = Track & { userId: string; };
|
||||
|
||||
export class GuildPlayer {
|
||||
private isDead = false;
|
||||
private canRecommend = true;
|
||||
private _GDB: GuildType | undefined;
|
||||
private lastPlayedTrack: QueueTrack | undefined;
|
||||
public queue: QueueTrack[] = [];
|
||||
private errorTimer: NodeJS.Timeout | undefined;
|
||||
private endTimer: NodeJS.Timeout | undefined;
|
||||
|
||||
constructor(
|
||||
public guild: Guild,
|
||||
public player: Player,
|
||||
public voiceChannelId: string,
|
||||
public textChannel: TextChannel,
|
||||
public msg: Message,
|
||||
) {
|
||||
this.player.setGlobalVolume(50);
|
||||
this.player.on("end", async (data: TrackEndEvent) => {
|
||||
if (this.isDead) return;
|
||||
if (data.reason === "replaced") return;
|
||||
// 방금 끝난 곡을 대기열에서 지우면서 lastPlayedTrack에 저장
|
||||
this.lastPlayedTrack = this.queue.shift();
|
||||
if (this.queue.length > 0) {
|
||||
await this.playNext();
|
||||
} else if (this.isRecommend && this.canRecommend) {
|
||||
await this.autoPlay();
|
||||
} else {
|
||||
this.end();
|
||||
}
|
||||
});
|
||||
this.player.on("closed", () => {
|
||||
if (this.isDead) return;
|
||||
this.delete();
|
||||
});
|
||||
}
|
||||
|
||||
private get GDB() {
|
||||
if (!this._GDB) this._GDB = DB.guild.get(this.guild.id);
|
||||
return this._GDB;
|
||||
}
|
||||
public get isPlaying(): boolean {
|
||||
return this.player.track !== null;
|
||||
}
|
||||
public get nowTrack(): QueueTrack | null {
|
||||
return this.queue[0] ?? null;
|
||||
}
|
||||
public get isPaused(): boolean {
|
||||
return this.player.paused;
|
||||
}
|
||||
public get isRecommend(): boolean {
|
||||
return this.GDB?.options.recommend ?? false;
|
||||
}
|
||||
|
||||
public async addTrack(track: Track, userId: string) {
|
||||
this.queue.push({ ...track, userId });
|
||||
if (this.queue.length === 1) {
|
||||
// 큐가 비어있으면 바로 재생
|
||||
await this.playNext();
|
||||
} else {
|
||||
// 재생목록 추가되었을때
|
||||
this.setMsg();
|
||||
}
|
||||
}
|
||||
|
||||
public async addTracks(tracks: Track[], userId: string) {
|
||||
const first = this.queue.length === 0;
|
||||
for (const track of tracks) this.queue.push({ ...track, userId });
|
||||
if (first) {
|
||||
// 큐가 비어있으면 바로 재생
|
||||
await this.playNext();
|
||||
} else {
|
||||
// 재생목록 추가되었을때
|
||||
this.setMsg();
|
||||
}
|
||||
}
|
||||
|
||||
public async playNext() {
|
||||
const track = this.queue[0];
|
||||
if (!track) return this.end();
|
||||
await this.player.playTrack({ track: { encoded: track.encoded } });
|
||||
// 재생관련
|
||||
this.setMsg();
|
||||
}
|
||||
|
||||
public async setPause() {
|
||||
if (this.isPaused) {
|
||||
await this.player.setPaused(false);
|
||||
this.setMsg();
|
||||
} else if (this.isPlaying) {
|
||||
await this.player.setPaused(true);
|
||||
this.setMsg();
|
||||
}
|
||||
}
|
||||
|
||||
public stop() {
|
||||
this.queue = [];
|
||||
this.canRecommend = false;
|
||||
this.player.stopTrack();
|
||||
}
|
||||
|
||||
public skip() {
|
||||
// 곡 스킵했을때
|
||||
this.player.stopTrack();
|
||||
}
|
||||
|
||||
public setShuffle() {
|
||||
let nowQueue = this.queue.slice(1);
|
||||
nowQueue = shuffle(nowQueue);
|
||||
this.queue = [this.queue[0], ...nowQueue];
|
||||
this.setMsg();
|
||||
}
|
||||
|
||||
public setRecommend() {
|
||||
if (!this.GDB) return;
|
||||
this.GDB.options.recommend = !this.GDB.options.recommend;
|
||||
DB.guild.update(this.GDB);
|
||||
this.setMsg();
|
||||
}
|
||||
|
||||
public seek(num: number) {
|
||||
if (!this.isPlaying) return;
|
||||
if (!this.nowTrack) return;
|
||||
this.player.seekTo(num);
|
||||
}
|
||||
|
||||
private async autoPlay() {
|
||||
if (!this.lastPlayedTrack) return;
|
||||
const source = this.lastPlayedTrack.info.sourceName;
|
||||
const trackId = this.lastPlayedTrack.info.identifier;
|
||||
let result;
|
||||
if (source === "spotify") {
|
||||
result = await this.player.node.rest.resolve(`sprec:seed_tracks=${trackId}&limit=11`);
|
||||
} else {
|
||||
result = await this.player.node.rest.resolve(`https://music.youtube.com/watch?v=${trackId}&list=RD${trackId}`);
|
||||
}
|
||||
let tracks: Track[] = [];
|
||||
if (result?.loadType === LoadType.PLAYLIST) {
|
||||
tracks = result.data.tracks;
|
||||
} else if (result?.loadType === LoadType.SEARCH || Array.isArray(result?.data)) {
|
||||
tracks = Array.isArray(result.data) ? result.data : [];
|
||||
} else if (result?.loadType === LoadType.TRACK) {
|
||||
tracks = [ result.data ];
|
||||
}
|
||||
if (tracks.length === 0) {
|
||||
this.end();
|
||||
return;
|
||||
}
|
||||
if (tracks[0].info.identifier === trackId) tracks = tracks.slice(1);
|
||||
this.addTracks(tracks, "자동재생");
|
||||
}
|
||||
|
||||
public end() {
|
||||
if (this.errorTimer !== undefined) {
|
||||
clearTimeout(this.errorTimer);
|
||||
this.errorTimer = undefined;
|
||||
}
|
||||
if (this.endTimer !== undefined) clearTimeout(this.endTimer);
|
||||
this.endTimer = setTimeout(() => {
|
||||
this.endTimer = undefined;
|
||||
this.delete(true);
|
||||
}, idleEndTime);
|
||||
this.lastPlayedTrack = undefined;
|
||||
this.queue = [];
|
||||
this.canRecommend = true;
|
||||
this.setMsg();
|
||||
}
|
||||
|
||||
public delete(afterEnd?: boolean) {
|
||||
if (this.isDead) return;
|
||||
if (!afterEnd) this.end();
|
||||
this.isDead = true;
|
||||
this.player.destroy().catch(() => {});
|
||||
lavalinkManager.delPlayer(this.guild.id);
|
||||
lavalinkManager.shoukaku.leaveVoiceChannel(this.guild.id);
|
||||
}
|
||||
|
||||
private async setMsg() {
|
||||
const { channel, msg, check } = await checkTextChannelAndMsg(this.guild, this.textChannel, this.msg);
|
||||
if (!check) return;
|
||||
this.textChannel = channel;
|
||||
this.msg = msg;
|
||||
if (!this.msg) return;
|
||||
|
||||
// content
|
||||
let content = default_content;
|
||||
let realQueue = this.queue.slice(1);
|
||||
if (realQueue.length > 0) {
|
||||
content = "__**대기열 목록:**__\n";
|
||||
let list: string[] = [];
|
||||
let nowLength = content.length + 50; // 50자 여유두기
|
||||
for (let i=0; i<realQueue.length; i++) {
|
||||
let track = realQueue[i];
|
||||
let text = `${
|
||||
(i+1).toString().padStart(realQueue.length.toString().length, "0")
|
||||
}\\. ${
|
||||
track.info.author.replace(" - Topic","")
|
||||
} - ${
|
||||
track.info.title
|
||||
} [${
|
||||
timeFormat(track.info.length)
|
||||
}] ~ ${
|
||||
track.userId === "자동재생" ? "자동재생" : `<@${track.userId}>`
|
||||
}`;
|
||||
if (nowLength + text.length > 2000) {
|
||||
content += `+ ${realQueue.length-list.length}곡\n`;
|
||||
break;
|
||||
}
|
||||
nowLength += text.length;
|
||||
list.push(text);
|
||||
}
|
||||
content += list.reverse().join("\n");
|
||||
}
|
||||
|
||||
// embed
|
||||
const embed = (this.isPlaying || this.isPaused) && !!this.nowTrack
|
||||
? client.mkembed({
|
||||
title: `**[${timeFormat(this.nowTrack.info.length)}] ${this.nowTrack.info.author.replace(" - Topic","")} - ${this.nowTrack.info.title}**`,
|
||||
description: `노래 요청자: ${this.nowTrack.userId === "자동재생" ? "자동재생" : `<@${this.nowTrack.userId}>`}`,
|
||||
image: this.nowTrack.info.artworkUrl,
|
||||
url: this.nowTrack.info.author.includes(" - Topic") ? this.nowTrack.info.uri?.replace("www.youtube","music.youtube") : this.nowTrack.info.uri,
|
||||
footer: { text: `대기열: ${realQueue.length}개 | 볼륨: ${this.player.volume}${
|
||||
this.isRecommend ? " | 자동재생: 활성화" : ""
|
||||
}${
|
||||
this.isPaused ? " | 노래가 일시중지 되었습니다." : ""
|
||||
}` }
|
||||
})
|
||||
: default_embed(this.guild.id);
|
||||
|
||||
this.msg.edit({
|
||||
content: content,
|
||||
embeds: [ embed ],
|
||||
files: embed.data.image?.url === default_embed(this.guild.id).data.image?.url ? [ default_image ] : [],
|
||||
components: [ getButtons(
|
||||
this.isPlaying || this.isPaused,
|
||||
this.isPaused,
|
||||
realQueue.length > 1,
|
||||
) ]
|
||||
});
|
||||
}
|
||||
|
||||
public async errMsg(reason: string) {
|
||||
if (!this.msg) return null;
|
||||
await this.msg.edit({
|
||||
content: this.msg.content,
|
||||
embeds: [ client.mkembed({
|
||||
title: "오류발생!!",
|
||||
description: reason,
|
||||
color: "DarkRed",
|
||||
}) ],
|
||||
files: [],
|
||||
components: [ getButtons(
|
||||
false,
|
||||
false,
|
||||
this.queue.length > 1,
|
||||
) ]
|
||||
});
|
||||
this.errorTimer = setTimeout(() => {
|
||||
this.errorTimer = undefined;
|
||||
this.playNext();
|
||||
}, DelayAfterErrMs);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
25
bot/src/classes/Handler.ts
Normal file
25
bot/src/classes/Handler.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ChatInputCommandInteraction, Collection } from "discord.js";
|
||||
import { readdirSync } from "node:fs";
|
||||
import { Command } from "../types/Command";
|
||||
import { COMMAND_PATH, COMMANDS_PATH } from "../utils/Config";
|
||||
|
||||
export class Handler {
|
||||
public commands: Collection<string, Command> = new Collection();
|
||||
public coolDown: Map<string, number> = new Map();
|
||||
|
||||
public constructor() {
|
||||
const commandFiles = readdirSync(COMMANDS_PATH);
|
||||
for (const commandFile of commandFiles) {
|
||||
const command = new (require(COMMAND_PATH(commandFile)).default)() as Command;
|
||||
this.commands.set(command.metaData.name, command);
|
||||
}
|
||||
}
|
||||
|
||||
public runCommand(interaction: ChatInputCommandInteraction) {
|
||||
const commandName = interaction.commandName;
|
||||
const command = this.commands.get(commandName);
|
||||
|
||||
if (!command) return;
|
||||
if (command.slashRun) command.slashRun(interaction);
|
||||
}
|
||||
}
|
||||
83
bot/src/classes/LavalinkManager.ts
Normal file
83
bot/src/classes/LavalinkManager.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Connectors, LoadType, Shoukaku } from "shoukaku";
|
||||
import { Client } from "discord.js";
|
||||
import { GuildPlayer } from "./GuildPlayer";
|
||||
import { Config } from "../utils/Config";
|
||||
import { Logger } from "../utils/Logger";
|
||||
import { parseLink } from "../utils/music/Url";
|
||||
import { shuffle } from "../utils/Shuffle";
|
||||
import { Spotify } from "../utils/api/Spotify";
|
||||
import { YoutubeMusic } from "../utils/api/YoutubeMusic";
|
||||
|
||||
export class LavalinkManager {
|
||||
public shoukaku: Shoukaku;
|
||||
private players: Map<string, GuildPlayer> = new Map();
|
||||
|
||||
constructor(readonly client: Client) {
|
||||
this.shoukaku = new Shoukaku(new Connectors.DiscordJS(client), [{
|
||||
name: "tkrmagid-Lavalink-Server",
|
||||
url: `${Config.lavalink.host}:${Config.lavalink.port}`,
|
||||
auth: Config.lavalink.pw,
|
||||
secure: false,
|
||||
}], {
|
||||
moveOnDisconnect: true,
|
||||
reconnectTries: 3,
|
||||
});
|
||||
|
||||
this.shoukaku.on("ready", (name) => {
|
||||
Logger.ready(`[LavalinkManager] 노드 [${name}] 연결 완료!`);
|
||||
});
|
||||
this.shoukaku.on("error", (name, err) => {
|
||||
Logger.error(`[LavalinkManager] 노드 [${name}] 에러: ${String(err)}`);
|
||||
});
|
||||
}
|
||||
|
||||
getPlayer(guildId: string) {
|
||||
return this.players.get(guildId);
|
||||
}
|
||||
addPlayer(guildId: string, player: GuildPlayer) {
|
||||
this.players.set(guildId, player);
|
||||
}
|
||||
delPlayer(guildId: string) {
|
||||
this.players.delete(guildId);
|
||||
}
|
||||
|
||||
async search(guildId: string, query: string, userId: string, player?: GuildPlayer) {
|
||||
const node = this.shoukaku.options.nodeResolver(this.shoukaku.nodes);
|
||||
if (!node) {
|
||||
this.delPlayer(guildId);
|
||||
throw new ReferenceError(`[LavalinkManager] lavalink node is missing`);
|
||||
}
|
||||
player = player ?? this.getPlayer(guildId);
|
||||
if (!player) return;
|
||||
const { isUrl, text, flags } = parseLink(query.trim());
|
||||
let searchText = isUrl ? text : (await Spotify.getSearchUrl(text)) ?? (await YoutubeMusic.getSearchUrl(text)) ?? `ytsearch:${text}`;
|
||||
if (flags.has('y')) searchText = (await YoutubeMusic.getSearchUrl(text)) ?? `ytsearch:${text}`;
|
||||
if (searchText.startsWith("ytsearch") && !flags.has('o')) searchText += " Topic";
|
||||
const result = await node.rest.resolve(searchText);
|
||||
if (!result || result.loadType === LoadType.EMPTY || result.loadType === LoadType.ERROR) {
|
||||
if (result?.loadType === LoadType.ERROR) Logger.error(`[LavalinkManager] loadtype ERROR: ${result.data.message}`);
|
||||
// 노래 못찾았을때
|
||||
player.errMsg(`노래를 찾을수 없습니다.`);
|
||||
return;
|
||||
}
|
||||
if (result.loadType === LoadType.PLAYLIST) {
|
||||
player.addTracks(
|
||||
flags?.has("s") ? shuffle(result.data.tracks) : result.data.tracks,
|
||||
userId
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (result.loadType === LoadType.TRACK) {
|
||||
player.addTrack(result.data, userId);
|
||||
return;
|
||||
}
|
||||
if (result.loadType === LoadType.SEARCH) {
|
||||
if (result.data.length === 0) {
|
||||
player.errMsg(`노래를 찾을수 없습니다.`);
|
||||
return;
|
||||
}
|
||||
player.addTrack(result.data[0], userId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
77
bot/src/classes/RedisClient.ts
Normal file
77
bot/src/classes/RedisClient.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
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";
|
||||
|
||||
export class RedisClient {
|
||||
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) => {
|
||||
if (ch !== "site-bot") return;
|
||||
Logger.log(`[Redis Sub] [Message] 수신: {\n 채널: ${ch}\n 내용: ${msg}\n}`);
|
||||
try {
|
||||
const data = JSON.parse(msg) as { action: "search"; requestId: string; [key: string]: any; };
|
||||
|
||||
if (data.action === "search") {
|
||||
const resultKey = `search:result:${data.requestId}`;
|
||||
const results = await Spotify.getSearchFull(data.query) ?? await YoutubeMusic.getSearchFull(data.query) ?? [];
|
||||
await this.pub.setex(resultKey, 60, JSON.stringify(results));
|
||||
Logger.log(`[Redis Pub] [setex] 결과 저장: (${resultKey})`);
|
||||
}
|
||||
} 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}`);
|
||||
});
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user