904 lines
32 KiB
TypeScript
904 lines
32 KiB
TypeScript
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;
|
||
}
|
||
}
|