wechaty-puppet-official-account
Version:
Wechaty Puppet for WeChat Official Accounts
313 lines (267 loc) • 8.47 kB
text/typescript
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 }