oak-external-sdk/src/service/wechat/WechatPublic.ts

758 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

require('../../fetch');
import crypto from 'crypto';
import { Buffer } from 'buffer';
import URL from 'url';
// 目前先支持text和news, 其他type文档https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Service_Center_messages.html
// type ServeMessageType = 'text' | 'news' | 'mpnews' | 'mpnewsarticle' | 'image' | 'voice' | 'video' | 'music' | 'msgmenu';/
type TextServeMessageOption = {
openId: string;
type: 'text';
content: string;
};
type NewsServeMessageOption = {
openId: string;
type: 'news';
title: string;
description?: string;
url: string;
picurl?: string;
};
type MpServeMessageOption = {
openId: string;
type: 'mp';
data: {
title: string;
appId: string;
pagepath: string;
thumbnailId: string;
};
};
type ServeMessageOption = TextServeMessageOption | NewsServeMessageOption | MpServeMessageOption;
export class WechatPublicInstance {
appId: string;
appSecret?: string;
private accessToken?: string;
private refreshAccessTokenHandler?: any;
private externalRefreshFn?: (appId: string) => Promise<string>;
constructor(
appId: string,
appSecret?: string,
accessToken?: string,
externalRefreshFn?: (appId: string) => Promise<string>
) {
this.appId = appId;
this.appSecret = appSecret;
this.externalRefreshFn = externalRefreshFn;
if (!appSecret && !externalRefreshFn) {
throw new Error('appSecret和externalRefreshFn必须至少支持一个');
}
if (accessToken) {
this.accessToken = accessToken;
} else {
this.refreshAccessToken();
}
}
private async getAccessToken() {
while (true) {
if (this.accessToken) {
return this.accessToken;
}
await new Promise((resolve) => setTimeout(() => resolve(0), 500));
}
}
private async access(
url: string,
mockData?: any,
init?: RequestInit
): Promise<any> {
if (process.env.NODE_ENV === 'development' && mockData) {
return mockData;
}
const response = await global.fetch(url, init);
const { headers, status } = response;
if (![200, 201].includes(status)) {
throw new Error(`微信服务器返回不正确应答:${status}`);
}
const contentType =
(headers as any)['Content-Type'] || headers.get('Content-Type')!;
if (contentType.includes('application/json')) {
const json = await response.json();
if (typeof json.errcode === 'number' && json.errcode !== 0) {
if ([40001, 42001].includes(json.errcode)) {
return this.refreshAccessToken(url, init);
}
throw new Error(
`调用微信接口返回出错code是${json.errcode},信息是${json.errmsg}`
);
}
return json;
}
if (
contentType.includes('text') ||
contentType.includes('xml') ||
contentType.includes('html')
) {
const data = await response.text();
return data;
}
if (contentType.includes('application/octet-stream')) {
return await response.arrayBuffer();
}
return response;
}
async code2Session(code: string) {
const result = await this.access(
`https://api.weixin.qq.com/sns/oauth2/access_token?appid=${this.appId}&secret=${this.appSecret}&code=${code}&grant_type=authorization_code`,
// {
// access_token: 'aaa',
// openid: code,
// unionid: code,
// refresh_token: 'aaa',
// is_snapshotuser: false,
// expires_in: 30,
// scope: 'userinfo',
// }
);
const {
access_token,
openid,
unionid,
scope,
refresh_token,
is_snapshotuser,
expires_in,
} = typeof result === 'string' ? JSON.parse(result) : result; // 这里微信返回的数据有时候竟然是text/plain
return {
accessToken: access_token as string,
openId: openid as string,
unionId: unionid as string,
scope: scope as string,
refreshToken: refresh_token as string,
isSnapshotUser: !!is_snapshotuser,
atExpiredAt: Date.now() + expires_in * 1000,
rtExpiredAt: Date.now() + 30 * 86400 * 1000,
};
}
async refreshUserAccessToken(refreshToken: string) {
const result = await this.access(
`https://api.weixin.qq.com/sns/oauth2/refresh_token?appid=${this.appId}&grant_type=refresh_token&refresh_token=${refreshToken}`,
// {
// access_token: 'aaa',
// refresh_token: 'aaa',
// expires_in: 30,
// scope: 'userinfo',
// }
);
const { access_token, refresh_token, expires_in, scope } = result;
return {
accessToken: access_token as string,
refreshToken: refresh_token as string,
atExpiredAt: Date.now() + expires_in * 1000,
scope: scope as string,
};
}
async getUserInfo(accessToken: string, openId: string) {
const result = await this.access(
`https://api.weixin.qq.com/sns/userinfo?access_token=${accessToken}&openid=${openId}&lang=zh_CN`,
// {
// nickname: '码农哥',
// sex: 1,
// headimgurl:
// 'https://www.ertongzy.com/uploads/allimg/161005/2021233Y7-0.jpg',
// }
);
const { nickname, sex, headimgurl } = result;
return {
nickname: nickname as string,
gender: sex === 1 ? 'male' : sex === 2 ? 'female' : undefined,
avatar: headimgurl as string,
};
}
async getTags() {
const myInit = {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
};
const token = await this.getAccessToken();
const result = await this.access(
`https://api.weixin.qq.com/cgi-bin/tags/get?access_token=${token}`,
undefined,
myInit
)
return result;
}
async getCurrentMenu() {
const myInit = {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
};
const token = await this.getAccessToken();
const result = await this.access(
`https://api.weixin.qq.com/cgi-bin/get_current_selfmenu_info?access_token=${token}`,
undefined,
myInit
)
return result;
}
async getMenu() {
const myInit = {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
};
const token = await this.getAccessToken();
const result = await this.access(
`https://api.weixin.qq.com/cgi-bin/menu/get?access_token=${token}`,
undefined,
myInit
)
return result;
}
async createMenu(menuConfig: any) {
const myInit = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(menuConfig),
};
const token = await this.getAccessToken();
const result = await this.access(
`https://api.weixin.qq.com/cgi-bin/menu/create?access_token=${token}`,
undefined,
myInit
);
const { errcode } = result;
if (errcode === 0) {
return Object.assign({ success: true }, result);
}
return Object.assign({ success: false }, result);
}
async createConditionalMenu(menuConfig: any) {
const myInit = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(menuConfig),
};
const token = await this.getAccessToken();
const result = await this.access(
`https://api.weixin.qq.com/cgi-bin/menu/addconditional?access_token=${token}`,
undefined,
myInit
);
const { errcode } = result;
if (errcode === 0) {
return Object.assign({ success: true }, result);
}
return Object.assign({ success: false }, result);
}
async deleteConditionalMenu(menuid: number) {
const myInit = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
menuid
}),
};
const token = await this.getAccessToken();
const result = await this.access(
`https://api.weixin.qq.com/cgi-bin/menu/delconditional?access_token=${token}`,
undefined,
myInit
);
const { errcode } = result;
if (errcode === 0) {
return Object.assign({ success: true }, result);
}
return Object.assign({ success: false }, result);
}
private async refreshAccessToken(url?: string, init?: RequestInit) {
const result = this.externalRefreshFn
? await this.externalRefreshFn(this.appId)
: await this.access(
`https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${this.appId}&secret=${this.appSecret}`,
//{ access_token: 'mockToken', expires_in: 600 }
);
const { access_token, expires_in } = result;
this.accessToken = access_token;
// 生成下次刷新的定时器
console.log((expires_in - 10) * 1000);
this.refreshAccessTokenHandler = setTimeout(() => {
this.refreshAccessToken();
}, (expires_in - 10) * 1000);
if (url) {
const url2= new URL.URL(url);
url2.searchParams.set('access_token', access_token)
return this.access(url2.toString(), {}, init);
}
}
decryptData(
sessionKey: string,
encryptedData: string,
iv: string,
signature: string
) {
const skBuf = Buffer.from(sessionKey, 'base64');
// const edBuf = Buffer.from(encryptedData, 'base64');
const ivBuf = Buffer.from(iv, 'base64');
const decipher = crypto.createDecipheriv('aes-128-cbc', skBuf, ivBuf);
// 设置自动 padding 为 true删除填充补位
decipher.setAutoPadding(true);
let decoded = decipher.update(encryptedData, 'base64', 'utf8');
decoded += decipher.final('utf8');
const data = JSON.parse(decoded);
if (data.watermark.appid !== this.appId) {
throw new Error('Illegal Buffer');
}
return data;
}
async getQrCode(options: {
sceneId?: number;
sceneStr?: string;
expireSeconds?: number;
isPermanent?: boolean;
}) {
const { sceneId, sceneStr, expireSeconds, isPermanent } = options;
if (!sceneId && !sceneStr) {
throw new Error('Missing sceneId or sceneStr');
}
const scene = sceneId
? {
scene_id: sceneId,
}
: {
scene_str: sceneStr,
};
let actionName = sceneId ? 'QR_SCENE' : 'QR_STR_SCENE';
let myInit = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
expire_seconds: expireSeconds,
action_name: actionName,
action_info: {
scene,
},
}),
};
if (isPermanent) {
actionName = sceneId ? 'QR_LIMIT_SCENE' : 'QR_LIMIT_STR_SCENE';
myInit = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action_name: actionName,
action_info: {
scene,
},
}),
};
}
const token = await this.getAccessToken();
const result = await this.access(
`https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token=${token}`,
// {
// ticket: `ticket${Date.now()}`,
// url: `http://mock/q/${sceneId ? sceneId : sceneStr}`,
// expireSeconds: expireSeconds,
// },
myInit
);
return {
ticket: result.ticket,
url: result.url,
expireSeconds: result.expire_seconds,
};
}
async sendTemplateMessage(options: {
openId: string;
templateId: string;
url?: string;
data: Object;
miniProgram?: {
appid: string;
pagepath: string;
};
clientMsgId?: string;
}) {
const { openId, templateId, url, data, miniProgram, clientMsgId } =
options;
const myInit = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
touser: openId,
template_id: templateId,
url,
miniProgram,
client_msg_id: clientMsgId,
data,
}),
};
const token = await this.getAccessToken();
const result = await this.access(
`https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=${token}`,
// {
// errcode: 0,
// errmsg: 'ok',
// msgid: Date.now(),
// },
myInit
);
const { errcode } = result;
if (errcode === 0) {
return Object.assign({ success: true }, result);
}
return Object.assign({ success: false }, result);
}
async sendServeMessage(options: ServeMessageOption) {
const { openId, type } = options;
const myInit = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
};
switch (type) {
case 'text': {
Object.assign(myInit, {
body: JSON.stringify({
touser: openId,
msgtype: 'text',
text: {
content: options.content,
},
}),
});
break;
}
case 'news': {
Object.assign(myInit, {
body: JSON.stringify({
touser: openId,
msgtype: 'news',
news: {
articles: [
{
title: options.title,
description: options.description,
url: options.url,
picurl: options.picurl,
},
],
},
}),
});
break;
}
case 'mp': {
Object.assign(myInit, {
body: JSON.stringify({
touser: openId,
msgtype: 'miniprogrampage',
miniprogrampage: {
title: options.data.title,
appid: options.data.appId,
pagepath: options.data.pagepath,
thumb_media_id: options.data.thumbnailId,
},
}),
});
break;
}
default: {
throw new Error('当前消息类型暂不支持');
}
}
const token = await this.getAccessToken();
const result = await this.access(
`https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=${token}`,
// {
// errcode: 0,
// errmsg: 'ok',
// },
myInit
);
const { errcode } = result;
if (errcode === 0) {
return Object.assign({ success: true }, result);
}
return Object.assign({ success: false }, result);
}
async batchGetArticle(options: {
offset?: number;
count: number;
noContent?: 0 | 1;
}) {
const { offset, count, noContent } = options;
const myInit = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
offset,
count,
no_content: noContent,
}),
};
const token = await this.getAccessToken();
const result = await this.access(
`https://api.weixin.qq.com/cgi-bin/freepublish/batchget?access_token=${token}`,
undefined,
myInit
);
const { errcode } = result;
if (!errcode) {
return result;
}
throw new Error(JSON.stringify(result));
}
async getArticle(options: {
article_id: string,
}) {
const { article_id } = options;
const myInit = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
article_id,
}),
};
const token = await this.getAccessToken();
const result = await this.access(
`https://api.weixin.qq.com/cgi-bin/freepublish/getarticle?access_token=${token}`,
undefined,
myInit
);
const { errcode } = result;
if (!errcode) {
return result;
}
throw new Error(JSON.stringify(result));
}
async createMaterial(options: {
type: 'image' | 'voice' | 'video' | 'thumb',
media: FormData,
description?: FormData
}) {
const { type, media, description } = options;
const myInit = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
};
if (type === 'video') {
Object.assign(myInit, {
body: JSON.stringify({
type,
media,
description,
}),
});
} else {
Object.assign(myInit, {
body: JSON.stringify({
type,
media,
}),
});
};
const token = await this.getAccessToken();
const result = await this.access(
`https://api.weixin.qq.com/cgi-bin/material/add_material?access_token=${token}`,
undefined,
myInit,
);
const { errcode } = result;
if (!errcode) {
return result;
}
throw new Error(JSON.stringify(result));
}
async batchGetMaterialList(options: {
type: 'image' | 'video' | 'voice' | 'news',
offset?: number;
count: number;
}) {
const { offset, count, type } = options;
const myInit = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type,
offset,
count,
}),
};
const token = await this.getAccessToken();
const result = await this.access(
`https://api.weixin.qq.com/cgi-bin/material/batchget_material?access_token=${token}`,
undefined,
myInit
);
const { errcode } = result;
if (!errcode) {
return result;
}
throw new Error(JSON.stringify(result));
}
async getMaterial(options: {
type: 'image' | 'video' | 'voice' | 'news',
media_id: string,
}) {
const { type, media_id } = options;
const myInit = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
media_id
}),
};
let imgFile;
const token = await this.getAccessToken();
const result = await this.access(
`https://api.weixin.qq.com/cgi-bin/material/get_material?access_token=${token}`,
undefined,
myInit
);
if ('errcode' in result) {
throw new Error(JSON.stringify(result));
} else {
return result;
}
}
async getTicket() {
const myInit = {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
};
const token = await this.getAccessToken();
const result = (await this.access(
`https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token=${token}&type=jsapi`,
// {
// ticket: `ticket${Date.now()}`,
// expires_in: 30,
// },
myInit
)) as {
ticket: string;
expires_in: number;
};
const { ticket } = result;
return ticket;
}
private randomString() {
let len = 16;
let $chars = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678';
/** **默认去掉了容易混淆的字符oOLl,9gq,Vv,Uu,I1****/
let maxPos = $chars.length;
let pwd = '';
for (let i = 0; i < len; i++) {
pwd += $chars.charAt(Math.floor(Math.random() * maxPos));
}
return pwd;
}
async signatureJsSDK(options: { url: string }) {
const url = options.url;
const noncestr = this.randomString();
const timestamp = parseInt((Date.now() / 1000).toString(), 10);
const jsapi_ticket = await this.getTicket();
const contentArray = {
noncestr,
jsapi_ticket,
timestamp,
url,
};
let zhimaString = '';
Object.keys(contentArray)
.sort()
.forEach((ele, idx) => {
if (idx > 0) {
zhimaString += '&';
}
zhimaString += ele;
zhimaString += '=';
zhimaString += contentArray[ele as keyof typeof contentArray];
});
return {
signature: crypto
.createHash('sha1')
.update(zhimaString)
.digest('hex'),
noncestr,
timestamp,
appId: this.appId,
};
}
}