UNPKG

@tsuk1ko/cq-websocket

Version:

A Node SDK for developing QQ chatbots based on WebSocket, which is depending on CoolQ and CQHTTP API plugin.

615 lines (548 loc) 19.1 kB
const { WebSocket } = require('ws'); const shortid = require('shortid'); const $get = require('lodash.get'); const $CQEventBus = require('./event-bus.js').CQEventBus; const $Callable = require('./util/callable'); const message = require('./message'); const { parse: parseCQTags, convertArrayMsgToStringMsg } = message; const { SocketError, InvalidWsTypeError, InvalidContextError, APITimeoutError, UnexpectedContextError, } = require('./errors'); const WebSocketType = { API: '/api', EVENT: '/event', }; const WebSocketState = { DISABLED: -1, INIT: 0, CONNECTING: 1, CONNECTED: 2, CLOSING: 3, CLOSED: 4, }; const WebSocketProtocols = ['https:', 'http:', 'ws:', 'wss:']; class CQWebSocket extends $Callable { constructor({ // connectivity configs protocol = 'ws:', host = '127.0.0.1', port = 6700, accessToken = '', baseUrl, // application aware configs enableAPI = true, enableEvent = true, qq = -1, // deprecated // reconnection configs reconnection = true, reconnectionAttempts = Infinity, reconnectionDelay = 1000, // API request options requestOptions = {}, // underlying websocket configs, only meaningful in Nodejs environment // fragmentOutgoingMessages = false, // fragmentationThreshold, // tlsOptions, forcePostFormat, } = {}) { super('__call__'); /// *****************/ // poka-yoke 😇 /// *****************/ protocol = protocol.toLowerCase(); if (protocol && !protocol.endsWith(':')) protocol += ':'; if (baseUrl && WebSocketProtocols.filter(proto => baseUrl.startsWith(proto + '//')).length === 0) { baseUrl = `${protocol}//${baseUrl}`; } /// *****************/ // options /// *****************/ this._token = String(accessToken); this._qq = parseInt(qq); // deprecated this._baseUrl = baseUrl || `${protocol}//${host}:${port}`; this._reconnectOptions = { reconnection, reconnectionAttempts, reconnectionDelay, }; this._requestOptions = requestOptions; // this._wsOptions = {}; // Object.entries({ // fragmentOutgoingMessages, // fragmentationThreshold, // tlsOptions, // }) // .filter(([k, v]) => v !== undefined) // .forEach(([k, v]) => { // this._wsOptions[k] = v; // }); this._forcePostFormat = forcePostFormat; /// *****************/ // states /// *****************/ this._monitor = { EVENT: { attempts: 0, state: enableEvent ? WebSocketState.INIT : WebSocketState.DISABLED, reconnecting: false, }, API: { attempts: 0, state: enableAPI ? WebSocketState.INIT : WebSocketState.DISABLED, reconnecting: false, }, }; /** * @type {Map<string, {onSuccess:Function,onFailure:Function}>} */ this._responseHandlers = new Map(); this._eventBus = new $CQEventBus(this); } off(eventType, handler) { this._eventBus.off(eventType, handler); return this; } on(eventType, handler) { this._eventBus.on(eventType, handler); return this; } once(eventType, handler) { this._eventBus.once(eventType, handler); return this; } __call__(method, params, optionsIn) { if (!this._apiSock) return Promise.reject(new Error('API socket has not been initialized.')); let options = { timeout: Infinity, ...this._requestOptions, }; if (typeof optionsIn === 'number') { options.timeout = optionsIn; } else if (typeof optionsIn === 'object') { options = { ...options, ...optionsIn, }; } return new Promise((resolve, reject) => { let ticket; const apiRequest = { action: method, params: params, }; this._eventBus.emit('api.send.pre', apiRequest); const onSuccess = ctxt => { if (ticket) { clearTimeout(ticket); ticket = undefined; } this._responseHandlers.delete(reqid); delete ctxt.echo; resolve(ctxt); }; const onFailure = err => { this._responseHandlers.delete(reqid); reject(err); }; const reqid = shortid.generate(); this._responseHandlers.set(reqid, { onFailure, onSuccess }); this._apiSock.send( JSON.stringify({ ...apiRequest, echo: { reqid }, }), ); this._eventBus.emit('api.send.post'); if (options.timeout < Infinity) { ticket = setTimeout(() => { this._responseHandlers.delete(reqid); onFailure(new APITimeoutError(options.timeout, apiRequest)); }, options.timeout); } }); } _handle(msgObj) { switch (msgObj.post_type) { case 'message': // parsing coolq tags const tags = parseCQTags(msgObj.message); if (this._forcePostFormat === 'string' && Array.isArray(msgObj.message)) { if (typeof msgObj.raw_message === 'string') { msgObj.message = msgObj.raw_message; } else { msgObj.message = convertArrayMsgToStringMsg(msgObj.message); } } switch (msgObj.message_type) { case 'private': this._eventBus.emit('message.private', msgObj, tags); break; case 'discuss': this._handleGroupMsg('discuss', msgObj, tags); break; case 'group': this._handleGroupMsg('group', msgObj, tags); break; case 'guild': this._handleGroupMsg('guild', msgObj, tags); break; default: this._eventBus.emit('message', msgObj, tags); } break; case 'notice': // Added, reason: CQHttp 4.X switch (msgObj.notice_type) { case 'group_upload': this._eventBus.emit('notice.group_upload', msgObj); break; case 'group_admin': switch (msgObj.sub_type) { case 'set': this._eventBus.emit('notice.group_admin.set', msgObj); break; case 'unset': this._eventBus.emit('notice.group_admin.unset', msgObj); break; default: this._eventBus.emit('notice.group_admin', msgObj); } break; case 'group_decrease': switch (msgObj.sub_type) { case 'leave': this._eventBus.emit('notice.group_decrease.leave', msgObj); break; case 'kick': this._eventBus.emit('notice.group_decrease.kick', msgObj); break; case 'kick_me': this._eventBus.emit('notice.group_decrease.kick_me', msgObj); break; default: this._eventBus.emit('notice.group_decrease', msgObj); } break; case 'group_increase': switch (msgObj.sub_type) { case 'approve': this._eventBus.emit('notice.group_increase.approve', msgObj); break; case 'invite': this._eventBus.emit('notice.group_increase.invite', msgObj); break; default: this._eventBus.emit('notice.group_increase', msgObj); } break; case 'group_ban': switch (msgObj.sub_type) { case 'ban': this._eventBus.emit('notice.group_ban.ban', msgObj); break; case 'lift_ban': this._eventBus.emit('notice.group_ban.lift_ban', msgObj); break; default: this._eventBus.emit('notice.group_ban', msgObj); } break; case 'friend_add': this._eventBus.emit('notice.friend_add', msgObj); break; case 'group_recall': this._eventBus.emit('notice.group_recall', msgObj); break; case 'friend_recall': this._eventBus.emit('notice.friend_recall', msgObj); break; case 'notify': switch (msgObj.sub_type) { case 'poke': this._eventBus.emit('notice.notify.poke', msgObj); break; case 'lucky_king': this._eventBus.emit('notice.notify.lucky_king', msgObj); break; case 'honor': this._eventBus.emit('notice.notify.honor', msgObj); break; default: this._eventBus.emit('notice.notify', msgObj); } break; case 'group_card': this._eventBus.emit('notice.group_card', msgObj); break; case 'offline_file': this._eventBus.emit('notice.offline_file', msgObj); break; case 'client_status': this._eventBus.emit('notice.client_status', msgObj); break; case 'essence': switch (msgObj.sub_type) { case 'add': this._eventBus.emit('notice.essence.add', msgObj); break; case 'delete': this._eventBus.emit('notice.essence.delete', msgObj); break; default: this._eventBus.emit('notice.essence', msgObj); } break; default: this._eventBus.emit('notice', msgObj); } break; case 'request': switch (msgObj.request_type) { case 'friend': this._eventBus.emit('request.friend', msgObj); break; case 'group': switch (msgObj.sub_type) { case 'add': this._eventBus.emit('request.group.add', msgObj); break; case 'invite': this._eventBus.emit('request.group.invite', msgObj); break; default: this._eventBus.emit('request.group', msgObj); } break; default: this._eventBus.emit('request', msgObj); } break; case 'meta_event': switch (msgObj.meta_event_type) { case 'lifecycle': this._eventBus.emit('meta_event.lifecycle', msgObj); break; case 'heartbeat': this._eventBus.emit('meta_event.heartbeat', msgObj); break; default: this._eventBus.emit('meta_event', msgObj); } break; default: this._eventBus.emit('error', new UnexpectedContextError(msgObj, 'unexpected "post_type"')); } } _handleGroupMsg(type, msgObj, tags) { const attags = tags.filter(t => t.tagName === 'at'); if (attags.length > 0) { const atMe = type === 'guild' ? attags.find(t => t.qq === msgObj.self_tiny_id) : attags.find(t => t.qq === String(msgObj.self_id)); if (atMe) { this._eventBus.emit(`message.${type}.@.me`, msgObj, tags); } else { this._eventBus.emit(`message.${type}.@`, msgObj, tags); } } else { this._eventBus.emit(`message.${type}`, msgObj, tags); } } /** * @param {(wsType: "/api"|"/event", label: "EVENT"|"API") => void} cb * @param {"/api"|"/event"} [types] */ _forEachSock(cb, types = [WebSocketType.EVENT, WebSocketType.API]) { if (!Array.isArray(types)) { types = [types]; } types.forEach(wsType => { cb(wsType, wsType === WebSocketType.EVENT ? 'EVENT' : 'API'); }); } isSockConnected(wsType) { if (wsType === WebSocketType.API) { return this._monitor.API.state === WebSocketState.CONNECTED; } else if (wsType === WebSocketType.EVENT) { return this._monitor.EVENT.state === WebSocketState.CONNECTED; } else { throw new InvalidWsTypeError(wsType); } } connect(wsType) { this._forEachSock((_type, _label) => { if ([WebSocketState.INIT, WebSocketState.CLOSED].includes(this._monitor[_label].state)) { const tokenQS = this._token ? `?access_token=${this._token}` : ''; let _sock = new WebSocket(`${this._baseUrl}/${_label.toLowerCase()}${tokenQS}`); if (_type === WebSocketType.EVENT) { this._eventSock = _sock; } else { this._apiSock = _sock; } _sock.addEventListener( 'open', () => { this._monitor[_label].state = WebSocketState.CONNECTED; this._eventBus.emit('socket.connect', WebSocketType[_label], _sock, this._monitor[_label].attempts); if (this._monitor[_label].reconnecting) { this._eventBus.emit('socket.reconnect', WebSocketType[_label], this._monitor[_label].attempts); } this._monitor[_label].attempts = 0; this._monitor[_label].reconnecting = false; if (this.isReady()) { this._eventBus.emit('ready', this); } }, { once: true, }, ); const _onMessage = e => { let context; try { context = JSON.parse(e.data); } catch (err) { this._eventBus.emit('error', new InvalidContextError(_type, e.data)); return; } if (_type === WebSocketType.EVENT) { this._handle(context); } else { const reqid = $get(context, 'echo.reqid', ''); const { onSuccess } = this._responseHandlers.get(reqid) || {}; if (typeof onSuccess === 'function') { onSuccess(context); } this._eventBus.emit('api.response', context); } }; _sock.addEventListener('message', _onMessage); _sock.addEventListener( 'close', e => { this._monitor[_label].state = WebSocketState.CLOSED; this._eventBus.emit('socket.close', WebSocketType[_label], e.code, e.reason); // code === 1000 : normal disconnection if (e.code !== 1000 && this._reconnectOptions.reconnection) { this.reconnect(this._reconnectOptions.reconnectionDelay, WebSocketType[_label]); } // clean up events _sock.removeEventListener('message', _onMessage); // clean up refs _sock = null; if (_type === WebSocketType.EVENT) { this._eventSock = null; } else { this._apiSock = null; } }, { once: true, }, ); _sock.addEventListener( 'error', () => { const errMsg = this._monitor[_label].state === WebSocketState.CONNECTING ? 'Failed to establish the websocket connection.' : this._monitor[_label].state === WebSocketState.CONNECTED ? 'The websocket connection has been hung up unexpectedly.' : `Unknown error occured. Conection state: ${this._monitor[_label].state}`; this._eventBus.emit('socket.error', WebSocketType[_label], new SocketError(errMsg)); if (this._monitor[_label].state === WebSocketState.CONNECTED) { // error occurs after the websocket is connected this._monitor[_label].state = WebSocketState.CLOSING; this._eventBus.emit('socket.closing', WebSocketType[_label]); } else if (this._monitor[_label].state === WebSocketState.CONNECTING) { // error occurs while trying to establish the connection this._monitor[_label].state = WebSocketState.CLOSED; this._eventBus.emit('socket.failed', WebSocketType[_label], this._monitor[_label].attempts); if (this._monitor[_label].reconnecting) { this._eventBus.emit('socket.reconnect_failed', WebSocketType[_label], this._monitor[_label].attempts); } this._monitor[_label].reconnecting = false; if ( this._reconnectOptions.reconnection && this._monitor[_label].attempts <= this._reconnectOptions.reconnectionAttempts ) { this.reconnect(this._reconnectOptions.reconnectionDelay, WebSocketType[_label]); } else { this._eventBus.emit('socket.max_reconnect', WebSocketType[_label], this._monitor[_label].attempts); } } }, { once: true, }, ); this._monitor[_label].state = WebSocketState.CONNECTING; this._monitor[_label].attempts++; this._eventBus.emit('socket.connecting', _type, this._monitor[_label].attempts); } }, wsType); return this; } disconnect(wsType) { this._forEachSock((_type, _label) => { if (this._monitor[_label].state === WebSocketState.CONNECTED) { const _sock = _type === WebSocketType.EVENT ? this._eventSock : this._apiSock; this._monitor[_label].state = WebSocketState.CLOSING; // explicitly provide status code to support both browsers and Node environment _sock.close(1000, 'Normal connection closure'); this._eventBus.emit('socket.closing', _type); } }, wsType); return this; } reconnect(delay, wsType) { if (typeof delay !== 'number') delay = 0; const _reconnect = (_type, _label) => { setTimeout(() => { this.connect(_type); }, delay); }; this._forEachSock((_type, _label) => { if (this._monitor[_label].reconnecting) return; switch (this._monitor[_label].state) { case WebSocketState.CONNECTED: this._monitor[_label].reconnecting = true; this._eventBus.emit('socket.reconnecting', _type, this._monitor[_label].attempts); this.disconnect(_type); this._eventBus.once('socket.close', _closedType => { return _closedType === _type ? _reconnect(_type, _label) : false; }); break; case WebSocketState.CLOSED: case WebSocketState.INIT: this._monitor[_label].reconnecting = true; this._eventBus.emit('socket.reconnecting', _type, this._monitor[_label].attempts); _reconnect(_type, _label); } }, wsType); return this; } isReady() { const isEventReady = this._monitor.EVENT.state === WebSocketState.DISABLED || this._monitor.EVENT.state === WebSocketState.CONNECTED; const isAPIReady = this._monitor.API.state === WebSocketState.DISABLED || this._monitor.API.state === WebSocketState.CONNECTED; return isEventReady && isAPIReady; } } module.exports = { default: CQWebSocket, CQWebSocket, WebSocketType, WebSocketState, SocketError, InvalidWsTypeError, InvalidContextError, APITimeoutError, UnexpectedContextError, ...message, };