UNPKG

wechaty-puppet-official-account

Version:
600 lines 26.2 kB
"use strict"; 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; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.OfficialAccount = void 0; /* eslint-disable camelcase */ const wechaty_puppet_1 = require("wechaty-puppet"); const types_1 = require("wechaty-puppet/types"); const file_box_1 = require("file-box"); const UUID = __importStar(require("uuid")); const crypto = __importStar(require("crypto")); const events_1 = require("events"); const webhook_js_1 = require("./webhook.js"); const simple_unirest_js_1 = require("./simple-unirest.js"); const payload_store_js_1 = require("./payload-store.js"); const utils_js_1 = require("./utils.js"); const normalize_file_box_js_1 = require("./normalize-file-box.js"); class OfficialAccount extends events_1.EventEmitter { options; payloadStore; webhook; simpleUnirest; accessTokenPayload; stopperFnList; oaId; // proxy of the access token center accessTokenProxyUrl; get accessToken() { if (!this.accessTokenPayload) { throw new Error('accessToken() this.accessTokenPayload uninitialized!'); } return this.accessTokenPayload.token; } constructor(options) { super(); this.options = options; wechaty_puppet_1.log.verbose('OfficialAccount', 'constructor(%s)', JSON.stringify(options)); // keep the official account id consist with puppet-oa this.oaId = `gh_${options.appId}`; this.webhook = new webhook_js_1.Webhook({ personalMode: !!this.options.personalMode, port: this.options.port, verify: this.verify.bind(this), webhookProxyUrl: this.options.webhookProxyUrl, }); this.payloadStore = new payload_store_js_1.PayloadStore(options.appId); this.simpleUnirest = (0, simple_unirest_js_1.getSimpleUnirest)('https://api.weixin.qq.com/cgi-bin/'); this.stopperFnList = []; this.accessTokenProxyUrl = options.accessTokenProxyUrl; } verify(args) { wechaty_puppet_1.log.verbose('OfficialAccount', 'verify(%s)', JSON.stringify(args)); const data = [ args.timestamp, args.nonce, this.options.token, ].sort().join(''); const digest = crypto .createHash('sha1') .update(data) .digest('hex'); return digest === args.signature; } async start() { wechaty_puppet_1.log.verbose('OfficialAccount', 'start()'); this.webhook.on('message', message => { this.payloadStore.setMessagePayload(message.MsgId, message) .then(() => this.emit('message', message)) .catch(console.error); }); await this.payloadStore.start(); const succeed = await this.updateAccessToken(); if (!succeed) { wechaty_puppet_1.log.error('OfficialAccount', 'start() updateAccessToken() failed.'); } const stopper = await this.startSyncingAccessToken(); this.stopperFnList.push(stopper); await this.webhook.start(); } async stop() { wechaty_puppet_1.log.verbose('OfficialAccount', 'stop()'); while (this.stopperFnList.length > 0) { const stopper = this.stopperFnList.pop(); if (stopper) { await stopper(); } } await this.webhook.stop(); await this.payloadStore.stop(); } async updateAccessToken() { wechaty_puppet_1.log.verbose('OfficialAccount', 'updateAccessToken()'); /** * updated: { * "access_token":"3...Q", * "expires_in":7200 * } */ let simpleUnirest = this.simpleUnirest; // NOTE: it will fetch accessToken from the specific endpoint if (this.accessTokenProxyUrl) { simpleUnirest = (0, simple_unirest_js_1.getSimpleUnirest)(this.accessTokenProxyUrl); } const ret = await simpleUnirest .get(`token?grant_type=client_credential&appid=${this.options.appId}&secret=${this.options.appSecret}`); wechaty_puppet_1.log.verbose('OfficialAccount', 'updateAccessToken() %s', JSON.stringify(ret.body)); if (ret.body.errcode && ret.body.errcode > 0) { // {"errcode":40164,"errmsg":"invalid ip 111.199.187.71 ipv6 ::ffff:111.199.187.71, not in whitelist hint: [H.BDtZFFE-Q7bNKA] rid: 5f283869-46321ea1-07d7260c"} wechaty_puppet_1.log.warn('OfficialAccount', `updateAccessToken() ${ret.body.errcode}: ${ret.body.errmsg}`); if (this.accessTokenPayload) { const expireTimestamp = this.accessTokenPayload.timestamp + (this.accessTokenPayload.expiresIn * 1000); if (expireTimestamp > Date.now()) { // expired. wechaty_puppet_1.log.warn('OfficialAccount', 'updateAccessToken() token expired!'); this.accessTokenPayload = undefined; } } return false; } this.accessTokenPayload = { expiresIn: ret.body.expires_in, timestamp: Date.now(), token: ret.body.access_token, }; wechaty_puppet_1.log.verbose('OfficialAccount', 'updateAccessToken() synced. New token will expiredIn %s seconds', this.accessTokenPayload.expiresIn); return true; } /** * https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Get_access_token.html */ async startSyncingAccessToken() { wechaty_puppet_1.log.verbose('OfficialAccount', 'startSyncingAccessToken()'); const marginSeconds = 5 * 60; // 5 minutes const tryAgainSeconds = 60; // 1 minute /** * Huan(202102): Why we lost `NodeJS` ? * * https://stackoverflow.com/a/56239226/1123955 */ let timer; // eslint-disable-next-line @typescript-eslint/no-misused-promises const update = () => this.updateAccessToken() .then(succeed => succeed ? this.accessTokenPayload.expiresIn - marginSeconds : tryAgainSeconds) .then(seconds => setTimeout(update, seconds * 1000)) // eslint-disable-next-line no-return-assign .then(newTimer => timer = newTimer) .catch(e => wechaty_puppet_1.log.error('OfficialAccount', 'startSyncingAccessToken() update() rejection: %s', e)); if (!this.accessTokenPayload) { await update(); } else { const seconds = this.accessTokenPayload.expiresIn - marginSeconds; timer = setTimeout(update, seconds * 1000); } return () => timer && clearTimeout(timer); } async sendCustomMessagePersonal(args) { this.webhook.emit('instantReply', args); const uuid = UUID.v4(); await this.payloadStore.setMessagePayload(uuid, { Content: args.content, CreateTime: (0, utils_js_1.getTimeStampString)(), FromUserName: this.oaId, MsgId: uuid, MsgType: 'text', ToUserName: args.touser, }); return uuid; } /** * 客服接口-发消息 * https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Service_Center_messages.html#7 */ async sendCustomMessage(args) { wechaty_puppet_1.log.verbose('OfficialAccount', 'sendCustomMessage(%s)', JSON.stringify(args)); const ret = await this.simpleUnirest .post(`message/custom/send?access_token=${this.accessToken}`) .type('json') .send({ msgtype: args.msgtype, text: { content: args.content, }, touser: args.touser, }); /** * { errcode: 0, errmsg: 'ok' } */ /** * TODO(huan) 202008: deal with this situation * { errcode: 45015, errmsg: 'response out of time limit or subscription is canceled hint: [CgCD2CMre-brVPIA] rid: 5f2b8ff1-4943a9b3-70b9fe5e' } */ // save the official-account payload if (ret.body.errcode) { throw new Error(`OfficialAccount sendCustomMessage() can send message <${JSON.stringify(args)}>`); } const uuid = UUID.v4(); await this.payloadStore.setMessagePayload(uuid, { CreateTime: (0, utils_js_1.getTimeStampString)(), FromUserName: this.oaId, MsgId: uuid, MsgType: 'text', ToUserName: args.touser, }); return uuid; } async sendCustomLink(args) { wechaty_puppet_1.log.verbose('OfficialAccount', 'sendCustomLink(%s)', JSON.stringify(args)); const msgtype = 'link'; const ret = await this.simpleUnirest .post(`message/custom/send?access_token=${this.accessToken}`) .type('json') .send({ msgtype: 'link', [msgtype]: { description: args.urlLinkPayload.description, thumb_url: args.urlLinkPayload.thumbnailUrl, title: args.urlLinkPayload.title, url: args.urlLinkPayload.url, }, touser: args.touser, }); if (ret.body.errcode) { throw new Error(`OfficialAccount sendCustomLink() can send link <${JSON.stringify(args)}>`); } const uuid = UUID.v4(); await this.payloadStore.setMessagePayload(uuid, { CreateTime: (0, utils_js_1.getTimeStampString)(), FromUserName: this.oaId, MsgId: uuid, MsgType: 'link', ToUserName: args.touser, }); return uuid; } async sendCustomMiniProgram(args) { wechaty_puppet_1.log.verbose('OfficialAccount', 'sendCustomMiniProgram(%s)', JSON.stringify(args)); const msgtype = 'miniprogrampage'; const ret = await this.simpleUnirest .post(`message/custom/send?access_token=${this.accessToken}`) .type('json') .send({ msgtype: 'miniprogrampage', [msgtype]: { appid: args.miniProgram.appid, pagepath: args.miniProgram.pagePath, thumb_media_id: args.miniProgram.thumbKey, title: args.miniProgram.title, }, touser: args.touser, }); if (ret.body.errcode) { throw new Error(`OfficialAccount sendCustomMiniProgram can send miniProgram <${JSON.stringify(args)}>`); } const uuid = UUID.v4(); await this.payloadStore.setMessagePayload(uuid, { CreateTime: (0, utils_js_1.getTimeStampString)(), FromUserName: this.oaId, MsgId: uuid, MsgType: 'miniprogrampage', ToUserName: args.touser, }); return uuid; } async sendFile(args) { wechaty_puppet_1.log.verbose('OfficialAccount', 'sendFile(%s)', JSON.stringify(args)); // JSON.stringify does not support .mp3 filetype const { buf, info } = await (0, normalize_file_box_js_1.normalizeFileBox)(args.file); // all of the image file are compressed into image/jpeg type // and fetched fileBox has no name, which will cause error in upload file process. // this works for all of the image file // TODO -> should be improved later. if (args.file.type === file_box_1.FileBoxType.Url && args.file.mediaType === 'image/jpeg') { info.filename = `${args.file.name}.jpeg`; } if (args.file.type === file_box_1.FileBoxType.Url && args.file.mediaType === 'audio/amr') { info.filename = `${args.file.name}`; } const mediaResponse = await this.simpleUnirest.post(`media/upload?access_token=${this.accessToken}&type=${args.msgtype}`).attach('attachments[]', buf, info); // the type of result is string if (typeof mediaResponse.body === 'string') { mediaResponse.body = JSON.parse(mediaResponse.body); } const data = { [args.msgtype]: { media_id: mediaResponse.body.media_id, }, msgtype: args.msgtype, touser: args.touser, }; const messageResponse = await this.simpleUnirest.post(`message/custom/send?access_token=${this.accessToken}`).type('json').send(data); if (messageResponse.body.errcode) { wechaty_puppet_1.log.error('OfficialAccount', 'SendFile() can not send file to wechat user .<%s>', messageResponse.body.errmsg); throw new Error(`OfficialAccount', 'SendFile() can not send file to wechat user .<${messageResponse.body.errmsg}>'`); } // Now only support uploading image or audio. // Notes about image upload: // Situation One: when contact user send image file to oa, there will be PicUrl & MediaId fields // Situation Two: when server send file to tencent server, there is only MediaId field. if (!(args.msgtype === 'voice' || args.msgtype === 'image' || args.msgtype === 'video')) { throw new Error(`OfficialAccount, sendFile() doesn't support message type ${args.msgtype}`); } const messagePayload = { CreateTime: (0, utils_js_1.getTimeStampString)(), FromUserName: this.oaId, MediaId: mediaResponse.body.media_id, MsgId: UUID.v4(), MsgType: args.msgtype, ToUserName: args.touser, }; await this.payloadStore.setMessagePayload(messagePayload.MsgId, messagePayload); return messagePayload.MsgId; } async getContactList() { wechaty_puppet_1.log.verbose('OfficialAccount', 'getContactList'); let openIdList = []; let nextOpenId = ''; // Individual subscription accounts and unverified accounts cannot access user information. if (this.options.personalMode) { return openIdList; } // eslint-disable-next-line while (true) { const req = await this.simpleUnirest.get(`user/get?access_token=${this.accessToken}&next_openid=${nextOpenId}`); if (req.body.errcode) { wechaty_puppet_1.log.error(`OfficialAccount', 'getContactList() ${req.body.errmsg}`); return openIdList; } if (!req.body.next_openid) { break; } openIdList = openIdList.concat(req.body.data.openid); nextOpenId = req.body.next_openid; } return openIdList; } async getContactPayload(openId) { wechaty_puppet_1.log.verbose('OfficialAccount', 'getContactPayload(%s)', openId); if (openId && openId.startsWith('gh_')) { // if (openId) { // wechaty load the SelfContact object, so just return it. /* eslint-disable sort-keys */ const selfContactPayload = { subscribe: 0, openid: openId, nickname: 'from official-account options ?', sex: types_1.ContactGender.Unknown, language: 'zh_CN', city: '北京', province: '北京', country: '中国', headimgurl: '', subscribe_time: 0, unionid: '0', remark: '微信公众号客服', groupid: 0, tagid_list: [], subscribe_scene: '', qr_scene: 0, qr_scene_str: '', }; return selfContactPayload; } if (openId && !!this.options.personalMode) { // Individual subscription accounts and unverified accounts cannot access user information. /* eslint-disable sort-keys */ const subscribeContactPayload = { subscribe: 1, openid: openId, nickname: '订阅者', sex: types_1.ContactGender.Unknown, language: 'zh_CN', city: '北京', province: '北京', country: '中国', headimgurl: '', subscribe_time: 0, unionid: '0', remark: '订阅者', groupid: 0, tagid_list: [], subscribe_scene: '', qr_scene: 0, qr_scene_str: '', }; return subscribeContactPayload; } const res = await this.simpleUnirest.get(`user/info?access_token=${this.accessToken}&openid=${openId}&lang=zh_CN`); if (res.body.errcode) { wechaty_puppet_1.log.error(`OfficialAccount', 'getContactPayload() ${res.body.errmsg}`); return; } // const payload: ContactPayload = { // alias : res.body.remark, // avatar : res.body.headimgurl, // city : res.body.city, // friend : true, // gender : res.body.sex, // id : res.body.openid, // name : res.body.nickname, // province : res.body.province, // signature : '', // star : false, // type : ContactType.Individual, // weixin : res.body.unionid, // } /* * wj-Mcat: What kind of the ContactType should be ? * TODO -> there are some data can be feed into ContactPayload */ return res.body; } async updateContactRemark(openId, remark) { wechaty_puppet_1.log.verbose('OfficialAccount', 'setContactRemark(%s)', JSON.stringify({ openId, remark })); const res = await this.simpleUnirest.post(`user/info/updateremark?access_token=${this.accessToken}`); if (res.body.errcode) { wechaty_puppet_1.log.error('OfficialAccount', 'setContactRemark() can update contact remark (%s)', res.body.errmsg); } } async createTag(name) { wechaty_puppet_1.log.verbose('OfficialAccount', 'createTag(%s)', name); const res = await this.simpleUnirest.post(`tags/create?access_token=${this.accessToken}`); if (res.body.errcode) { wechaty_puppet_1.log.error('OfficialAccount', 'createTag(%s) error code : %s', name, res.body.errcode); } else { return name; } } async getTagList() { wechaty_puppet_1.log.verbose('OfficialAccount', 'getTagList()'); const res = await this.simpleUnirest.get(`tags/get?access_token=${this.accessToken}`); if (res.body.errcode) { wechaty_puppet_1.log.error('OfficialAccount', 'getTagList() error code : %s', res.body.errcode); return []; } if (!res.body.tags || res.body.tags.length === 0) { wechaty_puppet_1.log.warn('OfficialAccount', 'getTagList() get empty tag list'); return []; } return res.body.tags; } async getTagIdByName(tagName) { wechaty_puppet_1.log.verbose('OfficialAccount', 'deleteTag(%s)', tagName); /** * TODO: this is not a frequent interface, So I don't cache the taglist */ const tagList = await this.getTagList(); const tag = tagList.filter((item) => item.name === tagName); if (tag.length === 0) { return null; } return tag[0].id; } async deleteTag(tagName) { wechaty_puppet_1.log.verbose('OfficialAccount', 'deleteTag(%s)', tagName); // find tagId by tagName from tagList const tagId = await this.getTagIdByName(tagName); if (!tagId) { throw new Error(`can not find tag(${tagName})`); } const res = await this.simpleUnirest.post(`tags/delete?access_token=${this.accessToken}`).send({ tag: { id: tagId, }, }); if (res.body.errcode) { wechaty_puppet_1.log.error('OfficialAccount', 'deleteTag() error code : %s', res.body.errcode); } } async addTagToMembers(tagName, openIdList) { wechaty_puppet_1.log.verbose('OfficialAccount', 'addTagToMembers(%s)', JSON.stringify({ tagName, openIdList })); const tagId = await this.getTagIdByName(tagName); if (!tagId) { throw new Error(`can not find tag(${tagName})`); } const res = await this.simpleUnirest.post(`tags/members/batchtagging?access_token=${this.accessToken}`).send({ opeid_list: openIdList, tag_id: tagId, }); if (res.body.errcode) { wechaty_puppet_1.log.error('OfficialAccount', 'addTagToMembers() error code : %s', res.body.errcode); } } async removeTagFromMembers(tagName, openIdList) { wechaty_puppet_1.log.verbose('OfficialAccount', 'removeTagFromMembers(%s)', JSON.stringify({ tagName, openIdList })); const tagId = await this.getTagIdByName(tagName); if (!tagId) { throw new Error(`can not find tag(${tagName})`); } const res = await this.simpleUnirest.post(`tags/members/batchuntagging?access_token=${this.accessToken}`).send({ opeid_list: openIdList, tag_id: tagId, }); if (res.body.errcode) { wechaty_puppet_1.log.error('OfficialAccount', 'removeTagFromMembers() error code : %s', res.body.errcode); } } async getMemberTags(openid) { wechaty_puppet_1.log.verbose('OfficialAccount', 'getMemberTags(%s)', openid); const res = await this.simpleUnirest.post(`tags/getidlist?access_token=${this.accessToken}`).send({ openid, }); if (res.body.errcode) { throw new Error(`OfficialAccount deleteTag() error code : ${res.body.errcode}`); } // 1. build the tag id-name map to improve search efficiency const allTagList = await this.getTagList(); const tagIdMap = allTagList.reduce((map, tag) => { map[tag.id] = tag.name; return map; }, {}); // 2. retrive the names from id const tagNames = []; for (const tagId of res.body.tagid_list) { if (tagId in tagIdMap) { tagNames.push(tagIdMap[tagId]); } } return tagNames; } async getAudioUrl(mediaId) { // NOTE(zhangfan): As per Wechat API documentation (https://developers.weixin.qq.com/doc/offiaccount/Asset_Management/Get_temporary_materials.html), // /media/get behavior is not documented if the retrieved media content is an audio. // From real world testing, we learned it returns the audio content directly. // This is subject to changes. // // Here is an excerpt of the response header seen in tests: // "connection": "close", // "cache-control": "no-cache, must-revalidate", // "date": "Thu, 04 Feb 2021 08:51:34 GMT", // "content-disposition": "attachment; filename=\"Nz30tHrSoMhGf7FcOmddXuCIud-TP7Z71Yci6nOgYtGnLTkoD9V4yisRlj75Ghs7.amr\"", // "content-type": "audio/amr", // "content-length": "8630" return `https://api.weixin.qq.com/cgi-bin/media/get?access_token=${this.accessToken}&media_id=${mediaId}`; } async setMemberRemark(openid, remark) { wechaty_puppet_1.log.verbose('OfficialAccount', 'setMemberRemark(%s)', openid); const res = await this.simpleUnirest.post(`user/info/updateremark?access_token=${this.accessToken}`).send({ openid, remark, }); if (res.body.errcode) { wechaty_puppet_1.log.error('OfficialAccount', 'deleteTag() error code : %s', res.body.errcode); } } async sendBatchTextMessageByTagId(tagId, msg) { wechaty_puppet_1.log.verbose('OfficialAccount', 'sendBatchTextMessageByTagId(%s)', JSON.stringify({ tagId, msg })); const res = await this.simpleUnirest.post(`message/mass/sendall?access_token=${this.accessToken}`).send({ filter: { is_to_all: false, tag_id: tagId, }, text: { content: msg, }, msgtype: 'text', }); if (res.body.errcode) { wechaty_puppet_1.log.error('OfficialAccount', 'deleteTag() error code : %s', res.body.errcode); } } async sendBatchTextMessageByOpenidList(openidList, msg) { wechaty_puppet_1.log.verbose('OfficialAccount', 'sendBatchTextMessageByOpenidList(%s)', JSON.stringify({ openidList, msg })); const res = await this.simpleUnirest.post(`message/mass/send?access_token=${this.accessToken}`).send({ touser: openidList, msgtype: 'text', text: { content: msg }, }); if (res.body.errcode) { wechaty_puppet_1.log.error('OfficialAccount', 'deleteTag() error code : %s', res.body.errcode); } } } exports.OfficialAccount = OfficialAccount; //# sourceMappingURL=official-account.js.map