UNPKG

wechaty-puppet-official-account

Version:
815 lines (686 loc) 26 kB
/* eslint-disable camelcase */ import { log, } from 'wechaty-puppet' import { ContactGender, } from 'wechaty-puppet/types' import type { MiniProgram, UrlLink, } from 'wechaty-puppet/payloads' import type { FileBoxInterface, } from 'file-box' import { FileBoxType } from 'file-box' import * as UUID from 'uuid' import * as crypto from 'crypto' import { EventEmitter } from 'events' import { Webhook, VerifyArgs, } from './webhook.js' import { getSimpleUnirest, SimpleUnirest, } from './simple-unirest.js' import type { OAMessageType, // OAMediaPayload, OAMediaType, ErrorPayload, OAContactPayload, OATagPayload, OAMessagePayload, } from './schema.js' import { PayloadStore } from './payload-store.js' import { getTimeStampString } from './utils.js' import { normalizeFileBox } from './normalize-file-box.js' export interface OfficialAccountOptions { appId : string, appSecret : string, port? : number, token : string, webhookProxyUrl? : string, personalMode? : boolean, accessTokenProxyUrl? : string, } export interface AccessTokenPayload { expiresIn : number, timestamp : number, token : string, } type StopperFn = () => void class OfficialAccount extends EventEmitter { payloadStore : PayloadStore protected webhook : Webhook protected simpleUnirest : SimpleUnirest protected accessTokenPayload? : AccessTokenPayload protected stopperFnList : StopperFn[] protected oaId : string // proxy of the access token center protected accessTokenProxyUrl?: string get accessToken (): string { if (!this.accessTokenPayload) { throw new Error('accessToken() this.accessTokenPayload uninitialized!') } return this.accessTokenPayload.token } constructor ( public options: OfficialAccountOptions, ) { super() 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({ personalMode : !!this.options.personalMode, port : this.options.port, verify : this.verify.bind(this), webhookProxyUrl : this.options.webhookProxyUrl, }) this.payloadStore = new PayloadStore(options.appId) this.simpleUnirest = getSimpleUnirest('https://api.weixin.qq.com/cgi-bin/') this.stopperFnList = [] this.accessTokenProxyUrl = options.accessTokenProxyUrl } verify (args: VerifyArgs): boolean { 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 () { 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) { log.error('OfficialAccount', 'start() updateAccessToken() failed.') } const stopper = await this.startSyncingAccessToken() this.stopperFnList.push(stopper) await this.webhook.start() } async stop () { 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() } protected async updateAccessToken (): Promise<boolean> { log.verbose('OfficialAccount', 'updateAccessToken()') /** * updated: { * "access_token":"3...Q", * "expires_in":7200 * } */ let simpleUnirest: SimpleUnirest = this.simpleUnirest // NOTE: it will fetch accessToken from the specific endpoint if (this.accessTokenProxyUrl) { simpleUnirest = getSimpleUnirest(this.accessTokenProxyUrl) } const ret = await simpleUnirest .get<Partial<ErrorPayload> & { access_token : string expires_in : number }>(`token?grant_type=client_credential&appid=${this.options.appId}&secret=${this.options.appSecret}`) 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"} 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. 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, } 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 */ protected async startSyncingAccessToken (): Promise<StopperFn> { 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: undefined | ReturnType<typeof setTimeout> // eslint-disable-next-line @typescript-eslint/no-misused-promises const update: () => void = () => 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 => 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: { touser: string, msgtype: OAMessageType, content: string, }): Promise<string> { this.webhook.emit('instantReply', args) const uuid: string = UUID.v4() await this.payloadStore.setMessagePayload(uuid, { Content : args.content, CreateTime : 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: { touser: string, msgtype: OAMessageType, content: string, }): Promise<string> { log.verbose('OfficialAccount', 'sendCustomMessage(%s)', JSON.stringify(args)) const ret = await this.simpleUnirest .post<ErrorPayload>(`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: string = UUID.v4() await this.payloadStore.setMessagePayload(uuid, { CreateTime : getTimeStampString(), FromUserName : this.oaId, MsgId : uuid, MsgType : 'text', ToUserName : args.touser, }) return uuid } async sendCustomLink (args: { touser: string urlLinkPayload:UrlLink }): Promise<string> { log.verbose('OfficialAccount', 'sendCustomLink(%s)', JSON.stringify(args)) const msgtype: OAMessageType = 'link' const ret = await this.simpleUnirest .post<ErrorPayload>(`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: string = UUID.v4() await this.payloadStore.setMessagePayload(uuid, { CreateTime : getTimeStampString(), FromUserName : this.oaId, MsgId : uuid, MsgType : 'link', ToUserName : args.touser, }) return uuid } async sendCustomMiniProgram (args: { miniProgram:MiniProgram touser: string, }): Promise<string> { log.verbose('OfficialAccount', 'sendCustomMiniProgram(%s)', JSON.stringify(args)) const msgtype:OAMessageType = 'miniprogrampage' const ret = await this.simpleUnirest .post<ErrorPayload>(`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: string = UUID.v4() await this.payloadStore.setMessagePayload(uuid, { CreateTime : getTimeStampString(), FromUserName : this.oaId, MsgId : uuid, MsgType : 'miniprogrampage', ToUserName : args.touser, }) return uuid } async sendFile (args: { file : FileBoxInterface, touser : string, msgtype : OAMediaType, }): Promise<string> { log.verbose('OfficialAccount', 'sendFile(%s)', JSON.stringify(args)) // JSON.stringify does not support .mp3 filetype const { buf, info } = await 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 === FileBoxType.Url && args.file.mediaType === 'image/jpeg') { info.filename = `${args.file.name}.jpeg` } if (args.file.type === FileBoxType.Url && args.file.mediaType === 'audio/amr') { info.filename = `${args.file.name}` } const mediaResponse = await this.simpleUnirest.post<Partial<ErrorPayload> & { media_id : string, created_at : string, type : string, }>(`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<ErrorPayload>(`message/custom/send?access_token=${this.accessToken}`).type('json').send(data) if (messageResponse.body.errcode) { 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: OAMessagePayload = { CreateTime : 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 (): Promise<string[]> { log.verbose('OfficialAccount', 'getContactList') let openIdList: string[] = [] 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<Partial<ErrorPayload> & { total : number, count : number, data : { openid : string[] }, next_openid : string }>(`user/get?access_token=${this.accessToken}&next_openid=${nextOpenId}`) if (req.body.errcode) { 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: string): Promise<void | OAContactPayload> { 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: OAContactPayload = { subscribe : 0, openid : openId, nickname : 'from official-account options ?', sex : 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: OAContactPayload = { subscribe : 1, openid : openId, nickname : '订阅者', sex : 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<OAContactPayload>(`user/info?access_token=${this.accessToken}&openid=${openId}&lang=zh_CN`) if (res.body.errcode) { 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: string, remark: string): Promise<void> { log.verbose('OfficialAccount', 'setContactRemark(%s)', JSON.stringify({ openId, remark })) const res = await this.simpleUnirest.post<ErrorPayload>(`user/info/updateremark?access_token=${this.accessToken}`) if (res.body.errcode) { log.error('OfficialAccount', 'setContactRemark() can update contact remark (%s)', res.body.errmsg) } } async createTag (name: string): Promise<void | string> { log.verbose('OfficialAccount', 'createTag(%s)', name) const res = await this.simpleUnirest.post<Partial<ErrorPayload> & { tag?: { id : string, } }>(`tags/create?access_token=${this.accessToken}`) if (res.body.errcode) { log.error('OfficialAccount', 'createTag(%s) error code : %s', name, res.body.errcode) } else { return name } } async getTagList (): Promise<OATagPayload[]> { log.verbose('OfficialAccount', 'getTagList()') const res = await this.simpleUnirest.get<Partial<ErrorPayload> & { tags? : OATagPayload[] }>(`tags/get?access_token=${this.accessToken}`) if (res.body.errcode) { log.error('OfficialAccount', 'getTagList() error code : %s', res.body.errcode) return [] } if (!res.body.tags || res.body.tags.length === 0) { log.warn('OfficialAccount', 'getTagList() get empty tag list') return [] } return res.body.tags } private async getTagIdByName (tagName: string): Promise<number| null> { log.verbose('OfficialAccount', 'deleteTag(%s)', tagName) /** * TODO: this is not a frequent interface, So I don't cache the taglist */ const tagList: OATagPayload[] = await this.getTagList() const tag: OATagPayload[] = tagList.filter((item) => item.name === tagName) if (tag.length === 0) { return null } return tag[0]!.id } async deleteTag (tagName: string): Promise<void> { 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<Partial<ErrorPayload>>(`tags/delete?access_token=${this.accessToken}`).send({ tag: { id : tagId, }, }) if (res.body.errcode) { log.error('OfficialAccount', 'deleteTag() error code : %s', res.body.errcode) } } async addTagToMembers (tagName: string, openIdList: string[]): Promise<void> { 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<Partial<ErrorPayload>>(`tags/members/batchtagging?access_token=${this.accessToken}`).send({ opeid_list : openIdList, tag_id : tagId, }) if (res.body.errcode) { log.error('OfficialAccount', 'addTagToMembers() error code : %s', res.body.errcode) } } async removeTagFromMembers (tagName: string, openIdList: string[]): Promise<void> { 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<Partial<ErrorPayload>>(`tags/members/batchuntagging?access_token=${this.accessToken}`).send({ opeid_list : openIdList, tag_id : tagId, }) if (res.body.errcode) { log.error('OfficialAccount', 'removeTagFromMembers() error code : %s', res.body.errcode) } } async getMemberTags (openid: string): Promise<string[]> { log.verbose('OfficialAccount', 'getMemberTags(%s)', openid) const res = await this.simpleUnirest.post<Partial<ErrorPayload> & { tagid_list : number[] }>(`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: any, tag) => { map[tag.id] = tag.name; return map }, {}) // 2. retrive the names from id const tagNames: string[] = [] for (const tagId of res.body.tagid_list) { if (tagId in tagIdMap) { tagNames.push(tagIdMap[tagId]) } } return tagNames } async getAudioUrl (mediaId: string): Promise<string> { // 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: string, remark: string): Promise<void> { log.verbose('OfficialAccount', 'setMemberRemark(%s)', openid) const res = await this.simpleUnirest.post<Partial<ErrorPayload>>(`user/info/updateremark?access_token=${this.accessToken}`).send({ openid, remark, }) if (res.body.errcode) { log.error('OfficialAccount', 'deleteTag() error code : %s', res.body.errcode) } } async sendBatchTextMessageByTagId (tagId: number, msg: string): Promise<void> { log.verbose('OfficialAccount', 'sendBatchTextMessageByTagId(%s)', JSON.stringify({ tagId, msg })) const res = await this.simpleUnirest.post<Partial<ErrorPayload> & { msg_id : number, msg_data_id : number, }>(`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) { log.error('OfficialAccount', 'deleteTag() error code : %s', res.body.errcode) } } async sendBatchTextMessageByOpenidList (openidList: string[], msg: string): Promise<void> { log.verbose('OfficialAccount', 'sendBatchTextMessageByOpenidList(%s)', JSON.stringify({ openidList, msg })) const res = await this.simpleUnirest.post<Partial<ErrorPayload> & { msg_id : number, msg_data_id : number, }>(`message/mass/send?access_token=${this.accessToken}`).send({ touser : openidList, msgtype : 'text', text : { content: msg }, }) if (res.body.errcode) { log.error('OfficialAccount', 'deleteTag() error code : %s', res.body.errcode) } } /** * 获取授权方的帐号基本信息 * 该 API 用于获取授权方的基本信息,包括头像、昵称、帐号类型、认证类型、微信号、原始ID和二维码图片URL。 * https://developers.weixin.qq.com/doc/oplatform/Third-party_Platforms/api/api_get_authorizer_info.html */ // async getInfo () { // log.verbose('OfficialAccount', 'sendCustomMessage(%s)', JSON.stringify(args)) // const ret = await this.simpleUnirest // .post<ErrorPayload>(`component/api_get_authorizer_info?component_access_token=${this.accessToken}`) // .type('json') // .send({ // msgtype: args.msgtype, // text: // { // content: args.content, // }, // touser: args.touser, // }) // return ret.body // POST https://api.weixin.qq.com/cgi-bin/ // } } export { OfficialAccount }