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; 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 res = await $http.get(this._apiPrefix + "/v1/chat/channels"); const data = JSON.parse(res); 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 { this._socket.emit("whisper", { to: recipient, message: msg, }); } }