soop-chat-client
Version:
SOOP Live Chat Client
690 lines (689 loc) • 27.2 kB
JavaScript
import WebSocket from 'ws';
import { TypedEmitter } from 'tiny-typed-emitter';
import chalk from 'chalk';
import { Logger } from 'Logger';
import { getChannelEmotes, getPlayerLiveInfo } from 'SoopApi';
import { CMD_CONNECT, IceFlag, IceFlagName, ServiceCommand, Userflag1, Userflag2, } from 'SoopChatConstants';
import { ConnectedState, SoopBalloonType, SoopBlockType, SoopChatMessageContentType, } from 'SoopChatTypes';
export class SoopChatClient extends TypedEmitter {
socket = null;
log;
_connection = null;
set connection(connection) {
this._connection = connection;
}
get connection() {
return this._connection;
}
_stream = null;
set stream(stream) {
this._stream = stream;
}
get stream() {
return this._stream;
}
_emotes = {};
set emotes(emotes) {
this._emotes = emotes;
}
get emotes() {
return this._emotes;
}
_connectedState = ConnectedState.STANDBY;
set connectedState(state) {
this._connectedState = state;
if (state === ConnectedState.STANDBY) {
clearInterval(this.loopPingPong);
}
else if (state === ConnectedState.CONNECTED) {
clearInterval(this.loopPingPong);
this.loopPingPong = setInterval(this.sendPing, 60000);
}
}
get connectedState() {
return this._connectedState;
}
loopPingPong;
decoder = new TextDecoder();
constructor({ logging = false }) {
super();
this.loopPingPong = setInterval(this.sendPing, 60000);
this.log = new Logger('soop-chat', logging);
}
sendPing = () => {
if (this.connectedState === ConnectedState.CONNECTED ||
this.connectedState === ConnectedState.JOINED) {
this.send(ServiceCommand.PING, '\x0c');
this.log.verbose(`PING: ${this.connection.channelId}`);
}
};
connect = async (userId, password = '') => {
this.connectedState = ConnectedState.STANDBY;
try {
const liveInfo = await getPlayerLiveInfo(userId);
if (liveInfo.CHANNEL?.RESULT !== 1) {
throw new Error(`방송 중이 아닙니다. (${userId})`);
}
this.stream = liveInfo;
this.connection = {
channelId: this.stream.CHANNEL.BJID,
host: this.stream.CHANNEL.CHDOMAIN,
port: parseInt(this.stream.CHANNEL.CHPT) + 1,
password,
};
this.log.name = `soop-chat:${this.connection.channelId}`.padEnd(23);
const wsHost = `wss://${this.connection.host.toLowerCase()}:${this.connection.port}/Websocket/${this.connection.channelId}`;
this.log.info(`연결시도: ${wsHost}`);
const ws = new WebSocket(wsHost, 'chat');
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
this.connectedState = ConnectedState.CONNECTED;
this.log.info(`연결됨: ${ws.url}`);
ws.send(CMD_CONNECT);
};
ws.onclose = () => {
this.connectedState = ConnectedState.STANDBY;
clearInterval(this.loopPingPong);
this.log.warn('채팅 서버와 연결이 끊어졌습니다.');
};
ws.onerror = (e) => {
this.connectedState = ConnectedState.STANDBY;
if (e.message == 'unable to verify the first certificate') {
this.log.warn('채팅 서버 인증서 오류로 인해 연결이 실패했습니다.');
}
else {
this.log.warn('채팅 서버와 연결 중 오류가 발생했습니다. ' + e.error);
}
};
ws.onmessage = (msg) => {
if (msg.data) {
this.processMessage(msg.data);
}
};
this.socket = ws;
this.loadEmotes(userId);
}
catch (e) {
if (e instanceof Error) {
this.log.warn(e.message);
}
}
};
disconnect = () => {
if (this.socket) {
this.log.warn('채팅 서버와 연결을 종료합니다.');
this.emit('part', this.connection?.channelId ?? '');
this.connectedState = ConnectedState.STANDBY;
this.socket.close();
this.connection = null;
this.emotes = {};
}
};
/**
* @deprecated
*/
close = this.disconnect;
send = (serviceCommand, body) => {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
return false;
}
const magicNumber = '\x1b\x09';
const cmd = serviceCommand.toString().padStart(4, '0');
const bodyLength = body.length.toString().padStart(6, '0');
const header = `${magicNumber}${cmd}${bodyLength}\x30\x30`; // 2 4 6 2 = 14
const sendData = `${header}${body}`;
this.socket?.send(sendData);
return true;
};
loadEmotes = async (userId) => {
try {
const emotes = await getChannelEmotes(userId);
this.emotes = {};
if (emotes.version === 0) {
this.log.info('채널 이모티콘이 없습니다.');
return;
}
emotes.data.forEach((emote) => {
this.emotes[emote.title] = {
title: emote.title,
tier: emote.tier_type,
pcImg: emotes.img_path + emote.pc_img,
pcAltImg: emotes.img_path + emote.pc_alternate_img,
mobileImg: emotes.img_path + emote.mobile_img,
mobileAltImg: emotes.img_path + emote.mob_alternate_img,
isAnimated: emote.move_img === 'Y' ? true : false,
orderNo: emote.order_no,
blackKeyword: emote.black_keyword === 'Y' ? true : false,
};
});
}
catch (e) {
if (e instanceof Error) {
this.log.warn(e.message);
}
return;
}
};
joinChannel = () => {
if (!this.stream) {
return;
}
const stream = this.stream.CHANNEL;
const body = [
`\x0c${stream.CHATNO}`,
`\x0c${stream.FTK}`,
'\x0c0\x0c\x0clog\x11',
'\x06&\x06set_bps\x06=\x06',
stream.BPS,
'\x06&\x06view_bps\x06=\x06',
stream.BPS,
'\x06&\x06quality\x06=\x06',
'ori',
'\x06&\x06geo_cc\x06=\x06',
stream.geo_cc,
'\x06&\x06geo_rc\x06=\x06',
stream.geo_rc,
'\x06&\x06acpt_lang\x06=\x06',
stream.acpt_lang,
'\x06&\x06svc_lang\x06=\x06',
stream.svc_lang,
'\x06&\x06subscribe\x06=\x060',
'\x06&\x06lowlatency\x06=\x061',
'\x12',
`pwd\x11${this.connection?.password ?? ''}\x12`,
'auth_info\x11NULL\x12',
'pver\x112\x12',
'access_system\x11html5\x12',
'\x0c',
];
if (this.stream.CHANNEL.BPWD === 'Y') {
this.log.info(`비번방에 접속합니다.`);
}
this.send(ServiceCommand.JOIN, body.join(''));
};
processMessage = (msg) => {
const header = msg.slice(0, 14);
// const magicNumber = header.slice(0, 2); // 1b 09
const serviceCommand = parseInt(this.decoder.decode(header.slice(2, 6)));
// const contentLength = parseInt(this.decoder.decode(header.slice(6, 12)));
const body = msg.slice(15);
const bodyParted = this.decoder.decode(body).split('\f');
switch (serviceCommand) {
case ServiceCommand.LOGIN:
this.log.info(`로그인: ${this.connection.channelId}`);
this.joinChannel();
break;
case ServiceCommand.JOIN:
this.onMsgJoin(bodyParted);
break;
case ServiceCommand.JOINPART_USER:
this.onMsgJoinPartUser(bodyParted);
break;
case ServiceCommand.USER_TIMEOUT:
this.onMsgSetUserTimeout(bodyParted);
break;
case ServiceCommand.CHAT:
this.onMsgChatMsg(bodyParted);
break;
case ServiceCommand.CHAT_OGQ:
this.onMsgChatOgq(bodyParted);
break;
case ServiceCommand.OGQ_GIFT:
this.onMsgOgqGift(bodyParted);
break;
case ServiceCommand.SET_USERFLAG:
this.onMsgSetUserflag(bodyParted);
break;
case ServiceCommand.INOUT_MANAGER:
this.onMsgInoutManager(bodyParted);
break;
case ServiceCommand.ICE1:
break;
case ServiceCommand.ICE2:
this.onMsgIce(bodyParted);
break;
case ServiceCommand.SLOWMODE:
this.onMsgSlowMode(bodyParted);
break;
case ServiceCommand.BALLOON:
this.onMsgBalloon(bodyParted);
break;
case ServiceCommand.BALLOON_AD:
this.onMsgBalloonAd(bodyParted);
break;
case ServiceCommand.BALLOON_AD_STATION:
this.onMsgBalloonAdStation(bodyParted);
break;
case ServiceCommand.POLL:
this.onMsgPoll(bodyParted);
break;
case ServiceCommand.BLOCK_WORDS_LIST:
this.onMsgBlockWordsList(bodyParted);
break;
case ServiceCommand.STREAM_CLOSED:
this.onMsgStreamClosed(bodyParted);
break;
case ServiceCommand.SYSTEM_NOTICE:
this.onMsgNoticeSystem(bodyParted);
break;
case ServiceCommand.CHANNEL_NOTICE:
this.onMsgNotice(bodyParted);
break;
case ServiceCommand.SUBSCRIPTION_GIFT:
this.onMsgSubscriptionGift(bodyParted);
break;
case ServiceCommand.SUBSCRIPTION_NEW:
this.onMsgSubscriptionNew(bodyParted);
break;
case ServiceCommand.SUBSCRIPTION:
this.onMsgSubscription(bodyParted);
break;
default:
this.log.verbose(`${ServiceCommand[serviceCommand]}(${serviceCommand}): ${bodyParted.join(' ')}`);
break;
}
};
onMsgJoin = (bodyParted) => {
if (bodyParted[0] === '비밀번호가 틀렸습니다.') {
this.log.warn(`채널 입장 실패: 비밀번호가 틀렸습니다.`);
this.disconnect();
return;
}
const [chatNo, channelId, unknown1, unknown2, unknown3, unknown4, unknown5, unknown6,] = bodyParted;
this.connectedState = ConnectedState.JOINED;
this.log.info(`채널 입장: ${channelId}`);
this.emit('join', channelId);
};
onMsgJoinPartUser = (bodyParted) => {
const [joinType, // '1': join, other: part
userId, nickname, quitType, unknown1, userFlag,] = bodyParted;
if (joinType === '1') {
this.log.verbose(`입장: ${nickname}(${userId}),${joinType},${quitType},${unknown1},${userFlag}`);
}
else {
// 퇴장
if (quitType === '1') {
this.log.verbose(`퇴장: ${nickname}(${userId}),${joinType},${quitType},${unknown1},${userFlag}`);
}
else {
this.log.verbose(`강퇴: ${nickname}(${userId}),${joinType},${quitType},${unknown1},${userFlag}`);
this.emit('block', {
type: SoopBlockType.KICK,
duration: 0,
userId,
nickname,
by: 'manager',
});
}
}
};
onMsgSetUserTimeout = (bodyParted) => {
const [userId, userflag, duration, // '30'
unknown1, // '1'
byManagerId, unknown2, // '2'
unknown3, // ''
nickname, unknown4, // ''
] = bodyParted;
this.log.verbose(`채팅 금지: ${nickname}(${userId}) ${duration}s by ${byManagerId}`);
this.emit('block', {
type: SoopBlockType.TIMEOUT,
duration: parseInt(duration),
userId,
nickname,
by: byManagerId,
});
};
onMsgChatMsg = (bodyParted) => {
const [content, userId, normalColor, // '0'
userType, // '0' 0/3/default: normal, 1: staff, 2:police
language, // '3'
nickname, userflag, subscription, color, colorDarkmode,] = bodyParted;
let userTypeName;
switch (parseInt(userType)) {
case 1:
userTypeName = 'staff';
break;
case 2:
userTypeName = 'police';
break;
case 3:
case 0:
default:
userTypeName = 'normal';
}
const subscriptionNum = parseInt(subscription);
const userflag1 = parseInt(userflag.split('|')[0]);
const userflag2 = parseInt(userflag.split('|')[1]);
const isStreamer = this.isUserflag(Userflag1.HOST, userflag1);
const isFan = this.isUserflag(Userflag1.FANCLUB, userflag1);
const isSubscriber = this.isUserflag(Userflag1.FOLLOWER, userflag1);
const isTopfan = this.isUserflag(Userflag1.TOPFAN, userflag1);
const isManager = this.isUserflag(Userflag1.MANAGER2, userflag1);
const isFemale = this.isUserflag(Userflag1.FEMALE, userflag1);
const badge = isFemale ? '[여]' : '[남]';
const badge1 = subscriptionNum > 0 ? `[${subscription.padStart(2, '0')}]` : ' ';
const badge2 = isStreamer
? '[스]'
: isManager
? '[매]'
: isTopfan
? '[열]'
: isFan
? '[팬]'
: ' ';
const userflags = [];
for (const flag in Object.values(Userflag1)) {
const excluded = [
Userflag1.HOST,
Userflag1.FANCLUB,
Userflag1.FOLLOWER,
Userflag1.TOPFAN,
Userflag1.MANAGER2,
Userflag1.FEMALE,
];
if (excluded.includes(Number(flag))) {
continue;
}
if (Userflag1[flag] == undefined) {
continue;
}
if (this.isUserflag(Number(flag), userflag1)) {
userflags.push(Userflag1[flag]);
}
}
const userflags2 = [];
for (const flag in Object.values(Userflag2)) {
if (Userflag2[flag] == undefined) {
continue;
}
if (this.isUserflag(Number(flag), userflag2)) {
userflags2.push(Userflag2[flag]);
}
}
this.log.verbose(`채팅: ${badge}${badge1}${badge2}${chalk.hex('#' + colorDarkmode)(`${nickname}(${userId})`)}: ${content}`, '\t|', language, `${userTypeName}(${userType})`, userflags.join(' '), '|', userflags2.join(' '));
this.emit('chat', {
content: isSubscriber
? this.parseContent(content)
: [
{
type: SoopChatMessageContentType.TEXT,
body: content,
},
],
channelId: this.connection.channelId,
userId,
normalColor,
language,
userType: userTypeName,
nickname,
subscription: subscriptionNum,
isStreamer,
isFan,
isTopfan,
isManager,
isFemale,
color,
colorDarkmode,
userflag1,
userflag2,
stickerUrl: '',
});
};
parseContent = (content) => {
const contentList = [];
const emoteRegex = /\/([^\/]+)\//g;
let lastIndex = 0;
let match;
while ((match = emoteRegex.exec(content)) !== null) {
const emoteName = match[1];
const emoteInfo = this.emotes[emoteName];
if (emoteInfo) {
if (lastIndex < match.index) {
contentList.push({
type: SoopChatMessageContentType.TEXT,
body: content.slice(lastIndex, match.index),
});
}
contentList.push({
type: SoopChatMessageContentType.EMOTE,
body: emoteInfo,
});
lastIndex = match.index + match[0].length;
}
}
if (lastIndex < content.length) {
contentList.push({
type: SoopChatMessageContentType.TEXT,
body: content.slice(lastIndex),
});
}
return contentList;
};
onMsgChatOgq = (bodyParted) => {
const [unknown1, content, stickerSetId, stickerId, stickerVer, userId, nickname, userflag, unknown2, language, userType, stickerFiletype, subscription, color, colorDarkmode, unknown6,] = bodyParted;
let userTypeName;
switch (parseInt(userType)) {
case 1:
userTypeName = 'staff';
break;
case 2:
userTypeName = 'police';
break;
case 3:
case 0:
default:
userTypeName = 'normal';
}
const subscriptionNum = parseInt(subscription);
const userflag1 = parseInt(userflag.split('|')[0]);
const userflag2 = parseInt(userflag.split('|')[1]);
const isStreamer = this.isUserflag(Userflag1.HOST, userflag1);
const isFan = this.isUserflag(Userflag1.FANCLUB, userflag1);
const isSubscriber = this.isUserflag(Userflag1.FOLLOWER, userflag1);
const isTopfan = this.isUserflag(Userflag1.TOPFAN, userflag1);
const isManager = this.isUserflag(Userflag1.MANAGER2, userflag1);
const isFemale = this.isUserflag(Userflag1.FEMALE, userflag1);
const stickerUrl = `${stickerSetId}/${stickerId}_40.${stickerFiletype}?ver=${stickerVer}`;
this.log.verbose(`OGQ: ${chalk.hex('#' + colorDarkmode)(`${nickname}(${userId})`)}: ${content} ${stickerUrl}`);
this.emit('chat', {
content: isSubscriber
? this.parseContent(content)
: [
{
type: SoopChatMessageContentType.TEXT,
body: content,
},
],
channelId: this.connection.channelId,
userId,
normalColor: '',
language,
userType: userTypeName,
nickname,
subscription: subscriptionNum,
isStreamer,
isFan,
isTopfan,
isManager,
isFemale,
color,
colorDarkmode,
userflag1,
userflag2,
stickerUrl,
});
};
onMsgOgqGift = (bodyParted) => {
const [unknown1, senderUserId, senderNickname, receiverUserId, receiverNickname, stickerName, stickerUrl,] = bodyParted;
this.log.verbose(`OGQ 선물: ${receiverNickname}(${receiverUserId}) ${stickerName} from ${senderNickname}(${senderUserId}) ${stickerUrl}`);
};
onMsgSetUserflag = (bodyParted) => {
const [before, userId, nickname, unknown1, unknown2, after, unknown3] = bodyParted;
const userflagBefore = before.split('|').map(Number);
const userflagAfter = after.split('|').map(Number);
const flagDiffList = [];
for (const flag in Object.values(Userflag1)) {
if (Userflag1[flag] == undefined) {
continue;
}
const userFlagBefore = this.isUserflag(Number(flag), userflagBefore[0]);
const userFlagAfter = this.isUserflag(Number(flag), userflagAfter[0]);
if (userFlagBefore != userFlagAfter) {
flagDiffList.push([Userflag1[flag], userFlagBefore, userFlagAfter]);
}
}
for (const flag in Object.values(Userflag2)) {
if (Userflag2[flag] == undefined) {
continue;
}
const userFlagBefore = this.isUserflag(Number(flag), userflagBefore[1]);
const userFlagAfter = this.isUserflag(Number(flag), userflagAfter[1]);
if (userFlagBefore != userFlagAfter) {
flagDiffList.push([Userflag2[flag], userFlagBefore, userFlagAfter]);
}
}
this.log.verbose(`유저 플래그 변경: ${nickname}(${userId}): `, flagDiffList.map(([flagName, before, after]) => `${flagName} -> ${after}`));
};
onMsgInoutManager = (bodyParted) => {
const [userId, userflag, unknown, nickname] = bodyParted;
const userflag1 = parseInt(userflag.split('|')[0]);
const isManager = this.isUserflag(Userflag1.MANAGER2, userflag1);
this.log.info(`매니저: ${nickname}(${userId}) ${isManager ? '임명' : '해임'}`);
};
onMsgIce = (bodyParted) => {
const [isEnabled, // '1': enable, '0': disable
isEnabled2, // '1': enable, '0': disable
_iceflag, minFanBalloon, minSubscriptionNum,] = bodyParted;
const iceflag = parseInt(_iceflag);
if (isEnabled === '1') {
const iceflags = [];
for (const flag in Object.values(IceFlag)) {
if (IceFlag[flag] == undefined) {
continue;
}
if (this.isIceflag(Number(flag), iceflag)) {
iceflags.push(IceFlagName[Number(flag)]);
}
}
this.log.info(`얼리기: 설정 스트리머, ${iceflags.join(', ')} 이상, 최소 별풍개수: ${minFanBalloon}, 최소 구독 개월수: ${minSubscriptionNum}`);
}
else {
this.log.info(`얼리기: 해제`);
}
};
onMsgSlowMode = (bodyParted) => {
const [unknown1, duration] = bodyParted;
this.log.info(`저속모드: ${duration === '0' ? '해제' : duration + '초'}`);
};
onMsgBalloon = (bodyParted) => {
const [channelId, userId, nickname, count, fanClubOrder, unknown2, unknown3, imageName, unknown4, unknown5, ttsType,] = bodyParted;
const joinedFan = parseInt(fanClubOrder) > 0
? '로 ' + fanClubOrder + '번째 팬클럽 가입'
: '';
this.log.verbose(`별풍선: ${nickname}(${userId}) ${count}개${joinedFan}`, ttsType, imageName);
this.emit('balloon', {
type: SoopBalloonType.NORMAL,
userId,
nickname,
count: parseInt(count),
fanClubOrder: parseInt(fanClubOrder),
isStation: false,
imageName,
ttsType,
});
};
onMsgBalloonAd = (bodyParted) => {
const [unknown1, channelId, userId, nickname, title, message, unknown2, // ''
icon, imageName, count, fanClubOrder, unknown3, // '0'
unknown4, // '0'
unknown5, // '0'
ttsType, unknown6, // ''
] = bodyParted;
this.log.verbose(`애드벌룬: ${nickname}(${userId}) ${count}개${parseInt(fanClubOrder) > 0
? '로 ' + fanClubOrder + '번째 팬클럽 가입'
: ''}`, ttsType);
this.emit('balloon', {
type: SoopBalloonType.AD,
userId,
nickname,
count: parseInt(count),
fanClubOrder: parseInt(fanClubOrder),
isStation: false,
imageName,
ttsType,
});
};
onMsgBalloonAdStation = (bodyParted) => {
const [channelId, userId, nickname, count, image, message, unknown1] = bodyParted;
this.log.verbose(`방송국 애드벌룬: ${nickname}(${userId}) ${count}개`);
this.emit('balloon', {
type: SoopBalloonType.AD,
userId,
nickname,
count: parseInt(count),
fanClubOrder: 0,
isStation: true,
imageName: image,
ttsType: '',
});
};
onMsgPoll = (bodyParted) => {
const [status, channelId, pollId, unknown1] = bodyParted;
if (status === '1') {
this.log.info(`투표 시작: ${pollId}`);
}
else if (status === '4') {
this.log.info(`투표 종료: ${pollId}`);
}
};
onMsgBlockWordsList = (bodyParted) => {
const [filterded, _targets] = bodyParted;
if (_targets === '') {
return;
}
const targets = _targets.split('\x06');
if (targets.length > 0) {
this.log.verbose(`금칙어 목록: ${targets.join(', ')} -> ${filterded}`);
}
};
onMsgStreamClosed = (_) => {
this.log.warn(`방송 종료됨: ${this.connection.channelId}`);
this.disconnect();
};
onMsgNoticeSystem = (bodyParted) => {
const [notice, unknown1] = bodyParted;
this.log.info(`시스템 공지: ${notice}`);
};
onMsgNotice = (bodyParted) => {
const [unknown1, unknown2, unknown3, notice] = bodyParted;
this.log.info(`공지: ${notice}`);
};
onMsgSubscriptionGift = (bodyParted) => {
const [subGiftChatNo, subGiftChannelId, subGiftChannelName, subGiftUserId, subGiftNickname, subGiftGifterUserId, subGiftGifterNickname, subGiftTicketDuration, subGiftKey, subGiftUnknown2, // '0'
subGiftUnknown3, // '0'
subGiftUnknown4, // ''
subGiftUnknown5, // '0'
subGiftUnknown6, // '0'
subGiftUnknown7, // ''
subGiftUnknown8, // ''
] = bodyParted;
this.log.verbose(`구독권 선물: ${subGiftNickname}(${subGiftUserId}) ${subGiftTicketDuration}개월 from ${subGiftGifterNickname}(${subGiftGifterUserId}) \t ${subGiftKey}`);
};
onMsgSubscriptionNew = (bodyParted) => {
const [unknown1, channelId, userId, nickname, duration, // '-1: 그냥 구독', '15: 그냥 1개월', '13: 1개월', '2: 6개월', '7: 1개월 선물',
unknown2, // ''
] = bodyParted;
this.log.verbose(`신규 구독: ${nickname}(${userId}) \t ${duration}, ${unknown2}`);
};
onMsgSubscription = (bodyParted) => {
const [channelId, userId, nickname, duration, unknown1, unknown2, // ''
] = bodyParted;
this.log.verbose(`구독: ${nickname}(${userId}) ${duration}개월째 \t ${unknown2}`);
};
isUserflag = (flagName, userflag) => {
return !!((userflag >>> flagName) & 1);
};
isIceflag = (flagName, iceflag) => {
return !!((iceflag >>> flagName) & 1);
};
}