UNPKG

wechaty-puppet-official-account

Version:
313 lines (267 loc) 8.47 kB
import http from 'http' import express from 'express' import xmlParser from 'express-xml-bodyparser' import localtunnel from 'localtunnel' import * as UUID from 'uuid' import { log } from 'wechaty-puppet' import { EventEmitter } from 'events' import type TypedEventEmitter from 'typed-emitter' import type { OAMessagePayload, OAMessageType, } from './schema.js' const WebhookEventEmitter = EventEmitter as new () => TypedEventEmitter<{ message: (message: OAMessagePayload) => void, instantReply: (message: { touser : string, msgtype : OAMessageType, content : string, }) => void, }> export interface VerifyArgs { timestamp : string, nonce : string, signature : string, } interface WebhookOptions { personalMode? : boolean, port? : number, webhookProxyUrl? : string, verify : (args: VerifyArgs) => boolean, } class Webhook extends WebhookEventEmitter { protected server? : http.Server protected tunnel? : localtunnel.Tunnel protected personalMode? : boolean protected messageCache? : any = {} protected userOpen? : any = {} public readonly webhookProxyHost? : string public readonly webhookProxySchema? : string public readonly webhookProxySubDomain? : string constructor ( protected options: WebhookOptions, ) { super() log.verbose('Webhook', 'constructor(%s)', JSON.stringify(options)) if (typeof options.port !== 'undefined' && options.webhookProxyUrl) { throw new Error('Please only provide either `port` or `webhookProxyUrl` for Webhook') } if (typeof options.port === 'undefined' && !options.webhookProxyUrl) { throw new Error('Please provide either `port` or `webhookProxyUrl` for Webhook') } this.personalMode = options.personalMode if (options.webhookProxyUrl) { const result = this.parseWebhookProxyUrl(options.webhookProxyUrl) if (!result) { throw new Error(`Webhook: invalid webhookProxyUrl ${options.webhookProxyUrl}`) } this.webhookProxyHost = result.host this.webhookProxySchema = result.schema this.webhookProxySubDomain = result.name } } parseWebhookProxyUrl ( webhookProxyUrl: string, ): undefined | { host : string, name : string, schema : string, } { log.verbose('Webhook', 'parseSubDomain(%s)', webhookProxyUrl) /** * Huan(20208): see webhook.spec.ts unit tests. * server: https://github.com/localtunnel/server * * Huan(202109): TODO: use URL for this? */ const URL_RE = /(https?):\/\/([^.]+)\.(.+)/i const matches = webhookProxyUrl.match(URL_RE) if (!matches) { log.warn('Webhook', 'parseSubDomain() fail to parse %s', webhookProxyUrl) return } const [ , // skip matches[0] schema, name, host, ] = matches log.verbose('Webhook', 'parseSubDomain() schema: %s, name: %s, host: %s', schema, name, host, ) if (!host || !name || !schema) { return undefined } return { host, name, schema, } } async start () { log.verbose('Webhook', 'start()') const app = express() app.use(xmlParser({ explicitArray : false, normalize : false, normalizeTags : false, trim : true, })) app.get('/', (req, res) => this.appGet(req, res)) app.post('/', (req, res) => { ;( async () => this.appPost(req, res) )().catch(console.error) }) this.on('instantReply', (msg: { msgtype: OAMessageType, content: string, touser: string }) => { if (this.userOpen[msg.touser]) { this.messageCache[msg.touser] = msg } else { throw Error('Webhook: personal mode only allow reply once and within 4s') } }) const server = this.server = http.createServer(app) await new Promise<void>((resolve, reject) => { /** * 1. for local port */ if (typeof this.options.port !== 'undefined') { server.listen(this.options.port, resolve) return } /** * 2. for tunnel helper */ server.listen(() => { const listenedPort = (server.address() as { port: number }).port this.setupTunnel(listenedPort) .then(resolve) .catch(reject) }) }) } async stop () { log.verbose('Webhook', 'stop()') if (this.tunnel) { this.tunnel.close() this.tunnel = undefined } if (this.server) { this.server.close() this.server = undefined } } async setupTunnel (port: number) { log.verbose('Webhook', 'setupTunnel(%s)', port) const host = `${this.webhookProxySchema}://${this.webhookProxyHost}` const tunnel = await localtunnel({ host, port, subdomain: this.webhookProxySubDomain, }) log.verbose('Webhook', 'setupTunnel() created at %s', tunnel.url) if (tunnel.url !== this.options.webhookProxyUrl) { throw new Error(`Webhook: webhookUrlUrl is not available ${this.options.webhookProxyUrl}`) } tunnel.on('close', () => { log.verbose('Webhook', 'setupTunnel() tunnel.on(close)') // TODO: check if need to reconnect at here. // FIXME: try to recover by restarting, or throw error when can not recover }) this.tunnel = tunnel } appGet ( req : express.Request, res : express.Response, ): void { log.verbose('Webhook', 'appGet({url: %s})', req.url) const { signature, timestamp, nonce, echostr, } = req.query as { [key: string]: string } if (nonce && signature && this.options.verify({ nonce, signature, timestamp: timestamp!, })) { log.verbose('Webhook', 'appGet() verify() succeed') res.end(echostr) } else { log.verbose('Webhook', 'appGet() verify() failed') res.end() } } async appPost ( req : express.Request, res : express.Response, ): Promise<void> { const payload = req.body.xml as OAMessagePayload log.verbose('Webhook', 'appPost({url: %s} with payload: %s', req.url, JSON.stringify(payload), ) const knownTypeList = [ 'text', 'image', 'voice', 'event', ] this.userOpen[payload.FromUserName] = true /** * TODO: support more MsgType */ if (payload.MsgType === 'event') { payload.MsgId = UUID.v4() } if (knownTypeList.includes(payload.MsgType)) { if (payload.MsgType === 'event' && payload.Event !== 'CLICK') return this.emit('message', payload) } /** * 假如服务器无法保证在五秒内处理并回复,必须做出下述回复,这样微信服务器才不会对此作任何处理, * 并且不会发起重试(这种情况下,可以使用客服消息接口进行异步回复),否则,将出现严重的错误提示。 * 1、直接回复success(推荐方式) * 2、直接回复空串(指字节长度为0的空字符串,而不是XML结构体中content字段的内容为空) * * https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Passive_user_reply_message.html */ if (this.personalMode) { let reply: string|null = null const timeout = async (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) for (let i = 0; i < (4000 / 5); i++) { await timeout(5) if (this.messageCache[payload.FromUserName]) { const msg: any = this.messageCache[payload.FromUserName] this.messageCache[payload.FromUserName] = undefined if (msg.msgtype === 'text') { reply = `<xml> <ToUserName><![CDATA[${payload.FromUserName}]]></ToUserName> <FromUserName><![CDATA[${payload.ToUserName}]]></FromUserName> <CreateTime>${payload.CreateTime}</CreateTime> <MsgType><![CDATA[text]]></MsgType> <Content><![CDATA[${msg.content}]]></Content> </xml> ` } break } } if (reply) { this.userOpen[payload.FromUserName] = undefined res.end(reply) return } } this.userOpen[payload.FromUserName] = undefined res.end('success') } } export { Webhook }