diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..f8cf0fe --- /dev/null +++ b/src/api.ts @@ -0,0 +1,310 @@ +import $http from "./httpClient"; +import logger from "./log"; +import { io } from "socket.io-client"; + +const $log = new logger("[bitwave.tv API]"); + +export interface Message { + avatar: string; + badge: string; + color: string; + unum: number; + message: string; + timestamp: number; + username: string; + channel: string; + global: boolean; + type: string; + id: string; +} + +export interface OutgoingMessage { + message: string; + channel: string; + global: boolean; + showBadge: boolean; +} + +export type Credentials = string | Token | void; + +const apiPrefix = "https://api.bitwave.tv/api/"; +const chatServer = "https://chat.bitwave.tv"; +const whisperEndpoint: [string, string] = [ + "api.bitwave.tv", + "/v1/whispers/send", +]; + +export interface Token { + recaptcha: any; + page: string; + jwt: string; +} + +/* ========================================= */ + +export class FedwaveChat { + private _socket = null; + + private _chatServer = "https://fw.rnih.org"; + private _apiPrefix = "https://fw.rnih.org"; + private _whisperEndpoint = "/v1/whispers/send"; + + public async getTrollToken() { + try { + return await $http.get(this._apiPrefix + "/mktroll"); + } catch (e) { + $log.error(`Couldn't get troll token!`, e); + } + } + + private userProfile: Token = { + recaptcha: null, + page: "global", // room name + jwt: null, + }; + + /** + * Uses `credentials` to get a token from the server. + * + * @return JWT token as string + */ + public async initToken(credentials: Token | void) { + if (credentials) { + this.userProfile = credentials; + } else { + this.userProfile.jwt = await this.getTrollToken(); + this.onUpdateCredentials(this.userProfile.jwt); + } + } + + public global: boolean | any = true; /**< Global chat mode flag */ + + /** + * Callback function that receives messages (in bulk) + * @param ms Message object array + */ + public rcvMessageBulk(ms: Message[]): void { + for (const m of ms) console.log(m); + } + + /** + * Callback function that receives paid chat alert objects + * @param message Alert object + */ + public alert(message: Object): void { + $log.warn(`Received alert: `, message); + } + + public channelViewers = []; /**< Array of channel viewers. */ + + /** + * Callback function that gets called when the list of usernames gets updated. + */ + public async onUpdateUsernames(newViewers: any[]) {} + + /** + * Callback function that gets called when current credentials change. + */ + public async onUpdateCredentials(newCredentials: Credentials) {} + + /** + * Gets an array of usernames from the server and puts it in channelViewers + * It is called automatically at request from the server, but can be called manually + * @see channelViewers + */ + public async updateUsernames(): Promise { + try { + const data = await $http.get(this._apiPrefix + "/v1/chat/channels"); + if (data && data.success) { + await this.onUpdateUsernames(data.data); + this.channelViewers = data.data; + } + } catch (e) { + $log.error(`Couldn't update usernames!`); + console.error(e); + } + } + + public onHydrate(data: Message[]) { + this.rcvMessageBulk(data); + } + + /** + * Requests messages from the server (called hydration) + * It is called automatically when reconnecting. + * @see socketError() + * @return False if unsuccessful or empty + */ + public async hydrate(): Promise { + try { + const url: string = + this._apiPrefix + + "/v1/messages/" + + (!this.global && this.userProfile.page ? this.userProfile.page : ""); + const data = JSON.parse(await $http.get(url)); + if (!data.size) + return $log.warn("Hydration data was empty") === undefined && false; + + this.onHydrate(data.data); + return true; + } catch (e) { + $log.error(`Couldn't get chat hydration data!`); + console.error(e); + return false; + } + } + + /** + * This function is called when connecting to the server + */ + public socketConnect() { + this._socket.emit("new user", this.userProfile); + $log.info(`Connected to chat! (${this.userProfile.page})`); + } + + /** + * This function is called when the server issues a reconnect. + * It force hydrates chat to catch up. + */ + public async socketReconnect() { + $log.info("Socket issued 'reconnect'. Forcing hydration..."); + await this.hydrate(); + } + + /** + * This function is called when there's a socket error. + */ + public async socketError(message: string, error) { + $log.error(`Socket error: ${message}`, error); + // TODO: handle error + } + + public blocked(data) { + $log.info("TODO: handle blocked event", data); + } + + public pollstate(data) { + $log.info("TODO: handle pollstate event", data); + } + + public constructor(doLogging?: boolean) { + $log.doOutput = doLogging; + } + + /** + * Inits data and starts connection to server + * @param room is a string for the channel you wish to connect to + * @param credentials User credentials if falsy, gets a new troll token. If a string, it's taken as the JWT chat token + * @param specificServer URI to a specific chat server + */ + async connect( + room: string, + credentials: string | Token | void, + specificServer?: string + ) { + if (typeof credentials === "string") this.userProfile.jwt = credentials; + else this.initToken(credentials); + + this.userProfile.page = room; + + const socketOptions = { transports: ["websocket"] }; + this._socket = io(specificServer || this._chatServer, socketOptions); + + const sockSetup = new Map([ + [ + "connect", + async () => { + this._socket.emit("new user", this.userProfile); + $log.info(`Connected to chat! (${this.userProfile.page})`); + await this.socketConnect.call(this); + }, + ], + [ + "reconnect", + async () => { + $log.info("Socket issued 'reconnect'. Forcing hydration..."); + await this.hydrate(); + await this.socketReconnect.call(this); + }, + ], + [ + "error", + async (error: Object) => { + // TODO: handle error + $log.error(`Socket error: Connection Failed`, error); + await this.socketError.call(this, `Connection Failed`, error); + }, + ], + [ + "disconnect", + async (data: Object) => + await $log.error(`Socket error: Connection Lost`, data), + ], + ["update usernames", async () => await this.updateUsernames()], + [ + "bulkmessage", + async (data: Message[]) => await this.rcvMessageBulk(data), + ], + ["alert", async (data) => await this.alert(data)], + ]); + + sockSetup.forEach((cb, event) => { + this._socket.on(event, cb); + }); + } + + get room() { + return this.userProfile.page; + } /**< Current room */ + set room(r) { + this.userProfile.page = r; + $log.info(`Changed to room ${r}`); + } + + get doLogging() { + return $log.doOutput; + } /**< Enable log output */ + set doLogging(r) { + $log.doOutput = r; + } + + get socket() { + return this._socket; + } /**< Deprecated, but allows access to underlying socket */ + set socket(s) { + this._socket = s; + } + + disconnect(): void { + this.socket?.off(); + this.socket?.disconnect(); + } + + /** + * Sends message with current config (this.userProfile) + * @param msg Message to be sent. Can be an object: { message, channel, global, showBadge }, or just a string (in which case channel/global use current values) + */ + sendMessage(msg: OutgoingMessage | string): void { + switch (typeof msg) { + case "object": + this._socket.emit("message", msg); + break; + case "string": + this._socket.emit("message", { + message: msg, + channel: this.userProfile.page, + global: this.global, + showBadge: true, + }); + break; + } + } + + async sendWhisper(recipient: string, msg: string): Promise { + await $http.post(this._whisperEndpoint[0], this._whisperEndpoint[1], { + chatToken: this.userProfile.jwt, + receiver: recipient, + message: msg, + }); + } +} diff --git a/src/httpClient.ts b/src/httpClient.ts new file mode 100644 index 0000000..1d61306 --- /dev/null +++ b/src/httpClient.ts @@ -0,0 +1,45 @@ +const isNode = typeof process === "object"; + +const $get = (url: string, cb: Function): void => { + const req = new XMLHttpRequest(); + req.onreadystatechange = () => { + if (req.readyState === 4 && req.status === 200) { + cb(req.responseText); + } + }; + req.open("GET", url, true); + req.send(null); +}; + +const $post = ( + url: string, + path: string, + data: any, + cb: (...args: any[]) => void +): void => { + const req = new XMLHttpRequest(); + req.onreadystatechange = () => { + if (req.readyState === 4 && req.status === 200) { + cb(JSON.parse(req.responseText)); + } + }; + req.open("POST", url + path); + + req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); + req.setRequestHeader("X-Requested-With", "XMLHttpRequest"); + + req.send(JSON.stringify(data)); +}; + +export default { + get: (url: string): Promise => { + return new Promise((resolve) => { + $get(url, (response) => resolve(response)); + }); + }, + post: (url: string, path: string, data: Object): Promise => { + return new Promise((resolve) => { + $post(url, path, data, (response) => resolve(response)); + }); + }, +}; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..d158c57 --- /dev/null +++ b/src/index.ts @@ -0,0 +1 @@ +export * from "./api"; diff --git a/src/log.ts b/src/log.ts new file mode 100644 index 0000000..1f0f0dc --- /dev/null +++ b/src/log.ts @@ -0,0 +1,32 @@ + +/** + * Represents a logger + * @constructor + * @param {string} prefix - logger prefix + */ + +export default class { + public readonly prefix: string; + public doOutput: boolean = true; + + /** Creates new logger */ + constructor( prefix?: string, doOutput?: boolean ) { + this.doOutput = doOutput; + this.prefix = (prefix ?? '[bitwave.tv API]') + ' '; + } + + /** Creates logger info */ + info( message: String, ...args ): void { + this.doOutput && console.log( this.prefix + message, ...args ); + } + + /** Creates logger warn */ + warn( message: String, ...args ): void { + this.doOutput && console.warn( this.prefix + '[WARN] ' + message, ...args ); + } + + /** Creates logger error */ + error( message: String, ...args ): void { + this.doOutput && console.error( this.prefix + '[ERROR] ' + message, ...args ); + } +}