427 lines
14 KiB
JavaScript
427 lines
14 KiB
JavaScript
import { core } from "oicq"
|
||
import common from "oicq"
|
||
import Contactable from "oicq"
|
||
import querystring from "querystring"
|
||
import fetch from "node-fetch"
|
||
import fs from "fs"
|
||
import path from "path"
|
||
import os from "os"
|
||
import util from "util"
|
||
import stream from "stream"
|
||
import crypto from "crypto"
|
||
import child_process from "child_process"
|
||
var errors = {};
|
||
|
||
|
||
async function uploadRecord(record_url, seconds = 0,transcoding = true) {
|
||
const result = await getPttBuffer(record_url, Bot.config.ffmpeg_path, transcoding);
|
||
if(!result.buffer){
|
||
return false;
|
||
}
|
||
let buf = result.buffer;
|
||
if(seconds == 0 && result.time) seconds = result.time.seconds;
|
||
const hash = (0, md5)(buf);
|
||
const codec = String(buf.slice(0, 7)).includes("SILK") ? (transcoding ? 1 : 0) : 0;
|
||
const body = core.pb.encode({
|
||
1: 3,
|
||
2: 3,
|
||
5: {
|
||
1: Contactable.target,
|
||
2: Bot.uin,
|
||
3: 0,
|
||
4: hash,
|
||
5: buf.length,
|
||
6: hash,
|
||
7: 5,
|
||
8: 9,
|
||
9: 4,
|
||
11: 0,
|
||
10: Bot.apk.version,
|
||
12: 1,
|
||
13: 1,
|
||
14: codec,
|
||
15: 1,
|
||
},
|
||
});
|
||
const payload = await Bot.sendUni("PttStore.GroupPttUp", body);
|
||
const rsp = core.pb.decode(payload)[5];
|
||
rsp[2] && (0, errors.drop)(rsp[2], rsp[3]);
|
||
const ip = rsp[5]?.[0] || rsp[5], port = rsp[6]?.[0] || rsp[6];
|
||
const ukey = rsp[7].toHex(), filekey = rsp[11].toHex();
|
||
const params = {
|
||
ver: 4679,
|
||
ukey, filekey,
|
||
filesize: buf.length,
|
||
bmd5: hash.toString("hex"),
|
||
mType: "pttDu",
|
||
voice_encodec: codec
|
||
};
|
||
const url = `http://${(0, int32ip2str)(ip)}:${port}/?` + querystring.stringify(params);
|
||
const headers = {
|
||
"User-Agent": `QQ/${Bot.apk.version} CFNetwork/1126`,
|
||
"Net-Type": "Wifi"
|
||
};
|
||
await fetch(url,{
|
||
method: 'POST',//post请求
|
||
headers: headers,
|
||
body: buf
|
||
});
|
||
//await axios.post(url, buf, { headers });
|
||
|
||
const fid = rsp[11].toBuffer();
|
||
const b = core.pb.encode({
|
||
1: 4,
|
||
2: Bot.uin,
|
||
3: fid,
|
||
4: hash,
|
||
5: hash.toString("hex") + ".amr",
|
||
6: seconds,
|
||
11: 1,
|
||
18: fid,
|
||
19: seconds,
|
||
30: Buffer.from([8, 0, 40, 0, 56, 0]),
|
||
});
|
||
return {
|
||
type: "record", file: "protobuf://" + Buffer.from(b).toString("base64")
|
||
};
|
||
}
|
||
|
||
export default uploadRecord
|
||
|
||
async function getPttBuffer(file, ffmpeg = "ffmpeg", transcoding = true) {
|
||
let buffer;
|
||
let time;
|
||
if (file instanceof Buffer || file.startsWith("base64://")) {
|
||
// Buffer或base64
|
||
const buf = file instanceof Buffer ? file : Buffer.from(file.slice(9), "base64");
|
||
const head = buf.slice(0, 7).toString();
|
||
if (head.includes("SILK") || head.includes("AMR") || !transcoding) {
|
||
const tmpfile = path.join(TMP_DIR, (0, uuid)());
|
||
await fs.promises.writeFile(tmpfile, buf);
|
||
let result = await getAudioTime(tmpfile,ffmpeg);
|
||
if(result.code == 1) time = result.data;
|
||
fs.unlink(tmpfile,NOOP);
|
||
buffer = buf;
|
||
}else {
|
||
const tmpfile = path.join(TMP_DIR, (0, uuid)());
|
||
let result = await getAudioTime(tmpfile,ffmpeg);
|
||
if(result.code == 1) time = result.data;
|
||
await fs.promises.writeFile(tmpfile, buf);
|
||
buffer = await audioTrans(tmpfile, ffmpeg);
|
||
}
|
||
}
|
||
else if (file.startsWith("http://") || file.startsWith("https://")) {
|
||
// 网络文件
|
||
//const readable = (await axios.get(file, { responseType: "stream" })).data;
|
||
try{
|
||
const headers = {
|
||
"User-Agent": `Dalvik/2.1.0 (Linux; U; Android 12; MI 9 Build/SKQ1.211230.001)`,
|
||
};
|
||
let response = await fetch(file,{
|
||
method: 'GET',//post请求
|
||
headers: headers
|
||
});
|
||
const buf = Buffer.from(await response.arrayBuffer());
|
||
const tmpfile = path.join(TMP_DIR, (0, uuid)());
|
||
await fs.promises.writeFile(tmpfile, buf);
|
||
//await (0, pipeline)(readable.pipe(new DownloadTransform), fs.createWriteStream(tmpfile));
|
||
const head = await read7Bytes(tmpfile);
|
||
let result = await getAudioTime(tmpfile,ffmpeg);
|
||
if(result.code == 1) time = result.data;
|
||
if (head.includes("SILK") || head.includes("AMR") || !transcoding) {
|
||
//const buf = await fs.promises.readFile(tmpfile);
|
||
fs.unlink(tmpfile,NOOP);
|
||
buffer = buf;
|
||
} else {
|
||
buffer = await audioTrans(tmpfile, ffmpeg);
|
||
}
|
||
}catch(err){}
|
||
}
|
||
else {
|
||
// 本地文件
|
||
file = String(file).replace(/^file:\/{2}/, "");
|
||
IS_WIN && file.startsWith("/") && (file = file.slice(1));
|
||
const head = await read7Bytes(file);
|
||
let result = await getAudioTime(file,ffmpeg);
|
||
if(result.code == 1) time = result.data;
|
||
if (head.includes("SILK") || head.includes("AMR") || !transcoding) {
|
||
buffer = await fs.promises.readFile(file);
|
||
} else {
|
||
buffer = await audioTrans(file, ffmpeg);
|
||
}
|
||
}
|
||
return {buffer: buffer, time: time}
|
||
}
|
||
|
||
async function getAudioTime(file, ffmpeg = "ffmpeg") {
|
||
return new Promise((resolve, reject) => {
|
||
(0, child_process.exec)(`${ffmpeg} -i "${file}"`, async (error, stdout, stderr) => {
|
||
try {
|
||
let time = stderr.split('Duration:')[1]?.split(',')[0].trim();
|
||
let arr = time?.split(':');
|
||
arr.reverse();
|
||
let n = 1;
|
||
let s = 0;
|
||
for(let val of arr){
|
||
if(parseInt(val) > 0) s += parseInt(val) * n;
|
||
n *= 60;
|
||
}
|
||
resolve({code: 1,data: {
|
||
time: time,
|
||
seconds: s,
|
||
exec_text: stderr
|
||
}});
|
||
}
|
||
catch {
|
||
resolve({code: -1});
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
async function audioTrans(file, ffmpeg = "ffmpeg") {
|
||
return new Promise((resolve, reject) => {
|
||
const tmpfile = path.join(TMP_DIR, (0, uuid)());
|
||
(0, child_process.exec)(`${ffmpeg} -y -i "${file}" -ac 1 -ar 8000 -f amr "${tmpfile}"`, async (error, stdout, stderr) => {
|
||
try {
|
||
const amr = await fs.promises.readFile(tmpfile);
|
||
resolve(amr);
|
||
}
|
||
catch {
|
||
reject(new core.ApiRejection(errors.ErrorCode.FFmpegPttTransError, "音频转码到amr失败,请确认你的ffmpeg可以处理此转换"));
|
||
}
|
||
finally {
|
||
fs.unlink(tmpfile, NOOP);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
async function read7Bytes(file) {
|
||
const fd = await fs.promises.open(file, "r");
|
||
const buf = (await fd.read(Buffer.alloc(7), 0, 7, 0)).buffer;
|
||
fd.close();
|
||
return buf;
|
||
}
|
||
|
||
function uuid() {
|
||
let hex = crypto.randomBytes(16).toString("hex");
|
||
return hex.substr(0, 8) + "-" + hex.substr(8, 4) + "-" + hex.substr(12, 4) + "-" + hex.substr(16, 4) + "-" + hex.substr(20);
|
||
}
|
||
|
||
/** 计算流的md5 */
|
||
function md5Stream(readable) {
|
||
return new Promise((resolve, reject) => {
|
||
readable.on("error", reject);
|
||
readable.pipe(crypto.createHash("md5")
|
||
.on("error", reject)
|
||
.on("data", resolve));
|
||
});
|
||
}
|
||
|
||
/** 计算文件的md5和sha */
|
||
function fileHash(filepath) {
|
||
const readable = fs.createReadStream(filepath);
|
||
const sha = new Promise((resolve, reject) => {
|
||
readable.on("error", reject);
|
||
readable.pipe(crypto.createHash("sha1")
|
||
.on("error", reject)
|
||
.on("data", resolve));
|
||
});
|
||
return Promise.all([md5Stream(readable), sha]);
|
||
}
|
||
|
||
/** 群号转uin */
|
||
function code2uin(code) {
|
||
let left = Math.floor(code / 1000000);
|
||
if (left >= 0 && left <= 10)
|
||
left += 202;
|
||
else if (left >= 11 && left <= 19)
|
||
left += 469;
|
||
else if (left >= 20 && left <= 66)
|
||
left += 2080;
|
||
else if (left >= 67 && left <= 156)
|
||
left += 1943;
|
||
else if (left >= 157 && left <= 209)
|
||
left += 1990;
|
||
else if (left >= 210 && left <= 309)
|
||
left += 3890;
|
||
else if (left >= 310 && left <= 335)
|
||
left += 3490;
|
||
else if (left >= 336 && left <= 386)
|
||
left += 2265;
|
||
else if (left >= 387 && left <= 499)
|
||
left += 3490;
|
||
return left * 1000000 + code % 1000000;
|
||
}
|
||
|
||
/** uin转群号 */
|
||
function uin2code(uin) {
|
||
let left = Math.floor(uin / 1000000);
|
||
if (left >= 202 && left <= 212)
|
||
left -= 202;
|
||
else if (left >= 480 && left <= 488)
|
||
left -= 469;
|
||
else if (left >= 2100 && left <= 2146)
|
||
left -= 2080;
|
||
else if (left >= 2010 && left <= 2099)
|
||
left -= 1943;
|
||
else if (left >= 2147 && left <= 2199)
|
||
left -= 1990;
|
||
else if (left >= 2600 && left <= 2651)
|
||
left -= 2265;
|
||
else if (left >= 3800 && left <= 3989)
|
||
left -= 3490;
|
||
else if (left >= 4100 && left <= 4199)
|
||
left -= 3890;
|
||
return left * 1000000 + uin % 1000000;
|
||
}
|
||
|
||
function int32ip2str(ip) {
|
||
if (typeof ip === "string")
|
||
return ip;
|
||
ip = ip & 0xffffffff;
|
||
return [
|
||
ip & 0xff,
|
||
(ip & 0xff00) >> 8,
|
||
(ip & 0xff0000) >> 16,
|
||
(ip & 0xff000000) >> 24 & 0xff,
|
||
].join(".");
|
||
}
|
||
|
||
/** 解析彩色群名片 */
|
||
function parseFunString(buf) {
|
||
if (buf[0] === 0xA) {
|
||
let res = "";
|
||
try {
|
||
let arr = core.pb.decode(buf)[1];
|
||
if (!Array.isArray(arr))
|
||
arr = [arr];
|
||
for (let v of arr) {
|
||
if (v[2])
|
||
res += String(v[2]);
|
||
}
|
||
}
|
||
catch { }
|
||
return res;
|
||
}
|
||
else {
|
||
return String(buf);
|
||
}
|
||
}
|
||
|
||
/** xml转义 */
|
||
function escapeXml(str) {
|
||
return str.replace(/[&"><]/g, function (s) {
|
||
if (s === "&")
|
||
return "&";
|
||
if (s === "<")
|
||
return "<";
|
||
if (s === ">")
|
||
return ">";
|
||
if (s === "\"")
|
||
return """;
|
||
return "";
|
||
});
|
||
}
|
||
|
||
/** 用于下载限量 */
|
||
class DownloadTransform extends stream.Transform {
|
||
constructor() {
|
||
super(...arguments);
|
||
this._size = 0;
|
||
}
|
||
_transform(data, encoding, callback) {
|
||
this._size += data.length;
|
||
let error = null;
|
||
if (this._size <= MAX_UPLOAD_SIZE)
|
||
this.push(data);
|
||
else
|
||
error = new Error("downloading over 30MB is refused");
|
||
callback(error);
|
||
}
|
||
}
|
||
const IS_WIN = os.platform() === "win32";
|
||
/** 系统临时目录,用于临时存放下载的图片等内容 */
|
||
const TMP_DIR = os.tmpdir();
|
||
/** 最大上传和下载大小,以图片上传限制为准:30MB */
|
||
const MAX_UPLOAD_SIZE = 31457280;
|
||
|
||
/** no operation */
|
||
const NOOP = () => { };
|
||
|
||
/** promisified pipeline */
|
||
const pipeline = (0, util.promisify)(stream.pipeline);
|
||
/** md5 hash */
|
||
const md5 = (data) => (0, crypto.createHash)("md5").update(data).digest();
|
||
|
||
|
||
errors.LoginErrorCode = errors.drop = errors.ErrorCode = void 0;
|
||
var ErrorCode;
|
||
(function (ErrorCode) {
|
||
/** 客户端离线 */
|
||
ErrorCode[ErrorCode["ClientNotOnline"] = -1] = "ClientNotOnline";
|
||
/** 发包超时未收到服务器回应 */
|
||
ErrorCode[ErrorCode["PacketTimeout"] = -2] = "PacketTimeout";
|
||
/** 用户不存在 */
|
||
ErrorCode[ErrorCode["UserNotExists"] = -10] = "UserNotExists";
|
||
/** 群不存在(未加入) */
|
||
ErrorCode[ErrorCode["GroupNotJoined"] = -20] = "GroupNotJoined";
|
||
/** 群员不存在 */
|
||
ErrorCode[ErrorCode["MemberNotExists"] = -30] = "MemberNotExists";
|
||
/** 发消息时传入的参数不正确 */
|
||
ErrorCode[ErrorCode["MessageBuilderError"] = -60] = "MessageBuilderError";
|
||
/** 群消息被风控发送失败 */
|
||
ErrorCode[ErrorCode["RiskMessageError"] = -70] = "RiskMessageError";
|
||
/** 群消息有敏感词发送失败 */
|
||
ErrorCode[ErrorCode["SensitiveWordsError"] = -80] = "SensitiveWordsError";
|
||
/** 上传图片/文件/视频等数据超时 */
|
||
ErrorCode[ErrorCode["HighwayTimeout"] = -110] = "HighwayTimeout";
|
||
/** 上传图片/文件/视频等数据遇到网络错误 */
|
||
ErrorCode[ErrorCode["HighwayNetworkError"] = -120] = "HighwayNetworkError";
|
||
/** 没有上传通道 */
|
||
ErrorCode[ErrorCode["NoUploadChannel"] = -130] = "NoUploadChannel";
|
||
/** 不支持的file类型(没有流) */
|
||
ErrorCode[ErrorCode["HighwayFileTypeError"] = -140] = "HighwayFileTypeError";
|
||
/** 文件安全校验未通过不存在 */
|
||
ErrorCode[ErrorCode["UnsafeFile"] = -150] = "UnsafeFile";
|
||
/** 离线(私聊)文件不存在 */
|
||
ErrorCode[ErrorCode["OfflineFileNotExists"] = -160] = "OfflineFileNotExists";
|
||
/** 群文件不存在(无法转发) */
|
||
ErrorCode[ErrorCode["GroupFileNotExists"] = -170] = "GroupFileNotExists";
|
||
/** 获取视频中的图片失败 */
|
||
ErrorCode[ErrorCode["FFmpegVideoThumbError"] = -210] = "FFmpegVideoThumbError";
|
||
/** 音频转换失败 */
|
||
ErrorCode[ErrorCode["FFmpegPttTransError"] = -220] = "FFmpegPttTransError";
|
||
})(ErrorCode = errors.ErrorCode || (errors.ErrorCode = {}));
|
||
const ErrorMessage = {
|
||
[ErrorCode.UserNotExists]: "查无此人",
|
||
[ErrorCode.GroupNotJoined]: "未加入的群",
|
||
[ErrorCode.MemberNotExists]: "幽灵群员",
|
||
[ErrorCode.RiskMessageError]: "群消息发送失败,可能被风控",
|
||
[ErrorCode.SensitiveWordsError]: "群消息发送失败,请检查消息内容",
|
||
10: "消息过长",
|
||
34: "消息过长",
|
||
120: "在该群被禁言",
|
||
121: "AT全体剩余次数不足"
|
||
};
|
||
function drop(code, message) {
|
||
if (!message || !message.length)
|
||
message = ErrorMessage[code];
|
||
throw new core.ApiRejection(code, message);
|
||
}
|
||
errors.drop = drop;
|
||
/** 登录时可能出现的错误,不在列的都属于未知错误,暂时无法解决 */
|
||
var LoginErrorCode;
|
||
(function (LoginErrorCode) {
|
||
/** 密码错误 */
|
||
LoginErrorCode[LoginErrorCode["WrongPassword"] = 1] = "WrongPassword";
|
||
/** 账号被冻结 */
|
||
LoginErrorCode[LoginErrorCode["AccountFrozen"] = 40] = "AccountFrozen";
|
||
/** 发短信太频繁 */
|
||
LoginErrorCode[LoginErrorCode["TooManySms"] = 162] = "TooManySms";
|
||
/** 短信验证码错误 */
|
||
LoginErrorCode[LoginErrorCode["WrongSmsCode"] = 163] = "WrongSmsCode";
|
||
/** 滑块ticket错误 */
|
||
LoginErrorCode[LoginErrorCode["WrongTicket"] = 237] = "WrongTicket";
|
||
})(LoginErrorCode = errors.LoginErrorCode || (errors.LoginErrorCode = {})); |