oicq
Version:
QQ protocol!
523 lines (489 loc) • 14 kB
JavaScript
/**
* 构造图片节点
* 上传图片
*/
"use strict";
const { Readable } = require("stream");
const fs = require("fs");
const path = require("path");
const { randomBytes } = require("crypto");
const probe = require("probe-image-size");
const pb = require("../algo/pb");
const common = require("../common");
const { downloadFromWeb, highwayUploadStream, MAX_UPLOAD_SIZE } = require("../service");
const ERROR_UNSUPPORTED_FILE = new Error("file必须为Buffer或string类型");
const ERROR_BAD_FILE = new Error("ERROR_BAD_FILE");
const ERROR_NO_CACHE = new Error("ERROR_NO_CACHE");
const ERROR_NOT_IMAGE = new Error("不是有效的图片");
const img_types = {
jpg: 1000,
png: 1001,
webp: 1002,
bmp: 1005,
gif: 2000,
face: 4,
};
const img_exts = {
3: "png",
4: "face",
1000: "jpg",
1001: "png",
1002: "webp",
1003: "jpg",
1005: "bmp",
2000: "gif",
2001: "png",
};
/**
* 生成CQ码file字段
* @param {string} md5
* @param {number} size
* @param {number} width
* @param {number} height
* @param {number} type
*/
function buildImageFileParam(md5, size, width, height, type) {
size = size > 0 ? String(size) : "";
width = width > 0 ? String(width) : "0";
height = height > 0 ? String(height) : "0";
const ext = img_exts[type] ? img_exts[type] : "jpg";
return md5 + size + "-" + width + "-" + height + "." + ext;
}
/**
* 图片处理流程
*
* localfile -> get stat(md5,size,hw) ↘
* httpfile -> tmplocalfile & get stat -> createReadStream() ↘
* Buffer -> get stat(md5,size,height,width) -> Readable.from() -> uploadImages(20并发) -> exists?
* ↑ no ↓
* base64file highwayUploadStream
*/
class ImageBuilder {
/**
* 图片protobuf节点
* @public
* @type {import("../ref").Proto}
*/
nested;
/**
* 图片字节流
* @public
* @type {Readable}
*/
readable;
/**
* 服务端返回的fileid
* @public
* @type {Buffer}
*/
fid;
/**
* 网络图片下载任务
* @public
* @type {Promise}
*/
task;
/**
* 上传ticket
* @public
* @type {Buffer}
*/
ticket;
cmd = 2;
/**
* @public
*/
md5 = randomBytes(16);
/**
* @public
*/
size = 0xff;
/**
* @public
*/
width = 960;
/**
* @public
*/
height = 640;
/**
* @public
*/
type = 1000;
/**
* 图像信息缓存文件路径
* @private
* @type {string}
*/
filepath;
/**
* 网络图片临时文件路径
* @private
* @type {string}
*/
tmpfile;
/**
* @private
* @type {number}
*/
timeout;
/**
* @private
* @type {import("http").OutgoingHttpHeaders}
*/
headers;
/**
* @private
* @type {string}
*/
address;
/**
* @param {import("../ref").Client} c
*/
constructor(c, c2c = false) {
this.c = c;
this.c2c = c2c;
if (c2c)
this.cmd = 1;
}
/**
* 计算图片md5, size, 长宽
* @private
* @param {Buffer} buf
*/
probeSync(buf) {
const dimensions = probe.sync(buf);
this.setProbe(dimensions);
this.md5 = common.md5(buf);
this.size = buf.length;
this.readable = Readable.from(buf, { objectMode: false });
}
/**
* @private
* @param {probe.ProbeResult} dimensions
*/
setProbe(dimensions) {
if (!dimensions)
throw ERROR_NOT_IMAGE;
this.width = dimensions.width;
this.height = dimensions.height;
this.type = img_types[dimensions.type] || 1000;
}
/**
* 从缓存文件中获取md5, size, 长宽
* @private
* @param {string} file
*/
parseImageFileParam(file) {
let md5, size, ext;
const split = file.split("-");
md5 = Buffer.from(split[0].slice(0, 32), "hex");
if (md5.length !== 16)
throw ERROR_BAD_FILE;
this.md5 = md5;
size = parseInt(split[0].slice(32));
this.size = size > 0 ? size : 0xff;
if (split[1] > 0)
this.width = parseInt(split[1]);
split[2] = parseInt(split[2]);
if (split[2] > 0)
this.height = split[2];
const split2 = file.split(".");
ext = split2[1] ? split2[1] : "jpg";
if (img_types[ext])
this.type = img_types[ext];
}
/**
* 构造图片protobuf节点
* @private
*/
setNested() {
let nested;
if (this.c2c) {
nested = {
1: this.md5.toString("hex"),
2: this.size,
3: this.fid,
5: this.type,
7: this.md5,
8: this.height,
9: this.width,
10: this.fid,
13: 0, //原图
16: this.type === 4 ? 5 : 0,
24: 0,
25: 0,
};
} else {
nested = {
2: this.md5.toString("hex") + ".gif",
7: this.fid,
8: 0,
9: 0,
10: 66,
12: 1,
13: this.md5,
// 17: 3,
20: this.type,
22: this.width,
23: this.height,
24: 200,
25: this.size,
26: 0, //原图
29: 0,
30: 0,
};
}
if (this.nested)
Object.assign(this.nested, nested);
else
this.nested = nested;
}
/**
* 下载网络图片并生成缓存文件
* @private
*/
async download() {
this.c.logger.debug("开始下载网络图片: " + this.address);
try {
this.tmpfile = this.filepath + common.uuid() + ".img";
var res = await downloadFromWeb(this.address, this.headers);
var id = setTimeout(()=>{
this.c.logger.warn(`download timeout after ${this.timeout}s`);
res.destroy();
}, this.timeout * 1000);
const [dimensions, md5] = await Promise.all([
probe(res, true),
common.md5Stream(res),
common.pipeline(res, fs.createWriteStream(this.tmpfile)),
])
clearTimeout(id);
this.setProbe(dimensions);
this.md5 = md5;
this.size = (await fs.promises.stat(this.tmpfile)).size;
this.c.logger.debug("图片下载完成: " + this.address);
this.readable = fs.createReadStream(this.tmpfile, { highWaterMark: 1024*256 });
} catch (e) {
clearTimeout(id);
this.deleteTmpFile();
this.c.logger.warn(`图片下载失败: ${e.message} (${this.address})`);
}
this.setNested();
const cache = buildImageFileParam(this.md5.toString("hex"), this.size, this.width, this.height, this.type);
fs.writeFile(this.filepath, cache, common.NOOP);
}
/**
* 服务端返回的fid(fileid)写入图片节点
* @public
* @param {Buffer} fid
*/
setFid(fid) {
if (!this.nested)
return;
this.fid = fid;
if (this.c2c) {
this.nested[3] = fid;
this.nested[10] = fid;
} else {
this.nested[7] = fid;
}
}
/**
* @public
* 图片失效时删除缓存文件(仅http)
*/
deleteCache() {
if (this.filepath) {
fs.unlink(this.filepath, common.NOOP)
}
}
/**
* @public
* 删除临时图片文件
*/
deleteTmpFile() {
if (this.readable)
this.readable.destroy();
if (this.tmpfile) {
fs.unlink(this.tmpfile, common.NOOP);
}
}
/**
* @public
* @param {import("../ref").ImgPttElem["data"]} cq
*/
async buildNested(cq) {
let { file, cache, timeout, headers } = cq;
// bytes
if (file instanceof Uint8Array || file instanceof ArrayBuffer || file instanceof SharedArrayBuffer) {
if (file instanceof Buffer === false)
file = Buffer.from(file);
this.probeSync(file);
} else if (typeof file !== "string" && file instanceof String === false) {
throw ERROR_UNSUPPORTED_FILE;
}
// base64图片
else if (file.startsWith("base64://")) {
this.c.logger.debug("转换base64图片");
this.probeSync(Buffer.from(file.slice(9), "base64"));
}
// 网络图片
else if (file.startsWith("http")) {
const filename = common.md5(Buffer.from(file, "utf-8")).toString("hex");
this.filepath = path.join(this.c.dir, "..", "image", filename);
this.address = file;
this.timeout = Math.abs(parseFloat(timeout)) || 60;
this.headers = headers;
try {
if (["0", "false", "no"].includes(String(cache)))
throw ERROR_NO_CACHE;
this.parseImageFileParam(await fs.promises.readFile(this.filepath, "utf8"));
this.c.logger.debug("使用缓存的图片信息");
} catch {
this.task = this.download();
}
}
else {
try {
//收到的图片
this.parseImageFileParam(file);
} catch {
//本地图片
file = file.replace(/^file:\/{2,3}/, "");
const stat = await fs.promises.stat(file);
if (stat.size <= 0 || stat.size > MAX_UPLOAD_SIZE)
throw new Error("图片尺寸太大, size: " + stat.size);
const readable = fs.createReadStream(file);
const [dimensions, md5] = await Promise.all([
probe(readable, true),
common.md5Stream(readable)
])
readable.destroy();
this.setProbe(dimensions);
this.md5 = md5;
this.size = stat.size;
this.readable = fs.createReadStream(file, { highWaterMark: 1024*256 });
}
}
this.setNested();
}
}
/**
* 上传群图(最多20张)
* @this {import("../ref").Client}
* @param {number} group_id
* @param {ImageBuilder[]} imgs
*/
async function _groupPicUp(group_id, imgs) {
const req = [];
for (const v of imgs) {
req.push({
1: group_id,
2: this.uin,
3: 0,
4: v.md5,
5: v.size,
6: v.md5.toString("hex"),
7: 5,
8: 9,
9: 1,
12: v.type,
13: this.apk.version,
15: 1052,
16: 0, //原图
19: 0,
});
}
const body = pb.encode({
1: 3,
2: 1,
3: req,
});
const blob = await this.sendUni("ImgStore.GroupPicUp", body);
return pb.decode(blob)[3];
}
/**
* 上传私聊图(最多20张)
* @this {import("../ref").Client}
* @param {number} user_id
* @param {ImageBuilder[]} imgs
*/
async function _offPicUp(user_id, imgs) {
const req = [];
for (const v of imgs) {
req.push({
1: this.uin,
2: user_id,
3: 0,
4: v.md5,
5: v.size,
6: v.md5.toString("hex"),
7: 5,
8: 9,
10: 0,
12: 1,
13: 0, //原图
16: v.type,
17: this.apk.version,
22: 0,
});
}
const body = pb.encode({
1: 1,
2: req
});
const blob = await this.sendUni("LongConn.OffPicUp", body);
return pb.decode(blob)[2];
}
/**
* 最多同时上传20张
* @this {import("../ref").Client}
* @param {number} target
* @param {ImageBuilder[]} imgs
*/
async function uploadImages(target, imgs, c2c = false) {
let n = 0;
const j = c2c ? 1 : 0;
while (imgs.length > n) {
try {
this.logger.debug("开始请求上传图片到tx服务器");
let rsp = await (c2c ? _offPicUp : _groupPicUp).call(this, target, imgs.slice(n, n + 20));
rsp = Array.isArray(rsp) ? rsp : [rsp];
const tasks = [];
for (let i = n; i < imgs.length; ++i) {
if (i >= n + 20)
break;
const v = rsp[i % 20];
if (v[2 + j] !== 0)
throw new Error(String(v[3 + j]));
imgs[i].setFid(c2c ? v[10].toBuffer() : v[9]);
if (v[4 + j]) {
imgs[i].deleteTmpFile();
continue;
}
if (!imgs[i].readable) {
imgs[i].deleteCache()
continue;
}
let ip = v[6 + j], port = v[7 + j];
if (Array.isArray(ip))
ip = ip[0];
if (Array.isArray(port))
port = port[0];
imgs[i].ticket = v[8 + j].toBuffer();
tasks.push(highwayUploadStream.call(this, imgs[i].readable, imgs[i], ip, port).then(() => {
imgs[i].deleteTmpFile();
}));
}
await Promise.all(tasks);
this.logger.debug("请求图片上传结束");
} catch (e) {
this.logger.warn("请求图片上传遇到错误: " + e.message);
this.logger.debug(e);
}
n += 20;
}
}
module.exports = {
ImageBuilder, uploadImages, buildImageFileParam
};