import Axios, {AxiosError, AxiosResponse} from 'axios'; import * as qs from 'qs'; import {Connection as Interfaces, WebSocketConnection} from './interfaces'; import ReadyState = WebSocketConnection.ReadyState; import log from 'electron-log'; //tslint:disable-line:match-default-export-name import core from '../chat/core'; import throat from 'throat'; const fatalErrors = [2, 3, 4, 9, 30, 31, 33, 39, 40, 62, -4]; const dieErrors = [9, 30, 31, 39, 40]; let lastFetch = Date.now(); let lastApiTicketFetch = Date.now(); const queryApiThroat = throat(2); const queryTicketThroat = throat(1); async function queryApi(this: void, endpoint: string, data: object): Promise { lastFetch = Date.now(); return Axios.post(`https://www.f-list.net/json/api/${endpoint}`, qs.stringify(data)); } export default class Connection implements Interfaces.Connection { character = ''; vars: Interfaces.Vars = {}; //tslint:disable-line:no-any protected socket: WebSocketConnection | undefined = undefined; //tslint:disable-next-line:no-object-literal-type-assertion private messageHandlers = <{ [key in keyof Interfaces.ServerCommands]: Interfaces.CommandHandler[] }>{}; private connectionHandlers: { [key in Interfaces.EventType]?: Interfaces.EventHandler[] } = {}; private errorHandlers: ((error: Error) => void)[] = []; private ticket = ''; private cleanClose = false; private reconnectTimer: NodeJS.Timer | undefined; private account = ''; private ticketProvider?: Interfaces.TicketProvider; private reconnectDelay = 0; private isReconnect = false; private pinTimeout?: NodeJS.Timer; constructor(private readonly clientName: string, private readonly version: string, private readonly socketProvider: new() => WebSocketConnection) { } setCredentials(account: string, ticketProvider: Interfaces.TicketProvider | string): void { this.account = account; this.ticketProvider = typeof ticketProvider === 'string' ? async() => this.getTicket(ticketProvider) : ticketProvider; } async connect(character: string): Promise { if(!this.ticketProvider) throw new Error('No credentials set!'); this.cleanClose = false; if(this.character !== character) this.isReconnect = false; this.character = character; try { this.ticket = await this.ticketProvider(); } catch(e) { if(this.reconnectTimer !== undefined) if((e).request !== undefined) this.reconnect(); else await this.invokeHandlers('closed', false); return this.invokeErrorHandlers(e, true); } try { await this.invokeHandlers('connecting', this.isReconnect); } catch(e) { await this.invokeHandlers('closed', false); return this.invokeErrorHandlers(e); } if(this.cleanClose) { this.cleanClose = false; await this.invokeHandlers('closed', false); return; } try { this.socket = new this.socketProvider(); } catch(e) { await this.invokeHandlers('closed', false); return this.invokeErrorHandlers(e, true); } this.socket.onOpen(() => { this.send('IDN', { account: this.account, character: this.character, cname: this.clientName, cversion: this.version, method: 'ticket', ticket: this.ticket }); this.resetPinTimeout(); }); this.socket.onMessage(async(msg: string) => { const type = msg.substr(0, 3); const data = msg.length > 6 ? JSON.parse(msg.substr(4)) : undefined; log.silly( 'socket.recv', { type, data } ); return this.handleMessage(type, data); }); this.socket.onClose(async(event: CloseEvent) => { log.debug( 'socket.onclose', { code: event.code, reason: event.reason, wasClean: event.wasClean, event } ); if(this.pinTimeout) clearTimeout(this.pinTimeout); if(!this.cleanClose) this.reconnect(); this.socket = undefined; await this.invokeHandlers('closed', !this.cleanClose); }); this.socket.onError((error: Error) => this.invokeErrorHandlers(error, true)); } private reconnect(): void { this.reconnectTimer = setTimeout(async() => this.connect(this.character), this.reconnectDelay); this.reconnectDelay = this.reconnectDelay >= 30000 ? 60000 : this.reconnectDelay >= 10000 ? 30000 : 10000; } close(keepState: boolean = true): void { if(this.reconnectTimer !== undefined) clearTimeout(this.reconnectTimer); this.reconnectTimer = undefined; this.cleanClose = true; if(this.socket !== undefined) this.socket.close(); if(!keepState) { this.character = ''; } } get isOpen(): boolean { return this.socket !== undefined && this.socket.readyState === ReadyState.OPEN; } async queryApi(endpoint: string, data?: {account?: string, ticket?: string}): Promise { return queryApiThroat(async() => this.queryApiExec(endpoint, data)); } protected async refreshTicket(oldTicket: string): Promise { if (this.ticket !== oldTicket) { log.debug( 'api.ticket.renew.resolve.cache', { character: core.characters.ownCharacter?.name } ); return this.ticket; } if (!this.ticketProvider) { throw new Error('No credentials set!'); } this.ticket = await queryTicketThroat(async() => this.ticketProvider!()); log.debug( 'api.ticket.renew.resolve.refresh', { character: core.characters.ownCharacter?.name } ); return this.ticket; } protected async queryApiExec(endpoint: string, data?: {account?: string, ticket?: string}): Promise { if(!this.ticketProvider) throw new Error('No credentials set!'); log.debug( 'api.query.start', { endpoint, data, character: core.characters.ownCharacter?.name, deltaToLastApiCall: Date.now() - lastFetch, deltaToLastApiTicket: Date.now() - lastApiTicketFetch } ); if(data === undefined) data = {}; data.account = this.account; data.ticket = this.ticket; let res = (await queryApi(endpoint, data)).data; if(res.error === 'Invalid ticket.' || res.error === 'Your login ticket has expired (five minutes) or no ticket requested.') { log.debug( 'api.ticket.loss', { error: res.error, character: core.characters.ownCharacter?.name, deltaToLastApiCall: Date.now() - lastFetch, deltaToLastApiTicket: Date.now() - lastApiTicketFetch } ); data.ticket = await this.refreshTicket(data.ticket); res = (await queryApi(endpoint, data)).data; } if(res.error !== '') { log.debug( 'api.query.error', { error: res.error, endpoint, data, character: core.characters.ownCharacter?.name, deltaToLastApiCall: Date.now() - lastFetch, deltaToLastApiTicket: Date.now() - lastApiTicketFetch } ); const error = new Error(res.error); (error).request = true; throw error; } log.debug( 'api.query.success', { endpoint, data, character: core.characters.ownCharacter?.name, deltaToLastApiCall: Date.now() - lastFetch, deltaToLastApiTicket: Date.now() - lastApiTicketFetch } ); return res; } onError(handler: (error: Error) => void): void { this.errorHandlers.push(handler); } onEvent(type: Interfaces.EventType, handler: Interfaces.EventHandler): void { let handlers = this.connectionHandlers[type]; if(handlers === undefined) handlers = this.connectionHandlers[type] = []; handlers.push(handler); } offEvent(type: Interfaces.EventType, handler: Interfaces.EventHandler): void { const handlers = this.connectionHandlers[type]; if(handlers === undefined) return; handlers.splice(handlers.indexOf(handler), 1); } onMessage(type: K, handler: Interfaces.CommandHandler): void { let handlers = [] | undefined>this.messageHandlers[type]; if(handlers === undefined) handlers = this.messageHandlers[type] = []; handlers.push(handler); } offMessage(type: K, handler: Interfaces.CommandHandler): void { const handlers = [] | undefined>this.messageHandlers[type]; if(handlers === undefined) return; handlers.splice(handlers.indexOf(handler), 1); } send(command: K, data?: Interfaces.ClientCommands[K]): void { if(this.socket !== undefined && this.socket.readyState === WebSocketConnection.ReadyState.OPEN) { const msg = command + (data !== undefined ? ` ${JSON.stringify(data)}` : ''); log.silly('socket.send', { data: msg }); this.socket.send(msg); } } //tslint:disable:no-unsafe-any no-any protected async handleMessage(type: T, data: any): Promise { const time = new Date(); const handlers = [] | undefined>this.messageHandlers[type]; if(handlers !== undefined) for(const handler of handlers) await handler(data, time); switch(type) { case 'VAR': this.vars[data.variable] = data.value; break; case 'PIN': this.send('PIN'); this.resetPinTimeout(); break; case 'ERR': if(fatalErrors.indexOf(data.number) !== -1) { this.invokeErrorHandlers(new Error(data.message), true); if(dieErrors.indexOf(data.number) !== -1) { this.close(); this.character = ''; } else this.socket!.close(); } break; case 'NLN': if(data.identity === this.character) { await this.invokeHandlers('connected', this.isReconnect); this.reconnectDelay = 0; this.isReconnect = true; } } } //tslint:enable private async getTicket(password: string): Promise { console.log('Acquiring new API ticket'); const oldLastApiTicketFetch = lastApiTicketFetch; log.debug( 'api.getTicket.start', { character: core.characters.ownCharacter?.name, deltaToLastApiCall: Date.now() - lastFetch, deltaToLastApiTicket: Date.now() - oldLastApiTicketFetch } ); lastApiTicketFetch = Date.now(); const data = <{ticket?: string, error: string}>(await Axios.post('https://www.f-list.net/json/getApiTicket.php', qs.stringify( {account: this.account, password, no_friends: true, no_bookmarks: true, no_characters: true}))).data; if(data.ticket !== undefined) { log.debug( 'api.getTicket.success', { character: core.characters.ownCharacter?.name, deltaToLastApiCall: Date.now() - lastFetch, deltaToLastApiTicket: Date.now() - oldLastApiTicketFetch } ); return data.ticket; } console.error('API Ticket Error', data.error); log.error( 'error.api.getTicket', { character: core.characters.ownCharacter.name, error: data.error, deltaToLastApiCall: Date.now() - lastFetch, deltaToLastApiTicket: Date.now() - oldLastApiTicketFetch } ); throw new Error(data.error); } private async invokeHandlers(type: Interfaces.EventType, isReconnect: boolean): Promise { const handlers = this.connectionHandlers[type]; if(handlers === undefined) return; for(const handler of handlers) await handler(isReconnect); } private invokeErrorHandlers(error: Error, request: boolean = false): void { if(request) (error).request = true; for(const handler of this.errorHandlers) handler(error); } private resetPinTimeout(): void { if(this.pinTimeout) clearTimeout(this.pinTimeout); this.pinTimeout = setTimeout( () => { log.error('pin.timeout'); this.socket!.close(); }, 90000 ); } }