SnowCat 雪猫底层全源码:NapCat 4.18.28 底座 + SnowLuma 能力移植 + V8 通道适配 9.9.37

26 包 monorepo(TS 全源码):
- snowcat-core: NT 内核 API 封装 + listener(含 9.9.37 六回调适配)+ Packet 发包
- snowcat-onebot: OneBot v11 全动作 + MCP Streamable HTTP 适配器 + 稳定线
- snowcat-webui-frontend/backend: 雪猫控制台(React+HeroUI)
- snowcat-shell: 单文件 bundle 出口(vite inlineDynamicImports)
- snowcat-native: napi2native / MoeHoo / ffmpeg / pty 运行时
- 56 项 SnowLuma 独门动作移植(qzone 全家桶/群相册/装扮/闪照/收藏)
- 工具件: 解包器/启动器/便携制作器/doctor

GitHub 关联已全量指向 DandanLLab/SnowCatQQ
This commit is contained in:
天天的鸟蛋蛋 committed 2026-11-12 06:27:36 +08:00
commit b9655a5ab1
1048 files changed
+130851

No files matched your search

+60
View File
@@ -0,0 +1,60 @@
import { InstanceContext, SnowCatCore } from '@/snowcat-core/index';
export class NTQQCollectionApi {
context: InstanceContext;
core: SnowCatCore;
constructor (context: InstanceContext, core: SnowCatCore) {
this.context = context;
this.core = core;
}
async createCollection (authorUin: string, authorUid: string, authorName: string, brief: string, rawData: string) {
return this.context.session.getCollectionService().createNewCollectionItem({
commInfo: {
bid: 1,
category: 2,
author: {
type: 1,
numId: authorUin,
strId: authorName,
groupId: '0',
groupName: '',
uid: authorUid,
},
customGroupId: '0',
createTime: Date.now().toString(),
sequence: Date.now().toString(),
},
richMediaSummary: {
originalUri: '',
publisher: '',
richMediaVersion: 0,
subTitle: '',
title: '',
brief,
picList: [],
contentType: 1,
},
richMediaContent: {
rawData,
bizDataList: [],
picList: [],
fileList: [],
},
need_share_url: false,
});
}
async getAllCollection (category: number = 0, count: number = 50) {
return this.context.session.getCollectionService().getCollectionItemList({
category,
groupId: -1,
forceSync: true,
forceFromDb: false,
timeStamp: '0',
count,
searchDown: true,
});
}
}
+201
View File
@@ -0,0 +1,201 @@
import path from 'node:path';
import { InstanceContext, SnowCatCore } from '@/snowcat-core/index';
import {
checkSqliteAvailable,
DatabaseHandle,
openDatabase,
decryptAndOpen,
readSingleDatabase,
scanAllDatabases,
formatScanResults,
decryptDatabaseFile,
type DatabaseScanResult,
type DatabaseReadOptions,
type TableInfo,
} from 'snowcat-database';
type SQLInputValue = import('node:sqlite').SQLInputValue;
export class NTQQDatabaseApi {
context: InstanceContext;
core: SnowCatCore;
constructor (context: InstanceContext, core: SnowCatCore) {
this.context = context;
this.core = core;
}
/** node:sqlite 是否可用 */
async isSqliteAvailable (): Promise<boolean> {
return checkSqliteAvailable();
}
/** 获取当前账号的 nt_db 目录 */
getNtDbDir (): string {
return path.resolve(this.core.dataPath, this.core.selfInfo.uin, 'nt_qq', 'nt_db');
}
/**
* 获取解密缓存目录
* 默认: <SnowCatDataPath>/db_<uin>/
* 可通过 customDir 完全自定义
*/
getDefaultCacheDir (customDir?: string): string {
if (customDir) return customDir;
return path.resolve(this.core.SnowCatDataPath, `db_${this.core.selfInfo.uin}`);
}
/** 当前是否已获取到数据库 passphrase */
hasPassphrase (): boolean {
return !!this.core.dbPassphrase;
}
/** 获取 passphrase Buffer,未获取时返回 null */
getPassphraseBuffer (): Buffer | null {
return this.core.dbPassphrase ? Buffer.from(this.core.dbPassphrase) : null;
}
// ======================== SQLite 数据库操作 ========================
/**
* 打开一个已解密的 SQLite 数据库文件
* @param filePath 解密后 .db 文件路径
* @param readOnly 是否只读 (默认 true)
* @returns DatabaseHandle 实例,使用完毕后需调用 .close()
*/
openDecryptedDb (filePath: string, readOnly = true): DatabaseHandle {
return openDatabase(filePath, readOnly);
}
/**
* 解密并打开一个 NTQQ 加密数据库
* @param dbName 数据库文件名 (如 'nt_msg.db') 或完整路径
* @param readOnly 是否只读 (默认 true)
* @param cacheDir 自定义缓存目录,默认 db_<uin>
* @returns DatabaseHandle 实例,使用完毕后需调用 .close()
*/
openDatabase (dbName: string, readOnly = true, cacheDir?: string): DatabaseHandle | null {
const passphrase = this.getPassphraseBuffer();
if (!passphrase) return null;
const dbPath = path.isAbsolute(dbName) ? dbName : path.resolve(this.getNtDbDir(), dbName);
const resolvedCacheDir = this.getDefaultCacheDir(cacheDir);
return decryptAndOpen(dbPath, passphrase, resolvedCacheDir, readOnly);
}
/**
* 对加密数据库执行查询并自动关闭连接
* @param dbName 数据库文件名或完整路径
* @param sql SQL 查询语句
* @param params 绑定参数
* @returns 查询结果行数组,passphrase 不可用时返回 null
*/
query<T = Record<string, unknown>> (
dbName: string,
sql: string,
params?: SQLInputValue[] | Record<string, SQLInputValue>
): T[] | null {
const db = this.openDatabase(dbName);
if (!db) return null;
try {
return db.query<T>(sql, params);
} finally {
db.close();
}
}
/**
* 对加密数据库执行查询,仅返回第一行
* @param dbName 数据库文件名或完整路径
* @param sql SQL 查询语句
* @param params 绑定参数
*/
queryOne<T = Record<string, unknown>> (
dbName: string,
sql: string,
params?: SQLInputValue[] | Record<string, SQLInputValue>
): T | undefined | null {
const db = this.openDatabase(dbName);
if (!db) return null;
try {
return db.queryOne<T>(sql, params);
} finally {
db.close();
}
}
/**
* 对加密数据库执行非查询 SQL (INSERT/UPDATE/DELETE 等) 并自动关闭
* @param dbName 数据库文件名或完整路径
* @param sql SQL 语句
* @param params 绑定参数
*/
execute (
dbName: string,
sql: string,
params?: SQLInputValue[] | Record<string, SQLInputValue>
): { changes: number | bigint; lastInsertRowid: number | bigint; } | null {
const db = this.openDatabase(dbName, false);
if (!db) return null;
try {
return db.execute(sql, params);
} finally {
db.close();
}
}
// ======================== 批量扫描 ========================
/**
* 读取单个加密数据库的表信息
* @param dbName 数据库文件名 (如 'nt_msg.db'),或完整路径
* @param cacheDir 自定义缓存目录,默认 db_<uin>
* @param options 其他选项
*/
readDatabase (dbName: string, cacheDir?: string, options?: Omit<DatabaseReadOptions, 'cacheDir'>): DatabaseScanResult | null {
const passphrase = this.getPassphraseBuffer();
if (!passphrase) return null;
const dbPath = path.isAbsolute(dbName) ? dbName : path.resolve(this.getNtDbDir(), dbName);
const resolvedCacheDir = this.getDefaultCacheDir(cacheDir);
return readSingleDatabase(dbPath, passphrase, { cacheDir: resolvedCacheDir, ...options });
}
/**
* 扫描所有加密数据库
* @param cacheDir 自定义缓存目录,默认 db_<uin>
* @param options 其他选项
*/
scanDatabases (cacheDir?: string, options?: Omit<DatabaseReadOptions, 'cacheDir'>): DatabaseScanResult[] {
const passphrase = this.getPassphraseBuffer();
if (!passphrase) return [];
const resolvedCacheDir = this.getDefaultCacheDir(cacheDir);
return scanAllDatabases(this.getNtDbDir(), passphrase, { cacheDir: resolvedCacheDir, ...options });
}
/**
* 仅解密数据库文件(不读取表信息,不需要 node:sqlite)
* @param dbName 数据库文件名或完整路径
* @param outputPath 输出路径,默认保存到 db_<uin>/<dbName>
*/
decryptDatabase (dbName: string, outputPath?: string): string | null {
const passphrase = this.getPassphraseBuffer();
if (!passphrase) return null;
const dbPath = path.isAbsolute(dbName) ? dbName : path.resolve(this.getNtDbDir(), dbName);
const outPath = outputPath ?? path.resolve(this.getDefaultCacheDir(), path.basename(dbName));
return decryptDatabaseFile(dbPath, passphrase, outPath);
}
/** 格式化扫描结果为可读字符串 */
formatResults (results: DatabaseScanResult[]): string {
return formatScanResults(results);
}
}
export { DatabaseHandle };
export type { DatabaseScanResult, DatabaseReadOptions, TableInfo };
+390
View File
@@ -0,0 +1,390 @@
import {
ChatType,
ElementType,
IMAGE_HTTP_HOST,
IMAGE_HTTP_HOST_NT,
Peer,
PicElement,
RawMessage,
} from '@/snowcat-core/types';
import path from 'path';
import fs from 'fs';
import fsPromises from 'fs/promises';
import { InstanceContext, SnowCatCore, SearchResultItem } from '@/snowcat-core/index';
import { fileTypeFromFile } from 'file-type';
import { RkeyManager } from '@/snowcat-core/helper/rkey';
import { calculateFileMD5 } from 'snowcat-common/src/file';
import { rkeyDataType } from '../types/file';
import { NapProtoMsg } from 'snowcat-protobuf';
import { FileId } from '../packet/transformer/proto/misc/fileid';
export class NTQQFileApi {
context: InstanceContext;
core: SnowCatCore;
rkeyManager: RkeyManager;
packetRkey: Array<{ rkey: string; time: number; type: number; ttl: bigint; }> | undefined;
private fetchRkeyFailures: number = 0;
private readonly MAX_RKEY_FAILURES: number = 8;
constructor (context: InstanceContext, core: SnowCatCore) {
this.context = context;
this.core = core;
this.rkeyManager = new RkeyManager([
'http://ss.xingzhige.com/music_card/rkey',
'https://secret-service.bietiaop.com/rkeys',
],
this.context.logger
);
}
private async fetchRkeyWithRetry () {
if (this.fetchRkeyFailures >= this.MAX_RKEY_FAILURES) {
throw new Error('Native.FetchRkey 已被禁用');
}
try {
const ret = await this.core.apis.PacketApi.pkt.operation.FetchRkey();
this.fetchRkeyFailures = 0; // Reset failures on success
return ret;
} catch (error) {
this.fetchRkeyFailures++;
this.context.logger.logError('FetchRkey 失败', (error as Error).message);
throw error;
}
}
async getFileUrl (chatType: ChatType, peer: string, fileUUID?: string, file10MMd5?: string | undefined, timeout: number = 5000) {
if (this.core.apis.PacketApi.packetStatus) {
try {
if (chatType === ChatType.KCHATTYPEGROUP && fileUUID) {
return this.core.apis.PacketApi.pkt.operation.GetGroupFileUrl(+peer, fileUUID, timeout);
} else if (file10MMd5 && fileUUID) {
return this.core.apis.PacketApi.pkt.operation.GetPrivateFileUrl(peer, fileUUID, file10MMd5, timeout);
}
} catch (error) {
this.context.logger.logError('获取文件URL失败', (error as Error).message);
}
}
throw new Error('fileUUID or file10MMd5 is undefined');
}
async getPttUrl (peer: string, fileUUID?: string, timeout: number = 5000) {
if (this.core.apis.PacketApi.packetStatus && fileUUID) {
const appid = new NapProtoMsg(FileId).decode(Buffer.from(fileUUID.replaceAll('-', '+').replaceAll('_', '/'), 'base64')).appid;
try {
if (appid && appid === 1403) {
return this.core.apis.PacketApi.pkt.operation.GetGroupPttUrl(+peer, {
fileUuid: fileUUID,
storeId: 1,
uploadTime: 0,
ttl: 0,
subType: 0,
}, timeout);
} else if (fileUUID) {
return this.core.apis.PacketApi.pkt.operation.GetPttUrl(peer, {
fileUuid: fileUUID,
storeId: 1,
uploadTime: 0,
ttl: 0,
subType: 0,
}, timeout);
}
} catch (error) {
this.context.logger.logError('获取文件URL失败', (error as Error).message);
}
}
throw new Error('packet cant get ptt url');
}
async getVideoUrlPacket (peer: string, fileUUID?: string, timeout: number = 5000) {
if (this.core.apis.PacketApi.packetStatus && fileUUID) {
const appid = new NapProtoMsg(FileId).decode(Buffer.from(fileUUID.replaceAll('-', '+').replaceAll('_', '/'), 'base64')).appid;
try {
if (appid && appid === 1415) {
return this.core.apis.PacketApi.pkt.operation.GetGroupVideoUrl(+peer, {
fileUuid: fileUUID,
storeId: 1,
uploadTime: 0,
ttl: 0,
subType: 0,
}, timeout);
} else if (fileUUID) {
return this.core.apis.PacketApi.pkt.operation.GetVideoUrl(peer, {
fileUuid: fileUUID,
storeId: 1,
uploadTime: 0,
ttl: 0,
subType: 0,
}, timeout);
}
} catch (error) {
this.context.logger.logError('获取文件URL失败', (error as Error).message);
}
}
throw new Error('packet cant get video url');
}
async copyFile (filePath: string, destPath: string) {
await this.core.util.copyFile(filePath, destPath);
}
async getFileSize (filePath: string): Promise<number> {
return await this.core.util.getFileSize(filePath);
}
async getVideoUrl (peer: Peer, msgId: string, elementId: string) {
return (await this.context.session.getRichMediaService().getVideoPlayUrlV2(peer, msgId, elementId, 0, {
downSourceType: 1,
triggerType: 1,
})).urlResult.domainUrl;
}
async uploadFile (filePath: string, elementType: ElementType = ElementType.PIC, elementSubType: number = 0, uploadGroupFile = true) {
const fileMd5 = await calculateFileMD5(filePath);
const extOrEmpty = await fileTypeFromFile(filePath).then(e => e?.ext ?? '').catch(() => '');
const ext = extOrEmpty ? `.${extOrEmpty}` : '';
let fileName = `${path.basename(filePath)}`;
if (fileName.indexOf('.') === -1) {
fileName += ext;
}
const fileSize = await this.getFileSize(filePath);
if (uploadGroupFile) {
const mediaPath = this.context.session.getMsgService().getRichMediaFilePathForGuild({
md5HexStr: fileMd5,
fileName,
elementType,
elementSubType,
thumbSize: 0,
needCreate: true,
downloadType: 1,
file_uuid: '',
});
await this.copyFile(filePath, mediaPath);
return {
md5: fileMd5,
fileName,
path: mediaPath,
fileSize,
ext,
};
}
return {
md5: fileMd5,
fileName,
path: filePath,
fileSize,
ext,
};
}
async downloadFileForModelId (peer: Peer, modelId: string, unknown: string, timeout = 1000 * 60 * 2) {
const [, fileTransNotifyInfo] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelRichMediaService/downloadFileForModelId',
'NodeIKernelMsgListener/onRichMediaDownloadComplete',
[peer, [modelId], unknown],
() => true,
(arg) => arg?.commonFileInfo?.fileModelId === modelId,
1,
timeout
);
return fileTransNotifyInfo.filePath;
}
async downloadRawMsgMedia (msg: RawMessage[]) {
const res = await Promise.all(
msg.map(m =>
Promise.all(
m.elements
.filter(element =>
element.elementType === ElementType.PIC ||
element.elementType === ElementType.VIDEO ||
element.elementType === ElementType.PTT ||
element.elementType === ElementType.FILE
)
.map(element =>
this.downloadMedia(m.msgId, m.chatType, m.peerUid, element.elementId, '', '', 1000 * 60 * 2, true)
)
)
)
);
msg.forEach((m, msgIndex) => {
const elementResults = res[msgIndex];
let elementIndex = 0;
m.elements.forEach(element => {
if (
element.elementType === ElementType.PIC ||
element.elementType === ElementType.VIDEO ||
element.elementType === ElementType.PTT ||
element.elementType === ElementType.FILE
) {
switch (element.elementType) {
case ElementType.PIC:
element.picElement!.sourcePath = elementResults?.[elementIndex] ?? '';
break;
case ElementType.VIDEO:
element.videoElement!.filePath = elementResults?.[elementIndex] ?? '';
break;
case ElementType.PTT:
element.pttElement!.filePath = elementResults?.[elementIndex] ?? '';
break;
case ElementType.FILE:
element.fileElement!.filePath = elementResults?.[elementIndex] ?? '';
break;
}
elementIndex++;
}
});
});
return res.flat();
}
async downloadMedia (msgId: string, chatType: ChatType, peerUid: string, elementId: string, thumbPath: string, sourcePath: string, timeout = 1000 * 60 * 2, force: boolean = false) {
// 用于下载文件
if (sourcePath && fs.existsSync(sourcePath)) {
if (force) {
try {
await fsPromises.unlink(sourcePath);
} catch {
//
}
} else {
return sourcePath;
}
}
const [, completeRetData] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelMsgService/downloadRichMedia',
'NodeIKernelMsgListener/onRichMediaDownloadComplete',
[{
fileModelId: '0',
downSourceType: 0,
downloadSourceType: 0,
triggerType: 1,
msgId,
chatType,
peerUid,
elementId,
thumbSize: 0,
downloadType: 1,
filePath: thumbPath,
}],
() => true,
(arg) => arg.msgElementId === elementId && arg.msgId === msgId,
1,
timeout
);
return completeRetData.filePath;
}
async searchForFile (keys: string[]): Promise<SearchResultItem | undefined> {
const randomResultId = 100000 + Math.floor(Math.random() * 10000);
let searchId = 0;
const [, searchResult] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelFileAssistantService/searchFile',
'NodeIKernelFileAssistantListener/onFileSearch',
[
keys,
{ resultType: 2, pageLimit: 1 },
randomResultId,
],
(ret) => {
searchId = ret;
return true;
},
result => result.searchId === searchId && result.resultId === randomResultId
);
return searchResult.resultItems[0];
}
async downloadFileById (
fileId: string,
fileSize: number = 1024576,
estimatedTime: number = (fileSize * 1000 / 1024576) + 5000
) {
const [, fileData] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelFileAssistantService/downloadFile',
'NodeIKernelFileAssistantListener/onFileStatusChanged',
[[fileId]],
ret => ret.result === 0,
status => status.fileStatus === 2 && status.fileProgress === '0',
1,
estimatedTime // estimate 1MB/s
);
return fileData.filePath!;
}
async getImageUrl (element: PicElement): Promise<string> {
if (!element) {
return '';
}
const url: string = element.originImageUrl ?? '';
const md5HexStr = element.md5HexStr;
const fileMd5 = element.md5HexStr;
const parsedUrl = new URL(IMAGE_HTTP_HOST + url);
const imageAppid = parsedUrl.searchParams.get('appid');
const isNTV2 = imageAppid && ['1406', '1407'].includes(imageAppid);
const imageFileId = parsedUrl.searchParams.get('fileid');
if (url && isNTV2 && imageFileId) {
const rkeyData = await this.getRkeyData();
return this.getImageUrlFromParsedUrl(imageFileId, imageAppid, rkeyData);
}
return this.getImageUrlFromMd5(fileMd5, md5HexStr);
}
private async getRkeyData () {
const rkeyData: rkeyDataType = {
private_rkey: 'CAQSKAB6JWENi5LM_xp9vumLbuThJSaYf-yzMrbZsuq7Uz2qEc3Rbib9LP4',
group_rkey: 'CAQSKAB6JWENi5LM_xp9vumLbuThJSaYf-yzMrbZsuq7Uz2qffcqm614gds',
online_rkey: false,
};
try {
if (this.core.apis.PacketApi.packetStatus) {
const rkey_expired_private = !this.packetRkey || (this.packetRkey[0] && this.packetRkey[0].time + Number(this.packetRkey[0].ttl) < Date.now() / 1000);
const rkey_expired_group = !this.packetRkey || (this.packetRkey[0] && this.packetRkey[0].time + Number(this.packetRkey[0].ttl) < Date.now() / 1000);
if (rkey_expired_private || rkey_expired_group) {
this.packetRkey = await this.fetchRkeyWithRetry();
}
if (this.packetRkey && this.packetRkey.length > 0) {
rkeyData.group_rkey = this.packetRkey[1]?.rkey.slice(6) ?? '';
rkeyData.private_rkey = this.packetRkey[0]?.rkey.slice(6) ?? '';
rkeyData.online_rkey = true;
}
}
} catch (error: unknown) {
this.context.logger.logDebug('获取native.rkey失败', (error as Error).message);
}
if (!rkeyData.online_rkey) {
try {
const tempRkeyData = await this.rkeyManager.getRkey();
rkeyData.group_rkey = tempRkeyData.group_rkey;
rkeyData.private_rkey = tempRkeyData.private_rkey;
rkeyData.online_rkey = tempRkeyData.expired_time > Date.now() / 1000;
} catch (error: unknown) {
this.context.logger.logDebug('获取remote.rkey失败', (error as Error).message);
}
}
// 进行 fallback.rkey 模式
return rkeyData;
}
private getImageUrlFromParsedUrl (imageFileId: string, appid: string, rkeyData: rkeyDataType): string {
const rkey = appid === '1406' ? rkeyData.private_rkey : rkeyData.group_rkey;
if (rkeyData.online_rkey) {
return IMAGE_HTTP_HOST_NT + `/download?appid=${appid}&fileid=${imageFileId}&rkey=${rkey}`;
}
return IMAGE_HTTP_HOST + `/download?appid=${appid}&fileid=${imageFileId}&rkey=${rkey}&spec=0`;
}
private getImageUrlFromMd5 (fileMd5: string | undefined, md5HexStr: string | undefined): string {
if (fileMd5 || md5HexStr) {
return `${IMAGE_HTTP_HOST}/gchatpic_new/0/0-0-${(fileMd5 ?? md5HexStr ?? '').toUpperCase()}/0`;
}
this.context.logger.logDebug('图片url获取失败', { fileMd5, md5HexStr });
return '';
}
}
+307
View File
@@ -0,0 +1,307 @@
import { GeneralCallResult, InstanceContext, SnowCatCore } from '@/snowcat-core';
import {
createFlashTransferResult,
FileListResponse,
FlashFileSetInfo,
SendStatus,
UploadSceneType,
} from '@/snowcat-core/data/flash';
import { Peer } from '@/snowcat-core/types';
export class NTQQFlashApi {
context: InstanceContext;
core: SnowCatCore;
constructor (context: InstanceContext, core: SnowCatCore) {
this.context = context;
this.core = core;
}
/**
* 发起闪传上传任务
* @param fileListToUpload 上传文件绝对路径的列表,可以是文件夹!!
* @param thumbnailPath
* @param filesetName
*/
async createFlashTransferUploadTask (fileListToUpload: string[], thumbnailPath: string, filesetName: string): Promise<GeneralCallResult & {
createFlashTransferResult: createFlashTransferResult;
seq: number;
}> {
const flashService = this.context.session.getFlashTransferService();
const timestamp: number = Date.now();
const selfInfo = this.core.selfInfo;
const fileUploadArg = {
screen: 1, // 1
name: filesetName,
uploaders: [{
uin: selfInfo.uin,
uid: selfInfo.uid,
sendEntrance: '',
nickname: selfInfo.nick,
}],
coverPath: thumbnailPath,
paths: fileListToUpload,
excludePaths: [],
expireLeftTime: 0,
isNeedDelDeviceInfo: false,
isNeedDelLocation: false,
coverOriginalInfos: [
{
path: fileListToUpload[0] || '',
thumbnailPath,
},
],
uploadSceneType: UploadSceneType.KUPLOADSCENEAIOFILESELECTOR, // 不知道怎么枚举 先硬编码吧 (PC QQ 10)
detectPrivacyInfoResult: {
exists: false,
allDetectResults: new Map(),
},
};
const uploadResult = await flashService.createFlashTransferUploadTask(timestamp, fileUploadArg);
if (uploadResult.result === 0) {
this.context.logger.log('[Flash] 发起闪传任务成功');
return uploadResult;
} else {
this.context.logger.logError('[Flash] 发起闪传上传任务失败!!');
return uploadResult;
}
}
/**
* 下载闪传文件集
* @param fileSetId
*/
async downloadFileSetBySetId (fileSetId: string): Promise<GeneralCallResult & {
extraInfo: unknown;
}> {
const flashService = this.context.session.getFlashTransferService();
const result = await flashService.startFileSetDownload(fileSetId, 1, { isIncludeCompressInnerFiles: false }); // 为了方便,暂时硬编码
if (result.result === 0) {
this.context.logger.log('[Flash] 成功开始下载文件集');
} else {
this.context.logger.logError('[Flash] 尝试下载文件集失败!');
}
return result;
}
/**
* 获取闪传的外链分享
* @param fileSetId
*/
async getShareLinkBySetId (fileSetId: string): Promise<GeneralCallResult & {
shareLink: string;
expireTimestamp: string;
}> {
const flashService = this.context.session.getFlashTransferService();
const result = await flashService.getShareLinkReq(fileSetId);
if (result.result === 0) {
this.context.logger.log('[Flash] 获取闪传外链分享成功:', result.shareLink);
} else {
this.context.logger.logError('[Flash] 获取闪传外链失败!!');
}
return result;
}
/**
* 从分享外链获取文件集id
* @param shareCode
*/
async fromShareLinkFindSetId (shareCode: string): Promise<GeneralCallResult & {
fileSetId: string;
}> {
const flashService = this.context.session.getFlashTransferService();
const result = await flashService.getFileSetIdByCode(shareCode);
if (result.result === 0) {
this.context.logger.log('[Flash] 获取shareCode的文件集Id成功!');
} else {
this.context.logger.logError('[Flash] 获取文件集ID失败!!');
}
return result;
}
/**
* 获取fileSet的文件结构信息 (未来可能需要深度遍历)
* == 注意返回结构和其它的不同,没有GeneralCallResult!!! ==
* @param fileSetId
*/
async getFileListBySetId (fileSetId: string): Promise<FileListResponse> {
const flashService = this.context.session.getFlashTransferService();
const requestArg = {
seq: 0,
fileSetId,
isUseCache: false,
sceneType: 1, // 硬编码
reqInfos: [
{
count: 18, // 18 ??
paginationInfo: {},
parentId: '',
reqIndexPath: '',
reqDepth: 1,
filterCondition: {
fileCategory: 0,
filterType: 0,
},
sortConditions: [
{
sortField: 0,
sortOrder: 0,
},
],
isNeedPhysicalInfoReady: false,
},
],
};
const result = await flashService.getFileList(requestArg);
if (result.rsp.result === 0) {
this.context.logger.log('[Flash] 获取fileSet文件信息成功!');
return result.rsp;
} else {
this.context.logger.logError(`[Flash] 获取文件信息失败:ErrMsg: ${result.rsp.errMs}`);
return result.rsp;
}
}
/**
* 获取闪传文件集合信息
* @param fileSetId
*/
async getFileSetIndoBySetId (fileSetId: string): Promise<GeneralCallResult & {
seq: number;
isCache: boolean;
fileSet: FlashFileSetInfo;
}> {
const flashService = this.context.session.getFlashTransferService();
const requestArg = {
fileSetId,
};
const result = await flashService.getFileSet(requestArg);
if (result.result === 0) {
this.context.logger.log('[Flash] 获取闪传文件集信息成功!');
} else {
this.context.logger.logError('[Flash] 获取闪传文件信息失败!!');
}
return result;
}
/**
* 发送闪传消息(私聊/群聊)
* @param fileSetId
* @param peer
*/
async sendFlashMessage (fileSetId: string, peer: Peer): Promise<{
errCode: number,
errMsg: string,
rsp: {
sendStatus: SendStatus[];
};
}> {
const flashService = this.context.session.getFlashTransferService();
const target = {
destUid: peer.peerUid,
destType: peer.chatType,
// destUin: peer.peerUin,
};
const requestsArg = {
fileSetId,
targets: [target],
};
const result = await flashService.sendFlashTransferMsg(requestsArg);
if (result.errCode === 0) {
this.context.logger.log('[Flash] 消息发送成功');
} else {
this.context.logger.logError(`[Flash] 消息发送失败!!原因:${result.errMsg}`);
}
return result;
}
/**
* 获取闪传文件集中某个文件的下载URL(外链)
* @param fileSetId
* @param options
*/
async getFileTransUrl (fileSetId: string, options: { fileName?: string; fileIndex?: number; }): Promise<GeneralCallResult & {
transferUrl: string;
}> {
const flashService = this.context.session.getFlashTransferService();
const result = await this.getFileListBySetId(fileSetId);
const { fileName, fileIndex } = options;
let targetFile: any;
let file: any;
const allFolder = result.fileLists;
// eslint-disable-next-line no-labels
searchLoop: for (const folder of allFolder) {
const fileList = folder.fileList;
for (let i = 0; i < fileList.length; i++) {
file = fileList[i];
if (fileName !== undefined && file.name === fileName) {
targetFile = file;
// eslint-disable-next-line no-labels
break searchLoop;
}
if (fileIndex !== undefined && i === fileIndex) {
targetFile = file;
// eslint-disable-next-line no-labels
break searchLoop;
}
}
}
if (targetFile === undefined) {
this.context.logger.logError('[Flash] 未找到对应文件!!');
return {
result: -1,
errMsg: '未找到对应文件',
transferUrl: '',
};
} else {
this.context.logger.log('[Flash] 找到对应文件,准备尝试获取传输链接');
const res = await flashService.startFileTransferUrl(targetFile);
return {
result: 0,
errMsg: '',
transferUrl: res.url,
};
}
}
async createFileThumbnail (filePath: string): Promise<any> {
const msgService = this.context.session.getMsgService();
const savePath = msgService.getFileThumbSavePathForSend(750, true);
const result = await this.core.util.createThumbnailImage(
'flashtransfer',
filePath,
savePath,
{
width: 520,
height: 520,
},
'jpeg',
null
);
if (result.result === 0) {
this.context.logger.log('获取缩略图成功!!');
result.targetPath = savePath;
return result;
}
return result;
}
}
+133
View File
@@ -0,0 +1,133 @@
import { FriendRequest, FriendV2 } from '@/snowcat-core/types';
import { BuddyListReqType, InstanceContext, SnowCatCore } from '@/snowcat-core/index';
import { LimitedHashTable } from 'snowcat-common/src/message-unique';
export class NTQQFriendApi {
context: InstanceContext;
core: SnowCatCore;
constructor (context: InstanceContext, core: SnowCatCore) {
this.context = context;
this.core = core;
}
async setBuddyRemark (uid: string, remark: string) {
return this.context.session.getBuddyService().setBuddyRemark({ uid, remark });
}
async getBuddyV2SimpleInfoMap () {
const buddyService = this.context.session.getBuddyService();
let uids: string[] = [];
if (this.core.context.basicInfoWrapper.requireMinNTQQBuild('41679')) {
const buddyListV2NT = await buddyService.getBuddyListV2('0', true, BuddyListReqType.KNOMAL);
uids = buddyListV2NT.data.flatMap(item => item.buddyUids);
} else {
const buddyListV2 = await buddyService.getBuddyListV2('0', BuddyListReqType.KNOMAL);
uids = buddyListV2.data.flatMap(item => item.buddyUids);
}
return await this.core.eventWrapper.callNoListenerEvent(
'NodeIKernelProfileService/getCoreAndBaseInfo',
'nodeStore',
uids
);
}
async getBuddy (): Promise<FriendV2[]> {
return Array.from((await this.getBuddyV2SimpleInfoMap()).values());
}
async getBuddyIdMap (): Promise<LimitedHashTable<string, string>> {
const retMap: LimitedHashTable<string, string> = new LimitedHashTable<string, string>(5000);
const data = await this.getBuddyV2SimpleInfoMap();
data.forEach((value) => retMap.set(value.uin!, value.uid!));
return retMap;
}
async delBuddy (uid: string, tempBlock = false, tempBothDel = false) {
return this.context.session.getBuddyService().delBuddy({
friendUid: uid,
tempBlock,
tempBothDel,
});
}
async getBuddyV2ExWithCate () {
const buddyService = this.context.session.getBuddyService();
let uids: string[] = [];
let buddyListV2: Awaited<ReturnType<typeof buddyService.getBuddyListV2>>['data'];
if (this.core.context.basicInfoWrapper.requireMinNTQQBuild('41679')) {
buddyListV2 = (await buddyService.getBuddyListV2('0', true, BuddyListReqType.KNOMAL)).data;
uids = buddyListV2.flatMap(item => item.buddyUids);
} else {
buddyListV2 = (await buddyService.getBuddyListV2('0', BuddyListReqType.KNOMAL)).data;
uids = buddyListV2.flatMap(item => item.buddyUids);
}
const data = await this.core.eventWrapper.callNoListenerEvent(
'NodeIKernelProfileService/getCoreAndBaseInfo',
'nodeStore',
uids
);
return buddyListV2.map(category => ({
categoryId: category.categoryId,
categorySortId: category.categorySortId,
categoryName: category.categroyName,
categoryMbCount: category.categroyMbCount,
onlineCount: category.onlineCount,
buddyList: category.buddyUids.map(uid => data.get(uid)).filter(value => !!value),
}));
}
async isBuddy (uid: string) {
return this.context.session.getBuddyService().isBuddy(uid);
}
async clearBuddyReqUnreadCnt () {
return this.context.session.getBuddyService().clearBuddyReqUnreadCnt();
}
async getBuddyReq () {
const [, ret] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelBuddyService/getBuddyReq',
'NodeIKernelBuddyListener/onBuddyReqChange',
[]
);
return ret;
}
async handleFriendRequest (notify: FriendRequest, accept: boolean) {
this.context.session.getBuddyService()?.approvalFriendRequest({
friendUid: notify.friendUid,
reqTime: notify.reqTime,
accept,
});
}
async handleDoubtFriendRequest (friendUid: string, str1: string = '', str2: string = '') {
this.context.session.getBuddyService().approvalDoubtBuddyReq(friendUid, str1, str2);
}
async getDoubtFriendRequest (count: number) {
const date = Date.now().toString();
const [, ret] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelBuddyService/getDoubtBuddyReq',
'NodeIKernelBuddyListener/onDoubtBuddyReqChange',
[date, count, ''],
() => true,
(data) => data.reqId === date
);
const requests = Promise.all(ret.doubtList.map(async (item) => {
return {
flag: item.uid, // 注意强制String 非isNumeric 不遵守则不符合设计
uin: await this.core.apis.UserApi.getUinByUidV2(item.uid) ?? 0, // 信息字段
nick: item.nick, // 信息字段 这个不是nickname 可能是来源的群内的昵称
source: item.source, // 信息字段
reason: item.reason, // 信息字段
msg: item.msg, // 信息字段
group_code: item.groupCode, // 信息字段
time: item.reqTime, // 信息字段
type: 'doubt', // 保留字段
};
}));
return requests;
}
}
+626
View File
@@ -0,0 +1,626 @@
import {
GeneralCallResult,
GroupMember,
NTGroupMemberRole,
NTGroupRequestOperateTypes,
InstanceContext,
KickMemberV2Req,
MemberExtSourceType,
SnowCatCore,
GroupNotify,
GroupInfoSource,
ShutUpGroupMember,
Peer,
ChatType,
GroupManagementSettings,
} from '@/snowcat-core/index';
import { isNumeric, solveAsyncProblem } from 'snowcat-common/src/helper';
import { LimitedHashTable } from 'snowcat-common/src/message-unique';
import { CancelableTask, TaskExecutor } from 'snowcat-common/src/cancel-task';
import {
createGroupDetailInfoV2Param,
createGroupExtFilter,
createGroupExtInfo,
createGroupManagementRequests,
} from '../data';
import { NTEventWrapper } from '../helper/event';
export class NTQQGroupApi {
context: InstanceContext;
core: SnowCatCore;
groupMemberCache: Map<string, Map<string, GroupMember>> = new Map<string, Map<string, GroupMember>>();
essenceLRU = new LimitedHashTable<number, string>(1000);
// psKey 滑动缓存,TTL 60 秒,惰性删除(不主动清理,等下次访问检测到过期再刷新)
private _qunPskeyCache: { value: string; expireAt: number; } | null = null;
constructor (context: InstanceContext, core: SnowCatCore) {
this.context = context;
this.core = core;
}
/** 获取 qun.qq.com 域名的 psKey,滑动 TTL 60 秒(每次访问顺延) */
private async getQunPskey (): Promise<string> {
const now = Date.now();
if (this._qunPskeyCache && this._qunPskeyCache.expireAt > now) {
// 滑动 TTL:每次访问都顺延过期时间
this._qunPskeyCache.expireAt = now + 60 * 1000;
return this._qunPskeyCache.value;
}
// 过期或为空,惰性删除旧值(直接覆盖)并重新获取
const psKey = (await this.core.apis.UserApi.getPSkey(['qun.qq.com'])).domainPskeyMap.get('qun.qq.com')!;
this._qunPskeyCache = { value: psKey, expireAt: now + 60 * 1000 };
return psKey;
}
async setGroupRemark (groupCode: string, remark: string) {
return this.context.session.getGroupService().modifyGroupRemark(groupCode, remark);
}
async fetchGroupDetail (groupCode: string) {
const [, detailInfo] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelGroupService/getGroupDetailInfo',
'NodeIKernelGroupListener/onGroupDetailInfoChange',
[groupCode, GroupInfoSource.KDATACARD],
(ret) => ret.result === 0,
(detailInfo) => detailInfo.groupCode === groupCode,
1,
5000
);
return detailInfo;
}
async initApi () {
this.initCache().then().catch(e => this.context.logger.logError(e));
}
async createGrayTip (groupCode: string, tip: string) {
return this.context.session.getMsgService().addLocalJsonGrayTipMsg(
{
chatType: ChatType.KCHATTYPEGROUP,
peerUid: groupCode,
} as Peer,
{
busiId: 2201,
jsonStr: JSON.stringify({ align: 'center', items: [{ txt: tip, type: 'nor' }] }),
recentAbstract: tip,
isServer: false,
},
true,
true
);
}
async initCache () {
for (const group of await this.getGroups(true)) {
this.refreshGroupMemberCache(group.groupCode, false).then().catch(e => this.context.logger.logError(e));
}
}
async fetchGroupEssenceList (groupCode: string) {
const pskey = await this.getQunPskey();
return this.context.session.getGroupService().fetchGroupEssenceList({
groupCode,
pageStart: 0,
pageLimit: 300,
}, pskey);
}
async getGroupShutUpMemberList (groupCode: string): Promise<ShutUpGroupMember[]> {
const executor: TaskExecutor<ShutUpGroupMember[]> = async (resolve, reject, onCancel) => {
this.core.eventWrapper.registerListen(
'NodeIKernelGroupListener/onShutUpMemberListChanged',
(group_id) => group_id === groupCode,
1,
1000
).then((data) => {
resolve(data[1]);
}).catch(reject);
onCancel(() => {
reject(new Error('Task was canceled'));
});
};
const task = new CancelableTask(executor);
this.context.session.getGroupService().getGroupShutUpMemberList(groupCode).then(e => {
if (e.result !== 0) {
task.cancel();
}
});
return await task.catch(() => []);
}
async clearGroupNotifiesUnreadCount (doubt: boolean) {
return this.context.session.getGroupService().clearGroupNotifiesUnreadCount(doubt);
}
async setGroupAvatar (groupCode: string, filePath: string) {
return this.context.session.getGroupService().setHeader(groupCode, filePath);
}
// 0 0 无需管理员审核
// 0 2 需要管理员审核
// 1 2 禁止Bot入群( 最好只传一个1 ?)
async setGroupRobotAddOption (groupCode: string, robotMemberSwitch?: number, robotMemberExamine?: number) {
const extInfo = createGroupExtInfo(groupCode);
const groupExtFilter = createGroupExtFilter();
if (robotMemberSwitch !== undefined) {
extInfo.extInfo.inviteRobotMemberSwitch = robotMemberSwitch;
groupExtFilter.inviteRobotMemberSwitch = 1;
}
if (robotMemberExamine !== undefined) {
extInfo.extInfo.inviteRobotMemberExamine = robotMemberExamine;
groupExtFilter.inviteRobotMemberExamine = 1;
}
return this.context.session.getGroupService().modifyGroupExtInfoV2(extInfo, groupExtFilter);
}
async setGroupAddOption (groupCode: string, option: {
addOption: number;
groupQuestion?: string;
groupAnswer?: string;
}) {
const param = createGroupDetailInfoV2Param(groupCode);
// 设置要修改的目标
param.filter.addOption = 1;
if (option.addOption === 4 || option.addOption === 5) {
// 4 问题进入答案 5 问题管理员批准
param.filter.groupQuestion = 1;
param.filter.groupAnswer = option.addOption === 4 ? 1 : 0;
param.modifyInfo.groupQuestion = option.groupQuestion || '';
param.modifyInfo.groupAnswer = option.addOption === 4 ? option.groupAnswer || '' : '';
}
param.modifyInfo.addOption = option.addOption;
return this.context.session.getGroupService().modifyGroupDetailInfoV2(param, 0);
}
async setGroupMemberInvitePolicy (
groupCode: string,
policy: NonNullable<GroupManagementSettings['memberInvite']>
) {
return this.modifyGroupManagementSettings(groupCode, { memberInvite: policy });
}
async setGroupMemberPermissions (
groupCode: string,
permissions: Pick<
GroupManagementSettings,
'allowMemberUploadAlbum' | 'allowMemberTemporarySession' | 'allowMemberCreateGroup'
>
) {
return this.modifyGroupManagementSettings(groupCode, permissions);
}
async setGroupNewMemberHistoryVisibility (groupCode: string, visible: boolean) {
return this.modifyGroupManagementSettings(groupCode, { newMembersSeeRecentHistory: visible });
}
private async modifyGroupManagementSettings (groupCode: string, settings: GroupManagementSettings) {
const results: Array<{ setting: string, result: GeneralCallResult; }> = [];
const changesKnownFlags = settings.memberInvite !== undefined ||
settings.allowMemberUploadAlbum !== undefined ||
settings.allowMemberTemporarySession !== undefined ||
settings.allowMemberCreateGroup !== undefined ||
settings.newMembersSeeRecentHistory !== undefined;
const currentDetail = changesKnownFlags ? await this.fetchGroupDetail(groupCode) : undefined;
for (const request of createGroupManagementRequests(
groupCode,
settings,
currentDetail?.privilegeFlag,
currentDetail?.groupFlagExt4
)) {
results.push({
setting: request.setting,
result: await this.context.session.getGroupService().modifyGroupDetailInfoV2(
request.param,
request.operationType
),
});
}
return results;
}
async setGroupSearch (groupCode: string, option: {
noCodeFingerOpenFlag?: number;
noFingerOpenFlag?: number;
}) {
const param = createGroupDetailInfoV2Param(groupCode);
if (option.noCodeFingerOpenFlag) {
param.filter.noCodeFingerOpenFlag = 1;
param.modifyInfo.noCodeFingerOpenFlag = option.noCodeFingerOpenFlag;
}
if (option.noFingerOpenFlag) {
param.filter.noFingerOpenFlag = 1;
param.modifyInfo.noFingerOpenFlag = option.noFingerOpenFlag;
}
return this.context.session.getGroupService().modifyGroupDetailInfoV2(param, 0);
}
async getGroups (forced: boolean = false) {
const [, , groupList] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelGroupService/getGroupList',
'NodeIKernelGroupListener/onGroupListUpdate',
[forced]
);
return groupList;
}
async getGroupExtFE0Info (groupCodes: Array<string>, forced = true) {
return this.context.session.getGroupService().getGroupExt0xEF0Info(
groupCodes,
[],
{
bindGuildId: 1,
blacklistExpireTime: 1,
companyId: 1,
essentialMsgPrivilege: 1,
essentialMsgSwitch: 1,
fullGroupExpansionSeq: 1,
fullGroupExpansionSwitch: 1,
gangUpId: 1,
groupAioBindGuildId: 1,
groupBindGuildIds: 1,
groupBindGuildSwitch: 1,
groupExcludeGuildIds: 1,
groupExtFlameData: 1,
groupFlagPro1: 1,
groupInfoExtSeq: 1,
groupOwnerId: 1,
groupSquareSwitch: 1,
hasGroupCustomPortrait: 1,
inviteRobotMemberExamine: 1,
inviteRobotMemberSwitch: 1,
inviteRobotSwitch: 1,
isLimitGroupRtc: 1,
lightCharNum: 1,
luckyWord: 1,
luckyWordId: 1,
msgEventSeq: 1,
qqMusicMedalSwitch: 1,
reserve: 1,
showPlayTogetherSwitch: 1,
starId: 1,
todoSeq: 1,
viewedMsgDisappearTime: 1,
},
forced
);
}
async getGroupMemberAll (groupCode: string, forced = false) {
return this.context.session.getGroupService().getAllMemberList(groupCode, forced);
}
async refreshGroupMemberCache (groupCode: string, isWait = true) {
const updateCache = async () => {
try {
const members = await this.getGroupMemberAll(groupCode, true);
this.groupMemberCache.set(groupCode, members.result.infos);
} catch (e) {
this.context.logger.logError(`刷新群成员缓存失败, 群号: ${groupCode}, 错误: ${e}`);
}
};
if (isWait) {
await updateCache();
} else {
updateCache();
}
return this.groupMemberCache.get(groupCode);
}
async refreshGroupMemberCachePartial (groupCode: string, uid: string) {
const member = await this.getGroupMemberEx(groupCode, uid, true);
if (member) {
this.groupMemberCache.get(groupCode)?.set(uid, member);
}
return member;
}
async getGroupMember (groupCode: string | number, memberUinOrUid: string | number) {
const groupCodeStr = groupCode.toString();
const memberUinOrUidStr = memberUinOrUid.toString();
// 获取群成员缓存
let members = this.groupMemberCache.get(groupCodeStr);
if (!members) {
members = (await this.refreshGroupMemberCache(groupCodeStr, true));
}
const getMember = () => {
if (isNumeric(memberUinOrUidStr)) {
return Array.from(members!.values()).find(member => member.uin === memberUinOrUidStr);
} else {
return members!.get(memberUinOrUidStr);
}
};
let member = getMember();
// 如果缓存中不存在该成员,尝试刷新缓存
if (!member) {
members = (await this.refreshGroupMemberCache(groupCodeStr, true));
member = getMember();
}
return member;
}
async getGroupRecommendContactArkJson (groupCode: string) {
return this.context.session.getGroupService().getGroupRecommendContactArkJson(groupCode);
}
async getJoinGroupLink (groupCode: string, needShortUrl = true, srcId = 73, additionalParam = '') {
return this.context.session.getGroupService().getJoinGroupLink({
groupCode,
srcId,
needShortUrl,
additionalParam,
});
}
async creatGroupFileFolder (groupCode: string, folderName: string) {
return this.context.session.getRichMediaService().createGroupFolder(groupCode, folderName);
}
async delGroupFile (groupCode: string, files: Array<string>) {
return this.context.session.getRichMediaService().deleteGroupFile(groupCode, [102], files);
}
async delGroupFileFolder (groupCode: string, folderId: string) {
return this.context.session.getRichMediaService().deleteGroupFolder(groupCode, folderId);
}
async transGroupFile (groupCode: string, fileId: string) {
return this.context.session.getRichMediaService().transGroupFile(groupCode, fileId);
}
async addGroupEssence (groupCode: string, msgId: string) {
const MsgData = await this.context.session.getMsgService().getMsgsIncludeSelf({
chatType: 2,
guildId: '',
peerUid: groupCode,
}, msgId, 1, false);
if (!MsgData.msgList[0]) {
throw new Error('消息不存在');
}
const param = {
groupCode,
msgRandom: parseInt(MsgData.msgList[0].msgRandom),
msgSeq: parseInt(MsgData.msgList[0].msgSeq),
};
return this.context.session.getGroupService().addGroupEssence(param);
}
async kickMemberV2Inner (param: KickMemberV2Req) {
return this.context.session.getGroupService().kickMemberV2(param);
}
async deleteGroupBulletin (groupCode: string, noticeId: string) {
const psKey = await this.getQunPskey();
return this.context.session.getGroupService().deleteGroupBulletin(groupCode, psKey, noticeId);
}
async quitGroupV2 (GroupCode: string, needDeleteLocalMsg: boolean) {
const param = {
groupCode: GroupCode,
needDeleteLocalMsg,
};
return this.context.session.getGroupService().quitGroupV2(param);
}
async removeGroupEssenceBySeq (groupCode: string, msgRandom: string, msgSeq: string) {
const param = {
groupCode,
msgRandom: parseInt(msgRandom),
msgSeq: parseInt(msgSeq),
};
return this.context.session.getGroupService().removeGroupEssence(param);
}
async removeGroupEssence (groupCode: string, msgId: string) {
const MsgData = await this.context.session.getMsgService().getMsgsIncludeSelf({
chatType: 2,
guildId: '',
peerUid: groupCode,
}, msgId, 1, false);
if (!MsgData.msgList[0]) {
throw new Error('消息不存在');
}
const param = {
groupCode,
msgRandom: parseInt(MsgData.msgList[0].msgRandom),
msgSeq: parseInt(MsgData.msgList[0].msgSeq),
};
return this.context.session.getGroupService().removeGroupEssence(param);
}
async getSingleScreenNotifies (doubt: boolean, count: number) {
const [, , , notifies] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelGroupService/getSingleScreenNotifies',
'NodeIKernelGroupListener/onGroupSingleScreenNotifies',
[
doubt,
'',
count,
]
);
return notifies;
}
async searchGroup (groupCode: string) {
const [, ret] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelSearchService/searchGroup',
'NodeIKernelSearchListener/onSearchGroupResult',
[{
keyWords: groupCode,
groupNum: 25,
exactSearch: false,
penetrate: '',
}],
(ret) => ret.result === 0,
(params) => !!params.groupInfos.find(g => g.groupCode === groupCode),
1,
5000
);
return ret.groupInfos.find(g => g.groupCode === groupCode);
}
async getGroupMemberEx (groupCode: string, uid: string, forced: boolean = false, retry: number = 2) {
const data = await solveAsyncProblem((eventWrapper: NTEventWrapper, GroupCode: string, uid: string, forced = false) => {
return eventWrapper.callNormalEventV2(
'NodeIKernelGroupService/getMemberInfo',
'NodeIKernelGroupListener/onMemberInfoChange',
[groupCode, [uid], forced],
(ret) => ret.result === 0,
(params: string, _: any, members: Map<string, GroupMember>) => params === GroupCode && members.size > 0 && members.has(uid),
1,
forced ? 2500 : 250
);
}, this.core.eventWrapper, groupCode, uid, forced);
if (data && data[3] instanceof Map && data[3].has(uid)) {
return data[3].get(uid);
}
if (retry > 0) {
const trydata = await this.getGroupMemberEx(groupCode, uid, true, retry - 1) as GroupMember | undefined;
if (trydata) return trydata;
}
return undefined;
}
async getGroupFileCount (groupCodes: Array<string>) {
return this.context.session.getRichMediaService().batchGetGroupFileCount(groupCodes);
}
// getGroupFileList also carries the group's used/total space in its call
// result's `groupSpaceResult`; a single-item fetch is enough to read it.
async getGroupFileSpace (groupCode: string) {
const [result] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelRichMediaService/getGroupFileList',
'NodeIKernelMsgListener/onGroupFileInfoUpdate',
[
groupCode,
{ sortType: 1, fileCount: 1, startIndex: 0, sortOrder: 1, showOnlinedocFolder: 0 },
],
() => true,
() => true,
1,
5000
);
return result?.groupSpaceResult;
}
async getArkJsonGroupShare (groupCode: string) {
const ret = await this.core.eventWrapper.callNoListenerEvent(
'NodeIKernelGroupService/getGroupRecommendContactArkJson',
groupCode
) as GeneralCallResult & { arkJson: string; };
return ret.arkJson;
}
async uploadGroupBulletinPic (groupCode: string, imageurl: string) {
const _Pskey = await this.getQunPskey();
return this.context.session.getGroupService().uploadGroupBulletinPic(groupCode, _Pskey, imageurl);
}
async handleGroupRequest (doubt: boolean, notify: GroupNotify, operateType: NTGroupRequestOperateTypes, reason?: string) {
return this.context.session.getGroupService().operateSysNotify(
doubt,
{
operateType,
targetMsg: {
seq: notify.seq, // 通知序列号
type: notify.type,
groupCode: notify.group.groupCode,
postscript: reason ?? ' ', // 仅传空值可能导致处理失败,故默认给个空格
},
});
}
async quitGroup (groupCode: string) {
return this.context.session.getGroupService().quitGroup(groupCode);
}
async kickMember (groupCode: string, kickUids: string[], refuseForever: boolean = false, kickReason: string = '') {
return this.context.session.getGroupService().kickMember(groupCode, kickUids, refuseForever, kickReason);
}
async banMember (groupCode: string, memList: Array<{ uid: string, timeStamp: number; }>) {
// timeStamp为秒数, 0为解除禁言
return this.context.session.getGroupService().setMemberShutUp(groupCode, memList);
}
async banGroup (groupCode: string, shutUp: boolean) {
return this.context.session.getGroupService().setGroupShutUp(groupCode, shutUp);
}
async setMemberCard (groupCode: string, memberUid: string, cardName: string) {
return this.context.session.getGroupService().modifyMemberCardName(groupCode, memberUid, cardName);
}
async setMemberRole (groupCode: string, memberUid: string, role: NTGroupMemberRole) {
return this.context.session.getGroupService().modifyMemberRole(groupCode, memberUid, role);
}
async setGroupName (groupCode: string, groupName: string, isNormalMember: boolean = false) {
return this.context.session.getGroupService().modifyGroupName(groupCode, groupName, isNormalMember);
}
async publishGroupBulletin (groupCode: string, content: string, picInfo: {
id: string,
width: number,
height: number;
} | undefined = undefined, pinned: number = 0, confirmRequired: number = 0) {
const psKey = await this.getQunPskey();
// text是content内容url编码
const data = {
text: encodeURI(content),
picInfo,
oldFeedsId: '',
pinned,
confirmRequired,
};
return this.context.session.getGroupService().publishGroupBulletin(groupCode, psKey!, data);
}
async getGroupRemainAtTimes (groupCode: string) {
return this.context.session.getGroupService().getGroupRemainAtTimes(groupCode);
}
async getMemberExtInfo (groupCode: string, uin: string) {
return this.context.session.getGroupService().getMemberExtInfo(
{
groupCode,
sourceType: MemberExtSourceType.TITLETYPE,
beginUin: '0',
dataTime: '0',
uinList: [uin],
uinNum: '',
seq: '',
groupType: '',
richCardNameVer: '',
memberExtFilter: {
memberLevelInfoUin: 1,
memberLevelInfoPoint: 1,
memberLevelInfoActiveDay: 1,
memberLevelInfoLevel: 1,
memberLevelInfoName: 1,
levelName: 1,
dataTime: 1,
userShowFlag: 1,
sysShowFlag: 1,
timeToUpdate: 1,
nickName: 1,
specialTitle: 1,
levelNameNew: 1,
userShowFlagNew: 1,
msgNeedField: 1,
cmdUinFlagExt3Grocery: 1,
memberIcon: 1,
memberInfoSeq: 1,
},
}
);
}
}
+12
View File
@@ -0,0 +1,12 @@
export * from './file';
export * from './friend';
export * from './group';
export * from './msg';
export * from './user';
export * from './webapi';
export * from './system';
export * from './packet';
export * from './file';
export * from './online';
export * from './flash';
export * from './database';
+325
View File
@@ -0,0 +1,325 @@
import { ChatType, GetFileListParam, Peer, RawMessage, SendMessageElement, SendStatusType } from '@/snowcat-core/types';
import { GroupFileInfoUpdateItem, InstanceContext, SnowCatCore, NodeIKernelMsgService } from '@/snowcat-core/index';
import { GeneralCallResult } from '@/snowcat-core/services/common';
export class NTQQMsgApi {
context: InstanceContext;
core: SnowCatCore;
constructor (context: InstanceContext, core: SnowCatCore) {
this.context = context;
this.core = core;
}
async clickInlineKeyboardButton (...params: Parameters<NodeIKernelMsgService['clickInlineKeyboardButton']>) {
return this.context.session.getMsgService().clickInlineKeyboardButton(...params);
}
getMsgByClientSeqAndTime (peer: Peer, replyMsgClientSeq: string, replyMsgTime: string) {
// https://bot.q.qq.com/wiki/develop/api-v2/openapi/emoji/model.html#EmojiType 可以用过特殊方式拉取
return this.context.session.getMsgService().getMsgByClientSeqAndTime(peer, replyMsgClientSeq, replyMsgTime);
}
async getAioFirstViewLatestMsgs (peer: Peer, MsgCount: number) {
return this.context.session.getMsgService().getAioFirstViewLatestMsgs(peer, MsgCount);
}
async sendShowInputStatusReq (peer: Peer, eventType: number) {
return this.context.session.getMsgService().sendShowInputStatusReq(peer.chatType, eventType, peer.peerUid);
}
async getSourceOfReplyMsgV2 (peer: Peer, clientSeq: string, time: string) {
return this.context.session.getMsgService().getSourceOfReplyMsgV2(peer, clientSeq, time);
}
async getMsgEmojiLikesList (peer: Peer, msgSeq: string, emojiId: string, emojiType: string, cookie: string = '', count: number = 20) {
// 注意此处emojiType 可选值一般为1-2 2好像是unicode表情dec值 大部分情况 Taged Mlikiowa
return this.context.session.getMsgService().getMsgEmojiLikesList(peer, msgSeq, emojiId, emojiType, cookie, false, count);
}
async setEmojiLike (peer: Peer, msgSeq: string, emojiId: string, set: boolean = true) {
emojiId = emojiId.toString();
return this.context.session.getMsgService().setMsgEmojiLikes(peer, msgSeq, emojiId, emojiId.length > 3 ? '2' : '1', set);
}
async getMultiMsg (peer: Peer, rootMsgId: string, parentMsgId: string): Promise<GeneralCallResult & {
msgList: RawMessage[];
} | undefined> {
return this.context.session.getMsgService().getMultiMsg(peer, rootMsgId, parentMsgId);
}
async ForwardMsg (peer: Peer, msgIds: string[]) {
return this.context.session.getMsgService().forwardMsg(msgIds, peer, [peer], new Map());
}
async getMsgsByMsgId (peer: Peer | undefined, msgIds: string[] | undefined) {
if (!peer) throw new Error('peer is not allowed');
if (!msgIds) throw new Error('msgIds is not allowed');
// MliKiowa: 参数不合规会导致NC异常崩溃 原因是TX未对进入参数判断 对应Android标记@NotNull AndroidJADX分析可得
return await this.context.session.getMsgService().getMsgsByMsgId(peer, msgIds);
}
async getSingleMsg (peer: Peer, seq: string) {
return await this.context.session.getMsgService().getSingleMsg(peer, seq);
}
async fetchFavEmojiList (num: number) {
return this.context.session.getMsgService().fetchFavEmojiList('', num, true, true);
}
async addFavEmoji (req: Parameters<NodeIKernelMsgService['addFavEmoji']>[0]) {
return this.context.session.getMsgService().addFavEmoji(req);
}
async deleteFavEmoji (ids: string[]) {
return this.context.session.getMsgService().deleteFavEmoji(ids);
}
async modifyFavEmojiDesc (items: Parameters<NodeIKernelMsgService['modifyFavEmojiDesc']>[0]) {
return this.context.session.getMsgService().modifyFavEmojiDesc(items);
}
async queryMsgsWithFilterExWithSeq (peer: Peer, msgSeq: string) {
return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', msgSeq, {
chatInfo: peer,
// searchFields: 3,
filterMsgType: [],
filterSendersUid: [],
filterMsgToTime: '0',
filterMsgFromTime: '0',
isReverseOrder: false,
isIncludeCurrent: true,
pageLimit: 1,
});
}
async queryMsgsWithFilterExWithSeqV2 (peer: Peer, msgSeq: string, MsgTime: string, SendersUid: string[]) {
return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', msgSeq, {
chatInfo: peer,
filterMsgType: [],
// searchFields: 3,
filterSendersUid: SendersUid,
filterMsgToTime: MsgTime,
filterMsgFromTime: MsgTime,
isReverseOrder: false,
isIncludeCurrent: true,
pageLimit: 1,
});
}
async queryMsgsWithFilterExWithSeqV3 (peer: Peer, msgSeq: string, SendersUid: string[]) {
return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', msgSeq, {
chatInfo: peer,
filterMsgType: [],
filterSendersUid: SendersUid,
filterMsgToTime: '0',
filterMsgFromTime: '0',
isReverseOrder: false,
// searchFields: 3,
isIncludeCurrent: true,
pageLimit: 1,
});
}
async queryFirstMsgBySeq (peer: Peer, msgSeq: string) {
return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', msgSeq, {
chatInfo: peer,
filterMsgType: [],
filterSendersUid: [],
filterMsgToTime: '0',
// searchFields: 3,
filterMsgFromTime: '0',
isReverseOrder: true,
isIncludeCurrent: true,
pageLimit: 1,
});
}
// 客户端还在用别慌
async getMsgsBySeqAndCount (peer: Peer, seq: string, count: number, desc: boolean, isReverseOrder: boolean) {
return await this.context.session.getMsgService().getMsgsBySeqAndCount(peer, seq, count, desc, isReverseOrder);
}
async getMsgExBySeq (peer: Peer, msgSeq: string) {
const DateNow = Math.floor(Date.now() / 1000);
const filterMsgFromTime = (DateNow - 300).toString();
const filterMsgToTime = DateNow.toString();
return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', msgSeq, {
chatInfo: peer, // 此处为Peer 为关键查询参数 没有啥也没有 by mlik iowa
filterMsgType: [],
filterSendersUid: [],
// searchFields: 3,
filterMsgToTime,
filterMsgFromTime,
isReverseOrder: false,
isIncludeCurrent: true,
pageLimit: 100,
});
}
async queryFirstMsgBySender (peer: Peer, SendersUid: string[]) {
return await this.context.session.getMsgService().queryMsgsWithFilterEx('0', '0', '0', {
chatInfo: peer,
filterMsgType: [],
filterSendersUid: SendersUid,
// searchFields: 3,
filterMsgToTime: '0',
filterMsgFromTime: '0',
isReverseOrder: true,
isIncludeCurrent: true,
pageLimit: 20000,
});
}
async setMsgRead (peer: Peer) {
return this.context.session.getMsgService().setMsgRead(peer);
}
async getGroupFileList (GroupCode: string, params: GetFileListParam) {
const item: GroupFileInfoUpdateItem[] = [];
let index = params.startIndex;
while (true) {
params.startIndex = index;
const [, groupFileListResult] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelRichMediaService/getGroupFileList',
'NodeIKernelMsgListener/onGroupFileInfoUpdate',
[
GroupCode,
params,
],
() => true,
() => true, // 应当通过 groupFileListResult 判断
1,
5000
);
if (!groupFileListResult?.item?.length) break;
item.push(...groupFileListResult.item);
if (groupFileListResult.isEnd) break;
if (item.length === params.fileCount) break;
index = groupFileListResult.nextIndex;
}
return item;
}
async getMsgHistory (peer: Peer, msgId: string, count: number, isReverseOrder: boolean = false) {
// 消息时间从旧到新
return this.context.session.getMsgService().getMsgsIncludeSelf(peer, msgId, count, isReverseOrder);
}
async recallMsg (peer: Peer, msgId: string) {
return await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelMsgService/recallMsg',
'NodeIKernelMsgListener/onMsgInfoListUpdate',
[peer, [msgId]],
() => true,
(updatedList) => updatedList.find(m => m.msgId === msgId && m.recallTime !== '0') !== undefined,
1,
1000
);
}
async PrepareTempChat (toUserUid: string, GroupCode: string, nickname: string) {
return this.context.session.getMsgService().prepareTempChat({
chatType: ChatType.KCHATTYPETEMPC2CFROMGROUP,
peerUid: toUserUid,
peerNickname: nickname,
fromGroupCode: GroupCode,
sig: '',
selfPhone: '',
selfUid: this.core.selfInfo.uid,
gameSession: {
nickname: '',
gameAppId: '',
selfTinyId: '',
peerRoleId: '',
peerOpenId: '',
},
});
}
async getTempChatInfo (chatType: ChatType, peerUid: string) {
return this.context.session.getMsgService().getTempChatInfo(chatType, peerUid);
}
async sendMsg (peer: Peer, msgElements: SendMessageElement[], timeout = 10000) {
// 唉?!我有个想法
if (peer.chatType === ChatType.KCHATTYPETEMPC2CFROMGROUP && peer.guildId && peer.guildId !== '') {
const member = await this.core.apis.GroupApi.getGroupMember(peer.guildId, peer.peerUid);
if (member) {
await this.PrepareTempChat(peer.peerUid, peer.guildId, member.nick);
}
}
const msgId = await this.generateMsgUniqueId(peer.chatType);
peer.guildId = msgId;
const [, msgList] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelMsgService/sendMsg',
'NodeIKernelMsgListener/onMsgInfoListUpdate',
[
'0',
peer,
msgElements,
new Map(),
],
(ret) => ret.result === 0,
msgRecords => {
for (const msgRecord of msgRecords) {
if (msgRecord.guildId === msgId && msgRecord.sendStatus === SendStatusType.KSEND_STATUS_SUCCESS) {
return true;
}
}
return false;
},
1,
timeout
);
return msgList.find(msgRecord => msgRecord.guildId === msgId);
}
async generateMsgUniqueId (chatType: number) {
return this.context.session.getMsgService().generateMsgUniqueId(chatType, this.context.session.getMSFService().getServerTime());
}
async forwardMsg (srcPeer: Peer, destPeer: Peer, msgIds: string[]) {
return this.context.session.getMsgService().forwardMsg(msgIds, srcPeer, [destPeer], new Map());
}
async multiForwardMsg (srcPeer: Peer, destPeer: Peer, msgIds: string[]): Promise<RawMessage> {
const msgInfos = msgIds.map(id => {
return { msgId: id, senderShowName: this.core.selfInfo.nick };
});
const [, msgList] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelMsgService/multiForwardMsgWithComment',
'NodeIKernelMsgListener/onMsgInfoListUpdate',
[
msgInfos,
srcPeer,
destPeer,
[],
new Map(),
],
() => true,
(msgRecords) => msgRecords.some(
msgRecord => msgRecord.peerUid === destPeer.peerUid &&
msgRecord.senderUid === this.core.selfInfo.uid
)
);
for (const msg of msgList) {
const arkElement = msg.elements.find(ele => ele.arkElement);
if (!arkElement) {
continue;
}
const forwardData: { app: string; } = JSON.parse(arkElement.arkElement?.bytesData ?? '');
if (forwardData.app !== 'com.tencent.multimsg') {
continue;
}
if (msg.peerUid === destPeer.peerUid && msg.senderUid === this.core.selfInfo.uid) {
return msg;
}
}
throw new Error('转发消息超时');
}
async markAllMsgAsRead () {
return this.context.session.getMsgService().setAllC2CAndGroupMsgRead();
}
}
+241
View File
@@ -0,0 +1,241 @@
import { InstanceContext, SnowCatCore } from '@/snowcat-core';
import { Peer } from '@/snowcat-core/types';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { GeneralCallResultStatus } from '@/snowcat-core/services/common';
import { sleep } from '@/snowcat-common/src/helper';
const normalizePath = (p: string) => path.normalize(p).toLowerCase();
export class NTQQOnlineApi {
context: InstanceContext;
core: SnowCatCore;
constructor (context: InstanceContext, core: SnowCatCore) {
this.context = context;
this.core = core;
}
/**
* 这里不等待node返回,因为the fuck wrapper.node 根本不返回(会卡死不知道为什么)!!! 只能手动查询判断死活
* @param peer
* @param filePath
* @param fileName
*/
async sendOnlineFile (peer: Peer, filePath: string, fileName: string): Promise<any> {
if (!fs.existsSync(filePath)) {
throw new Error(`[SnowCat] 文件不存在: ${filePath}`);
}
const actualFileName = fileName || path.basename(filePath);
const fileSize = fs.statSync(filePath).size.toString();
const fileElementToSend = [{
elementType: 23,
elementId: '',
fileElement: {
fileName: actualFileName,
filePath,
fileSize,
},
}];
const msgService = this.context.session.getMsgService();
const startTime = Math.floor(Date.now() / 1000) - 2; // 容错时间窗口
msgService.sendMsg('0', peer, fileElementToSend, new Map()).catch((_e: any) => {
});
const maxRetries = 10;
let retryCount = 0;
while (retryCount < maxRetries) {
await sleep(1000);
retryCount++;
try {
const msgListResult = await msgService.getOnlineFileMsgs(peer);
const msgs = msgListResult?.msgList || [];
const foundMsg = msgs.find((msg: any) => {
if (parseInt(msg.msgTime) < startTime) return false;
const validElement = msg.elements.find((el: any) => {
if (el.elementType !== 23 || !el.fileElement) return false;
const isNameMatch = el.fileElement.fileName === actualFileName;
const isPathMatch = normalizePath(el.fileElement.filePath) === normalizePath(filePath);
return isNameMatch && isPathMatch;
});
return !!validElement;
});
if (foundMsg) {
const targetElement = foundMsg.elements.find((el: any) => el.elementType === 23);
this.context.logger.log('[OnlineFile] 在线文件发送成功!');
return {
result: GeneralCallResultStatus.OK,
errMsg: '',
msgId: foundMsg.msgId,
elementId: targetElement?.elementId || '',
};
}
} catch (_e) {
}
}
this.context.logger.logError('[OnlineFile] 在线文件发送失败!!!');
return {
result: GeneralCallResultStatus.ERROR,
errMsg: '[SnowCat] Send Online File Timeout: Message not found in history.',
};
}
/**
* 发送在线文件夹
* @param peer
* @param folderPath
* @param folderName
*/
async sendOnlineFolder (peer: Peer, folderPath: string, folderName?: string): Promise<any> {
const actualFolderName = folderName || path.basename(folderPath);
if (!fs.existsSync(folderPath)) {
return { result: GeneralCallResultStatus.ERROR, errMsg: `Folder not found: ${folderPath}` };
}
if (!fs.statSync(folderPath).isDirectory()) {
return { result: GeneralCallResultStatus.ERROR, errMsg: `Path is not a directory: ${folderPath}` };
}
const folderElementItem = {
elementType: 30,
elementId: '',
fileElement: {
fileName: actualFolderName,
filePath: folderPath,
fileSize: '',
},
};
const msgService = this.context.session.getMsgService();
const startTime = Math.floor(Date.now() / 1000) - 2;
msgService.sendMsg('0', peer, [folderElementItem], new Map()).catch((_e: any) => {
});
const maxRetries = 10;
let retryCount = 0;
while (retryCount < maxRetries) {
await sleep(1000);
retryCount++;
try {
const msgListResult = await msgService.getOnlineFileMsgs(peer);
const msgs = msgListResult?.msgList || [];
const foundMsg = msgs.find((msg: any) => {
if (parseInt(msg.msgTime) < startTime) return false;
const validElement = msg.elements.find((el: any) => {
if (el.elementType !== 30 || !el.fileElement) return false;
const isNameMatch = el.fileElement.fileName === actualFolderName;
const isPathMatch = normalizePath(el.fileElement.filePath) === normalizePath(folderPath);
return isNameMatch && isPathMatch;
});
return !!validElement;
});
if (foundMsg) {
const targetElement = foundMsg.elements.find((el: any) => el.elementType === 30);
this.context.logger.log('[OnlineFile] 在线文件夹发送成功!');
return {
result: GeneralCallResultStatus.OK,
errMsg: '',
msgId: foundMsg.msgId,
elementId: targetElement?.elementId || '',
};
}
} catch (_e) {
}
}
this.context.logger.logError('[OnlineFile] 在线文件发送失败!!!');
return {
result: GeneralCallResultStatus.ERROR,
errMsg: '[SnowCat] Send Online Folder Timeout: Message not found in history.',
};
}
/**
* 获取好友的在线文件消息
* @param peer
*/
async getOnlineFileMsg (peer: Peer): Promise<any> {
const msgService = this.context.session.getMsgService();
return await msgService.getOnlineFileMsgs(peer);
}
/**
* 取消在线文件的发送
* @param peer
* @param msgId
*/
async cancelMyOnlineFileMsg (peer: Peer, msgId: string): Promise<void> {
const msgService = this.context.session.getMsgService();
await msgService.cancelSendMsg(peer, msgId);
}
/**
* 拒绝接收在线文件
* @param peer
* @param msgId
* @param elementId
*/
async refuseOnlineFileMsg (peer: Peer, msgId: string, elementId: string): Promise<void> {
const msgService = this.context.session.getMsgService();
const arrToSend = {
msgId,
peerUid: peer.peerUid,
chatType: 1,
elementId,
downloadType: 1,
downSourceType: 1,
};
await msgService.refuseGetRichMediaElement(arrToSend);
}
/**
* 接收在线文件/文件夹
* @param peer
* @param msgId
* @param elementId
* @constructor
*/
async receiveOnlineFileOrFolder (peer: Peer, msgId: string, elementId: string): Promise<any> {
const msgService = this.context.session.getMsgService();
const arrToSend = {
msgId,
peerUid: peer.peerUid,
chatType: 1,
elementId,
downSourceType: 1,
downloadType: 1,
};
return await msgService.getRichMediaElement(arrToSend);
}
/**
* 在线文件/文件夹转离线
* @param peer
* @param msgId
*/
async switchFileToOffline (peer: Peer, msgId: string): Promise<void> {
const msgService = this.context.session.getMsgService();
await msgService.switchToOfflineSendMsg(peer, msgId);
}
}
+93
View File
@@ -0,0 +1,93 @@
import * as os from 'os';
import offset from '@/snowcat-core/external/napi2native.json';
import { InstanceContext, SnowCatCore } from '@/snowcat-core/index';
import { PacketClientSession } from '@/snowcat-core/packet/clientSession';
import { snowCatVersion } from 'snowcat-common/src/version';
import { LogWrapper } from '../helper/log';
interface OffsetType {
[key: string]: {
recv: string;
send: string;
};
}
const typedOffset: OffsetType = offset;
export class NTQQPacketApi {
context: InstanceContext;
core: SnowCatCore;
logger: LogWrapper;
qqVersion: string | undefined;
pkt!: PacketClientSession;
errStack: string[] = [];
packetStatus: boolean = false;
constructor (context: InstanceContext, core: SnowCatCore) {
this.context = context;
this.core = core;
this.logger = core.context.logger;
}
async initApi () {
this.packetStatus = (await this.InitSendPacket(this.context.basicInfoWrapper.getFullQQVersion())
.then((result) => {
return result;
})
.catch((err) => {
this.logger.logError(err);
this.pushError(err);
return false;
})) && this.pkt?.available;
}
get available (): boolean {
return this.pkt?.available ?? false;
}
get clientLogStack () {
return [this.pkt?.clientLogStack, ...this.errStack].filter(Boolean).join('\n');
}
private pushError (error: unknown) {
if (error instanceof Error) {
this.errStack.push(error.stack || error.message);
return;
}
this.errStack.push(String(error));
}
async InitSendPacket (qqVer: string) {
this.qqVersion = qqVer;
const table = typedOffset[qqVer + '-' + os.arch()];
if (!table) {
const err = `[Core] [Packet] PacketBackend 不支持当前QQ版本架构:${qqVer}-${os.arch()},
请参照 https://github.com/NapNeko/SnowCatQQ/releases/tag/v${snowCatVersion} 配置正确的QQ版本!`;
this.logger.logError(err);
this.errStack.push(err);
return false;
}
if (this.core.configLoader.configData.packetBackend === 'disable') {
const err = '[Core] [Packet] 已禁用PacketBackend,NapCat.Packet将不会加载!';
this.logger.logError(err);
this.errStack.push(err);
return false;
}
this.pkt = new PacketClientSession(this.core);
await this.pkt.init(process.pid, table.recv, table.send);
if (!this.pkt.available) {
const err = '[Core] [Packet] NativePacketClient 初始化失败,PacketBackend 发包能力不可用!';
this.logger.logError(err);
this.errStack.push(err);
return false;
}
try {
await this.pkt.operation.FetchRkey(3000);
} catch (error) {
this.logger.logError('测试Packet状态异常', error);
this.pushError(error);
return false;
}
return true;
}
}
@@ -0,0 +1,469 @@
/**
* SnowCat · QQ空间公共空间(空友推荐)Web 线 —— 纯数据构造与请求封装
*
* SnowLuma v2.2.1 (packages/protocol/src/web/qzone-public.ts) 移植改造:
* - 走 h5.qzone.qq.com/http2rpc gotrpc 通道(trpc 服务 + x-oidb 命令头)
* - cookie 串形态对齐 NapCat getQzoneAuth(p_uin/p_skey/skey/uin)
* - cursor 编解码绑 uin 防串号
*/
export type QzonePublicContent = string | Record<string, unknown>[];
export interface QzonePublicFeedsPage {
feeds: Record<string, unknown>[];
has_more: boolean;
next_cursor: string | null;
[key: string]: unknown;
}
interface PublicFeedCursor {
version: 1;
uin: string;
refresh_id: string;
attach_info: string;
last_client_feed_union_id: Record<string, unknown> | null;
}
const reader = 'trpc.qzone.feeds_reader.FeedsReader';
const interaction = 'trpc.qzone.interaction_writer.InteractionWriter';
const upvoteService = 'trpc.public_qzone.interaction.UFOComment';
const relation = 'trpc.public_qzone.relation.RelationService';
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/** http2rpc 统一请求封装:retcode!=0或缺 data 抛错。 */
export async function qzonePublicRequest(
cookie: string,
bkn: string,
service: string,
method: string,
oidb: string,
params: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const [command, serviceType] = oidb.split('_');
const { RequestUtil } = await import('snowcat-common/src/request');
const result = await RequestUtil.HttpGetJson<unknown>(
`https://h5.qzone.qq.com/http2rpc/gotrpc/v1/${service}/${method}?g_tk=${bkn}`,
'POST',
params,
{
Cookie: cookie,
'Content-Type': 'application/json',
Referer: 'https://h5.qzone.qq.com/pc-qzone/index.html',
'x-web-qua': 'V1_HT5_QZ_9.9.32_001_IDC_B',
'x-oidb': JSON.stringify({
uint32_command: command,
uint32_service_type: serviceType,
}),
},
);
if (!isObject(result) || result['retcode'] !== 0 || !isObject(result['data'])) {
const code = isObject(result) ? result['retcode'] : 'invalid response';
const message =
isObject(result) && typeof result['message'] === 'string' ? result['message'] : '';
throw new Error(`qzone public ${method} failed: retcode=${code} ${message}`.trim());
}
return result['data'];
}
/** 动态寻址参数:从 feed.cell_common.union_id 提取(完整动态才有)。 */
export function qzonePublicFeedParams(feed: Record<string, unknown>): Record<string, unknown> {
const common = feed['cell_common'];
if (!isObject(common) || !isObject(common['union_id']) || !Object.keys(common['union_id']).length) {
throw new Error('空友动态缺少 cell_common.union_id,请使用推荐列表或详情返回的完整动态');
}
return {
union_id: common['union_id'],
...(feed['cell_feed_transfer'] === undefined
? {}
: { cell_feed_transfer: feed['cell_feed_transfer'] }),
};
}
function encodeCursor(state: PublicFeedCursor): string {
const cursor = Buffer.from(JSON.stringify(state)).toString('base64url');
if (cursor.length > 65_536) {
throw new Error('空友推荐分页游标过大');
}
return cursor;
}
function decodeCursor(cursor: string, uin: string): PublicFeedCursor {
if (!cursor || cursor.length > 65_536 || !/^[A-Za-z0-9_-]+$/.test(cursor)) {
throw new Error('无效的空友推荐 cursor');
}
let value: unknown;
try {
const bytes = Buffer.from(cursor, 'base64url');
if (bytes.toString('base64url') !== cursor) {
throw new Error('non-canonical encoding');
}
value = JSON.parse(bytes.toString('utf8'));
} catch {
throw new Error('无效的空友推荐 cursor');
}
if (
!isObject(value) ||
value['version'] !== 1 ||
value['uin'] !== uin ||
typeof value['refresh_id'] !== 'string' ||
!value['refresh_id'] ||
typeof value['attach_info'] !== 'string' ||
!(value['last_client_feed_union_id'] === null || isObject(value['last_client_feed_union_id']))
) {
throw new Error('空友推荐 cursor 无效或不属于当前账号');
}
return {
version: 1,
uin,
refresh_id: value['refresh_id'],
attach_info: value['attach_info'],
last_client_feed_union_id: value['last_client_feed_union_id'] as Record<string, unknown> | null,
};
}
/** 查询账号在空友各入口的白名单能力(entrances)。 */
export async function getQzonePublicCapabilities(
cookie: string,
bkn: string,
selfUin: string,
): Promise<Record<string, unknown>> {
const data = await qzonePublicRequest(
cookie, bkn,
'trpc.public_qzone.entrance_ctrl.EntranceCtrl',
'BatchEntranceWhitelist',
'0x9741_0',
{ uins: [selfUin], entrance_types: [1, 15] },
);
const result = Array.isArray(data['results'])
? (data['results'] as unknown[]).find(
(entry) => isObject(entry) && String(entry['uin']) === selfUin,
)
: undefined;
if (!isObject(result) || !isObject(result['entrances'])) {
throw new Error('qzone public BatchEntranceWhitelist 缺少当前账号的 entrances');
}
return result['entrances'];
}
/** 空友推荐动态流(cursor 续翻;广告条不进续翻锚点)。 */
export async function getQzonePublicFeeds(
cookie: string,
bkn: string,
selfUin: string,
cursor?: string,
): Promise<QzonePublicFeedsPage> {
const state: PublicFeedCursor =
cursor === undefined
? {
version: 1,
uin: selfUin,
refresh_id: `${selfUin}_${Date.now()}`,
attach_info: '',
last_client_feed_union_id: null,
}
: decodeCursor(cursor, selfUin);
const data = await qzonePublicRequest(cookie, bkn, reader, 'GetPublicRecomFeeds', '0x9a38_0', {
refresh_id: state.refresh_id,
...(cursor === undefined
? {}
: {
attach_info: state.attach_info,
...(state.last_client_feed_union_id === null
? {}
: { last_client_feed_union_id: state.last_client_feed_union_id }),
}),
});
if (!Array.isArray(data['feeds']) || typeof data['has_more'] !== 'boolean') {
throw new Error('qzone public GetPublicRecomFeeds 分页响应异常');
}
const feeds = (data['feeds'] as unknown[]).map((entry) => {
if (!isObject(entry) || !isObject(entry['feed'])) {
throw new Error('qzone public GetPublicRecomFeeds 动态响应异常');
}
return entry['feed'] as Record<string, unknown>;
});
let lastUnion = state.last_client_feed_union_id;
// web 客户端跨页携带最后一条非广告动态(含纯广告页)
for (const feed of feeds) {
const common = feed['cell_common'];
if (isObject(common) && common['feed_type'] !== 3 && isObject(common['union_id'])) {
lastUnion = common['union_id'] as Record<string, unknown>;
}
}
let nextCursor: string | null = null;
if (data['has_more']) {
if (typeof data['attach_info'] !== 'string') {
throw new Error('qzone public GetPublicRecomFeeds 缺少 attach_info');
}
nextCursor = encodeCursor({
...state,
attach_info: data['attach_info'],
last_client_feed_union_id: lastUnion,
});
}
return { ...data, feeds, has_more: data['has_more'] as boolean, next_cursor: nextCursor };
}
/** 空友单条动态详情(含评论首屏)。 */
export function getQzonePublicFeedDetail(
cookie: string,
bkn: string,
feed: Record<string, unknown>,
): Promise<Record<string, unknown>> {
return qzonePublicRequest(cookie, bkn, reader, 'GetFeedDetail', '0x97b1_1', {
...qzonePublicFeedParams(feed),
feed_action_domain_type: 3,
from_page: 1,
});
}
/** 空友评论分页(cursor 用详情/上页返回的 attach_info)。 */
export function getQzonePublicComments(
cookie: string,
bkn: string,
feed: Record<string, unknown>,
cursor: string,
): Promise<Record<string, unknown>> {
return qzonePublicRequest(cookie, bkn, reader, 'GetComment', '0x9bfd_1', {
...qzonePublicFeedParams(feed),
attach_info: cursor,
});
}
/** 空友楼中楼回复分页。 */
export function getQzonePublicReplies(
cookie: string,
bkn: string,
feed: Record<string, unknown>,
comment: Record<string, unknown>,
cursor: string,
pageSize: number,
): Promise<Record<string, unknown>> {
return qzonePublicRequest(cookie, bkn, reader, 'GetReplies', '0x9bfe_1', {
...qzonePublicFeedParams(feed),
cur_comment: comment,
comment_attach_info: '',
reply_attach_info: cursor || comment['reply_attach_info'] || '',
page_size: pageSize,
});
}
/** 空友点赞/取消赞(UFOComment Upvote/Downvote)。 */
export async function setQzonePublicUpvote(
cookie: string,
bkn: string,
feed: Record<string, unknown>,
enable: boolean,
): Promise<Record<string, unknown>> {
const data = await qzonePublicRequest(
cookie, bkn,
upvoteService,
enable ? 'Upvote' : 'Downvote',
enable ? '0x9d18_1' : '0x9d19_1',
qzonePublicFeedParams(feed),
);
if (!data['upvote']) {
throw new Error('qzone public upvote 响应缺少 upvote');
}
return data;
}
async function setLike(
cookie: string,
bkn: string,
feed: Record<string, unknown>,
likeKey: unknown,
likeType: number,
enable: boolean,
): Promise<Record<string, unknown>> {
if (typeof likeKey !== 'string' || !likeKey) {
throw new Error('空友点赞目标缺少 like_key');
}
const data = await qzonePublicRequest(
cookie, bkn,
interaction,
enable ? 'DoLike' : 'UnLike',
enable ? '0x9699_1' : '0x969c_1',
{
...qzonePublicFeedParams(feed),
like_key: likeKey,
like_type: likeType,
},
);
if (!data['like']) {
throw new Error('qzone public like 响应缺少 like');
}
return data;
}
/** 空友动态点赞/取消赞(like_type=1,key 取 cell_common.cur_like_key)。 */
export function setQzonePublicLike(
cookie: string,
bkn: string,
feed: Record<string, unknown>,
enable: boolean,
): Promise<Record<string, unknown>> {
const common = feed['cell_common'];
return setLike(cookie, bkn, feed, isObject(common) ? common['cur_like_key'] : undefined, 1, enable);
}
/** 空友评论/回复点赞(like_type:评论=2,回复=3,key 取 like.id)。 */
export function setQzonePublicCommentLike(
cookie: string,
bkn: string,
feed: Record<string, unknown>,
comment: Record<string, unknown>,
enable: boolean,
reply?: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const like = (reply ?? comment)['like'];
return setLike(cookie, bkn, feed, isObject(like) ? like['id'] : undefined, reply ? 3 : 2, enable);
}
function richContent(content: QzonePublicContent): Record<string, unknown>[] {
return typeof content === 'string' ? [{ type: 0, content }] : content;
}
/** 空友发评论。user 为评论者身份对象(详情/列表响应里取,或 {uid,uin,nickname} 形态)。 */
export async function commentQzonePublicFeed(
cookie: string,
bkn: string,
feed: Record<string, unknown>,
user: Record<string, unknown>,
content: QzonePublicContent,
): Promise<Record<string, unknown>> {
const data = await qzonePublicRequest(cookie, bkn, interaction, 'AddComment', '0x9676_1', {
...qzonePublicFeedParams(feed),
comment: { user, content: richContent(content) },
});
if (!data['comment']) {
throw new Error('qzone public AddComment 响应缺少 comment');
}
return data;
}
/** 空友回复评论/楼中楼。点名对象自动取 reply?.user ?? comment.user。 */
export async function replyQzonePublicFeed(
cookie: string,
bkn: string,
feed: Record<string, unknown>,
user: Record<string, unknown>,
comment: Record<string, unknown>,
content: QzonePublicContent,
reply?: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const targetUser = reply?.['user'] ?? comment['user'];
if (!isObject(targetUser)) {
throw new Error('空友评论或回复缺少 user');
}
const data = await qzonePublicRequest(cookie, bkn, interaction, 'AddReply', '0x9677_1', {
...qzonePublicFeedParams(feed),
comment,
reply: { content: richContent(content), target_user: targetUser, user },
});
if (!data['reply']) {
throw new Error('qzone public AddReply 响应缺少 reply');
}
return data;
}
/** 删空友评论。 */
export function deleteQzonePublicComment(
cookie: string,
bkn: string,
feed: Record<string, unknown>,
comment: Record<string, unknown>,
): Promise<Record<string, unknown>> {
return qzonePublicRequest(cookie, bkn, interaction, 'DeleteComment', '0x9989_0', {
...qzonePublicFeedParams(feed),
comment,
});
}
/** 删空友楼中楼回复。 */
export function deleteQzonePublicReply(
cookie: string,
bkn: string,
feed: Record<string, unknown>,
comment: Record<string, unknown>,
reply: Record<string, unknown>,
): Promise<Record<string, unknown>> {
return qzonePublicRequest(cookie, bkn, interaction, 'DeleteReply', '0x998a_0', {
...qzonePublicFeedParams(feed),
comment,
reply,
});
}
/** 空友关注/取关作者(domain_type=3 且有 uid 用 uid,否则用 uin)。 */
export async function setQzonePublicFollow(
cookie: string,
bkn: string,
feed: Record<string, unknown>,
enable: boolean,
): Promise<Record<string, unknown>> {
const params = qzonePublicFeedParams(feed);
const common = feed['cell_common'] as Record<string, unknown>;
const user = isObject(feed['cell_user_info']) ? (feed['cell_user_info'] as Record<string, unknown>)['user'] : undefined;
const uid = isObject(user) && typeof user['uid'] === 'string' ? user['uid'].trim() : '';
const uin = isObject(user) && typeof user['uin'] === 'string' ? user['uin'].trim() : '';
const useUid = (common['domain_type'] === 3 && !!uid) || !uin;
if (useUid ? !uid : !uin) {
throw new Error('空友动态缺少作者 uid/uin');
}
const union = params['union_id'] as Record<string, unknown>;
const data = await qzonePublicRequest(
cookie, bkn,
relation,
enable ? 'Follow' : 'Unfollow',
enable ? '0x9d1a_1' : '0x9d1b_1',
{
follow_from_type: 1,
...(useUid ? { target_uid: uid } : { target_uin: uin }),
...(union['uf_key'] ? { follow_feed_id: union['uf_key'] } : {}),
...(feed['cell_feed_transfer'] === undefined
? {}
: { cell_feed_transfer: feed['cell_feed_transfer'] }),
},
);
if (
data['ret_code'] !== undefined &&
data['ret_code'] !== null &&
data['ret_code'] !== 0 &&
data['ret_code'] !== '0'
) {
throw new Error(
`qzone public follow failed: ret_code=${data['ret_code']} ${data['ret_msg'] ?? ''}`.trim(),
);
}
return data;
}
/** 转发空友动态。 */
export function forwardQzonePublicFeed(
cookie: string,
bkn: string,
feed: Record<string, unknown>,
reason: string,
): Promise<Record<string, unknown>> {
return qzonePublicRequest(cookie, bkn, 'trpc.qzone.feeds_writer.FeedsWriter', 'ForwardFeed', '0x969a_1', {
...qzonePublicFeedParams(feed),
reason,
});
}
+36
View File
@@ -0,0 +1,36 @@
import { InstanceContext, SnowCatCore } from '@/snowcat-core/index';
export class NTQQSystemApi {
context: InstanceContext;
core: SnowCatCore;
constructor (context: InstanceContext, core: SnowCatCore) {
this.context = context;
this.core = core;
}
async hasOtherRunningQQProcess () {
return this.core.util.hasOtherRunningQQProcess();
}
async ocrImage (filePath: string) {
return this.context.session.getNodeMiscService().wantWinScreenOCR(filePath);
}
async translateEnWordToZn (words: string[]) {
return this.context.session.getRichMediaService().translateEnWordToZn(words);
}
async getOnlineDev () {
this.context.session.getMsgService().getOnLineDev();
}
async getArkJsonCollection () {
return await this.core.eventWrapper.callNoListenerEvent('NodeIKernelCollectionService/collectionArkShare', '1717662698058');
}
async bootMiniApp (appFile: string, params: string) {
await this.context.session.getNodeMiscService().setMiniAppVersion('2.16.4');
return this.context.session.getNodeMiscService().startNewMiniApp(appFile, params);
}
}
+248
View File
@@ -0,0 +1,248 @@
import { ModifyProfileParams, User, UserDetailSource } from '@/snowcat-core/types';
import { RequestUtil } from 'snowcat-common/src/request';
import { InstanceContext, SnowCatCore, ProfileBizType } from '..';
import { solveAsyncProblem } from 'snowcat-common/src/helper';
import { Fallback, FallbackUtil } from 'snowcat-common/src/fall-back';
export class NTQQUserApi {
context: InstanceContext;
core: SnowCatCore;
constructor (context: InstanceContext, core: SnowCatCore) {
this.context = context;
this.core = core;
}
async getCoreAndBaseInfo (uids: string[]) {
return await this.core.eventWrapper.callNoListenerEvent(
'NodeIKernelProfileService/getCoreAndBaseInfo',
'nodeStore',
uids
);
}
// 默认获取自己的 type = 2 获取别人 type = 1
async getProfileLike (uid: string, start: number, count: number, type: number = 2) {
return this.context.session.getProfileLikeService().getBuddyProfileLike({
friendUids: [uid],
basic: 1,
vote: 1,
favorite: 0,
userProfile: 1,
type,
start,
limit: count,
});
}
async setLongNick (longNick: string) {
return this.context.session.getProfileService().setLongNick(longNick);
}
async setSelfOnlineStatus (status: number, extStatus: number, batteryStatus: number) {
return this.context.session.getMsgService().setStatus({
status,
extStatus,
batteryStatus,
});
}
async setDiySelfOnlineStatus (faceId: string, wording: string, faceType: string) {
return this.context.session.getMsgService().setStatus({
status: 10,
extStatus: 2000,
customStatus: { faceId, wording, faceType },
batteryStatus: 0,
});
}
async getBuddyRecommendContactArkJson (uin: string, sencenID = '') {
return this.context.session.getBuddyService().getBuddyRecommendContactArkJson(uin, sencenID);
}
async like (uid: string, count = 1): Promise<{ result: number, errMsg: string, succCounts: number; }> {
return this.context.session.getProfileLikeService().setBuddyProfileLike({
friendUid: uid,
sourceId: 71,
doLikeCount: count,
doLikeTollCount: 0,
});
}
async setQQAvatar (filePath: string) {
const ret = await this.context.session.getProfileService().setHeader(filePath);
return { result: ret?.result, errMsg: ret?.errMsg };
}
async setGroupAvatar (gc: string, filePath: string) {
return this.context.session.getGroupService().setHeader(gc, filePath);
}
async fetchUserDetailInfo (uid: string, mode: UserDetailSource = UserDetailSource.KDB) {
const [, profile] = await this.core.eventWrapper.callNormalEventV2(
'NodeIKernelProfileService/fetchUserDetailInfo',
'NodeIKernelProfileListener/onUserDetailInfoChanged',
[
'BuddyProfileStore',
[uid],
mode,
[ProfileBizType.KALL],
],
() => true,
(profile) => profile.uid === uid
);
return profile;
}
async getUserDetailInfo (uid: string, no_cache: boolean = false): Promise<User> {
let profile = await solveAsyncProblem(async (uid) => this.fetchUserDetailInfo(uid, no_cache ? UserDetailSource.KSERVER : UserDetailSource.KDB), uid);
if (profile && profile.uin !== '0' && profile.commonExt) {
return {
...profile.simpleInfo.status,
...profile.simpleInfo.vasInfo,
...profile.commonExt,
...profile.simpleInfo.baseInfo,
...profile.simpleInfo.coreInfo,
qqLevel: profile.commonExt?.qqLevel,
age: profile.simpleInfo.baseInfo.age,
pendantId: '',
nick: profile.simpleInfo.coreInfo.nick || '',
};
}
this.context.logger.logDebug('[SnowCat] [Mark] getUserDetailInfo Mode1 Failed.');
profile = await this.fetchUserDetailInfo(uid, UserDetailSource.KSERVER);
if (profile && profile.uin === '0') {
profile.uin = await this.core.apis.UserApi.getUidByUinV2(uid) ?? '0';
}
return {
...profile.simpleInfo.status,
...profile.simpleInfo.vasInfo,
...profile.commonExt,
...profile.simpleInfo.baseInfo,
...profile.simpleInfo.coreInfo,
qqLevel: profile.commonExt?.qqLevel,
age: profile.simpleInfo.baseInfo.age,
pendantId: '',
nick: profile.simpleInfo.coreInfo.nick || '',
};
}
async modifySelfProfile (param: ModifyProfileParams) {
return this.context.session.getProfileService().modifyDesktopMiniProfile(param);
}
async getCookies (domain: string) {
const ClientKeyData = await this.forceFetchClientKey();
const requestUrl = 'https://ssl.ptlogin2.qq.com/jump?ptlang=1033&clientuin=' + this.core.selfInfo.uin +
'&clientkey=' + ClientKeyData.clientKey + '&u1=https%3A%2F%2F' + domain + '%2F' + this.core.selfInfo.uin + '%2Finfocenter&keyindex=19%27';
const data = await RequestUtil.HttpsGetCookies(requestUrl);
if (!data['p_skey'] || data['p_skey'].length === 0) {
try {
const pskey = (await this.getPSkey([domain])).domainPskeyMap.get(domain);
if (pskey) data['p_skey'] = pskey;
} catch {
return data;
}
}
return data;
}
async getPSkey (domainList: string[]) {
return await this.context.session.getTipOffService().getPskey(domainList, true);
}
async getRobotUinRange (): Promise<Array<unknown>> {
const robotUinRanges = await this.context.session.getRobotService().getRobotUinRange({
justFetchMsgConfig: '1',
type: 1,
version: 0,
aioKeywordVersion: 0,
});
return robotUinRanges?.response?.robotUinRanges;
}
// 需要异常处理
async getQzoneCookies () {
const ClientKeyData = await this.forceFetchClientKey();
const requestUrl = 'https://ssl.ptlogin2.qq.com/jump?ptlang=1033&clientuin=' + this.core.selfInfo.uin + '&clientkey=' + ClientKeyData.clientKey + '&u1=https%3A%2F%2Fuser.qzone.qq.com%2F' + this.core.selfInfo.uin + '%2Finfocenter&keyindex=19%27';
return await RequestUtil.HttpsGetCookies(requestUrl);
}
// 需要异常处理
async getSKey (): Promise<string | undefined> {
const ClientKeyData = await this.forceFetchClientKey();
if (ClientKeyData.result !== 0) {
throw new Error('getClientKey Error');
}
const clientKey = ClientKeyData.clientKey;
// const keyIndex = ClientKeyData.keyIndex;
const requestUrl = 'https://ssl.ptlogin2.qq.com/jump?ptlang=1033&clientuin=' + this.core.selfInfo.uin + '&clientkey=' + clientKey + '&u1=https%3A%2F%2Fh5.qzone.qq.com%2Fqqnt%2Fqzoneinpcqq%2Ffriend%3Frefresh%3D0%26clientuin%3D0%26darkMode%3D0&keyindex=19%27';
const cookies: { [key: string]: string; } = await RequestUtil.HttpsGetCookies(requestUrl);
const skey = cookies['skey'];
if (!skey) {
throw new Error('SKey is Empty');
}
return skey;
}
async getUidByUinV2 (uin: string) {
if (!uin) {
return '';
}
const fallback =
new Fallback<string | undefined>((uid) => FallbackUtil.boolchecker(uid, uid !== undefined && uid.indexOf('*') === -1 && uid !== ''))
.add(() => this.context.session.getUixConvertService().getUid([uin]).then((data) => data.uidInfo.get(uin)))
.add(() => this.context.session.getProfileService().getUidByUin('FriendsServiceImpl', [uin]).get(uin))
.add(() => this.context.session.getGroupService().getUidByUins([uin]).then((data) => data.uids.get(uin)))
.add(() => this.getUserDetailInfoByUin(uin).then((data) => data.detail.uid));
const uid = await fallback.run().catch(() => '');
return uid ?? '';
}
async getUinByUidV2 (uid: string) {
if (!uid) {
return '0';
}
const fallback = new Fallback<string | undefined>((uin) => FallbackUtil.boolchecker(uin, uin !== undefined && uin !== '0' && uin !== ''))
.add(() => this.context.session.getUixConvertService().getUin([uid]).then((data) => data.uinInfo.get(uid)))
.add(() => this.context.session.getProfileService().getUinByUid('FriendsServiceImpl', [uid]).get(uid))
.add(() => this.context.session.getGroupService().getUinByUids([uid]).then((data) => data.uins.get(uid)))
.add(() => this.getUserDetailInfo(uid).then((data) => data.uin));
const uin = await fallback.run().catch(() => '0');
return uin ?? '0';
}
async getRecentContactListSnapShot (count: number) {
return await this.context.session.getRecentContactService().getRecentContactListSnapShot(count);
}
async getRecentContactListSyncLimit (count: number) {
return await this.context.session.getRecentContactService().getRecentContactListSyncLimit(count);
}
async getRecentContactListSync () {
return await this.context.session.getRecentContactService().getRecentContactListSync();
}
async getRecentContactList () {
return await this.context.session.getRecentContactService().getRecentContactList();
}
async getUserDetailInfoByUin (Uin: string) {
return await this.core.eventWrapper.callNoListenerEvent(
'NodeIKernelProfileService/getUserDetailInfoByUin',
Uin
);
}
async forceFetchClientKey () {
return await this.context.session.getTicketService().forceFetchClientKey('');
}
}
+1000
View File
@@ -0,0 +1,1000 @@
import { RequestUtil } from 'snowcat-common/src/request';
import {
GroupEssenceMsgRet,
InstanceContext,
WebApiGroupMember,
WebApiGroupMemberRet,
WebApiGroupNoticeRet,
WebHonorType, SnowCatCore,
} from '@/snowcat-core/index';
import { createReadStream, readFileSync, statSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { basename } from 'node:path';
import { qunAlbumControl } from '../data/webapi';
import { createAlbumCommentRequest, createAlbumFeedPublish, createAlbumMediaFeed } from '../data/album';
import {
buildQzoneDeleteBody,
buildQzonePublishBody,
buildQzoneUploadImageBody,
parseQzoneDeleteResponse,
parseQzonePublishResponse,
parseQzoneUploadImageResponseText,
toQzoneRichval,
QzoneDeleteResponse,
QzonePublishResponse,
} from '../data/qzone';
// SnowCat · qzone Web 线扩展(SnowLuma 移植):读列表/详情/点赞/评论/回复/资料/拉黑/权限
import {
decodeQzoneFeedCursor,
encodeQzoneFeedCursor,
initialQzoneFeedState,
mapQzoneFeeds,
nextQzoneFeedState,
qzoneFeedPosition,
type QzoneFeedState,
type RawFeedsResponse,
} from '../data/qzone-feeds';
import { parseQzoneCallback } from '../data/qzone-web';
import {
buildQzoneBlackBody,
buildQzoneCommentBody,
buildQzoneLikeBody,
buildQzoneMsgDetailQuery,
buildQzoneProfileQuery,
buildQzoneProfileUpdateBody,
buildQzoneReplyBody,
legacyMsgListRoute,
mapQzoneMsgDetail,
mapQzoneMsgList,
parseQzoneBlackResponse,
parseQzoneCommentResponse,
parseQzoneDetailError,
parseQzoneJson,
parseQzoneLikeResponse,
parseQzoneProfileResponse,
parseQzoneProfileUpdateResponse,
parseQzonePublishFull,
parseQzoneUpdateRightResponse,
userMsgListRoute,
buildQzoneUpdatePayload,
type QzoneProfile,
type QzoneProfileUpdate,
type QzoneReplyTarget,
type QzoneUpdateRightResult,
type RawMsgListResponse,
type RawMsgDetailResponse,
type QzoneRoute,
} from '../data/qzone-web';
export interface SetNoticeRetSuccess {
ec: number;
em: string;
id: number;
ltsm: number;
new_fid: string;
read_only: number;
role: number;
srv_code: number;
}
export class NTQQWebApi {
context: InstanceContext;
core: SnowCatCore;
constructor (context: InstanceContext, core: SnowCatCore) {
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) {
const img = readFileSync(path);
const img_size = img.length;
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,
});
const api = `https://h5.qzone.qq.com/webapp/json/sliceUpload/FileBatchControl/${img_md5}?g_tk=${GTK}`;
const post = await RequestUtil.HttpGetJson<{ data: { session: string; }, ret: number, msg: string; }>(api, 'POST', body, {
Cookie: cookie,
'Content-Type': 'application/json',
});
return post;
}
async uploadQunAlbumSlice (path: string, session: string, skey: string, pskey: string, uin: string, slice_size: number) {
const img_size = statSync(path).size;
let seq = 0;
let offset = 0;
const GTK = this.getBknFromSKey(skey);
const cookie = `p_uin=o${uin}; p_skey=${pskey}; skey=${skey}; uin=o${uin}`;
const stream = createReadStream(path, { highWaterMark: slice_size });
for await (const chunk of stream) {
const end = Math.min(offset + chunk.length, img_size);
const form = new FormData();
form.append('uin', uin);
form.append('appid', 'qun');
form.append('session', session);
form.append('offset', 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', seq.toString());
form.append('end', end.toString());
form.append('cmd', 'FileUpload');
form.append('slice_size', slice_size.toString());
form.append('biz_req.iUploadType', '0');
const api = `https://h5.qzone.qq.com/webapp/json/sliceUpload/FileUpload?seq=${seq}&retry=0&offset=${offset}&end=${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; }; if (post.ret !== 0) {
throw new Error(`分片 ${seq} 上传失败: ${post.msg}`);
}
offset += chunk.length;
seq++;
}
return { success: true, message: '上传完成' };
}
async uploadImageToQunAlbum (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 img_md5 = createHash('md5').update(readFileSync(path)).digest('hex');
const uin = this.core.selfInfo.uin || '10001';
const session = (await this.createQunAlbumSession(gc, sAlbumID, sAlbumName, path, skey, pskey, img_md5, uin)).data.session;
if (!session) throw new Error('创建群相册会话失败');
await this.uploadQunAlbumSlice(path, session, skey, pskey, uin, 16384);
}
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,
content: string) {
const random_seq = Math.floor(Math.random() * 9000) + 1000;
const uin = this.core.selfInfo.uin || '10001';
// 16位number数字
const client_key = Date.now() * 1000;
return await this.context.session.getAlbumService().doQunComment(
random_seq, {
map_info: [],
map_bytes_info: [],
map_user_account: [],
},
qunId,
2,
createAlbumMediaFeed(uin, albumId, lloc),
createAlbumCommentRequest(uin, content, client_key)
);
}
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 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 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?: 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;
}
// ───────────── SnowCat · qzone Web 线扩展(SnowLuma 移植) ─────────────
/** 获取说说列表(默认自己空间;-10000 限频自动切 user.qzone 备路) */
async getQzoneMsgList (targetUin?: string, pos = 0, num = 20) {
const { uin, g_tk, cookie } = await this.getQzoneAuth();
const host = targetUin ?? uin;
let route: QzoneRoute = legacyMsgListRoute(cookie, host, pos, num, g_tk);
let text = await RequestUtil.HttpGetText(route.url, 'GET', '', route.headers);
let data = parseQzoneJson<RawMsgListResponse>(text);
// -10000「使用人数过多」是 legacy 网关限频,切备路重试一次
if (data.code === -10000) {
route = userMsgListRoute(cookie, host, pos, num, g_tk);
text = await RequestUtil.HttpGetText(route.url, 'GET', '', route.headers);
data = parseQzoneJson<RawMsgListResponse>(text);
}
if (typeof data.code === 'number' && data.code !== 0) {
throw new Error(`qzone msglist failed: code=${data.code} ${data.message ?? ''}`.trim());
}
return mapQzoneMsgList(data);
}
/** 获取单条说说详情 + 一页评论(含内联回复) */
async getQzoneMsgDetail (targetUin: string, tid: string, pos = 0, num = 20) {
const { g_tk, cookie } = await this.getQzoneAuth();
const query = buildQzoneMsgDetailQuery(targetUin, tid, g_tk, { pos, num });
const url = `https://h5.qzone.qq.com/proxy/domain/taotao.qq.com/cgi-bin/emotion_cgi_msgdetail_v6?${query}`;
const text = await RequestUtil.HttpGetText(url, 'GET', '', { Cookie: cookie });
const data = parseQzoneJson<RawMsgDetailResponse>(text);
parseQzoneDetailError(data, tid);
return mapQzoneMsgDetail(data, tid);
}
/** 点赞/取消赞一条说说(abstime 传目标帖的发帖时间更稳,0 可容忍) */
async setQzoneLike (targetUin: string, tid: string, like: boolean, abstime = 0) {
const { uin, g_tk, cookie } = await this.getQzoneAuth();
const verb = like ? 'like' : 'unlike';
const cgi = like ? 'internal_dolike_app' : 'internal_unlike_app';
const url = `https://h5.qzone.qq.com/proxy/domain/w.qzone.qq.com/cgi-bin/likes/${cgi}?g_tk=${g_tk}`;
const body = buildQzoneLikeBody(uin, targetUin, tid, abstime);
const text = await RequestUtil.HttpGetText(url, 'POST', body, {
Cookie: cookie,
'Content-Type': 'application/x-www-form-urlencoded',
});
parseQzoneLikeResponse(text, verb);
}
/** 评论一条说说(richval 传图片直链,与发说说的 richval 串不同) */
async commentQzoneMsg (hostUin: string, tid: string, content: string, richval?: string) {
const { uin, g_tk, cookie } = await this.getQzoneAuth();
const url = `https://h5.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_re_feeds?g_tk=${g_tk}`;
const body = buildQzoneCommentBody(uin, hostUin, tid, content, richval ? 1 : undefined, richval);
const text = await RequestUtil.HttpGetText(url, 'POST', body, {
Cookie: cookie,
'Content-Type': 'application/x-www-form-urlencoded',
});
return parseQzoneCommentResponse(text, hostUin, tid);
}
/** 楼中楼回复(独立端点 emotion_cgi_addreply_ugc;replyTo 自动转 @ 提及) */
async replyQzoneComment (hostUin: string, tid: string, content: string, target: QzoneReplyTarget) {
const { uin, g_tk, cookie } = await this.getQzoneAuth();
const url = `https://h5.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_addreply_ugc?g_tk=${g_tk}`;
const body = buildQzoneReplyBody(uin, hostUin, tid, content, target);
const text = await RequestUtil.HttpGetText(url, 'POST', body, {
Cookie: cookie,
'Content-Type': 'application/x-www-form-urlencoded',
});
// 复用评论解析:回复的 id 在 data.id(与评论的 commentid 不同名)
const response = parseQzoneJson<{ code?: number; subcode?: number; message?: string; data?: { id?: string | number } }>(text);
if (typeof response.code === 'number' && response.code !== 0) {
throw new Error(`qzone reply failed: code=${response.code} ${response.message ?? ''}`.trim());
}
const id = response.data?.id;
return { comment_id: id !== undefined ? String(id) : '' };
}
/** 发说说(富返回:tid + time;SnowCat 原版 publishQzoneMsg 只返回 tid,保留不动) */
async publishQzoneMsgFull (content: string, richvals: string[], ugcRight: number, targetUins?: string[]) {
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 resultText = await RequestUtil.HttpGetText(api, 'POST', body, {
Cookie: cookie,
'Content-Type': 'application/x-www-form-urlencoded',
});
return parseQzonePublishFull(resultText);
}
/** 获取空间名称与说明(默认自己,可查别人——需有访问权) */
async getQzoneProfile (targetUin?: string): Promise<QzoneProfile> {
const { uin, g_tk, cookie } = await this.getQzoneAuth();
const query = buildQzoneProfileQuery(uin, targetUin ?? uin, g_tk);
const url = `https://h5.qzone.qq.com/proxy/domain/base.qzone.qq.com/cgi-bin/user/cgi_userinfo_get_all?${query}`;
const text = await RequestUtil.HttpGetText(url, 'GET', '', { Cookie: cookie });
return parseQzoneProfileResponse(text);
}
/** 修改空间名称/说明(只动传了的字段;空串=清空) */
async setQzoneProfile (profile: QzoneProfileUpdate): Promise<void> {
const { uin, g_tk, cookie } = await this.getQzoneAuth();
const body = buildQzoneProfileUpdateBody(uin, profile);
const url = `https://h5.qzone.qq.com/proxy/domain/w.qzone.qq.com/cgi-bin/user/cgi_apply_updateuserinfo_new?g_tk=${g_tk}`;
const text = await RequestUtil.HttpGetText(url, 'POST', body, {
Cookie: cookie,
'Content-Type': 'application/x-www-form-urlencoded',
});
parseQzoneProfileUpdateResponse(text);
}
/** 空间拉黑/解拉黑(action=1 拉黑 / 2 解拉黑) */
async setQzoneBlack (targetUin: string, ban: boolean): Promise<void> {
const { uin, g_tk, cookie } = await this.getQzoneAuth();
const url = `https://h5.qzone.qq.com/proxy/domain/w.qzone.qq.com/cgi-bin/right/cgi_black_action_new?g_tk=${g_tk}`;
const body = buildQzoneBlackBody(uin, targetUin, ban);
const text = await RequestUtil.HttpGetText(url, 'POST', body, {
Cookie: cookie,
'Content-Type': 'application/x-www-form-urlencoded',
});
parseQzoneBlackResponse(text, ban);
}
/** 改已有说说的查看权限(msgdetail_v6 → emotion_cgi_update 两步全量重提) */
async setQzoneMsgRight (tid: string, ugcRight: number, targetUins?: string[]): Promise<QzoneUpdateRightResult> {
const { uin, g_tk, cookie } = await this.getQzoneAuth();
// 第一步:拉详情
const detailQuery = buildQzoneMsgDetailQuery(uin, tid, g_tk);
const detailUrl = `https://h5.qzone.qq.com/proxy/domain/taotao.qq.com/cgi-bin/emotion_cgi_msgdetail_v6?${detailQuery}`;
const detailText = await RequestUtil.HttpGetText(detailUrl, 'GET', '', { Cookie: cookie });
const detail = parseQzoneJson<RawMsgDetailResponse>(detailText);
parseQzoneDetailError(detail, tid);
// 第二步:全量重提 + 覆写权限
const bodyParams = buildQzoneUpdatePayload(detail, uin);
bodyParams.set('ugc_right', String(ugcRight));
if (targetUins && targetUins.length > 0) {
bodyParams.set('allow_uins', targetUins.join('|'));
}
const url = `https://h5.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_update?g_tk=${g_tk}`;
const text = await RequestUtil.HttpGetText(url, 'POST', bodyParams.toString(), {
Cookie: cookie,
'Content-Type': 'application/x-www-form-urlencoded',
});
return parseQzoneUpdateRightResponse(text, tid, ugcRight);
}
/**
* 获取空间好友动态流(feeds3_html_more,HTML 载荷数据级解析)。
* cursor 传上次返回的 next_cursor 续翻;不传从第 page_num 页走。
*/
async getQzoneFeeds (pageNum = 1, count = 10, cursor?: string) {
const { uin, g_tk, cookie } = await this.getQzoneAuth();
if (!Number.isSafeInteger(pageNum) || pageNum < 1 || !Number.isSafeInteger(count) || count < 1 || count > 50) {
throw new Error('空间好友动态页码或数量无效');
}
const start = cursor === undefined
? { page: 1, state: initialQzoneFeedState() }
: decodeQzoneFeedCursor(cursor, uin);
if (cursor !== undefined && pageNum !== 1 && pageNum !== start.page) {
throw new Error('page_num 与空间好友动态 cursor 不匹配');
}
const target = cursor === undefined ? pageNum : start.page;
let state: QzoneFeedState = start.state;
const seen = new Set([qzoneFeedPosition(state)]);
for (let page = start.page; page <= target; page++) {
const query = new URLSearchParams({
uin,
scope: '0',
view: '1',
gid: '',
flag: '1',
filter: 'all',
applist: 'all',
refresh: '0',
firstGetGroup: '0',
mixnocache: '0',
scene: '0',
sidomain: 'qzonestyle.gtimg.cn',
count: String(count),
...state,
getAisort: state.aisortNextTime,
g_tk: g_tk,
callback: '_preloadCallback',
format: 'jsonp',
useutf8: '1',
outputhtmlfeed: '1',
usertime: String(Date.now()),
rd: String(Math.random()),
windowId: String(Math.random()),
});
query.delete('aisortNextTime');
const url = `https://h5.qzone.qq.com/proxy/domain/ic2.qzone.qq.com/cgi-bin/feeds/feeds3_html_more?${query}`;
const body = await RequestUtil.HttpGetText(url, 'GET', '', { Cookie: cookie });
const data = parseQzoneCallback<RawFeedsResponse>(body);
if (data.code !== 0 || (data.subcode !== undefined && data.subcode !== 0)) {
throw new Error(
`qzone feeds failed: code=${data.code} subcode=${data.subcode ?? 0} ${typeof data.message === 'string' ? data.message : ''}`.trim(),
);
}
const main = data.data?.main;
if (main !== undefined && (main === null || typeof main !== 'object' || Array.isArray(main))) {
throw new Error('空间好友动态分页信息结构异常');
}
if (main?.['error'] !== undefined && main['error'] !== '') {
throw new Error('空间好友动态服务端报告拉取失败');
}
const result = mapQzoneFeeds(data);
if (result.has_more) {
if (!main) throw new Error('空间好友动态缺少下一页游标');
state = nextQzoneFeedState(main, state);
const position = qzoneFeedPosition(state);
if (seen.has(position)) throw new Error('空间好友动态分页游标未推进');
seen.add(position);
result.next_cursor = encodeQzoneFeedCursor(uin, page + 1, state);
}
if (page === target) return result;
if (!result.has_more) {
return { feeds: [], has_more: false, next_cursor: null };
}
}
throw new Error('空间好友动态分页未完成');
}
// ───────────── SnowCat · qzone 公共空间(空友)代理(SnowLuma 移植) ─────────────
async getQzonePublicCapabilities (cookie: string, bkn: string, uin: string) {
const impl = await import('./qzone-public-web');
return impl.getQzonePublicCapabilities(cookie, bkn, uin);
}
async getQzonePublicFeeds (cookie: string, bkn: string, uin: string, cursor?: string) {
const impl = await import('./qzone-public-web');
return impl.getQzonePublicFeeds(cookie, bkn, uin, cursor);
}
async getQzonePublicFeedDetail (cookie: string, bkn: string, feed: Record<string, unknown>) {
const impl = await import('./qzone-public-web');
return impl.getQzonePublicFeedDetail(cookie, bkn, feed);
}
async getQzonePublicComments (cookie: string, bkn: string, feed: Record<string, unknown>, cursor: string) {
const impl = await import('./qzone-public-web');
return impl.getQzonePublicComments(cookie, bkn, feed, cursor);
}
async getQzonePublicReplies (cookie: string, bkn: string, feed: Record<string, unknown>, comment: Record<string, unknown>, cursor: string, pageSize: number) {
const impl = await import('./qzone-public-web');
return impl.getQzonePublicReplies(cookie, bkn, feed, comment, cursor, pageSize);
}
async setQzonePublicUpvote (cookie: string, bkn: string, feed: Record<string, unknown>, enable: boolean) {
const impl = await import('./qzone-public-web');
return impl.setQzonePublicUpvote(cookie, bkn, feed, enable);
}
async setQzonePublicLike (cookie: string, bkn: string, feed: Record<string, unknown>, enable: boolean) {
const impl = await import('./qzone-public-web');
return impl.setQzonePublicLike(cookie, bkn, feed, enable);
}
async setQzonePublicCommentLike (cookie: string, bkn: string, feed: Record<string, unknown>, comment: Record<string, unknown>, enable: boolean, reply?: Record<string, unknown>) {
const impl = await import('./qzone-public-web');
return impl.setQzonePublicCommentLike(cookie, bkn, feed, comment, enable, reply);
}
async commentQzonePublicFeed (cookie: string, bkn: string, feed: Record<string, unknown>, user: Record<string, unknown>, content: string | Record<string, unknown>[]) {
const impl = await import('./qzone-public-web');
return impl.commentQzonePublicFeed(cookie, bkn, feed, user, content);
}
async replyQzonePublicFeed (cookie: string, bkn: string, feed: Record<string, unknown>, user: Record<string, unknown>, comment: Record<string, unknown>, content: string | Record<string, unknown>[], reply?: Record<string, unknown>) {
const impl = await import('./qzone-public-web');
return impl.replyQzonePublicFeed(cookie, bkn, feed, user, comment, content, reply);
}
async deleteQzonePublicComment (cookie: string, bkn: string, feed: Record<string, unknown>, comment: Record<string, unknown>) {
const impl = await import('./qzone-public-web');
return impl.deleteQzonePublicComment(cookie, bkn, feed, comment);
}
async deleteQzonePublicReply (cookie: string, bkn: string, feed: Record<string, unknown>, comment: Record<string, unknown>, reply: Record<string, unknown>) {
const impl = await import('./qzone-public-web');
return impl.deleteQzonePublicReply(cookie, bkn, feed, comment, reply);
}
async setQzonePublicFollow (cookie: string, bkn: string, feed: Record<string, unknown>, enable: boolean) {
const impl = await import('./qzone-public-web');
return impl.setQzonePublicFollow(cookie, bkn, feed, enable);
}
async forwardQzonePublicFeed (cookie: string, bkn: string, feed: Record<string, unknown>, reason: string) {
const impl = await import('./qzone-public-web');
return impl.forwardQzonePublicFeed(cookie, bkn, feed, reason);
}
}