NapCatQQ source snapshot
This commit is contained in:
commit
f954252a34
1111 files changed
+139867
No files matched your search
@@ -0,0 +1,903 @@
|
||||
import { RequestUtil } from 'napcat-common/src/request';
|
||||
import {
|
||||
GroupEssenceMsgRet,
|
||||
InstanceContext,
|
||||
WebApiGroupMember,
|
||||
WebApiGroupMemberRet,
|
||||
WebApiGroupNoticeRet,
|
||||
WebHonorType, NapCatCore,
|
||||
} from '@/napcat-core/index';
|
||||
|
||||
import { createReadStream, existsSync, statSync } from 'node:fs';
|
||||
import { open, unlink, writeFile, type FileHandle } from 'node:fs/promises';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { basename, join } from 'node:path';
|
||||
import { qunAlbumControl, qunAlbumVideoControl, qunAlbumVideoCoverControl, QunAlbumBatch, nextQunAlbumBatchId } from '../data/webapi';
|
||||
import { createAlbumCommentRequest, createAlbumFeedPublish, createAlbumMediaFeed } from '../data/album';
|
||||
import { FFmpegService } from '../helper/ffmpeg/ffmpeg';
|
||||
import { defaultVideoThumbB64 } from '../helper/ffmpeg/video';
|
||||
import {
|
||||
buildQzoneDeleteBody,
|
||||
buildQzonePublishBody,
|
||||
buildQzoneUploadImageBody,
|
||||
parseQzoneDeleteResponse,
|
||||
parseQzonePublishResponse,
|
||||
parseQzoneUploadImageResponseText,
|
||||
toQzoneRichval,
|
||||
QzoneDeleteResponse,
|
||||
QzonePublishResponse,
|
||||
} from '../data/qzone';
|
||||
export interface SetNoticeRetSuccess {
|
||||
ec: number;
|
||||
em: string;
|
||||
id: number;
|
||||
ltsm: number;
|
||||
new_fid: string;
|
||||
read_only: number;
|
||||
role: number;
|
||||
srv_code: number;
|
||||
}
|
||||
|
||||
// 群相册分片上传的并发数
|
||||
const QUN_ALBUM_SLICE_CONCURRENCY = 8;
|
||||
// 服务端没给分片大小时的下限,以及防止异常值的上限
|
||||
const QUN_ALBUM_SLICE_MIN = 16384;
|
||||
const QUN_ALBUM_SLICE_MAX = 4 * 1024 * 1024;
|
||||
|
||||
// 流式计算文件摘要,避免为了算 MD5/SHA1 把整个文件读进内存(视频可能有几十上百 MB)
|
||||
function hashFile (path: string, algorithm: 'md5' | 'sha1'): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = createHash(algorithm);
|
||||
createReadStream(path)
|
||||
.on('data', chunk => hash.update(chunk))
|
||||
.on('end', () => resolve(hash.digest('hex')))
|
||||
.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// 按需读取单个分片:并发上传时的内存占用只有「并发数 × 分片大小」
|
||||
async function readSlice (handle: FileHandle, offset: number, length: number): Promise<Buffer> {
|
||||
const buffer = Buffer.alloc(length);
|
||||
const { bytesRead } = await handle.read(buffer, 0, length, offset);
|
||||
// 读不满说明文件在上传过程中被改短了,继续传只会传上去一段补零的数据
|
||||
if (bytesRead !== length) {
|
||||
throw new Error(`读取分片失败: offset=${offset} 预期 ${length} 字节,实际 ${bytesRead} 字节`);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export class NTQQWebApi {
|
||||
context: InstanceContext;
|
||||
core: NapCatCore;
|
||||
|
||||
constructor (context: InstanceContext, core: NapCatCore) {
|
||||
this.context = context;
|
||||
this.core = core;
|
||||
}
|
||||
|
||||
async shareDigest (groupCode: string, msgSeq: string, msgRandom: string, targetGroupCode: string) {
|
||||
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
|
||||
const url = `https://qun.qq.com/cgi-bin/group_digest/share_digest?${new URLSearchParams({
|
||||
bkn: this.getBknFromCookie(cookieObject),
|
||||
group_code: groupCode,
|
||||
msg_seq: msgSeq,
|
||||
msg_random: msgRandom,
|
||||
target_group_code: targetGroupCode,
|
||||
}).toString()}`;
|
||||
try {
|
||||
return RequestUtil.HttpGetText(url, 'GET', '', { Cookie: this.cookieToString(cookieObject) });
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async getGroupEssenceMsgAll (GroupCode: string) {
|
||||
const ret: GroupEssenceMsgRet[] = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const data = await this.getGroupEssenceMsg(GroupCode, i, 50);
|
||||
if (!data) break;
|
||||
ret.push(data);
|
||||
if (data.data.is_end) break;
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
async getGroupEssenceMsg (GroupCode: string, page_start: number = 0, page_limit: number = 50) {
|
||||
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
|
||||
const url = `https://qun.qq.com/cgi-bin/group_digest/digest_list?${new URLSearchParams({
|
||||
bkn: this.getBknFromCookie(cookieObject),
|
||||
page_start: page_start.toString(),
|
||||
page_limit: page_limit.toString(),
|
||||
group_code: GroupCode,
|
||||
}).toString()}`;
|
||||
try {
|
||||
const ret = await RequestUtil.HttpGetJson<GroupEssenceMsgRet>(
|
||||
url,
|
||||
'GET',
|
||||
'',
|
||||
{ Cookie: this.cookieToString(cookieObject) }
|
||||
);
|
||||
return ret.retcode === 0 ? ret : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async getGroupMembers (GroupCode: string): Promise<WebApiGroupMember[]> {
|
||||
// logDebug('webapi 获取群成员', GroupCode);
|
||||
const memberData: Array<WebApiGroupMember> = new Array<WebApiGroupMember>();
|
||||
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
|
||||
const retList: Promise<WebApiGroupMemberRet>[] = [];
|
||||
const fastRet = await RequestUtil.HttpGetJson<WebApiGroupMemberRet>(
|
||||
`https://qun.qq.com/cgi-bin/qun_mgr/search_group_members?${new URLSearchParams({
|
||||
st: '0',
|
||||
end: '40',
|
||||
sort: '1',
|
||||
gc: GroupCode,
|
||||
bkn: this.getBknFromCookie(cookieObject),
|
||||
}).toString()}`,
|
||||
'POST',
|
||||
'',
|
||||
{ Cookie: this.cookieToString(cookieObject) }
|
||||
);
|
||||
if (!fastRet?.count || fastRet?.errcode !== 0 || !fastRet?.mems) {
|
||||
return [];
|
||||
} else {
|
||||
for (const key in fastRet.mems) {
|
||||
if (fastRet.mems[key]) {
|
||||
memberData.push(fastRet.mems[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 初始化获取PageNum
|
||||
const PageNum = Math.ceil(fastRet.count / 40);
|
||||
// 遍历批量请求
|
||||
for (let i = 2; i <= PageNum; i++) {
|
||||
const ret = RequestUtil.HttpGetJson<WebApiGroupMemberRet>(
|
||||
`https://qun.qq.com/cgi-bin/qun_mgr/search_group_members?${new URLSearchParams({
|
||||
st: ((i - 1) * 40).toString(),
|
||||
end: (i * 40).toString(),
|
||||
sort: '1',
|
||||
gc: GroupCode,
|
||||
bkn: this.getBknFromCookie(cookieObject),
|
||||
}).toString()}`,
|
||||
'POST',
|
||||
'',
|
||||
{ Cookie: this.cookieToString(cookieObject) }
|
||||
);
|
||||
retList.push(ret);
|
||||
}
|
||||
// 批量等待
|
||||
for (let i = 1; i <= PageNum; i++) {
|
||||
const ret = await (retList[i]);
|
||||
if (!ret?.count || ret?.errcode !== 0 || !ret?.mems) {
|
||||
continue;
|
||||
}
|
||||
for (const key in ret.mems) {
|
||||
if (ret.mems[key]) {
|
||||
memberData.push(ret.mems[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return memberData;
|
||||
}
|
||||
|
||||
// public async addGroupDigest(groupCode: string, msgSeq: string) {
|
||||
// const url = `https://qun.qq.com/cgi-bin/group_digest/cancel_digest?random=665&X-CROSS-ORIGIN=fetch&group_code=${groupCode}&msg_seq=${msgSeq}&msg_random=444021292`;
|
||||
// const res = await this.request(url);
|
||||
// return await res.json();
|
||||
// }
|
||||
|
||||
// public async getGroupDigest(groupCode: string) {
|
||||
// const url = `https://qun.qq.com/cgi-bin/group_digest/digest_list?random=665&X-CROSS-ORIGIN=fetch&group_code=${groupCode}&page_start=0&page_limit=20`;
|
||||
// const res = await this.request(url);
|
||||
// return await res.json();
|
||||
// }
|
||||
|
||||
async setGroupNotice (
|
||||
GroupCode: string,
|
||||
Content: string,
|
||||
pinned: number = 0,
|
||||
type: number = 1,
|
||||
is_show_edit_card: number = 1,
|
||||
tip_window_type: number = 1,
|
||||
confirm_required: number = 1,
|
||||
picId: string = '',
|
||||
imgWidth: number = 540,
|
||||
imgHeight: number = 300
|
||||
) {
|
||||
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
|
||||
|
||||
try {
|
||||
const settings = JSON.stringify({
|
||||
is_show_edit_card,
|
||||
tip_window_type,
|
||||
confirm_required,
|
||||
});
|
||||
const externalParam = {
|
||||
pic: picId,
|
||||
imgWidth: imgWidth.toString(),
|
||||
imgHeight: imgHeight.toString(),
|
||||
};
|
||||
const ret: SetNoticeRetSuccess = await RequestUtil.HttpGetJson<SetNoticeRetSuccess>(
|
||||
`https://web.qun.qq.com/cgi-bin/announce/add_qun_notice?${new URLSearchParams({
|
||||
bkn: this.getBknFromCookie(cookieObject),
|
||||
qid: GroupCode,
|
||||
text: Content,
|
||||
pinned: pinned.toString(),
|
||||
type: type.toString(),
|
||||
settings,
|
||||
...(picId === '' ? {} : externalParam),
|
||||
}).toString()}`,
|
||||
'POST',
|
||||
'',
|
||||
{ Cookie: this.cookieToString(cookieObject) }
|
||||
);
|
||||
return ret;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async getGroupNotice (GroupCode: string): Promise<undefined | WebApiGroupNoticeRet> {
|
||||
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
|
||||
try {
|
||||
const ret = await RequestUtil.HttpGetJson<WebApiGroupNoticeRet>(
|
||||
`https://web.qun.qq.com/cgi-bin/announce/get_t_list?${new URLSearchParams({
|
||||
bkn: this.getBknFromCookie(cookieObject),
|
||||
qid: GroupCode,
|
||||
ft: '23',
|
||||
ni: '1',
|
||||
n: '1',
|
||||
i: '1',
|
||||
log_read: '1',
|
||||
platform: '1',
|
||||
s: '-1',
|
||||
}).toString()}&n=20`,
|
||||
'GET',
|
||||
'',
|
||||
{ Cookie: this.cookieToString(cookieObject) }
|
||||
);
|
||||
return ret?.ec === 0 ? ret : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async getDataInternal (cookieObject: { [key: string]: string; }, groupCode: string, type: number) {
|
||||
let resJson;
|
||||
try {
|
||||
const res = await RequestUtil.HttpGetText(
|
||||
`https://qun.qq.com/interactive/honorlist?${new URLSearchParams({
|
||||
gc: groupCode,
|
||||
type: type.toString(),
|
||||
}).toString()}`,
|
||||
'GET',
|
||||
'',
|
||||
{ Cookie: this.cookieToString(cookieObject) }
|
||||
);
|
||||
const match = /window\.__INITIAL_STATE__=(.*?);/.exec(res);
|
||||
if (match?.[1]) {
|
||||
resJson = JSON.parse(match[1].trim());
|
||||
}
|
||||
return type === 1 ? resJson?.talkativeList : resJson?.actorList;
|
||||
} catch (e) {
|
||||
this.context.logger.logDebug('获取当前群荣耀失败', e);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async getHonorList (cookieObject: { [key: string]: string; }, groupCode: string, type: number) {
|
||||
const data = await this.getDataInternal(cookieObject, groupCode, type);
|
||||
if (!data) {
|
||||
this.context.logger.logError(`获取类型 ${type} 的荣誉信息失败`);
|
||||
return [];
|
||||
}
|
||||
return data.map((item: {
|
||||
uin: string,
|
||||
name: string,
|
||||
avatar: string,
|
||||
desc: string,
|
||||
}) => ({
|
||||
user_id: item?.uin,
|
||||
nickname: item?.name,
|
||||
avatar: item?.avatar,
|
||||
description: item?.desc,
|
||||
}));
|
||||
}
|
||||
|
||||
async getGroupHonorInfo (groupCode: string, getType: WebHonorType) {
|
||||
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
|
||||
const HonorInfo = {
|
||||
group_id: Number(groupCode),
|
||||
current_talkative: {},
|
||||
talkative_list: [],
|
||||
performer_list: [],
|
||||
legend_list: [],
|
||||
emotion_list: [],
|
||||
strong_newbie_list: [],
|
||||
};
|
||||
|
||||
if (getType === WebHonorType.TALKATIVE || getType === WebHonorType.ALL) {
|
||||
const talkativeList = await this.getHonorList(cookieObject, groupCode, 1);
|
||||
if (talkativeList.length > 0) {
|
||||
HonorInfo.current_talkative = talkativeList[0];
|
||||
HonorInfo.talkative_list = talkativeList;
|
||||
}
|
||||
}
|
||||
|
||||
if (getType === WebHonorType.PERFORMER || getType === WebHonorType.ALL) {
|
||||
HonorInfo.performer_list = await this.getHonorList(cookieObject, groupCode, 2);
|
||||
}
|
||||
|
||||
if (getType === WebHonorType.LEGEND || getType === WebHonorType.ALL) {
|
||||
HonorInfo.legend_list = await this.getHonorList(cookieObject, groupCode, 3);
|
||||
}
|
||||
|
||||
if (getType === WebHonorType.EMOTION || getType === WebHonorType.ALL) {
|
||||
HonorInfo.emotion_list = await this.getHonorList(cookieObject, groupCode, 6);
|
||||
}
|
||||
|
||||
// 冒尖小春笋好像已经被tx扬了 R.I.P.
|
||||
if (getType === WebHonorType.EMOTION || getType === WebHonorType.ALL) {
|
||||
HonorInfo.strong_newbie_list = [];
|
||||
}
|
||||
|
||||
return HonorInfo;
|
||||
}
|
||||
|
||||
private cookieToString (cookieObject: { [key: string]: string; }) {
|
||||
return Object.entries(cookieObject).map(([key, value]) => `${key}=${value}`).join('; ');
|
||||
}
|
||||
|
||||
public getBknFromCookie (cookieObject: { [key: string]: string; }) {
|
||||
const sKey = cookieObject['skey'] as string;
|
||||
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < sKey.length; i++) {
|
||||
const code = sKey.charCodeAt(i);
|
||||
hash = hash + (hash << 5) + code;
|
||||
}
|
||||
return (hash & 0x7FFFFFFF).toString();
|
||||
}
|
||||
|
||||
public getBknFromSKey (sKey: string) {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < sKey.length; i++) {
|
||||
const code = sKey.charCodeAt(i);
|
||||
hash = hash + (hash << 5) + code;
|
||||
}
|
||||
return (hash & 0x7FFFFFFF).toString();
|
||||
}
|
||||
|
||||
public getBknFromPSKey (psKey: string) {
|
||||
return this.getBknFromSKey(psKey);
|
||||
}
|
||||
|
||||
async getAlbumListByNTQQ (gc: string, attach_info: string = '') {
|
||||
return await this.context.session.getAlbumService().getAlbumList({
|
||||
qun_id: gc,
|
||||
attach_info,
|
||||
seq: 3331,
|
||||
request_time_line: {
|
||||
request_invoke_time: '0',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getAlbumList (gc: string) {
|
||||
const skey = await this.core.apis.UserApi.getSKey() || '';
|
||||
const pskey = (await this.core.apis.UserApi.getPSkey(['qzone.qq.com'])).domainPskeyMap.get('qzone.qq.com') || '';
|
||||
const bkn = this.getBknFromSKey(skey);
|
||||
const uin = this.core.selfInfo.uin || '10001';
|
||||
const cookies = `p_uin=o${this.core.selfInfo.uin}; p_skey=${pskey}; skey=${skey}; uin=o${uin} `;
|
||||
const api = 'https://h5.qzone.qq.com/proxy/domain/u.photo.qzone.qq.com/cgi-bin/upp/qun_list_album_v2?';
|
||||
const params = new URLSearchParams({
|
||||
random: '7570',
|
||||
g_tk: bkn,
|
||||
format: 'json',
|
||||
inCharset: 'utf-8',
|
||||
outCharset: 'utf-8',
|
||||
qua: 'V1_IPH_SQ_6.2.0_0_HDBM_T',
|
||||
cmd: 'qunGetAlbumList',
|
||||
qunId: gc,
|
||||
qunid: gc,
|
||||
start: '0',
|
||||
num: '1000',
|
||||
uin,
|
||||
getMemberRole: '0',
|
||||
});
|
||||
const response = await RequestUtil.HttpGetJson<{ data: { album: Array<{ id: string, title: string; }>; }; }>(api + params.toString(), 'GET', '', {
|
||||
Cookie: cookies,
|
||||
});
|
||||
return response.data.album;
|
||||
}
|
||||
|
||||
async createQunAlbumSession (
|
||||
gc: string, sAlbumID: string, sAlbumName: string, path: string,
|
||||
skey: string, pskey: string, img_md5: string, uin: string, batch?: QunAlbumBatch
|
||||
) {
|
||||
const img_size = statSync(path).size;
|
||||
const img_name = basename(path);
|
||||
const GTK = this.getBknFromSKey(skey);
|
||||
const cookie = `p_uin=o${uin}; p_skey=${pskey}; skey=${skey}; uin=o${uin}`;
|
||||
const body = qunAlbumControl({
|
||||
uin,
|
||||
group_id: gc,
|
||||
pskey,
|
||||
pic_md5: img_md5,
|
||||
img_size,
|
||||
img_name,
|
||||
sAlbumName,
|
||||
sAlbumID,
|
||||
// 同批多图共用 batchId,并按「第几张 / 共几张」上报,相册里才会合并成同一条
|
||||
batchId: batch?.batchId,
|
||||
batchIndex: batch?.index,
|
||||
photo_num: batch?.count,
|
||||
batch_num: batch?.count,
|
||||
});
|
||||
const api = `https://h5.qzone.qq.com/webapp/json/sliceUpload/FileBatchControl/${img_md5}?g_tk=${GTK}`;
|
||||
const post = await RequestUtil.HttpGetJson<{
|
||||
data: { session: string; slice_size?: number | string; },
|
||||
ret: number,
|
||||
msg: string;
|
||||
}>(api, 'POST', body, {
|
||||
Cookie: cookie,
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
return post;
|
||||
}
|
||||
|
||||
// 服务端会在会话响应里给出建议的分片大小;取不到或异常时退回 16384。
|
||||
private resolveQunAlbumSliceSize (raw: unknown) {
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value) || value < QUN_ALBUM_SLICE_MIN) return QUN_ALBUM_SLICE_MIN;
|
||||
return Math.min(value, QUN_ALBUM_SLICE_MAX);
|
||||
}
|
||||
|
||||
async uploadQunAlbumSlice (
|
||||
path: string, session: string, skey: string, pskey: string, uin: string, slice_size: number,
|
||||
options: { appid?: string, cmd?: string; } = {}
|
||||
) {
|
||||
const appid = options.appid ?? 'qun';
|
||||
const cmd = options.cmd ?? 'FileUpload';
|
||||
const img_size = statSync(path).size;
|
||||
const GTK = this.getBknFromSKey(skey);
|
||||
const cookie = `p_uin=o${uin}; p_skey=${pskey}; skey=${skey}; uin=o${uin}`;
|
||||
|
||||
// 只记分片位置,不预先读内容;再并发上传(串行上传在几 MB 的图上要几十秒)
|
||||
const slices: { seq: number, offset: number, end: number; }[] = [];
|
||||
let seq = 0;
|
||||
let offset = 0;
|
||||
while (offset < img_size) {
|
||||
const end = Math.min(offset + slice_size, img_size);
|
||||
slices.push({ seq, offset, end });
|
||||
offset = end;
|
||||
seq++;
|
||||
}
|
||||
|
||||
// 视频分片响应里会带 sVid,封面二段上传要靠它把封面挂到视频上
|
||||
let sVid: string | undefined;
|
||||
|
||||
const handle = await open(path, 'r');
|
||||
const uploadSlice = async (slice: { seq: number, offset: number, end: number; }) => {
|
||||
const chunk = await readSlice(handle, slice.offset, slice.end - slice.offset);
|
||||
const form = new FormData();
|
||||
form.append('uin', uin);
|
||||
form.append('appid', appid);
|
||||
form.append('session', session);
|
||||
form.append('offset', slice.offset.toString());
|
||||
form.append('data', new Blob([chunk], { type: 'application/octet-stream' }), 'blob');
|
||||
form.append('checksum', '');
|
||||
form.append('check_type', '0');
|
||||
form.append('retry', '0');
|
||||
form.append('seq', slice.seq.toString());
|
||||
form.append('end', slice.end.toString());
|
||||
form.append('cmd', cmd);
|
||||
form.append('slice_size', slice_size.toString());
|
||||
form.append('biz_req.iUploadType', '0');
|
||||
|
||||
// 分片路径必须与 cmd 一致:视频走 /sliceUpload/FileUploadVideo,
|
||||
// 否则服务端按图片处理,分片会「成功」但响应里没有 sVid
|
||||
const api = `https://h5.qzone.qq.com/webapp/json/sliceUpload/${cmd}?seq=${slice.seq}&retry=0&offset=${slice.offset}&end=${slice.end}&total=${img_size}&type=form&g_tk=${GTK}`;
|
||||
const response = await fetch(api, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const post = await response.json() as { ret: number, msg: string, data?: { biz?: { sVid?: string; }; }; };
|
||||
if (post.ret !== 0) {
|
||||
throw new Error(`分片 ${slice.seq} 上传失败: ${post.msg}`);
|
||||
}
|
||||
if (!sVid && typeof post.data?.biz?.sVid === 'string' && post.data.biz.sVid) {
|
||||
sVid = post.data.biz.sVid;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// 最后一片要等前面的全部传完再发:服务端收到最后一片就认为文件传完了,
|
||||
// 并发时它可能比前面的分片先到
|
||||
const lastSlice = slices.pop();
|
||||
let next = 0;
|
||||
let failure: { error: unknown; } | undefined;
|
||||
// 固定数量的 worker 各自领下一片,慢的分片不会拖住整批
|
||||
const worker = async () => {
|
||||
while (!failure) {
|
||||
const slice = slices[next++];
|
||||
if (!slice) break;
|
||||
try {
|
||||
await uploadSlice(slice);
|
||||
} catch (error) {
|
||||
if (!failure) failure = { error };
|
||||
}
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(QUN_ALBUM_SLICE_CONCURRENCY, slices.length) }, worker));
|
||||
if (failure) throw failure.error;
|
||||
if (lastSlice) await uploadSlice(lastSlice);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
|
||||
return { success: true, message: '上传完成', sVid };
|
||||
}
|
||||
|
||||
// 多张图片一次上传:共用同一个 batchId,相册里显示为同一条
|
||||
async uploadImagesToQunAlbum (gc: string, sAlbumID: string, sAlbumName: string, paths: string[]) {
|
||||
if (!paths.length) throw new Error('上传列表为空');
|
||||
const skey = await this.core.apis.UserApi.getSKey() || '';
|
||||
const pskey = (await this.core.apis.UserApi.getPSkey(['qzone.qq.com'])).domainPskeyMap.get('qzone.qq.com') || '';
|
||||
const uin = this.core.selfInfo.uin || '10001';
|
||||
const batchId = nextQunAlbumBatchId();
|
||||
const count = paths.length.toString();
|
||||
|
||||
for (const [index, path] of paths.entries()) {
|
||||
const img_md5 = await hashFile(path, 'md5');
|
||||
const post = await this.createQunAlbumSession(
|
||||
gc, sAlbumID, sAlbumName, path, skey, pskey, img_md5, uin,
|
||||
{ batchId, count, index: index.toString() }
|
||||
);
|
||||
if (!post.data?.session) throw new Error(`创建群相册会话失败: ${post.msg || post.ret}`);
|
||||
await this.uploadQunAlbumSlice(path, post.data.session, skey, pskey, uin, this.resolveQunAlbumSliceSize(post.data.slice_size));
|
||||
}
|
||||
}
|
||||
|
||||
async uploadImageToQunAlbum (gc: string, sAlbumID: string, sAlbumName: string, path: string) {
|
||||
await this.uploadImagesToQunAlbum(gc, sAlbumID, sAlbumName, [path]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传视频到群相册。
|
||||
*
|
||||
* 分三步:
|
||||
* 1. 建视频会话:appid=video_qun / cmd=FileUploadVideo / SHA1 校验,此时不带相册
|
||||
* 2. 分片上传视频,从分片响应里拿 sVid
|
||||
* 3. 按图片上传封面并用 vid 关联视频,相册和批次号在这一步带上,视频随封面进相册
|
||||
*/
|
||||
async uploadVideoToQunAlbum (gc: string, sAlbumID: string, sAlbumName: string, path: string) {
|
||||
const skey = await this.core.apis.UserApi.getSKey() || '';
|
||||
const pskey = (await this.core.apis.UserApi.getPSkey(['qzone.qq.com'])).domainPskeyMap.get('qzone.qq.com') || '';
|
||||
const uin = this.core.selfInfo.uin || '10001';
|
||||
const GTK = this.getBknFromSKey(skey);
|
||||
const cookie = `p_uin=o${uin}; p_skey=${pskey}; skey=${skey}; uin=o${uin}`;
|
||||
const video_sha1 = await hashFile(path, 'sha1');
|
||||
|
||||
// 时长:ffmpeg 不可用时上报 0
|
||||
let playTime = 0;
|
||||
try {
|
||||
playTime = Math.max(0, Math.round(await FFmpegService.getDuration(path)));
|
||||
} catch (e) {
|
||||
this.context.logger.logWarn('获取视频时长失败,iPlayTime 上报 0', e);
|
||||
}
|
||||
|
||||
// 1) 视频主体(不带相册,只传成素材)
|
||||
const videoSession = await RequestUtil.HttpGetJson<{
|
||||
data: { session: string; slice_size?: number | string; },
|
||||
ret: number,
|
||||
msg: string;
|
||||
}>(`https://h5.qzone.qq.com/webapp/json/sliceUpload/FileBatchControl/${video_sha1}?g_tk=${GTK}`, 'POST', qunAlbumVideoControl({
|
||||
uin,
|
||||
group_id: gc,
|
||||
pskey,
|
||||
video_sha1,
|
||||
video_size: statSync(path).size,
|
||||
play_time: playTime,
|
||||
}), {
|
||||
Cookie: cookie,
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
if (!videoSession.data?.session) throw new Error(`创建群相册视频会话失败: ${videoSession.msg || videoSession.ret}`);
|
||||
|
||||
const uploaded = await this.uploadQunAlbumSlice(path, videoSession.data.session, skey, pskey, uin,
|
||||
this.resolveQunAlbumSliceSize(videoSession.data.slice_size), { appid: 'video_qun', cmd: 'FileUploadVideo' });
|
||||
if (!uploaded.sVid) throw new Error('视频上传完成但未取得 sVid,无法挂进相册');
|
||||
|
||||
// 2) 封面:抽首帧,失败(含 ffmpeg 不可用)回落内置默认封面
|
||||
// 用 .png:Native Addon 路径产出的就是 png(日志里可见 Detected format: png),
|
||||
// 而命令行路径会按扩展名编码,两条路都能得到真正的 PNG
|
||||
const coverPath = join(this.core.NapCatTempPath, `${randomUUID()}.png`);
|
||||
try {
|
||||
try {
|
||||
await FFmpegService.extractThumbnail(path, coverPath);
|
||||
} catch (e) {
|
||||
this.context.logger.logWarn('提取视频封面失败,使用默认封面', e);
|
||||
await writeFile(coverPath, Buffer.from(defaultVideoThumbB64, 'base64'));
|
||||
}
|
||||
|
||||
// 3) 封面二段上传:这一步才带相册和批次号,视频靠它进相册
|
||||
const cover_md5 = await hashFile(coverPath, 'md5');
|
||||
const coverSession = await RequestUtil.HttpGetJson<{
|
||||
data: { session: string; slice_size?: number | string; },
|
||||
ret: number,
|
||||
msg: string;
|
||||
}>(`https://h5.qzone.qq.com/webapp/json/sliceUpload/FileBatchControl/${cover_md5}?g_tk=${GTK}`, 'POST', qunAlbumVideoCoverControl({
|
||||
uin,
|
||||
group_id: gc,
|
||||
pskey,
|
||||
sAlbumName,
|
||||
sAlbumID,
|
||||
cover_md5,
|
||||
cover_size: statSync(coverPath).size,
|
||||
vid: uploaded.sVid,
|
||||
}), {
|
||||
Cookie: cookie,
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
if (!coverSession.data?.session) throw new Error(`创建群相册视频封面会话失败: ${coverSession.msg || coverSession.ret}`);
|
||||
|
||||
await this.uploadQunAlbumSlice(coverPath, coverSession.data.session, skey, pskey, uin,
|
||||
this.resolveQunAlbumSliceSize(coverSession.data.slice_size));
|
||||
} finally {
|
||||
if (existsSync(coverPath)) {
|
||||
await unlink(coverPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getAlbumMediaListByNTQQ (gc: string, albumId: string, attach_info: string = '') {
|
||||
return (await this.context.session.getAlbumService().getMediaList({
|
||||
qun_id: gc,
|
||||
attach_info,
|
||||
seq: 0,
|
||||
request_time_line: {
|
||||
request_invoke_time: '0',
|
||||
},
|
||||
album_id: albumId,
|
||||
lloc: '',
|
||||
batch_id: '',
|
||||
})).response;
|
||||
}
|
||||
|
||||
async doAlbumMediaPlainCommentByNTQQ (
|
||||
qunId: string,
|
||||
albumId: string,
|
||||
lloc: string | undefined,
|
||||
content: string,
|
||||
batchId: string = '') {
|
||||
if (!lloc && !batchId) {
|
||||
throw new Error('lloc 或 batch_id 至少需要提供一个');
|
||||
}
|
||||
const random_seq = Math.floor(Math.random() * 9000) + 1000;
|
||||
const uin = this.core.selfInfo.uin || '10001';
|
||||
const albumService = this.context.session.getAlbumService();
|
||||
let feed;
|
||||
if (lloc) {
|
||||
feed = createAlbumMediaFeed(uin, albumId, lloc, batchId);
|
||||
} else {
|
||||
// Batch comments need the server's feed identity and uploader, not an
|
||||
// empty media cell with the commenter substituted for the feed owner.
|
||||
const { response } = await albumService.getQunFeedDetail({
|
||||
seq: random_seq,
|
||||
request_time_line: { request_invoke_time: '0' },
|
||||
ext_info: { map_info: [], map_bytes_info: [], map_user_account: [] },
|
||||
qun_id: qunId,
|
||||
feed_id: '',
|
||||
comment_count: 20,
|
||||
attach_info: '',
|
||||
album_id: albumId,
|
||||
batch_id: batchId,
|
||||
lloc: '',
|
||||
});
|
||||
if (response?.result !== 0) {
|
||||
throw new Error(`获取群相册批次动态失败, ${response?.result ?? 'unknown'}:${response?.errMs || '未知错误'}`);
|
||||
}
|
||||
feed = response.feed?.feed;
|
||||
if (!feed?.cell_common?.feed_id || !feed.cell_user_info?.user || !feed.cell_media) {
|
||||
throw new Error('获取群相册批次动态失败: 未返回有效动态');
|
||||
}
|
||||
}
|
||||
// 16位number数字
|
||||
const client_key = Date.now() * 1000;
|
||||
const result = await albumService.doQunComment(
|
||||
random_seq, {
|
||||
map_info: [],
|
||||
map_bytes_info: [],
|
||||
map_user_account: [],
|
||||
},
|
||||
qunId,
|
||||
2,
|
||||
feed,
|
||||
createAlbumCommentRequest(uin, content, client_key)
|
||||
);
|
||||
if (result?.result !== 0) {
|
||||
throw new Error(`群相册评论失败, ${result?.result ?? 'unknown'}:${result?.errMs || '未知错误'}`);
|
||||
}
|
||||
if (!result.comment?.id) {
|
||||
throw new Error('群相册评论失败: 未返回有效评论');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async deleteAlbumMediaByNTQQ (
|
||||
qunId: string,
|
||||
albumId: string,
|
||||
lloc: string) {
|
||||
const random_seq = Math.floor(Math.random() * 9000) + 1000;
|
||||
return await this.context.session.getAlbumService().deleteMedias(
|
||||
random_seq,
|
||||
qunId,
|
||||
albumId,
|
||||
[lloc],
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
async doAlbumMediaLikeByNTQQ (
|
||||
qunId: string,
|
||||
albumId: string,
|
||||
batchId: string,
|
||||
lloc: string | undefined,
|
||||
isLike: boolean
|
||||
) {
|
||||
const random_seq = Math.floor(Math.random() * 9000) + 1000;
|
||||
const uin = this.core.selfInfo.uin || '10001';
|
||||
|
||||
const type = isLike ? 2 : 1;
|
||||
const status = isLike ? 0 : 1;
|
||||
|
||||
let id = '';
|
||||
if (lloc) {
|
||||
id = `421_1_0_${qunId}|${albumId}|${batchId}^||^421_1_0_${qunId}|${albumId}|${lloc}^||^0`;
|
||||
} else {
|
||||
id = `421_1_0_${qunId}|${albumId}|${batchId}`;
|
||||
}
|
||||
|
||||
return await this.context.session.getAlbumService().doQunLike(
|
||||
random_seq,
|
||||
{
|
||||
map_info: [],
|
||||
map_bytes_info: [],
|
||||
map_user_account: [],
|
||||
},
|
||||
type,
|
||||
{
|
||||
id,
|
||||
status,
|
||||
},
|
||||
createAlbumFeedPublish(qunId, uin, albumId, batchId)
|
||||
);
|
||||
}
|
||||
|
||||
private async getQzoneAuth () {
|
||||
const uin = this.core.selfInfo.uin;
|
||||
const uid = this.core.selfInfo.uid;
|
||||
if (!uin || !uid) throw new Error('QQ空间需要已登录的当前账号');
|
||||
const assertAccount = () => {
|
||||
if (this.core.selfInfo.uin !== uin || this.core.selfInfo.uid !== uid) throw new Error('QQ账号已切换,请重新确认QQ空间操作');
|
||||
};
|
||||
const skey = await this.core.apis.UserApi.getSKey() || '';
|
||||
assertAccount();
|
||||
const pskey = (await this.core.apis.UserApi.getPSkey(['qzone.qq.com'])).domainPskeyMap.get('qzone.qq.com') || '';
|
||||
assertAccount();
|
||||
const g_tk = this.getBknFromSKey(skey);
|
||||
const cookie = `p_uin=o${uin}; p_skey=${pskey}; skey=${skey}; uin=o${uin}`;
|
||||
return { skey, pskey, uin, g_tk, cookie };
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传图片到 QQ 空间相册, 返回可用于发说说的 richval 字符串
|
||||
* @param base64 图片Base64编码内容(不带data:前缀)
|
||||
*/
|
||||
async uploadImageToQzone (base64: string): Promise<string> {
|
||||
const { skey, pskey, uin, g_tk, cookie } = await this.getQzoneAuth();
|
||||
const body = buildQzoneUploadImageBody({ uin, skey, pskey, g_tk, base64 });
|
||||
const api = `https://up.qzone.qq.com/cgi-bin/upload/cgi_upload_image?g_tk=${g_tk}`;
|
||||
const resultText = await RequestUtil.HttpGetText(api, 'POST', body, {
|
||||
Cookie: cookie,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
});
|
||||
const parsed = parseQzoneUploadImageResponseText(resultText);
|
||||
if (parsed.code !== undefined && parsed.code !== 0) {
|
||||
throw new Error(parsed.msg || `QQ空间图片上传失败, code=${parsed.code}`);
|
||||
}
|
||||
if (!parsed.data) {
|
||||
throw new Error('QQ空间图片上传失败, 响应缺少data字段');
|
||||
}
|
||||
return toQzoneRichval(parsed.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发表QQ空间说说
|
||||
* @param content 说说正文
|
||||
* @param richvals 每张图片对应的richval数组, 为空表示纯文字说说
|
||||
* @param ugcRight 查看权限 1所有人可见 4好友可见 16部分好友可见 64仅自己可见 128部分好友不可见
|
||||
* @param targetUins 权限作用QQ号数组, ugcRight为16/128时使用
|
||||
*/
|
||||
async publishQzoneMsg (content: string, richvals: string[], ugcRight: number, targetUins?: Array<string | number>) {
|
||||
const { uin, g_tk, cookie } = await this.getQzoneAuth();
|
||||
const richval = richvals.length > 0 ? richvals.join('\t') : undefined;
|
||||
const allowUins = targetUins && targetUins.length > 0 ? targetUins.join('|') : undefined;
|
||||
const body = buildQzonePublishBody({
|
||||
hostuin: uin,
|
||||
content,
|
||||
richval,
|
||||
ugcRight,
|
||||
allowUins,
|
||||
});
|
||||
const api = `https://user.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_publish_v6?g_tk=${g_tk}`;
|
||||
const result = await RequestUtil.HttpGetJson<QzonePublishResponse>(api, 'POST', body, {
|
||||
Cookie: cookie,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
}, true, false);
|
||||
return parseQzonePublishResponse(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除QQ空间说说
|
||||
* @param tid 说说Tid, 来自 publishQzoneMsg 的返回值
|
||||
*/
|
||||
async deleteQzoneMsg (tid: string) {
|
||||
const { uin, g_tk, cookie } = await this.getQzoneAuth();
|
||||
const body = buildQzoneDeleteBody({ hostuin: uin, tid });
|
||||
const api = `https://user.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_delete_v6?g_tk=${g_tk}`;
|
||||
const result = await RequestUtil.HttpGetJson<QzoneDeleteResponse>(api, 'POST', body, {
|
||||
Cookie: cookie,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
}, true, false);
|
||||
return parseQzoneDeleteResponse(result);
|
||||
}
|
||||
|
||||
async getDaySignedList (groupCode: string) {
|
||||
const pSkey = (await this.core.apis.UserApi.getPSkey(['qun.qq.com'])).domainPskeyMap.get('qun.qq.com')!;
|
||||
const selfUin = this.core.selfInfo.uin;
|
||||
const cookie = `p_uin=o${selfUin}; p_skey=${pSkey}; uin=o${selfUin}`;
|
||||
const post = await RequestUtil.HttpGetJson<{
|
||||
retCode: number,
|
||||
costTime: number,
|
||||
response: {
|
||||
ret?: {
|
||||
code: string,
|
||||
msg: string,
|
||||
},
|
||||
page?: {
|
||||
infos?: {
|
||||
uid: string,
|
||||
uidGroupNick: string,
|
||||
signedTimeStamp: string,
|
||||
signInRank: number,
|
||||
}[],
|
||||
offset: number,
|
||||
total: number,
|
||||
}[],
|
||||
},
|
||||
funcCode: number,
|
||||
}>(`https://qun.qq.com/v2/signin/trpc/GetDaySignedList?g_tk=${this.getBknFromPSKey(pSkey)}`, 'POST', {
|
||||
dayYmd: new Date().toISOString().slice(0, 10).replace(/-/g, ''),
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
uid: selfUin,
|
||||
groupId: groupCode,
|
||||
}, {
|
||||
Cookie: cookie,
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
return post;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user