oicq
Version:
QQ protocol!
632 lines (631 loc) • 23.8 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.Contactable = void 0;
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const querystring_1 = __importDefault(require("querystring"));
const axios_1 = __importDefault(require("axios"));
const stream_1 = require("stream");
const crypto_1 = require("crypto");
const child_process_1 = require("child_process");
const core_1 = require("../core");
const errors_1 = require("../errors");
const common_1 = require("../common");
const message_1 = require("../message");
const highway_1 = require("./highway");
/** 所有用户和群的基类 */
class Contactable {
constructor(c) {
this.c = c;
(0, common_1.lock)(this, "c");
}
// 对方账号,可能是群号也可能是QQ号
get target() {
return this.uid || this.gid || this.c.uin;
}
// 是否是 Direct Message (私聊)
get dm() {
return !!this.uid;
}
/** 返回所属的客户端对象 */
get client() {
return this.c;
}
// 取私聊图片fid
async _offPicUp(imgs) {
const req = [];
for (const img of imgs) {
req.push({
1: this.c.uin,
2: this.uid,
3: 0,
4: img.md5,
5: img.size,
6: img.md5.toString("hex"),
7: 5,
8: 9,
9: 0,
10: 0,
11: 0,
12: 1,
13: img.origin ? 1 : 0,
14: img.width,
15: img.height,
16: img.type,
17: this.c.apk.version,
22: 0,
});
}
const body = core_1.pb.encode({
1: 1,
2: req,
// 10: 3
});
const payload = await this.c.sendUni("LongConn.OffPicUp", body);
return core_1.pb.decode(payload)[2];
}
// 取群聊图片fid
async _groupPicUp(imgs) {
const req = [];
for (const img of imgs) {
req.push({
1: this.gid,
2: this.c.uin,
3: 0,
4: img.md5,
5: img.size,
6: img.md5.toString("hex"),
7: 5,
8: 9,
9: 1,
10: img.width,
11: img.height,
12: img.type,
13: this.c.apk.version,
14: 0,
15: 1052,
16: img.origin ? 1 : 0,
18: 0,
19: 0,
});
}
const body = core_1.pb.encode({
1: 3,
2: 1,
3: req,
});
const payload = await this.c.sendUni("ImgStore.GroupPicUp", body);
return core_1.pb.decode(payload)[3];
}
/** 上传一批图片以备发送(无数量限制)(理论上传一次所有群和好友都能发) */
async uploadImages(imgs) {
this.c.logger.debug(`开始图片任务,共有${imgs.length}张图片`);
const tasks = [];
for (let i = 0; i < imgs.length; i++) {
if (imgs[i] instanceof message_1.Image === false)
imgs[i] = new message_1.Image(imgs[i], this.dm, path_1.default.join(this.c.dir, "../image"));
tasks.push(imgs[i].task);
}
const res1 = await Promise.allSettled(tasks);
for (let i = 0; i < res1.length; i++) {
if (res1[i].status === "rejected")
this.c.logger.warn(`图片${i + 1}失败, reason: ` + res1[i].reason?.message);
}
let n = 0;
while (imgs.length > n) {
let rsp = await (this.dm ? this._offPicUp : this._groupPicUp).call(this, imgs.slice(n, n + 20));
!Array.isArray(rsp) && (rsp = [rsp]);
const tasks = [];
for (let i = n; i < imgs.length; ++i) {
if (i >= n + 20)
break;
tasks.push(this._uploadImage(imgs[i], rsp[i % 20]));
}
const res2 = await Promise.allSettled(tasks);
for (let i = 0; i < res2.length; i++) {
if (res2[i].status === "rejected") {
res1[n + i] = res2[i];
this.c.logger.warn(`图片${n + i + 1}上传失败, reason: ` + res2[i].reason?.message);
}
}
n += 20;
}
this.c.logger.debug(`图片任务结束`);
return res1;
}
async _uploadImage(img, rsp) {
const j = this.dm ? 1 : 0;
if (rsp[2 + j] !== 0)
throw new Error(String(rsp[3 + j]));
img.fid = rsp[9 + j].toBuffer?.() || rsp[9 + j];
if (rsp[4 + j]) {
img.deleteTmpFile();
return;
}
if (!img.readable) {
img.deleteCacheFile();
return;
}
const ip = rsp[6 + j]?.[0] || rsp[6 + j];
const port = rsp[7 + j]?.[0] || rsp[7 + j];
return highway_1.highwayUpload.call(this.c, img.readable, {
cmdid: j ? highway_1.CmdID.DmImage : highway_1.CmdID.GroupImage,
md5: img.md5,
size: img.size,
ticket: rsp[8 + j].toBuffer()
}, ip, port).finally(img.deleteTmpFile.bind(img));
}
/** 发消息预处理 */
async _preprocess(content, source) {
try {
if (!Array.isArray(content))
content = [content];
if (content[0].type === "video")
content[0] = await this.uploadVideo(content[0]);
else if (content[0].type === "record")
content[0] = await this.uploadPtt(content[0]);
const converter = new message_1.Converter(content, {
dm: this.dm,
cachedir: path_1.default.join(this.c.dir, "../image"),
mlist: this.c.gml.get(this.gid)
});
if (source)
converter.quote(source);
if (converter.imgs.length)
await this.uploadImages(converter.imgs);
return converter;
}
catch (e) {
(0, errors_1.drop)(errors_1.ErrorCode.MessageBuilderError, e.message);
}
}
/** 上传一个视频以备发送(理论上传一次所有群和好友都能发) */
async uploadVideo(elem) {
let { file } = elem;
if (file.startsWith("protobuf://"))
return elem;
file = file.replace(/^file:\/{2}/, "");
common_1.IS_WIN && file.startsWith("/") && (file = file.slice(1));
const thumb = path_1.default.join(common_1.TMP_DIR, (0, common_1.uuid)());
await new Promise((resolve, reject) => {
(0, child_process_1.exec)(`${this.c.config.ffmpeg_path || "ffmpeg"} -y -i "${file}" -f image2 -frames:v 1 "${thumb}"`, (error, stdout, stderr) => {
this.c.logger.debug("ffmpeg output: " + stdout + stderr);
fs_1.default.stat(thumb, (err) => {
if (err)
reject(new core_1.ApiRejection(errors_1.ErrorCode.FFmpegVideoThumbError, "ffmpeg获取视频图像帧失败"));
else
resolve(undefined);
});
});
});
const [width, height, seconds] = await new Promise((resolve) => {
(0, child_process_1.exec)(`${this.c.config.ffprobe_path || "ffprobe"} -i "${file}" -show_streams`, (error, stdout, stderr) => {
const lines = (stdout || stderr || "").split("\n");
let width = 1280, height = 720, seconds = 120;
for (const line of lines) {
if (line.startsWith("width=")) {
width = parseInt(line.slice(6));
}
else if (line.startsWith("height=")) {
height = parseInt(line.slice(7));
}
else if (line.startsWith("duration=")) {
seconds = parseInt(line.slice(9));
break;
}
}
resolve([width, height, seconds]);
});
});
const md5video = await (0, common_1.md5Stream)(fs_1.default.createReadStream(file));
const md5thumb = await (0, common_1.md5Stream)(fs_1.default.createReadStream(thumb));
const name = md5video.toString("hex") + ".mp4";
const videosize = (await fs_1.default.promises.stat(file)).size;
const thumbsize = (await fs_1.default.promises.stat(thumb)).size;
const ext = core_1.pb.encode({
1: this.c.uin,
2: this.target,
3: 1,
4: 2,
5: {
1: name,
2: md5video,
3: md5thumb,
4: videosize,
5: height,
6: width,
7: 3,
8: seconds,
9: thumbsize,
},
6: this.target,
20: 1,
});
const body = core_1.pb.encode({
1: 300,
3: ext,
100: {
1: 0,
2: 1,
}
});
const payload = await this.c.sendUni("PttCenterSvr.GroupShortVideoUpReq", body);
const rsp = core_1.pb.decode(payload)[3];
if (rsp[1])
throw new Error(String(rsp[2]));
if (!rsp[7]) {
const md5 = await (0, common_1.md5Stream)(createReadable(thumb, file));
await highway_1.highwayUpload.call(this.c, createReadable(thumb, file), {
cmdid: highway_1.CmdID.ShortVideo,
md5,
size: thumbsize + videosize,
ext,
encrypt: true,
});
}
fs_1.default.unlink(thumb, common_1.NOOP);
const buf = core_1.pb.encode({
1: rsp[5].toBuffer(),
2: md5video,
3: name,
4: 3,
5: seconds,
6: videosize,
7: width,
8: height,
9: md5thumb,
10: "camera",
11: thumbsize,
12: 0,
15: 1,
16: width,
17: height,
18: 0,
19: 0,
});
return {
type: "video", file: "protobuf://" + Buffer.from(buf).toString("base64")
};
}
/** 上传一个语音以备发送(理论上传一次所有群和好友都能发) */
async uploadPtt(elem) {
this.c.logger.debug("开始语音任务");
if (typeof elem.file === "string" && elem.file.startsWith("protobuf://"))
return elem;
const buf = await getPttBuffer(elem.file, this.c.config.ffmpeg_path);
const hash = (0, common_1.md5)(buf);
const codec = String(buf.slice(0, 7)).includes("SILK") ? 1 : 0;
const body = core_1.pb.encode({
1: 3,
2: 3,
5: {
1: this.target,
2: this.c.uin,
3: 0,
4: hash,
5: buf.length,
6: hash,
7: 5,
8: 9,
9: 4,
11: 0,
10: this.c.apk.version,
12: 1,
13: 1,
14: codec,
15: 1,
},
});
const payload = await this.c.sendUni("PttStore.GroupPttUp", body);
const rsp = core_1.pb.decode(payload)[5];
rsp[2] && (0, errors_1.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, common_1.int32ip2str)(ip)}:${port}/?` + querystring_1.default.stringify(params);
const headers = {
"User-Agent": `QQ/${this.c.apk.version} CFNetwork/1126`,
"Net-Type": "Wifi"
};
await axios_1.default.post(url, buf, { headers });
this.c.logger.debug("语音任务结束");
const fid = rsp[11].toBuffer();
const b = core_1.pb.encode({
1: 4,
2: this.c.uin,
3: fid,
4: hash,
5: hash.toString("hex") + ".amr",
6: buf.length,
11: 1,
18: fid,
30: Buffer.from([8, 0, 40, 0, 56, 0]),
});
return {
type: "record", file: "protobuf://" + Buffer.from(b).toString("base64")
};
}
async _uploadMultiMsg(compressed) {
const body = core_1.pb.encode({
1: 1,
2: 5,
3: 9,
4: 3,
5: this.c.apk.version,
6: [{
1: this.target,
2: compressed.length,
3: (0, common_1.md5)(compressed),
4: 3,
5: 0,
}],
8: 1,
});
const payload = await this.c.sendUni("MultiMsg.ApplyUp", body);
const rsp = core_1.pb.decode(payload)[2];
if (rsp[1] !== 0)
(0, errors_1.drop)(rsp[1], rsp[2]?.toString() || "unknown MultiMsg.ApplyUp error");
const buf = core_1.pb.encode({
1: 1,
2: 5,
3: 9,
4: [{
//1: 3,
2: this.target,
4: compressed,
5: 2,
6: rsp[3].toBuffer(),
}],
});
const ip = rsp[4]?.[0] || rsp[4], port = rsp[5]?.[0] || rsp[5];
await highway_1.highwayUpload.call(this.c, stream_1.Readable.from(Buffer.from(buf), { objectMode: false }), {
cmdid: highway_1.CmdID.MultiMsg,
md5: (0, common_1.md5)(buf),
size: buf.length,
ticket: rsp[10].toBuffer(),
}, ip, port);
return rsp[2].toString();
}
/**
* 1. 制作一条合并转发消息以备发送(制作一次可以到处发)。
* 2. 需要注意的是,好友图片和群图片的内部格式不一样,
* 对着群制作的转发消息中的图片,发给好友可能会裂图,反过来也一样。
* 3. 暂不完全支持套娃转发。
*/
async makeForwardMsg(msglist) {
if (!Array.isArray(msglist))
msglist = [msglist];
const nodes = [];
const makers = [];
let imgs = [];
let preview = "";
let cnt = 0;
for (const fake of msglist) {
const maker = new message_1.Converter(fake.message, { dm: this.dm, cachedir: this.c.config.data_dir });
makers.push(maker);
const seq = (0, crypto_1.randomBytes)(2).readInt16BE();
const rand = (0, crypto_1.randomBytes)(4).readInt32BE();
let nickname = String(fake.nickname || fake.user_id);
if (!nickname && fake instanceof message_1.PrivateMessage)
nickname = this.c.fl.get(fake.user_id)?.nickname || this.c.sl.get(fake.user_id)?.nickname || nickname;
if (cnt < 4) {
cnt++;
preview += `<title color="#777777" size="26">${(0, common_1.escapeXml)(nickname)}: ${(0, common_1.escapeXml)(maker.brief.slice(0, 50))}</title>`;
}
nodes.push({
1: {
1: fake.user_id,
2: this.target,
3: this.dm ? 166 : 82,
4: this.dm ? 11 : null,
5: seq,
6: fake.time || (0, common_1.timestamp)(),
7: (0, message_1.rand2uuid)(rand),
9: this.dm ? null : {
1: this.target,
4: nickname,
},
14: this.dm ? nickname : null,
20: {
1: 0,
2: rand
}
},
3: {
1: maker.rich
}
});
}
for (const maker of makers)
imgs = [...imgs, ...maker.imgs];
if (imgs.length)
await this.uploadImages(imgs);
const compressed = await (0, common_1.gzip)(core_1.pb.encode({
1: nodes,
2: {
1: "MultiMsg",
2: {
1: nodes
}
}
}));
const resid = await this._uploadMultiMsg(compressed);
const xml = `<?xml version="1.0" encoding="utf-8"?>
<msg brief="[聊天记录]" m_fileName="${(0, common_1.uuid)().toUpperCase()}" action="viewMultiMsg" tSum="${nodes.length}" flag="3" m_resid="${resid}" serviceID="35" m_fileSize="${compressed.length}"><item layout="1"><title color="#000000" size="34">转发的聊天记录</title>${preview}<hr></hr><summary color="#808080" size="26">查看${nodes.length}条转发消息</summary></item><source name="聊天记录"></source></msg>`;
return {
type: "xml",
data: xml,
id: 35,
};
}
/** 下载并解析合并转发 */
async getForwardMsg(resid, fileName = "MultiMsg") {
const ret = [];
const buf = await this._downloadMultiMsg(String(resid), 2);
let a = core_1.pb.decode(buf)[2];
if (!Array.isArray(a))
a = [a];
for (let b of a) {
const m_fileName = b[1].toString();
if (m_fileName === fileName) {
a = b;
break;
}
}
if (Array.isArray(a))
a = a[0];
a = a[2][1];
if (!Array.isArray(a))
a = [a];
for (let proto of a) {
try {
ret.push(new message_1.ForwardMessage(proto));
}
catch { }
}
return ret;
}
async _downloadMultiMsg(resid, bu) {
const body = core_1.pb.encode({
1: 2,
2: 5,
3: 9,
4: 3,
5: this.c.apk.version,
7: [{
1: resid,
2: 3,
}],
8: bu,
9: 2,
});
const payload = await this.c.sendUni("MultiMsg.ApplyDown", body);
const rsp = core_1.pb.decode(payload)[3];
const ip = (0, common_1.int32ip2str)(rsp[4]?.[0] || rsp[4]);
const port = rsp[5]?.[0] || rsp[5];
let url = port == 443 ? "https://ssl.htdata.qq.com" : `http://${ip}:${port}`;
url += rsp[2];
let { data, headers } = await axios_1.default.get(url, { headers: {
"User-Agent": `QQ/${this.c.apk.version} CFNetwork/1126`,
"Net-Type": "Wifi"
}, responseType: "arraybuffer" });
data = Buffer.from(data);
let buf = headers["accept-encoding"]?.includes("gzip") ? await (0, common_1.unzip)(data) : data;
const head_len = buf.readUInt32BE(1);
const body_len = buf.readUInt32BE(5);
buf = core_1.tea.decrypt(buf.slice(head_len + 9, head_len + 9 + body_len), rsp[3].toBuffer());
return (0, common_1.unzip)(core_1.pb.decode(buf)[3][3].toBuffer());
}
/** 获取视频下载地址 */
async getVideoUrl(fid, md5) {
const body = core_1.pb.encode({
1: 400,
4: {
1: this.c.uin,
2: this.c.uin,
3: 1,
4: 7,
5: fid,
6: 1,
8: md5 instanceof Buffer ? md5 : Buffer.from(md5, "hex"),
9: 1,
10: 2,
11: 2,
12: 2,
}
});
const payload = await this.c.sendUni("PttCenterSvr.ShortVideoDownReq", body);
const rsp = core_1.pb.decode(payload)[4];
if (rsp[1] !== 0)
(0, errors_1.drop)(rsp[1], "获取视频下载地址失败");
const obj = rsp[9];
return String(Array.isArray(obj[10]) ? obj[10][0] : obj[10]) + String(obj[11]);
}
}
exports.Contactable = Contactable;
// 两个文件合并到一个流
function createReadable(file1, file2) {
return stream_1.Readable.from(concatStreams(fs_1.default.createReadStream(file1, { highWaterMark: 256 * 1024 }), fs_1.default.createReadStream(file2, { highWaterMark: 256 * 1024 })));
}
// 合并两个流
async function* concatStreams(readable1, readable2) {
for await (const chunk of readable1)
yield chunk;
for await (const chunk of readable2)
yield chunk;
}
async function getPttBuffer(file, ffmpeg = "ffmpeg") {
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")) {
return buf;
}
else {
const tmpfile = path_1.default.join(common_1.TMP_DIR, (0, common_1.uuid)());
await fs_1.default.promises.writeFile(tmpfile, buf);
return audioTrans(tmpfile, ffmpeg);
}
}
else if (file.startsWith("http://") || file.startsWith("https://")) {
// 网络文件
const readable = (await axios_1.default.get(file, { responseType: "stream" })).data;
const tmpfile = path_1.default.join(common_1.TMP_DIR, (0, common_1.uuid)());
await (0, common_1.pipeline)(readable.pipe(new common_1.DownloadTransform), fs_1.default.createWriteStream(tmpfile));
const head = await read7Bytes(tmpfile);
if (head.includes("SILK") || head.includes("AMR")) {
const buf = await fs_1.default.promises.readFile(tmpfile);
fs_1.default.unlink(tmpfile, common_1.NOOP);
return buf;
}
else {
return audioTrans(tmpfile, ffmpeg);
}
}
else {
// 本地文件
file = String(file).replace(/^file:\/{2}/, "");
common_1.IS_WIN && file.startsWith("/") && (file = file.slice(1));
const head = await read7Bytes(file);
if (head.includes("SILK") || head.includes("AMR")) {
return fs_1.default.promises.readFile(file);
}
else {
return audioTrans(file, ffmpeg);
}
}
}
function audioTrans(file, ffmpeg = "ffmpeg") {
return new Promise((resolve, reject) => {
const tmpfile = path_1.default.join(common_1.TMP_DIR, (0, common_1.uuid)());
(0, child_process_1.exec)(`${ffmpeg} -y -i "${file}" -ac 1 -ar 8000 -f amr "${tmpfile}"`, async (error, stdout, stderr) => {
try {
const amr = await fs_1.default.promises.readFile(tmpfile);
resolve(amr);
}
catch {
reject(new core_1.ApiRejection(errors_1.ErrorCode.FFmpegPttTransError, "音频转码到amr失败,请确认你的ffmpeg可以处理此转换"));
}
finally {
fs_1.default.unlink(tmpfile, common_1.NOOP);
}
});
});
}
async function read7Bytes(file) {
const fd = await fs_1.default.promises.open(file, "r");
const buf = (await fd.read(Buffer.alloc(7), 0, 7, 0)).buffer;
fd.close();
return buf;
}