NapCatQQ source snapshot
Build NapCat Artifacts / Type Check & Test (push) Waiting to run
Release Tag / validate-version (push) Waiting to run

This commit is contained in:
TanTan committed 2026-11-13 09:01:47 +08:00
commit f954252a34
1111 files changed
+139867

No files matched your search

@@ -0,0 +1,73 @@
import * as stream from 'node:stream';
import { ReadStream } from 'node:fs';
import { HighwayTcpUploader } from '@/napcat-core/packet/highway/uploader/highwayTcpUploader';
import { HighwayHttpUploader } from '@/napcat-core/packet/highway/uploader/highwayHttpUploader';
import { PacketHighwaySig } from '@/napcat-core/packet/highway/highwayContext';
import { PacketLogger } from '@/napcat-core/packet/context/loggerContext';
export interface PacketHighwayTrans {
uin: string;
cmd: number;
command: string;
data: stream.Readable;
sum: Uint8Array;
size: number;
ticket: Uint8Array;
loginSig?: Uint8Array;
ext: Uint8Array;
encrypt: boolean;
timeout?: number;
server: string;
port: number;
}
export class PacketHighwayClient {
sig: PacketHighwaySig;
server: string = 'htdata3.qq.com';
port: number = 80;
logger: PacketLogger;
constructor (sig: PacketHighwaySig, logger: PacketLogger, _server: string = 'htdata3.qq.com', _port: number = 80) {
this.sig = sig;
this.logger = logger;
}
changeServer (server: string, port: number) {
this.server = server;
this.port = port;
}
private buildDataUpTrans (cmd: number, data: ReadStream, fileSize: number, md5: Uint8Array, extendInfo: Uint8Array, timeout: number = 1200): PacketHighwayTrans {
return {
uin: this.sig.uin,
cmd,
command: 'PicUp.DataUp',
data,
sum: md5,
size: fileSize,
ticket: this.sig.sigSession!,
ext: extendInfo,
encrypt: false,
timeout,
server: this.server,
port: this.port,
} as PacketHighwayTrans;
}
async upload (cmd: number, data: ReadStream, fileSize: number, md5: Uint8Array, extendInfo: Uint8Array): Promise<void> {
const trans = this.buildDataUpTrans(cmd, data, fileSize, md5, extendInfo);
try {
const tcpUploader = new HighwayTcpUploader(trans, this.logger);
await tcpUploader.upload();
} catch (e) {
this.logger.error(`[Highway] upload failed: ${e}, fallback to http upload`);
try {
const httpUploader = new HighwayHttpUploader(trans, this.logger);
await httpUploader.upload();
} catch (e) {
this.logger.error(`[Highway] http upload failed: ${e}`);
throw e;
}
}
}
}
@@ -0,0 +1,23 @@
import assert from 'node:assert';
export class Frame {
static pack (head: Buffer, body: Buffer): Buffer {
const totalLength = 9 + head.length + body.length + 1;
const buffer = Buffer.allocUnsafe(totalLength);
buffer[0] = 0x28;
buffer.writeUInt32BE(head.length, 1);
buffer.writeUInt32BE(body.length, 5);
head.copy(buffer, 9);
body.copy(buffer, 9 + head.length);
buffer[totalLength - 1] = 0x29;
return buffer;
}
static unpack (frame: Buffer): [Buffer, Buffer] {
assert(frame[0] === 0x28 && frame[frame.length - 1] === 0x29, 'Invalid frame!');
const headLen = frame.readUInt32BE(1);
const bodyLen = frame.readUInt32BE(5);
// assert(frame.length === 9 + headLen + bodyLen + 1, `Frame ${frame.toString('hex')} length does not match head and body lengths!`);
return [frame.subarray(9, 9 + headLen), frame.subarray(9 + headLen, 9 + headLen + bodyLen)];
}
}
@@ -0,0 +1,655 @@
import { PacketHighwayClient } from '@/napcat-core/packet/highway/client';
import { PacketLogger } from '@/napcat-core/packet/context/loggerContext';
import FetchSessionKey from '@/napcat-core/packet/transformer/highway/FetchSessionKey';
import { int32ip2str, oidbIpv4s2HighwayIpv4s } from '@/napcat-core/packet/highway/utils';
import {
PacketMsgFileElement,
PacketMsgPicElement,
PacketMsgPttElement,
PacketMsgVideoElement,
} from '@/napcat-core/packet/message/element';
import { ChatType, Peer } from '@/napcat-core/index';
import { calculateSha1, calculateSha1StreamBytes, computeMd5AndLengthWithLimit } from '@/napcat-core/packet/utils/crypto/hash';
import UploadGroupImage from '@/napcat-core/packet/transformer/highway/UploadGroupImage';
import { NapProtoMsg } from 'napcat-protobuf';
import * as proto from '@/napcat-core/packet/transformer/proto';
import * as trans from '@/napcat-core/packet/transformer';
import fs from 'fs';
import path from 'path';
import { NapCoreContext } from '@/napcat-core/packet/context/napCoreContext';
import { PacketClientContext } from '@/napcat-core/packet/context/clientContext';
import { FFmpegService } from '@/napcat-core/helper/ffmpeg/ffmpeg';
import { defaultVideoThumbB64 } from '@/napcat-core/helper/ffmpeg/video';
import { calculateFileMD5 } from 'napcat-common/src/file';
import { randomUUID } from 'node:crypto';
import { imageSizeFallBack } from 'napcat-image-size/src/index';
export const BlockSize = 1024 * 1024;
const VIDEO_THUMBNAIL_MAX_BYTES = 1024 * 1024;
interface HighwayServerAddr {
ip: string;
port: number;
}
export interface PacketHighwaySig {
uin: string;
uid: string;
sigSession: Uint8Array | null;
sessionKey: Uint8Array | null;
serverAddr: HighwayServerAddr[];
}
export class PacketHighwayContext {
private readonly napcore: NapCoreContext;
private readonly client: PacketClientContext;
protected sig: PacketHighwaySig;
protected logger: PacketLogger;
protected hwClient: PacketHighwayClient;
private cachedPrepareReq: Promise<void> | null = null;
constructor (napcore: NapCoreContext, logger: PacketLogger, client: PacketClientContext) {
this.napcore = napcore;
this.client = client;
this.sig = {
uin: String(this.napcore.basicInfo.uin),
uid: this.napcore.basicInfo.uid,
sigSession: null,
sessionKey: null,
serverAddr: [],
};
this.logger = logger;
this.hwClient = new PacketHighwayClient(this.sig, this.logger);
}
private async checkAvailable () {
if (this.sig.sigSession === null || this.sig.sessionKey === null) {
if (this.cachedPrepareReq === null) {
this.cachedPrepareReq = this.prepareUpload().finally(() => {
this.cachedPrepareReq = null;
});
}
await this.cachedPrepareReq;
}
}
private async prepareUpload (): Promise<void> {
this.logger.debug('[Highway] on prepareUpload!');
const packet = FetchSessionKey.build();
const req = await this.client.sendOidbPacket(packet, true);
const rsp = FetchSessionKey.parse(req);
this.sig.sigSession = rsp.httpConn.sigSession;
this.sig.sessionKey = rsp.httpConn.sessionKey;
for (const info of rsp.httpConn.serverInfos) {
if (info.serviceType !== 1) continue;
for (const addr of info.serverAddrs) {
this.logger.debug(`[Highway PrepareUpload] server addr add: ${int32ip2str(addr.ip)}:${addr.port}`);
this.sig.serverAddr.push({
ip: int32ip2str(addr.ip),
port: addr.port,
});
this.hwClient.changeServer(int32ip2str(addr.ip), addr.port);
}
}
if (this.sig.serverAddr.length === 0) {
this.logger.warn('[Highway PrepareUpload] server addr is empty!');
}
}
async uploadImage (peer: Peer, img: PacketMsgPicElement): Promise<void> {
await this.checkAvailable();
if (peer.chatType === ChatType.KCHATTYPEGROUP) {
await this.uploadGroupImage(+peer.peerUid, img);
} else if (peer.chatType === ChatType.KCHATTYPEC2C) {
await this.uploadC2CImage(peer.peerUid, img);
} else {
throw new Error(`[Highway] unsupported chatType: ${peer.chatType}`);
}
}
async uploadVideo (peer: Peer, video: PacketMsgVideoElement): Promise<void> {
await this.checkAvailable();
if (+(video.fileSize ?? 0) > 1024 * 1024 * 100) {
throw new Error(`[Highway] 视频文件过大: ${(+(video.fileSize ?? 0) / (1024 * 1024)).toFixed(2)} MB > 100 MB,请使用文件上传!`);
}
const cleanupThumbPaths: string[] = [];
try {
let thumbExists = false;
if (video.thumbPath) {
try {
await fs.promises.access(video.thumbPath, fs.constants.F_OK);
thumbExists = true;
} catch {
thumbExists = false;
}
}
let shouldGenerateThumb = !video.thumbPath || !thumbExists;
if (!shouldGenerateThumb) {
try {
await this.updateVideoThumbInfo(video);
if ((video.thumbSize ?? 0) > VIDEO_THUMBNAIL_MAX_BYTES) {
this.logger.warn(`[Highway] Video thumbnail is too large: ${video.thumbSize} bytes, regenerating`);
shouldGenerateThumb = true;
}
} catch (e) {
this.logger.warn(`[Highway] Failed to inspect video thumbnail, regenerating. Reason: ${e instanceof Error ? e.message : e}`);
shouldGenerateThumb = true;
}
}
if (shouldGenerateThumb) {
cleanupThumbPaths.push(await this.ensureVideoThumb(video));
}
video.businessType = peer.chatType === ChatType.KCHATTYPEGROUP ? 21 : 11;
if (peer.chatType === ChatType.KCHATTYPEGROUP) {
await this.uploadGroupVideo(+peer.peerUid, video);
} else if (peer.chatType === ChatType.KCHATTYPEC2C) {
await this.uploadC2CVideo(peer.peerUid, video);
} else {
throw new Error(`[Highway] unsupported chatType: ${peer.chatType}`);
}
} finally {
await Promise.allSettled(cleanupThumbPaths.map(async (thumbToClean) => {
try {
await fs.promises.unlink(thumbToClean);
this.logger.debug(`[Highway] Cleaned up temp thumbnail: ${thumbToClean}`);
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') {
this.logger.warn(`[Highway] Failed to clean up temp thumbnail: ${thumbToClean}, reason: ${e instanceof Error ? e.message : e}`);
}
}
}));
}
}
/**
* 使用当前 FFmpeg 适配器生成符合 QQ 大小限制的视频缩略图
* @returns 生成的临时缩略图路径,用于后续清理
*/
private async ensureVideoThumb (video: PacketMsgVideoElement): Promise<string> {
if (!video.filePath) {
throw new Error('video.filePath is empty, cannot generate thumbnail');
}
// 生成缩略图路径
const videoDir = path.dirname(video.filePath);
const videoBasename = path.basename(video.filePath, path.extname(video.filePath));
const thumbPath = path.join(videoDir, `${videoBasename}_thumb_${randomUUID()}.jpg`);
this.logger.debug(`[Highway] Video thumb missing, generating at: ${thumbPath}`);
video.thumbPath = thumbPath;
try {
// 尝试使用 FFmpeg 提取视频缩略图
await FFmpegService.extractThumbnail(video.filePath, thumbPath);
try {
await fs.promises.access(thumbPath, fs.constants.F_OK);
this.logger.debug('[Highway] Video thumbnail generated successfully using FFmpeg');
} catch {
throw new Error('FFmpeg failed to generate thumbnail');
}
await this.updateVideoThumbInfo(video);
if ((video.thumbSize ?? 0) > VIDEO_THUMBNAIL_MAX_BYTES) {
throw new Error(`generated thumbnail is too large: ${video.thumbSize} bytes`);
}
} catch (e) {
// FFmpeg 失败时(包括未初始化的情况)使用默认缩略图
this.logger.warn(`[Highway] Failed to extract thumbnail, using default. Reason: ${e instanceof Error ? e.message : e}`);
await fs.promises.writeFile(thumbPath, Buffer.from(defaultVideoThumbB64, 'base64'));
await this.updateVideoThumbInfo(video);
}
return thumbPath;
}
private async updateVideoThumbInfo (video: PacketMsgVideoElement): Promise<void> {
if (!video.thumbPath) {
throw new Error('video.thumbPath is empty, cannot inspect thumbnail');
}
const [thumbStat, thumbMd5, dimensions] = await Promise.all([
fs.promises.stat(video.thumbPath),
calculateFileMD5(video.thumbPath),
imageSizeFallBack(video.thumbPath),
]);
video.thumbSize = thumbStat.size;
video.thumbMd5 = thumbMd5;
video.thumbWidth = dimensions.width;
video.thumbHeight = dimensions.height;
this.logger.debug(`[Highway] Video thumbnail ready: ${dimensions.width}x${dimensions.height}, ${thumbStat.size} bytes`);
}
async uploadPtt (peer: Peer, ptt: PacketMsgPttElement): Promise<void> {
await this.checkAvailable();
if (peer.chatType === ChatType.KCHATTYPEGROUP) {
await this.uploadGroupPtt(+peer.peerUid, ptt);
} else if (peer.chatType === ChatType.KCHATTYPEC2C) {
await this.uploadC2CPtt(peer.peerUid, ptt);
} else {
throw new Error(`[Highway] unsupported chatType: ${peer.chatType}`);
}
}
async uploadFile (peer: Peer, file: PacketMsgFileElement): Promise<void> {
await this.checkAvailable();
if (peer.chatType === ChatType.KCHATTYPEGROUP) {
await this.uploadGroupFile(+peer.peerUid, file);
} else if (peer.chatType === ChatType.KCHATTYPEC2C) {
await this.uploadC2CFile(peer.peerUid, file);
} else {
throw new Error(`[Highway] unsupported chatType: ${peer.chatType}`);
}
}
private async uploadGroupImage (groupUin: number, img: PacketMsgPicElement): Promise<void> {
img.sha1 = Buffer.from(await calculateSha1(img.path)).toString('hex');
const req = UploadGroupImage.build(groupUin, img);
const resp = await this.client.sendOidbPacket(req, true);
const preRespData = UploadGroupImage.parse(resp);
const ukey = preRespData.upload.uKey;
if (ukey && ukey !== '') {
this.logger.debug(`[Highway] uploadGroupImageReq get upload ukey: ${ukey}, need upload!`);
const index = preRespData.upload.msgInfo.msgInfoBody[0]!.index;
const sha1 = Buffer.from(index.info.fileSha1, 'hex');
const md5 = Buffer.from(index.info.fileHash, 'hex');
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
fileUuid: index.fileUuid,
uKey: ukey,
network: {
ipv4S: oidbIpv4s2HighwayIpv4s(preRespData.upload.ipv4S),
},
msgInfoBody: preRespData.upload.msgInfo.msgInfoBody,
blockSize: BlockSize,
hash: {
fileSha1: [sha1],
},
});
await this.hwClient.upload(
1004,
fs.createReadStream(img.path, { highWaterMark: BlockSize }),
img.size,
md5,
extend
);
} else {
this.logger.debug(`[Highway] uploadGroupImageReq get upload invalid ukey ${ukey}, don't need upload!`);
}
img.msgInfo = preRespData.upload.msgInfo;
// img.groupPicExt = new NapProtoMsg(CustomFace).decode(preRespData.tcpUpload.compatQMsg)
}
private async uploadC2CImage (peerUid: string, img: PacketMsgPicElement): Promise<void> {
img.sha1 = Buffer.from(await calculateSha1(img.path)).toString('hex');
const req = trans.UploadPrivateImage.build(peerUid, img);
const resp = await this.client.sendOidbPacket(req, true);
const preRespData = trans.UploadPrivateImage.parse(resp);
const ukey = preRespData.upload.uKey;
if (ukey && ukey !== '') {
this.logger.debug(`[Highway] uploadC2CImageReq get upload ukey: ${ukey}, need upload!`);
const index = preRespData.upload.msgInfo.msgInfoBody[0]!.index;
const sha1 = Buffer.from(index.info.fileSha1, 'hex');
const md5 = Buffer.from(index.info.fileHash, 'hex');
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
fileUuid: index.fileUuid,
uKey: ukey,
network: {
ipv4S: oidbIpv4s2HighwayIpv4s(preRespData.upload.ipv4S),
},
msgInfoBody: preRespData.upload.msgInfo.msgInfoBody,
blockSize: BlockSize,
hash: {
fileSha1: [sha1],
},
});
await this.hwClient.upload(
1003,
fs.createReadStream(img.path, { highWaterMark: BlockSize }),
img.size,
md5,
extend
);
} else {
this.logger.debug(`[Highway] uploadC2CImageReq get upload invalid ukey ${ukey}, don't need upload!`);
}
img.msgInfo = preRespData.upload.msgInfo;
}
private async uploadGroupVideo (groupUin: number, video: PacketMsgVideoElement): Promise<void> {
if (!video.filePath || !video.thumbPath) throw new Error('video.filePath or video.thumbPath is empty');
video.fileSha1 = Buffer.from(await calculateSha1(video.filePath)).toString('hex');
video.thumbSha1 = Buffer.from(await calculateSha1(video.thumbPath)).toString('hex');
const req = trans.UploadGroupVideo.build(groupUin, video);
const resp = await this.client.sendOidbPacket(req, true);
const preRespData = trans.UploadGroupVideo.parse(resp);
const videoIndex = preRespData.upload.msgInfo.msgInfoBody[0]?.index;
const thumbIndex = preRespData.upload.msgInfo.msgInfoBody[1]?.index;
if (!videoIndex || !thumbIndex) {
throw new Error('[Highway] uploadGroupVideo response is missing video or thumbnail MsgInfo');
}
const ukey = preRespData.upload.uKey;
if (ukey && ukey !== '') {
this.logger.debug(`[Highway] uploadGroupVideoReq get upload video ukey: ${ukey}, need upload!`);
const md5 = Buffer.from(videoIndex.info.fileHash, 'hex');
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
fileUuid: videoIndex.fileUuid,
uKey: ukey,
network: {
ipv4S: oidbIpv4s2HighwayIpv4s(preRespData.upload.ipv4S),
},
msgInfoBody: preRespData.upload.msgInfo.msgInfoBody,
blockSize: BlockSize,
hash: {
fileSha1: await calculateSha1StreamBytes(video.filePath),
},
});
await this.hwClient.upload(
1005,
fs.createReadStream(video.filePath, { highWaterMark: BlockSize }),
+video.fileSize!,
md5,
extend
);
} else {
this.logger.debug(`[Highway] uploadGroupVideoReq get upload invalid ukey ${ukey}, don't need upload!`);
}
const subFile = preRespData.upload.subFileInfos[0];
if (subFile?.uKey && subFile.uKey !== '') {
this.logger.debug(`[Highway] uploadGroupVideoReq get upload video thumb ukey: ${subFile.uKey}, need upload!`);
const md5 = Buffer.from(thumbIndex.info.fileHash, 'hex');
const sha1 = Buffer.from(thumbIndex.info.fileSha1, 'hex');
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
fileUuid: thumbIndex.fileUuid,
uKey: subFile.uKey,
network: {
ipv4S: oidbIpv4s2HighwayIpv4s(subFile.ipv4S),
},
msgInfoBody: preRespData.upload.msgInfo.msgInfoBody,
blockSize: BlockSize,
hash: {
fileSha1: [sha1],
},
});
await this.hwClient.upload(
1006,
fs.createReadStream(video.thumbPath, { highWaterMark: BlockSize }),
+video.thumbSize!,
md5,
extend
);
} else {
this.logger.debug(`[Highway] uploadGroupVideoReq get upload invalid thumb ukey ${subFile?.uKey ?? ''}, don't need upload!`);
}
video.msgInfo = preRespData.upload.msgInfo;
}
private async uploadC2CVideo (peerUid: string, video: PacketMsgVideoElement): Promise<void> {
if (!video.filePath || !video.thumbPath) throw new Error('video.filePath or video.thumbPath is empty');
video.fileSha1 = Buffer.from(await calculateSha1(video.filePath)).toString('hex');
video.thumbSha1 = Buffer.from(await calculateSha1(video.thumbPath)).toString('hex');
const req = trans.UploadPrivateVideo.build(peerUid, video);
const resp = await this.client.sendOidbPacket(req, true);
const preRespData = trans.UploadPrivateVideo.parse(resp);
const videoIndex = preRespData.upload.msgInfo.msgInfoBody[0]?.index;
const thumbIndex = preRespData.upload.msgInfo.msgInfoBody[1]?.index;
if (!videoIndex || !thumbIndex) {
throw new Error('[Highway] uploadC2CVideo response is missing video or thumbnail MsgInfo');
}
const ukey = preRespData.upload.uKey;
if (ukey && ukey !== '') {
this.logger.debug(`[Highway] uploadC2CVideoReq get upload video ukey: ${ukey}, need upload!`);
const md5 = Buffer.from(videoIndex.info.fileHash, 'hex');
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
fileUuid: videoIndex.fileUuid,
uKey: ukey,
network: {
ipv4S: oidbIpv4s2HighwayIpv4s(preRespData.upload.ipv4S),
},
msgInfoBody: preRespData.upload.msgInfo.msgInfoBody,
blockSize: BlockSize,
hash: {
fileSha1: await calculateSha1StreamBytes(video.filePath),
},
});
await this.hwClient.upload(
1001,
fs.createReadStream(video.filePath, { highWaterMark: BlockSize }),
+video.fileSize!,
md5,
extend
);
} else {
this.logger.debug(`[Highway] uploadC2CVideoReq get upload invalid ukey ${ukey}, don't need upload!`);
}
const subFile = preRespData.upload.subFileInfos[0];
if (subFile?.uKey && subFile.uKey !== '') {
this.logger.debug(`[Highway] uploadC2CVideoReq get upload video thumb ukey: ${subFile.uKey}, need upload!`);
const md5 = Buffer.from(thumbIndex.info.fileHash, 'hex');
const sha1 = Buffer.from(thumbIndex.info.fileSha1, 'hex');
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
fileUuid: thumbIndex.fileUuid,
uKey: subFile.uKey,
network: {
ipv4S: oidbIpv4s2HighwayIpv4s(subFile.ipv4S),
},
msgInfoBody: preRespData.upload.msgInfo.msgInfoBody,
blockSize: BlockSize,
hash: {
fileSha1: [sha1],
},
});
await this.hwClient.upload(
1002,
fs.createReadStream(video.thumbPath, { highWaterMark: BlockSize }),
+video.thumbSize!,
md5,
extend
);
} else {
this.logger.debug(`[Highway] uploadC2CVideoReq get upload invalid thumb ukey ${subFile?.uKey ?? ''}, don't need upload!`);
}
video.msgInfo = preRespData.upload.msgInfo;
}
private async uploadGroupPtt (groupUin: number, ptt: PacketMsgPttElement): Promise<void> {
ptt.fileSha1 = Buffer.from(await calculateSha1(ptt.filePath)).toString('hex');
const req = trans.UploadGroupPtt.build(groupUin, ptt);
const resp = await this.client.sendOidbPacket(req, true);
const preRespData = trans.UploadGroupPtt.parse(resp);
const ukey = preRespData.upload.uKey;
if (ukey && ukey !== '') {
this.logger.debug(`[Highway] uploadGroupPttReq get upload ptt ukey: ${ukey}, need upload!`);
const index = preRespData.upload.msgInfo.msgInfoBody[0]!.index;
const md5 = Buffer.from(index.info.fileHash, 'hex');
const sha1 = Buffer.from(index.info.fileSha1, 'hex');
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
fileUuid: index.fileUuid,
uKey: ukey,
network: {
ipv4S: oidbIpv4s2HighwayIpv4s(preRespData.upload.ipv4S),
},
msgInfoBody: preRespData.upload.msgInfo.msgInfoBody,
blockSize: BlockSize,
hash: {
fileSha1: [sha1],
},
});
await this.hwClient.upload(
1008,
fs.createReadStream(ptt.filePath, { highWaterMark: BlockSize }),
ptt.fileSize,
md5,
extend
);
} else {
this.logger.debug(`[Highway] uploadGroupPttReq get upload invalid ukey ${ukey}, don't need upload!`);
}
ptt.msgInfo = preRespData.upload.msgInfo;
}
private async uploadC2CPtt (peerUid: string, ptt: PacketMsgPttElement): Promise<void> {
ptt.fileSha1 = Buffer.from(await calculateSha1(ptt.filePath)).toString('hex');
const req = trans.UploadPrivatePtt.build(peerUid, ptt);
const resp = await this.client.sendOidbPacket(req, true);
const preRespData = trans.UploadPrivatePtt.parse(resp);
const ukey = preRespData.upload.uKey;
if (ukey && ukey !== '') {
this.logger.debug(`[Highway] uploadC2CPttReq get upload ptt ukey: ${ukey}, need upload!`);
const index = preRespData.upload.msgInfo.msgInfoBody[0]!.index;
const md5 = Buffer.from(index.info.fileHash, 'hex');
const sha1 = Buffer.from(index.info.fileSha1, 'hex');
const extend = new NapProtoMsg(proto.NTV2RichMediaHighwayExt).encode({
fileUuid: index.fileUuid,
uKey: ukey,
network: {
ipv4S: oidbIpv4s2HighwayIpv4s(preRespData.upload.ipv4S),
},
msgInfoBody: preRespData.upload.msgInfo.msgInfoBody,
blockSize: BlockSize,
hash: {
fileSha1: [sha1],
},
});
await this.hwClient.upload(
1007,
fs.createReadStream(ptt.filePath, { highWaterMark: BlockSize }),
ptt.fileSize,
md5,
extend
);
} else {
this.logger.debug(`[Highway] uploadC2CPttReq get upload invalid ukey ${ukey}, don't need upload!`);
}
ptt.msgInfo = preRespData.upload.msgInfo;
}
private async uploadGroupFile (groupUin: number, file: PacketMsgFileElement): Promise<void> {
file.isGroupFile = true;
file.fileMd5 = await computeMd5AndLengthWithLimit(file.filePath);
file.fileSha1 = await calculateSha1(file.filePath);
const req = trans.UploadGroupFile.build(groupUin, file);
const resp = await this.client.sendOidbPacket(req, true);
const preRespData = trans.UploadGroupFile.parse(resp);
if (!preRespData?.upload?.boolFileExist) {
this.logger.debug('[Highway] uploadGroupFileReq file not exist, need upload!');
const ext = new NapProtoMsg(proto.FileUploadExt).encode({
unknown1: 100,
unknown2: 1,
entry: {
busiBuff: {
senderUin: BigInt(this.sig.uin),
receiverUin: BigInt(groupUin),
groupCode: BigInt(groupUin),
},
fileEntry: {
fileSize: BigInt(file.fileSize),
md5: file.fileMd5,
md5S2: file.fileMd5,
checkKey: preRespData.upload.checkKey,
fileId: preRespData.upload.fileId,
uploadKey: preRespData.upload.fileKey,
},
clientInfo: {
clientType: 3,
appId: '100',
terminalType: 3,
clientVer: '1.1.1',
unknown: 4,
},
fileNameInfo: {
fileName: file.fileName,
},
host: {
hosts: [
{
url: {
host: preRespData.upload.uploadIp,
unknown: 1,
},
port: preRespData.upload.uploadPort,
},
],
},
},
unknown200: 0,
});
await this.hwClient.upload(
71,
fs.createReadStream(file.filePath, { highWaterMark: BlockSize }),
file.fileSize,
file.fileMd5,
ext
);
} else {
this.logger.debug('[Highway] uploadGroupFileReq file exist, don\'t need upload!');
}
file.fileUuid = preRespData.upload.fileId;
}
private async uploadC2CFile (peerUid: string, file: PacketMsgFileElement): Promise<void> {
file.isGroupFile = false;
file.fileMd5 = await computeMd5AndLengthWithLimit(file.filePath);
file.fileSha1 = await calculateSha1(file.filePath);
const req = await trans.UploadPrivateFile.build(this.sig.uid, peerUid, file);
const res = await this.client.sendOidbPacket(req, true);
const preRespData = trans.UploadPrivateFile.parse(res);
if (!preRespData.upload?.boolFileExist) {
this.logger.debug('[Highway] uploadC2CFileReq file not exist, need upload!');
const ext = new NapProtoMsg(proto.FileUploadExt).encode({
unknown1: 100,
unknown2: 1,
entry: {
busiBuff: {
senderUin: BigInt(this.sig.uin),
},
fileEntry: {
fileSize: BigInt(file.fileSize),
md5: file.fileMd5,
md5S2: file.fileMd5,
checkKey: file.fileSha1,
fileId: preRespData.upload?.uuid,
uploadKey: preRespData.upload?.mediaPlatformUploadKey,
},
clientInfo: {
clientType: 3,
appId: '100',
terminalType: 3,
clientVer: '1.1.1',
unknown: 4,
},
fileNameInfo: {
fileName: file.fileName,
},
host: {
hosts: [
{
url: {
host: preRespData.upload?.uploadIp,
unknown: 1,
},
port: preRespData.upload?.uploadPort,
},
],
},
},
unknown200: 1,
unknown3: 0,
});
await this.hwClient.upload(
95,
fs.createReadStream(file.filePath, { highWaterMark: BlockSize }),
file.fileSize,
file.fileMd5,
ext
);
}
file.fileUuid = preRespData.upload?.uuid;
file.fileHash = preRespData.upload?.fileAddon;
const fileExistReq = trans.DownloadOfflineFile.build(file.fileUuid!, file.fileHash!, this.sig.uid, peerUid);
const fileExistRes = await this.client.sendOidbPacket(fileExistReq, true);
file._e37_800_rsp = trans.DownloadOfflineFile.parse(fileExistRes);
file._private_send_uid = this.sig.uid;
file._private_recv_uid = peerUid;
}
}
@@ -0,0 +1,75 @@
import crypto from 'node:crypto';
import http from 'node:http';
import { NapProtoMsg } from 'napcat-protobuf';
import { IHighwayUploader } from '@/napcat-core/packet/highway/uploader/highwayUploader';
import { Frame } from '@/napcat-core/packet/highway/frame';
import * as proto from '@/napcat-core/packet/transformer/proto';
export class HighwayHttpUploader extends IHighwayUploader {
async upload (): Promise<void> {
const controller = new AbortController();
const { signal } = controller;
const upload = (async () => {
let offset = 0;
for await (const chunk of this.trans.data) {
if (signal.aborted) {
throw new Error('Upload aborted due to timeout');
}
const block = chunk as Buffer;
try {
await this.uploadBlock(block, offset);
} catch (err) {
throw new Error(`[Highway] httpUpload Error uploading block at offset ${offset}: ${err}`);
}
offset += block.length;
}
})();
const timeout = this.timeout().catch((err) => {
controller.abort();
throw new Error(err.message);
});
await Promise.race([upload, timeout]);
}
private async uploadBlock (block: Buffer, offset: number): Promise<void> {
const chunkMD5 = crypto.createHash('md5').update(block).digest();
const payload = this.buildPicUpHead(offset, block.length, chunkMD5);
const frame = Frame.pack(Buffer.from(payload), block);
const resp = await this.httpPostHighwayContent(frame, `http://${this.trans.server}:${this.trans.port}/cgi-bin/httpconn?htcmd=0x6FF0087&uin=${this.trans.uin}`);
const [head, body] = Frame.unpack(resp);
const headData = new NapProtoMsg(proto.RespDataHighwayHead).decode(head);
this.logger.debug(`[Highway] httpUploadBlock: ${headData.errorCode} | ${headData.msgSegHead?.retCode} | ${headData.bytesRspExtendInfo} | ${head.toString('hex')} | ${body.toString('hex')}`);
if (headData.errorCode !== 0) throw new Error(`[Highway] httpUploadBlock failed (code=${headData.errorCode})`);
}
private async httpPostHighwayContent (frame: Buffer, serverURL: string): Promise<Buffer> {
return new Promise((resolve, reject) => {
try {
const options: http.RequestOptions = {
method: 'POST',
headers: {
Connection: 'keep-alive',
'Accept-Encoding': 'identity',
'User-Agent': 'Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.2)',
'Content-Length': frame.length.toString(),
},
};
const req = http.request(serverURL, options, (res) => {
const data: Buffer[] = [];
res.on('data', (chunk) => {
data.push(chunk);
});
res.on('end', () => {
resolve(Buffer.concat(data));
});
});
req.write(frame);
req.on('error', (error: Error) => {
reject(error);
});
} catch (error: unknown) {
reject(new Error((error as Error).message));
}
});
}
}
@@ -0,0 +1,86 @@
import net from 'node:net';
import stream from 'node:stream';
import crypto from 'node:crypto';
import { NapProtoMsg } from 'napcat-protobuf';
import { BlockSize } from '@/napcat-core/packet/highway/highwayContext';
import { Frame } from '@/napcat-core/packet/highway/frame';
import { IHighwayUploader } from '@/napcat-core/packet/highway/uploader/highwayUploader';
import * as proto from '@/napcat-core/packet/transformer/proto';
class HighwayTcpUploaderTransform extends stream.Transform {
uploader: HighwayTcpUploader;
offset: number;
constructor (uploader: HighwayTcpUploader) {
super();
this.uploader = uploader;
this.offset = 0;
}
override _transform (data: Buffer, _: BufferEncoding, callback: stream.TransformCallback) {
let chunkOffset = 0;
while (chunkOffset < data.length) {
const chunkSize = Math.min(BlockSize, data.length - chunkOffset);
const chunk = data.subarray(chunkOffset, chunkOffset + chunkSize);
const chunkMd5 = crypto.createHash('md5').update(chunk).digest();
const head = this.uploader.buildPicUpHead(this.offset, chunk.length, chunkMd5);
chunkOffset += chunk.length;
this.offset += chunk.length;
this.push(Frame.pack(Buffer.from(head), chunk));
}
callback(null);
}
}
export class HighwayTcpUploader extends IHighwayUploader {
async upload (): Promise<void> {
const controller = new AbortController();
const { signal } = controller;
const upload = new Promise<void>((resolve, reject) => {
const highwayTransForm = new HighwayTcpUploaderTransform(this);
const socket = net.connect(this.trans.port, this.trans.server, () => {
this.trans.data.pipe(highwayTransForm).pipe(socket, { end: false });
});
const handleRspHeader = (header: Buffer) => {
const rsp = new NapProtoMsg(proto.RespDataHighwayHead).decode(header);
if (rsp.errorCode !== 0) {
socket.end();
reject(new Error(`[Highway] tcpUpload failed (code=${rsp.errorCode})`));
}
const percent = ((Number(rsp.msgSegHead?.dataOffset) + Number(rsp.msgSegHead?.dataLength)) / Number(rsp.msgSegHead?.filesize)).toFixed(2);
this.logger.debug(`[Highway] tcpUpload ${rsp.errorCode} | ${percent} | ${Buffer.from(header).toString('hex')}`);
if (Number(rsp.msgSegHead?.dataOffset) + Number(rsp.msgSegHead?.dataLength) >= Number(rsp.msgSegHead?.filesize)) {
this.logger.debug('[Highway] tcpUpload finished.');
socket.end();
resolve();
}
};
socket.on('data', (chunk: Buffer) => {
if (signal.aborted) {
socket.end();
reject(new Error('Upload aborted due to timeout'));
}
const [head, _] = Frame.unpack(chunk);
handleRspHeader(head);
});
socket.on('close', () => {
this.logger.debug('[Highway] tcpUpload socket closed.');
resolve();
});
socket.on('error', (err) => {
socket.end();
reject(new Error(`[Highway] tcpUpload socket.on error: ${err}`));
});
this.trans.data.on('error', (err) => {
socket.end();
reject(new Error(`[Highway] tcpUpload readable error: ${err}`));
});
});
const timeout = this.timeout().catch((err) => {
controller.abort();
throw new Error(err.message);
});
await Promise.race([upload, timeout]);
}
}
@@ -0,0 +1,64 @@
// import * as tea from '@/napcat-core/packet/utils/crypto/tea';
import { NapProtoMsg } from 'napcat-protobuf';
import { PacketHighwayTrans } from '@/napcat-core/packet/highway/client';
import { PacketLogger } from '@/napcat-core/packet/context/loggerContext';
import * as proto from '@/napcat-core/packet/transformer/proto';
export abstract class IHighwayUploader {
readonly trans: PacketHighwayTrans;
readonly logger: PacketLogger;
constructor (trans: PacketHighwayTrans, logger: PacketLogger) {
this.trans = trans;
this.logger = logger;
}
// TODO: 没用到的加密方法先注释掉
// private encryptTransExt (key: Uint8Array) {
// if (!this.trans.encrypt) return;
// this.trans.ext = tea.encrypt(Buffer.from(this.trans.ext), Buffer.from(key));
// }
protected timeout (): Promise<void> {
return new Promise<void>((_resolve, reject) => {
setTimeout(() => {
reject(new Error(`[Highway] timeout after ${this.trans.timeout}s`));
}, (this.trans.timeout ?? Infinity) * 1000
);
});
}
buildPicUpHead (offset: number, bodyLength: number, bodyMd5: Uint8Array): Uint8Array {
return new NapProtoMsg(proto.ReqDataHighwayHead).encode({
msgBaseHead: {
version: 1,
uin: this.trans.uin,
command: 'PicUp.DataUp',
seq: 0,
retryTimes: 0,
appId: 1600001604,
dataFlag: 16,
commandId: this.trans.cmd,
},
msgSegHead: {
serviceId: 0,
filesize: BigInt(this.trans.size),
dataOffset: BigInt(offset),
dataLength: bodyLength,
serviceTicket: this.trans.ticket,
md5: bodyMd5,
fileMd5: this.trans.sum,
cacheAddr: 0,
cachePort: 0,
},
bytesReqExtendInfo: this.trans.ext,
timestamp: BigInt(0),
msgLoginSigHead: {
uint32LoginSigType: 8,
appId: 1600001604,
},
});
}
abstract upload (): Promise<void>;
}
@@ -0,0 +1,19 @@
import { NapProtoEncodeStructType } from 'napcat-protobuf';
import * as proto from '@/napcat-core/packet/transformer/proto';
export const int32ip2str = (ip: number) => {
ip = ip & 0xffffffff;
return [ip & 0xff, (ip & 0xff00) >> 8, (ip & 0xff0000) >> 16, ((ip & 0xff000000) >> 24) & 0xff].join('.');
};
export const oidbIpv4s2HighwayIpv4s = (ipv4s: NapProtoEncodeStructType<typeof proto.IPv4>[]): NapProtoEncodeStructType<typeof proto.NTHighwayIPv4>[] => {
return ipv4s.map((ip) => {
return {
domain: {
isEnable: true,
ip: int32ip2str(ip.outIP ?? 0),
},
port: ip.outPort!,
} as NapProtoEncodeStructType<typeof proto.NTHighwayIPv4>;
});
};