wechaty-puppet-official-account
Version:
Wechaty Puppet for WeChat Official Accounts
600 lines • 26.2 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;
};
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