|
import http2, { ClientHttp2Session } from "http2";
|
|
import path from "path";
|
|
import fs from "fs";
|
|
import _ from "lodash";
|
|
import mime from "mime";
|
|
import FormData from "form-data";
|
|
import OSS from "ali-oss";
|
|
import axios, { AxiosRequestConfig, AxiosResponse } from "axios";
|
|
|
|
import APIException from "@/lib/exceptions/APIException.ts";
|
|
import EX from "@/api/consts/exceptions.ts";
|
|
import logger from "@/lib/logger.ts";
|
|
import util from "@/lib/util.ts";
|
|
|
|
|
|
const DEVICE_INFO_EXPIRES = 10800;
|
|
|
|
const FAKE_HEADERS = {
|
|
Accept: "*/*",
|
|
"Accept-Encoding": "gzip, deflate, br, zstd",
|
|
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
|
"Cache-Control": "no-cache",
|
|
Origin: "https://hailuoai.com",
|
|
Pragma: "no-cache",
|
|
Priority: "u=1, i",
|
|
"Sec-Ch-Ua":
|
|
'"Not)A;Brand";v="99", "Google Chrome";v="127", "Chromium";v="127"',
|
|
"Sec-Ch-Ua-Mobile": "?0",
|
|
"Sec-Ch-Ua-Platform": '"Windows"',
|
|
"Sec-Fetch-Dest": "empty",
|
|
"Sec-Fetch-Mode": "cors",
|
|
"Sec-Fetch-Site": "same-origin",
|
|
"User-Agent":
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36",
|
|
};
|
|
|
|
const FAKE_USER_DATA = {
|
|
device_platform: "web",
|
|
app_id: "3001",
|
|
version_code: "22200",
|
|
uuid: null,
|
|
device_id: null,
|
|
os_name: "Windows",
|
|
browser_name: "chrome",
|
|
device_memory: 8,
|
|
cpu_core_num: 12,
|
|
browser_language: "zh-CN",
|
|
browser_platform: "Win32",
|
|
screen_width: 1920,
|
|
screen_height: 1080,
|
|
unix: null,
|
|
};
|
|
const SENTRY_RELEASE = "CI7N-1MjJnx5pru-bzzhR";
|
|
const SENTRY_PUBLIC_KEY = "6cf106db5c7b7262eae7cc6b411c776a";
|
|
|
|
const FILE_MAX_SIZE = 100 * 1024 * 1024;
|
|
|
|
const deviceInfoMap = new Map();
|
|
|
|
const deviceInfoRequestQueueMap: Record<string, Function[]> = {};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function requestDeviceInfo(token: string) {
|
|
if (deviceInfoRequestQueueMap[token])
|
|
return new Promise((resolve) =>
|
|
deviceInfoRequestQueueMap[token].push(resolve)
|
|
);
|
|
deviceInfoRequestQueueMap[token] = [];
|
|
logger.info(`Token: ${token}`);
|
|
const result = await (async () => {
|
|
const userId = util.uuid();
|
|
const result = await request(
|
|
"POST",
|
|
"/v1/api/user/device/register",
|
|
{
|
|
uuid: userId,
|
|
},
|
|
token,
|
|
{
|
|
userId,
|
|
},
|
|
{
|
|
params: FAKE_USER_DATA
|
|
}
|
|
);
|
|
const { deviceIDStr } = checkResult(result);
|
|
return {
|
|
deviceId: deviceIDStr,
|
|
userId,
|
|
refreshTime: util.unixTimestamp() + DEVICE_INFO_EXPIRES,
|
|
};
|
|
})()
|
|
.then((result) => {
|
|
if (deviceInfoRequestQueueMap[token]) {
|
|
deviceInfoRequestQueueMap[token].forEach((resolve) => resolve(result));
|
|
delete deviceInfoRequestQueueMap[token];
|
|
}
|
|
logger.success(`Refresh successful`);
|
|
return result;
|
|
})
|
|
.catch((err) => {
|
|
if (deviceInfoRequestQueueMap[token]) {
|
|
deviceInfoRequestQueueMap[token].forEach((resolve) => resolve(err));
|
|
delete deviceInfoRequestQueueMap[token];
|
|
}
|
|
return err;
|
|
});
|
|
if (_.isError(result)) throw result;
|
|
return result;
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function acquireDeviceInfo(token: string): Promise<string> {
|
|
let result = deviceInfoMap.get(token);
|
|
if (!result) {
|
|
result = await requestDeviceInfo(token);
|
|
deviceInfoMap.set(token, result);
|
|
}
|
|
if (util.unixTimestamp() > result.refreshTime) {
|
|
result = await requestDeviceInfo(token);
|
|
deviceInfoMap.set(token, result);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function checkFileUrl(fileUrl: string) {
|
|
if (util.isBASE64Data(fileUrl)) return;
|
|
const result = await axios.head(fileUrl, {
|
|
timeout: 15000,
|
|
validateStatus: () => true,
|
|
});
|
|
if (result.status >= 400)
|
|
throw new APIException(
|
|
EX.API_FILE_URL_INVALID,
|
|
`File ${fileUrl} is not valid: [${result.status}] ${result.statusText}`
|
|
);
|
|
|
|
if (result.headers && result.headers["content-length"]) {
|
|
const fileSize = parseInt(result.headers["content-length"], 10);
|
|
if (fileSize > FILE_MAX_SIZE)
|
|
throw new APIException(
|
|
EX.API_FILE_EXECEEDS_SIZE,
|
|
`File ${fileUrl} is not valid`
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function uploadFile(fileUrl: string, token: string) {
|
|
|
|
await checkFileUrl(fileUrl);
|
|
|
|
let filename, fileData: Buffer, mimeType;
|
|
|
|
if (util.isBASE64Data(fileUrl)) {
|
|
mimeType = util.extractBASE64DataFormat(fileUrl);
|
|
const ext = mime.getExtension(mimeType);
|
|
filename = `${util.uuid()}.${ext}`;
|
|
fileData = Buffer.from(util.removeBASE64DataHeader(fileUrl), "base64");
|
|
}
|
|
|
|
else {
|
|
filename = `${util.uuid()}${path.extname(fileUrl)}`;
|
|
({ data: fileData } = await axios.get(fileUrl, {
|
|
responseType: "arraybuffer",
|
|
|
|
maxContentLength: FILE_MAX_SIZE,
|
|
|
|
timeout: 60000,
|
|
}));
|
|
}
|
|
|
|
|
|
mimeType = mimeType || mime.getType(filename);
|
|
|
|
const deviceInfo = await acquireDeviceInfo(token);
|
|
|
|
|
|
const policyResult = await request(
|
|
"GET",
|
|
"/v1/api/files/request_policy",
|
|
{},
|
|
token,
|
|
deviceInfo
|
|
);
|
|
const {
|
|
accessKeyId,
|
|
accessKeySecret,
|
|
bucketName,
|
|
dir,
|
|
endpoint,
|
|
securityToken,
|
|
} = checkResult(policyResult);
|
|
|
|
|
|
const client = new OSS({
|
|
accessKeyId,
|
|
accessKeySecret,
|
|
bucket: bucketName,
|
|
endpoint,
|
|
stsToken: securityToken,
|
|
});
|
|
await client.put(`${dir}/${filename}`, fileData);
|
|
|
|
|
|
const policyCallbackResult = await request(
|
|
"POST",
|
|
"/v1/api/files/policy_callback",
|
|
{
|
|
fileName: filename,
|
|
originFileName: filename,
|
|
dir,
|
|
endpoint: endpoint,
|
|
bucketName,
|
|
size: `${fileData.byteLength}`,
|
|
mimeType,
|
|
},
|
|
token,
|
|
deviceInfo
|
|
);
|
|
const { fileID } = checkResult(policyCallbackResult);
|
|
|
|
const isImage = [
|
|
"image/jpeg",
|
|
"image/jpg",
|
|
"image/tiff",
|
|
"image/png",
|
|
"image/bmp",
|
|
"image/gif",
|
|
"image/svg+xml",
|
|
"image/webp",
|
|
"image/ico",
|
|
"image/heic",
|
|
"image/heif",
|
|
"image/bmp",
|
|
"image/x-icon",
|
|
"image/vnd.microsoft.icon",
|
|
"image/x-png",
|
|
].includes(mimeType);
|
|
|
|
return {
|
|
fileType: isImage ? 2 : 6,
|
|
filename,
|
|
fileId: fileID,
|
|
};
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function checkResult(result: AxiosResponse) {
|
|
if (!result.data) return null;
|
|
const { statusInfo, data } = result.data;
|
|
if (!_.isObject(statusInfo)) return result.data;
|
|
const { code, message } = statusInfo as any;
|
|
if (code === 0) return data;
|
|
throw new APIException(EX.API_REQUEST_FAILED, `[请求hailuo失败]: ${message}`);
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function tokenSplit(authorization: string) {
|
|
return authorization.replace("Bearer ", "").split(",");
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function request(
|
|
method: string,
|
|
uri: string,
|
|
data: any,
|
|
token: string,
|
|
deviceInfo: any,
|
|
options: AxiosRequestConfig = {}
|
|
) {
|
|
const unix = `${Date.parse(new Date().toString())}`;
|
|
const userData = _.clone(FAKE_USER_DATA);
|
|
userData.uuid = deviceInfo.userId;
|
|
userData.device_id = deviceInfo.deviceId || undefined;
|
|
userData.unix = unix;
|
|
let queryStr = "";
|
|
for (let key in userData) {
|
|
if (_.isUndefined(userData[key])) continue;
|
|
queryStr += `&${key}=${userData[key]}`;
|
|
}
|
|
queryStr = queryStr.substring(1);
|
|
const dataJson = JSON.stringify(data || {});
|
|
const fullUri = `${uri}${uri.lastIndexOf("?") != -1 ? "&" : "?"}${queryStr}`;
|
|
const yy = util.md5(
|
|
`${encodeURIComponent(fullUri)}_${dataJson}${util.md5(unix)}ooui`
|
|
);
|
|
const traceId = util.uuid(false);
|
|
return await axios.request({
|
|
method,
|
|
url: `https://hailuoai.com${fullUri}`,
|
|
data,
|
|
timeout: 15000,
|
|
validateStatus: () => true,
|
|
...options,
|
|
headers: {
|
|
Referer: "https://hailuoai.com/",
|
|
Token: token,
|
|
...FAKE_HEADERS,
|
|
"Baggage": `sentry-environment=production,sentry-release=${SENTRY_RELEASE},sentry-public_key=${SENTRY_PUBLIC_KEY},sentry-trace_id=${traceId},sentry-sample_rate=1,sentry-sampled=true`,
|
|
"Sentry-Trace": `${traceId}-${util.uuid(false).substring(16)}-1`,
|
|
...(options.headers || {}),
|
|
Yy: yy,
|
|
},
|
|
});
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function requestStream(
|
|
method: string,
|
|
uri: string,
|
|
data: any,
|
|
token: string,
|
|
deviceInfo: any,
|
|
options: AxiosRequestConfig = {}
|
|
) {
|
|
const unix = `${Date.parse(new Date().toString())}`;
|
|
const userData = _.clone(FAKE_USER_DATA);
|
|
userData.uuid = deviceInfo.userId;
|
|
userData.device_id = deviceInfo.deviceId || undefined;
|
|
userData.unix = unix;
|
|
let queryStr = "";
|
|
for (let key in userData) {
|
|
if (_.isUndefined(userData[key])) continue;
|
|
queryStr += `&${key}=${userData[key]}`;
|
|
}
|
|
queryStr = queryStr.substring(1);
|
|
const formData = new FormData();
|
|
for (let key in data) {
|
|
if (!data[key]) continue;
|
|
if (_.isBuffer(data[key])) {
|
|
formData.append(key, data[key], {
|
|
filename: "audio.mp3",
|
|
contentType: "audio/mp3",
|
|
});
|
|
} else formData.append(key, data[key]);
|
|
}
|
|
let dataJson = "";
|
|
if (data.msgContent)
|
|
dataJson = `${util.md5(data.characterID)}${util.md5(
|
|
data.msgContent.replace(/(\r\n|\n|\r)/g, "")
|
|
)}${util.md5(data.chatID)}${util.md5(data.form ? data.form : "")}`;
|
|
else if (data.voiceBytes)
|
|
dataJson = `${util.md5(data.characterID)}${util.md5(data.chatID)}${util.md5(
|
|
data.voiceBytes.subarray(0, 1024)
|
|
)}`;
|
|
data = formData;
|
|
const yy = util.md5(
|
|
encodeURIComponent(`${uri}?${queryStr}`) +
|
|
`_${dataJson}${util.md5(unix)}ooui`
|
|
);
|
|
const session: ClientHttp2Session = await new Promise((resolve, reject) => {
|
|
const session = http2.connect("https://hailuoai.com");
|
|
session.on("connect", () => resolve(session));
|
|
session.on("error", reject);
|
|
});
|
|
|
|
const traceId = util.uuid(false);
|
|
const stream = session.request({
|
|
":method": method,
|
|
":path": `${uri}?${queryStr}`,
|
|
":scheme": "https",
|
|
Referer: "https://hailuoai.com/",
|
|
Token: token,
|
|
...FAKE_HEADERS,
|
|
"Baggage": `sentry-environment=production,sentry-release=${SENTRY_RELEASE},sentry-public_key=${SENTRY_PUBLIC_KEY},sentry-trace_id=${traceId},sentry-sample_rate=1,sentry-sampled=true`,
|
|
"Sentry-Trace": `${traceId}-${util.uuid(false).substring(16)}-1`,
|
|
...(options.headers || {}),
|
|
Yy: yy,
|
|
...data.getHeaders(),
|
|
});
|
|
stream.setTimeout(120000);
|
|
stream.setEncoding("utf8");
|
|
stream.end(data.getBuffer());
|
|
return {
|
|
session,
|
|
stream,
|
|
};
|
|
}
|
|
|
|
|
|
|
|
|
|
async function getTokenLiveStatus(token: string) {
|
|
const deviceInfo = await acquireDeviceInfo(token);
|
|
const result = await request(
|
|
"GET",
|
|
"/v1/api/user/info",
|
|
{},
|
|
token,
|
|
deviceInfo
|
|
);
|
|
try {
|
|
const { userInfo } = checkResult(result);
|
|
return _.isObject(userInfo);
|
|
} catch (err) {
|
|
deviceInfoMap.delete(token);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export default {
|
|
acquireDeviceInfo,
|
|
request,
|
|
requestStream,
|
|
checkResult,
|
|
checkFileUrl,
|
|
uploadFile,
|
|
tokenSplit,
|
|
getTokenLiveStatus,
|
|
};
|
|
|