//Copyright 2008-2021 Jacky Zong . All rights reserved.
// 江山如此多娇,
// 引无数英雄竞折腰;
// 惜秦皇汉武
// 略输文采
import fs from 'fs';
import AxiosError from 'axios-error';
import FormData from 'form-data';
import axios, { AxiosInstance, AxiosResponse } from 'axios';
import invariant from 'ts-invariant';
import warning from 'warning';
import {
OnRequestFunction,
camelcaseKeys,
createRequestInterceptor,
onRequest,
snakecaseKeys,
} from 'messaging-api-common';
import * as WechatTypes from './WechatTypes';
function throwErrorIfAny(response: AxiosResponse): AxiosResponse {
const { errcode, errmsg } = response.data;
if (!errcode || errcode === 0) return response;
const msg = `WeChat API - ${errcode} ${errmsg}`;
throw new AxiosError(msg, {
response,
config: response.config,
request: response.request,
});
}
export default class WechatClient {
static connect(config: WechatTypes.ClientConfig): WechatClient {
warning(
false,
'`WechatClient.connect(...)` is deprecated. Use `new WechatClient(...)` instead.'
);
return new WechatClient(config);
}
readonly axios: AxiosInstance;
accessToken = '';
private onRequest?: OnRequestFunction;
private appId: string;
private appSecret: string;
private tokenExpiresAt = 0;
constructor(config: WechatTypes.ClientConfig) {
invariant(
typeof config !== 'string',
`WechatClient: do not allow constructing client with ${config} string. Use object instead.`
);
this.appId = config.appId;
this.appSecret = config.appSecret;
this.onRequest = config.onRequest || onRequest;
const { origin } = config;
this.axios = axios.create({
baseURL: `${origin || 'https://api.weixin.qq.com'}/cgi-bin/`,
headers: {
'Content-Type': 'application/json',
},
});
this.axios.interceptors.request.use(
createRequestInterceptor({
onRequest: this.onRequest,
})
);
}
private async refreshToken(): Promise<void> {
const { accessToken, expiresIn } = await this.getAccessToken();
this.accessToken = accessToken;
this.tokenExpiresAt = Date.now() + expiresIn * 1000;
}
private async refreshTokenWhenExpired(): Promise<void> {
if (Date.now() > this.tokenExpiresAt) {
await this.refreshToken();
}
}
getAccessToken(): Promise<WechatTypes.AccessToken> {
return this.axios
.get<
| { access_token: string; expires_in: number }
| WechatTypes.FailedResponseData
>(
`/token?grant_type=client_credential&appid=${this.appId}&secret=${this.appSecret}`
)
.then(throwErrorIfAny)
.then(
(res) =>
camelcaseKeys(res.data, {
deep: true,
}) as any
);
}
async uploadMedia(
type: WechatTypes.MediaType,
media: Buffer | fs.ReadStream
): Promise<WechatTypes.UploadedMedia> {
await this.refreshTokenWhenExpired();
const form = new FormData();
form.append('media', media);
return this.axios
.post<
| { type: string; media_id: string; created_at: number }
| WechatTypes.FailedResponseData
>(`/media/upload?access_token=${this.accessToken}&type=${type}`, form, {
headers: form.getHeaders(),
})
.then(throwErrorIfAny)
.then(
(res) =>
camelcaseKeys(res.data, {
deep: true,
}) as any
);
}
async getMedia(mediaId: string): Promise<WechatTypes.Media> {
await this.refreshTokenWhenExpired();
return this.axios
.get<{ video_url: string } | WechatTypes.FailedResponseData>(
`/media/get?access_token=${this.accessToken}&media_id=${mediaId}`
)
.then(throwErrorIfAny)
.then(
(res) =>
camelcaseKeys(res.data, {
deep: true,
}) as any
);
}
async sendRawBody(
body: {
touser: string;
} & WechatTypes.SendMessageOptions &
(
| {
msgtype: 'text';
text: {
content: string;
};
}
| {
msgtype: 'image';
image: {
mediaId: string;
};
}
| {
msgtype: 'voice';
voice: {
mediaId: string;
};
}
| {
msgtype: 'video';
video: WechatTypes.Video;
}
| {
msgtype: 'music';
music: WechatTypes.Music;
}
| {
msgtype: 'news';
news: WechatTypes.News;
}
| {
msgtype: 'mpnews';
mpnews: {
mediaId: string;
};
}
| {
msgtype: 'msgmenu';
msgmenu: WechatTypes.MsgMenu;
}
| {
msgtype: 'wxcard';
wxcard: {
cardId: string;
};
}
| {
msgtype: 'miniprogrampage';
miniprogrampage: WechatTypes.MiniProgramPage;
}
)
): Promise<WechatTypes.SucceededResponseData> {
await this.refreshTokenWhenExpired();
return this.axios
.post<WechatTypes.ResponseData>(
`/message/custom/send?access_token=${this.accessToken}`,
snakecaseKeys(body, { deep: true })
)
.then(throwErrorIfAny)
.then(
(res) =>
camelcaseKeys(res.data, {
deep: true,
}) as any
);
}
sendText(
userId: string,
text: string,
options?: WechatTypes.SendMessageOptions
): Promise<WechatTypes.SucceededResponseData> {
return this.sendRawBody({
touser: userId,
msgtype: 'text',
text: {
content: text,
},
...options,
});
}
sendImage(
userId: string,
mediaId: string,
options?: WechatTypes.SendMessageOptions
): Promise<WechatTypes.SucceededResponseData> {
return this.sendRawBody({
touser: userId,
msgtype: 'image',
image: {
mediaId,
},
...options,
});
}
sendVoice(
userId: string,
mediaId: string,
options?: WechatTypes.SendMessageOptions
): Promise<WechatTypes.SucceededResponseData> {
return this.sendRawBody({
touser: userId,
msgtype: 'voice',
voice: {
mediaId,
},
...options,
});
}
sendVideo(
userId: string,
video: WechatTypes.Video,
options?: WechatTypes.SendMessageOptions
): Promise<WechatTypes.SucceededResponseData> {
return this.sendRawBody({
touser: userId,
msgtype: 'video',
video,
...options,
});
}
sendMusic(
userId: string,
music: WechatTypes.Music,
options?: WechatTypes.SendMessageOptions
): Promise<WechatTypes.SucceededResponseData> {
return this.sendRawBody({
touser: userId,
msgtype: 'music',
music,
...options,
});
}
sendNews(
userId: string,
news: WechatTypes.News,
options?: WechatTypes.SendMessageOptions
): Promise<WechatTypes.SucceededResponseData> {
return this.sendRawBody({
touser: userId,
msgtype: 'news',
news,
...options,
});
}
sendMPNews(
userId: string,
mediaId: string,
options?: WechatTypes.SendMessageOptions
): Promise<WechatTypes.SucceededResponseData> {
return this.sendRawBody({
touser: userId,
msgtype: 'mpnews',
mpnews: {
mediaId,
},
...options,
});
}
sendMsgMenu(
userId: string,
msgMenu: WechatTypes.MsgMenu,
options?: WechatTypes.SendMessageOptions
): Promise<WechatTypes.SucceededResponseData> {
return this.sendRawBody({
touser: userId,
msgtype: 'msgmenu',
msgmenu: msgMenu,
...options,
});
}
sendWXCard(
userId: string,
cardId: string,
options?: WechatTypes.SendMessageOptions
): Promise<WechatTypes.SucceededResponseData> {
return this.sendRawBody({
touser: userId,
msgtype: 'wxcard',
wxcard: {
cardId,
},
...options,
});
}
sendMiniProgramPage(
userId: string,
miniProgramPage: WechatTypes.MiniProgramPage,
options?: WechatTypes.SendMessageOptions
): Promise<WechatTypes.SucceededResponseData> {
return this.sendRawBody({
touser: userId,
msgtype: 'miniprogrampage',
miniprogrampage: miniProgramPage,
...options,
});
}
// TODO: implement typing
// TODO: 客服帳號相關
}