soop-extension
Version:
라이브 스트리밍 서비스 숲(soop)의 비공식 API 라이브러리
338 lines (337 loc) • 14.8 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.SoopChat = void 0;
const ws_1 = __importDefault(require("ws"));
const const_1 = require("../const");
const client_1 = require("../client");
const types_1 = require("./types");
const tls_1 = require("tls");
const https_1 = require("https");
const event_1 = require("./event");
class SoopChat {
client;
ws;
chatUrl;
liveDetail;
cookie = null;
options;
handlers = [];
pingIntervalId = null;
constructor(options) {
this.options = options;
this.options.baseUrls = options.baseUrls ?? const_1.DEFAULT_BASE_URLS;
this.client = options.client ?? new client_1.SoopClient({ baseUrls: options.baseUrls });
}
_connected = false;
_entered = false;
async connect() {
if (this._connected) {
this.errorHandling('Already connected');
}
if (this.options.login) {
this.cookie = await this.client.auth.signIn(this.options.login.userId, this.options.login.password);
this.liveDetail = await this.client.live.detail(this.options.streamerId, this.cookie);
}
else {
this.liveDetail = await this.client.live.detail(this.options.streamerId);
}
if (this.liveDetail.CHANNEL.RESULT === 0) {
throw this.errorHandling("Not Streaming now");
}
this.chatUrl = this.makeChatUrl(this.liveDetail);
this.ws = new ws_1.default(this.chatUrl, ['chat'], { agent: this.createAgent() });
this.ws.onopen = () => {
const CONNECT_PACKET = this.getConnectPacket();
this.ws.send(CONNECT_PACKET);
};
this.ws.onmessage = this.handleMessage.bind(this);
this.startPingInterval();
this.ws.onclose = () => {
this.disconnect();
};
}
async disconnect() {
if (!this._connected) {
return;
}
const receivedTime = new Date().toISOString();
this.emit(event_1.SoopChatEvent.DISCONNECT, { streamerId: this.options.streamerId, receivedTime: receivedTime });
this.stopPingInterval();
this.ws?.close();
this.ws = null;
this._connected = false;
}
async sendChat(message) {
if (!this.cookie?.AuthTicket) {
this.errorHandling("No Auth");
return false;
}
if (!this.ws)
return false;
if (!this._entered)
await this.waitForEnter();
const packet = `${types_1.ChatDelimiter.SEPARATOR.repeat(1)}${message}${types_1.ChatDelimiter.SEPARATOR.repeat(1)}0${types_1.ChatDelimiter.SEPARATOR.repeat(1)}`;
this.ws.send(`${types_1.ChatDelimiter.STARTER}${types_1.ChatType.CHAT}${this.getPayloadLength(packet)}00${packet}`);
return true;
}
on(event, handler) {
const e = event;
this.handlers[e] = this.handlers[e] || [];
this.handlers[e].push(handler);
}
emit(event, data) {
if (this.handlers[event]) {
for (const handler of this.handlers[event]) {
handler(data);
}
}
}
async handleMessage(data) {
const receivedTime = new Date().toISOString();
const packet = data.data.toString();
this.emit(event_1.SoopChatEvent.RAW, Buffer.from(packet));
const messageType = this.parseMessageType(packet);
switch (messageType) {
case types_1.ChatType.CONNECT:
this._connected = true;
const connect = this.parseConnect(packet);
this.emit(event_1.SoopChatEvent.CONNECT, { ...connect, streamerId: this.options.streamerId, receivedTime: receivedTime });
const JOIN_PACKET = this.getJoinPacket();
this.ws.send(JOIN_PACKET);
break;
case types_1.ChatType.ENTER_CHAT_ROOM:
const enterChatRoom = this.parseEnterChatRoom(packet);
this.emit(event_1.SoopChatEvent.ENTER_CHAT_ROOM, { ...enterChatRoom, receivedTime: receivedTime });
if (this.cookie?.AuthTicket) {
const ENTER_INFO_PACKET = this.getEnterInfoPacket(enterChatRoom.synAck);
this.ws.send(ENTER_INFO_PACKET);
}
this._entered = true;
break;
case types_1.ChatType.NOTIFICATION:
const notification = this.parseNotification(packet);
this.emit(event_1.SoopChatEvent.NOTIFICATION, { ...notification, receivedTime: receivedTime });
break;
case types_1.ChatType.CHAT:
const chat = this.parseChat(packet);
this.emit(event_1.SoopChatEvent.CHAT, { ...chat, receivedTime: receivedTime });
break;
case types_1.ChatType.VIDEO_DONATION:
const videoDonation = this.parseVideoDonation(packet);
this.emit(event_1.SoopChatEvent.VIDEO_DONATION, { ...videoDonation, receivedTime: receivedTime });
break;
case types_1.ChatType.TEXT_DONATION:
const textDonation = this.parseTextDonation(packet);
this.emit(event_1.SoopChatEvent.TEXT_DONATION, { ...textDonation, receivedTime: receivedTime });
break;
case types_1.ChatType.AD_BALLOON_DONATION:
const adBalloonDonation = this.parseAdBalloonDonation(packet);
this.emit(event_1.SoopChatEvent.AD_BALLOON_DONATION, { ...adBalloonDonation, receivedTime: receivedTime });
break;
case types_1.ChatType.EMOTICON:
const emoticon = this.parseEmoticon(packet);
this.emit(event_1.SoopChatEvent.EMOTICON, { ...emoticon, receivedTime: receivedTime });
break;
case types_1.ChatType.VIEWER:
const viewer = this.parseViewer(packet);
this.emit(event_1.SoopChatEvent.VIEWER, { ...viewer, receivedTime: receivedTime });
break;
case types_1.ChatType.SUBSCRIBE:
const subscribe = this.parseSubscribe(packet);
this.emit(event_1.SoopChatEvent.SUBSCRIBE, { ...subscribe, receivedTime: receivedTime });
break;
case types_1.ChatType.EXIT:
const exit = this.parseExit(packet);
this.emit(event_1.SoopChatEvent.EXIT, { ...exit, receivedTime: receivedTime });
break;
case types_1.ChatType.DISCONNECT:
await this.disconnect();
break;
default:
const parts = packet.split(types_1.ChatDelimiter.SEPARATOR);
this.emit(event_1.SoopChatEvent.UNKNOWN, parts);
break;
}
}
parseConnect(packet) {
const parts = packet.split(types_1.ChatDelimiter.SEPARATOR);
const [, username, syn] = parts;
return { username: username, syn: syn };
}
parseEnterChatRoom(packet) {
const parts = packet.split(types_1.ChatDelimiter.SEPARATOR);
const [, , streamerId, , , , , synAck] = parts;
return { streamerId: streamerId, synAck: synAck };
}
parseSubscribe(packet) {
const parts = packet.split(types_1.ChatDelimiter.SEPARATOR);
const [, to, from, fromUsername, amount, , , , tier] = parts;
return { to: to, from: from, fromUsername: fromUsername, amount: amount, tier: tier };
}
parseAdBalloonDonation(packet) {
const parts = packet.split(types_1.ChatDelimiter.SEPARATOR);
const [, , to, from, fromUsername, , , , , , amount, fanClubOrdinal] = parts;
return { to: to, from: from, fromUsername: fromUsername, amount: amount, fanClubOrdinal: fanClubOrdinal };
}
parseVideoDonation(packet) {
const parts = packet.split(types_1.ChatDelimiter.SEPARATOR);
const [, , to, from, fromUsername, amount, fanClubOrdinal] = parts;
return { to: to, from: from, fromUsername: fromUsername, amount: amount, fanClubOrdinal: fanClubOrdinal };
}
parseViewer(packet) {
const parts = packet.split(types_1.ChatDelimiter.SEPARATOR);
if (parts.length > 4) {
return { userId: this.getViewerElements(parts) };
}
else {
const [, userId] = parts;
return { userId: [userId] };
}
}
parseExit(packet) {
const parts = packet.split(types_1.ChatDelimiter.SEPARATOR);
const [, , userId, username] = parts;
return { userId: userId, username: username };
}
parseEmoticon(packet) {
const parts = packet.split(types_1.ChatDelimiter.SEPARATOR);
const [, , , emoticonId, , , userId, username] = parts;
return { userId: userId, username: username, emoticonId: emoticonId };
}
parseTextDonation(packet) {
const parts = packet.split(types_1.ChatDelimiter.SEPARATOR);
const [, to, from, fromUsername, amount, fanClubOrdinal] = parts;
return { to: to, from: from, fromUsername: fromUsername, amount: amount, fanClubOrdinal: fanClubOrdinal };
}
parseNotification(packet) {
const parts = packet.split(types_1.ChatDelimiter.SEPARATOR);
const [, , , , notification] = parts;
return { notification: notification };
}
parseChat(packet) {
const parts = packet.split(types_1.ChatDelimiter.SEPARATOR);
const [, comment, userId, , , , username] = parts;
return { userId: userId, comment: comment, username: username };
}
parseMessageType(packet) {
if (!packet.startsWith(types_1.ChatDelimiter.STARTER)) {
throw this.errorHandling("Invalid data: does not start with STARTER byte");
}
if (packet.length >= 5) {
return packet.substring(2, 6);
}
throw this.errorHandling("Invalid data: does not have any data for opcode");
}
startPingInterval() {
if (this.pingIntervalId) {
clearInterval(this.pingIntervalId);
}
this.pingIntervalId = setInterval(() => this.sendPing(), 60000);
}
stopPingInterval() {
if (this.pingIntervalId) {
clearInterval(this.pingIntervalId);
this.pingIntervalId = null;
}
}
sendPing() {
if (!this.ws)
return;
const packet = this.getPacket(types_1.ChatType.PING, types_1.ChatDelimiter.SEPARATOR);
this.ws.send(packet);
}
makeChatUrl(detail) {
return `wss://${detail.CHANNEL.CHDOMAIN.toLowerCase()}:${Number(detail.CHANNEL.CHPT) + 1}/Websocket/${this.options.streamerId}`;
}
getByteSize(input) {
return Buffer.byteLength(input, 'utf-8');
}
getPayloadLength(packet) {
return this.getByteSize(packet).toString().padStart(6, '0');
}
createAgent() {
const options = {};
const secureContext = (0, tls_1.createSecureContext)(options);
return new https_1.Agent({
secureContext,
rejectUnauthorized: false
});
}
getViewerElements(array) {
return array.filter((_, index) => index % 2 === 1);
}
getConnectPacket() {
let payload = `${types_1.ChatDelimiter.SEPARATOR.repeat(3)}16${types_1.ChatDelimiter.SEPARATOR}`;
if (this.cookie?.AuthTicket) {
payload = `${types_1.ChatDelimiter.SEPARATOR.repeat(1)}${this.cookie?.AuthTicket}${types_1.ChatDelimiter.SEPARATOR.repeat(2)}16${types_1.ChatDelimiter.SEPARATOR}`;
}
return this.getPacket(types_1.ChatType.CONNECT, payload);
}
getJoinPacket() {
let payload = `${types_1.ChatDelimiter.SEPARATOR}${this.liveDetail.CHANNEL.CHATNO}`;
if (this.cookie) {
payload += `${types_1.ChatDelimiter.SEPARATOR}${this.liveDetail.CHANNEL.FTK}`;
payload += `${types_1.ChatDelimiter.SEPARATOR}0${types_1.ChatDelimiter.SEPARATOR}`;
const log = {
set_bps: this.liveDetail.CHANNEL.BPS,
view_bps: this.liveDetail.CHANNEL.VIEWPRESET[0].bps,
quality: 'normal',
uuid: this.cookie._au,
geo_cc: this.liveDetail.CHANNEL.geo_cc,
geo_rc: this.liveDetail.CHANNEL.geo_rc,
acpt_lang: this.liveDetail.CHANNEL.acpt_lang,
svc_lang: this.liveDetail.CHANNEL.svc_lang,
subscribe: 0,
lowlatency: 0,
mode: "landing"
};
const query = this.objectToQueryString(log);
payload += `log${types_1.ChatDelimiter.ELEMENT_START}${query}${types_1.ChatDelimiter.ELEMENT_END}`;
payload += `pwd${types_1.ChatDelimiter.ELEMENT_START}${types_1.ChatDelimiter.ELEMENT_END}`;
payload += `auth_info${types_1.ChatDelimiter.ELEMENT_START}NULL${types_1.ChatDelimiter.ELEMENT_END}`;
payload += `pver${types_1.ChatDelimiter.ELEMENT_START}2${types_1.ChatDelimiter.ELEMENT_END}`;
payload += `access_system${types_1.ChatDelimiter.ELEMENT_START}html5${types_1.ChatDelimiter.ELEMENT_END}`;
payload += `${types_1.ChatDelimiter.SEPARATOR}`;
}
else {
payload += `${types_1.ChatDelimiter.SEPARATOR.repeat(5)}`;
}
return this.getPacket(types_1.ChatType.ENTER_CHAT_ROOM, payload);
}
getEnterInfoPacket(synAck) {
const payload = `${types_1.ChatDelimiter.SEPARATOR}${synAck}${types_1.ChatDelimiter.SEPARATOR}0${types_1.ChatDelimiter.SEPARATOR}`;
return this.getPacket(types_1.ChatType.ENTER_INFO, payload);
}
getPacket(chatType, payload) {
const packetHeader = `${types_1.ChatDelimiter.STARTER}${chatType}${this.getPayloadLength(payload)}00`;
return packetHeader + payload;
}
waitForEnter() {
return new Promise((resolve) => {
if (this._entered) {
resolve();
return;
}
const checkInterval = setInterval(() => {
if (this._entered) {
clearInterval(checkInterval);
resolve();
}
}, 500);
});
}
errorHandling(message) {
const error = new Error(message);
console.error(error);
return error;
}
objectToQueryString(obj) {
return Object.entries(obj)
.map(([key, value]) => `${types_1.ChatDelimiter.SPACE}&${types_1.ChatDelimiter.SPACE}${key}${types_1.ChatDelimiter.SPACE}=${types_1.ChatDelimiter.SPACE}${value}`)
.join("");
}
}
exports.SoopChat = SoopChat;