wechaty-puppet-official-account
Version:
Wechaty Puppet for WeChat Official Accounts
244 lines • 9.81 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.Webhook = void 0;
const http_1 = __importDefault(require("http"));
const express_1 = __importDefault(require("express"));
const express_xml_bodyparser_1 = __importDefault(require("express-xml-bodyparser"));
const localtunnel_1 = __importDefault(require("localtunnel"));
const UUID = __importStar(require("uuid"));
const wechaty_puppet_1 = require("wechaty-puppet");
const events_1 = require("events");
const WebhookEventEmitter = events_1.EventEmitter;
class Webhook extends WebhookEventEmitter {
options;
server;
tunnel;
personalMode;
messageCache = {};
userOpen = {};
webhookProxyHost;
webhookProxySchema;
webhookProxySubDomain;
constructor(options) {
super();
this.options = options;
wechaty_puppet_1.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) {
wechaty_puppet_1.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) {
wechaty_puppet_1.log.warn('Webhook', 'parseSubDomain() fail to parse %s', webhookProxyUrl);
return;
}
const [, // skip matches[0]
schema, name, host,] = matches;
wechaty_puppet_1.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() {
wechaty_puppet_1.log.verbose('Webhook', 'start()');
const app = (0, express_1.default)();
app.use((0, express_xml_bodyparser_1.default)({
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) => {
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_1.default.createServer(app);
await new Promise((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().port;
this.setupTunnel(listenedPort)
.then(resolve)
.catch(reject);
});
});
}
async stop() {
wechaty_puppet_1.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) {
wechaty_puppet_1.log.verbose('Webhook', 'setupTunnel(%s)', port);
const host = `${this.webhookProxySchema}://${this.webhookProxyHost}`;
const tunnel = await (0, localtunnel_1.default)({
host,
port,
subdomain: this.webhookProxySubDomain,
});
wechaty_puppet_1.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', () => {
wechaty_puppet_1.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, res) {
wechaty_puppet_1.log.verbose('Webhook', 'appGet({url: %s})', req.url);
const { signature, timestamp, nonce, echostr, } = req.query;
if (nonce && signature && this.options.verify({
nonce,
signature,
timestamp: timestamp,
})) {
wechaty_puppet_1.log.verbose('Webhook', 'appGet() verify() succeed');
res.end(echostr);
}
else {
wechaty_puppet_1.log.verbose('Webhook', 'appGet() verify() failed');
res.end();
}
}
async appPost(req, res) {
const payload = req.body.xml;
wechaty_puppet_1.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 = null;
const timeout = async (ms) => new Promise(resolve => setTimeout(resolve, ms));
for (let i = 0; i < (4000 / 5); i++) {
await timeout(5);
if (this.messageCache[payload.FromUserName]) {
const msg = 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');
}
}
exports.Webhook = Webhook;
//# sourceMappingURL=webhook.js.map