wechaty-puppet-padplus
Version:
Puppet Padplus for Wechaty
502 lines (459 loc) • 16.1 kB
text/typescript
import grpc from 'grpc'
import util from 'util'
import { v4 as uuid } from 'uuid'
import { log, padplusToken } from '../config'
import { PadPlusServerClient } from './proto-ts/PadPlusServer_grpc_pb' // add proto file from Gao
import { CallbackPool } from '../utils/callbackHelper'
import {
RequestObject,
InitConfig,
StreamResponse,
ResponseObject,
ApiType,
ResponseType,
} from './proto-ts/PadPlusServer_pb'
import { EventEmitter } from 'events'
import { GrpcEventEmitter } from './grpc-event-emitter'
import { DebounceQueue, ThrottleQueue } from 'rx-queue'
import { Subscription } from 'rxjs'
export interface ResultObject {
code: number,
data: any,
}
const PRE = 'GRPC_GATEWAY'
const NEED_CALLBACK_API_LIST: ApiType[] = [
ApiType.SEND_MESSAGE,
ApiType.SEND_FILE,
ApiType.GET_MESSAGE_MEDIA,
ApiType.SEARCH_CONTACT,
ApiType.ADD_CONTACT,
ApiType.CREATE_ROOM,
ApiType.GET_ROOM_ANNOUNCEMENT,
ApiType.SET_ROOM_ANNOUNCEMENT,
ApiType.HEARTBEAT,
ApiType.GET_ROOM_QRCODE,
ApiType.GET_CONTACT_SELF_QRCODE,
ApiType.SET_CONTACT_SELF_INFO,
ApiType.GET_CONTACT_SELF_INFO,
ApiType.LOGOUT,
]
export type GrpcGatewayEvent = 'data' | 'reconnect' | 'grpc-end' | 'grpc-close' | 'heartbeat'
export class GrpcGateway extends EventEmitter {
private static _instance?: GrpcGateway = undefined
private debounceQueue?: DebounceQueue
private debounceQueueSubscription?: Subscription
private throttleQueue?: ThrottleQueue
private throttleQueueSubscription?: Subscription
public static get Instance () {
return this._instance
}
private eventEmitterMap: { [name: string]: GrpcEventEmitter } = {}
public static async init (
token: string,
endpoint: string,
name: string,
): Promise<GrpcEventEmitter> {
if (!this._instance) {
const instance = new GrpcGateway(token, endpoint)
await instance.initSelf()
this._instance = instance
} else if (!this._instance.isAlive) {
await this._instance.stop()
const instance = new GrpcGateway(token, endpoint)
await instance.initSelf()
this._instance = instance
}
return this._instance.addNewInstance(name)
}
private async keepHeartbeat () {
log.silly(PRE, `keepHeartbeat()`)
try {
const res = await this.request(ApiType.HEARTBEAT, '')
if (!res) {
throw new Error(`no heartbeat response from grpc server`)
}
} catch (error) {
log.error(`can not get heartbeat from grpc server`, error)
Object.values(this.eventEmitterMap).map(emitter => {
emitter.emit('reconnect')
})
}
}
public static async release () {
if (this._instance) {
await this._instance.stop()
this._instance = undefined
}
}
private client: PadPlusServerClient
private stopping: boolean
private isAlive: boolean
private timeoutNumber: number
private startTime: number
private stream?: grpc.ClientReadableStream<StreamResponse>
private reconnectStatus: boolean
private constructor (
private token: string,
private endpoint: string,
) {
super()
this.stopping = false
this.client = new PadPlusServerClient(this.endpoint, grpc.credentials.createInsecure())
this.isAlive = false
this.reconnectStatus = true
this.timeoutNumber = 0
this.startTime = Date.now()
}
private async initSelf () {
this.debounceQueue = new DebounceQueue(30 * 1000)
this.debounceQueueSubscription = this.debounceQueue.subscribe(async () => {
try {
await this.keepHeartbeat()
} catch (e) {
log.silly(PRE, `debounce error : ${util.inspect(e)}`)
}
})
this.throttleQueue = new ThrottleQueue(15 * 1000)
this.throttleQueueSubscription = this.throttleQueue.subscribe((data) => {
log.silly(PRE, `throttleQueue emit heartbeat.`)
Object.values(this.eventEmitterMap).map(emitter => {
emitter.emit('heartbeat', data.getRequestid())
})
})
await this.initGrpcGateway()
this.isAlive = true
}
private addNewInstance (
name: string,
): GrpcEventEmitter {
const eventEmitter = new GrpcEventEmitter(name)
this.eventEmitterMap[name] = eventEmitter
return eventEmitter
}
public emit (event: 'data', data: StreamResponse): boolean
public emit (event: 'reconnect'): boolean
public emit (event: 'heartbeat', requestId: any): boolean
public emit (event: 'grpc-end' | 'grpc-close'): boolean
public emit (event: never, data: any): never
public emit (
event: GrpcGatewayEvent,
data?: StreamResponse,
): boolean {
return super.emit(event, data)
}
public on (event: 'data', listener: ((data: StreamResponse) => any)): this
public on (event: 'reconnect', listener: (() => any)): this
public on (event: 'heartbeat', listener: ((requestId: any) => any)): this
public on (event: 'grpc-end' | 'grpc-close', listener: (() => any)): this
public on (event: never, listener: ((data: any) => any)): never
public on (
event: GrpcGatewayEvent,
listener: ((data: StreamResponse) => any),
): this {
log.verbose(PRE, `on(${event}, ${typeof listener}) registered`)
super.on(event, (data: StreamResponse) => {
try {
listener.call(this, data)
} catch (e) {
log.error(PRE, `onFunction(${event}) listener exception: ${e}`)
}
})
return this
}
private async checkTimeout (uin: string) {
if (this.timeoutNumber > 10 && Date.now() - this.startTime <= 3 * 60 * 1000) {
await this.request(
ApiType.RECONNECT,
uin,
)
this.startTime = Date.now()
this.timeoutNumber = 0
} else if (this.timeoutNumber === 0) {
this.startTime = Date.now()
} else if (Date.now() - this.startTime > 3 * 60 * 1000) {
this.startTime = Date.now()
this.timeoutNumber = 0
}
this.timeoutNumber++
}
public async request (apiType: ApiType, uin: string, data?: any): Promise<StreamResponse | null> {
const request = new RequestObject()
const requestId = uuid()
log.silly(PRE, `GRPC Request ApiType: ${apiType}`)
request.setToken(this.token)
if (uin !== '') {
request.setUin(uin)
}
request.setApitype(apiType)
request.setParams(JSON.stringify(data))
request.setRequestid(requestId)
const traceId = uuid()
request.setTraceid(traceId)
try {
const result = await this._request(request)
if (result && NEED_CALLBACK_API_LIST.includes(apiType)) {
return new Promise<StreamResponse>((resolve, reject) => {
let timeoutMs = 5 * 1000
switch (apiType) {
case ApiType.SEND_MESSAGE:
case ApiType.SEND_FILE:
timeoutMs = 3 * 60 * 1000
break
case ApiType.GET_MESSAGE_MEDIA:
timeoutMs = 5 * 60 * 1000
break
case ApiType.SEARCH_CONTACT:
case ApiType.ADD_CONTACT:
case ApiType.CREATE_ROOM:
case ApiType.GET_ROOM_QRCODE:
timeoutMs = 1 * 60 * 1000
break
default:
timeoutMs = 5 * 1000
break
}
const timeout = setTimeout(async () => {
if (apiType !== ApiType.HEARTBEAT) {
await this.checkTimeout(uin)
}
reject(new Error(`ApiType: ${apiType} request timeout, traceId: ${traceId}`))
}, timeoutMs)
CallbackPool.Instance.pushCallbackToPool(traceId, (data: StreamResponse) => {
const _traceId = data.getTraceid()
if (!_traceId) {
log.error(PRE, `Can not get trace id by type: ${apiType}`)
}
if (traceId === _traceId) {
clearTimeout(timeout)
if (apiType !== ApiType.HEARTBEAT) {
this.timeoutNumber = 0
}
resolve(data)
}
})
})
}
} catch (err) {
await new Promise(resolve => setTimeout(resolve, 5000))
this.isAlive = false
this.client.close()
Object.values(this.eventEmitterMap).map(emitter => {
emitter.emit('reconnect')
})
if (err.details === 'INVALID_TOKEN') {
padplusToken()
}
}
return null
}
private async _request (request: RequestObject): Promise<boolean> {
return new Promise<boolean>((resolve, reject) => {
try {
this.client.request(
request,
(err: Error | null, response: ResponseObject) => {
if (err !== null) {
reject(err)
} else {
const flag = response.getResult()
if (flag) {
resolve(true)
} else {
reject(err)
}
}
}
)
} catch (error) {
throw new Error(`can not get response data of grpc request`)
}
})
}
public async stop () {
log.silly(PRE, `stop()`)
if (this.stream) {
this.stream.destroy()
this.stream.removeAllListeners()
}
this.client.close()
this.stopping = true
try {
await this.request(ApiType.CLOSE, '')
} catch (error) {
log.error(PRE, `error : ${util.inspect(error)}`)
}
if (!this.throttleQueueSubscription || !this.debounceQueueSubscription) {
log.verbose(PRE, `releaseQueue() subscriptions have been released.`)
} else {
this.throttleQueueSubscription.unsubscribe()
this.debounceQueueSubscription.unsubscribe()
this.throttleQueueSubscription = undefined
this.debounceQueueSubscription = undefined
}
if (!this.debounceQueue || !this.throttleQueue) {
log.verbose(PRE, `releaseQueue() queues have been released.`)
} else {
this.debounceQueue.unsubscribe()
this.throttleQueue.unsubscribe()
this.debounceQueue = undefined
this.throttleQueue = undefined
}
}
public async initGrpcGateway () {
log.silly(PRE, `initGrpcGateway()`)
const initConfig = new InitConfig()
initConfig.setToken(this.token)
const channel = this.client.getChannel()
if (channel) {
await new Promise((resolve, reject) => {
channel.getConnectivityState(true)
const beforeState = channel.getConnectivityState(false)
channel.watchConnectivityState(beforeState, Date.now() + 5000, (err) => {
if (err) {
reject(new Error('Try to connect to server timeout.'))
} else {
const state = channel.getConnectivityState(false)
if (state !== grpc.connectivityState.READY) {
reject(new Error(`Failed to connect to server, state changed to ${state}`))
} else {
resolve()
}
}
})
})
} else {
throw new Error('No channel for grpc client.')
}
const stream = this.client.init(initConfig)
stream.on('error', async (err: any) => {
log.error(PRE, `GRPC SERVER ERROR.
=====================================================================
try to reconnect grpc server, waiting...
=====================================================================
`)
if (err.code === 14 || err.code === 13 || err.code === 2) {
await new Promise(resolve => setTimeout(resolve, 5000))
this.isAlive = false
Object.values(this.eventEmitterMap).map(emitter => {
emitter.emit('reconnect')
})
} else {
log.error(PRE, `stream error:`, util.inspect(err))
}
})
stream.on('end', async () => {
if (this.reconnectStatus) {
log.error(PRE, `GRPC SERVER END.
=====================================================================
try to reconnect grpc server, waiting...
=====================================================================
`)
await new Promise(resolve => setTimeout(resolve, 5000))
this.isAlive = false
if (!this.stopping) {
Object.values(this.eventEmitterMap).map(emitter => {
emitter.emit('reconnect')
})
}
} else {
log.info(PRE, `
=====================================================================
DUPLICATE CONNECTED, THIS THREAD WILL EXIT NOW
=====================================================================
`)
process.exit()
}
})
stream.on('close', async () => {
log.error(PRE, 'GRPC SERVER CLOSE')
this.isAlive = false
Object.values(this.eventEmitterMap).map(emitter => {
emitter.emit('reconnect')
})
})
stream.on('data', async (data: StreamResponse) => {
const traceId = data.getTraceid()
const responseType = data.getResponsetype()
if (responseType !== ResponseType.LOGIN_QRCODE && responseType !== ResponseType.ROOM_QRCODE && responseType !== ResponseType.CONTACT_SELF_QRCODE_GET) {
log.silly(`==P==A==D==P==L==U==S==<GRPC DATA>==P==A==D==P==L==U==S==`)
log.silly(PRE, `responseType: ${responseType}, data : ${data.getData()}`)
log.silly(`==P==A==D==P==L==U==S==<GRPC DATA>==P==A==D==P==L==U==S==\n`)
}
if (this.debounceQueue && this.throttleQueue) {
this.debounceQueue.next(data)
this.throttleQueue.next(data)
}
let message = ''
const _data = data.getData()
if (_data) {
try {
message = JSON.parse(_data).message
} catch (error) {
log.error(`can not parse data`)
}
}
if (message && message === 'Another instance connected, disconnected the current one.') {
this.reconnectStatus = false
} else if (message && message === 'EXPIRED_TOKEN') {
Object.values(this.eventEmitterMap).map(emitter => {
this.reconnectStatus = false
emitter.emit('EXPIRED_TOKEN')
})
} else if (message && message === 'INVALID_TOKEN') {
Object.values(this.eventEmitterMap).map(emitter => {
this.reconnectStatus = false
emitter.emit('INVALID_TOKEN')
})
}
if (traceId) {
let callback = CallbackPool.Instance.getCallback(traceId)
if (callback) {
callback(data)
CallbackPool.Instance.removeCallback(traceId)
} else {
await new Promise(resolve => {
setTimeout(resolve, 500)
})
try {
callback = CallbackPool.Instance.getCallback(traceId)
callback(data)
CallbackPool.Instance.removeCallback(traceId)
} catch (error) {
throw new Error(`can not find callback by traceId : ${traceId}`)
}
}
} else {
if (responseType === ResponseType.LOGIN_QRCODE) {
const name = Object.keys(this.eventEmitterMap).find(name => {
const qrcodeId = this.eventEmitterMap[name].getQrcodeId()
const uin = this.eventEmitterMap[name].getUIN()
const userName = this.eventEmitterMap[name].getUserName()
log.silly(PRE, `uin : ${uin}, userName: ${userName}`)
return qrcodeId === '' && uin === '' && userName === ''
})
if (name) {
this.eventEmitterMap[name].emit('data', data)
}
} else {
const uin = data.getUin()
try {
const user = JSON.parse(data.getData()!)
const userName = user.userName
const emitter = Object.values(this.eventEmitterMap).find(em => em.getUIN() === uin || em.getQrcodeId() === userName)
if (!emitter) {
return
}
if (responseType === ResponseType.QRCODE_SCAN && user.status === 3 && user.qrcodeId !== emitter.getQrcodeId()) {
return
} else {
emitter.emit('data', data)
}
} catch (error) {
throw new Error(`can not parse json data from grpc server.`)
}
}
}
})
this.stream = stream
}
}