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

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

No files matched your search

+176
View File
@@ -0,0 +1,176 @@
import { InstanceContext, NapCatCore } from 'napcat-core';
import { NapCatPathWrapper } from 'napcat-common/src/path';
import { NapCatOneBot11Adapter } from 'napcat-onebot';
import { NapCatProtocolAdapter } from 'napcat-protocol';
// 协议适配器类型
export type ProtocolAdapterType = 'onebot11' | 'napcat-protocol';
// 协议适配器接口
export interface IProtocolAdapter {
readonly name: string;
readonly enabled: boolean;
init (): Promise<void>;
close (): Promise<void>;
}
// 协议适配器包装器
class OneBotAdapterWrapper implements IProtocolAdapter {
readonly name = 'onebot11';
private adapter: NapCatOneBot11Adapter;
constructor (adapter: NapCatOneBot11Adapter) {
this.adapter = adapter;
}
get enabled (): boolean {
return true; // OneBot11 默认启用
}
async init (): Promise<void> {
await this.adapter.InitOneBot();
}
async close (): Promise<void> {
await this.adapter.networkManager.closeAllAdapters();
}
getAdapter (): NapCatOneBot11Adapter {
return this.adapter;
}
}
// NapCat Protocol 适配器包装器
class NapCatProtocolAdapterWrapper implements IProtocolAdapter {
readonly name = 'napcat-protocol';
private adapter: NapCatProtocolAdapter;
constructor (adapter: NapCatProtocolAdapter) {
this.adapter = adapter;
}
get enabled (): boolean {
return this.adapter.isEnabled();
}
async init (): Promise<void> {
await this.adapter.initProtocol();
}
async close (): Promise<void> {
await this.adapter.close();
}
getAdapter (): NapCatProtocolAdapter {
return this.adapter;
}
}
// 协议适配器管理器
export class NapCatAdapterManager {
private core: NapCatCore;
private context: InstanceContext;
private pathWrapper: NapCatPathWrapper;
// 协议适配器实例
private onebotAdapter: OneBotAdapterWrapper | null = null;
private napcatProtocolAdapter: NapCatProtocolAdapterWrapper | null = null;
// 所有已注册的适配器
private adapters: Map<string, IProtocolAdapter> = new Map();
constructor (core: NapCatCore, context: InstanceContext, pathWrapper: NapCatPathWrapper) {
this.core = core;
this.context = context;
this.pathWrapper = pathWrapper;
}
// 初始化所有协议适配器
async initAdapters (): Promise<void> {
this.context.logger.log('[AdapterManager] 开始初始化协议适配器...');
// 初始化 OneBot11 适配器 (默认启用)
try {
const onebot = new NapCatOneBot11Adapter(this.core, this.context, this.pathWrapper);
this.onebotAdapter = new OneBotAdapterWrapper(onebot);
this.adapters.set('onebot11', this.onebotAdapter);
await this.onebotAdapter.init();
this.context.logger.log('[AdapterManager] OneBot11 适配器初始化完成');
} catch (e) {
this.context.logger.logError('[AdapterManager] OneBot11 适配器初始化失败:', e);
}
// 初始化 NapCat Protocol 适配器 (默认关闭,需要配置启用)
try {
const napcatProtocol = new NapCatProtocolAdapter(this.core, this.context, this.pathWrapper);
this.napcatProtocolAdapter = new NapCatProtocolAdapterWrapper(napcatProtocol);
this.adapters.set('napcat-protocol', this.napcatProtocolAdapter);
if (this.napcatProtocolAdapter.enabled) {
await this.napcatProtocolAdapter.init();
this.context.logger.log('[AdapterManager] NapCat Protocol 适配器初始化完成');
} else {
this.context.logger.log('[AdapterManager] NapCat Protocol 适配器未启用,跳过初始化');
}
} catch (e) {
this.context.logger.logError('[AdapterManager] NapCat Protocol 适配器初始化失败:', e);
}
this.context.logger.log(`[AdapterManager] 协议适配器初始化完成,已加载 ${this.adapters.size} 个适配器`);
}
// 获取 OneBot11 适配器
getOneBotAdapter (): NapCatOneBot11Adapter | null {
return this.onebotAdapter?.getAdapter() ?? null;
}
// 获取 NapCat Protocol 适配器
getNapCatProtocolAdapter (): NapCatProtocolAdapter | null {
return this.napcatProtocolAdapter?.getAdapter() ?? null;
}
// 获取指定适配器
getAdapter (name: ProtocolAdapterType): IProtocolAdapter | undefined {
return this.adapters.get(name);
}
// 获取所有已启用的适配器
getEnabledAdapters (): IProtocolAdapter[] {
return Array.from(this.adapters.values()).filter(adapter => adapter.enabled);
}
// 获取所有适配器
getAllAdapters (): IProtocolAdapter[] {
return Array.from(this.adapters.values());
}
// 关闭所有适配器
async closeAllAdapters (): Promise<void> {
this.context.logger.log('[AdapterManager] 开始关闭所有协议适配器...');
for (const [name, adapter] of this.adapters) {
try {
await adapter.close();
this.context.logger.log(`[AdapterManager] ${name} 适配器已关闭`);
} catch (e) {
this.context.logger.logError(`[AdapterManager] 关闭 ${name} 适配器失败:`, e);
}
}
this.adapters.clear();
this.context.logger.log('[AdapterManager] 所有协议适配器已关闭');
}
// 重新加载指定适配器
async reloadAdapter (name: ProtocolAdapterType): Promise<void> {
const adapter = this.adapters.get(name);
if (adapter) {
await adapter.close();
await adapter.init();
this.context.logger.log(`[AdapterManager] ${name} 适配器已重新加载`);
}
}
}
export { NapCatOneBot11Adapter } from 'napcat-onebot';
export { NapCatProtocolAdapter } from 'napcat-protocol';
+30
View File
@@ -0,0 +1,30 @@
{
"name": "napcat-adapter",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "index.ts",
"scripts": {
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
},
"exports": {
".": {
"import": "./index.ts"
},
"./*": {
"import": "./*"
}
},
"dependencies": {
"napcat-core": "workspace:*",
"napcat-common": "workspace:*",
"napcat-onebot": "workspace:*",
"napcat-protocol": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.0.1"
},
"engines": {
"node": ">=18.0.0"
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"include": [
"*.ts",
"**/*.ts"
],
"exclude": [
"node_modules",
"dist"
]
}
+27
View File
@@ -0,0 +1,27 @@
{
"name": "napcat-common",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
},
"exports": {
".": {
"import": "./src/index.ts"
},
"./src/*": {
"import": "./src/*"
}
},
"dependencies": {
"file-type": "^21.0.0"
},
"devDependencies": {
"@types/node": "^22.0.1"
},
"engines": {
"node": ">=18.0.0"
}
}
+79
View File
@@ -0,0 +1,79 @@
export type TaskExecutor<T> = (resolve: (value: T | PromiseLike<T>) => void, reject: (reason?: any) => void, onCancel: (callback: () => void) => void) => void | Promise<void>;
export class CancelableTask<T> {
private promise: Promise<T>;
private cancelCallback: (() => void) | null = null;
private isCanceled = false;
private cancelListeners: Array<() => void> = [];
constructor (executor: TaskExecutor<T>) {
this.promise = new Promise<T>((resolve, reject) => {
const onCancel = (callback: () => void) => {
this.cancelCallback = callback;
};
const execute = async () => {
try {
await executor(
(value) => {
if (!this.isCanceled) {
resolve(value);
}
},
(reason) => {
if (!this.isCanceled) {
reject(reason);
}
},
onCancel
);
} catch (error) {
if (!this.isCanceled) {
reject(error);
}
}
};
execute();
});
}
public cancel () {
if (this.cancelCallback) {
this.cancelCallback();
}
this.isCanceled = true;
this.cancelListeners.forEach(listener => listener());
}
public isTaskCanceled (): boolean {
return this.isCanceled;
}
public onCancel (listener: () => void) {
this.cancelListeners.push(listener);
}
public then<TResult1 = T, TResult2 = never>(
onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null,
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null
): Promise<TResult1 | TResult2> {
return this.promise.then(onfulfilled, onrejected);
}
public catch<TResult = never>(
onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | undefined | null
): Promise<T | TResult> {
return this.promise.catch(onrejected);
}
public finally (onfinally?: (() => void) | undefined | null): Promise<T> {
return this.promise.finally(onfinally);
}
[Symbol.asyncIterator] () {
return {
next: () => this.promise.then(value => ({ value, done: true })),
};
}
}
+229
View File
@@ -0,0 +1,229 @@
import fs from 'fs';
// generate Claude 3.7 Sonet Thinking
interface FileRecord {
filePath: string;
addedTime: number;
retries: number;
}
interface CleanupTask {
fileRecord: FileRecord;
timer: NodeJS.Timeout;
}
class CleanupQueue {
private tasks: Map<string, CleanupTask> = new Map();
private readonly MAX_RETRIES = 3;
private isProcessing: boolean = false;
private pendingOperations: Array<() => void> = [];
/**
* 执行队列中的待处理操作,确保异步安全
*/
private executeNextOperation (): void {
if (this.pendingOperations.length === 0) {
this.isProcessing = false;
return;
}
this.isProcessing = true;
const operation = this.pendingOperations.shift();
operation?.();
// 使用 setImmediate 允许事件循环继续,防止阻塞
setImmediate(() => this.executeNextOperation());
}
/**
* 安全执行操作,防止竞态条件
* @param operation 要执行的操作
*/
private safeExecute (operation: () => void): void {
this.pendingOperations.push(operation);
if (!this.isProcessing) {
this.executeNextOperation();
}
}
/**
* 检查文件是否存在
* @param filePath 文件路径
* @returns 文件是否存在
*/
private fileExists (filePath: string): boolean {
try {
return fs.existsSync(filePath);
} catch (_error) {
// console.log(`检查文件存在出错: ${filePath}`, error);
return false;
}
}
/**
* 添加文件到清理队列
* @param filePath 文件路径
* @param cleanupDelay 清理延迟时间(毫秒)
*/
addFile (filePath: string, cleanupDelay: number): void {
this.safeExecute(() => {
// 如果文件已在队列中,取消原来的计时器
if (this.tasks.has(filePath)) {
this.cancelCleanup(filePath);
}
// 创建新的文件记录
const fileRecord: FileRecord = {
filePath,
addedTime: Date.now(),
retries: 0,
};
// 设置计时器
const timer = setTimeout(() => {
this.cleanupFile(fileRecord, cleanupDelay);
}, cleanupDelay);
// 添加到任务队列
this.tasks.set(filePath, { fileRecord, timer });
});
}
/**
* 批量添加文件到清理队列
* @param filePaths 文件路径数组
* @param cleanupDelay 清理延迟时间(毫秒)
*/
addFiles (filePaths: string[], cleanupDelay: number): void {
this.safeExecute(() => {
for (const filePath of filePaths) {
// 内部直接处理,不通过 safeExecute 以保证批量操作的原子性
if (this.tasks.has(filePath)) {
// 取消已有的计时器,但不使用 cancelCleanup 方法以避免重复的安全检查
const existingTask = this.tasks.get(filePath);
if (existingTask) {
clearTimeout(existingTask.timer);
}
}
const fileRecord: FileRecord = {
filePath,
addedTime: Date.now(),
retries: 0,
};
const timer = setTimeout(() => {
this.cleanupFile(fileRecord, cleanupDelay);
}, cleanupDelay);
this.tasks.set(filePath, { fileRecord, timer });
}
});
}
/**
* 清理文件
* @param record 文件记录
* @param delay 延迟时间,用于重试
*/
private cleanupFile (record: FileRecord, delay: number): void {
this.safeExecute(() => {
// 首先检查文件是否存在,不存在则视为清理成功
if (!this.fileExists(record.filePath)) {
// console.log(`文件已不存在,跳过清理: ${record.filePath}`);
this.tasks.delete(record.filePath);
return;
}
try {
// 尝试删除文件
fs.unlinkSync(record.filePath);
// 删除成功,从队列中移除任务
this.tasks.delete(record.filePath);
} catch (error) {
const err = error as NodeJS.ErrnoException;
// 明确处理文件不存在的情况
if (err.code === 'ENOENT') {
// console.log(`文件在删除时不存在,视为清理成功: ${record.filePath}`);
this.tasks.delete(record.filePath);
return;
}
// 文件没有访问权限等情况
if (err.code === 'EACCES' || err.code === 'EPERM') {
// console.error(`没有权限删除文件: ${record.filePath}`, err);
}
// 其他删除失败情况,考虑重试
if (record.retries < this.MAX_RETRIES - 1) {
// 还有重试机会,增加重试次数
record.retries++;
// console.log(`清理文件失败,将重试(${record.retries}/${this.MAX_RETRIES}): ${record.filePath}`);
// 设置相同的延迟时间再次尝试
const timer = setTimeout(() => {
this.cleanupFile(record, delay);
}, delay);
// 更新任务
this.tasks.set(record.filePath, { fileRecord: record, timer });
} else {
// 已达到最大重试次数,从队列中移除任务
this.tasks.delete(record.filePath);
// console.error(`清理文件失败,已达最大重试次数(${this.MAX_RETRIES}): ${record.filePath}`, error);
}
}
});
}
/**
* 取消文件的清理任务
* @param filePath 文件路径
* @returns 是否成功取消
*/
cancelCleanup (filePath: string): boolean {
let cancelled = false;
this.safeExecute(() => {
const task = this.tasks.get(filePath);
if (task) {
clearTimeout(task.timer);
this.tasks.delete(filePath);
cancelled = true;
}
});
return cancelled;
}
/**
* 获取队列中的文件数量
* @returns 文件数量
*/
getQueueSize (): number {
return this.tasks.size;
}
/**
* 获取所有待清理的文件
* @returns 文件路径数组
*/
getPendingFiles (): string[] {
return Array.from(this.tasks.keys());
}
/**
* 清空所有清理任务
*/
clearAll (): void {
this.safeExecute(() => {
// 取消所有定时器
for (const task of this.tasks.values()) {
clearTimeout(task.timer);
}
this.tasks.clear();
// console.log('已清空所有清理任务');
});
}
}
export const cleanTaskQueue = new CleanupQueue();
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
declare global {
interface ImportMetaEnv {
readonly VITE_NAPCAT_VERSION: string;
}
}
export {};
+42
View File
@@ -0,0 +1,42 @@
type Handler<T> = () => T | Promise<T>;
type Checker<T> = (result: T) => T | Promise<T>;
export class Fallback<T> {
private handlers: Handler<T>[] = [];
private checker: Checker<T>;
constructor (checker?: Checker<T>) {
this.checker = checker || (async (result: T) => result);
}
add (handler: Handler<T>): this {
this.handlers.push(handler);
return this;
}
// 执行处理程序链
async run (): Promise<T> {
const errors: Error[] = [];
for (const handler of this.handlers) {
try {
const result = await handler();
const data = await this.checker(result);
if (data) {
return data;
}
} catch (error) {
errors.push(error instanceof Error ? error : new Error(String(error)));
}
}
throw new AggregateError(errors, 'All handlers failed');
}
}
export class FallbackUtil {
static boolchecker<T>(value: T, condition: boolean): T {
if (condition) {
return value;
} else {
throw new Error('Condition is false, throwing error');
}
}
}
+126
View File
@@ -0,0 +1,126 @@
import { Peer } from './types';
import { randomUUID } from 'crypto';
class TimeBasedCache<K, V> {
private cache = new Map<K, { value: V, timestamp: number, frequency: number }>();
private keyList = new Set<K>();
private operationCount = 0;
constructor (private maxCapacity: number, private ttl: number = 30 * 1000 * 60, private cleanupCount: number = 10) {}
public put (key: K, value: V): void {
const timestamp = Date.now();
const cacheEntry = { value, timestamp, frequency: 1 };
this.cache.set(key, cacheEntry);
this.keyList.add(key);
this.operationCount++;
if (this.keyList.size > this.maxCapacity) this.evict();
if (this.operationCount >= this.cleanupCount) this.cleanup(this.cleanupCount);
}
public get (key: K): V | undefined {
const entry = this.cache.get(key);
if (entry && Date.now() - entry.timestamp < this.ttl) {
entry.timestamp = Date.now();
entry.frequency++;
this.operationCount++;
if (this.operationCount >= this.cleanupCount) this.cleanup(this.cleanupCount);
return entry.value;
} else {
this.deleteKey(key);
}
return undefined;
}
private cleanup (count: number): void {
const currentTime = Date.now();
let cleaned = 0;
for (const key of this.keyList) {
if (cleaned >= count) break;
const entry = this.cache.get(key);
if (entry && currentTime - entry.timestamp >= this.ttl) {
this.deleteKey(key);
cleaned++;
}
}
this.operationCount = 0; // 重置操作计数器
}
private deleteKey (key: K): void {
this.cache.delete(key);
this.keyList.delete(key);
}
private evict (): void {
while (this.keyList.size > this.maxCapacity) {
let oldestKey: K | undefined;
let minFrequency = Infinity;
for (const key of this.keyList) {
const entry = this.cache.get(key);
if (entry && entry.frequency < minFrequency) {
minFrequency = entry.frequency;
oldestKey = key;
}
}
if (oldestKey !== undefined) this.deleteKey(oldestKey);
}
}
}
interface FileUUIDData {
peer: Peer;
modelId?: string;
fileId?: string;
msgId?: string;
elementId?: string;
fileUUID?: string;
busId?: number;
}
class FileUUIDManager {
private cache: TimeBasedCache<string, FileUUIDData>;
constructor (ttl: number) {
this.cache = new TimeBasedCache<string, FileUUIDData>(5000, ttl);
}
public encode (data: FileUUIDData, endString: string = '', customUUID?: string): string {
const uuid = customUUID || randomUUID().replace(/-/g, '') + endString;
this.cache.put(uuid, data);
return uuid;
}
public decode (uuid: string): FileUUIDData | undefined {
return this.cache.get(uuid);
}
}
export class FileNapCatOneBotUUIDWrap {
private manager: FileUUIDManager;
constructor (ttl: number = 86400000) {
this.manager = new FileUUIDManager(ttl);
}
public encodeModelId (peer: Peer, modelId: string, fileId: string, fileUUID: string = '', endString: string = '', customUUID?: string): string {
return this.manager.encode({ peer, modelId, fileId, fileUUID }, endString, customUUID);
}
public encodeGroupFile (peer: Peer, file: { fileModelId: string; fileId: string; busId: number; }): string {
return this.manager.encode({ peer, modelId: file.fileModelId, fileId: file.fileId, fileUUID: file.fileId, busId: file.busId });
}
public decodeModelId (uuid: string): FileUUIDData | undefined {
return this.manager.decode(uuid);
}
public encode (peer: Peer, msgId: string, elementId: string, fileUUID: string = '', customUUID?: string): string {
return this.manager.encode({ peer, msgId, elementId, fileUUID }, '', customUUID);
}
public decode (uuid: string): FileUUIDData | undefined {
return this.manager.decode(uuid);
}
}
export const FileNapCatOneBotUUID = new FileNapCatOneBotUUIDWrap();
+440
View File
@@ -0,0 +1,440 @@
import fs from 'fs';
import { stat } from 'fs/promises';
import crypto, { randomUUID } from 'crypto';
import path from 'node:path';
import http from 'node:http';
import tls from 'node:tls';
import { solveProblem } from '@/napcat-common/src/helper';
export interface HttpDownloadOptions {
url: string;
headers?: Record<string, string> | string;
proxy?: string;
}
export type Uri2LocalRes = {
success: boolean,
errMsg: string,
fileName: string,
path: string;
isLocal: boolean; // true=用户本地文件(不可删除),false=临时下载/解码文件(需清理)
};
// 定义一个异步函数来检查文件是否存在
export function checkFileExist (path: string, timeout: number = 3000): Promise<void> {
return new Promise((resolve, reject) => {
const startTime = Date.now();
function check () {
if (fs.existsSync(path)) {
resolve();
} else if (Date.now() - startTime > timeout) {
reject(new Error(`文件不存在: ${path}`));
} else {
setTimeout(check, 100);
}
}
check();
});
}
// 定义一个异步函数来检查文件是否存在
export async function checkFileExistV2 (path: string, timeout: number = 3000): Promise<void> {
// 使用 Promise.race 来同时进行文件状态检查和超时计时
// Promise.race 会返回第一个解决(resolve)或拒绝(reject)的 Promise
await Promise.race([
checkFile(path),
timeoutPromise(timeout, `文件不存在: ${path}`),
]);
}
// 转换超时时间至 Promise
function timeoutPromise (timeout: number, errorMsg: string): Promise<void> {
return new Promise((_resolve, reject) => {
setTimeout(() => {
reject(new Error(errorMsg));
}, timeout);
});
}
// 异步检查文件是否存在
async function checkFile (path: string): Promise<void> {
try {
await stat(path);
} catch (error: unknown) {
if ((error as Error & { code: string; }).code === 'ENOENT') {
// 如果文件不存在,则抛出一个错误
throw new Error(`文件不存在: ${path}`);
} else {
// 对于 stat 调用的其他错误,重新抛出
throw error;
}
}
// 如果文件存在,则无需做任何事情,Promise 解决(resolve)自身
}
export function calculateFileMD5 (filePath: string): Promise<string> {
return new Promise((resolve, reject) => {
// 创建一个流式读取器
const stream = fs.createReadStream(filePath);
const hash = crypto.createHash('md5');
stream.on('data', (data) => {
// 当读取到数据时,更新哈希对象的状态
hash.update(data);
});
stream.on('end', () => {
// 文件读取完成,计算哈希
const md5 = hash.digest('hex');
resolve(md5);
});
stream.on('error', (err: Error) => {
// 处理可能的读取错误
reject(err);
});
});
}
async function tryDownload (options: string | HttpDownloadOptions, useReferer: boolean = false): Promise<Response> {
let url: string;
let proxy: string | undefined;
let headers: Record<string, string> = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.71 Safari/537.36',
};
if (typeof options === 'string') {
url = options;
headers['Host'] = new URL(url).hostname;
} else {
url = options.url;
proxy = options.proxy;
if (options.headers) {
if (typeof options.headers === 'string') {
headers = JSON.parse(options.headers);
} else {
headers = options.headers;
}
}
}
if (useReferer && !headers['Referer']) {
headers['Referer'] = url;
}
// 如果配置了代理,使用代理下载
if (proxy) {
try {
const response = await httpDownloadWithProxy(url, headers, proxy);
return new Response(response, { status: 200, statusText: 'OK' });
} catch (proxyError) {
// 如果代理失败,记录错误并尝试直接下载
console.error('代理下载失败,尝试直接下载:', proxyError);
}
}
const fetchRes = await fetch(url, { headers, redirect: 'follow' }).catch((err) => {
if (err.cause) {
throw err.cause;
}
throw err;
});
return fetchRes;
}
/**
* 使用 HTTP/HTTPS 代理下载文件
*/
function httpDownloadWithProxy (url: string, headers: Record<string, string>, proxy: string): Promise<Buffer> {
return new Promise((resolve, reject) => {
const targetUrl = new URL(url);
const proxyUrl = new URL(proxy);
const isTargetHttps = targetUrl.protocol === 'https:';
const proxyPort = parseInt(proxyUrl.port) || (proxyUrl.protocol === 'https:' ? 443 : 80);
// 代理认证头
const proxyAuthHeader = proxyUrl.username && proxyUrl.password
? { 'Proxy-Authorization': 'Basic ' + Buffer.from(`${decodeURIComponent(proxyUrl.username)}:${decodeURIComponent(proxyUrl.password)}`).toString('base64') }
: {};
if (isTargetHttps) {
// HTTPS 目标:需要通过 CONNECT 建立隧道
const connectReq = http.request({
host: proxyUrl.hostname,
port: proxyPort,
method: 'CONNECT',
path: `${targetUrl.hostname}:${targetUrl.port || 443}`,
headers: {
Host: `${targetUrl.hostname}:${targetUrl.port || 443}`,
...proxyAuthHeader,
},
});
connectReq.on('connect', (res, socket) => {
if (res.statusCode !== 200) {
socket.destroy();
reject(new Error(`代理 CONNECT 失败: ${res.statusCode} ${res.statusMessage}`));
return;
}
// 在隧道上建立 TLS 连接
const tlsSocket = tls.connect({
socket,
servername: targetUrl.hostname,
rejectUnauthorized: true,
}, () => {
// TLS 握手成功,发送 HTTP 请求
const requestPath = targetUrl.pathname + targetUrl.search;
const requestHeaders = {
...headers,
Host: targetUrl.hostname,
Connection: 'close',
};
const headerLines = Object.entries(requestHeaders)
.map(([key, value]) => `${key}: ${value}`)
.join('\r\n');
const httpRequest = `GET ${requestPath} HTTP/1.1\r\n${headerLines}\r\n\r\n`;
tlsSocket.write(httpRequest);
});
// 解析 HTTP 响应
let responseData = Buffer.alloc(0);
let headersParsed = false;
let statusCode = 0;
let isChunked = false;
let bodyData = Buffer.alloc(0);
let redirectLocation: string | null = null;
tlsSocket.on('data', (chunk: Buffer) => {
responseData = Buffer.concat([responseData, chunk]);
if (!headersParsed) {
const headerEndIndex = responseData.indexOf('\r\n\r\n');
if (headerEndIndex !== -1) {
headersParsed = true;
const headerStr = responseData.subarray(0, headerEndIndex).toString();
const headerLines = headerStr.split('\r\n');
// 解析状态码
const statusLine = headerLines[0];
const statusMatch = statusLine?.match(/HTTP\/\d\.\d\s+(\d+)/);
statusCode = statusMatch ? parseInt(statusMatch[1]!) : 0;
// 解析响应头
for (const line of headerLines.slice(1)) {
const [key, ...valueParts] = line.split(':');
const value = valueParts.join(':').trim();
if (key?.toLowerCase() === 'transfer-encoding' && value.toLowerCase() === 'chunked') {
isChunked = true;
} else if (key?.toLowerCase() === 'location') {
redirectLocation = value;
}
}
bodyData = responseData.subarray(headerEndIndex + 4);
}
} else {
bodyData = Buffer.concat([bodyData, chunk]);
}
});
tlsSocket.on('end', () => {
// 处理重定向
if (statusCode >= 300 && statusCode < 400 && redirectLocation) {
const redirectUrl = redirectLocation.startsWith('http')
? redirectLocation
: `${targetUrl.protocol}//${targetUrl.host}${redirectLocation}`;
httpDownloadWithProxy(redirectUrl, headers, proxy).then(resolve).catch(reject);
return;
}
if (statusCode !== 200) {
reject(new Error(`下载失败: ${statusCode}`));
return;
}
// 处理 chunked 编码
if (isChunked) {
resolve(parseChunkedBody(bodyData));
} else {
resolve(bodyData);
}
});
tlsSocket.on('error', (err) => {
reject(new Error(`TLS 连接错误: ${err.message}`));
});
});
connectReq.on('error', (err) => {
reject(new Error(`代理连接错误: ${err.message}`));
});
connectReq.end();
} else {
// HTTP 目标:直接通过代理请求
const req = http.request({
host: proxyUrl.hostname,
port: proxyPort,
method: 'GET',
path: url, // 完整 URL
headers: {
...headers,
Host: targetUrl.hostname,
...proxyAuthHeader,
},
}, (response) => {
handleResponse(response, resolve, reject, url, headers, proxy);
});
req.on('error', (err) => {
reject(new Error(`代理请求错误: ${err.message}`));
});
req.end();
}
});
}
/**
* 解析 chunked 编码的响应体
*/
function parseChunkedBody (data: Buffer): Buffer {
const chunks: Buffer[] = [];
let offset = 0;
while (offset < data.length) {
// 查找 chunk 大小行的结束
const lineEnd = data.indexOf('\r\n', offset);
if (lineEnd === -1) break;
const sizeStr = data.subarray(offset, lineEnd).toString().split(';')[0]; // 忽略 chunk 扩展
const chunkSize = parseInt(sizeStr!, 16);
if (chunkSize === 0) break; // 最后一个 chunk
const chunkStart = lineEnd + 2;
const chunkEnd = chunkStart + chunkSize;
if (chunkEnd > data.length) break;
chunks.push(data.subarray(chunkStart, chunkEnd));
offset = chunkEnd + 2; // 跳过 chunk 数据后的 \r\n
}
return Buffer.concat(chunks);
}
/**
* 处理 HTTP 响应
*/
function handleResponse (
response: http.IncomingMessage,
resolve: (value: Buffer) => void,
reject: (reason: Error) => void,
_url: string,
headers: Record<string, string>,
proxy: string
): void {
// 处理重定向
if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
httpDownloadWithProxy(response.headers.location, headers, proxy).then(resolve).catch(reject);
return;
}
if (response.statusCode !== 200) {
reject(new Error(`下载失败: ${response.statusCode} ${response.statusMessage}`));
return;
}
const chunks: Buffer[] = [];
response.on('data', (chunk: Buffer) => chunks.push(chunk));
response.on('end', () => resolve(Buffer.concat(chunks)));
response.on('error', reject);
}
export async function httpDownload (options: string | HttpDownloadOptions): Promise<Buffer> {
const useReferer = typeof options === 'string';
let resp = await tryDownload(options);
if (resp.status === 403 && useReferer) {
resp = await tryDownload(options, true);
}
if (!resp.ok) throw new Error(`下载文件失败: ${resp.statusText}`);
const blob = await resp.blob();
const buffer = await blob.arrayBuffer();
return Buffer.from(buffer);
}
export enum FileUriType {
Unknown = 0,
Local = 1,
Remote = 2,
Base64 = 3,
}
export async function checkUriType (Uri: string) {
const LocalFileRet = await solveProblem((uri: string) => {
if (fs.existsSync(path.normalize(uri))) {
return { Uri: path.normalize(uri), Type: FileUriType.Local };
}
return undefined;
}, Uri);
if (LocalFileRet) return LocalFileRet;
const OtherFileRet = await solveProblem((uri: string) => {
// 再判断是否是Http
if (uri.startsWith('http:') || uri.startsWith('https:')) {
return { Uri: uri, Type: FileUriType.Remote };
}
// 再判断是否是Base64
if (uri.startsWith('base64:')) {
return { Uri: uri, Type: FileUriType.Base64 };
}
// 默认file://
if (uri.startsWith('file:')) {
const filePath: string = decodeURIComponent(uri.startsWith('file:///') && process.platform === 'win32' ? uri.slice(8) : uri.slice(7));
return { Uri: filePath, Type: FileUriType.Local };
}
if (uri.startsWith('data:')) {
const data = uri.split(',')[1];
if (data) return { Uri: data, Type: FileUriType.Base64 };
}
return undefined;
}, Uri);
if (OtherFileRet) return OtherFileRet;
return { Uri, Type: FileUriType.Unknown };
}
export async function uriToLocalFile (dir: string, uri: string, filename: string = randomUUID(), headers?: Record<string, string>, proxy?: string): Promise<Uri2LocalRes> {
const { Uri: HandledUri, Type: UriType } = await checkUriType(uri);
const filePath = path.join(dir, filename);
switch (UriType) {
case FileUriType.Local: {
const fileExt = path.extname(HandledUri);
const localFileName = path.basename(HandledUri, fileExt) + fileExt;
// 直接返回原始路径,不复制,避免不必要的 IO
return { success: true, errMsg: '', fileName: localFileName, path: HandledUri, isLocal: true };
}
case FileUriType.Remote: {
const buffer = await httpDownload({ url: HandledUri, headers: headers ?? {}, proxy });
fs.writeFileSync(filePath, buffer);
return { success: true, errMsg: '', fileName: filename, path: filePath, isLocal: false };
}
case FileUriType.Base64: {
const base64 = HandledUri.replace(/^base64:\/\//, '');
const base64Buffer = Buffer.from(base64, 'base64');
fs.writeFileSync(filePath, base64Buffer);
return { success: true, errMsg: '', fileName: filename, path: filePath, isLocal: false };
}
default:
return { success: false, errMsg: `识别URL失败, uri= ${uri}`, fileName: '', path: '', isLocal: false };
}
}
+280
View File
@@ -0,0 +1,280 @@
export interface ResourceConfig<T extends any[], R> {
/** 资源获取函数 */
resourceFn: (...args: T) => Promise<R>;
/** 失败后禁用时间(毫秒),默认 30 秒 */
disableTime?: number;
/** 最大重试次数,默认 3 次 */
maxRetries?: number;
/** 主动测试间隔(毫秒),默认 60 秒 */
healthCheckInterval?: number;
/** 最大健康检查失败次数,超过后永久禁用,默认 5 次 */
maxHealthCheckFailures?: number;
/** 健康检查函数,如果提供则优先使用此函数进行健康检查 */
healthCheckFn?: (...args: T) => Promise<boolean>;
/** 测试参数(用于健康检查) */
testArgs?: T;
}
interface ResourceTypeState {
/** 资源配置 */
config: {
resourceFn: (...args: any[]) => Promise<any>;
healthCheckFn?: (...args: any[]) => Promise<boolean>;
disableTime: number;
maxRetries: number;
healthCheckInterval: number;
maxHealthCheckFailures: number;
testArgs?: any[];
};
/** 是否启用 */
isEnabled: boolean;
/** 禁用截止时间 */
disableUntil: number;
/** 当前重试次数 */
currentRetries: number;
/** 健康检查失败次数 */
healthCheckFailureCount: number;
/** 是否永久禁用 */
isPermanentlyDisabled: boolean;
/** 上次健康检查时间 */
lastHealthCheckTime: number;
/** 成功次数统计 */
successCount: number;
/** 失败次数统计 */
failureCount: number;
}
export class ResourceManager {
private resourceTypes = new Map<string, ResourceTypeState>();
private destroyed = false;
/**
* 调用资源(自动注册或复用已有配置)
*/
async callResource<T extends any[], R>(
type: string,
config: ResourceConfig<T, R>,
...args: T
): Promise<R> {
if (this.destroyed) {
throw new Error('ResourceManager has been destroyed');
}
// 获取或创建资源类型状态
let state = this.resourceTypes.get(type);
if (!state) {
// 首次注册
state = {
config: {
resourceFn: config.resourceFn as (...args: any[]) => Promise<any>,
healthCheckFn: config.healthCheckFn as ((...args: any[]) => Promise<boolean>) | undefined,
disableTime: config.disableTime ?? 30000,
maxRetries: config.maxRetries ?? 3,
healthCheckInterval: config.healthCheckInterval ?? 60000,
maxHealthCheckFailures: config.maxHealthCheckFailures ?? 20,
testArgs: config.testArgs as any[] | undefined,
},
isEnabled: true,
disableUntil: 0,
currentRetries: 0,
healthCheckFailureCount: 0,
isPermanentlyDisabled: false,
lastHealthCheckTime: 0,
successCount: 0,
failureCount: 0,
};
this.resourceTypes.set(type, state);
}
// 在调用前检查是否需要进行健康检查
await this.checkAndPerformHealthCheck(state);
// 检查资源状态
if (state.isPermanentlyDisabled) {
throw new Error(`Resource type '${type}' is permanently disabled (success: ${state.successCount}, failure: ${state.failureCount})`);
}
if (!this.isResourceAvailable(type)) {
const disableUntilDate = new Date(state.disableUntil).toISOString();
throw new Error(`Resource type '${type}' is currently disabled until ${disableUntilDate} (success: ${state.successCount}, failure: ${state.failureCount})`);
}
// 调用资源
try {
const result = await config.resourceFn(...args);
this.onResourceSuccess(state);
return result;
} catch (error) {
this.onResourceFailure(state);
throw error;
}
}
/**
* 检查资源类型是否可用
*/
isResourceAvailable (type: string): boolean {
const state = this.resourceTypes.get(type);
if (!state) {
return true; // 未注册的资源类型视为可用
}
if (state.isPermanentlyDisabled || !state.isEnabled) {
return false;
}
return Date.now() >= state.disableUntil;
}
/**
* 获取资源类型统计信息
*/
getResourceStats (type: string): { successCount: number; failureCount: number; isEnabled: boolean; isPermanentlyDisabled: boolean } | null {
const state = this.resourceTypes.get(type);
if (!state) {
return null;
}
return {
successCount: state.successCount,
failureCount: state.failureCount,
isEnabled: state.isEnabled,
isPermanentlyDisabled: state.isPermanentlyDisabled,
};
}
/**
* 获取所有资源类型统计
*/
getAllResourceStats (): Map<string, { successCount: number; failureCount: number; isEnabled: boolean; isPermanentlyDisabled: boolean }> {
const stats = new Map();
for (const [type, state] of this.resourceTypes) {
stats.set(type, {
successCount: state.successCount,
failureCount: state.failureCount,
isEnabled: state.isEnabled,
isPermanentlyDisabled: state.isPermanentlyDisabled,
});
}
return stats;
}
/**
* 注销资源类型
*/
unregister (type: string): boolean {
return this.resourceTypes.delete(type);
}
/**
* 销毁管理器
*/
destroy (): void {
if (this.destroyed) {
return;
}
this.resourceTypes.clear();
this.destroyed = true;
}
/**
* 检查并执行健康检查(如果需要)
*/
private async checkAndPerformHealthCheck (state: ResourceTypeState): Promise<void> {
// 如果资源可用或已永久禁用,无需健康检查
if (state.isEnabled && Date.now() >= state.disableUntil) {
return;
}
if (state.isPermanentlyDisabled) {
return;
}
const now = Date.now();
// 检查是否还在禁用期内
if (now < state.disableUntil) {
return;
}
// 检查是否需要进行健康检查(根据间隔时间)
if (now - state.lastHealthCheckTime < state.config.healthCheckInterval) {
return;
}
// 执行健康检查
await this.performHealthCheck(state);
}
private async performHealthCheck (state: ResourceTypeState): Promise<void> {
state.lastHealthCheckTime = Date.now();
try {
let healthCheckResult: boolean;
if (state.config.healthCheckFn) {
const testArgs = state.config.testArgs || [];
healthCheckResult = await state.config.healthCheckFn(...testArgs);
} else {
const testArgs = state.config.testArgs || [];
await state.config.resourceFn(...testArgs);
healthCheckResult = true;
}
if (healthCheckResult) {
// 健康检查成功,重新启用
state.isEnabled = true;
state.disableUntil = 0;
state.currentRetries = 0;
state.healthCheckFailureCount = 0;
} else {
throw new Error('Health check function returned false');
}
} catch {
// 健康检查失败,增加失败计数
state.healthCheckFailureCount++;
// 检查是否达到最大健康检查失败次数
if (state.healthCheckFailureCount >= state.config.maxHealthCheckFailures) {
// 永久禁用资源
state.isPermanentlyDisabled = true;
state.disableUntil = 0;
} else {
// 继续禁用一段时间
state.disableUntil = Date.now() + state.config.disableTime;
}
}
}
private onResourceSuccess (state: ResourceTypeState): void {
state.currentRetries = 0;
state.disableUntil = 0;
state.healthCheckFailureCount = 0;
state.successCount++;
}
private onResourceFailure (state: ResourceTypeState): void {
state.currentRetries++;
state.failureCount++;
// 如果重试次数达到上限,禁用资源
if (state.currentRetries >= state.config.maxRetries) {
state.disableUntil = Date.now() + state.config.disableTime;
state.currentRetries = 0;
}
}
}
// 创建全局实例
export const resourceManager = new ResourceManager();
// 便捷函数
export async function registerResource<T extends any[], R> (
type: string,
config: ResourceConfig<T, R>,
...args: T
): Promise<R> {
return resourceManager.callResource(type, config, ...args);
}
+268
View File
@@ -0,0 +1,268 @@
import path from 'node:path';
import fs from 'fs';
import os from 'node:os';
import { QQVersionConfigType, QQLevel } from './types';
import { compareSemVer } from './version';
import { getAllGitHubTags as getAllTagsFromMirror } from './mirror';
// 导出 compareSemVer 供其他模块使用
export { compareSemVer } from './version';
export async function solveProblem<T extends (...arg: any[]) => any> (func: T, ...args: Parameters<T>): Promise<ReturnType<T> | undefined> {
return new Promise<ReturnType<T> | undefined>((resolve) => {
try {
const result = func(...args);
resolve(result);
} catch {
resolve(undefined);
}
});
}
export async function solveAsyncProblem<T extends (...args: any[]) => Promise<any>> (func: T, ...args: Parameters<T>): Promise<Awaited<ReturnType<T>> | undefined> {
return new Promise<Awaited<ReturnType<T>> | undefined>((resolve) => {
func(...args).then((result) => {
resolve(result);
}).catch(() => {
resolve(undefined);
});
});
}
export function sleep (ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function PromiseTimer<T> (promise: Promise<T>, ms: number): Promise<T> {
const timeoutPromise = new Promise<T>((_resolve, reject) =>
setTimeout(() => reject(new Error('PromiseTimer: Operation timed out')), ms)
);
return Promise.race([promise, timeoutPromise]);
}
export async function runAllWithTimeout<T> (tasks: Promise<T>[], timeout: number): Promise<T[]> {
const wrappedTasks = tasks.map((task) =>
PromiseTimer(task, timeout).then(
(result) => ({ status: 'fulfilled', value: result }),
(error) => ({ status: 'rejected', reason: error })
)
);
const results = await Promise.all(wrappedTasks);
return results
.filter((result) => result.status === 'fulfilled')
.map((result) => (result as { status: 'fulfilled'; value: T; }).value);
}
export function isNull (value: any) {
return value === undefined || value === null;
}
export function isNumeric (str: string) {
return /^\d+$/.test(str);
}
export function truncateString (obj: any, maxLength = 500) {
if (obj !== null && typeof obj === 'object') {
Object.keys(obj).forEach((key) => {
if (typeof obj[key] === 'string') {
// 如果是字符串且超过指定长度,则截断
if (obj[key].length > maxLength) {
obj[key] = obj[key].substring(0, maxLength) + '...';
}
} else if (typeof obj[key] === 'object') {
// 如果是对象或数组,则递归调用
truncateString(obj[key], maxLength);
}
});
}
return obj;
}
export function isEqual (obj1: any, obj2: any) {
if (obj1 === obj2) return true;
if (obj1 == null || obj2 == null) return false;
if (typeof obj1 !== 'object' || typeof obj2 !== 'object') return obj1 === obj2;
const keys1 = Object.keys(obj1);
const keys2 = Object.keys(obj2);
if (keys1.length !== keys2.length) return false;
for (const key of keys1) {
if (!isEqual(obj1[key], obj2[key])) return false;
}
return true;
}
export function getDefaultQQVersionConfigInfo (): QQVersionConfigType {
if (os.platform() === 'linux') {
return {
baseVersion: '3.2.12.28060',
curVersion: '3.2.12.28060',
prevVersion: '',
onErrorVersions: [],
buildId: '27254',
};
}
if (os.platform() === 'darwin') {
return {
baseVersion: '6.9.53.28060',
curVersion: '6.9.53.28060',
prevVersion: '',
onErrorVersions: [],
buildId: '28060',
};
}
return {
baseVersion: '9.9.15-28131',
curVersion: '9.9.15-28131',
prevVersion: '',
onErrorVersions: [],
buildId: '28131',
};
}
export function getQQPackageInfoPath (exePath: string = '', version?: string): string {
if (process.env['NAPCAT_QQ_PACKAGE_INFO_PATH']) {
return process.env['NAPCAT_QQ_PACKAGE_INFO_PATH'];
}
let packagePath;
if (os.platform() === 'darwin') {
packagePath = path.join(path.dirname(exePath), '..', 'Resources', 'app', 'package.json');
} else if (os.platform() === 'linux') {
packagePath = path.join(path.dirname(exePath), './resources/app/package.json');
} else {
packagePath = path.join(path.dirname(exePath), './versions/' + version + '/resources/app/package.json');
}
// 下面是老版本兼容 未来去掉
if (!fs.existsSync(packagePath)) {
packagePath = path.join(path.dirname(exePath), './resources/app/versions/' + version + '/package.json');
}
return packagePath;
}
export function getQQVersionConfigPath (exePath: string = ''): string | undefined {
if (process.env['NAPCAT_QQ_VERSION_CONFIG_PATH']) {
return process.env['NAPCAT_QQ_VERSION_CONFIG_PATH'];
}
let configVersionInfoPath;
if (os.platform() === 'win32') {
configVersionInfoPath = path.join(path.dirname(exePath), 'versions', 'config.json');
} else if (os.platform() === 'darwin') {
const userPath = os.homedir();
const appDataPath = path.resolve(userPath, './Library/Application Support/QQ');
configVersionInfoPath = path.resolve(appDataPath, './versions/config.json');
} else {
const userPath = os.homedir();
const appDataPath = path.resolve(userPath, './.config/QQ');
configVersionInfoPath = path.resolve(appDataPath, './versions/config.json');
}
if (typeof configVersionInfoPath !== 'string') {
return undefined;
}
// 老版本兼容 未来去掉
if (!fs.existsSync(configVersionInfoPath)) {
configVersionInfoPath = path.join(path.dirname(exePath), './resources/app/versions/config.json');
}
if (!fs.existsSync(configVersionInfoPath)) {
return undefined;
}
return configVersionInfoPath;
}
export function calcQQLevel (level?: QQLevel) {
if (!level) return 0;
// const { penguinNum, crownNum, sunNum, moonNum, starNum } = level;
const { crownNum, sunNum, moonNum, starNum } = level;
// 没补类型
return crownNum * 64 + sunNum * 16 + moonNum * 4 + starNum;
}
export function stringifyWithBigInt (obj: any) {
return JSON.stringify(obj, (_key, value) =>
typeof value === 'bigint' ? value.toString() : value
);
}
export function parseAppidFromMajorV2 (nodeMajor: string): string | undefined {
const marker = Buffer.from('QQAppId/', 'utf-8');
const filePath = path.resolve(nodeMajor);
const fileContent = fs.readFileSync(filePath);
let searchPosition = 0;
while (true) {
const index = fileContent.indexOf(marker, searchPosition);
if (index === -1) {
break;
}
const start = index + marker.length;
const end = fileContent.indexOf(0x00, start);
if (end === -1) {
break;
}
const content = fileContent.subarray(start, end);
const str = content.toString('utf-8');
if (/^\d+$/.test(str)) {
return str;
}
searchPosition = end + 1;
}
return undefined;
}
export function parseAppidFromMajor (nodeMajor: string): string | undefined {
const hexSequence = 'A4 09 00 00 00 35';
const sequenceBytes = Buffer.from(hexSequence.replace(/ /g, ''), 'hex');
const filePath = path.resolve(nodeMajor);
const fileContent = fs.readFileSync(filePath);
let searchPosition = 0;
while (true) {
const index = fileContent.indexOf(sequenceBytes, searchPosition);
if (index === -1) {
break;
}
const start = index + sequenceBytes.length - 1;
const end = fileContent.indexOf(0x00, start);
if (end === -1) {
break;
}
const content = fileContent.subarray(start, end);
if (!content.every(byte => byte === 0x00)) {
try {
return content.toString('utf-8');
} catch {
break;
}
}
searchPosition = end + 1;
}
return undefined;
}
// ============== GitHub Tags 获取 ==============
// 使用 mirror 模块统一管理镜像
export async function getAllTags (mirror?: string): Promise<{ tags: string[], mirror: string; }> {
return getAllTagsFromMirror('NapNeko', 'NapCatQQ', mirror);
}
export async function getLatestTag (mirror?: string): Promise<string> {
const { tags } = await getAllTags(mirror);
// 使用 SemVer 规范排序
tags.sort((a, b) => compareSemVer(a, b));
const latest = tags.at(-1);
if (!latest) {
throw new Error('No tags found');
}
// 去掉开头的 v
return latest.replace(/^v/, '');
}
@@ -0,0 +1,24 @@
export enum LogLevel {
DEBUG = 'debug',
INFO = 'info',
WARN = 'warn',
ERROR = 'error',
FATAL = 'fatal',
}
export interface ILogWrapper {
fileLogEnabled: boolean;
consoleLogEnabled: boolean;
cleanOldLogs (logDir: string): void;
setFileAndConsoleLogLevel (fileLogLevel: LogLevel, consoleLogLevel: LogLevel): void;
setLogSelfInfo (selfInfo: { nick: string; uid: string; }): void;
setFileLogEnabled (isEnabled: boolean): void;
setConsoleLogEnabled (isEnabled: boolean): void;
formatMsg (msg: any[]): string;
_log (level: LogLevel, ...args: any[]): void;
log (...args: any[]): void;
logDebug (...args: any[]): void;
logError (...args: any[]): void;
logWarn (...args: any[]): void;
logFatal (...args: any[]): void;
logMessage (msg: unknown, selfInfo: unknown): void;
}
+43
View File
@@ -0,0 +1,43 @@
export class LRUCache<K, V> {
private capacity: number;
public cache: Map<K, V>;
constructor (capacity: number) {
this.capacity = capacity;
this.cache = new Map<K, V>();
}
public get (key: K): V | undefined {
const value = this.cache.get(key);
if (value !== undefined) {
// Move the accessed key to the end to mark it as most recently used
this.cache.delete(key);
this.cache.set(key, value);
}
return value;
}
public put (key: K, value: V): void {
if (this.cache.has(key)) {
// If the key already exists, move it to the end to mark it as most recently used
this.cache.delete(key);
} else if (this.cache.size >= this.capacity) {
// If the cache is full, remove the least recently used key (the first one in the map)
const firstKey = this.cache.keys().next().value;
if (firstKey !== undefined) {
this.cache.delete(firstKey);
}
}
this.cache.set(key, value);
}
public resetCapacity (newCapacity: number): void {
this.capacity = newCapacity;
while (this.cache.size > this.capacity) {
const firstKey = this.cache.keys().next().value;
if (firstKey !== undefined) {
this.cache.delete(firstKey);
}
}
}
}
@@ -0,0 +1,147 @@
import crypto from 'crypto';
import { Peer } from './types';
export class LimitedHashTable<K, V> {
private readonly keyToValue: Map<K, V> = new Map();
private readonly valueToKey: Map<V, K> = new Map();
private maxSize: number;
constructor (maxSize: number) {
this.maxSize = maxSize;
}
resize (count: number) {
this.maxSize = count;
}
set (key: K, value: V): void {
this.keyToValue.set(key, value);
this.valueToKey.set(value, key);
while (this.keyToValue.size !== this.valueToKey.size) {
this.keyToValue.clear();
this.valueToKey.clear();
}
while (this.keyToValue.size > this.maxSize || this.valueToKey.size > this.maxSize) {
const oldestKey = this.keyToValue.keys().next().value;
if (oldestKey !== undefined) {
this.valueToKey.delete(this.keyToValue.get(oldestKey) as V);
this.keyToValue.delete(oldestKey);
}
}
}
getValue (key: K): V | undefined {
return this.keyToValue.get(key);
}
getKey (value: V): K | undefined {
return this.valueToKey.get(value);
}
deleteByValue (value: V): void {
const key = this.valueToKey.get(value);
if (key !== undefined) {
this.keyToValue.delete(key);
this.valueToKey.delete(value);
}
}
deleteByKey (key: K): void {
const value = this.keyToValue.get(key);
if (value !== undefined) {
this.keyToValue.delete(key);
this.valueToKey.delete(value);
}
}
getKeyList (): K[] {
return Array.from(this.keyToValue.keys());
}
// 获取最近刚写入的几个值
getHeads (size: number): { key: K; value: V; }[] | undefined {
const keyList = this.getKeyList();
if (keyList.length === 0) {
return undefined;
}
const result: { key: K; value: V; }[] = [];
const listSize = Math.min(size, keyList.length);
for (let i = 0; i < listSize; i++) {
const key = keyList[listSize - i];
if (key !== undefined) {
result.push({ key, value: this.keyToValue.get(key)! });
}
}
return result;
}
}
class MessageUniqueWrapper {
private readonly msgDataMap: LimitedHashTable<string, number>;
private readonly msgIdMap: LimitedHashTable<string, number>;
constructor (maxMap: number = 5000) {
this.msgIdMap = new LimitedHashTable<string, number>(maxMap);
this.msgDataMap = new LimitedHashTable<string, number>(maxMap);
}
getRecentMsgIds (Peer: Peer, size: number): string[] {
const heads = this.msgIdMap.getHeads(size);
if (!heads) {
return [];
}
const data = heads.map((t) => MessageUnique.getMsgIdAndPeerByShortId(t.value));
const ret = data.filter((t) => t?.Peer.chatType === Peer.chatType && t?.Peer.peerUid === Peer.peerUid);
return ret.map((t) => t?.MsgId).filter((t) => t !== undefined);
}
createUniqueMsgId (peer: Peer, msgId: string) {
const key = `${msgId}|${peer.chatType}|${peer.peerUid}`;
const hash = crypto.createHash('md5').update(key).digest();
if (hash[0]) {
// 设置第一个bit为0 保证shortId为正数
hash[0] &= 0x7f;
}
const shortId = hash.readInt32BE(0);
// 减少性能损耗
this.msgIdMap.set(msgId, shortId);
this.msgDataMap.set(key, shortId);
return shortId;
}
getMsgIdAndPeerByShortId (shortId: number): { MsgId: string; Peer: Peer; } | undefined {
const data = this.msgDataMap.getKey(shortId);
if (data) {
const [msgId, chatTypeStr, peerUid] = data.split('|');
const peer: Peer = {
chatType: parseInt(chatTypeStr ?? '0'),
peerUid: peerUid ?? '',
guildId: '',
};
return { MsgId: msgId ?? '0', Peer: peer };
}
return undefined;
}
getShortIdByMsgId (msgId: string): number | undefined {
return this.msgIdMap.getValue(msgId);
}
getPeerByMsgId (msgId: string) {
const shortId = this.msgIdMap.getValue(msgId);
if (!shortId) return undefined;
return this.getMsgIdAndPeerByShortId(shortId);
}
resize (maxSize: number): void {
this.msgIdMap.resize(maxSize);
this.msgDataMap.resize(maxSize);
}
isShortId (message_id: string): boolean {
const num = Number(message_id);
// 判断是否是整数并且在 INT32 的范围内
return Number.isInteger(num) && num >= -2147483648 && num <= 2147483647;
}
}
export const MessageUnique: MessageUniqueWrapper = new MessageUniqueWrapper();
File diff suppressed because it is too large. Load diff
+49
View File
@@ -0,0 +1,49 @@
import path, { dirname } from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
import os from 'os';
export function getQQDataDirOverride (): string | undefined {
const dataDir = process.env['NAPCAT_QQ_DATA_DIR'];
return dataDir ? path.resolve(dataDir) : undefined;
}
export class NapCatPathWrapper {
binaryPath: string;
logsPath: string;
configPath: string;
cachePath: string;
staticPath: string;
pluginPath: string;
constructor (mainPath: string = dirname(fileURLToPath(import.meta.url))) {
this.binaryPath = mainPath;
let writePath: string;
const qqDataDir = getQQDataDirOverride();
if (process.env['NAPCAT_WORKDIR']) {
writePath = process.env['NAPCAT_WORKDIR'];
} else if (qqDataDir) {
writePath = path.join(qqDataDir, 'NapCat');
} else if (os.platform() === 'darwin') {
writePath = path.join(os.homedir(), 'Library', 'Application Support', 'QQ', 'NapCat');
} else {
writePath = this.binaryPath;
}
this.logsPath = path.join(writePath, 'logs');
this.configPath = path.join(writePath, 'config');
this.pluginPath = path.join(writePath, 'plugins');// dynamic load
this.cachePath = path.join(writePath, 'cache');
this.staticPath = path.join(this.binaryPath, 'static');
if (!fs.existsSync(this.logsPath)) {
fs.mkdirSync(this.logsPath, { recursive: true });
}
if (!fs.existsSync(this.configPath)) {
fs.mkdirSync(this.configPath, { recursive: true });
}
if (!fs.existsSync(this.cachePath)) {
fs.mkdirSync(this.cachePath, { recursive: true });
}
}
}
+130
View File
@@ -0,0 +1,130 @@
import https from 'node:https';
import http from 'node:http';
export class RequestUtil {
// 适用于获取服务器下发cookies时获取,仅GET
static async HttpsGetCookies (url: string): Promise<{ [key: string]: string; }> {
const client = url.startsWith('https') ? https : http;
return new Promise((resolve, reject) => {
const req = client.get(url, (res) => {
const cookies: { [key: string]: string; } = {};
res.on('data', () => { }); // Necessary to consume the stream
res.on('end', () => {
this.handleRedirect(res, url, cookies)
.then(resolve)
.catch(reject);
});
if (res.headers['set-cookie']) {
this.extractCookies(res.headers['set-cookie'], cookies);
}
});
req.on('error', (error: Error) => {
reject(error);
});
});
}
private static async handleRedirect (res: http.IncomingMessage, url: string, cookies: { [key: string]: string; }): Promise<{ [key: string]: string; }> {
if (res.statusCode === 301 || res.statusCode === 302) {
if (res.headers.location) {
const redirectUrl = new URL(res.headers.location, url);
const redirectCookies = await this.HttpsGetCookies(redirectUrl.href);
// 合并重定向过程中的cookies
return { ...cookies, ...redirectCookies };
}
}
return cookies;
}
private static extractCookies (setCookieHeaders: string[], cookies: { [key: string]: string; }) {
setCookieHeaders.forEach((cookie) => {
const parts = cookie.split(';')[0]?.split('=');
if (parts) {
const key = parts[0];
const value = parts[1];
if (key && value && key.length > 0 && value.length > 0) {
cookies[key] = value;
}
}
});
}
// 请求和回复都是JSON data传原始内容 自动编码json
// 支持 301/302 重定向(最多 5 次)
static async HttpGetJson<T> (url: string, method: string = 'GET', data?: any, headers: {
[key: string]: string;
} = {}, isJsonRet: boolean = true, isArgJson: boolean = true, maxRedirects: number = 5): Promise<T> {
const option = new URL(url);
const protocol = url.startsWith('https://') ? https : http;
const options = {
hostname: option.hostname,
port: option.port,
path: option.pathname + option.search,
method,
headers,
};
// headers: {
// 'Content-Type': 'application/json',
// 'Content-Length': Buffer.byteLength(postData),
// },
return new Promise((resolve, reject) => {
const req = protocol.request(options, (res: http.IncomingMessage) => {
// 处理重定向
if ((res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307 || res.statusCode === 308) && res.headers.location) {
if (maxRedirects <= 0) {
reject(new Error('Too many redirects'));
return;
}
const redirectUrl = new URL(res.headers.location, url).href;
// 递归跟随重定向
this.HttpGetJson<T>(redirectUrl, method, data, headers, isJsonRet, isArgJson, maxRedirects - 1)
.then(resolve)
.catch(reject);
return;
}
let responseBody = '';
res.on('data', (chunk: string | Buffer) => {
responseBody += chunk.toString();
});
res.on('end', () => {
try {
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
if (isJsonRet) {
const responseJson = JSON.parse(responseBody);
resolve(responseJson as T);
} else {
resolve(responseBody as T);
}
} else {
reject(new Error(`Unexpected status code: ${res.statusCode}`));
}
} catch (parseError: unknown) {
reject(new Error((parseError as Error).message));
}
});
});
req.on('error', (error: Error) => {
reject(error);
});
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
if (isArgJson) {
req.write(JSON.stringify(data));
} else {
req.write(data);
}
}
req.end();
});
}
// 请求返回都是原始内容
static async HttpGetText (url: string, method: string = 'GET', data?: any, headers: { [key: string]: string; } = {}) {
return this.HttpGetJson<string>(url, method, data, headers, false, false);
}
}
@@ -0,0 +1,24 @@
export interface SystemStatus {
cpu: {
model: string,
speed: string;
usage: {
system: string;
qq: string;
},
core: number;
},
memory: {
total: string;
usage: {
system: string;
qq: string;
};
},
arch: string;
}
export interface IStatusHelperSubscription {
on (event: 'statusUpdate', listener: (status: SystemStatus) => void): this;
off (event: 'statusUpdate', listener: (status: SystemStatus) => void): this;
emit (event: 'statusUpdate', status: SystemStatus): boolean;
}
+22
View File
@@ -0,0 +1,22 @@
class Store {
private store = new Map<string, any>();
set<T> (key: string, value: T, ttl?: number): void {
this.store.set(key, value);
if (ttl) {
setTimeout(() => this.store.delete(key), ttl * 1000);
}
}
get<T> (key: string): T | null {
return this.store.get(key) ?? null;
}
exists (...keys: string[]): number {
return keys.filter(key => this.store.has(key)).length;
}
}
const store = new Store();
export default store;
@@ -0,0 +1,6 @@
export type LogListener = (msg: string) => void;
export interface ISubscription {
subscribe (listener: LogListener): void;
unsubscribe (listener: LogListener): void;
notify (msg: string): void;
}
+20
View File
@@ -0,0 +1,20 @@
import os from 'node:os';
import path from 'node:path';
// 缓解Win7设备兼容性问题
let osName: string;
try {
osName = os.hostname();
} catch {
osName = 'NapCat'; // + crypto.randomUUID().substring(0, 4);
}
const homeDir = os.homedir();
export const systemPlatform = os.platform();
export const cpuArch = os.arch();
export const systemVersion = os.release();
export const hostname = osName;
export const downloadsPath = path.join(homeDir, 'Downloads');
export const systemName = os.type();
+28
View File
@@ -0,0 +1,28 @@
// QQVersionType
export type QQPackageInfoType = {
version: string;
buildVersion: string;
platform: string;
eleArch: string;
};
export type QQVersionConfigType = {
baseVersion: string;
curVersion: string;
prevVersion: string;
onErrorVersions: Array<unknown>;
buildId: string;
};
export type QQAppidTableType = {
[key: string]: { appid: string, qua: string };
};
export interface Peer {
chatType: number; // 聊天类型
peerUid: string; // 对等方的唯一标识符
guildId?: string; // 可选的频道ID
}
export interface QQLevel {
crownNum: number;
sunNum: number;
moonNum: number;
starNum: number;
}
+120
View File
@@ -0,0 +1,120 @@
// @ts-ignore
export const napCatVersion = (typeof import.meta?.env !== 'undefined' && import.meta.env.VITE_NAPCAT_VERSION) || '1.0.0-dev';
/**
* SemVer 2.0 正则表达式
* 格式: 主版本号.次版本号.修订号[-先行版本号][+版本编译信息]
* 参考: https://semver.org/lang/zh-CN/
*/
const SEMVER_REGEX = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
export interface SemVerInfo {
valid: boolean;
normalized: string;
major: number;
minor: number;
patch: number;
prerelease: string | null;
buildmetadata: string | null;
}
/**
* 解析并验证版本号是否符合 SemVer 2.0 规范
* @param version - 版本字符串 (支持 v 前缀)
* @returns SemVer 解析结果
*/
export function parseSemVer (version: string | undefined | null): SemVerInfo {
if (!version || typeof version !== 'string') {
return { valid: false, normalized: '1.0.0-dev', major: 1, minor: 0, patch: 0, prerelease: 'dev', buildmetadata: null };
}
const match = version.trim().match(SEMVER_REGEX);
if (match) {
const major = parseInt(match[1]!, 10);
const minor = parseInt(match[2]!, 10);
const patch = parseInt(match[3]!, 10);
const prerelease = match[4] || null;
const buildmetadata = match[5] || null;
// 构建标准化版本号(不带 v 前缀)
let normalized = `${major}.${minor}.${patch}`;
if (prerelease) normalized += `-${prerelease}`;
if (buildmetadata) normalized += `+${buildmetadata}`;
return { valid: true, normalized, major, minor, patch, prerelease, buildmetadata };
}
return { valid: false, normalized: '1.0.0-dev', major: 1, minor: 0, patch: 0, prerelease: 'dev', buildmetadata: null };
}
/**
* 验证版本号是否符合 SemVer 2.0 规范
* @param version - 版本字符串
* @returns 是否有效
*/
export function isValidSemVer (version: string | undefined | null): boolean {
return parseSemVer(version).valid;
}
/**
* 比较两个 SemVer 版本号
* @param v1 - 版本号1
* @param v2 - 版本号2
* @returns -1 (v1 < v2), 0 (v1 == v2), 1 (v1 > v2)
*/
export function compareSemVer (v1: string, v2: string): -1 | 0 | 1 {
const a = parseSemVer(v1);
const b = parseSemVer(v2);
if (!a.valid && !b.valid) {
return v1.localeCompare(v2) as -1 | 0 | 1;
}
if (!a.valid) return -1;
if (!b.valid) return 1;
// 比较主版本号
if (a.major !== b.major) return a.major > b.major ? 1 : -1;
// 比较次版本号
if (a.minor !== b.minor) return a.minor > b.minor ? 1 : -1;
// 比较修订号
if (a.patch !== b.patch) return a.patch > b.patch ? 1 : -1;
// 有先行版本号的版本优先级较低
if (a.prerelease && !b.prerelease) return -1;
if (!a.prerelease && b.prerelease) return 1;
// 两者都有先行版本号时,按字典序比较
if (a.prerelease && b.prerelease) {
const aParts = a.prerelease.split('.');
const bParts = b.prerelease.split('.');
const len = Math.max(aParts.length, bParts.length);
for (let i = 0; i < len; i++) {
const aPart = aParts[i];
const bPart = bParts[i];
if (aPart === undefined) return -1;
if (bPart === undefined) return 1;
const aNum = /^\d+$/.test(aPart) ? parseInt(aPart, 10) : NaN;
const bNum = /^\d+$/.test(bPart) ? parseInt(bPart, 10) : NaN;
// 数字 vs 数字
if (!isNaN(aNum) && !isNaN(bNum)) {
if (aNum !== bNum) return aNum > bNum ? 1 : -1;
continue;
}
// 数字优先级低于字符串
if (!isNaN(aNum)) return -1;
if (!isNaN(bNum)) return 1;
// 字符串 vs 字符串
if (aPart !== bPart) return aPart > bPart ? 1 : -1;
}
}
return 0;
}
/**
* 获取解析后的当前版本信息
*/
export const napCatVersionInfo = parseSemVer(napCatVersion);
+34
View File
@@ -0,0 +1,34 @@
import { Worker } from 'worker_threads';
export async function runTask<T, R> (workerScript: string, taskData: T): Promise<R> {
const worker = new Worker(workerScript);
try {
return await new Promise<R>((resolve, reject) => {
worker.on('message', (result: R) => {
if ((result as any)?.log) {
console.error('Worker Log--->:', (result as { log: string }).log);
}
if ((result as any)?.error) {
reject(new Error('Worker error: ' + (result as { error: string }).error));
}
resolve(result);
});
worker.on('error', (error) => {
reject(new Error(`Worker error: ${error.message}`));
});
worker.on('exit', (code) => {
if (code !== 0) {
reject(new Error(`Worker stopped with exit code ${code}`));
}
});
worker.postMessage(taskData);
});
} catch (error: unknown) {
throw new Error(`Failed to run task: ${(error as Error).message}`);
} finally {
// Ensure the worker is terminated after the promise is settled
worker.terminate();
}
}
+58
View File
@@ -0,0 +1,58 @@
{
"compilerOptions": {
"target": "ES2021",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": [
"ES2021"
],
"typeRoots": [
"./node_modules/@types"
],
"esModuleInterop": true,
"outDir": "dist",
"rootDir": ".",
"noEmit": false,
"sourceMap": true,
"strict": true,
"noImplicitAny": false,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"alwaysStrict": true,
"noImplicitThis": true,
"noImplicitReturns": true,
"noPropertyAccessFromIndexSignature": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": false,
"forceConsistentCasingInFileNames": true,
"useUnknownInCatchVariables": true,
"noImplicitOverride": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"useDefineForClassFields": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"paths": {
"napcat-common/src/*": [
"../../packages/napcat-common/src/*"
],
"napcat-image-size/src/*": [
"../../packages/napcat-image-size/src/*"
],
"@/*": [
"../../packages/*"
]
},
"skipLibCheck": true,
"skipDefaultLibCheck": true
},
"include": [
"src/**/*.ts"
],
"exclude": [
"node_modules",
"dist"
]
}
@@ -0,0 +1,27 @@
import { MsfChangeReasonType, MsfStatusType } from '@/napcat-core/types/adapter';
export class NodeIDependsAdapter {
onMSFStatusChange (_statusType: MsfStatusType, _changeReasonType: MsfChangeReasonType) {
}
onMSFSsoError (_code: number, _desc: string) {
}
getGroupCode (_args: unknown) {
}
// onSendMsfReply (_seq: string, _cmd: string, _uk1: number, _uk2: string, _rsp: {
// ssoRetCode: 0,
// trpcRetCode: 0,
// trpcFuncCode: 0,
// errorMsg: '',
// pbBuffer: Uint8Array,
// transInfoMap: Map<unknown, unknown>;
// }) {
// console.log('[NodeIDependsAdapter] onSendMsfReply', _seq, _cmd, _uk1, _uk2, Buffer.from(_rsp.pbBuffer).toString('hex'));
// }
}
@@ -0,0 +1,10 @@
export class NodeIDispatcherAdapter {
dispatchRequest (_arg: unknown) {
}
dispatchCall (_arg: unknown) {
}
dispatchCallWithJson (_arg: unknown) {
}
}
@@ -0,0 +1,25 @@
export class NodeIGlobalAdapter {
onLog (..._args: unknown[]) {
}
onGetSrvCalTime (..._args: unknown[]) {
}
onShowErrUITips (..._args: unknown[]) {
}
fixPicImgType (..._args: unknown[]) {
}
getAppSetting (..._args: unknown[]) {
}
onInstallFinished (..._args: unknown[]) {
}
onUpdateGeneralFlag (..._args: unknown[]) {
}
onGetOfflineMsg (..._args: unknown[]) {
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './NodeIDependsAdapter';
export * from './NodeIDispatcherAdapter';
export * from './NodeIGlobalAdapter';
+60
View File
@@ -0,0 +1,60 @@
import { InstanceContext, NapCatCore } from '@/napcat-core/index';
export class NTQQCollectionApi {
context: InstanceContext;
core: NapCatCore;
constructor (context: InstanceContext, core: NapCatCore) {
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, NapCatCore } from '@/napcat-core/index';
import {
checkSqliteAvailable,
DatabaseHandle,
openDatabase,
decryptAndOpen,
readSingleDatabase,
scanAllDatabases,
formatScanResults,
decryptDatabaseFile,
type DatabaseScanResult,
type DatabaseReadOptions,
type TableInfo,
} from 'napcat-database';
type SQLInputValue = import('node:sqlite').SQLInputValue;
export class NTQQDatabaseApi {
context: InstanceContext;
core: NapCatCore;
constructor (context: InstanceContext, core: NapCatCore) {
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');
}
/**
* 获取解密缓存目录
* 默认: <NapCatDataPath>/db_<uin>/
* 可通过 customDir 完全自定义
*/
getDefaultCacheDir (customDir?: string): string {
if (customDir) return customDir;
return path.resolve(this.core.NapCatDataPath, `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 '@/napcat-core/types';
import path from 'path';
import fs from 'fs';
import fsPromises from 'fs/promises';
import { InstanceContext, NapCatCore, SearchResultItem } from '@/napcat-core/index';
import { fileTypeFromFile } from 'file-type';
import { RkeyManager } from '@/napcat-core/helper/rkey';
import { calculateFileMD5 } from 'napcat-common/src/file';
import { rkeyDataType } from '../types/file';
import { NapProtoMsg } from 'napcat-protobuf';
import { FileId } from '../packet/transformer/proto/misc/fileid';
export class NTQQFileApi {
context: InstanceContext;
core: NapCatCore;
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: NapCatCore) {
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 '';
}
}
+319
View File
@@ -0,0 +1,319 @@
import { GeneralCallResult, InstanceContext, NapCatCore } from '@/napcat-core';
import {
createFlashTransferResult,
DEFAULT_FLASH_VALIDITY_SECONDS,
FileListResponse,
FlashFileSetInfo,
SendStatus,
UploadSceneType,
} from '@/napcat-core/data/flash';
import { Peer } from '@/napcat-core/types';
export class NTQQFlashApi {
context: InstanceContext;
core: NapCatCore;
constructor (context: InstanceContext, core: NapCatCore) {
this.context = context;
this.core = core;
}
/**
* 发起闪传上传任务
* @param fileListToUpload 上传文件绝对路径的列表,可以是文件夹!!
* @param thumbnailPath
* @param filesetName
* @param validitySeconds 文件集有效期(秒),默认 1209600(14 天),可选 90 天 / 180 天,必须传否则新版内核段错误
*/
async createFlashTransferUploadTask (fileListToUpload: string[], thumbnailPath: string, filesetName: string, validitySeconds: number = DEFAULT_FLASH_VALIDITY_SECONDS): Promise<GeneralCallResult & {
createFlashTransferResult: createFlashTransferResult;
seq: number;
}> {
const flashService = this.context.session.getFlashTransferService();
const timestamp: number = Date.now();
const selfInfo = this.core.selfInfo;
const fileUploadArg = {
scene: 1, // 1
name: filesetName,
uploaders: [{
uin: selfInfo.uin,
nickname: selfInfo.nick,
uid: selfInfo.uid,
sendEntrance: '',
}],
permission: {
code: 0,
},
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: {},
},
announcement: {
announcementSegments: [],
modifyTime: '',
avatarUrl: '',
},
albumFilePathList: [],
validitySeconds,
};
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 '@/napcat-core/types';
import { BuddyListReqType, InstanceContext, NapCatCore } from '@/napcat-core/index';
import { LimitedHashTable } from 'napcat-common/src/message-unique';
export class NTQQFriendApi {
context: InstanceContext;
core: NapCatCore;
constructor (context: InstanceContext, core: NapCatCore) {
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;
}
}
+651
View File
@@ -0,0 +1,651 @@
import {
GeneralCallResult,
GroupMember,
NTGroupMemberRole,
NTGroupRequestOperateTypes,
InstanceContext,
KickMemberV2Req,
MemberExtSourceType,
NapCatCore,
GroupNotify,
GroupInfoSource,
ShutUpGroupMember,
Peer,
ChatType,
GroupManagementSettings,
} from '@/napcat-core/index';
import { isNumeric, solveAsyncProblem } from 'napcat-common/src/helper';
import { LimitedHashTable } from 'napcat-common/src/message-unique';
import { CancelableTask, TaskExecutor } from 'napcat-common/src/cancel-task';
import {
createGroupDetailInfoV2Param,
createGroupExtFilter,
createGroupExtInfo,
createGroupManagementRequests,
} from '../data';
import { NTEventWrapper } from '../helper/event';
import { deleteGroupFiles } from '../helper/group-file';
export class NTQQGroupApi {
context: InstanceContext;
core: NapCatCore;
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: NapCatCore) {
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);
if (!members?.result?.infos) {
throw new Error(`errCode: ${members?.errCode}, errMsg: ${members?.errMsg}`);
}
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);
const cached = !!members;
if (!members) {
members = (await this.refreshGroupMemberCache(groupCodeStr, true));
}
// 刚入的群可能拉不到成员列表,这时 members 为空,不能直接抛错,否则整条群消息都上报不出去
const getMember = () => {
if (!members) {
return undefined;
}
if (isNumeric(memberUinOrUidStr)) {
return Array.from(members.values()).find(member => member.uin?.toString() === memberUinOrUidStr);
} else {
return members.get(memberUinOrUidStr);
}
};
let member = getMember();
// 缓存里没有该成员(比如新入群),刷新一次;上面刚刷新过就不再重复拉取
if (!member && cached) {
members = (await this.refreshGroupMemberCache(groupCodeStr, true));
member = getMember();
}
return member;
}
// 从已缓存的群成员里按 QQ 号找 uid,供 uin 转 uid 兜底
getUidFromMemberCache (uin: string) {
for (const members of this.groupMemberCache.values()) {
for (const [uid, member] of members) {
if (member.uin?.toString() === uin) {
return uid;
}
}
}
return undefined;
}
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>, busIds?: Array<number>) {
return deleteGroupFiles(this.context.session.getRichMediaService(), groupCode, files, busIds);
}
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 destroyGroup (groupCode: string) {
return this.context.session.getGroupService().destroyGroup(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,
},
}
);
}
}
+14
View File
@@ -0,0 +1,14 @@
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';
export * from './qzone';
export * from './qzone-public';
+325
View File
@@ -0,0 +1,325 @@
import { ChatType, GetFileListParam, Peer, RawMessage, SendMessageElement, SendStatusType } from '@/napcat-core/types';
import { GroupFileInfoUpdateItem, InstanceContext, NapCatCore, NodeIKernelMsgService } from '@/napcat-core/index';
import { GeneralCallResult } from '@/napcat-core/services/common';
export class NTQQMsgApi {
context: InstanceContext;
core: NapCatCore;
constructor (context: InstanceContext, core: NapCatCore) {
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, NapCatCore } from '@/napcat-core';
import { Peer } from '@/napcat-core/types';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { GeneralCallResultStatus } from '@/napcat-core/services/common';
import { sleep } from '@/napcat-common/src/helper';
const normalizePath = (p: string) => path.normalize(p).toLowerCase();
export class NTQQOnlineApi {
context: InstanceContext;
core: NapCatCore;
constructor (context: InstanceContext, core: NapCatCore) {
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(`[NapCat] 文件不存在: ${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: '[NapCat] 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: '[NapCat] 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 '@/napcat-core/external/napi2native.json';
import { InstanceContext, NapCatCore } from '@/napcat-core/index';
import { PacketClientSession } from '@/napcat-core/packet/clientSession';
import { napCatVersion } from 'napcat-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: NapCatCore;
logger: LogWrapper;
qqVersion: string | undefined;
pkt!: PacketClientSession;
errStack: string[] = [];
packetStatus: boolean = false;
constructor (context: InstanceContext, core: NapCatCore) {
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/NapCatQQ/releases/tag/v${napCatVersion} 配置正确的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;
}
}
+274
View File
@@ -0,0 +1,274 @@
import type { InstanceContext, NapCatCore } from '@/napcat-core';
import { randomUUID } from 'node:crypto';
import {
buildQzonePublicRequest,
parseQzonePublicResponse,
QZONE_PUBLIC_ROUTES,
type QzonePublicContent,
type QzonePublicFeedsPage,
type QzonePublicObject,
type QzonePublicOperation,
} from '../data/qzone-public';
const RPC_ROOT = 'https://h5.qzone.qq.com/http2rpc/gotrpc/v1/';
const TIMEOUT_MS = 15000;
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
const MAX_CURSOR_COUNT = 128;
const CURSOR_TTL_MS = 10 * 60 * 1000;
const READ_OPERATIONS = new Set<QzonePublicOperation>(['capabilities', 'feeds', 'detail', 'comments', 'replies']);
interface CurrentUser extends QzonePublicObject { uin: string; uid: string; nick: string; }
interface PublicCursor {
account: string;
issuedAt: number;
refreshId: string;
attachInfo: string;
lastUnionId?: QzonePublicObject;
}
function object (value: unknown): value is QzonePublicObject {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
async function withTimeout<T> (work: () => Promise<T>): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
Promise.resolve().then(work),
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error('空间登录态读取超时')), TIMEOUT_MS);
}),
]);
} finally {
if (timer !== undefined) clearTimeout(timer);
}
}
/** Public recommendation uses its own feed identities, interaction routes and cursor chain. */
export class NTQQQzonePublicApi {
private readonly cursors = new Map<string, PublicCursor>();
constructor (readonly context: InstanceContext, readonly core: NapCatCore) {}
private currentUser (): CurrentUser {
const info = this.core.selfInfo;
if (!info || !/^[1-9]\d{0,19}$/.test(info.uin) || typeof info.uid !== 'string' || !info.uid.trim()) {
throw new Error('空友爱看需要已登录的当前账号');
}
return { uin: info.uin, uid: info.uid, nick: info.nick ?? '' };
}
private assertAccount (user: CurrentUser): void {
const current = this.currentUser();
if (current.uin !== user.uin || current.uid !== user.uid) throw new Error('QQ账号已切换,请重新获取空友动态');
}
private async auth (user: CurrentUser): Promise<{ cookie: string; gtk: number; }> {
this.assertAccount(user);
const [skey, pskey] = await withTimeout(() => Promise.allSettled([
this.core.apis.UserApi.getSKey(),
this.core.apis.UserApi.getPSkey(['qzone.qq.com']),
]));
this.assertAccount(user);
const sk = skey.status === 'fulfilled' && typeof skey.value === 'string' ? skey.value : '';
const pk = pskey.status === 'fulfilled' ? pskey.value.domainPskeyMap.get('qzone.qq.com') ?? '' : '';
if (!sk && !pk) throw new Error('无法取得当前账号的空间登录态');
let hash = 5381;
const key = pk || sk;
for (let index = 0; index < key.length; index++) hash += (hash << 5) + key.charCodeAt(index);
const cookies = [`uin=o${user.uin}`, `p_uin=o${user.uin}`];
if (sk) cookies.push(`skey=${sk}`);
if (pk) cookies.push(`p_skey=${pk}`);
return { cookie: cookies.join('; '), gtk: hash & 0x7fffffff };
}
private async rpc (
operation: QzonePublicOperation,
options: Parameters<typeof buildQzonePublicRequest>[1] = {},
user = this.currentUser()
): Promise<QzonePublicObject> {
// Validate complete caller data before acquiring tickets or starting any request.
const body = buildQzonePublicRequest(operation, { ...options, selfUin: user.uin, user });
const encoded = JSON.stringify(body);
if (Buffer.byteLength(encoded) > MAX_RESPONSE_BYTES) throw new Error('空友爱看请求超过大小限制');
const auth = await this.auth(user);
const route = QZONE_PUBLIC_ROUTES[operation];
const [command, serviceType] = route.oidb.split('_');
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
let text: string;
try {
this.assertAccount(user);
const response = await fetch(`${RPC_ROOT}${route.service}/${route.method}?g_tk=${auth.gtk}`, {
method: 'POST',
redirect: 'error',
signal: controller.signal,
headers: {
Cookie: auth.cookie,
'Content-Type': 'application/json',
Accept: 'application/json',
Origin: 'https://h5.qzone.qq.com',
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 }),
},
body: encoded,
});
if (!response.ok) {
await response.body?.cancel();
throw new Error('HTTP请求失败');
}
const reader = response.body?.getReader();
if (!reader) throw new Error('响应为空');
const chunks: Uint8Array[] = [];
let bytes = 0;
try {
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
bytes += chunk.value.byteLength;
if (bytes > MAX_RESPONSE_BYTES) {
await reader.cancel();
throw new Error('响应超过大小限制');
}
chunks.push(chunk.value);
}
} finally {
reader.releaseLock();
}
text = Buffer.concat(chunks, bytes).toString('utf8');
} catch {
throw new Error(READ_OPERATIONS.has(operation)
? '空友爱看请求失败或超时'
: '空友爱看写入结果未确认,请查询状态,勿自动重试');
} finally {
clearTimeout(timer);
}
this.assertAccount(user);
let value: unknown;
try {
value = JSON.parse(text);
} catch {
throw new Error(READ_OPERATIONS.has(operation)
? '空友爱看响应不是有效JSON'
: '空友爱看写入回执无效,请查询状态,勿自动重试');
}
return parseQzonePublicResponse(value, operation);
}
private readCursor (token: string | undefined, user: CurrentUser): PublicCursor | undefined {
if (token === undefined) return undefined;
const state = typeof token === 'string' && token.length <= 128 ? this.cursors.get(token) : undefined;
if (!state || state.account !== `${user.uin}:${user.uid}` || Date.now() - state.issuedAt > CURSOR_TTL_MS) {
throw new Error('空友爱看游标无效或已过期,请重新获取第一页');
}
return state;
}
private issueCursor (state: PublicCursor): string {
const now = Date.now();
for (const [key, value] of this.cursors) {
if (now - value.issuedAt > CURSOR_TTL_MS) this.cursors.delete(key);
}
while (this.cursors.size >= MAX_CURSOR_COUNT) this.cursors.delete(this.cursors.keys().next().value!);
const token = `qzone-public:${randomUUID()}`;
this.cursors.set(token, state);
return token;
}
getCapabilities (): Promise<QzonePublicObject> {
return this.rpc('capabilities');
}
async getFeeds (cursor?: string): Promise<QzonePublicFeedsPage> {
const user = this.currentUser();
const state = this.readCursor(cursor, user);
const refreshId = state?.refreshId ?? `${user.uin}_${Date.now()}`;
const page = await this.rpc('feeds', {
refreshId,
cursor: state?.attachInfo,
lastUnionId: state?.lastUnionId,
}, user);
if (!Array.isArray(page['feeds']) || typeof page['has_more'] !== 'boolean') throw new Error('空友动态分页响应不完整');
const feeds: QzonePublicObject[] = page['feeds'].map((entry: unknown) => {
if (!object(entry)) throw new Error('空友动态条目格式无效');
const feed = Object.prototype.hasOwnProperty.call(entry, 'feed') ? entry['feed'] : entry;
if (!object(feed)) throw new Error('空友动态条目缺少完整内容');
return feed;
});
let lastUnionId = state?.lastUnionId;
for (let index = feeds.length - 1; index >= 0; index--) {
const common = feeds[index]!['cell_common'];
if (object(common) && common['feed_type'] !== 3 && object(common['union_id'])) {
lastUnionId = common['union_id'];
break;
}
}
const hasMore = page['has_more'];
let nextCursor: string | null = null;
if (hasMore) {
const attachInfo = page['attach_info'];
if (typeof attachInfo !== 'string' || !attachInfo || attachInfo.length > 65536) throw new Error('空友动态缺少有效续页信息');
nextCursor = this.issueCursor({
account: `${user.uin}:${user.uid}`,
issuedAt: Date.now(),
refreshId,
attachInfo,
lastUnionId: lastUnionId === undefined ? undefined : structuredClone(lastUnionId),
});
}
return { ...page, feeds, has_more: hasMore, next_cursor: nextCursor };
}
getFeedDetail (feed: QzonePublicObject): Promise<QzonePublicObject> {
return this.rpc('detail', { feed });
}
getComments (feed: QzonePublicObject, cursor: string): Promise<QzonePublicObject> {
return this.rpc('comments', { feed, cursor });
}
getReplies (feed: QzonePublicObject, comment: QzonePublicObject, cursor?: string, pageSize = 20): Promise<QzonePublicObject> {
return this.rpc('replies', { feed, comment, cursor, pageSize });
}
setUpvote (feed: QzonePublicObject, enable = true): Promise<QzonePublicObject> {
if (typeof enable !== 'boolean') throw new Error('enable必须为布尔值');
return this.rpc(enable ? 'upvote' : 'downvote', { feed });
}
setLike (feed: QzonePublicObject, enable = true): Promise<QzonePublicObject> {
if (typeof enable !== 'boolean') throw new Error('enable必须为布尔值');
return this.rpc(enable ? 'like' : 'unlike', { feed });
}
setFollow (feed: QzonePublicObject, enable = true): Promise<QzonePublicObject> {
if (typeof enable !== 'boolean') throw new Error('enable必须为布尔值');
return this.rpc(enable ? 'follow' : 'unfollow', { feed });
}
setCommentLike (feed: QzonePublicObject, comment: QzonePublicObject, enable = true, reply?: QzonePublicObject): Promise<QzonePublicObject> {
if (typeof enable !== 'boolean') throw new Error('enable必须为布尔值');
return this.rpc(enable ? 'commentLike' : 'commentUnlike', { feed, comment, reply });
}
addComment (feed: QzonePublicObject, content: QzonePublicContent): Promise<QzonePublicObject> {
return this.rpc('addComment', { feed, content });
}
addReply (feed: QzonePublicObject, comment: QzonePublicObject, content: QzonePublicContent, reply?: QzonePublicObject): Promise<QzonePublicObject> {
return this.rpc('addReply', { feed, comment, content, reply });
}
deleteComment (feed: QzonePublicObject, comment: QzonePublicObject): Promise<QzonePublicObject> {
return this.rpc('deleteComment', { feed, comment });
}
deleteReply (feed: QzonePublicObject, comment: QzonePublicObject, reply: QzonePublicObject): Promise<QzonePublicObject> {
return this.rpc('deleteReply', { feed, comment, reply });
}
forwardFeed (feed: QzonePublicObject, reason = ''): Promise<QzonePublicObject> {
return this.rpc('forward', { feed, reason });
}
}
+947
View File
@@ -0,0 +1,947 @@
import type { InstanceContext, NapCatCore } from '@/napcat-core';
import { createHash, randomUUID } from 'node:crypto';
import { TextDecoder } from 'node:util';
import {
buildQzonePublishRequest,
buildQzoneText,
getQzoneGtk,
getQzoneTid,
normalizeQzoneFeed,
parseQzoneRpcResponse,
validateQzoneFeedReference,
type QzoneComment,
type QzoneDetail,
type QzoneFeed,
type QzoneFeedPage,
type QzoneFeedReference,
type QzoneMsgPage,
type QzoneUnionId,
type QzoneUser,
} from '../data/qzone-rpc';
import {
buildQzoneBanBody,
buildQzoneProfileBody,
buildQzoneRightBody,
normalizeQzoneRight,
parseQzoneProfile,
parseQzoneWebResponse,
QzoneWebError,
type QzoneProfile,
type QzoneProfileUpdate,
} from '../data/qzone-web';
import {
normalizeQzoneComment,
normalizeQzoneReply,
type QzoneCommentPage,
type QzoneReplyPage,
} from '../data/qzone-comments';
import { buildQzoneUploadImageBody, parseQzonePublishTime } from '../data/qzone';
import {
buildSlQzoneCommentBody,
buildSlQzoneLikeBody,
parseSlQzoneComment,
parseSlQzoneMsgDetail,
parseSlQzoneMsgList,
type SlQzoneCommentOptions,
type SlQzoneCommentResult,
type SlQzoneMsgDetail,
type SlQzoneMsgList,
} from '../data/qzone-snowluma';
import {
advanceSlQzoneFeedState,
initialSlQzoneFeedState,
parseSlQzoneFeeds,
slQzoneFeedPosition,
type SlQzoneFeedPage,
type SlQzoneFeedState,
} from '../data/qzone-snowluma-feeds';
const RPC_ROOT = 'https://h5.qzone.qq.com/http2rpc/gotrpc/v1/';
const READER = 'trpc.qzone.feeds_reader.FeedsReader';
const WRITER = 'trpc.qzone.feeds_writer.FeedsWriter';
const INTERACTION = 'trpc.qzone.interaction_writer.InteractionWriter';
const REQUEST_TIMEOUT_MS = 15000;
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
const MAX_REFERENCE_BYTES = 64 * 1024;
const MAX_CURSOR_LENGTH = 16384;
const MAX_ATTACH_LENGTH = 8192;
const CURSOR_TTL_MS = 10 * 60 * 1000;
const MAX_CURSORS = 128;
const WEB_ROOT = 'https://h5.qzone.qq.com/proxy/domain/';
const SL_FEED_SKIP_LIMIT = 20;
const SL_FEED_DEADLINE_MS = 45000;
const WEB_METHODS = {
GetProfile: { path: 'base.qzone.qq.com/cgi-bin/user/cgi_userinfo_get_all', method: 'GET', requireCode: true },
SetProfile: { path: 'w.qzone.qq.com/cgi-bin/user/cgi_apply_updateuserinfo_new', method: 'POST', requireCode: true },
GetOwnMsgDetail: { path: 'taotao.qq.com/cgi-bin/emotion_cgi_msgdetail_v6', method: 'GET', requireCode: false },
SetMsgRight: { path: 'taotao.qzone.qq.com/cgi-bin/emotion_cgi_update', method: 'POST', requireCode: false },
SetBan: { path: 'w.qzone.qq.com/cgi-bin/right/cgi_black_action_new', method: 'POST', requireCode: false },
GetSlMsgList: { path: 'taotao.qzone.qq.com/cgi-bin/emotion_cgi_msglist_v6', method: 'GET', requireCode: false },
GetSlMsgListFallback: { path: 'taotao.qq.com/cgi-bin/emotion_cgi_msglist_v6', method: 'GET', requireCode: false, root: 'https://user.qzone.qq.com/proxy/domain/', ownerReferer: true },
LikeSlMsg: { path: 'w.qzone.qq.com/cgi-bin/likes/internal_dolike_app', method: 'POST', requireCode: false },
UnlikeSlMsg: { path: 'w.qzone.qq.com/cgi-bin/likes/internal_unlike_app', method: 'POST', requireCode: false },
CommentSlMsg: { path: 'taotao.qzone.qq.com/cgi-bin/emotion_cgi_re_feeds', method: 'POST', requireCode: false },
ReplySlMsg: { path: 'taotao.qzone.qq.com/cgi-bin/emotion_cgi_addreply_ugc', method: 'POST', requireCode: true },
} as const;
interface QzoneHttpRequest {
url: string;
method: 'GET' | 'POST';
body?: string;
headers?: Record<string, string>;
}
// 固定路由,不向调用方暴露任意 RPC、URL 或取票能力。
const RPC_METHODS = {
GetFriendFeeds: { service: READER, command: '0x97e5', type: '0', write: false },
GetShuoshuoList: { service: READER, command: '0x9a97', type: '1', write: false },
GetFeedDetail: { service: READER, command: '0x97b1', type: '1', write: false },
GetComment: { service: READER, command: '0x9bfd', type: '1', write: false },
GetReplies: { service: READER, command: '0x9bfe', type: '1', write: false },
PublishFeed: { service: WRITER, command: '0x9775', type: '1', write: true },
DoLike: { service: INTERACTION, command: '0x9699', type: '1', write: true },
UnLike: { service: INTERACTION, command: '0x969c', type: '1', write: true },
AddComment: { service: INTERACTION, command: '0x9676', type: '1', write: true },
AddReply: { service: INTERACTION, command: '0x9677', type: '1', write: true },
} as const;
interface PageCursor {
account: string;
kind: 'friends' | 'moods' | 'comments' | 'replies' | 'sl-feeds';
target: string;
issuedAt: number;
attachInfo: string;
refreshId?: string;
lastUnionId?: QzoneUnionId;
feedReference?: QzoneFeedReference;
parentComment?: QzoneComment;
slFeedState?: SlQzoneFeedState;
slFeedPage?: number;
slFeedPositions?: string[];
}
function isRecord (value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function validUin (value: unknown): value is string {
return typeof value === 'string' && /^[1-9]\d{0,19}$/.test(value);
}
function validIdentity (value: unknown, maxLength = 4096): value is string {
return typeof value === 'string' && value.trim().length > 0 && value.length <= maxLength;
}
function validateUser (value: unknown): QzoneUser {
if (!isRecord(value) || !validUin(value['uin']) ||
(value['uid'] !== undefined && value['uid'] !== null && value['uid'] !== '' && !validIdentity(value['uid'])) ||
(value['nick'] !== undefined && (typeof value['nick'] !== 'string' || value['nick'].length > 4096))) {
throw new Error('QQ空间用户信息无效');
}
// A raw parent may carry uid:null; keep the parent intact and omit only this target's absent UID.
const user = { ...value };
if (user['uid'] === null) delete user['uid'];
return user as unknown as QzoneUser;
}
function validateComment (value: unknown): QzoneComment {
if (!isRecord(value) || !validIdentity(value['id'], 8192)) {
throw new Error('QQ空间评论缺少有效标识');
}
validateUser(value['user']);
let size: number;
try {
size = Buffer.byteLength(JSON.stringify(value));
} catch {
throw new Error('QQ空间评论格式无效');
}
if (size > MAX_REFERENCE_BYTES) {
throw new Error('QQ空间评论对象过大');
}
return value as QzoneComment;
}
function assertReadJson (value: unknown, depth = 0): void {
if (depth > 32) throw new Error('QQ空间父评论嵌套过深');
if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
if (typeof value === 'number' && Number.isFinite(value)) return;
if (Array.isArray(value)) {
for (const item of value) assertReadJson(item, depth + 1);
return;
}
if (!isRecord(value) || (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)) {
throw new Error('QQ空间父评论必须为JSON数据');
}
for (const item of Object.values(value)) {
if (item !== undefined) assertReadJson(item, depth + 1);
}
}
function readParentSnapshot (value: unknown): QzoneComment {
if (!isRecord(value) || !(validIdentity(value['id'], 8192) ||
(typeof value['id'] === 'number' && Number.isSafeInteger(value['id']) && value['id'] >= 0))) {
throw new Error('QQ空间父评论缺少有效标识');
}
if (value['user'] !== undefined && !isRecord(value['user'])) throw new Error('QQ空间父评论用户信息无效');
assertReadJson(value);
if (Buffer.byteLength(JSON.stringify(value)) > MAX_REFERENCE_BYTES) throw new Error('QQ空间父评论不能超过64KiB');
return structuredClone(value) as QzoneComment;
}
function referenceSnapshot (value: unknown): QzoneFeedReference {
return structuredClone(validateQzoneFeedReference(value));
}
function canonicalJson (value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
if (isRecord(value)) {
return `{${Object.keys(value).filter(key => value[key] !== undefined).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
}
const json = JSON.stringify(value);
if (json === undefined) throw new Error('QQ空间分页上下文格式无效');
return json;
}
function commentScope (reference: QzoneFeedReference, parent?: QzoneComment): string {
return createHash('sha256').update(canonicalJson(parent === undefined
? reference
: { reference, parent: { id: parent.id, user: parent.user } })).digest('hex');
}
function observedCount (value: unknown): number | null {
if (typeof value !== 'number' && !(typeof value === 'string' && /^\d+$/.test(value))) return null;
const count = typeof value === 'number' ? value : Number(value);
return Number.isSafeInteger(count) && count >= 0 ? count : null;
}
async function withTimeout<T> (work: () => Promise<T>, message: string, duration = REQUEST_TIMEOUT_MS): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
Promise.resolve().then(work),
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error(message)), duration);
}),
]);
} finally {
if (timer !== undefined) clearTimeout(timer);
}
}
export class NTQQQzoneApi {
context: InstanceContext;
core: NapCatCore;
private readonly cursors = new Map<string, PageCursor>();
private readonly rightUpdates = new Set<string>();
constructor (context: InstanceContext, core: NapCatCore) {
this.context = context;
this.core = core;
}
private currentUser (): QzoneUser {
const info = this.core.selfInfo;
if (!info || !validUin(info.uin) || !validIdentity(info.uid)) {
throw new Error('QQ空间需要已登录的当前账号');
}
return validateUser({ uin: info.uin, uid: info.uid, nick: info.nick ?? '' });
}
private accountKey (user: QzoneUser): string {
return `${user.uin}:${user.uid ?? ''}`;
}
private assertAccount (user: QzoneUser): void {
if (this.accountKey(this.currentUser()) !== this.accountKey(user)) {
throw new Error('QQ账号已切换,请重新获取QQ空间数据');
}
}
private readCursor (token: string | undefined, user: QzoneUser, kind: PageCursor['kind'], target: string): PageCursor | undefined {
if (token === undefined) return undefined;
if (typeof token !== 'string' || token.length === 0 || token.length > MAX_CURSOR_LENGTH) {
throw new Error('QQ空间游标无效,请重新获取第一页');
}
const cursor = this.cursors.get(token);
const now = Date.now();
if (!cursor || cursor.issuedAt > now || now - cursor.issuedAt >= CURSOR_TTL_MS ||
cursor.account !== this.accountKey(user) || cursor.kind !== kind || cursor.target !== target) {
throw new Error('QQ空间游标无效或已过期,请重新获取第一页');
}
return cursor;
}
private saveCursor (cursor: Omit<PageCursor, 'issuedAt'>, prefix = ''): string {
const now = Date.now();
for (const [token, entry] of this.cursors) {
if (entry.issuedAt > now || now - entry.issuedAt >= CURSOR_TTL_MS) this.cursors.delete(token);
}
while (this.cursors.size >= MAX_CURSORS) {
const oldest = this.cursors.keys().next().value;
if (oldest === undefined) break;
this.cursors.delete(oldest);
}
const token = `${prefix}${randomUUID()}`;
this.cursors.set(token, { ...structuredClone(cursor), issuedAt: now });
return token;
}
private parsePage (data: Record<string, unknown>): { feeds: QzoneFeed[]; hasMore: boolean; attachInfo: string; } {
if (!Array.isArray(data['feeds']) ||
(data['has_more'] !== undefined && typeof data['has_more'] !== 'boolean')) {
throw new Error('QQ空间列表响应格式异常');
}
const feeds = data['feeds'].map((item: unknown) => {
if (!isRecord(item) || !isRecord(item['feed'])) throw new Error('QQ空间动态响应格式异常');
return item['feed'] as QzoneFeed;
});
const hasMore = data['has_more'] === true;
const attachInfo = data['attach_info'];
if (hasMore && (typeof attachInfo !== 'string' || attachInfo.length === 0 || attachInfo.length > MAX_ATTACH_LENGTH)) {
throw new Error('QQ空间续页信息无效,请重新获取第一页');
}
return { feeds, hasMore, attachInfo: typeof attachInfo === 'string' ? attachInfo : '' };
}
private async authHeaders (user: QzoneUser, deadline?: number): Promise<{ cookie: string; gtk: number; skey: string; pskey: string; }> {
const remaining = deadline === undefined ? REQUEST_TIMEOUT_MS : Math.min(REQUEST_TIMEOUT_MS, deadline - Date.now());
if (remaining <= 0) throw new Error('QQ空间读取超过时间预算,请使用游标分页');
const results = await withTimeout(() => Promise.allSettled([
this.core.apis.UserApi.getSKey(),
this.core.apis.UserApi.getPSkey(['qzone.qq.com']),
]), 'QQ空间取票超时,请稍后重试', remaining);
this.assertAccount(user);
const skey = results[0].status === 'fulfilled' ? results[0].value : undefined;
const ticket = results[1].status === 'fulfilled' ? results[1].value : undefined;
const pskey = ticket?.result === 0 ? ticket.domainPskeyMap?.get('qzone.qq.com') : undefined;
const validKey = (key: unknown): key is string => typeof key === 'string' && key.length > 0 && key.length <= 4096 &&
[...key].every(char => char.charCodeAt(0) > 32 && char.charCodeAt(0) !== 127 && char !== ';' && char !== ',');
const sk = validKey(skey) ? skey : '';
const pk = validKey(pskey) ? pskey : '';
if (!sk && !pk) throw new Error('QQ空间取票失败,请确认当前账号登录状态');
const cookies = [`uin=o${user.uin}`, `p_uin=o${user.uin}`];
if (sk) cookies.push(`skey=${sk}`);
if (pk) cookies.push(`p_skey=${pk}`);
return { cookie: cookies.join('; '), gtk: getQzoneGtk(pk || sk), skey: sk, pskey: pk };
}
private async requestText (user: QzoneUser, build: (gtk: number, auth: { skey: string; pskey: string; }) => QzoneHttpRequest, write: boolean, deadline?: number): Promise<string> {
const auth = await this.authHeaders(user, deadline);
const request = build(auth.gtk, auth);
const remaining = deadline === undefined ? REQUEST_TIMEOUT_MS : Math.min(REQUEST_TIMEOUT_MS, deadline - Date.now());
if (remaining <= 0) throw new Error('QQ空间读取超过时间预算,请使用游标分页');
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), remaining);
let text: string;
try {
this.assertAccount(user);
const response = await fetch(request.url, {
method: request.method,
redirect: 'error',
signal: controller.signal,
headers: {
Accept: 'application/json',
Cookie: auth.cookie,
Origin: 'https://h5.qzone.qq.com',
Referer: 'https://h5.qzone.qq.com/pc-qzone/index.html',
...request.headers,
},
...(request.body !== undefined ? { body: request.body } : {}),
});
if (response.status !== 200 || !response.body) {
await response.body?.cancel();
throw new Error('Invalid response');
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let size = 0;
try {
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
size += chunk.value.byteLength;
if (size > MAX_RESPONSE_BYTES) {
await reader.cancel();
throw new Error('Response too large');
}
chunks.push(chunk.value);
}
} finally {
reader.releaseLock();
}
text = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks, size));
} catch {
throw new Error(write
? 'QQ空间请求未能确认结果,请先查询状态,勿自动重试'
: 'QQ空间请求失败或超时,请稍后重试');
} finally {
clearTimeout(timer);
}
this.assertAccount(user);
return text;
}
private async rpc (method: keyof typeof RPC_METHODS, request: Record<string, unknown>, user: QzoneUser): Promise<Record<string, unknown>> {
const route = RPC_METHODS[method];
const text = await this.requestText(user, gtk => ({
url: `${RPC_ROOT}${route.service}/${method}?g_tk=${gtk}`,
method: 'POST',
body: JSON.stringify(request),
headers: {
'Content-Type': 'application/json',
'x-oidb': JSON.stringify({ uint32_command: route.command, uint32_service_type: route.type }),
'x-web-qua': 'V1_HT5_QZ_9.9.32_001_IDC_B',
},
}), route.write);
let value: unknown;
try {
value = JSON.parse(text);
} catch {
throw new Error(route.write
? 'QQ空间响应无法解析,操作结果未知,请先查询状态,勿自动重试'
: 'QQ空间响应格式异常');
}
return parseQzoneRpcResponse(value);
}
private async web (method: keyof typeof WEB_METHODS, params: URLSearchParams, user: QzoneUser): Promise<Record<string, unknown>> {
const route = WEB_METHODS[method];
const write = route.method === 'POST';
const text = await this.requestText(user, gtk => {
const url = new URL(`${'root' in route ? route.root : WEB_ROOT}${route.path}`);
url.searchParams.set('g_tk', String(gtk));
if (!write) {
for (const [key, value] of params) url.searchParams.set(key, value);
}
const headers: Record<string, string> = {};
if ('ownerReferer' in route) headers['Referer'] = `https://user.qzone.qq.com/${params.get('uin')}`;
if (write) headers['Content-Type'] = 'application/x-www-form-urlencoded';
return {
url: url.href,
method: route.method,
headers,
...(write ? { body: params.toString() } : {}),
};
}, write);
try {
return parseQzoneWebResponse(text, route.requireCode);
} catch (error) {
if (!write) throw error;
// Parser errors contain only local descriptions and numeric status codes.
throw new Error(`${(error as Error).message};未确认成功,请先查询状态,勿自动重试`);
}
}
async getProfile (targetUin?: string): Promise<QzoneProfile> {
const user = this.currentUser();
const target = targetUin ?? user.uin;
if (!validUin(target)) throw new Error('target_uin 必须是有效QQ号');
const response = await this.web('GetProfile', new URLSearchParams({
uin: target, vuin: user.uin, fupdate: '1', rd: String(Math.random()),
}), user);
return parseQzoneProfile(response);
}
private slTarget (user: QzoneUser, targetUin?: string): string {
const target = targetUin ?? user.uin;
if (!validUin(target)) throw new Error('target_uin 必须是有效QQ号');
return target;
}
private slPagination (pos: number, num: number): void {
if (!Number.isSafeInteger(pos) || pos < 0 || !Number.isSafeInteger(num) || num < 1 || num > 100) {
throw new Error('pos 必须为非负安全整数,num 必须在1到100之间');
}
}
async getSnowlumaMsgList (targetUin?: string, pos = 0, num = 20): Promise<SlQzoneMsgList> {
this.slPagination(pos, num);
const user = this.currentUser();
const target = this.slTarget(user, targetUin);
const params = new URLSearchParams({
uin: target,
ftype: '0',
sort: '0',
pos: String(pos),
num: String(num),
replynum: '100',
callback: '_preloadCallback',
code_version: '1',
format: 'jsonp',
need_private_comment: '1',
});
let response: Record<string, unknown>;
try {
response = await this.web('GetSlMsgList', params, user);
} catch (error) {
// 仅对已明确拒绝的只读请求切换已知代理域,不跟随重定向、不重放写操作。
if (!(error instanceof QzoneWebError) || error.field !== 'code' || error.code !== -10000) throw error;
response = await this.web('GetSlMsgListFallback', new URLSearchParams({
uin: target, ftype: '0', sort: '0', pos: String(pos), num: String(num), code_version: '1', format: 'json',
}), user);
}
return parseSlQzoneMsgList(response);
}
async getSnowlumaMsgDetail (tid: string, targetUin?: string, pos = 0, num = 20): Promise<SlQzoneMsgDetail> {
if (!validIdentity(tid, 8192)) throw new Error('tid 必须是有效的说说标识');
this.slPagination(pos, num);
const user = this.currentUser();
const target = this.slTarget(user, targetUin);
const response = await this.web('GetOwnMsgDetail', new URLSearchParams({
tid,
uin: target,
t1_source: '1',
not_trunc_con: '1',
need_right: '1',
not_adapt_outpic: '1',
pos: String(pos),
num: String(num),
}), user);
return parseSlQzoneMsgDetail(response, tid);
}
async setSnowlumaLike (tid: string, targetUin: string | undefined, enable: boolean, abstime = 0): Promise<void> {
const user = this.currentUser();
const target = this.slTarget(user, targetUin);
const body = buildSlQzoneLikeBody(user.uin, target, tid, enable, abstime);
await this.web(enable ? 'LikeSlMsg' : 'UnlikeSlMsg', new URLSearchParams(body), user);
}
async commentSnowluma (tid: string, content: string, targetUin?: string, options?: SlQzoneCommentOptions): Promise<SlQzoneCommentResult> {
const user = this.currentUser();
const target = this.slTarget(user, targetUin);
const body = buildSlQzoneCommentBody(user.uin, target, tid, content, options);
const response = await this.web(options?.comment_id !== undefined ? 'ReplySlMsg' : 'CommentSlMsg', new URLSearchParams(body), user);
return parseSlQzoneComment(response, options);
}
async uploadCommentImage (base64: string): Promise<{ url: string; }> {
if (typeof base64 !== 'string' || !base64 || base64.length > 32 * 1024 * 1024 || !/^[A-Za-z0-9+/]+={0,2}$/.test(base64)) {
throw new Error('QQ空间评论图片数据无效或超过限制');
}
const user = this.currentUser();
const text = await this.requestText(user, (gtk, auth) => ({
url: `https://up.qzone.qq.com/cgi-bin/upload/cgi_upload_image?g_tk=${gtk}`,
method: 'POST',
body: buildQzoneUploadImageBody({ uin: user.uin, skey: auth.skey, pskey: auth.pskey, g_tk: String(gtk), base64 }),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
}), true);
let response: Record<string, unknown>;
try {
// The upload CGI acknowledges with ret:0 rather than code:0.
response = parseQzoneWebResponse(text);
} catch (error) {
throw new Error(`${(error as Error).message};图片上传结果未确认,请先查询状态,勿自动重试`);
}
const data = response['data'];
if (!isRecord(data) || typeof data['url'] !== 'string' || data['url'].trim() !== data['url'] ||
[...data['url']].some(char => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)) {
throw new Error('QQ空间上传未返回可用图片地址');
}
let url: URL;
try {
url = new URL(data['url']);
} catch {
throw new Error('QQ空间上传图片地址无效');
}
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) throw new Error('QQ空间上传图片地址无效');
return { url: url.href };
}
async getSnowlumaFeeds (pageNum = 1, count = 10, cursor?: string): Promise<SlQzoneFeedPage> {
if (!Number.isSafeInteger(pageNum) || pageNum < 1 || !Number.isSafeInteger(count) || count < 1 || count > 50) {
throw new Error('page_num 必须为正安全整数,count 必须在1到50之间');
}
const user = this.currentUser();
const previous = this.readCursor(cursor, user, 'sl-feeds', '');
if (previous && (!previous.slFeedState || !previous.slFeedPage)) throw new Error('QQ空间动态游标无效');
const requestedPage = previous?.slFeedPage ?? pageNum;
if (previous && pageNum !== 1 && pageNum !== requestedPage) throw new Error('page_num 与动态游标不匹配');
if (!previous && pageNum > SL_FEED_SKIP_LIMIT) throw new Error('跳页超过20页读取预算,请从首页使用游标续页');
let state = previous?.slFeedState ? structuredClone(previous.slFeedState) : initialSlQzoneFeedState();
const position = (value: SlQzoneFeedState): string => createHash('sha256').update(slQzoneFeedPosition(value)).digest('hex');
const visited = new Set(previous?.slFeedPositions ?? [position(state)]);
const deadline = Date.now() + SL_FEED_DEADLINE_MS;
for (let page = previous?.slFeedPage ?? 1; page <= requestedPage; page++) {
const text = await this.requestText(user, gtk => {
const params = new URLSearchParams({
uin: user.uin,
scope: '0',
view: '1',
gid: '',
flag: '1',
filter: 'all',
applist: 'all',
refresh: '0',
firstGetGroup: '0',
mixnocache: '0',
scene: '0',
sidomain: 'qzonestyle.gtimg.cn',
callback: '_preloadCallback',
format: 'jsonp',
useutf8: '1',
outputhtmlfeed: '1',
count: String(count),
usertime: String(Math.floor(Date.now() / 1000)),
rd: String(Math.random()),
windowId: String(Math.random()),
g_tk: String(gtk),
});
for (const [key, value] of Object.entries(state)) params.set(key === 'aisortNextTime' ? 'getAisort' : key, value);
return { url: `${WEB_ROOT}ic2.qzone.qq.com/cgi-bin/feeds/feeds3_html_more?${params}`, method: 'GET' };
}, false, deadline);
const data = parseSlQzoneFeeds(text);
if (!data.has_more) return page < requestedPage ? { feeds: [], has_more: false, next_cursor: null } : { feeds: data.feeds, has_more: false, next_cursor: null };
if (!data.main) throw new Error('QQ空间动态缺少续页状态');
const next = advanceSlQzoneFeedState(state, data.main);
const nextPosition = position(next);
if (visited.has(nextPosition)) throw new Error('QQ空间动态续页位置未推进,请重新读取');
visited.add(nextPosition);
if (page === requestedPage) {
const token = this.saveCursor({
account: this.accountKey(user),
kind: 'sl-feeds',
target: '',
attachInfo: '',
slFeedState: next,
slFeedPage: page + 1,
slFeedPositions: [...visited].slice(-32),
}, 'slqf:');
return { feeds: data.feeds, has_more: true, next_cursor: token };
}
state = next;
}
throw new Error('QQ空间动态页码无效');
}
async setProfile (profile: QzoneProfileUpdate): Promise<void> {
const user = this.currentUser();
const body = buildQzoneProfileBody(user.uin, profile);
await this.web('SetProfile', new URLSearchParams(body), user);
}
async setMsgRight (tid: string, ugcRight: number, targetUins?: string[]): Promise<{ ugc_right: number; }> {
if (!validIdentity(tid, 8192)) throw new Error('tid 必须是有效的说说标识');
const normalized = normalizeQzoneRight(ugcRight, targetUins);
const user = this.currentUser();
const lockKey = `${this.accountKey(user)}:${tid}`;
if (this.rightUpdates.has(lockKey)) throw new Error('该说说正在修改权限,请稍后重试');
this.rightUpdates.add(lockKey);
try {
const detail = await this.web('GetOwnMsgDetail', new URLSearchParams({
tid, uin: user.uin, t1_source: '1', not_trunc_con: '1', need_right: '1', not_adapt_outpic: '1',
}), user);
const body = buildQzoneRightBody(detail, user.uin, tid, normalized.right, normalized.targetUins);
const result = await this.web('SetMsgRight', new URLSearchParams(body), user);
if (result['ugc_right'] === undefined) return { ugc_right: normalized.right };
const returnedRight = typeof result['ugc_right'] === 'string' && /^\d+$/.test(result['ugc_right'])
? Number(result['ugc_right'])
: result['ugc_right'];
if (typeof returnedRight !== 'number' || ![1, 4, 16, 64, 128].includes(returnedRight)) {
throw new Error('QQ空间返回的权限无效,请回读说说确认,勿自动重试');
}
if (returnedRight !== normalized.right) throw new Error('QQ空间返回权限与请求不一致,请回读说说确认,勿自动重试');
return { ugc_right: returnedRight };
} finally {
this.rightUpdates.delete(lockKey);
}
}
async setBan (targetUin: string, enable = true): Promise<void> {
const user = this.currentUser();
const body = buildQzoneBanBody(user.uin, targetUin, enable);
await this.web('SetBan', new URLSearchParams(body), user);
}
async getFriendFeeds (cursor?: string): Promise<QzoneFeedPage> {
const user = this.currentUser();
const previous = this.readCursor(cursor, user, 'friends', '');
const refreshId = previous?.refreshId ?? `${user.uin}_${Date.now()}`;
const request: Record<string, unknown> = { refresh_id: refreshId };
if (previous) {
request['attach_info'] = previous.attachInfo;
if (previous.lastUnionId) request['last_client_feed_union_id'] = previous.lastUnionId;
}
const page = this.parsePage(await this.rpc('GetFriendFeeds', request, user));
let lastUnionId = previous?.lastUnionId;
if (page.hasMore) {
for (let index = page.feeds.length - 1; index >= 0; index--) {
const feed = page.feeds[index];
if (!feed || feed.cell_common?.feed_type === 3 || !feed.cell_common?.union_id) continue;
try {
lastUnionId = validateQzoneFeedReference({ union_id: feed.cell_common.union_id }).union_id;
break;
} catch {
// 未知动态仍保留在返回页;只有可用身份才能作为后续分页锚点。
}
}
}
const nextCursor = page.hasMore
? this.saveCursor({
account: this.accountKey(user), kind: 'friends', target: '', attachInfo: page.attachInfo, refreshId, lastUnionId,
})
: null;
return { feeds: page.feeds.map(normalizeQzoneFeed), has_more: page.hasMore, next_cursor: nextCursor };
}
async getMsgList (targetUin?: string, cursor?: string): Promise<QzoneMsgPage> {
const user = this.currentUser();
const target = targetUin ?? user.uin;
if (!validUin(target)) throw new Error('target_uin 必须是有效QQ号');
const previous = this.readCursor(cursor, user, 'moods', target);
let uid = user.uid;
if (target !== user.uin) {
try {
uid = await withTimeout(() => this.core.apis.UserApi.getUidByUinV2(target), 'QQ空间账号解析超时');
} catch {
throw new Error('无法解析目标QQ号,请确认账号可访问');
}
}
if (!validIdentity(uid) || uid.includes('*')) throw new Error('无法解析目标QQ号,请确认账号可访问');
this.assertAccount(user);
const data = await this.rpc('GetShuoshuoList', {
host_uid: uid, host_uin: target, attach_info: previous?.attachInfo ?? '', feed_action_domain_type: 1,
}, user);
const page = this.parsePage(data);
if (data['seal_off_memory'] !== undefined && data['seal_off_memory'] !== null && !isRecord(data['seal_off_memory'])) {
throw new Error('QQ空间封存状态响应格式异常');
}
const nextCursor = page.hasMore
? this.saveCursor({
account: this.accountKey(user), kind: 'moods', target, attachInfo: page.attachInfo,
})
: null;
return {
msglist: page.feeds.map(normalizeQzoneFeed),
has_more: page.hasMore,
next_cursor: nextCursor,
...(isRecord(data['seal_off_memory']) ? { seal_off_memory: data['seal_off_memory'] } : {}),
};
}
private async detailResponse (ref: QzoneFeedReference, user: QzoneUser): Promise<Record<string, unknown>> {
return await this.rpc('GetFeedDetail', {
union_id: ref.union_id,
cell_feed_transfer: ref.cell_feed_transfer,
feed_action_domain_type: ref.feed_action_domain_type ?? 1,
from_page: 1,
}, user);
}
private detailFeed (data: Record<string, unknown>): QzoneFeed {
if (!isRecord(data['feed']) || !isRecord(data['feed']['feed'])) {
throw new Error('QQ空间未返回说说详情,内容可能不可访问或已删除');
}
const feed = data['feed']['feed'] as QzoneFeed;
if (feed.cell_comment !== undefined && !isRecord(feed.cell_comment)) {
throw new Error('QQ空间评论响应格式异常');
}
const comments = feed.cell_comment?.comments;
if (comments !== undefined && (!Array.isArray(comments) || comments.some(comment => !isRecord(comment)))) {
throw new Error('QQ空间评论响应格式异常');
}
return feed;
}
private currentReference (ref: QzoneFeedReference, feed: QzoneFeed): QzoneFeedReference {
const current = referenceSnapshot(ref);
const common = feed.cell_common;
if (isRecord(common)) {
if (common['union_id'] !== undefined && common['union_id'] !== null) current.union_id = common['union_id'] as QzoneUnionId;
if (typeof common['domain_type'] === 'number' && Number.isInteger(common['domain_type']) && common['domain_type'] >= 1 && common['domain_type'] <= 3) {
current.feed_action_domain_type = common['domain_type'];
}
if (validIdentity(common['cur_like_key'], 8192)) current.like_key = common['cur_like_key'];
}
if (feed.cell_feed_transfer !== undefined && feed.cell_feed_transfer !== null) current.cell_feed_transfer = feed.cell_feed_transfer;
return referenceSnapshot(current);
}
private commentCell (value: unknown): { comments: QzoneComment[]; count: number | null; } {
if (value === undefined) return { comments: [], count: null };
if (!isRecord(value) || (value['comments'] !== undefined && (!Array.isArray(value['comments']) || value['comments'].some(item => !isRecord(item))))) {
throw new Error('QQ空间评论响应格式异常');
}
return { comments: (value['comments'] ?? []) as QzoneComment[], count: observedCount(value['num']) };
}
private commentPageState (data: Record<string, unknown>, field: 'attach_info' | 'reply_attach_info', previous?: string): { hasMore: boolean; attachInfo: string; } {
if (data['has_more'] !== undefined && typeof data['has_more'] !== 'boolean') throw new Error('QQ空间评论分页状态无效');
const hasMore = data['has_more'] === true;
const attachInfo = data[field];
if (hasMore && (typeof attachInfo !== 'string' || !attachInfo.length || attachInfo.length > MAX_ATTACH_LENGTH || attachInfo === previous)) {
throw new Error('QQ空间评论续页信息缺失或未推进,请重新读取');
}
return { hasMore, attachInfo: typeof attachInfo === 'string' ? attachInfo : '' };
}
private saveCommentCursor (user: QzoneUser, boundRef: QzoneFeedReference, requestRef: QzoneFeedReference, attachInfo: string): string {
return this.saveCursor({
account: this.accountKey(user), kind: 'comments', target: commentScope(boundRef), attachInfo, feedReference: requestRef,
});
}
async getMsgDetail (reference: QzoneFeedReference): Promise<QzoneDetail> {
const ref = referenceSnapshot(reference);
const user = this.currentUser();
const data = await this.detailResponse(ref, user);
const feed = this.detailFeed(data);
const item = normalizeQzoneFeed(feed);
const state = this.commentPageState(data, 'attach_info');
// 旧详情仍返回原来的规范化引用;分页请求在内部保留 H5 返回的完整新上下文。
const nextCursor = state.hasMore && item.feed_ref
? this.saveCommentCursor(user, item.feed_ref, this.currentReference(ref, feed), state.attachInfo)
: null;
return {
...item,
comments: feed.cell_comment?.comments ?? [],
comment_has_more: state.hasMore,
comment_next_cursor: nextCursor,
};
}
async getComments (reference: QzoneFeedReference, cursor?: string): Promise<QzoneCommentPage> {
const ref = referenceSnapshot(reference);
const user = this.currentUser();
const previous = this.readCursor(cursor, user, 'comments', commentScope(ref));
let current = ref;
let data: Record<string, unknown>;
let cell: { comments: QzoneComment[]; count: number | null; };
if (previous) {
if (!previous.feedReference) throw new Error('QQ空间评论游标无效,请重新读取');
current = referenceSnapshot(previous.feedReference);
data = await this.rpc('GetComment', {
union_id: current.union_id, attach_info: previous.attachInfo, cell_feed_transfer: current.cell_feed_transfer,
}, user);
const commentCell = data['cell_comment'];
// 空末页可能省略 repeated 字段,只有明确结束时才接受,避免把畸形响应当作读完。
if (commentCell === undefined && data['has_more'] === false) {
cell = { comments: [], count: null };
} else {
if (!isRecord(commentCell) || (commentCell['comments'] === undefined && data['has_more'] !== false)) {
throw new Error('QQ空间评论响应缺少有效cell_comment对象或评论列表');
}
cell = this.commentCell(commentCell);
}
} else {
data = await this.detailResponse(ref, user);
const feed = this.detailFeed(data);
current = this.currentReference(ref, feed);
cell = this.commentCell(feed.cell_comment);
}
const comments = cell.comments.map(normalizeQzoneComment);
const state = this.commentPageState(data, 'attach_info', previous?.attachInfo);
const nextCursor = state.hasMore ? this.saveCommentCursor(user, current, current, state.attachInfo) : null;
return {
feed_ref: current,
comment_num: cell.count,
comments,
has_more: state.hasMore,
next_cursor: nextCursor,
raw: data,
};
}
async getCommentReplies (reference: QzoneFeedReference, parent: QzoneComment, cursor?: string): Promise<QzoneReplyPage> {
const ref = referenceSnapshot(reference);
const parentSnapshot = readParentSnapshot(parent);
const user = this.currentUser();
const scope = commentScope(ref, parentSnapshot);
const previous = this.readCursor(cursor, user, 'replies', scope);
let current = ref;
let requestParent = parentSnapshot;
if (previous) {
if (!previous.feedReference || !previous.parentComment) throw new Error('QQ空间回复游标无效,请重新读取');
current = referenceSnapshot(previous.feedReference);
requestParent = readParentSnapshot(previous.parentComment);
}
const initialAttach = requestParent['reply_attach_info'];
if (initialAttach !== undefined && initialAttach !== null && (typeof initialAttach !== 'string' || initialAttach.length > MAX_ATTACH_LENGTH)) {
throw new Error('QQ空间父评论的回复续页信息无效');
}
const attachInfo = previous?.attachInfo ?? (typeof initialAttach === 'string' ? initialAttach : '');
const data = await this.rpc('GetReplies', {
union_id: current.union_id,
cur_comment: requestParent,
comment_attach_info: '',
reply_attach_info: attachInfo,
page_size: 20,
cell_feed_transfer: current.cell_feed_transfer,
}, user);
const replyList = data['replys'];
if ((replyList === undefined && data['has_more'] !== false) ||
(replyList !== undefined && (!Array.isArray(replyList) || replyList.some(item => !isRecord(item))))) {
throw new Error('QQ空间回复响应格式异常');
}
const replies = ((replyList ?? []) as QzoneComment[]).map(normalizeQzoneReply);
const state = this.commentPageState(data, 'reply_attach_info', attachInfo);
const nextParent = state.hasMore
? readParentSnapshot({
...requestParent, reply_attach_info: state.attachInfo, has_more_reply: state.hasMore,
})
: undefined;
const nextCursor = state.hasMore
? this.saveCursor({
account: this.accountKey(user),
kind: 'replies',
target: scope,
attachInfo: state.attachInfo,
feedReference: current,
// 保留首次完整父对象和内联回复,只推进分页元数据,不累计整段历史到请求中。
parentComment: nextParent,
})
: null;
return {
feed_ref: current,
comment_id: String(requestParent.id),
replies,
reply_num: data['reply_num'] === undefined ? observedCount(requestParent['reply_num']) : observedCount(data['reply_num']),
has_more: state.hasMore,
next_cursor: nextCursor,
raw: data,
};
}
async publishMsg (content: string, ugcRight = 1, targetUins?: string[]): Promise<{ tid: string; time: number; }> {
const user = this.currentUser();
const request = buildQzonePublishRequest(user, content, ugcRight, targetUins);
const data = await this.rpc('PublishFeed', request, user);
const tid = isRecord(data['feed']) ? getQzoneTid(data['feed'] as QzoneFeed) : null;
if (!tid) throw new Error('QQ空间发表响应缺少说说标识,结果未知,请先查询列表确认,勿自动重试');
return { tid, time: parseQzonePublishTime((data['feed'] as QzoneFeed).cell_common?.time) };
}
async setLike (reference: QzoneFeedReference, enable: boolean): Promise<void> {
const ref = validateQzoneFeedReference(reference);
if (typeof ref.like_key !== 'string' || !ref.like_key.trim() || ref.like_key.length > 8192) {
throw new Error('缺少QQ空间点赞标识,请先重新获取说说详情');
}
if (typeof enable !== 'boolean') throw new Error('QQ空间点赞状态必须是布尔值');
const user = this.currentUser();
const data = await this.rpc(enable ? 'DoLike' : 'UnLike', {
like_key: ref.like_key, union_id: ref.union_id, like_type: 1, cell_feed_transfer: ref.cell_feed_transfer,
}, user);
if (!isRecord(data['like'])) throw new Error('QQ空间未返回点赞结果,请先查询状态,勿自动重试');
}
async comment (reference: QzoneFeedReference, content: string, parentComment?: QzoneComment, replyUser?: QzoneUser): Promise<{ comment_id: string; comment: QzoneComment; }> {
const ref = validateQzoneFeedReference(reference);
const segments = buildQzoneText(content);
if (replyUser !== undefined && parentComment === undefined) throw new Error('reply_user 必须与父评论一同提供');
const parent = parentComment === undefined ? undefined : validateComment(parentComment);
const target = parent ? validateUser(replyUser ?? parent.user) : undefined;
const user = this.currentUser();
const data = await this.rpc(parent ? 'AddReply' : 'AddComment', {
union_id: ref.union_id,
cell_feed_transfer: ref.cell_feed_transfer,
...(parent ? { comment: parent, reply: { content: segments, target_user: target, user } } : { comment: { user, content: segments } }),
}, user);
const result = data[parent ? 'reply' : 'comment'];
if (!isRecord(result) || !validIdentity(result['id'], 8192)) {
throw new Error('QQ空间未返回有效评论标识,结果未知,请先查询详情确认,勿自动重试');
}
return { comment_id: result['id'], comment: result as QzoneComment };
}
}
+36
View File
@@ -0,0 +1,36 @@
import { InstanceContext, NapCatCore } from '@/napcat-core/index';
export class NTQQSystemApi {
context: InstanceContext;
core: NapCatCore;
constructor (context: InstanceContext, core: NapCatCore) {
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);
}
}
+249
View File
@@ -0,0 +1,249 @@
import { ModifyProfileParams, User, UserDetailSource } from '@/napcat-core/types';
import { RequestUtil } from 'napcat-common/src/request';
import { InstanceContext, NapCatCore, ProfileBizType } from '..';
import { solveAsyncProblem } from 'napcat-common/src/helper';
import { Fallback, FallbackUtil } from 'napcat-common/src/fall-back';
export class NTQQUserApi {
context: InstanceContext;
core: NapCatCore;
constructor (context: InstanceContext, core: NapCatCore) {
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('[NapCat] [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.core.apis.GroupApi.getUidFromMemberCache(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('');
}
}
+903
View File
@@ -0,0 +1,903 @@
import { RequestUtil } from 'napcat-common/src/request';
import {
GroupEssenceMsgRet,
InstanceContext,
WebApiGroupMember,
WebApiGroupMemberRet,
WebApiGroupNoticeRet,
WebHonorType, NapCatCore,
} from '@/napcat-core/index';
import { createReadStream, existsSync, statSync } from 'node:fs';
import { open, unlink, writeFile, type FileHandle } from 'node:fs/promises';
import { createHash, randomUUID } from 'node:crypto';
import { basename, join } from 'node:path';
import { qunAlbumControl, qunAlbumVideoControl, qunAlbumVideoCoverControl, QunAlbumBatch, nextQunAlbumBatchId } from '../data/webapi';
import { createAlbumCommentRequest, createAlbumFeedPublish, createAlbumMediaFeed } from '../data/album';
import { FFmpegService } from '../helper/ffmpeg/ffmpeg';
import { defaultVideoThumbB64 } from '../helper/ffmpeg/video';
import {
buildQzoneDeleteBody,
buildQzonePublishBody,
buildQzoneUploadImageBody,
parseQzoneDeleteResponse,
parseQzonePublishResponse,
parseQzoneUploadImageResponseText,
toQzoneRichval,
QzoneDeleteResponse,
QzonePublishResponse,
} from '../data/qzone';
export interface SetNoticeRetSuccess {
ec: number;
em: string;
id: number;
ltsm: number;
new_fid: string;
read_only: number;
role: number;
srv_code: number;
}
// 群相册分片上传的并发数
const QUN_ALBUM_SLICE_CONCURRENCY = 8;
// 服务端没给分片大小时的下限,以及防止异常值的上限
const QUN_ALBUM_SLICE_MIN = 16384;
const QUN_ALBUM_SLICE_MAX = 4 * 1024 * 1024;
// 流式计算文件摘要,避免为了算 MD5/SHA1 把整个文件读进内存(视频可能有几十上百 MB)
function hashFile (path: string, algorithm: 'md5' | 'sha1'): Promise<string> {
return new Promise((resolve, reject) => {
const hash = createHash(algorithm);
createReadStream(path)
.on('data', chunk => hash.update(chunk))
.on('end', () => resolve(hash.digest('hex')))
.on('error', reject);
});
}
// 按需读取单个分片:并发上传时的内存占用只有「并发数 × 分片大小」
async function readSlice (handle: FileHandle, offset: number, length: number): Promise<Buffer> {
const buffer = Buffer.alloc(length);
const { bytesRead } = await handle.read(buffer, 0, length, offset);
// 读不满说明文件在上传过程中被改短了,继续传只会传上去一段补零的数据
if (bytesRead !== length) {
throw new Error(`读取分片失败: offset=${offset} 预期 ${length} 字节,实际 ${bytesRead} 字节`);
}
return buffer;
}
export class NTQQWebApi {
context: InstanceContext;
core: NapCatCore;
constructor (context: InstanceContext, core: NapCatCore) {
this.context = context;
this.core = core;
}
async shareDigest (groupCode: string, msgSeq: string, msgRandom: string, targetGroupCode: string) {
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
const url = `https://qun.qq.com/cgi-bin/group_digest/share_digest?${new URLSearchParams({
bkn: this.getBknFromCookie(cookieObject),
group_code: groupCode,
msg_seq: msgSeq,
msg_random: msgRandom,
target_group_code: targetGroupCode,
}).toString()}`;
try {
return RequestUtil.HttpGetText(url, 'GET', '', { Cookie: this.cookieToString(cookieObject) });
} catch {
return undefined;
}
}
async getGroupEssenceMsgAll (GroupCode: string) {
const ret: GroupEssenceMsgRet[] = [];
for (let i = 0; i < 20; i++) {
const data = await this.getGroupEssenceMsg(GroupCode, i, 50);
if (!data) break;
ret.push(data);
if (data.data.is_end) break;
}
return ret;
}
async getGroupEssenceMsg (GroupCode: string, page_start: number = 0, page_limit: number = 50) {
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
const url = `https://qun.qq.com/cgi-bin/group_digest/digest_list?${new URLSearchParams({
bkn: this.getBknFromCookie(cookieObject),
page_start: page_start.toString(),
page_limit: page_limit.toString(),
group_code: GroupCode,
}).toString()}`;
try {
const ret = await RequestUtil.HttpGetJson<GroupEssenceMsgRet>(
url,
'GET',
'',
{ Cookie: this.cookieToString(cookieObject) }
);
return ret.retcode === 0 ? ret : undefined;
} catch {
return undefined;
}
}
async getGroupMembers (GroupCode: string): Promise<WebApiGroupMember[]> {
// logDebug('webapi 获取群成员', GroupCode);
const memberData: Array<WebApiGroupMember> = new Array<WebApiGroupMember>();
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
const retList: Promise<WebApiGroupMemberRet>[] = [];
const fastRet = await RequestUtil.HttpGetJson<WebApiGroupMemberRet>(
`https://qun.qq.com/cgi-bin/qun_mgr/search_group_members?${new URLSearchParams({
st: '0',
end: '40',
sort: '1',
gc: GroupCode,
bkn: this.getBknFromCookie(cookieObject),
}).toString()}`,
'POST',
'',
{ Cookie: this.cookieToString(cookieObject) }
);
if (!fastRet?.count || fastRet?.errcode !== 0 || !fastRet?.mems) {
return [];
} else {
for (const key in fastRet.mems) {
if (fastRet.mems[key]) {
memberData.push(fastRet.mems[key]);
}
}
}
// 初始化获取PageNum
const PageNum = Math.ceil(fastRet.count / 40);
// 遍历批量请求
for (let i = 2; i <= PageNum; i++) {
const ret = RequestUtil.HttpGetJson<WebApiGroupMemberRet>(
`https://qun.qq.com/cgi-bin/qun_mgr/search_group_members?${new URLSearchParams({
st: ((i - 1) * 40).toString(),
end: (i * 40).toString(),
sort: '1',
gc: GroupCode,
bkn: this.getBknFromCookie(cookieObject),
}).toString()}`,
'POST',
'',
{ Cookie: this.cookieToString(cookieObject) }
);
retList.push(ret);
}
// 批量等待
for (let i = 1; i <= PageNum; i++) {
const ret = await (retList[i]);
if (!ret?.count || ret?.errcode !== 0 || !ret?.mems) {
continue;
}
for (const key in ret.mems) {
if (ret.mems[key]) {
memberData.push(ret.mems[key]);
}
}
}
return memberData;
}
// public async addGroupDigest(groupCode: string, msgSeq: string) {
// const url = `https://qun.qq.com/cgi-bin/group_digest/cancel_digest?random=665&X-CROSS-ORIGIN=fetch&group_code=${groupCode}&msg_seq=${msgSeq}&msg_random=444021292`;
// const res = await this.request(url);
// return await res.json();
// }
// public async getGroupDigest(groupCode: string) {
// const url = `https://qun.qq.com/cgi-bin/group_digest/digest_list?random=665&X-CROSS-ORIGIN=fetch&group_code=${groupCode}&page_start=0&page_limit=20`;
// const res = await this.request(url);
// return await res.json();
// }
async setGroupNotice (
GroupCode: string,
Content: string,
pinned: number = 0,
type: number = 1,
is_show_edit_card: number = 1,
tip_window_type: number = 1,
confirm_required: number = 1,
picId: string = '',
imgWidth: number = 540,
imgHeight: number = 300
) {
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
try {
const settings = JSON.stringify({
is_show_edit_card,
tip_window_type,
confirm_required,
});
const externalParam = {
pic: picId,
imgWidth: imgWidth.toString(),
imgHeight: imgHeight.toString(),
};
const ret: SetNoticeRetSuccess = await RequestUtil.HttpGetJson<SetNoticeRetSuccess>(
`https://web.qun.qq.com/cgi-bin/announce/add_qun_notice?${new URLSearchParams({
bkn: this.getBknFromCookie(cookieObject),
qid: GroupCode,
text: Content,
pinned: pinned.toString(),
type: type.toString(),
settings,
...(picId === '' ? {} : externalParam),
}).toString()}`,
'POST',
'',
{ Cookie: this.cookieToString(cookieObject) }
);
return ret;
} catch {
return undefined;
}
}
async getGroupNotice (GroupCode: string): Promise<undefined | WebApiGroupNoticeRet> {
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
try {
const ret = await RequestUtil.HttpGetJson<WebApiGroupNoticeRet>(
`https://web.qun.qq.com/cgi-bin/announce/get_t_list?${new URLSearchParams({
bkn: this.getBknFromCookie(cookieObject),
qid: GroupCode,
ft: '23',
ni: '1',
n: '1',
i: '1',
log_read: '1',
platform: '1',
s: '-1',
}).toString()}&n=20`,
'GET',
'',
{ Cookie: this.cookieToString(cookieObject) }
);
return ret?.ec === 0 ? ret : undefined;
} catch {
return undefined;
}
}
private async getDataInternal (cookieObject: { [key: string]: string; }, groupCode: string, type: number) {
let resJson;
try {
const res = await RequestUtil.HttpGetText(
`https://qun.qq.com/interactive/honorlist?${new URLSearchParams({
gc: groupCode,
type: type.toString(),
}).toString()}`,
'GET',
'',
{ Cookie: this.cookieToString(cookieObject) }
);
const match = /window\.__INITIAL_STATE__=(.*?);/.exec(res);
if (match?.[1]) {
resJson = JSON.parse(match[1].trim());
}
return type === 1 ? resJson?.talkativeList : resJson?.actorList;
} catch (e) {
this.context.logger.logDebug('获取当前群荣耀失败', e);
return undefined;
}
}
private async getHonorList (cookieObject: { [key: string]: string; }, groupCode: string, type: number) {
const data = await this.getDataInternal(cookieObject, groupCode, type);
if (!data) {
this.context.logger.logError(`获取类型 ${type} 的荣誉信息失败`);
return [];
}
return data.map((item: {
uin: string,
name: string,
avatar: string,
desc: string,
}) => ({
user_id: item?.uin,
nickname: item?.name,
avatar: item?.avatar,
description: item?.desc,
}));
}
async getGroupHonorInfo (groupCode: string, getType: WebHonorType) {
const cookieObject = await this.core.apis.UserApi.getCookies('qun.qq.com');
const HonorInfo = {
group_id: Number(groupCode),
current_talkative: {},
talkative_list: [],
performer_list: [],
legend_list: [],
emotion_list: [],
strong_newbie_list: [],
};
if (getType === WebHonorType.TALKATIVE || getType === WebHonorType.ALL) {
const talkativeList = await this.getHonorList(cookieObject, groupCode, 1);
if (talkativeList.length > 0) {
HonorInfo.current_talkative = talkativeList[0];
HonorInfo.talkative_list = talkativeList;
}
}
if (getType === WebHonorType.PERFORMER || getType === WebHonorType.ALL) {
HonorInfo.performer_list = await this.getHonorList(cookieObject, groupCode, 2);
}
if (getType === WebHonorType.LEGEND || getType === WebHonorType.ALL) {
HonorInfo.legend_list = await this.getHonorList(cookieObject, groupCode, 3);
}
if (getType === WebHonorType.EMOTION || getType === WebHonorType.ALL) {
HonorInfo.emotion_list = await this.getHonorList(cookieObject, groupCode, 6);
}
// 冒尖小春笋好像已经被tx扬了 R.I.P.
if (getType === WebHonorType.EMOTION || getType === WebHonorType.ALL) {
HonorInfo.strong_newbie_list = [];
}
return HonorInfo;
}
private cookieToString (cookieObject: { [key: string]: string; }) {
return Object.entries(cookieObject).map(([key, value]) => `${key}=${value}`).join('; ');
}
public getBknFromCookie (cookieObject: { [key: string]: string; }) {
const sKey = cookieObject['skey'] as string;
let hash = 5381;
for (let i = 0; i < sKey.length; i++) {
const code = sKey.charCodeAt(i);
hash = hash + (hash << 5) + code;
}
return (hash & 0x7FFFFFFF).toString();
}
public getBknFromSKey (sKey: string) {
let hash = 5381;
for (let i = 0; i < sKey.length; i++) {
const code = sKey.charCodeAt(i);
hash = hash + (hash << 5) + code;
}
return (hash & 0x7FFFFFFF).toString();
}
public getBknFromPSKey (psKey: string) {
return this.getBknFromSKey(psKey);
}
async getAlbumListByNTQQ (gc: string, attach_info: string = '') {
return await this.context.session.getAlbumService().getAlbumList({
qun_id: gc,
attach_info,
seq: 3331,
request_time_line: {
request_invoke_time: '0',
},
});
}
async getAlbumList (gc: string) {
const skey = await this.core.apis.UserApi.getSKey() || '';
const pskey = (await this.core.apis.UserApi.getPSkey(['qzone.qq.com'])).domainPskeyMap.get('qzone.qq.com') || '';
const bkn = this.getBknFromSKey(skey);
const uin = this.core.selfInfo.uin || '10001';
const cookies = `p_uin=o${this.core.selfInfo.uin}; p_skey=${pskey}; skey=${skey}; uin=o${uin} `;
const api = 'https://h5.qzone.qq.com/proxy/domain/u.photo.qzone.qq.com/cgi-bin/upp/qun_list_album_v2?';
const params = new URLSearchParams({
random: '7570',
g_tk: bkn,
format: 'json',
inCharset: 'utf-8',
outCharset: 'utf-8',
qua: 'V1_IPH_SQ_6.2.0_0_HDBM_T',
cmd: 'qunGetAlbumList',
qunId: gc,
qunid: gc,
start: '0',
num: '1000',
uin,
getMemberRole: '0',
});
const response = await RequestUtil.HttpGetJson<{ data: { album: Array<{ id: string, title: string; }>; }; }>(api + params.toString(), 'GET', '', {
Cookie: cookies,
});
return response.data.album;
}
async createQunAlbumSession (
gc: string, sAlbumID: string, sAlbumName: string, path: string,
skey: string, pskey: string, img_md5: string, uin: string, batch?: QunAlbumBatch
) {
const img_size = statSync(path).size;
const img_name = basename(path);
const GTK = this.getBknFromSKey(skey);
const cookie = `p_uin=o${uin}; p_skey=${pskey}; skey=${skey}; uin=o${uin}`;
const body = qunAlbumControl({
uin,
group_id: gc,
pskey,
pic_md5: img_md5,
img_size,
img_name,
sAlbumName,
sAlbumID,
// 同批多图共用 batchId,并按「第几张 / 共几张」上报,相册里才会合并成同一条
batchId: batch?.batchId,
batchIndex: batch?.index,
photo_num: batch?.count,
batch_num: batch?.count,
});
const api = `https://h5.qzone.qq.com/webapp/json/sliceUpload/FileBatchControl/${img_md5}?g_tk=${GTK}`;
const post = await RequestUtil.HttpGetJson<{
data: { session: string; slice_size?: number | string; },
ret: number,
msg: string;
}>(api, 'POST', body, {
Cookie: cookie,
'Content-Type': 'application/json',
});
return post;
}
// 服务端会在会话响应里给出建议的分片大小;取不到或异常时退回 16384。
private resolveQunAlbumSliceSize (raw: unknown) {
const value = Number(raw);
if (!Number.isFinite(value) || value < QUN_ALBUM_SLICE_MIN) return QUN_ALBUM_SLICE_MIN;
return Math.min(value, QUN_ALBUM_SLICE_MAX);
}
async uploadQunAlbumSlice (
path: string, session: string, skey: string, pskey: string, uin: string, slice_size: number,
options: { appid?: string, cmd?: string; } = {}
) {
const appid = options.appid ?? 'qun';
const cmd = options.cmd ?? 'FileUpload';
const img_size = statSync(path).size;
const GTK = this.getBknFromSKey(skey);
const cookie = `p_uin=o${uin}; p_skey=${pskey}; skey=${skey}; uin=o${uin}`;
// 只记分片位置,不预先读内容;再并发上传(串行上传在几 MB 的图上要几十秒)
const slices: { seq: number, offset: number, end: number; }[] = [];
let seq = 0;
let offset = 0;
while (offset < img_size) {
const end = Math.min(offset + slice_size, img_size);
slices.push({ seq, offset, end });
offset = end;
seq++;
}
// 视频分片响应里会带 sVid,封面二段上传要靠它把封面挂到视频上
let sVid: string | undefined;
const handle = await open(path, 'r');
const uploadSlice = async (slice: { seq: number, offset: number, end: number; }) => {
const chunk = await readSlice(handle, slice.offset, slice.end - slice.offset);
const form = new FormData();
form.append('uin', uin);
form.append('appid', appid);
form.append('session', session);
form.append('offset', slice.offset.toString());
form.append('data', new Blob([chunk], { type: 'application/octet-stream' }), 'blob');
form.append('checksum', '');
form.append('check_type', '0');
form.append('retry', '0');
form.append('seq', slice.seq.toString());
form.append('end', slice.end.toString());
form.append('cmd', cmd);
form.append('slice_size', slice_size.toString());
form.append('biz_req.iUploadType', '0');
// 分片路径必须与 cmd 一致:视频走 /sliceUpload/FileUploadVideo,
// 否则服务端按图片处理,分片会「成功」但响应里没有 sVid
const api = `https://h5.qzone.qq.com/webapp/json/sliceUpload/${cmd}?seq=${slice.seq}&retry=0&offset=${slice.offset}&end=${slice.end}&total=${img_size}&type=form&g_tk=${GTK}`;
const response = await fetch(api, {
method: 'POST',
headers: {
Cookie: cookie,
},
body: form,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const post = await response.json() as { ret: number, msg: string, data?: { biz?: { sVid?: string; }; }; };
if (post.ret !== 0) {
throw new Error(`分片 ${slice.seq} 上传失败: ${post.msg}`);
}
if (!sVid && typeof post.data?.biz?.sVid === 'string' && post.data.biz.sVid) {
sVid = post.data.biz.sVid;
}
};
try {
// 最后一片要等前面的全部传完再发:服务端收到最后一片就认为文件传完了,
// 并发时它可能比前面的分片先到
const lastSlice = slices.pop();
let next = 0;
let failure: { error: unknown; } | undefined;
// 固定数量的 worker 各自领下一片,慢的分片不会拖住整批
const worker = async () => {
while (!failure) {
const slice = slices[next++];
if (!slice) break;
try {
await uploadSlice(slice);
} catch (error) {
if (!failure) failure = { error };
}
}
};
await Promise.all(Array.from({ length: Math.min(QUN_ALBUM_SLICE_CONCURRENCY, slices.length) }, worker));
if (failure) throw failure.error;
if (lastSlice) await uploadSlice(lastSlice);
} finally {
await handle.close();
}
return { success: true, message: '上传完成', sVid };
}
// 多张图片一次上传:共用同一个 batchId,相册里显示为同一条
async uploadImagesToQunAlbum (gc: string, sAlbumID: string, sAlbumName: string, paths: string[]) {
if (!paths.length) throw new Error('上传列表为空');
const skey = await this.core.apis.UserApi.getSKey() || '';
const pskey = (await this.core.apis.UserApi.getPSkey(['qzone.qq.com'])).domainPskeyMap.get('qzone.qq.com') || '';
const uin = this.core.selfInfo.uin || '10001';
const batchId = nextQunAlbumBatchId();
const count = paths.length.toString();
for (const [index, path] of paths.entries()) {
const img_md5 = await hashFile(path, 'md5');
const post = await this.createQunAlbumSession(
gc, sAlbumID, sAlbumName, path, skey, pskey, img_md5, uin,
{ batchId, count, index: index.toString() }
);
if (!post.data?.session) throw new Error(`创建群相册会话失败: ${post.msg || post.ret}`);
await this.uploadQunAlbumSlice(path, post.data.session, skey, pskey, uin, this.resolveQunAlbumSliceSize(post.data.slice_size));
}
}
async uploadImageToQunAlbum (gc: string, sAlbumID: string, sAlbumName: string, path: string) {
await this.uploadImagesToQunAlbum(gc, sAlbumID, sAlbumName, [path]);
}
/**
* 上传视频到群相册。
*
* 分三步:
* 1. 建视频会话:appid=video_qun / cmd=FileUploadVideo / SHA1 校验,此时不带相册
* 2. 分片上传视频,从分片响应里拿 sVid
* 3. 按图片上传封面并用 vid 关联视频,相册和批次号在这一步带上,视频随封面进相册
*/
async uploadVideoToQunAlbum (gc: string, sAlbumID: string, sAlbumName: string, path: string) {
const skey = await this.core.apis.UserApi.getSKey() || '';
const pskey = (await this.core.apis.UserApi.getPSkey(['qzone.qq.com'])).domainPskeyMap.get('qzone.qq.com') || '';
const uin = this.core.selfInfo.uin || '10001';
const GTK = this.getBknFromSKey(skey);
const cookie = `p_uin=o${uin}; p_skey=${pskey}; skey=${skey}; uin=o${uin}`;
const video_sha1 = await hashFile(path, 'sha1');
// 时长:ffmpeg 不可用时上报 0
let playTime = 0;
try {
playTime = Math.max(0, Math.round(await FFmpegService.getDuration(path)));
} catch (e) {
this.context.logger.logWarn('获取视频时长失败,iPlayTime 上报 0', e);
}
// 1) 视频主体(不带相册,只传成素材)
const videoSession = await RequestUtil.HttpGetJson<{
data: { session: string; slice_size?: number | string; },
ret: number,
msg: string;
}>(`https://h5.qzone.qq.com/webapp/json/sliceUpload/FileBatchControl/${video_sha1}?g_tk=${GTK}`, 'POST', qunAlbumVideoControl({
uin,
group_id: gc,
pskey,
video_sha1,
video_size: statSync(path).size,
play_time: playTime,
}), {
Cookie: cookie,
'Content-Type': 'application/json',
});
if (!videoSession.data?.session) throw new Error(`创建群相册视频会话失败: ${videoSession.msg || videoSession.ret}`);
const uploaded = await this.uploadQunAlbumSlice(path, videoSession.data.session, skey, pskey, uin,
this.resolveQunAlbumSliceSize(videoSession.data.slice_size), { appid: 'video_qun', cmd: 'FileUploadVideo' });
if (!uploaded.sVid) throw new Error('视频上传完成但未取得 sVid,无法挂进相册');
// 2) 封面:抽首帧,失败(含 ffmpeg 不可用)回落内置默认封面
// 用 .png:Native Addon 路径产出的就是 png(日志里可见 Detected format: png),
// 而命令行路径会按扩展名编码,两条路都能得到真正的 PNG
const coverPath = join(this.core.NapCatTempPath, `${randomUUID()}.png`);
try {
try {
await FFmpegService.extractThumbnail(path, coverPath);
} catch (e) {
this.context.logger.logWarn('提取视频封面失败,使用默认封面', e);
await writeFile(coverPath, Buffer.from(defaultVideoThumbB64, 'base64'));
}
// 3) 封面二段上传:这一步才带相册和批次号,视频靠它进相册
const cover_md5 = await hashFile(coverPath, 'md5');
const coverSession = await RequestUtil.HttpGetJson<{
data: { session: string; slice_size?: number | string; },
ret: number,
msg: string;
}>(`https://h5.qzone.qq.com/webapp/json/sliceUpload/FileBatchControl/${cover_md5}?g_tk=${GTK}`, 'POST', qunAlbumVideoCoverControl({
uin,
group_id: gc,
pskey,
sAlbumName,
sAlbumID,
cover_md5,
cover_size: statSync(coverPath).size,
vid: uploaded.sVid,
}), {
Cookie: cookie,
'Content-Type': 'application/json',
});
if (!coverSession.data?.session) throw new Error(`创建群相册视频封面会话失败: ${coverSession.msg || coverSession.ret}`);
await this.uploadQunAlbumSlice(coverPath, coverSession.data.session, skey, pskey, uin,
this.resolveQunAlbumSliceSize(coverSession.data.slice_size));
} finally {
if (existsSync(coverPath)) {
await unlink(coverPath);
}
}
}
async getAlbumMediaListByNTQQ (gc: string, albumId: string, attach_info: string = '') {
return (await this.context.session.getAlbumService().getMediaList({
qun_id: gc,
attach_info,
seq: 0,
request_time_line: {
request_invoke_time: '0',
},
album_id: albumId,
lloc: '',
batch_id: '',
})).response;
}
async doAlbumMediaPlainCommentByNTQQ (
qunId: string,
albumId: string,
lloc: string | undefined,
content: string,
batchId: string = '') {
if (!lloc && !batchId) {
throw new Error('lloc 或 batch_id 至少需要提供一个');
}
const random_seq = Math.floor(Math.random() * 9000) + 1000;
const uin = this.core.selfInfo.uin || '10001';
const albumService = this.context.session.getAlbumService();
let feed;
if (lloc) {
feed = createAlbumMediaFeed(uin, albumId, lloc, batchId);
} else {
// Batch comments need the server's feed identity and uploader, not an
// empty media cell with the commenter substituted for the feed owner.
const { response } = await albumService.getQunFeedDetail({
seq: random_seq,
request_time_line: { request_invoke_time: '0' },
ext_info: { map_info: [], map_bytes_info: [], map_user_account: [] },
qun_id: qunId,
feed_id: '',
comment_count: 20,
attach_info: '',
album_id: albumId,
batch_id: batchId,
lloc: '',
});
if (response?.result !== 0) {
throw new Error(`获取群相册批次动态失败, ${response?.result ?? 'unknown'}:${response?.errMs || '未知错误'}`);
}
feed = response.feed?.feed;
if (!feed?.cell_common?.feed_id || !feed.cell_user_info?.user || !feed.cell_media) {
throw new Error('获取群相册批次动态失败: 未返回有效动态');
}
}
// 16位number数字
const client_key = Date.now() * 1000;
const result = await albumService.doQunComment(
random_seq, {
map_info: [],
map_bytes_info: [],
map_user_account: [],
},
qunId,
2,
feed,
createAlbumCommentRequest(uin, content, client_key)
);
if (result?.result !== 0) {
throw new Error(`群相册评论失败, ${result?.result ?? 'unknown'}:${result?.errMs || '未知错误'}`);
}
if (!result.comment?.id) {
throw new Error('群相册评论失败: 未返回有效评论');
}
return result;
}
async deleteAlbumMediaByNTQQ (
qunId: string,
albumId: string,
lloc: string) {
const random_seq = Math.floor(Math.random() * 9000) + 1000;
return await this.context.session.getAlbumService().deleteMedias(
random_seq,
qunId,
albumId,
[lloc],
[]
);
}
async doAlbumMediaLikeByNTQQ (
qunId: string,
albumId: string,
batchId: string,
lloc: string | undefined,
isLike: boolean
) {
const random_seq = Math.floor(Math.random() * 9000) + 1000;
const uin = this.core.selfInfo.uin || '10001';
const type = isLike ? 2 : 1;
const status = isLike ? 0 : 1;
let id = '';
if (lloc) {
id = `421_1_0_${qunId}|${albumId}|${batchId}^||^421_1_0_${qunId}|${albumId}|${lloc}^||^0`;
} else {
id = `421_1_0_${qunId}|${albumId}|${batchId}`;
}
return await this.context.session.getAlbumService().doQunLike(
random_seq,
{
map_info: [],
map_bytes_info: [],
map_user_account: [],
},
type,
{
id,
status,
},
createAlbumFeedPublish(qunId, uin, albumId, batchId)
);
}
private async getQzoneAuth () {
const uin = this.core.selfInfo.uin;
const uid = this.core.selfInfo.uid;
if (!uin || !uid) throw new Error('QQ空间需要已登录的当前账号');
const assertAccount = () => {
if (this.core.selfInfo.uin !== uin || this.core.selfInfo.uid !== uid) throw new Error('QQ账号已切换,请重新确认QQ空间操作');
};
const skey = await this.core.apis.UserApi.getSKey() || '';
assertAccount();
const pskey = (await this.core.apis.UserApi.getPSkey(['qzone.qq.com'])).domainPskeyMap.get('qzone.qq.com') || '';
assertAccount();
const g_tk = this.getBknFromSKey(skey);
const cookie = `p_uin=o${uin}; p_skey=${pskey}; skey=${skey}; uin=o${uin}`;
return { skey, pskey, uin, g_tk, cookie };
}
/**
* 上传图片到 QQ 空间相册, 返回可用于发说说的 richval 字符串
* @param base64 图片Base64编码内容(不带data:前缀)
*/
async uploadImageToQzone (base64: string): Promise<string> {
const { skey, pskey, uin, g_tk, cookie } = await this.getQzoneAuth();
const body = buildQzoneUploadImageBody({ uin, skey, pskey, g_tk, base64 });
const api = `https://up.qzone.qq.com/cgi-bin/upload/cgi_upload_image?g_tk=${g_tk}`;
const resultText = await RequestUtil.HttpGetText(api, 'POST', body, {
Cookie: cookie,
'Content-Type': 'application/x-www-form-urlencoded',
});
const parsed = parseQzoneUploadImageResponseText(resultText);
if (parsed.code !== undefined && parsed.code !== 0) {
throw new Error(parsed.msg || `QQ空间图片上传失败, code=${parsed.code}`);
}
if (!parsed.data) {
throw new Error('QQ空间图片上传失败, 响应缺少data字段');
}
return toQzoneRichval(parsed.data);
}
/**
* 发表QQ空间说说
* @param content 说说正文
* @param richvals 每张图片对应的richval数组, 为空表示纯文字说说
* @param ugcRight 查看权限 1所有人可见 4好友可见 16部分好友可见 64仅自己可见 128部分好友不可见
* @param targetUins 权限作用QQ号数组, ugcRight为16/128时使用
*/
async publishQzoneMsg (content: string, richvals: string[], ugcRight: number, targetUins?: Array<string | number>) {
const { uin, g_tk, cookie } = await this.getQzoneAuth();
const richval = richvals.length > 0 ? richvals.join('\t') : undefined;
const allowUins = targetUins && targetUins.length > 0 ? targetUins.join('|') : undefined;
const body = buildQzonePublishBody({
hostuin: uin,
content,
richval,
ugcRight,
allowUins,
});
const api = `https://user.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_publish_v6?g_tk=${g_tk}`;
const result = await RequestUtil.HttpGetJson<QzonePublishResponse>(api, 'POST', body, {
Cookie: cookie,
'Content-Type': 'application/x-www-form-urlencoded',
}, true, false);
return parseQzonePublishResponse(result);
}
/**
* 删除QQ空间说说
* @param tid 说说Tid, 来自 publishQzoneMsg 的返回值
*/
async deleteQzoneMsg (tid: string) {
const { uin, g_tk, cookie } = await this.getQzoneAuth();
const body = buildQzoneDeleteBody({ hostuin: uin, tid });
const api = `https://user.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_delete_v6?g_tk=${g_tk}`;
const result = await RequestUtil.HttpGetJson<QzoneDeleteResponse>(api, 'POST', body, {
Cookie: cookie,
'Content-Type': 'application/x-www-form-urlencoded',
}, true, false);
return parseQzoneDeleteResponse(result);
}
async getDaySignedList (groupCode: string) {
const pSkey = (await this.core.apis.UserApi.getPSkey(['qun.qq.com'])).domainPskeyMap.get('qun.qq.com')!;
const selfUin = this.core.selfInfo.uin;
const cookie = `p_uin=o${selfUin}; p_skey=${pSkey}; uin=o${selfUin}`;
const post = await RequestUtil.HttpGetJson<{
retCode: number,
costTime: number,
response: {
ret?: {
code: string,
msg: string,
},
page?: {
infos?: {
uid: string,
uidGroupNick: string,
signedTimeStamp: string,
signInRank: number,
}[],
offset: number,
total: number,
}[],
},
funcCode: number,
}>(`https://qun.qq.com/v2/signin/trpc/GetDaySignedList?g_tk=${this.getBknFromPSKey(pSkey)}`, 'POST', {
dayYmd: new Date().toISOString().slice(0, 10).replace(/-/g, ''),
offset: 0,
limit: 100,
uid: selfUin,
groupId: groupCode,
}, {
Cookie: cookie,
'Content-Type': 'application/json',
});
return post;
}
}
+226
View File
@@ -0,0 +1,226 @@
/**
* 群相册列表请求参数接口
*/
export interface AlbumListRequest {
qun_id: string;
attach_info: string;
seq: number;
request_time_line: {
request_invoke_time: string;
};
album_id: string;
lloc: string;
batch_id: string;
}
/**
* 创建群相册列表请求参数
* @param qunId 群号
* @param albumId 相册ID
* @param seq 请求序列号,默认值为0
* @returns 请求参数对象
*/
export function createAlbumListRequest (
qunId: string,
albumId: string,
seq: number = 0
): AlbumListRequest {
return {
qun_id: qunId,
attach_info: '',
seq,
request_time_line: {
request_invoke_time: '0',
},
album_id: albumId,
lloc: '',
batch_id: '',
};
}
/**
* 相册媒体项请求接口
*/
export interface AlbumMediaFeed {
cell_common: {
time: string;
feed_id?: string;
};
cell_user_info: {
user: {
uin: string;
};
};
cell_media: {
album_id: string;
batch_id: string;
media_items: Array<{
image: {
lloc: string;
};
}>;
};
}
/**
* 创建相册媒体请求参数
* @param uin 用户QQ号
* @param albumId 相册ID
* @param lloc 图片ID;不传或为空时评论整个上传批次
* @param batchId 上传批次ID;lloc 为空时必填
* @returns 媒体请求参数对象
*/
export function createAlbumMediaFeed (
uin: string,
albumId: string,
lloc?: string,
batchId: string = ''
): AlbumMediaFeed {
if (!lloc && !batchId) {
throw new Error('lloc 或 batch_id 至少需要提供一个');
}
return {
cell_common: {
time: '',
},
cell_user_info: {
user: {
uin,
},
},
cell_media: {
album_id: albumId,
batch_id: batchId,
media_items: lloc ? [{ image: { lloc } }] : [],
},
};
}
/**
* 相册评论内容接口
*/
export interface AlbumCommentContent {
type: number;
content: string;
who: number;
uid: string;
name: string;
url: string;
}
/**
* 相册评论请求接口
*/
export interface AlbumCommentReplyContent {
client_key: number;
content: AlbumCommentContent[];
user: {
uin: string;
};
like: {
id: string;
count: number;
status: number;
};
}
export enum RichMsgType {
KRICHMSGTYPEPLAINTEXT,
KRICHMSGTYPEAT,
KRICHMSGTYPEURL,
KRICHMSGTYPEMEDIA,
}
/**
* 创建相册评论请求参数
* @param uin 用户QQ号
* @param content 评论内容
* @param client_key 客户端鉴权密钥
* @returns 评论请求参数对象
*/
export function createAlbumCommentRequest (
uin: string,
content: string,
client_key: number
): AlbumCommentReplyContent {
return {
client_key,
// 暂时只支持纯文本吧
content: [{
type: RichMsgType.KRICHMSGTYPEPLAINTEXT,
content,
who: 0,
uid: '',
name: '',
url: '',
}],
user: {
uin,
},
// QQNT 会将 like 转换为对象,即使新评论没有点赞也不能省略。
like: {
id: '',
count: 0,
status: 0,
},
};
}
export interface AlbumFeedLikePublish {
cell_common: {
time: number;
feed_id: string;
};
cell_user_info: {
user: {
uin: string;
};
};
cell_media: {
album_id: string;
batch_id: string | number;
media_items?: {
type: number;
image: {
lloc: string;
sloc: string;
};
}[];
};
cell_qun_info: {
qun_id: string;
};
}
/**
* 创建相册动态发布请求参数
* @param qunId 群号
* @param uin 用户QQ号
* @param albumId 相册ID
* @param batchId 批次ID
* @returns 动态发布请求参数对象
*/
export function createAlbumFeedPublish (
qunId: string,
uin: string,
albumId: string,
batchId: string
) {
return {
cell_common: {
time: Date.now(),
feed_id: `422_0_${batchId}`, // 需要补全feed_id
},
cell_user_info: {
user: {
uin,
},
},
cell_media: {
album_id: albumId,
batch_id: batchId,
},
cell_qun_info: {
qun_id: qunId,
},
};
}
+368
View File
@@ -0,0 +1,368 @@
export interface FlashBaseRequest {
fileSetId: string;
}
export interface UploaderInfo {
uin: string,
nickname: string,
uid: string,
sendEntrance: string, // ""
}
export interface thumbnailInfo {
id: string,
url: {
spec: number,
uri: string,
}[],
localCachePath: string,
}
export interface SendTarget {
destType: number; // 1私聊
destUin?: string,
destUid: string,
}
export interface SendTargetRequests {
fileSetId: string;
targets: SendTarget[];
}
export interface DownloadStatusInfo {
result: number; // 0
fileSetId: string;
status: number;
info: {
curDownLoadFailFileNum: number,
curDownLoadedPauseFileNum: number,
curDownLoadedFileNum: number,
curRealDownLoadedFileNum: number,
curDownloadingFileNum: number,
totalDownLoadedFileNum: number,
curDownLoadedBytes: string, // "0"
totalDownLoadedBytes: string,
curSpeedBps: number,
avgSpeedBps: number,
maxSpeedBps: number,
remainDownLoadSeconds: number,
failFileIdList: [],
allFileIdList: [],
hasNormalFileDownloading: boolean,
onlyCompressInnerFileDownloading: boolean,
isAllFileAlreadyDownloaded: boolean,
saveFileSetDir: string,
allWaitingStatusTask: boolean,
downloadSceneType: DownloadSceneType,
retryCount: number,
statisticInfo: {
downloadTaskId: string,
downloadFilesetName: string,
downloadFileTypeDistribution: string,
downloadFileSizeDistribution: string;
},
albumStorageFailImageNum: number,
albumStorageFailVideoNum: number,
albumStorageFailFileIdList: [],
albumStorageSucImageNum: number,
albumStorageSucVideoNum: number,
albumStorageSucFileIdList: [],
albumStorageFileNum: number;
};
}
export interface physicalInfo {
id: string,
url: string,
status: number, // 2 已下载
processing: string,
localPath: string,
width: 0,
height: 0,
time: number,
}
export interface downloadInfo {
status: number,
curDownLoadBytes: string,
totalFileBytes: string,
errorCode: number,
}
export interface uploadInfo {
uploadedBytes: string,
errorCode: number,
svrRrrCode: number,
errMsg: string,
isNeedDelDeviceInfo: boolean,
thumbnailUploadState: number;
isSecondHit: boolean,
hasModifiedErr: boolean,
}
export interface folderUploadInfo {
totalUploadedFileSize: string;
successCount: number;
failedCount: number;
}
export interface folderDownloadInfo {
totalDownloadedFileSize: string;
totalFileSize: string;
totalDownloadFileCount: number;
successCount: number;
failedCount: number;
pausedCount: number;
cancelCount: number;
downloadingCount: number;
partialDownloadCount: number;
curLevelDownloadedFileCount: number;
curLevelUnDownloadedFileCount: number;
}
export interface compressFileFolderInfo {
downloadStatus: number;
saveFileDirPath: string;
totalFileCount: string;
totalFileSize: string;
}
export interface albumStorgeInfo {
status: number;
localIdentifier: string;
errorCode: number;
timeCost: number;
}
export interface FlashOneFileInfo {
fileSetId: string;
cliFileId: string; // client?? 或许可以换取url
compressedFileFolderId: string;
archiveIndex: 0;
indexPath: string;
isDir: boolean; // 文件或者文件夹!!
parentId: string;
depth: number; // 1
cliFileIndex: number;
fileType: number; // 枚举!! 已完成枚举!!
name: string;
namePinyin: string;
isCover: boolean;
isCoverOriginal: boolean;
fileSize: string;
fileCount: number;
thumbnail: thumbnailInfo;
physical: physicalInfo;
srvFileId: string; // service?? 服务器上面的id吗?
srvParentFileId: string;
svrLastUpdateTimestamp: string;
downloadInfo: downloadInfo;
saveFilePath: string;
search_relative_path: string;
disk_relative_path: string;
uploadInfo: uploadInfo;
status: number;
uploadStatus: number; // 3已上传成功
downloadStatus: number; // 0未下载
folderUploadInfo: folderUploadInfo;
folderDownloadInfo: folderDownloadInfo;
sha1: string;
bookmark: string;
compressFileFolderInfo: compressFileFolderInfo;
uploadPauseReason: string;
downloadPauseReason: string;
filePhysicalSize: string;
thumbnail_sha1: string | null;
thumbnail_size: string | null;
needAlbumStorage: boolean;
albumStorageInfo: albumStorgeInfo;
}
export interface fileListsInfo {
parentId: string,
depth: number, // 1
fileList: FlashOneFileInfo[],
paginationInfo: {};
isEnd: boolean,
isCache: boolean,
}
export interface FileListResponse {
seq: number,
result: number,
errMs: string,
fileLists: fileListsInfo[],
}
export interface createFlashTransferResult {
fileSetId: string,
shareLink: string,
expireTime: string,
expireLeftTime: string,
}
export enum UploadSceneType {
KUPLOADSCENEUNKNOWN,
KUPLOADSCENEFLOATWINDOWRIGHTCLICKMENU,
KUPLOADSCENEFLOATWINDOWDRAG,
KUPLOADSCENEFLOATWINDOWFILESELECTOR,
KUPLOADSCENEFLOATWINDOWSHORTCUTKEYCTRLCV,
KUPLOADSCENEH5LAUNCHCLIENTRIGHTCLICKMENU,
KUPLOADSCENEH5LAUNCHCLIENTDRAG,
KUPLOADSCENEH5LAUNCHCLIENTFILESELECTOR,
KUPLOADSCENEH5LAUNCHCLIENTSHORTCUTKEYCTRLCV,
KUPLOADSCENEAIODRAG,
KUPLOADSCENEAIOFILESELECTOR,
KUPLOADSCENEAIOSHORTCUTKEYCTRLCV,
}
/** 闪传文件集默认有效期(秒):14 天 */
export const DEFAULT_FLASH_VALIDITY_SECONDS = 1209600;
export interface StartFlashTaskRequests {
scene: number; // 1 PC-QQ
name?: string;
uploaders: UploaderInfo[];
permission?: { code: number; };
coverPath?: string;
paths: string[]; // 文件的绝对路径,可以是文件夹
excludePaths?: string[];
expireLeftTime?: number, // 0
validitySeconds: number, // 文件集有效期(秒):14 天 1209600 / 90 天 7776000 / 180 天 15552000,必须传,否则新版内核段错误
isNeedDelDeviceInfo: boolean,
isNeedDelLocation: boolean,
coverOriginalInfos?: {
path: string,
thumbnailPath: string,
}[],
uploadSceneType: UploadSceneType, // 不知道怎么枚举 先硬编码吧 (PC QQ 10)
detectPrivacyInfoResult: {
exists: boolean,
allDetectResults: {};
};
announcement: {
announcementSegments: unknown[],
modifyTime: string,
avatarUrl: string;
};
albumFilePathList: unknown[];
}
export enum BusiScene {
KBUSISCENEINVALID,
KBUSISCENEFLASHSCENE,
}
export interface FileListInfoRequests {
seq: number, // 0
fileSetId: string,
isUseCache: boolean,
sceneType: BusiScene, // 1
reqInfos: {
count: number, // 18 ?? 硬编码吧 不懂
paginationInfo: {},
parentId: string,
reqIndexPath: string,
reqDepth: number, // 1
filterCondition: {
fileCategory: number,
filterType: number,
}, // 0
sortConditions: {
sortField: number,
sortOrder: number,
}[],
isNeedPhysicalInfoReady: boolean;
}[];
}
export enum DownloadSceneType {
KDOWNLOADSCENEUNKNOWN,
KDOWNLOADSCENEARKC2C,
KDOWNLOADSCENEARKC2CDETAILPAGE,
KDOWNLOADSCENEARKGROUP,
KDOWNLOADSCENEARKGROUPDETAILPAGE,
KDOWNLOADSCENELINKC2C,
KDOWNLOADSCENELINKGROUP,
KDOWNLOADSCENELINKCHANNEL,
KDOWNLOADSCENELINKTEMPCHAT,
KDOWNLOADSCENELINKOTHERINQQ,
KDOWNLOADSCENESCANQRCODE,
KDWONLOADSCENEFLASHTRANSFERCENTERCLIENT,
KDWONLOADSCENEFLASHTRANSFERCENTERSCHEMA,
}
export interface FlashFileSetInfo {
fileSetId: string,
name: string,
namePinyin: string,
totalFileCount: number,
totalFileSize: number,
permission: {},
shareInfo: {
shareLink: string,
extractionCode: string,
},
cover: {
id: string,
urls: [
{
spec: number, // 2
url: string;
}
],
localCachePath: string;
},
uploaders: [
{
uin: string,
nickname: string,
uid: string,
sendEntrance: string;
}
],
expireLeftTime: number,
aiClusteringStatus: {
firstClusteringList: [],
shouldPull: boolean;
},
createTime: number,
expireTime: number,
firstLevelItemCount: 1,
svrLastUpdateTimestamp: 0,
taskId: string, // 同 fileSetId
uploadInfo: {
totalUploadedFileSize: number,
successCount: number,
failedCount: number;
},
downloadInfo: {
totalDownloadedFileSize: 0,
totalFileSize: 0,
totalDownloadFileCount: 0,
successCount: 0,
failedCount: 0,
pausedCount: 0,
cancelCount: 0,
status: 0,
curLevelDownloadedFileCount: number,
curLevelUnDownloadedFileCount: 0;
},
transferType: number,
isLocalCreate: true,
status: number, // todo 枚举全部状态
uploadStatus: number, // todo 同上
uploadPauseReason: 0,
downloadStatus: 0,
downloadPauseReason: 0,
saveFileSetDir: string,
uploadSceneType: UploadSceneType,
downloadSceneType: DownloadSceneType, // 0 PC-QQ 103 web
retryCount: number,
isMergeShareUpload: 0,
isRemoveDeviceInfo: boolean,
isRemoveLocation: boolean;
}
export interface SendStatus {
result: number,
msg: string,
target: {
destType: number,
destUid: string,
};
}
+386
View File
@@ -0,0 +1,386 @@
import { GroupDetailInfoV2Param, GroupExtInfo, GroupExtFilter, GroupManagementSettings } from '../types';
const MEMBER_INVITE_PRIVILEGE_MASK = 0x06100000;
const MEMBER_UPLOAD_ALBUM_PRIVILEGE_MASK = 0x1;
const MEMBER_CREATE_GROUP_FLAG = 0x8000;
const MEMBER_TEMPORARY_SESSION_FLAG = 0x10000;
const NEW_MEMBER_RECENT_HISTORY_FLAG = 0x4;
function mergeMaskedFlag (currentFlag: number, requestedFlag: number, maskValue: number): number {
const mask = BigInt(maskValue);
return Number((BigInt(currentFlag) & ~mask) | (BigInt(requestedFlag) & mask));
}
export function applyGroupManagementSettings (
param: GroupDetailInfoV2Param,
settings: GroupManagementSettings
): boolean {
let changed = false;
if (settings.memberInvite !== undefined) {
const privilegeByPolicy = {
disabled: 0x04000000,
require_approval: 0,
no_approval: 0x00100000,
no_approval_under_100: 0x02000000,
} satisfies Record<NonNullable<GroupManagementSettings['memberInvite']>, number>;
param.filter.allowMemberInvite = 1;
param.modifyInfo.allowMemberInvite = settings.memberInvite === 'disabled' ? 0 : 1;
param.filter.appPrivilegeFlag = 1;
param.filter.appPrivilegeMask = 1;
param.modifyInfo.appPrivilegeMask |= MEMBER_INVITE_PRIVILEGE_MASK;
param.modifyInfo.appPrivilegeFlag |= privilegeByPolicy[settings.memberInvite];
changed = true;
}
if (settings.allowMemberUploadAlbum !== undefined) {
param.filter.appPrivilegeFlag = 1;
param.filter.appPrivilegeMask = 1;
param.modifyInfo.appPrivilegeMask |= MEMBER_UPLOAD_ALBUM_PRIVILEGE_MASK;
if (!settings.allowMemberUploadAlbum) param.modifyInfo.appPrivilegeFlag |= MEMBER_UPLOAD_ALBUM_PRIVILEGE_MASK;
changed = true;
}
if (settings.allowMemberTemporarySession !== undefined) {
param.filter.appPrivilegeFlag = 1;
param.filter.appPrivilegeMask = 1;
param.modifyInfo.appPrivilegeMask |= MEMBER_TEMPORARY_SESSION_FLAG;
if (!settings.allowMemberTemporarySession) param.modifyInfo.appPrivilegeFlag |= MEMBER_TEMPORARY_SESSION_FLAG;
changed = true;
}
if (settings.allowMemberCreateGroup !== undefined) {
param.filter.appPrivilegeFlag = 1;
param.filter.appPrivilegeMask = 1;
param.modifyInfo.appPrivilegeMask |= MEMBER_CREATE_GROUP_FLAG;
if (!settings.allowMemberCreateGroup) param.modifyInfo.appPrivilegeFlag |= MEMBER_CREATE_GROUP_FLAG;
changed = true;
}
if (settings.newMembersSeeRecentHistory !== undefined) {
param.filter.groupFlagExt4 = 1;
param.filter.groupFlagExt4Mask = 1;
param.modifyInfo.groupFlagExt4Mask |= NEW_MEMBER_RECENT_HISTORY_FLAG;
if (settings.newMembersSeeRecentHistory) param.modifyInfo.groupFlagExt4 |= NEW_MEMBER_RECENT_HISTORY_FLAG;
changed = true;
}
return changed;
}
export function createGroupManagementRequests (
groupCode: string,
settings: GroupManagementSettings,
initialAppPrivilegeFlag = 0,
initialGroupFlagExt4 = 0
) {
const requests: Array<{ setting: string, param: GroupDetailInfoV2Param, operationType: number; }> = [];
const requestSettings: Array<{
setting: string,
value: GroupManagementSettings,
operationType: number;
}> = [
{ setting: 'member_invite', value: { memberInvite: settings.memberInvite }, operationType: 0 },
{
setting: 'new_members_see_recent_history',
value: { newMembersSeeRecentHistory: settings.newMembersSeeRecentHistory },
operationType: 0,
},
{
setting: 'allow_member_upload_album',
value: { allowMemberUploadAlbum: settings.allowMemberUploadAlbum },
operationType: 8,
},
{
setting: 'allow_member_temporary_session',
value: { allowMemberTemporarySession: settings.allowMemberTemporarySession },
operationType: 8,
},
{
setting: 'allow_member_create_group',
value: { allowMemberCreateGroup: settings.allowMemberCreateGroup },
operationType: 8,
},
];
let currentAppPrivilegeFlag = initialAppPrivilegeFlag;
let currentGroupFlagExt4 = initialGroupFlagExt4;
for (const requestSetting of requestSettings) {
const param = createGroupDetailInfoV2Param(groupCode);
if (applyGroupManagementSettings(param, requestSetting.value)) {
if (param.filter.appPrivilegeFlag) {
const nextAppPrivilegeFlag = mergeMaskedFlag(
currentAppPrivilegeFlag,
param.modifyInfo.appPrivilegeFlag,
param.modifyInfo.appPrivilegeMask
);
if (requestSetting.operationType === 8 && nextAppPrivilegeFlag === currentAppPrivilegeFlag) continue;
currentAppPrivilegeFlag = nextAppPrivilegeFlag;
// Linux NTQQ's wrapper builds this field from the current complete privilege value.
// Supplying only the targeted bit makes zero-valued clears get acknowledged but ignored.
param.modifyInfo.appPrivilegeFlag = currentAppPrivilegeFlag;
}
if (param.filter.groupFlagExt4) {
const nextGroupFlagExt4 = mergeMaskedFlag(
currentGroupFlagExt4,
param.modifyInfo.groupFlagExt4,
param.modifyInfo.groupFlagExt4Mask
);
if (nextGroupFlagExt4 === currentGroupFlagExt4) continue;
currentGroupFlagExt4 = nextGroupFlagExt4;
param.modifyInfo.groupFlagExt4 = currentGroupFlagExt4;
}
requests.push({ setting: requestSetting.setting, param, operationType: requestSetting.operationType });
}
}
return requests;
}
export function createGroupDetailInfoV2Param (group_code: string): GroupDetailInfoV2Param {
return {
groupCode: group_code,
filter:
{
noCodeFingerOpenFlag: 0,
noFingerOpenFlag: 0,
groupName: 0,
classExt: 0,
classText: 0,
fingerMemo: 0,
richFingerMemo: 0,
tagRecord: 0,
groupGeoInfo:
{
ownerUid: 0,
setTime: 0,
cityId: 0,
longitude: 0,
latitude: 0,
geoContent: 0,
poiId: 0,
},
groupExtAdminNum: 0,
flag: 0,
groupMemo: 0,
groupAioSkinUrl: 0,
groupBoardSkinUrl: 0,
groupCoverSkinUrl: 0,
groupGrade: 0,
activeMemberNum: 0,
certificationType: 0,
certificationText: 0,
groupNewGuideLines:
{
enabled: 0,
content: 0,
},
groupFace: 0,
addOption: 0,
shutUpTime: 0,
groupTypeFlag: 0,
appPrivilegeFlag: 0,
appPrivilegeMask: 0,
groupExtOnly:
{
tribeId: 0,
moneyForAddGroup: 0,
},
groupSecLevel: 0,
groupSecLevelInfo: 0,
subscriptionUin: 0,
subscriptionUid: '',
allowMemberInvite: 0,
groupQuestion: 0,
groupAnswer: 0,
groupFlagExt3: 0,
groupFlagExt3Mask: 0,
groupOpenAppid: 0,
rootId: 0,
msgLimitFrequency: 0,
hlGuildAppid: 0,
hlGuildSubType: 0,
hlGuildOrgId: 0,
groupFlagExt4: 0,
groupFlagExt4Mask: 0,
groupSchoolInfo: {
location: 0,
grade: 0,
school: 0,
},
groupCardPrefix:
{
introduction: 0,
rptPrefix: 0,
},
allianceId: 0,
groupFlagPro1: 0,
groupFlagPro1Mask: 0,
},
modifyInfo: {
noCodeFingerOpenFlag: 0,
noFingerOpenFlag: 0,
groupName: '',
classExt: 0,
classText: '',
fingerMemo: '',
richFingerMemo: '',
tagRecord: [],
groupGeoInfo: {
ownerUid: '',
SetTime: 0,
CityId: 0,
Longitude: '',
Latitude: '',
GeoContent: '',
poiId: '',
},
groupExtAdminNum: 0,
flag: 0,
groupMemo: '',
groupAioSkinUrl: '',
groupBoardSkinUrl: '',
groupCoverSkinUrl: '',
groupGrade: 0,
activeMemberNum: 0,
certificationType: 0,
certificationText: '',
groupNewGuideLines: {
enabled: false,
content: '',
},
groupFace: 0,
addOption: 0,
shutUpTime: 0,
groupTypeFlag: 0,
appPrivilegeFlag: 0,
appPrivilegeMask: 0,
groupExtOnly: {
tribeId: 0,
moneyForAddGroup: 0,
},
groupSecLevel: 0,
groupSecLevelInfo: 0,
subscriptionUin: '',
subscriptionUid: '',
allowMemberInvite: 0,
groupQuestion: '',
groupAnswer: '',
groupFlagExt3: 0,
groupFlagExt3Mask: 0,
groupOpenAppid: 0,
rootId: '',
msgLimitFrequency: 0,
hlGuildAppid: 0,
hlGuildSubType: 0,
hlGuildOrgId: 0,
groupFlagExt4: 0,
groupFlagExt4Mask: 0,
groupSchoolInfo: {
location: '',
grade: 0,
school: '',
},
groupCardPrefix:
{
introduction: '',
rptPrefix: [],
},
allianceId: '',
groupFlagPro1: 0,
groupFlagPro1Mask: 0,
},
};
}
export function createGroupExtInfo (group_code: string): GroupExtInfo {
return {
groupCode: group_code,
resultCode: 0,
extInfo: {
groupInfoExtSeq: 0,
reserve: 0,
luckyWordId: '',
lightCharNum: 0,
luckyWord: '',
starId: 0,
essentialMsgSwitch: 0,
todoSeq: 0,
blacklistExpireTime: 0,
isLimitGroupRtc: 0,
companyId: 0,
hasGroupCustomPortrait: 0,
bindGuildId: '',
groupOwnerId: {
memberUin: '',
memberUid: '',
memberQid: '',
},
essentialMsgPrivilege: 0,
msgEventSeq: '',
inviteRobotSwitch: 0,
gangUpId: '',
qqMusicMedalSwitch: 0,
showPlayTogetherSwitch: 0,
groupFlagPro1: '',
groupBindGuildIds: {
guildIds: [],
},
viewedMsgDisappearTime: '',
groupExtFlameData: {
switchState: 0,
state: 0,
dayNums: [],
version: 0,
updateTime: '',
isDisplayDayNum: false,
},
groupBindGuildSwitch: 0,
groupAioBindGuildId: '',
groupExcludeGuildIds: {
guildIds: [],
},
fullGroupExpansionSwitch: 0,
fullGroupExpansionSeq: '',
inviteRobotMemberSwitch: 0,
inviteRobotMemberExamine: 0,
groupSquareSwitch: 0,
},
};
}
export function createGroupExtFilter (): GroupExtFilter {
return {
groupInfoExtSeq: 0,
reserve: 0,
luckyWordId: 0,
lightCharNum: 0,
luckyWord: 0,
starId: 0,
essentialMsgSwitch: 0,
todoSeq: 0,
blacklistExpireTime: 0,
isLimitGroupRtc: 0,
companyId: 0,
hasGroupCustomPortrait: 0,
bindGuildId: 0,
groupOwnerId: 0,
essentialMsgPrivilege: 0,
msgEventSeq: 0,
inviteRobotSwitch: 0,
gangUpId: 0,
qqMusicMedalSwitch: 0,
showPlayTogetherSwitch: 0,
groupFlagPro1: 0,
groupBindGuildIds: 0,
viewedMsgDisappearTime: 0,
groupExtFlameData: 0,
groupBindGuildSwitch: 0,
groupAioBindGuildId: 0,
groupExcludeGuildIds: 0,
fullGroupExpansionSwitch: 0,
fullGroupExpansionSeq: 0,
inviteRobotMemberSwitch: 0,
inviteRobotMemberExamine: 0,
groupSquareSwitch: 0,
};
}
+1
View File
@@ -0,0 +1 @@
export * from './group';
+146
View File
@@ -0,0 +1,146 @@
import {
normalizeQzoneMediaImages,
normalizeQzoneRichContent,
type QzoneComment,
type QzoneFeedReference,
} from './qzone-rpc';
export interface QzoneReplyItem {
reply_id: string | null;
user_id: string | null;
uid: string | null;
nickname: string | null;
time: number | null;
content: string | null;
images: string[] | null;
like_count: number | null;
liked: boolean | null;
target_user_id: string | null;
target_uid: string | null;
target_nickname: string | null;
reply: QzoneComment;
}
export interface QzoneCommentItem {
comment_id: string | null;
user_id: string | null;
uid: string | null;
nickname: string | null;
time: number | null;
content: string | null;
images: string[] | null;
like_count: number | null;
liked: boolean | null;
reply_num: number | null;
has_more_replies: boolean | null;
replies: QzoneReplyItem[];
comment: QzoneComment;
}
export interface QzoneCommentPage {
feed_ref: QzoneFeedReference;
comment_num: number | null;
comments: QzoneCommentItem[];
has_more: boolean;
next_cursor: string | null;
raw: Record<string, unknown>;
}
export interface QzoneReplyPage {
feed_ref: QzoneFeedReference;
comment_id: string;
replies: QzoneReplyItem[];
reply_num: number | null;
has_more: boolean;
next_cursor: string | null;
raw: Record<string, unknown>;
}
function object (value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null ? value as Record<string, unknown> : undefined;
}
function identifier (value: unknown): string | null {
if (typeof value === 'string') return value.trim().length > 0 && value.length <= 8192 ? value : null;
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? String(value) : null;
}
function userId (value: unknown): string | null {
if (typeof value === 'string' && /^\d+$/.test(value) && /[1-9]/.test(value)) return value;
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? String(value) : null;
}
function count (value: unknown): number | null {
if (typeof value !== 'number' && !(typeof value === 'string' && /^\d+$/.test(value))) return null;
const result = Number(value);
return Number.isSafeInteger(result) && result >= 0 ? result : null;
}
function nickname (value: unknown): string | null {
return typeof value === 'string' ? value : null;
}
function likeState (value: unknown): boolean | null {
return value === 1 ? true : value === 0 ? false : null;
}
function author (value: unknown): { user_id: string | null; uid: string | null; nickname: string | null; } {
const user = object(value);
const uid = user?.['uid'];
return {
user_id: userId(user?.['uin']),
uid: typeof uid === 'string' ? identifier(uid) : null,
nickname: nickname(user?.['nick']),
};
}
export function normalizeQzoneReply (value: QzoneComment): QzoneReplyItem {
const reply = object(value);
if (!reply) throw new Error('QQ空间回复数据格式异常');
const user = author(reply['user']);
const target = author(reply['target_user']);
const like = object(reply['like']);
return {
reply_id: identifier(reply['id']),
...user,
// The renderer uses time in Unix seconds. create_time is not this wire field.
time: count(reply['time']),
content: normalizeQzoneRichContent(reply['content']),
images: normalizeQzoneMediaImages(reply['media_items']),
like_count: count(like?.['count']),
liked: likeState(like?.['status']),
target_user_id: target.user_id,
target_uid: target.uid,
target_nickname: target.nickname,
reply: value,
};
}
export function normalizeQzoneComment (value: QzoneComment): QzoneCommentItem {
const comment = object(value);
if (!comment) throw new Error('QQ空间评论数据格式异常');
const user = author(comment['user']);
const like = object(comment['like']);
const inline = comment['replys'];
if (inline !== undefined && inline !== null && !Array.isArray(inline)) throw new Error('QQ空间内联回复列表格式异常');
const replies = (inline ?? []) as unknown[];
return {
comment_id: identifier(comment['id']),
...user,
time: count(comment['time']),
content: normalizeQzoneRichContent(comment['content']),
images: normalizeQzoneMediaImages(comment['media_items']),
like_count: count(like?.['count']),
liked: likeState(like?.['status']),
reply_num: count(comment['reply_num']),
has_more_replies: typeof comment['has_more_reply'] === 'boolean' ? comment['has_more_reply'] : null,
// These are only the inline replies returned on this page, not an exhaustiveness claim.
replies: replies.map((reply) => {
if (!object(reply)) throw new Error('QQ空间内联回复数据格式异常');
return normalizeQzoneReply(reply as QzoneComment);
}),
comment: value,
};
}
+282
View File
@@ -0,0 +1,282 @@
export type QzonePublicObject = Record<string, unknown>;
export type QzonePublicContent = string | QzonePublicObject[];
export type QzonePublicOperation =
| 'capabilities' | 'feeds' | 'detail' | 'comments' | 'replies'
| 'upvote' | 'downvote' | 'like' | 'unlike' | 'follow' | 'unfollow'
| 'commentLike' | 'commentUnlike' | 'addComment' | 'addReply'
| 'deleteComment' | 'deleteReply' | 'forward';
export interface QzonePublicFeedsPage extends QzonePublicObject {
feeds: QzonePublicObject[];
has_more: boolean;
next_cursor: string | null;
}
export interface QzonePublicRequestOptions extends QzonePublicObject {
feed?: QzonePublicObject;
comment?: QzonePublicObject;
reply?: QzonePublicObject;
content?: QzonePublicContent;
cursor?: string;
pageSize?: number;
reason?: string;
selfUin?: string;
user?: QzonePublicObject;
refreshId?: string;
lastUnionId?: QzonePublicObject;
}
export interface QzonePublicRoute {
service: string;
method: string;
oidb: string;
}
const READER = 'trpc.qzone.feeds_reader.FeedsReader';
const WRITER = 'trpc.qzone.feeds_writer.FeedsWriter';
const INTERACTION = 'trpc.qzone.interaction_writer.InteractionWriter';
const RELATION = 'trpc.public_qzone.relation.RelationService';
const UPVOTE = 'trpc.public_qzone.interaction.UFOComment';
const MAX_OBJECT_BYTES = 8 * 1024 * 1024;
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
const MAX_IDENTITY_LENGTH = 8192;
function route (service: string, method: string, oidb: string): QzonePublicRoute {
return Object.freeze({ service, method, oidb });
}
export const QZONE_PUBLIC_ROUTES: Record<QzonePublicOperation, QzonePublicRoute> = Object.freeze({
capabilities: route('trpc.public_qzone.entrance_ctrl.EntranceCtrl', 'BatchEntranceWhitelist', '0x9741_0'),
feeds: route(READER, 'GetPublicRecomFeeds', '0x9a38_0'),
detail: route(READER, 'GetFeedDetail', '0x97b1_1'),
comments: route(READER, 'GetComment', '0x9bfd_1'),
replies: route(READER, 'GetReplies', '0x9bfe_1'),
upvote: route(UPVOTE, 'Upvote', '0x9d18_1'),
downvote: route(UPVOTE, 'Downvote', '0x9d19_1'),
like: route(INTERACTION, 'DoLike', '0x9699_1'),
unlike: route(INTERACTION, 'UnLike', '0x969c_1'),
follow: route(RELATION, 'Follow', '0x9d1a_1'),
unfollow: route(RELATION, 'Unfollow', '0x9d1b_1'),
commentLike: route(INTERACTION, 'DoLike', '0x9699_1'),
commentUnlike: route(INTERACTION, 'UnLike', '0x969c_1'),
addComment: route(INTERACTION, 'AddComment', '0x9676_1'),
addReply: route(INTERACTION, 'AddReply', '0x9677_1'),
deleteComment: route(INTERACTION, 'DeleteComment', '0x9989_0'),
deleteReply: route(INTERACTION, 'DeleteReply', '0x998a_0'),
forward: route(WRITER, 'ForwardFeed', '0x969a_1'),
});
function object (value: unknown): value is QzonePublicObject {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function assertOperation (operation: QzonePublicOperation): void {
if (!Object.prototype.hasOwnProperty.call(QZONE_PUBLIC_ROUTES, operation)) throw new Error('QQ空间公开接口操作无效');
}
/** Clone JSON without calling toJSON or assigning prototype setters. */
function snapshot<T> (value: T, name: string, maxBytes = MAX_OBJECT_BYTES): T {
const seen = new Set<object>();
let nodes = 0;
const maxNodes = 200000;
const maxDepth = 64;
function copy (input: unknown, depth: number): unknown {
if (++nodes > maxNodes || depth > maxDepth) throw new Error(`${name}结构超出限制`);
if (input === null || typeof input === 'boolean') return input;
if (typeof input === 'string') {
if (Buffer.byteLength(input, 'utf8') > maxBytes) throw new Error(`${name}超过大小限制`);
return input;
}
if (typeof input === 'number' && Number.isFinite(input)) return input;
if (!Array.isArray(input) && !object(input)) throw new Error(`${name}必须为JSON数据`);
if (seen.has(input)) throw new Error(`${name}不能包含循环引用`);
seen.add(input);
let output: unknown;
if (Array.isArray(input)) {
const items: unknown[] = [];
for (let index = 0; index < input.length; index++) items.push(copy(input[index], depth + 1));
output = items;
} else {
const result: QzonePublicObject = {};
for (const key of Object.keys(input)) {
const item = input[key];
if (item === undefined) continue;
Object.defineProperty(result, key, { value: copy(item, depth + 1), enumerable: true, configurable: true, writable: true });
}
output = result;
}
seen.delete(input);
return output;
}
const result = copy(value, 0) as T;
if (Buffer.byteLength(JSON.stringify(result), 'utf8') > maxBytes) throw new Error(`${name}超过大小限制`);
return result;
}
function jsonObject (value: unknown, name: string): QzonePublicObject {
if (!object(value)) throw new Error(`${name}必须为JSON对象`);
return snapshot(value, name);
}
function identity (value: unknown, name: string, allowEmpty = false): string {
if (typeof value !== 'string' || value.length > MAX_IDENTITY_LENGTH || (!allowEmpty && !value.trim())) {
throw new Error(`${name}无效`);
}
for (const character of value) {
const code = character.charCodeAt(0);
if (code < 32 || code === 127) throw new Error(`${name}无效`);
}
return value;
}
function uin (value: unknown, name: string): string {
if (typeof value !== 'string' || !/^[1-9]\d{0,19}$/.test(value)) throw new Error(`${name}无效`);
return value;
}
function pageToken (value: unknown, name: string, allowEmpty = false, maxLength = MAX_OBJECT_BYTES): string {
if (typeof value !== 'string' || value.length > maxLength || (!allowEmpty && value.length === 0)) throw new Error(`${name}无效`);
return value;
}
function unionId (value: unknown): QzonePublicObject {
const union = jsonObject(value, 'QQ空间完整动态标识');
let identified = false;
for (const key of ['uf_key', 'ugc_id', 'feed_id', 'serialized_data']) {
if (union[key] === undefined || union[key] === null) continue;
const field = identity(union[key], 'QQ空间动态身份字段', true);
if (field.trim()) identified = true;
}
if (!identified) throw new Error('QQ空间动态缺少完整有效身份');
if (union['ext_ids'] !== undefined && union['ext_ids'] !== null && (!Array.isArray(union['ext_ids']) || union['ext_ids'].some(item => !object(item)))) {
throw new Error('QQ空间动态扩展身份无效');
}
return union;
}
function readComment (value: unknown, name: string): QzonePublicObject {
const comment = jsonObject(value, name);
const id = comment['id'];
if (typeof id === 'string') identity(id, `${name}标识`);
else if (typeof id !== 'number' || !Number.isSafeInteger(id) || id < 0) throw new Error(`${name}标识无效`);
if (comment['user'] !== undefined && !object(comment['user'])) throw new Error(`${name}用户必须为JSON对象`);
return comment;
}
function user (value: unknown, name: string): QzonePublicObject {
const result = jsonObject(value, name);
const userUin = result['uin'];
const userUid = result['uid'];
const absentUin = userUin === undefined || userUin === null || userUin === '' || userUin === '0' || userUin === 0;
if (!absentUin) uin(userUin, `${name}QQ号`);
if (userUid !== undefined && userUid !== null && userUid !== '') identity(userUid, `${name}UID`);
if (absentUin && !userUid) throw new Error(`${name}缺少QQ号或UID`);
return result;
}
function content (value: unknown): QzonePublicObject[] {
if (typeof value === 'string') {
if (!value.length) throw new Error('QQ空间评论正文不能为空');
return snapshot([{ type: 0, content: value }], 'QQ空间评论正文');
}
if (!Array.isArray(value) || !value.length) throw new Error('QQ空间评论正文必须为非空文本或RichMsg数组');
const items = snapshot(value, 'QQ空间评论正文');
for (const item of items) {
if (!object(item)) throw new Error('QQ空间RichMsg条目必须为JSON对象');
}
return items as QzonePublicObject[];
}
export function buildQzonePublicRequest (operation: QzonePublicOperation, options: QzonePublicRequestOptions): QzonePublicObject {
assertOperation(operation);
if (!object(options)) throw new Error('QQ空间公开接口参数必须为对象');
if (operation === 'capabilities') return { uins: [uin(options.selfUin, '当前QQ号')], entrance_types: [1, 15] };
if (operation === 'feeds') {
const body: QzonePublicObject = { refresh_id: identity(options.refreshId, 'QQ空间刷新标识') };
if (options.cursor !== undefined) body['attach_info'] = pageToken(options.cursor, 'QQ空间分页标识', true, 65536);
if (options.lastUnionId !== undefined) body['last_client_feed_union_id'] = unionId(options.lastUnionId);
return body;
}
const feed = jsonObject(options.feed, 'QQ空间原始Feed');
const common = feed['cell_common'];
if (!object(common)) throw new Error('QQ空间原始Feed缺少cell_common');
const union = unionId(common['union_id']);
const body: QzonePublicObject = { union_id: union };
if (feed['cell_feed_transfer'] !== undefined) body['cell_feed_transfer'] = snapshot(feed['cell_feed_transfer'], 'QQ空间动态上下文');
if (operation === 'detail') return { ...body, feed_action_domain_type: 3, from_page: 1 };
if (operation === 'comments') return { ...body, attach_info: pageToken(options.cursor, 'QQ空间评论分页标识') };
if (operation === 'replies') {
const parent = readComment(options.comment, 'QQ空间父评论');
const size = options.pageSize === undefined ? 20 : options.pageSize;
if (typeof size !== 'number' || !Number.isFinite(size) || size < 1) throw new Error('QQ空间回复page_size必须为不小于1的有限数值');
const pointer = options.cursor || parent['reply_attach_info'] || '';
return { ...body, cur_comment: parent, comment_attach_info: '', reply_attach_info: pageToken(pointer, 'QQ空间回复分页标识', true), page_size: Math.trunc(size) };
}
if (operation === 'upvote' || operation === 'downvote') return body;
if (operation === 'like' || operation === 'unlike') {
return { ...body, like_key: identity(common['cur_like_key'], 'QQ空间动态点赞标识'), like_type: 1 };
}
if (operation === 'follow' || operation === 'unfollow') {
const userInfo = feed['cell_user_info'];
const author = user(object(userInfo) ? userInfo['user'] : undefined, 'QQ空间动态作者');
const authorUin = typeof author['uin'] === 'string' && /^[1-9]\d{0,19}$/.test(author['uin']) ? author['uin'] : '';
const authorUid = typeof author['uid'] === 'string' ? author['uid'].trim() : '';
const useUid = (common['domain_type'] === 3 && !!authorUid) || !authorUin;
const request: QzonePublicObject = { follow_from_type: 1, ...(useUid ? { target_uid: authorUid } : { target_uin: authorUin }) };
if ((useUid && !authorUid) || (!useUid && !authorUin)) throw new Error('QQ空间动态缺少可蹲的作者身份');
if (typeof union['uf_key'] === 'string' && union['uf_key']) request['follow_feed_id'] = union['uf_key'];
if (feed['cell_feed_transfer']) request['cell_feed_transfer'] = body['cell_feed_transfer'];
return request;
}
if (operation === 'commentLike' || operation === 'commentUnlike') {
const parent = readComment(options.comment, 'QQ空间父评论');
const target = options.reply === undefined ? parent : readComment(options.reply, 'QQ空间回复');
const like = target['like'];
return { ...body, like_key: identity(object(like) ? like['id'] : undefined, 'QQ空间评论点赞标识'), like_type: options.reply === undefined ? 2 : 3 };
}
if (operation === 'addComment') return { ...body, comment: { user: user(options.user, '当前用户'), content: content(options.content) } };
if (operation === 'addReply') {
const parent = readComment(options.comment, 'QQ空间父评论');
const target = options.reply === undefined ? parent : readComment(options.reply, 'QQ空间回复');
return { ...body, comment: parent, reply: { content: content(options.content), target_user: user(target['user'], 'QQ空间回复目标'), user: user(options.user, '当前用户') } };
}
if (operation === 'deleteComment') return { ...body, comment: readComment(options.comment, 'QQ空间父评论') };
if (operation === 'deleteReply') return { ...body, comment: readComment(options.comment, 'QQ空间父评论'), reply: readComment(options.reply, 'QQ空间回复') };
if (operation === 'forward') {
const reason = options.reason ?? '';
if (typeof reason !== 'string') throw new Error('QQ空间转发理由必须为文本');
return { ...body, reason: snapshot(reason, 'QQ空间转发理由') };
}
throw new Error('QQ空间公开接口操作无效');
}
/** Return the business data, preserving unknown fields while checking the observed acknowledgements. */
export function parseQzonePublicResponse (value: unknown, operation: QzonePublicOperation): QzonePublicObject {
assertOperation(operation);
if (object(value) && typeof value['retcode'] === 'number' && Number.isSafeInteger(value['retcode']) && value['retcode'] !== 0) {
throw new Error(`QQ空间公开接口拒绝请求 (retcode=${value['retcode']})`);
}
if (!object(value) || value['retcode'] !== 0 || !object(value['data'])) throw new Error('QQ空间公开接口未返回明确成功响应');
const data = snapshot(value['data'], 'QQ空间公开接口响应', MAX_RESPONSE_BYTES);
if (operation === 'follow' || operation === 'unfollow') {
const code = data['ret_code'];
if (code !== undefined && code !== null && code !== 0 && code !== '0') throw new Error('QQ空间蹲操作未确认成功');
}
const resultFields: Partial<Record<QzonePublicOperation, string>> = {
upvote: 'upvote',
downvote: 'upvote',
like: 'like',
unlike: 'like',
commentLike: 'like',
commentUnlike: 'like',
addComment: 'comment',
addReply: 'reply',
};
const field = resultFields[operation];
if (field && !data[field]) throw new Error('QQ空间公开互动未返回业务成功结果');
return data;
}
+365
View File
@@ -0,0 +1,365 @@
import { Buffer } from 'node:buffer';
export const QZONE_REFERENCE_MAX_BYTES = 64 * 1024;
const MAX_IDENTITY_LENGTH = 8192;
const MAX_TEXT_LENGTH = 2000;
export interface QzoneUnionExtension {
ext_id_type?: number;
numeric?: number | string;
str?: string;
unsigned_numeric?: number | string;
[key: string]: unknown;
}
export interface QzoneUnionId {
uf_key?: string;
ugc_id?: string;
feed_id?: string;
ext_ids?: QzoneUnionExtension[];
serialized_data?: string;
[key: string]: unknown;
}
export interface QzoneUser {
uin: string;
uid?: string;
nick?: string;
[key: string]: unknown;
}
export interface QzoneRichMsg {
type: number;
content?: string;
media_items?: unknown[];
name?: string;
uid?: string;
uin?: string;
url?: string;
who?: number;
[key: string]: unknown;
}
export interface QzoneRawCommentUser {
uin?: string;
uid?: string | null;
nick?: string;
[key: string]: unknown;
}
export interface QzoneComment {
id?: string | number;
user?: QzoneRawCommentUser;
content?: QzoneRichMsg[];
replys?: QzoneComment[];
[key: string]: unknown;
}
export interface QzoneFeed {
cell_common?: {
union_id?: QzoneUnionId;
time?: number | string;
app_id?: number;
feed_type?: number;
feed_sub_type?: number;
feed_biz_type?: number;
client_key?: string;
cur_like_key?: string;
domain_type?: number;
operate_mask?: number;
[key: string]: unknown;
};
cell_user_info?: { user?: QzoneUser; [key: string]: unknown; };
cell_summary?: { summary?: QzoneRichMsg[]; [key: string]: unknown; };
cell_media?: { media_items?: unknown[]; [key: string]: unknown; };
cell_comment?: { num?: number | string; comments?: QzoneComment[]; [key: string]: unknown; };
cell_like?: { num?: number | string; liked?: boolean; [key: string]: unknown; };
cell_feed_transfer?: unknown;
[key: string]: unknown;
}
export interface QzoneFeedReference {
union_id: QzoneUnionId;
cell_feed_transfer?: unknown;
feed_action_domain_type?: number;
like_key?: string;
[key: string]: unknown;
}
export interface QzoneFeedItem {
tid: string | null;
user_id: string | null;
content: string | null;
time: number | null;
comment_num: number | null;
like_num: number | null;
images: string[] | null;
feed_ref: QzoneFeedReference | null;
feed: QzoneFeed;
}
export interface QzoneFeedPage {
feeds: QzoneFeedItem[];
has_more: boolean;
next_cursor: string | null;
}
export interface QzoneMsgPage {
msglist: QzoneFeedItem[];
has_more: boolean;
next_cursor: string | null;
seal_off_memory?: Record<string, unknown>;
}
export interface QzoneDetail extends QzoneFeedItem {
comments: QzoneComment[];
comment_has_more?: boolean;
comment_next_cursor?: string | null;
}
function record (value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null ? value as Record<string, unknown> : undefined;
}
function identity (value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 && value.length <= MAX_IDENTITY_LENGTH ? value : null;
}
function nonnegativeInteger (value: unknown): number | null {
if (typeof value !== 'number' && !(typeof value === 'string' && /^\d+$/.test(value))) return null;
const number = typeof value === 'number' ? value : Number(value);
return Number.isSafeInteger(number) && number >= 0 ? number : null;
}
function numericIdentifier (value: unknown): string | null {
if (typeof value === 'string' && /^\d+$/.test(value) && /[1-9]/.test(value)) return value;
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? String(value) : null;
}
export function getQzoneGtk (key: string): number {
let hash = 5381;
for (let i = 0; i < key.length; i++) hash += (hash << 5) + key.charCodeAt(i);
return hash & 0x7fffffff;
}
export function getQzoneTid (feed: QzoneFeed): string | null {
const common = record(feed?.cell_common);
const union = record(common?.['union_id']);
if (!union) return null;
const extensions = union['ext_ids'];
if (Array.isArray(extensions)) {
const extension = extensions.map(record).find(item => item?.['ext_id_type'] === 5);
if (extension) {
const tid = identity(extension['str']) ?? numericIdentifier(extension['unsigned_numeric']) ?? numericIdentifier(extension['numeric']);
if (tid !== null) return tid;
}
}
// Other feed kinds also have ugc_id; an album ID is not a mood tid.
if (common?.['feed_sub_type'] !== 1 && common?.['app_id'] !== 311) return null;
return identity(union['ugc_id']);
}
export function normalizeQzoneRichContent (value: unknown): string | null {
if (!Array.isArray(value)) return null;
const parts: string[] = [];
for (const item of value) {
const segment = record(item);
if (!segment) return null;
switch (segment['type']) {
case 0:
case 2:
case 4:
if (typeof segment['content'] !== 'string') return null;
parts.push(segment['content']);
break;
case 1:
case 5:
if (typeof segment['name'] !== 'string') return null;
parts.push(segment['type'] === 1 ? `@${segment['name']}` : segment['name']);
break;
case 3:
break;
default:
return null;
}
}
return parts.join('');
}
function imageUrl (value: unknown): string | null {
if (typeof value !== 'string' || !value) return null;
const url = value.startsWith('//') ? `https:${value}` : value;
try {
const parsed = new URL(url);
return (parsed.protocol === 'https:' || parsed.protocol === 'http:') && !parsed.username && !parsed.password ? url : null;
} catch {
return null;
}
}
export function normalizeQzoneMediaImages (value: unknown): string[] | null {
if (!Array.isArray(value)) return null;
const images: string[] = [];
for (const item of value) {
const media = record(item);
if (!media) return null;
if (media['type'] === 1) continue; // Video covers are not post images.
if (media['type'] !== 0) return null;
const image = record(media['image']);
if (!image) return null;
const variants = Array.isArray(image['photo_url']) ? image['photo_url'].map(record) : [];
let selected: string | null = null;
// Same original-image preference as the H5 client: download, origin, big, current.
for (const spec of [6, 0, 1, 5]) {
const variant = variants.find(candidate => candidate?.['spec'] === spec);
selected = imageUrl(record(variant?.['url'])?.['url']);
if (selected !== null) break;
}
selected ??= imageUrl(record(image['default_url'])?.['url']);
if (selected === null) return null;
images.push(selected);
}
return images;
}
function assertJson (value: unknown, depth = 0): void {
if (depth > 32) throw new Error('QQ空间动态引用嵌套过深');
if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
if (typeof value === 'number' && Number.isFinite(value)) return;
if (Array.isArray(value)) {
for (const item of value) assertJson(item, depth + 1);
return;
}
const object = record(value);
if (!object) throw new Error('QQ空间动态引用必须为JSON数据');
for (const item of Object.values(object)) {
if (item !== undefined) assertJson(item, depth + 1);
}
}
export function validateQzoneFeedReference (value: unknown): QzoneFeedReference {
const reference = record(value);
const union = record(reference?.['union_id']);
if (!reference || !union) throw new Error('QQ空间动态引用缺少union_id');
const keys = ['uf_key', 'ugc_id', 'feed_id', 'serialized_data'];
let hasIdentity = false;
for (const key of keys) {
if (union[key] === undefined) continue;
const field = union[key];
if (typeof field !== 'string' || field.length > MAX_IDENTITY_LENGTH) throw new Error('QQ空间动态标识无效');
// Protobuf JSON may include empty defaults alongside an issued identity.
if (field.trim().length > 0) hasIdentity = true;
}
if (!hasIdentity) throw new Error('QQ空间动态引用缺少有效标识');
if (union['ext_ids'] !== undefined && (!Array.isArray(union['ext_ids']) || union['ext_ids'].some(item => !record(item)))) {
throw new Error('QQ空间动态扩展标识无效');
}
const domain = reference['feed_action_domain_type'];
if (domain !== undefined && (typeof domain !== 'number' || !Number.isInteger(domain) || domain < 1 || domain > 3)) {
throw new Error('QQ空间动态所属域无效');
}
if (reference['like_key'] !== undefined && identity(reference['like_key']) === null) throw new Error('QQ空间点赞标识无效');
assertJson(reference);
let serialized: string;
try {
const encoded = JSON.stringify(reference);
if (typeof encoded !== 'string') throw new Error('Invalid JSON');
serialized = encoded;
} catch {
throw new Error('QQ空间动态引用无法序列化');
}
if (Buffer.byteLength(serialized, 'utf8') > QZONE_REFERENCE_MAX_BYTES) throw new Error('QQ空间动态引用不能超过64KiB');
return reference as unknown as QzoneFeedReference;
}
export function normalizeQzoneFeed (feed: QzoneFeed): QzoneFeedItem {
if (!record(feed)) throw new Error('QQ空间动态数据格式无效');
const common = record(feed.cell_common);
const user = record(record(feed.cell_user_info)?.['user']);
let reference: QzoneFeedReference | null = null;
if (record(common?.['union_id'])) {
const candidate: Record<string, unknown> = { union_id: common?.['union_id'] };
if (feed.cell_feed_transfer !== undefined) candidate['cell_feed_transfer'] = feed.cell_feed_transfer;
const domain = common?.['domain_type'];
if (typeof domain === 'number' && Number.isInteger(domain) && domain >= 1 && domain <= 3) candidate['feed_action_domain_type'] = domain;
if (identity(common?.['cur_like_key']) !== null) candidate['like_key'] = common?.['cur_like_key'];
try {
reference = validateQzoneFeedReference(candidate);
} catch {
// Preserve the raw feed while declining to issue an unusable action handle.
}
}
return {
tid: getQzoneTid(feed),
user_id: numericIdentifier(user?.['uin']),
content: normalizeQzoneRichContent(record(feed.cell_summary)?.['summary']),
time: nonnegativeInteger(common?.['time']),
comment_num: nonnegativeInteger(record(feed.cell_comment)?.['num']),
like_num: nonnegativeInteger(record(feed.cell_like)?.['num']),
images: normalizeQzoneMediaImages(record(feed.cell_media)?.['media_items']),
feed_ref: reference,
feed,
};
}
export function buildQzoneText (content: string): QzoneRichMsg[] {
if (typeof content !== 'string' || !content.trim()) throw new Error('QQ空间正文不能为空');
if (content.length > MAX_TEXT_LENGTH) throw new Error('QQ空间正文不能超过2000字');
return [{ content, media_items: [], name: '', type: 0, uid: '', uin: '', url: '', who: 0 }];
}
export function buildQzonePublishRequest (
user: QzoneUser,
content: string,
ugcRight: number,
targetUins?: string[],
now = Math.floor(Date.now() / 1000),
key?: string
): { feed: QzoneFeed; from: number; } {
if (!user || typeof user.uin !== 'string' || numericIdentifier(user.uin) === null) throw new Error('QQ空间当前账号无效');
if (user.uid !== undefined && typeof user.uid !== 'string') throw new Error('QQ空间当前账号UID无效');
if (user.nick !== undefined && typeof user.nick !== 'string') throw new Error('QQ空间当前账号昵称无效');
const permissions = new Map([[1, 1], [4, 2], [16, 3], [128, 4], [64, 5]]);
const flag = permissions.get(ugcRight);
if (flag === undefined) throw new Error('ugc_right仅支持1、4、16、64、128');
if (targetUins !== undefined && (!Array.isArray(targetUins) || targetUins.some(uin => typeof uin !== 'string' || numericIdentifier(uin) === null))) {
throw new Error('target_uins必须为有效QQ号数组');
}
if ((ugcRight === 16 || ugcRight === 128) && !targetUins?.length) throw new Error('ugc_right为16或128时target_uins不能为空');
if (!Number.isSafeInteger(now) || now < 0) throw new Error('QQ空间发表时间无效');
const clientKey = key ?? `${user.uin}_${now}_${Math.floor(Math.random() * 1e10)}`;
if (identity(clientKey) === null) throw new Error('QQ空间发表请求标识无效');
const right: Record<string, unknown> = { flag: [flag] };
if (ugcRight === 16) right['white_uins'] = [...targetUins!];
if (ugcRight === 128) right['black_uins'] = [...targetUins!];
return {
feed: {
cell_common: {
time: now,
client_key: clientKey,
feed_type: 1,
feed_sub_type: 1,
union_id: { uf_key: clientKey, ugc_id: clientKey, feed_id: clientKey },
feed_biz_type: 0,
},
cell_user_info: { user: { uin: user.uin, uid: user.uid ?? '', nick: user.nick ?? '' } },
cell_summary: { summary: buildQzoneText(content) },
cell_permission: { right },
},
from: 3,
};
}
export function parseQzoneRpcResponse (value: unknown): Record<string, unknown> {
const response = record(value);
if (!response || response['retcode'] !== 0) {
const code = response?.['retcode'];
const suffix = typeof code === 'number' && Number.isSafeInteger(code) ? ` (retcode=${code})` : '';
throw new Error(`QQ空间请求失败${suffix}`);
}
const data = record(response['data']);
if (!data) throw new Error('QQ空间响应缺少有效data对象');
return data;
}
@@ -0,0 +1,446 @@
import JSON5 from 'json5';
import { Parser } from 'htmlparser2';
import { createHash } from 'node:crypto';
export interface SlQzoneFeed {
uin: number;
nickname: string;
time: number;
appid: number;
key: string;
html: string;
tid: string | null;
content: string | null;
content_complete: boolean | null;
images: string[] | null;
like_count: number | null;
comment_count: number | null;
}
export interface SlQzoneFeedPage {
feeds: SlQzoneFeed[];
has_more: boolean;
next_cursor: string | null;
}
export interface SlQzoneFeedState {
begintime: string;
dayspac: string;
pagenum: string;
aisortEndTime: string;
aisortOffset: string;
aisortBeginTime: string;
aisortNextTime: string;
icServerTime: string;
externparam: string;
daylist: string;
uinlist: string;
}
export interface SlQzoneParsedFeeds {
feeds: SlQzoneFeed[];
has_more: boolean;
main: Record<string, unknown> | null;
}
const MAX_INPUT_BYTES = 8 * 1024 * 1024;
const MAX_HTML_BYTES = 2 * 1024 * 1024;
const MAX_DEPTH = 64;
const MAX_TOKENS = 200000;
const MAX_HTML_NODES = 50000;
const STATE_NUMBERS = ['begintime', 'dayspac', 'pagenum', 'aisortEndTime', 'aisortOffset', 'aisortBeginTime', 'aisortNextTime', 'icServerTime'] as const;
const STATE_STRINGS = ['externparam', 'daylist', 'uinlist'] as const;
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
function record (value: unknown): value is Record<string, unknown> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function count (value: unknown): number | null {
if (typeof value !== 'number' && !(typeof value === 'string' && /^\d+$/.test(value))) return null;
const number = Number(value);
return Number.isSafeInteger(number) && number >= 0 ? number : null;
}
function scalar (value: unknown, label: string): string {
if (value === undefined || value === null) return '';
if (typeof value !== 'string') throw new Error(`QQ空间动态${label}格式异常`);
return value;
}
function wireNumber (value: unknown, label: string): number {
if (value === undefined || value === null) return 0;
const result = count(value);
if (result === null) throw new Error(`QQ空间动态${label}格式异常`);
return result;
}
/** Only adapt data syntax that JSON5 lacks: undefined values and elided array slots. */
function json5DataSource (source: string, undefinedMarker: string): string {
const chunks: string[] = [];
const nesting: Array<{ array: boolean; awaiting: boolean; }> = [];
let offset = 0;
let tokens = 0;
const statusKeys = new Set<string>();
const markValue = () => {
const parent = nesting[nesting.length - 1];
if (parent?.array) parent.awaiting = false;
};
const nextSignificant = (start: number): number => {
let index = start;
while (index < source.length) {
if (/\s/.test(source[index]!)) index++;
else if (source.startsWith('//', index)) {
const end = source.indexOf('\n', index + 2);
index = end < 0 ? source.length : end + 1;
} else if (source.startsWith('/*', index)) {
const end = source.indexOf('*/', index + 2);
if (end < 0) throw new Error('QQ空间动态字面量注释未结束');
index = end + 2;
} else break;
}
return index;
};
const checkStatusKey = (raw: string, quoted: boolean, end: number) => {
if (nesting.length !== 1 || nesting[0]?.array || source[nextSignificant(end)] !== ':') return;
let key: unknown = raw;
if (quoted) key = JSON5.parse(raw);
else if (raw.includes('\\')) key = Object.keys(JSON5.parse(`{${raw}:0}`))[0];
if (key !== 'code' && key !== 'subcode') return;
if (statusKeys.has(key)) throw new Error('QQ空间动态响应状态字段重复');
statusKeys.add(key);
};
while (offset < source.length) {
if (++tokens > MAX_TOKENS) throw new Error('QQ空间动态字面量节点过多');
const char = source[offset]!;
if (/\s/.test(char)) {
const start = offset++;
while (offset < source.length && /\s/.test(source[offset]!)) offset++;
chunks.push(source.slice(start, offset));
continue;
}
if (source.startsWith('//', offset) || source.startsWith('/*', offset)) {
const start = offset;
if (source[offset + 1] === '/') {
const end = source.indexOf('\n', offset + 2);
offset = end < 0 ? source.length : end + 1;
} else {
const end = source.indexOf('*/', offset + 2);
if (end < 0) throw new Error('QQ空间动态字面量注释未结束');
offset = end + 2;
}
chunks.push(source.slice(start, offset));
continue;
}
if (char === '"' || char === "'") {
const start = offset++;
let closed = false;
while (offset < source.length) {
const current = source[offset++]!;
if (current === '\\') offset++;
else if (current === char) {
closed = true;
break;
}
}
if (!closed) throw new Error('QQ空间动态字面量字符串未结束');
const literal = source.slice(start, offset);
checkStatusKey(literal, true, offset);
chunks.push(literal);
markValue();
continue;
}
if (char === '[' || char === '{') {
markValue();
nesting.push({ array: char === '[', awaiting: true });
if (nesting.length > MAX_DEPTH) throw new Error('QQ空间动态字面量嵌套过深');
chunks.push(char);
offset++;
continue;
}
if (char === ']' || char === '}') {
const current = nesting.pop();
if (!current || current.array !== (char === ']')) throw new Error('QQ空间动态字面量括号不匹配');
chunks.push(char);
offset++;
continue;
}
if (char === ',') {
const current = nesting[nesting.length - 1];
if (current?.array) {
if (current.awaiting) chunks.push('null');
current.awaiting = true;
}
chunks.push(char);
offset++;
continue;
}
if (char === ':') {
chunks.push(char);
offset++;
continue;
}
const start = offset++;
while (offset < source.length && !/[\s[\]{},:'"]/.test(source[offset]!) && !source.startsWith('//', offset) && !source.startsWith('/*', offset)) offset++;
const token = source.slice(start, offset);
checkStatusKey(token, false, offset);
chunks.push(token === 'undefined' && source[nextSignificant(offset)] !== ':' ? JSON.stringify(undefinedMarker) : token);
markValue();
}
if (nesting.length) throw new Error('QQ空间动态字面量括号未结束');
return chunks.join('');
}
function parseDataBody (text: string): Record<string, unknown> {
if (typeof text !== 'string' || Buffer.byteLength(text) > MAX_INPUT_BYTES) throw new Error('QQ空间动态响应过大或无效');
let source = text.trim();
if (!source.startsWith('{')) {
const prefix = /^(?:_preloadCallback|_Callback|callback)\s*\(/.exec(source);
const suffix = /\)\s*;?\s*$/.exec(source);
if (!prefix || !suffix || suffix.index < prefix[0].length) throw new Error('QQ空间动态回调格式异常');
source = source.slice(prefix[0].length, suffix.index).trim();
}
if (!source.startsWith('{')) throw new Error('QQ空间动态缺少对象响应');
let value: unknown;
try {
// Content-addressed marker keeps optional object fields absent instead of changing undefined into null.
const undefinedMarker = `\u0000qz:${createHash('sha256').update(source).digest('hex')}`;
value = JSON5.parse(json5DataSource(source, undefinedMarker), (key, item: unknown) => {
if (DANGEROUS_KEYS.has(key) || (typeof item === 'number' && !Number.isFinite(item))) throw new Error('Unsupported data');
return item === undefinedMarker ? undefined : item;
});
} catch {
throw new Error('QQ空间动态字面量格式异常');
}
if (!record(value)) throw new Error('QQ空间动态缺少对象响应');
return value;
}
interface HtmlElement {
tag: string;
attributes: Record<string, string>;
parent?: HtmlElement;
children: Array<HtmlElement | string>;
}
function classes (element: HtmlElement): string[] {
return (element.attributes['class'] ?? '').split(/\s+/);
}
const EXCLUDED_CLASSES = new Set(['mod-comments', 'comments-list', 'f-item-passive', 'f-ct-video']);
const EXCLUDED_TAGS = new Set(['script', 'style', 'noscript', 'template']);
function excluded (element: HtmlElement): boolean {
return EXCLUDED_TAGS.has(element.tag) || classes(element).some(name => EXCLUDED_CLASSES.has(name));
}
function descendants (root: HtmlElement, prune: boolean): HtmlElement[] {
const result: HtmlElement[] = [];
const pending: HtmlElement[] = [root];
while (pending.length) {
const current = pending.pop()!;
if (current !== root && prune && excluded(current)) continue;
result.push(current);
for (let index = current.children.length - 1; index >= 0; index--) {
const child = current.children[index];
if (typeof child !== 'string' && child) pending.push(child);
}
}
return result;
}
function htmlTree (html: string): HtmlElement {
if (Buffer.byteLength(html) > MAX_HTML_BYTES) throw new Error('QQ空间动态HTML过大');
const root: HtmlElement = { tag: '#root', attributes: {}, children: [] };
const stack = [root];
let nodes = 0;
const parser = new Parser({
onopentag (tag, attributes) {
if (++nodes > MAX_HTML_NODES || stack.length > MAX_DEPTH) throw new Error('QQ空间动态HTML结构过大');
const parent = stack[stack.length - 1]!;
const element: HtmlElement = { tag, attributes: { ...attributes }, parent, children: [] };
parent.children.push(element);
stack.push(element);
},
onclosetag (tag) {
for (let index = stack.length - 1; index > 0; index--) {
if (stack[index]?.tag === tag) {
stack.length = index;
break;
}
}
},
ontext (text) {
if (++nodes > MAX_HTML_NODES) throw new Error('QQ空间动态HTML结构过大');
stack[stack.length - 1]!.children.push(text);
},
}, { decodeEntities: true, lowerCaseTags: true, lowerCaseAttributeNames: true });
parser.end(html);
return root;
}
function contentText (element: HtmlElement): string {
const pieces: string[] = [];
const blocks = new Set(['p', 'div', 'li', 'section', 'article', 'blockquote', 'ul', 'ol']);
const visit = (node: HtmlElement | string) => {
if (typeof node === 'string') {
pieces.push(node.replace(/\s+/g, ' '));
return;
}
if (excluded(node) || node.attributes['data-cmd'] === 'qz_toggle' || classes(node).includes('load_img')) return;
if (node.tag === 'br') pieces.push('\n');
else if (node.tag === 'img') pieces.push(node.attributes['alt'] ?? '');
else {
if (blocks.has(node.tag)) pieces.push('\n');
for (const child of node.children) visit(child);
if (blocks.has(node.tag)) pieces.push('\n');
}
};
visit(element);
return pieces.join('').replace(/[^\S\n]*\n[^\S\n]*/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
}
function imageUrl (value: string | undefined): string | null {
if (!value) return null;
try {
const url = new URL(value.startsWith('//') ? `https:${value}` : value);
return (url.protocol === 'https:' || url.protocol === 'http:') && !url.username && !url.password ? url.href : null;
} catch {
return null;
}
}
function projectHtml (feed: SlQzoneFeed): void {
const document = htmlTree(feed.html);
const roots = descendants(document, true).filter(node => classes(node).includes('f-item') && node.attributes['data-key'] === feed.key);
if (!feed.key || roots.length !== 1) return;
const root = roots[0]!;
if (excluded(root)) return;
const elements = descendants(root, true);
const metadata = elements.find(node => node.attributes['name'] === 'feed_data' && node.attributes['data-fkey'] === feed.key && node.attributes['data-uin'] === String(feed.uin));
const tid = metadata?.attributes['data-tid'];
if (feed.appid === 311 && tid?.trim() && tid.length <= 8192) feed.tid = tid;
const textElements = elements.filter(node => classes(node).includes('f-info'));
const full = textElements.find(node => classes(node).includes('qz_info_complete') && contentText(node).length > 0);
const selected = full ?? textElements.find(node => !classes(node).includes('qz_info_complete'));
if (selected) {
feed.content = contentText(selected);
feed.content_complete = full !== undefined || !descendants(selected, true).some(node => node.attributes['data-cmd'] === 'qz_toggle');
}
const pictures = new Set<string>();
for (const element of elements) {
if (!classes(element).includes('img-item') || element.attributes['data-v_itemid'] !== undefined || element.attributes['data-videotype'] !== undefined) continue;
const fullUrl = imageUrl(element.attributes['data-originurl'] ?? element.attributes['data-url3']);
if (fullUrl) pictures.add(fullUrl);
else {
for (const image of descendants(element, true)) {
if (image.tag !== 'img') continue;
const url = imageUrl(image.attributes['truesrc'] ?? image.attributes['src']);
if (url) pictures.add(url);
}
}
}
feed.images = [...pictures];
if (feed.like_count === null) {
let card = root;
for (let parent = root.parent; parent; parent = parent.parent) {
if (classes(parent).includes('f-single')) {
card = parent;
break;
}
}
const button = descendants(card, true).find(node => classes(node).includes('qz_like_btn_v3'));
feed.like_count = count(button?.attributes['data-likecnt']);
}
}
function moreFlag (value: unknown): boolean {
if (value === true || value === 1 || value === '1') return true;
if (value === false || value === 0 || value === '0') return false;
throw new Error('QQ空间动态缺少明确的分页结束标志');
}
export function parseSlQzoneFeeds (text: string): SlQzoneParsedFeeds {
const response = parseDataBody(text);
if (response['code'] !== 0 || (response['subcode'] !== undefined && response['subcode'] !== 0)) throw new Error('QQ空间好友动态请求失败');
const data = response['data'];
if (!record(data) || !Array.isArray(data['data']) || data['data'].length > 1000) throw new Error('QQ空间好友动态列表格式异常');
const mainValue = data['main'];
if (mainValue !== undefined && !record(mainValue)) throw new Error('QQ空间好友动态分页信息无效');
const main = record(mainValue) ? mainValue : null;
if (main && main['error'] !== undefined && main['error'] !== '') throw new Error('QQ空间好友动态服务端报告失败');
const hasMore = moreFlag(main ? main['hasMoreFeeds'] : data['hasmore']);
if (hasMore && !main) throw new Error('QQ空间好友动态缺少续页状态');
const feeds: SlQzoneFeed[] = [];
for (const value of data['data']) {
if (value === null || value === undefined) continue;
if (!record(value)) throw new Error('QQ空间好友动态条目格式异常');
const key = scalar(value['key'] ?? value['feedskey'], '标识');
if (key.length > 8192) throw new Error('QQ空间动态标识过长');
const feed: SlQzoneFeed = {
uin: wireNumber(value['uin'], '账号'),
nickname: scalar(value['nickname'], '昵称'),
time: wireNumber(value['abstime'], '时间'),
appid: wireNumber(value['appid'], '应用'),
key,
html: scalar(value['html'], 'HTML'),
tid: null,
content: null,
content_complete: null,
images: null,
like_count: count(value['likecnt']),
comment_count: count(value['commentcnt']),
};
projectHtml(feed);
feeds.push(feed);
}
return { feeds, has_more: hasMore, main };
}
export function initialSlQzoneFeedState (): SlQzoneFeedState {
return {
begintime: '0',
dayspac: '0',
pagenum: '1',
aisortEndTime: '0',
aisortOffset: '0',
aisortBeginTime: '0',
aisortNextTime: '0',
icServerTime: '0',
externparam: '',
daylist: '',
uinlist: '',
};
}
export function advanceSlQzoneFeedState (previous: SlQzoneFeedState, main: Record<string, unknown>): SlQzoneFeedState {
const next = { ...previous };
for (const field of STATE_NUMBERS) {
const value = main[field];
if (value === undefined || value === '') continue;
if ((typeof value !== 'number' && typeof value !== 'string') || !/^\d+$/.test(String(value)) ||
(typeof value === 'number' && !Number.isSafeInteger(value)) || String(value).length > 32) {
throw new Error('QQ空间动态分页数值无效');
}
const canonical = BigInt(value).toString();
if (field === 'pagenum' && canonical === '0') throw new Error('QQ空间动态分页页码无效');
next[field] = canonical;
}
for (const field of STATE_STRINGS) {
const value = main[field];
if (value === undefined || (value === '' && field !== 'uinlist')) continue;
if (typeof value !== 'string' || value.length > 32768) throw new Error('QQ空间动态分页字符串无效');
next[field] = value;
}
return next;
}
export function slQzoneFeedPosition (state: SlQzoneFeedState): string {
const position: Record<string, string> = {};
for (const field of Object.keys(state).sort()) {
if (field !== 'icServerTime') position[field] = state[field as keyof SlQzoneFeedState];
}
return JSON.stringify(position);
}
+310
View File
@@ -0,0 +1,310 @@
export interface SlQzoneEmotion {
tid: string;
content: string;
time: number;
comment_num: number;
is_private: boolean;
images: string[];
}
export interface SlQzoneMsgList {
total: number;
/** False means total is the legacy page-length fallback, not a measured account total. */
total_known?: boolean;
msglist: SlQzoneEmotion[];
}
export interface SlQzoneFlatReply {
uin: number;
name: string;
time: number;
content: string;
comment_id: string;
parent_comment_id: string;
parent_comment_uin: number;
}
export interface SlQzoneFlatComment {
uin: number;
name: string;
time: number;
content: string;
comment_id: string;
replies: SlQzoneFlatReply[];
}
export interface SlQzoneMsgDetail {
tid: string;
content: string;
comment_num: number;
comments: SlQzoneFlatComment[];
}
export interface SlQzoneCommentResult {
comment_id: string;
parent_comment_id?: string;
parent_comment_uin?: number;
}
export interface SlQzoneCommentOptions {
imageUrls?: string[];
comment_id?: string;
comment_uin?: string;
reply_uin?: string;
reply_name?: string;
}
function object (value: unknown): Record<string, unknown> | undefined {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null ? value as Record<string, unknown> : undefined;
}
function requireObject (value: unknown): Record<string, unknown> {
const result = object(value);
if (!result) throw new Error('QQ空间兼容响应对象格式异常');
return result;
}
function responseCodes (response: Record<string, unknown>, requireAcknowledgement = false, requireCode = false): void {
let acknowledged = false;
for (const field of ['code', 'subcode', 'retcode', 'ret']) {
if (!Object.prototype.hasOwnProperty.call(response, field)) continue;
const value = response[field];
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value !== 0) {
const code = typeof value === 'number' && Number.isSafeInteger(value) ? ` (${field}=${value})` : '';
throw new Error(`QQ空间兼容接口拒绝请求${code}`);
}
acknowledged = true;
}
if ((requireAcknowledgement && !acknowledged) || (requireCode && response['code'] !== 0)) {
throw new Error('QQ空间兼容响应缺少明确成功状态');
}
}
function integer (value: unknown, minimum = 0): number {
if (typeof value !== 'number' && !(typeof value === 'string' && /^\d+$/.test(value))) throw new Error('QQ空间兼容响应数值无效');
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed < minimum) throw new Error('QQ空间兼容响应数值无效');
return parsed;
}
function requiredText (value: unknown): string {
if (typeof value !== 'string') throw new Error('QQ空间兼容响应文本字段无效');
return value;
}
function hasControlCharacter (value: string): boolean {
for (const character of value) {
const code = character.charCodeAt(0);
if (code < 32 || code === 127) return true;
}
return false;
}
function id (value: unknown, allowEmpty = false): string {
if (value === undefined || value === null) {
if (allowEmpty) return '';
throw new Error('QQ空间兼容响应缺少标识');
}
if (typeof value === 'number') return String(integer(value));
if (typeof value !== 'string' || value.length > 8192 || hasControlCharacter(value) || (!value.trim() && (!allowEmpty || value !== ''))) {
throw new Error('QQ空间兼容响应标识无效');
}
return value;
}
function array (value: unknown): unknown[] {
if (value === undefined || value === null) return [];
if (!Array.isArray(value)) throw new Error('QQ空间兼容响应列表格式异常');
return value;
}
function pictureUrls (value: unknown): string[] {
const result: string[] = [];
for (const picture of array(value)) {
const fields = requireObject(picture);
for (const key of ['url3', 'url2', 'url1', 'smallurl']) {
const url = fields[key];
if (url === undefined || url === null || url === '') continue;
if (typeof url !== 'string') throw new Error('QQ空间说说图片地址格式异常');
result.push(url);
break;
}
}
return result;
}
function privateFlag (value: unknown): boolean {
if (value === undefined || value === null) return false;
if (typeof value !== 'number' && !(typeof value === 'string' && /^-?\d+$/.test(value))) throw new Error('QQ空间说说私密标记无效');
const parsed = Number(value);
if (!Number.isSafeInteger(parsed)) throw new Error('QQ空间说说私密标记无效');
return parsed !== 0;
}
/** Legacy zero/empty sentinels apply only to absent fields, never malformed supplied values. */
export function parseSlQzoneMsgList (response: Record<string, unknown>): SlQzoneMsgList {
const fields = requireObject(response);
responseCodes(fields);
if (!Array.isArray(fields['msglist'])) throw new Error('QQ空间兼容说说列表响应不完整');
const msglist: SlQzoneEmotion[] = [];
for (const entry of fields['msglist']) {
const row = requireObject(entry);
msglist.push({
tid: id(row['tid'], true),
content: row['content'] === undefined || row['content'] === null ? '' : requiredText(row['content']),
time: row['created_time'] === undefined || row['created_time'] === null ? 0 : integer(row['created_time']),
comment_num: row['cmtnum'] === undefined || row['cmtnum'] === null ? 0 : integer(row['cmtnum']),
is_private: privateFlag(row['secret']),
images: pictureUrls(row['pic']),
});
}
const totalKnown = fields['total'] !== undefined && fields['total'] !== null;
return {
total: totalKnown ? integer(fields['total']) : msglist.length,
total_known: totalKnown,
msglist,
};
}
function flatComment (value: unknown): Omit<SlQzoneFlatComment, 'replies'> {
const fields = requireObject(value);
return {
comment_id: id(fields['tid']),
uin: integer(fields['uin'], 1),
name: requiredText(fields['name']),
time: integer(fields['create_time']),
content: requiredText(fields['content']),
};
}
export function parseSlQzoneMsgDetail (response: Record<string, unknown>, tid: string): SlQzoneMsgDetail {
assertTid(tid);
const fields = requireObject(response);
responseCodes(fields);
if (typeof fields['tid'] !== 'string' || fields['tid'] !== tid) throw new Error('QQ空间兼容详情与请求的说说不匹配');
const comments: SlQzoneFlatComment[] = [];
for (const value of array(fields['commentlist'])) {
const original = requireObject(value);
const parent = flatComment(original);
const replies: SlQzoneFlatReply[] = [];
for (const reply of array(original['list_3'])) {
replies.push({ ...flatComment(reply), parent_comment_id: parent.comment_id, parent_comment_uin: parent.uin });
}
comments.push({ ...parent, replies });
}
return { tid, content: requiredText(fields['content']), comment_num: integer(fields['cmtnum']), comments };
}
function assertUin (value: unknown, safeNumeric = false): asserts value is string {
if (typeof value !== 'string' || !/^[1-9]\d{0,19}$/.test(value) || (safeNumeric && !Number.isSafeInteger(Number(value)))) {
throw new Error('QQ空间兼容账号标识无效');
}
}
function assertTid (value: unknown): asserts value is string {
if (typeof value !== 'string' || !value.trim() || value.length > 8192 || hasControlCharacter(value)) throw new Error('QQ空间兼容说说标识无效');
}
export function buildSlQzoneLikeBody (self: string, target: string, tid: string, enable: boolean, abstime: number): string {
assertUin(self);
assertUin(target);
assertTid(tid);
if (typeof enable !== 'boolean' || typeof abstime !== 'number' || !Number.isSafeInteger(abstime) || abstime < 0) throw new Error('QQ空间兼容点赞参数无效');
const resource = `http://user.qzone.qq.com/${target}/mood/${tid}`;
const form = new URLSearchParams();
form.set('opuin', self);
form.set('fid', tid);
form.set('abstime', String(abstime));
form.set('unikey', resource);
form.set('curkey', resource);
for (const [key, value] of Object.entries({ appid: '311', typeid: '0', from: '1', active: '0', fupdate: '1', format: 'json' })) form.set(key, value);
form.set('qzreferrer', `https://user.qzone.qq.com/${self}`);
return form.toString();
}
function commentOptions (options: SlQzoneCommentOptions | undefined): { options: SlQzoneCommentOptions; reply: boolean; } {
if (options !== undefined && !object(options)) throw new Error('QQ空间兼容评论选项无效');
const fields: SlQzoneCommentOptions = options ?? {};
if (Object.keys(fields).some(key => !['imageUrls', 'comment_id', 'comment_uin', 'reply_uin', 'reply_name'].includes(key))) throw new Error('QQ空间兼容评论选项无效');
const reply = fields.comment_id !== undefined || fields.comment_uin !== undefined;
if (reply) {
assertTid(fields.comment_id);
assertUin(fields.comment_uin, true);
}
const addressed = fields.reply_uin !== undefined || fields.reply_name !== undefined;
if (addressed) {
if (!reply || typeof fields.reply_name !== 'string' || !fields.reply_name.trim() || fields.reply_name.length > 8192) throw new Error('指定回复目标需要完整父评论与昵称');
assertUin(fields.reply_uin, true);
}
if (fields.imageUrls !== undefined) {
if (!Array.isArray(fields.imageUrls)) throw new Error('QQ空间评论图片地址必须为数组');
if (reply && fields.imageUrls.length > 0) throw new Error('QQ空间楼中楼回复不支持图片');
for (const url of fields.imageUrls) {
if (typeof url !== 'string' || !url || url.length > 8192 || url.trim() !== url) throw new Error('QQ空间评论图片地址无效');
try {
const parsed = new URL(url);
if (!['https:', 'http:'].includes(parsed.protocol) || parsed.username || parsed.password || /[\t\r\n]/.test(url)) throw new Error('Invalid URL');
} catch {
throw new Error('QQ空间评论图片地址无效');
}
}
}
return { options: fields, reply };
}
function addressedContent (content: string, options: SlQzoneCommentOptions): string {
if (options.reply_uin === undefined) return content;
const escapes: Record<string, string> = { '%': '%25', ',': '%2C', '}': '%7D' };
const name = [...options.reply_name!.trim()].map(character => escapes[character] ?? character).join('');
return `@{uin:${options.reply_uin},nick:${name},auto:1} ${content}`;
}
export function buildSlQzoneCommentBody (self: string, target: string, tid: string, content: string, options?: SlQzoneCommentOptions): string {
assertUin(self);
assertUin(target);
assertTid(tid);
if (typeof content !== 'string' || content === '') throw new Error('QQ空间兼容评论正文不能为空');
const branch = commentOptions(options);
const form = new URLSearchParams({ uin: self, hostUin: target, inCharset: 'utf-8', outCharset: 'utf-8' });
if (branch.reply) {
if (!content.trim()) throw new Error('QQ空间兼容回复正文不能为空白');
form.set('hostuin', self);
form.set('topicId', `${target}_${tid}`);
form.set('commentId', branch.options.comment_id!);
form.set('commentUin', branch.options.comment_uin!);
form.set('content', addressedContent(content, branch.options));
form.set('ref', '');
form.set('code_version', '1');
form.set('format', 'fs');
form.set('qzreferrer', `https://user.qzone.qq.com/${target}`);
} else {
form.set('topicId', `${target}_${tid}__1`);
form.set('content', content);
form.set('richtype', branch.options.imageUrls?.length ? '1' : '');
form.set('richval', branch.options.imageUrls?.join('\t') ?? '');
for (const [key, value] of Object.entries({ format: 'json', ref: 'feeds', feedsType: '100', private: '0', paramstr: '1', isSignIn: '', plat: 'qzone', source: 'ic', platformid: '52' })) form.set(key, value);
form.set('qzreferrer', `https://user.qzone.qq.com/${self}`);
}
return form.toString();
}
export function parseSlQzoneComment (response: Record<string, unknown>, options?: SlQzoneCommentOptions): SlQzoneCommentResult {
const fields = requireObject(response);
const branch = commentOptions(options);
responseCodes(fields, true, branch.reply);
if (branch.reply) {
const result = object(fields['data']);
if (!result) throw new Error('QQ空间回复响应缺少新标识,结果未知,请查询后确认,勿自动重试');
let replyId: string;
try {
replyId = id(result['id']);
} catch {
throw new Error('QQ空间回复响应缺少有效新标识,结果未知,请查询后确认,勿自动重试');
}
return { comment_id: replyId, parent_comment_id: branch.options.comment_id!, parent_comment_uin: Number(branch.options.comment_uin!) };
}
// An empty ID is the explicit legacy sentinel after an acknowledged ordinary comment.
return { comment_id: id(fields['commentid'] ?? fields['commentId'], true) };
}
+396
View File
@@ -0,0 +1,396 @@
export interface QzoneProfile {
name: string;
description: string;
}
export interface QzoneProfileUpdate {
name?: string;
description?: string;
}
export type QzoneUgcRight = 1 | 4 | 16 | 64 | 128;
const SUCCESS_FIELDS = ['code', 'subcode', 'retcode', 'ret'];
const MAX_PROFILE_LENGTH = 8192;
const MAX_IDENTITY_LENGTH = 8192;
export class QzoneWebError extends Error {
constructor (public readonly field: string, public readonly code: number) {
super(`QQ空间接口拒绝请求 (${field}=${code})`);
}
}
function object (value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null ? value as Record<string, unknown> : undefined;
}
function assertUin (uin: unknown): asserts uin is string {
if (typeof uin !== 'string' || !/^[1-9]\d{0,19}$/.test(uin)) throw new Error('QQ空间账号标识无效');
}
function assertIdentity (value: unknown): asserts value is string {
if (typeof value !== 'string' || !value.trim() || value.length > MAX_IDENTITY_LENGTH) {
throw new Error('QQ空间说说标识无效');
}
}
function assertSuccess (response: Record<string, unknown>, requireCode: boolean): void {
let acknowledged = false;
for (const field of SUCCESS_FIELDS) {
if (!Object.prototype.hasOwnProperty.call(response, field)) continue;
const code = response[field];
if (typeof code !== 'number' || !Number.isSafeInteger(code) || code !== 0) {
// CGI messages may contain account content or tickets. Only include a numeric code.
if (typeof code === 'number' && Number.isSafeInteger(code)) throw new QzoneWebError(field, code);
throw new Error('QQ空间接口拒绝请求');
}
acknowledged = true;
}
if (!acknowledged || (requireCode && response['code'] !== 0)) throw new Error('QQ空间响应缺少明确成功状态');
}
/** Quote only observed bare name keys with string values, never string contents. */
function normalizeNameStringKeys (text: string): string {
let result = '';
let quoted = false;
let escaped = false;
const containers: Array<{ object: boolean; key: boolean; }> = [];
for (let index = 0; index < text.length; index++) {
const character = text[index]!;
if (!quoted) {
const container = containers[containers.length - 1];
if (container?.object && container.key && character === 'n' && /^name(?=\s*:\s*")/.test(text.slice(index))) {
result += '"name"';
index += 'name'.length - 1;
container.key = false;
continue;
}
if (character === '{') containers.push({ object: true, key: true });
else if (character === '[') containers.push({ object: false, key: false });
else if (character === '}' || character === ']') containers.pop();
else if (character === ',' && container?.object) container.key = true;
else if (!/\s/.test(character) && container?.object) container.key = false;
}
result += character;
if (quoted) {
if (escaped) escaped = false;
else if (character === '\\') escaped = true;
else if (character === '"') quoted = false;
} else if (character === '"') {
quoted = true;
}
}
return result;
}
function rejectDuplicateSuccessFields (text: string): void {
const seen = new Set<string>();
let depth = 0;
for (let index = 0; index < text.length; index++) {
const character = text[index];
if (character === '{' || character === '[') depth++;
else if (character === '}' || character === ']') depth--;
else if (character === '"') {
const start = index;
let escaped = false;
for (index++; index < text.length; index++) {
const current = text[index];
if (escaped) escaped = false;
else if (current === '\\') escaped = true;
else if (current === '"') break;
}
let next = index + 1;
while (/\s/.test(text[next] ?? '') && next < text.length) next++;
if (depth !== 1 || text[next] !== ':') continue;
const field: unknown = JSON.parse(text.slice(start, index + 1));
if (typeof field !== 'string' || !SUCCESS_FIELDS.includes(field)) continue;
if (seen.has(field)) throw new Error('QQ空间响应包含重复状态字段');
seen.add(field);
}
}
}
// FormSender's response is a data callback inside one fixed, passive HTML shell.
// Consume its observed domain setup as text; never run the returned JavaScript.
const FORM_SENDER_HTML = /^(?:<!doctype\s+html>\s*)?<html>\s*<head>\s*<meta\s+http-equiv="content-type"\s+content="text\/html;\s*charset=utf-8"\s*\/?>\s*<\/head>\s*<body>\s*<script\s+type="text\/javascript">\s*([\s\S]*?)\s*<\/script>\s*<\/body>\s*<\/html>$/i;
const DOMAIN_SETUP = 'document\\s*\\.\\s*domain\\s*=\\s*"h5\\.qzone\\.qq\\.com"\\s*;\\s*';
const CALLBACK_SETUP = 'cb\\s*=\\s*frameElement\\s*\\.\\s*callback\\s*;\\s*';
const DOMAIN_TRY_SETUP = new RegExp(
'^var\\s+cb\\s*;\\s*try\\s*\\{\\s*' + DOMAIN_SETUP + CALLBACK_SETUP +
'\\}\\s*catch\\s*\\(\\s*e\\s*\\)\\s*\\{\\s*try\\s*\\{\\s*' + DOMAIN_SETUP + CALLBACK_SETUP +
'\\}\\s*catch\\s*\\(\\s*e\\s*\\)\\s*\\{\\s*' + DOMAIN_SETUP + CALLBACK_SETUP + '\\}\\s*\\}\\s*'
);
const DOMAIN_ONLY_SETUP = new RegExp('^' + DOMAIN_SETUP);
function unwrapFormSenderHtml (text: string): string | undefined {
const html = FORM_SENDER_HTML.exec(text);
if (!html) return undefined;
const script = html[1]!.trim();
const setup = DOMAIN_TRY_SETUP.exec(script) ?? DOMAIN_ONLY_SETUP.exec(script);
if (!setup) throw new Error('QQ空间响应脚本包裹格式异常');
const callback = script.slice(setup[0].length).trim();
if (!/^frameElement\.callback\s*\(/.test(callback)) throw new Error('QQ空间响应脚本包裹格式异常');
return callback;
}
/** Parse data only: no script evaluation and no first-brace/last-brace extraction. */
export function parseQzoneWebResponse (text: string, requireCode = false): Record<string, unknown> {
if (typeof text !== 'string' || text.length > 8 * 1024 * 1024) throw new Error('QQ空间响应格式异常');
let payload = text.trim();
const formSender = unwrapFormSenderHtml(payload);
if (formSender !== undefined) payload = formSender;
// Preserve the original single-script and bare callback envelopes as well.
const script = /^<script>\s*([\s\S]*?)\s*<\/script>$/i.exec(payload);
if (script) {
payload = script[1]!.trim();
if (!/^(?:_Callback|callback|_preloadCallback|back|frameElement\.callback)\s*\(/.test(payload)) throw new Error('QQ空间响应脚本包裹格式异常');
} else if (payload.endsWith('</script>') && payload.startsWith('frameElement.callback')) payload = payload.slice(0, -9).trim();
if (!payload.startsWith('{')) {
const callback = /^(?:_Callback|callback|_preloadCallback|back|frameElement\.callback)\s*\(\s*([\s\S]*)\s*\)\s*;?\s*$/.exec(payload);
if (!callback) throw new Error('QQ空间响应格式异常');
payload = callback[1]!.trim();
}
let parsed: unknown;
try {
payload = normalizeNameStringKeys(payload);
rejectDuplicateSuccessFields(payload);
parsed = JSON.parse(payload);
} catch {
throw new Error('QQ空间响应JSON格式异常');
}
const response = object(parsed);
if (!response) throw new Error('QQ空间响应必须是对象');
assertSuccess(response, requireCode);
return response;
}
export function parseQzoneProfile (response: Record<string, unknown>): QzoneProfile {
if (!object(response)) throw new Error('QQ空间资料响应格式异常');
assertSuccess(response, true);
const data = object(response['data']);
if (!data || typeof data['spacename'] !== 'string' || typeof data['desc'] !== 'string') {
throw new Error('QQ空间资料响应不完整');
}
return { name: data['spacename'], description: data['desc'] };
}
export function buildQzoneProfileBody (selfUin: string, profile: QzoneProfileUpdate): string {
assertUin(selfUin);
const fields = object(profile);
if (!fields || Object.keys(fields).some(key => key !== 'name' && key !== 'description')) throw new Error('QQ空间资料参数无效');
const params = new URLSearchParams({ uin: selfUin, pageindex: '3', fupdate: '1' });
let bitmap = 0;
for (const [field, wireName, bit] of [['name', 'spacename', 2048], ['description', 'desc', 4096]] as const) {
const value = fields[field];
if (value === undefined) continue;
if (typeof value !== 'string' || value.length > MAX_PROFILE_LENGTH) throw new Error('QQ空间名称或说明必须是8192字以内的文本');
params.set(wireName, value.replaceAll('\r', ''));
bitmap |= bit;
}
if (bitmap === 0) throw new Error('至少提供空间名称或说明');
params.set('mb', String(bitmap));
return params.toString();
}
export function normalizeQzoneRight (ugcRight: number, targetUins?: string[]): { right: QzoneUgcRight; targetUins: string[]; } {
const rights = [1, 4, 16, 64, 128];
if (typeof ugcRight !== 'number' || !rights.includes(ugcRight)) throw new Error('ugc_right仅支持1、4、16、64、128');
if (targetUins !== undefined && !Array.isArray(targetUins)) throw new Error('target_uins必须为QQ号数组');
const targets: string[] = [];
const seen = new Set<string>();
for (const uin of targetUins ?? []) {
assertUin(uin);
if (!seen.has(uin)) {
targets.push(uin);
seen.add(uin);
}
}
if ((ugcRight === 16 || ugcRight === 128) && targets.length === 0) throw new Error('ugc_right为16或128时target_uins不能为空');
return { right: ugcRight as QzoneUgcRight, targetUins: targets };
}
function wireText (value: unknown, fallback: string): string {
if (value === undefined) return fallback;
if (typeof value !== 'string') throw new Error('QQ空间说说重建字段格式异常');
return value;
}
function wireInteger (value: unknown, fallback: string, minimum = 0, allowEmpty = false): string {
if (value === undefined) return fallback;
if (allowEmpty && value === '') return '';
if ((typeof value !== 'number' || !Number.isSafeInteger(value)) && (typeof value !== 'string' || !/^\d+$/.test(value))) {
throw new Error('QQ空间说说重建数值无效');
}
const number = Number(value);
if (!Number.isSafeInteger(number) || number < minimum) throw new Error('QQ空间说说重建数值无效');
return String(value);
}
function firstPresent (first: unknown, second: unknown): unknown {
return first === undefined ? second : first;
}
function hasMediaValue (value: unknown): boolean {
if (value === undefined || value === null || value === false || value === 0 || value === '' || value === '0') return false;
if (Array.isArray(value)) return value.length > 0;
const record = object(value);
return record ? Object.keys(record).length > 0 : true;
}
function hasObservedEmptyForwardContent (detail: Record<string, unknown>): boolean {
if (detail['rt_tid'] !== '' || detail['rt_uin'] !== 0 || detail['rt_source'] !== 0) return false;
const content = object(detail['rt_con']);
if (!content) return false;
const keys = Object.keys(content);
return keys.length === 2 && keys.includes('conlist') && keys.includes('content') &&
content['conlist'] === null && content['content'] === '';
}
function assertSupportedMedia (detail: Record<string, unknown>): void {
for (const field of [
'video', 'videos', 'video_info', 'video_url', 'videoid', 'video_id', 'vid',
'music', 'music_info', 'audio', 'voice', 'voice_info',
'rt_tid', 'rt_uin', 'rt_con', 'rt_source', 'forward', 'forward_info', 'forward_tid',
'origin', 'origin_tid', 'origin_feed', 'cell_origin',
]) {
// The legacy detail CGI supplies this exact empty object for ordinary text and image posts.
if (field === 'rt_con' && hasObservedEmptyForwardContent(detail)) continue;
if (hasMediaValue(detail[field])) throw new Error('该说说包含暂不支持完整重建的媒体或转发内容');
}
for (const field of ['appid', 'app_id']) {
if (detail[field] !== undefined && detail[field] !== 311 && detail[field] !== '311') throw new Error('该动态不是可重建的说说');
}
if (wireText(detail['special_url'], '') !== '') throw new Error('暂不支持修改含特殊分享内容的说说权限');
}
function pictureBo (picture: Record<string, unknown>): string | undefined {
for (const key of ['smallurl', 'url1', 'url2', 'url3']) {
const value = picture[key];
if (value === undefined || value === '') continue;
if (typeof value !== 'string') throw new Error('QQ空间图片地址元数据无效');
const matches = [...value.matchAll(/[?&]bo=([^&#]*)/g)];
if (matches.length > 1) throw new Error('QQ空间图片包含歧义元数据');
if (matches.length === 0) continue;
try {
const decoded = decodeURIComponent(matches[0]![1]!);
if (!decoded || /[\t\r\n]/.test(decoded)) throw new Error('Invalid picture metadata');
return decoded;
} catch {
throw new Error('QQ空间图片元数据无法完整重建');
}
}
return undefined;
}
function rebuildContent (detail: Record<string, unknown>): string {
const conlist = detail['conlist'];
if (conlist !== undefined && conlist !== null) {
if (!Array.isArray(conlist)) throw new Error('QQ空间说说正文分段格式异常');
if (conlist.length > 0) {
return conlist.map((value) => {
const part = object(value);
if (!part || typeof part['con'] !== 'string') throw new Error('QQ空间说说正文分段不完整');
return part['con'];
}).join('');
}
}
if (typeof detail['content'] !== 'string') throw new Error('QQ空间说说缺少完整正文');
return detail['content'];
}
/** Permission updates resubmit a whole post, so unsupported or partial data must fail before POST. */
export function buildQzoneRightBody (
detail: Record<string, unknown>,
selfUin: string,
tid: string,
ugcRight: number,
targetUins?: string[]
): string {
assertUin(selfUin);
assertIdentity(tid);
const source = object(detail);
if (!source || source['tid'] !== tid) throw new Error('QQ空间详情与请求的说说标识不匹配');
const owner = source['uin'];
if ((typeof owner !== 'string' && !(typeof owner === 'number' && Number.isSafeInteger(owner))) || String(owner) !== selfUin) {
throw new Error('只能修改当前账号自己的说说权限');
}
if (SUCCESS_FIELDS.some(field => Object.prototype.hasOwnProperty.call(source, field))) assertSuccess(source, false);
const permission = normalizeQzoneRight(ugcRight, targetUins);
assertSupportedMedia(source);
const pics = source['pic'] ?? [];
if (!Array.isArray(pics)) throw new Error('QQ空间说说图片列表格式异常');
const richType = wireInteger(source['richtype'], pics.length ? '1' : '', 0, true);
if (!['', '0', '1'].includes(richType)) throw new Error('该说说的媒体类型暂不支持完整重建');
if ((pics.length > 0 && richType !== '1') || (pics.length === 0 && richType === '1')) throw new Error('QQ空间说说图片信息不完整');
const richValues: string[] = [];
const boValues: Array<string | undefined> = [];
for (const value of pics) {
const picture = object(value);
const pictureId = picture?.['pic_id'];
if (!picture || typeof pictureId !== 'string' || !pictureId || /[\t\r\n]/.test(pictureId)) throw new Error('QQ空间说说图片标识不完整');
const parts = pictureId.split(',');
const albumId = parts[1];
const lloc = parts[2];
if (!albumId?.trim() || !lloc?.trim()) throw new Error('QQ空间说说图片信息无法完整重建');
// 22 is the observed legacy image default; existing image dimensions cannot be guessed.
const pictureType = wireInteger(firstPresent(picture['pictype'], picture['type']), '22');
const originalHeight = firstPresent(picture['height'], picture['b_height']);
const originalWidth = firstPresent(picture['width'], picture['b_width']);
if (originalHeight === undefined || originalWidth === undefined) throw new Error('QQ空间说说图片尺寸不完整');
const height = wireInteger(originalHeight, '', 1);
const width = wireInteger(originalWidth, '', 1);
richValues.push(`,${albumId},${lloc},${lloc},${pictureType},${height},${width},,0,0`);
boValues.push(pictureBo(picture));
}
if (boValues.some(value => value !== undefined) && boValues.some(value => value === undefined)) throw new Error('QQ空间图片元数据数量不完整');
const boGroup = boValues.every(value => value !== undefined) ? boValues.join(',') : '';
const originalRichValue = wireText(source['richval'], '');
if (richValues.length === 0 && originalRichValue !== '') throw new Error('QQ空间说说包含无法完整重建的媒体数据');
const subtype = wireInteger(firstPresent(source['t1_subtype'], source['subrichtype']), richValues.length ? '1' : '', 0, true);
// Subtype 2 was observed on plain text, without either rich-type field or pictures.
const observedPlainTextSubtype = subtype === '2' && hasObservedEmptyForwardContent(source) &&
pics.length === 0 && source['richtype'] === undefined && source['subrichtype'] === undefined;
if (!['', '0', '1'].includes(subtype) && !observedPlainTextSubtype) throw new Error('该说说的媒体子类型暂不支持完整重建');
const sign = wireInteger(source['to_sign'], '0');
if (sign !== '0' && sign !== '1') throw new Error('QQ空间说说同步标记无效');
const rightId = wireText(source['ugcright_id'], tid);
assertIdentity(rightId);
const params = new URLSearchParams({
syn_tweet_verson: '1',
tid,
paramstr: '1',
pic_template: wireText(source['pic_template'], ''),
richtype: richType,
richval: richValues.join('\t'),
special_url: '',
subrichtype: subtype,
pic_bo: boGroup ? `${boGroup}\t${boGroup}` : '',
con: rebuildContent(source),
feedversion: wireInteger(source['feedversion'], '1', 1),
ver: wireInteger(source['ver'], '1', 1),
ugc_right: String(permission.right),
to_sign: sign,
ugcright_id: rightId,
hostuin: selfUin,
code_version: wireInteger(source['code_version'], '1', 1),
format: 'fs',
qzreferrer: `https://user.qzone.qq.com/${selfUin}`,
});
if (permission.right === 16 || permission.right === 128) params.set('allow_uins', permission.targetUins.join('|'));
return params.toString();
}
export function buildQzoneBanBody (selfUin: string, targetUin: string, enable: boolean): string {
assertUin(selfUin);
assertUin(targetUin);
if (typeof enable !== 'boolean') throw new Error('空间黑名单状态必须是布尔值');
return new URLSearchParams({
uin: selfUin,
act_uin: targetUin,
action: enable ? '1' : '2',
fupdate: '1',
qzreferrer: `https://user.qzone.qq.com/${selfUin}/main`,
}).toString();
}
+212
View File
@@ -0,0 +1,212 @@
/**
* QQ空间(Qzone)发说说/上传图片相关的纯数据构造与解析函数
* 不发起任何网络请求,方便单独测试
*/
export interface QzoneUploadImageParams {
uin: string;
skey: string;
pskey: string;
g_tk: string;
base64: string;
}
/**
* 构造上传图片到 QQ 空间的表单请求体 (application/x-www-form-urlencoded)
* 对齐 https://up.qzone.qq.com/cgi-bin/upload/cgi_upload_image
*/
export function buildQzoneUploadImageBody (params: QzoneUploadImageParams): string {
const { uin, skey, pskey, g_tk, base64 } = params;
const backUrls = `http://upbak.photo.qzone.qq.com/cgi-bin/upload/cgi_upload_image,http://119.147.64.75/cgi-bin/upload/cgi_upload_image&url=https://up.qzone.qq.com/cgi-bin/upload/cgi_upload_image?g_tk=${g_tk}`;
const query = new URLSearchParams({
filename: 'filename',
uin,
skey,
zzpaneluin: uin,
p_uin: uin,
p_skey: pskey,
uploadtype: '1',
albumtype: '7',
exttype: '0',
refer: 'shuoshuo',
output_type: 'jsonhtml',
charset: 'utf-8',
output_charset: 'utf-8',
upload_hd: '1',
hd_width: '2048',
hd_height: '10000',
hd_quality: '96',
backUrls,
base64: '1',
jsonhtml_callback: 'callback',
picfile: base64,
qzreferrer: `https://user.qzone.qq.com/${uin}/main`,
});
return query.toString();
}
export interface QzoneUploadImageData {
albumid: string;
lloc: string;
type: string;
height: string;
width: string;
url?: string;
}
export interface QzoneUploadImageRawResponse {
code?: number;
msg?: string;
data?: QzoneUploadImageData;
}
/**
* 解析上传图片接口返回的 `frameElement.callback({...});</script>` 包裹响应
*/
export function parseQzoneUploadImageResponseText (raw: string): QzoneUploadImageRawResponse {
const startTag = 'frameElement.callback';
const endTag = '</script>';
const startIdx = raw.indexOf(startTag);
const endIdx = raw.indexOf(endTag);
if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) {
throw new Error('QQ空间图片上传响应格式异常');
}
const segment = raw.slice(startIdx + startTag.length, endIdx);
const jsonStart = segment.indexOf('(');
const jsonEnd = segment.lastIndexOf(')');
if (jsonStart === -1 || jsonEnd === -1 || jsonEnd <= jsonStart) {
throw new Error('QQ空间图片上传响应解析失败');
}
const jsonText = segment.slice(jsonStart + 1, jsonEnd);
try {
return JSON.parse(jsonText) as QzoneUploadImageRawResponse;
} catch {
throw new Error('QQ空间图片上传响应JSON解析失败');
}
}
/**
* 依据上传图片返回数据拼接 richval 字段
* 格式: ,albumid,lloc,sloc,type,height,width,,height,width (sloc 与 lloc 一致)
*/
export function toQzoneRichval (data: QzoneUploadImageData): string {
const { albumid, lloc, type, height, width } = data;
return `,${albumid},${lloc},${lloc},${type},${height},${width},,${height},${width}`;
}
export interface QzonePublishParams {
hostuin: string;
content: string;
/** 多张图片使用 \t 拼接后的 richval,无图时不传 */
richval?: string;
ugcRight: number;
/** ugc_right 为 16/128 时必填,多个QQ号使用 | 拼接 */
allowUins?: string;
}
/**
* 构造发表说说的表单请求体 (application/x-www-form-urlencoded)
* 对齐 https://user.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_publish_v6
*/
export function buildQzonePublishBody (params: QzonePublishParams): string {
const { hostuin, content, richval, ugcRight, allowUins } = params;
const fields: Record<string, string> = {
syn_tweet_verson: '1',
paramstr: '1',
con: content,
feedversion: '1',
ver: '1',
ugc_right: String(ugcRight),
to_sign: '0',
hostuin,
code_version: '1',
format: 'json',
qzreferrer: `https://user.qzone.qq.com/${hostuin}/main`,
};
if (richval) {
fields['richtype'] = '1';
fields['richval'] = richval;
}
if (allowUins) {
fields['allow_uins'] = allowUins;
fields['who'] = '1';
}
return new URLSearchParams(fields).toString();
}
export interface QzonePublishResponse {
subcode?: number;
code?: number;
tid?: string;
t1_tid?: string;
t1_time?: string | number;
t1_ntime?: string | number;
now?: number;
message?: string;
msg?: string;
}
export function parseQzonePublishTime (value: unknown): number {
if (value === undefined || value === null) return 0;
const time = (typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value))) ? Number(value) : NaN;
if (!Number.isSafeInteger(time) || time < 0) throw new Error('QQ空间发表时间无效,结果需查询确认,勿自动重试');
return time;
}
/**
* 解析发表说说接口返回的 JSON, 提取 tid
*/
export function parseQzonePublishResponse (json: QzonePublishResponse): { tid: string; time: number; } {
for (const code of [json.subcode, json.code]) {
if (code !== undefined && code !== 0) throw new Error('QQ空间发说说失败,服务端拒绝请求');
}
const tid = json.t1_tid || json.tid;
if (!tid) {
throw new Error('QQ空间发说说失败, 未返回tid');
}
// t1_ntime is Unix seconds; current CGI t1_time is a display label such as "22:08".
const displayTime = typeof json.t1_time === 'string' && /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(json.t1_time);
const time = json.t1_ntime ?? (displayTime ? json.now : json.t1_time ?? json.now);
// SL uses zero when no machine-readable creation time is supplied, never the local clock.
return { tid, time: parseQzonePublishTime(time) };
}
export interface QzoneDeleteParams {
hostuin: string;
tid: string;
}
/**
* 构造删除说说的表单请求体 (application/x-www-form-urlencoded)
* 对齐 https://user.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_delete_v6
*/
export function buildQzoneDeleteBody (params: QzoneDeleteParams): string {
const { hostuin, tid } = params;
const fields: Record<string, string> = {
hostuin,
tid,
t1_source: '1',
code_version: '1',
format: 'json',
qzreferrer: `https://user.qzone.qq.com/${hostuin}`,
};
return new URLSearchParams(fields).toString();
}
export interface QzoneDeleteResponse {
subcode?: number;
code?: number;
message?: string;
msg?: string;
}
/**
* 解析删除说说接口返回的 JSON
*/
export function parseQzoneDeleteResponse (json: QzoneDeleteResponse): { success: true; } {
const subcode = json.subcode ?? json.code;
if (subcode !== undefined && subcode !== 0) {
throw new Error(json.message || json.msg || `QQ空间删说说失败, subcode=${subcode}`);
}
return { success: true };
}
+386
View File
@@ -0,0 +1,386 @@
export interface ControlReq {
appid?: string;
asy_upload?: number;
biz_req?: BizReq;
check_type?: number;
checksum?: string;
cmd?: string;
env?: Env;
file_len?: number;
model?: number;
session?: string;
token?: Token;
uin?: string;
}
export interface BizReq {
iAlbumTypeID: number;
iBatchID: number;
iBitmap: number;
iDistinctUse: number;
iNeedFeeds?: number;
iPicHight: number;
iPicWidth: number;
iUploadTime: number;
iUploadType: number;
iUpPicType: number;
iWaterType: number;
mapExt?: MapExt;
mutliPicInfo?: MutliPicInfo;
sAlbumID: string;
sAlbumName: string;
sPicDesc: string;
sPicPath: string;
sPicTitle: string;
stExtendInfo?: StExtendInfo;
// 视频主体上传专用
extend_info?: VideoExtendInfo;
iFlag?: number;
iIsFormatF20?: number;
iIsNew?: number;
iIsOriginalVideo?: number;
iPlayTime?: number;
sCoverUrl?: string;
sDesc?: string;
sTitle?: string;
// 视频封面二段上传专用
stExternalMapExt?: StExternalMapExt;
sExif_CameraMaker?: string;
sExif_CameraModel?: string;
sExif_Latitude?: string;
sExif_LatitudeRef?: string;
sExif_Longitude?: string;
sExif_LongitudeRef?: string;
sExif_Time?: string;
}
// 多图同批上传时描述「本批共几张 / 当前第几张」,相册里据此合并成同一条
export interface MutliPicInfo {
iBatUploadNum: string;
iCurUpload: string;
iSuccNum: number;
iFailNum: number;
}
// 视频主体上传用(视频不走 stExtendInfo.mapParams,而是这段扩展信息)
export interface VideoExtendInfo {
video_type: string;
domainid: string;
photo_num: string;
video_num: string;
batch_num: string;
qun_id: string;
}
// 视频封面二段上传用:声明这是客户端上传的封面,且与图片混合在同一条动态里
export interface StExternalMapExt {
is_client_upload_cover: string;
is_pic_video_mix_feeds: string;
}
export interface MapExt {
appid: string;
userid: string;
}
export interface StExtendInfo {
mapParams: MapParams;
}
export interface MapParams {
batch_num: string;
photo_num: string;
video_num: string;
// 视频封面二段上传时用它把封面挂到已上传的视频上
vid?: string;
}
export interface Env {
deviceInfo: string;
refer: string;
}
export interface Token {
appid: number;
data: string;
type: number;
}
// 一次上传的批次信息:同批的多张图必须共用 batchId,相册里才会显示成同一条。
export interface QunAlbumBatch {
batchId: number,
count: string,
index: string
}
let lastQunAlbumBatchId = 0;
// 批次号取秒级时间戳。同一秒里发起的两次上传要错开,不然会被相册并成同一条
export function nextQunAlbumBatchId () {
lastQunAlbumBatchId = Math.max(Math.floor(Date.now() / 1000), lastQunAlbumBatchId + 1);
return lastQunAlbumBatchId;
}
// control_req 的外层(鉴权、来源、会话),图片、视频、封面都一样
function buildQunAlbumControlReq (
{ uin, pskey, appid, checksum, check_type, file_len, refer = 'qzone', cmd }: {
uin: string,
pskey: string,
appid: string,
checksum: string,
check_type: number,
file_len: number,
refer?: string,
cmd?: string
},
biz_req: BizReq
): ControlReq {
const req: ControlReq = {
uin,
token: { type: 4, data: pskey, appid: 5 },
appid,
checksum,
check_type,
file_len,
env: { refer, deviceInfo: 'h5' },
model: 0,
biz_req,
session: '',
asy_upload: 0,
};
if (cmd) req.cmd = cmd;
return req;
}
// biz_req 里三种上传都要带的基础字段
function buildQunAlbumBizBase (
{ iUploadType, sAlbumName = '', sAlbumID = '', iBatchID = 0, sPicTitle = '' }: {
iUploadType: number,
sAlbumName?: string,
sAlbumID?: string,
iBatchID?: number,
sPicTitle?: string
}
): BizReq {
return {
sPicTitle,
sPicDesc: '',
sAlbumName,
sAlbumID,
iAlbumTypeID: 0,
iBitmap: 0,
iUploadType,
iUpPicType: 0,
iBatchID,
sPicPath: '',
iPicWidth: 0,
iPicHight: 0,
iWaterType: 0,
iDistinctUse: 0,
iUploadTime: Math.floor(Date.now() / 1000),
};
}
// 进相册的那一步(图片、视频封面)要带的群与批次信息
function buildQunAlbumFeedFields (group_id: string, mapParams: MapParams, batchIndex: string) {
return {
iNeedFeeds: 1,
mapExt: { appid: 'qun', userid: group_id },
stExtendInfo: { mapParams },
mutliPicInfo: {
iBatUploadNum: mapParams.batch_num,
iCurUpload: batchIndex,
iSuccNum: 0,
iFailNum: 0,
},
};
}
export function qunAlbumControl ({
uin,
group_id,
pskey,
pic_md5,
img_size,
img_name,
sAlbumName,
sAlbumID,
photo_num = '1',
video_num = '0',
batch_num = '1',
batchId = nextQunAlbumBatchId(),
batchIndex = '0',
}: {
uin: string,
group_id: string,
pskey: string,
pic_md5: string,
img_size: number,
img_name: string,
sAlbumName: string,
sAlbumID: string,
photo_num?: string,
video_num?: string,
batch_num?: string,
batchId?: number,
batchIndex?: string
}
): {
control_req: ControlReq[]
} {
return {
control_req: [buildQunAlbumControlReq(
{ uin, pskey, appid: 'qun', checksum: pic_md5, check_type: 0, file_len: img_size, cmd: 'FileUpload' },
{
...buildQunAlbumBizBase({ iUploadType: 0, sAlbumName, sAlbumID, iBatchID: batchId, sPicTitle: img_name }),
...buildQunAlbumFeedFields(group_id, { photo_num, video_num, batch_num }, batchIndex),
}
)],
};
}
export function createStreamUpload (
{
uin,
session,
offset,
seq,
end,
slice_size,
data,
}: { uin: string, session: string, offset: number, seq: number, end: number, slice_size: number, data: string }
) {
return {
uin,
appid: 'qun',
session,
offset, // 分片起始位置
data, // base64编码数据
checksum: '',
check_type: 0,
retry: 0, // 重试次数
seq, // 分片序号
end, // 分片结束位置 文件总大小
cmd: 'FileUpload',
slice_size, // 分片大小16KB 16384
biz_req: {
iUploadType: 3,
},
};
}
/**
* 视频主体上传的控制体。
*
* 视频这一步不进相册(相册留空,iBatchID 为 0),只是先把视频传成素材;
* 分片响应里拿到 sVid 后,再由封面上传(qunAlbumVideoCoverControl)把视频挂进相册。
*/
export function qunAlbumVideoControl ({
uin,
group_id,
pskey,
video_sha1,
video_size,
play_time = 0,
photo_num = '0',
video_num = '1',
batch_num = '1',
}: {
uin: string,
group_id: string,
pskey: string,
video_sha1: string,
video_size: number,
play_time?: number,
photo_num?: string,
video_num?: string,
batch_num?: string
}): {
control_req: ControlReq[]
} {
return {
control_req: [buildQunAlbumControlReq(
{ uin, pskey, appid: 'video_qun', checksum: video_sha1, check_type: 1, file_len: video_size, cmd: 'FileUploadVideo' },
{
...buildQunAlbumBizBase({ iUploadType: 3 }),
sTitle: '',
sDesc: '',
sCoverUrl: '',
iFlag: 0,
iPlayTime: play_time,
iIsNew: 111,
iIsOriginalVideo: 0,
iIsFormatF20: 0,
extend_info: {
video_type: '3',
domainid: '5',
photo_num,
video_num,
batch_num,
qun_id: group_id,
},
}
)],
};
}
/**
* 视频封面上传的控制体。
*
* 按图片上传,但 iUploadType 为 2,并用 mapParams.vid 指向已上传的视频。
* 相册和批次号只在这一步出现,视频是靠封面进的相册。
*/
export function qunAlbumVideoCoverControl ({
uin,
group_id,
pskey,
sAlbumName,
sAlbumID,
cover_md5,
cover_size,
vid,
batchId = nextQunAlbumBatchId(),
batchIndex = '0',
photo_num = '0',
video_num = '1',
batch_num = '1',
}: {
uin: string,
group_id: string,
pskey: string,
sAlbumName: string,
sAlbumID: string,
cover_md5: string,
cover_size: number,
vid: string,
batchId?: number,
batchIndex?: string,
photo_num?: string,
video_num?: string,
batch_num?: string
}): {
control_req: ControlReq[]
} {
return {
control_req: [buildQunAlbumControlReq(
{ uin, pskey, appid: 'qun', checksum: cover_md5, check_type: 0, file_len: cover_size, refer: 'huodong' },
{
...buildQunAlbumBizBase({ iUploadType: 2, sAlbumName, sAlbumID, iBatchID: batchId }),
...buildQunAlbumFeedFields(group_id, { vid, photo_num, video_num, batch_num }, batchIndex),
// 标明封面由客户端上传,且允许和图片出现在同一条动态里
stExternalMapExt: {
is_client_upload_cover: '1',
is_pic_video_mix_feeds: '1',
},
sExif_CameraMaker: '',
sExif_CameraModel: '',
sExif_Time: '',
sExif_LatitudeRef: '',
sExif_Latitude: '',
sExif_LongitudeRef: '',
sExif_Longitude: '',
}
)],
};
}
+790
View File
@@ -0,0 +1,790 @@
{
"9.9.15-28060": {
"appid": 537246092,
"qua": "V1_WIN_NQ_9.9.15_28060_GW_B"
},
"9.9.15-28131": {
"appid": 537246092,
"qua": "V1_WIN_NQ_9.9.15_28131_GW_B"
},
"3.2.12-28060": {
"appid": 537246140,
"qua": "V1_LNX_NQ_3.2.12_28060_GW_B"
},
"3.2.12-28131": {
"appid": 537246140,
"qua": "V1_LNX_NQ_3.2.12_28131_GW_B"
},
"6.9.55-28131": {
"appid": 537246115,
"qua": "V1_MAC_NQ_6.9.55_28131_GW_B"
},
"9.9.15-28327": {
"appid": 537249321,
"qua": "V1_WIN_NQ_9.9.15_28327_GW_B"
},
"3.2.12-28327": {
"appid": 537249393,
"qua": "V1_LNX_NQ_3.2.12_28327_GW_B"
},
"9.9.15-28418": {
"appid": 537249321,
"qua": "V1_WIN_NQ_9.9.15_28418_GW_B"
},
"3.2.12-28418": {
"appid": 537249393,
"qua": "V1_LNX_NQ_3.2.12_28418_GW_B"
},
"6.9.56-28418": {
"appid": 537249367,
"qua": "V1_MAC_NQ_6.9.56_28418_GW_B"
},
"9.9.15-28498": {
"appid": 537249321,
"qua": "V1_WIN_NQ_9.9.15_28498_GW_B"
},
"3.2.13-28788": {
"appid": 537249787,
"qua": "V1_LNX_NQ_3.2.13_28788_GW_B"
},
"9.9.16-28788": {
"appid": 537249739,
"qua": "V1_WIN_NQ_9.9.16_28788_GW_B"
},
"9.9.16-28971": {
"appid": 537249775,
"qua": "V1_WIN_NQ_9.9.16_28971_GW_B"
},
"3.2.13-28971": {
"appid": 537249848,
"qua": "V1_LNX_NQ_3.2.13_28971_GW_B"
},
"6.9.58-28971": {
"appid": 537249826,
"qua": "V1_MAC_NQ_6.9.58_28971_GW_B"
},
"9.9.16-29271": {
"appid": 537249813,
"qua": "V1_WIN_NQ_9.9.16_29271_GW_B"
},
"3.2.13-29271": {
"appid": 537249913,
"qua": "V1_LNX_NQ_3.2.13_29271_GW_B"
},
"6.9.59-29271": {
"appid": 537249863,
"qua": "V1_MAC_NQ_6.9.59_29271_GW_B"
},
"9.9.16-29456": {
"appid": 537249875,
"qua": "V1_WIN_NQ_9.9.16_29456_GW_B"
},
"3.2.13-29456": {
"appid": 537249996,
"qua": "V1_LNX_NQ_3.2.13_29456_GW_B"
},
"6.9.59-29456": {
"appid": 537249961,
"qua": "V1_MAC_NQ_6.9.59_29456_GW_B"
},
"9.9.16-29927": {
"appid": 537255812,
"qua": "V1_WIN_NQ_9.9.16_29927_GW_B"
},
"3.2.13-29927": {
"appid": 537255847,
"qua": "V1_LNX_NQ_3.2.13_29927_GW_B"
},
"6.9.61-29927": {
"appid": 537255836,
"qua": "V1_MAC_NQ_6.9.61_29927_GW_B"
},
"9.9.17-30366": {
"appid": 537258389,
"qua": "V1_WIN_NQ_9.9.17_30366_GW_B"
},
"3.2.15-30366": {
"appid": 537258413,
"qua": "V1_LNX_NQ_3.2.15_30366_GW_B"
},
"6.9.62-30366": {
"appid": 537258401,
"qua": "V1_MAC_NQ_6.9.62_30366_GW_B"
},
"9.9.17-30483": {
"appid": 537258439,
"qua": "V1_WIN_NQ_9.9.17_30483_GW_B"
},
"6.9.62-30483": {
"appid": 537258463,
"qua": "V1_MAC_NQ_6.9.62_30483_GW_B"
},
"3.2.15-30483": {
"appid": 537258474,
"qua": "V1_LNX_NQ_3.2.15_30483_GW_B"
},
"9.9.17-30594": {
"appid": 537258439,
"qua": "V1_WIN_NQ_9.9.17_30594_GW_B"
},
"6.9.62-30594": {
"appid": 537258463,
"qua": "V1_MAC_NQ_6.9.62_30594_GW_B"
},
"3.2.15-30594": {
"appid": 537258474,
"qua": "V1_LNX_NQ_3.2.15_30594_GW_B"
},
"9.9.17-30851": {
"appid": 537263796,
"qua": "V1_WIN_NQ_9.9.17_30851_GW_B"
},
"3.2.15-30851": {
"appid": 537263831,
"qua": "V1_LNX_NQ_3.2.15_30851_GW_B"
},
"6.9.63-30851": {
"appid": 537263820,
"qua": "V1_MAC_NQ_6.9.63_30851_GW_B"
},
"9.9.17-30899": {
"appid": 537263796,
"qua": "V1_WIN_NQ_9.9.17_30899_GW_B"
},
"3.2.15-30899": {
"appid": 537263831,
"qua": "V1_LNX_NQ_3.2.15_30899_GW_B"
},
"6.9.63-30899": {
"appid": 537263820,
"qua": "V1_MAC_NQ_6.9.63_30899_GW_B"
},
"9.9.17-31219": {
"appid": 537266450,
"qua": "V1_WIN_NQ_9.9.17_31219_GW_B"
},
"9.9.17-31245": {
"appid": 537266450,
"qua": "V1_WIN_NQ_9.9.17_31245_GW_B"
},
"3.2.15-31245": {
"appid": 537266485,
"qua": "V1_LNX_NQ_3.2.15_31245_GW_B"
},
"6.9.63-31245": {
"appid": 537266474,
"qua": "V1_MAC_NQ_6.9.63_31245_GW_B"
},
"3.2.15-31363": {
"appid": 537266535,
"qua": "V1_LNX_NQ_3.2.15_31363_GW_B"
},
"6.9.65-31363": {
"appid": 537266524,
"qua": "V1_MAC_NQ_6.9.65_31363_GW_B"
},
"9.9.17-31363": {
"appid": 537266500,
"qua": "V1_WIN_NQ_9.9.17_31363_GW_B"
},
"3.2.16-32690": {
"appid": 537271229,
"qua": "V1_LNX_NQ_3.2.16_32690_GW_B"
},
"9.9.18-32690": {
"appid": 537271194,
"qua": "V1_WIN_NQ_9.9.18_32690_GW_B"
},
"6.9.66-32690": {
"appid": 537271218,
"qua": "V1_MAC_NQ_6.9.66_32690_GW_B"
},
"3.2.16-32721": {
"appid": 537271229,
"qua": "V1_LNX_NQ_3.2.16_32721_GW_B"
},
"9.9.18-32793": {
"appid": 537271244,
"qua": "V1_WIN_NQ_9.9.18_32793_GW_B"
},
"3.2.16-32793": {
"appid": 537271279,
"qua": "V1_LNX_NQ_3.2.16_32793_GW_B"
},
"3.2.16-32869": {
"appid": 537271329,
"qua": "V1_LNX_NQ_3.2.16_32869_GW_B"
},
"9.9.18-32869": {
"appid": 537271294,
"qua": "V1_WIN_NQ_9.9.18_32869_GW_B"
},
"3.2.16-33139": {
"appid": 537273909,
"qua": "V1_LNX_NQ_3.2.16_33139_GW_B"
},
"9.9.18-33139": {
"appid": 537273874,
"qua": "V1_WIN_NQ_9.9.18_33139_GW_B"
},
"9.9.18-33800": {
"appid": 537273974,
"qua": "V1_WIN_NQ_9.9.18_33800_GW_B"
},
"3.2.16-33800": {
"appid": 537274009,
"qua": "V1_LNX_NQ_3.2.16_33800_GW_B"
},
"9.9.19-34231": {
"appid": 537279209,
"qua": "V1_WIN_NQ_9.9.19_34231_GW_B"
},
"3.2.17-34231": {
"appid": 537279245,
"qua": "V1_LNX_NQ_3.2.17_34231_GW_B"
},
"9.9.19-34362": {
"appid": 537279260,
"qua": "V1_WIN_NQ_9.9.19_34362_GW_B"
},
"3.2.17-34362": {
"appid": 537279296,
"qua": "V1_LNX_NQ_3.2.17_34362_GW_B"
},
"9.9.19-34467": {
"appid": 537282256,
"qua": "V1_WIN_NQ_9.9.19_34467_GW_B"
},
"3.2.17-34467": {
"appid": 537282292,
"qua": "V1_LNX_NQ_3.2.17_34467_GW_B"
},
"9.9.19-34566": {
"appid": 537282307,
"qua": "V1_WIN_NQ_9.9.19_34566_GW_B"
},
"3.2.17-34566": {
"appid": 537282343,
"qua": "V1_LNX_NQ_3.2.17_34566_GW_B"
},
"3.2.17-34606": {
"appid": 537282343,
"qua": "V1_LNX_NQ_3.2.17_34606_GW_B"
},
"9.9.19-34606": {
"appid": 537282307,
"qua": "V1_WIN_NQ_9.9.19_34606_GW_B"
},
"9.9.19-34740": {
"appid": 537290691,
"qua": "V1_WIN_NQ_9.9.19_34740_GW_B"
},
"3.2.17-34740": {
"appid": 537290727,
"qua": "V1_LNX_NQ_3.2.17_34740_GW_B"
},
"9.9.19-34958": {
"appid": 537290742,
"qua": "V1_WIN_NQ_9.9.19_34958_GW_B"
},
"3.2.17-35184": {
"appid": 537291084,
"qua": "V1_LNX_NQ_3.2.17_35184_GW_B"
},
"9.9.19-35184": {
"appid": 537291048,
"qua": "V1_WIN_NQ_9.9.19_35184_GW_B"
},
"3.2.17-35341": {
"appid": 537291383,
"qua": "V1_LNX_NQ_3.2.17_35341_GW_B"
},
"9.9.19-35341": {
"appid": 537291347,
"qua": "V1_WIN_NQ_9.9.19_35341_GW_B"
},
"9.9.19-35469": {
"appid": 537291398,
"qua": "V1_WIN_NQ_9.9.19_35469_GW_B"
},
"3.2.18-35951": {
"appid": 537296013,
"qua": "V1_LNX_NQ_3.2.18_35951_GW_B"
},
"9.9.20-35951": {
"appid": 537295977,
"qua": "V1_WIN_NQ_9.9.20_35951_GW_B"
},
"3.2.18-36580": {
"appid": 537298509,
"qua": "V1_LNX_NQ_3.2.18_36580_GW_B"
},
"9.9.20-36580": {
"appid": 537298473,
"qua": "V1_WIN_NQ_9.9.20_36580_GW_B"
},
"9.9.20-37012": {
"appid": 537304071,
"qua": "V1_WIN_NQ_9.9.20_37012_GW_B"
},
"3.2.18-37012": {
"appid": 537304107,
"qua": "V1_LNX_NQ_3.2.18_37012_GW_B"
},
"3.2.18-37051": {
"appid": 537304158,
"qua": "V1_LNX_NQ_3.2.18_37051_GW_B"
},
"9.9.20-37051": {
"appid": 537304122,
"qua": "V1_WIN_NQ_9.9.20_37051_GW_B"
},
"9.9.20-37475": {
"appid": 537304173,
"qua": "V1_WIN_NQ_9.9.20_37475_GW_B"
},
"3.2.18-37475": {
"appid": 537304210,
"qua": "V1_LNX_NQ_3.2.18_37475_GW_B"
},
"9.9.20-37625": {
"appid": 537304224,
"qua": "V1_WIN_NQ_9.9.20_37625_GW_B"
},
"3.2.18-37625": {
"appid": 537304261,
"qua": "V1_LNX_NQ_3.2.18_37625_GW_B"
},
"9.9.21-38503": {
"appid": 537307604,
"qua": "V1_WIN_NQ_9.9.21_38503_GW_B"
},
"3.2.19-38503": {
"appid": 537307640,
"qua": "V1_LNX_NQ_3.2.19_38503_GW_B"
},
"3.2.19-38626": {
"appid": 537307691,
"qua": "V1_LNX_NQ_3.2.19_38626_GW_B"
},
"9.9.21-38711": {
"appid": 537307655,
"qua": "V1_WIN_NQ_9.9.21_38626_GW_B"
},
"9.9.21-38960": {
"appid": 537313855,
"qua": "V1_WIN_NQ_9.9.21_38960_GW_B"
},
"3.2.19-38960": {
"appid": 537313891,
"qua": "V1_LNX_NQ_3.2.19_38960_GW_B"
},
"3.2.19-39038": {
"appid": 537313942,
"qua": "V1_LNX_NQ_3.2.19_39038_GW_B"
},
"9.9.21-39038": {
"appid": 537313906,
"qua": "V1_WIN_NQ_9.9.21_39038_GW_B"
},
"9.9.22-40362": {
"appid": 537314212,
"qua": "V1_WIN_NQ_9.9.22_40362_GW_B"
},
"3.2.20-40768": {
"appid": 537319840,
"qua": "V1_LNX_NQ_3.2.20_40768_GW_B"
},
"3.2.20-40768-arm64": {
"appid": 537319841,
"qua": "V1_LNX_NQ_3.2.20_40768_GW_B"
},
"9.9.22-40768": {
"appid": 537319804,
"qua": "V1_WIN_NQ_9.9.22_40768_GW_B"
},
"6.9.82-40768": {
"appid": 537319828,
"qua": "V1_MAC_NQ_6.9.82_40768_GW_B"
},
"3.2.20-40824": {
"appid": 537319840,
"qua": "V1_LNX_NQ_3.2.20_40824_GW_B"
},
"9.9.22-40824": {
"appid": 537319804,
"qua": "V1_WIN_NQ_9.9.22_40824_GW_B"
},
"6.9.82-40824": {
"appid": 537319829,
"qua": "V1_MAC_NQ_6.9.82_40824_GW_B"
},
"6.9.82-40990": {
"appid": 537319879,
"qua": "V1_MAC_NQ_6.9.82_40990_GW_B"
},
"9.9.22-40990": {
"appid": 537319855,
"qua": "V1_WIN_NQ_9.9.22_40990_GW_B"
},
"3.2.20-40990": {
"appid": 537319891,
"qua": "V1_LNX_NQ_3.2.20_40990_GW_B"
},
"3.2.20-40990-arm64": {
"appid": 537319892,
"qua": "V1_LNX_NQ_3.2.20_40990_GW_B"
},
"9.9.23-41679": {
"appid": 537320110,
"qua": "V1_WIN_NQ_9.9.23_41679_GW_B"
},
"6.9.83-41679": {
"appid": 537320134,
"qua": "V1_MAC_NQ_6.9.83_41679_GW_B"
},
"9.9.23-41785": {
"appid": 537320110,
"qua": "V1_WIN_NQ_9.9.23_41785_GW_B"
},
"6.9.83-41785": {
"appid": 537320134,
"qua": "V1_MAC_NQ_6.9.83_41785_GW_B"
},
"9.9.23-41857": {
"appid": 537320161,
"qua": "V1_WIN_NQ_9.9.23_41857_GW_B"
},
"3.2.21-41857": {
"appid": 537320197,
"qua": "V1_LNX_NQ_3.2.21_41857_GW_B"
},
"3.2.21-41857-arm64": {
"appid": 537320198,
"qua": "V1_LNX_NQ_3.2.21_41857_GW_B"
},
"6.9.83-41857": {
"appid": 537320185,
"qua": "V1_MAC_NQ_6.9.83_41857_GW_B"
},
"3.2.21-42086": {
"appid": 537320248,
"qua": "V1_LNX_NQ_3.2.21_42086_GW_B"
},
"3.2.21-42086-arm64": {
"appid": 537320249,
"qua": "V1_LNX_NQ_3.2.21_42086_GW_B"
},
"9.9.23-42086": {
"appid": 537320212,
"qua": "V1_WIN_NQ_9.9.23_42086_GW_B"
},
"6.9.85-42086": {
"appid": 537320236,
"qua": "V1_MAC_NQ_6.9.85_42086_GW_B"
},
"9.9.23-42430": {
"appid": 537320212,
"qua": "V1_WIN_NQ_9.9.23_42430_GW_B"
},
"9.9.25-42744": {
"appid": 537328470,
"qua": "V1_WIN_NQ_9.9.25_42744_GW_B"
},
"6.9.86-42744": {
"appid": 537328494,
"qua": "V1_MAC_NQ_6.9.86_42744_GW_B"
},
"9.9.25-42905": {
"appid": 537328521,
"qua": "V1_WIN_NQ_9.9.25_42905_GW_B"
},
"6.9.86-42905": {
"appid": 537328545,
"qua": "V1_MAC_NQ_6.9.86_42905_GW_B"
},
"3.2.22-42941": {
"appid": 537328659,
"qua": "V1_LNX_NQ_3.2.22_42941_GW_B"
},
"3.2.22-42941-arm64": {
"appid": 537328660,
"qua": "V1_LNX_NQ_3.2.22_42941_GW_B"
},
"9.9.25-42941": {
"appid": 537328623,
"qua": "V1_WIN_NQ_9.9.25_42941_GW_B"
},
"6.9.86-42941": {
"appid": 537328647,
"qua": "V1_MAC_NQ_6.9.86_42941_GW_B"
},
"9.9.26-44175": {
"appid": 537336450,
"qua": "V1_WIN_NQ_9.9.26_44175_GW_B"
},
"9.9.26-44343": {
"appid": 537336603,
"qua": "V1_WIN_NQ_9.9.26_44343_GW_B"
},
"3.2.23-44343": {
"appid": 537336639,
"qua": "V1_LNX_NQ_3.2.23_44343_GW_B"
},
"3.2.23-44343-arm64": {
"appid": 537336640,
"qua": "V1_LNX_NQ_3.2.23_44343_GW_B"
},
"6.9.87-44343": {
"appid": 537336627,
"qua": "V1_MAC_NQ_6.9.87_44343_GW_B"
},
"9.9.26-44498": {
"appid": 537337416,
"qua": "V1_WIN_NQ_9.9.26_44498_GW_B"
},
"9.9.26-44725": {
"appid": 537337569,
"qua": "V1_WIN_NQ_9.9.26_44725_GW_B"
},
"9.9.27-45627": {
"appid": 537340060,
"qua": "V1_WIN_NQ_9.9.27_45627_GW_B"
},
"6.9.88-44725": {
"appid": 537337593,
"qua": "V1_MAC_NQ_6.9.88_44725_GW_B"
},
"3.2.25-45758": {
"appid": 537340249,
"qua": "V1_LNX_NQ_3.2.25_45758_GW_B"
},
"3.2.25-45758-arm64": {
"appid": 537340250,
"qua": "V1_LNX_NQ_3.2.25_45758_GW_B"
},
"6.9.89-45758": {
"appid": 537340237,
"qua": "V1_MAC_NQ_6.9.89_45758_GW_B"
},
"9.9.27-45758": {
"appid": 537340213,
"qua": "V1_WIN_NQ_9.9.27_45758_GW_B"
},
"3.2.26-46494": {
"appid": 537345891,
"qua": "V1_LNX_NQ_3.2.26_46494_GW_B"
},
"3.2.26-46494-arm64": {
"appid": 537345892,
"qua": "V1_LNX_NQ_3.2.26_46494_GW_B"
},
"9.9.28-46494": {
"appid": 537345855,
"qua": "V1_WIN_NQ_9.9.28_46494_GW_B"
},
"6.9.90-46494": {
"appid": 537345879,
"qua": "V1_MAC_NQ_6.9.90_46494_GW_B"
},
"3.2.26-46928": {
"appid": 537345993,
"qua": "V1_LNX_NQ_3.2.26_46928_GW_B"
},
"3.2.26-46928-arm64": {
"appid": 537345994,
"qua": "V1_LNX_NQ_3.2.26_46928_GW_B"
},
"6.9.91-46928": {
"appid": 537345981,
"qua": "V1_MAC_NQ_6.9.91_46928_GW_B"
},
"9.9.28-46928": {
"appid": 537345957,
"qua": "V1_WIN_NQ_9.9.28_46928_GW_B"
},
"3.2.27-47256": {
"appid": 537346857,
"qua": "V1_LNX_NQ_3.2.27_47256_GW_B"
},
"3.2.27-47354": {
"appid": 537346908,
"qua": "V1_LNX_NQ_3.2.27_47354_GW_B"
},
"9.9.29-47354": {
"appid": 537346872,
"qua": "V1_WIN_NQ_9.9.29_47354_GW_B"
},
"6.9.93-47354": {
"appid": 537346896,
"qua": "V1_MAC_NQ_6.9.93_47354_GW_B"
},
"9.9.30-48517": {
"appid": 537352474,
"qua": "V1_WIN_NQ_9.9.30_48517_GW_B"
},
"3.2.28-48517": {
"appid": 537352510,
"qua": "V1_LNX_NQ_3.2.28_48517_GW_B"
},
"3.2.28-48517-arm64": {
"appid": 537352511,
"qua": "V1_LNX_NQ_3.2.28_48517_GW_B"
},
"6.9.95-48517": {
"appid": 537352498,
"qua": "V1_MAC_NQ_6.9.95_48517_GW_B"
},
"9.9.30-48762": {
"appid": 537352525,
"qua": "V1_WIN_NQ_9.9.30_48762_GW_B"
},
"9.9.31-49599": {
"appid": 537355779,
"qua": "V1_WIN_NQ_9.9.31_49599_GW_B"
},
"9.9.31-49738": {
"appid": 537355830,
"qua": "V1_WIN_NQ_9.9.31_49738_GW_B"
},
"3.2.29-49738": {
"appid": 537355866,
"qua": "V1_LNX_NQ_3.2.29_49738_GW_B"
},
"3.2.29-49738-arm64": {
"appid": 537355867,
"qua": "V1_LNX_NQ_3.2.29_49738_GW_B"
},
"6.9.96-49738": {
"appid": 537355854,
"qua": "V1_MAC_NQ_6.9.96_49738_GW_B"
},
"9.9.32-50828": {
"appid": 537358739,
"qua": "V1_WIN_NQ_9.9.32_50828_GW_B"
},
"3.2.30-50828": {
"appid": 537358775,
"qua": "V1_LNX_NQ_3.2.30_50828_GW_B"
},
"3.2.30-50828-arm64": {
"appid": 537358776,
"qua": "V1_LNX_NQ_3.2.30_50828_GW_B"
},
"9.9.32-50969": {
"appid": 537376308,
"qua": "V1_WIN_NQ_9.9.32_50969_GW_B"
},
"3.2.30-50969": {
"appid": 537376344,
"qua": "V1_LNX_NQ_3.2.30_50969_GW_B"
},
"3.2.30-50969-arm64": {
"appid": 537376345,
"qua": "V1_LNX_NQ_3.2.30_50969_GW_B"
},
"3.2.31-51102": {
"appid": 537376497,
"qua": "V1_LNX_NQ_3.2.31_51102_GW_B"
},
"3.2.31-51102-arm64": {
"appid": 537376498,
"qua": "V1_LNX_NQ_3.2.31_51102_GW_B"
},
"6.9.98-51102": {
"appid": 537376485,
"qua": "V1_MAC_NQ_6.9.98_51102_GW_B"
},
"9.9.32-51246": {
"appid": 537376512,
"qua": "V1_WIN_NQ_9.9.32_51246_GW_B"
},
"3.2.32-51728": {
"appid": 537376803,
"qua": "V1_LNX_NQ_3.2.32_51728_GW_B"
},
"3.2.32-51728-arm64": {
"appid": 537376804,
"qua": "V1_LNX_NQ_3.2.32_51728_GW_B"
},
"3.2.32-51802": {
"appid": 537376854,
"qua": "V1_LNX_NQ_3.2.32_51802_GW_B"
},
"3.2.32-51802-arm64": {
"appid": 537376855,
"qua": "V1_LNX_NQ_3.2.32_51802_GW_B"
},
"6.9.99-51802": {
"appid": 537376842,
"qua": "V1_MAC_NQ_6.9.99_51802_GW_B"
},
"9.9.33-51802": {
"appid": 537376818,
"qua": "V1_WIN_NQ_9.9.33_51802_GW_B"
},
"9.9.33-52230": {
"appid": 537379411,
"qua": "V1_WIN_NQ_9.9.33_52230_GW_B"
},
"3.2.32-52194": {
"appid": 537379447,
"qua": "V1_LNX_NQ_3.2.32_52194_GW_B"
},
"3.2.32-52194-arm64": {
"appid": 537379448,
"qua": "V1_LNX_NQ_3.2.32_52194_GW_B"
},
"7.0.0-52194": {
"appid": 537379435,
"qua": "V1_MAC_NQ_7.0.0_52194_GW_B"
},
"9.9.35-52804": {
"appid": 537382717,
"qua": "V1_WIN_NQ_9.9.35_52804_GW_B"
},
"9.9.35-52892": {
"appid": 537382819,
"qua": "V1_WIN_NQ_9.9.35_52892_GW_B"
},
"9.9.36-53489": {
"appid": 537391475,
"qua": "V1_WIN_NQ_9.9.36_53489_GW_B"
},
"3.2.33-52892": {
"appid": 537382855,
"qua": "V1_LNX_NQ_3.2.33_52892_GW_B"
},
"3.2.33-52892-arm64": {
"appid": 537382856,
"qua": "V1_LNX_NQ_3.2.33_52892_GW_B"
},
"7.0.1-52892": {
"appid": 537382843,
"qua": "V1_MAC_NQ_7.0.1_52892_GW_B"
},
"3.2.34-53644": {
"appid": 537391664,
"qua": "V1_LNX_NQ_3.2.34_53644_GW_B"
},
"3.2.34-53644-arm64": {
"appid": 537391665,
"qua": "V1_LNX_NQ_3.2.34_53644_GW_B"
},
"7.0.2-53644": {
"appid": 537391652,
"qua": "V1_MAC_NQ_7.0.2_53644_GW_B"
},
"9.9.36-53644": {
"appid": 537391628,
"qua": "V1_WIN_NQ_9.9.36_53644_GW_B"
},
"9.9.37-54276": {
"appid": 537403570,
"qua": "V1_WIN_NQ_9.9.37_54276_GW_B"
},
"7.0.3-54276": {
"appid": 537403594,
"qua": "V1_MAC_NQ_7.0.3_54276_GW_B"
}
}
File diff suppressed because it is too large. Load diff
+17
View File
@@ -0,0 +1,17 @@
{
"fileLog": false,
"consoleLog": true,
"fileLogLevel": "debug",
"consoleLogLevel": "info",
"packetBackend": "auto",
"packetServer": "",
"o3HookMode": 1,
"bypass": {
"hook": false,
"window": false,
"module": false,
"process": false,
"container": false,
"js": false
}
}
+498
View File
@@ -0,0 +1,498 @@
{
"9.9.22-40990-x64": {
"send": "1B5699C",
"recv": "1D8CA9D"
},
"9.9.22-40824-x64": {
"send": "1B5699C",
"recv": "1D8CA9D"
},
"9.9.22-40768-x64": {
"send": "1B5699C",
"recv": "1D8CA9D"
},
"3.2.20-40768-x64": {
"send": "2A1B840",
"recv": "2D28F20"
},
"3.2.20-40824-x64": {
"send": "2A1B840",
"recv": "2D28F20"
},
"3.2.20-40990-x64": {
"send": "2A1B840",
"recv": "2D28F20"
},
"3.2.20-40990-arm64": {
"send": "157C0E8",
"recv": "1546658"
},
"3.2.20-40824-arm64": {
"send": "157C0E8",
"recv": "1546658"
},
"3.2.20-40768-arm64": {
"send": "157C0E8",
"recv": "1546658"
},
"9.9.23-41679-x64": {
"send": "09FF0F4",
"recv": "1D1A039"
},
"6.9.82-40824-arm64": {
"send": "05FA930",
"recv": "0B41B90"
},
"6.9.82-40768-arm64": {
"send": "05FA930",
"recv": "0B41B90"
},
"6.9.82-40990-arm64": {
"send": "05FA930",
"recv": "0B41B90"
},
"6.9.83-41679-arm64": {
"send": "237D114",
"recv": "0957648"
},
"6.9.83-41785-arm64": {
"send": "23B0BF0",
"recv": "095567C"
},
"9.9.23-41785-x64": {
"send": "09FF0A4",
"recv": "1D19FF9"
},
"6.9.83-41857-arm64": {
"send": "23B41F0",
"recv": "0958B3C"
},
"3.2.21-41857-x64": {
"send": "5B44510",
"recv": "2FDB0B0"
},
"3.2.21-41857-arm64": {
"send": "3D6EE6C",
"recv": "1479EDC"
},
"9.9.23-41857-x64": {
"send": "0A01394",
"recv": "1D1C4F9"
},
"9.9.23-42086-x64": {
"send": "0A01814",
"recv": "1D1C9B9"
},
"6.9.85-42086-arm64": {
"send": "23B0330",
"recv": "9556B8"
},
"3.2.21-42086-arm64": {
"send": "3D6D98C",
"recv": "14797C8"
},
"3.2.21-42086-x64": {
"send": "5B42CF0",
"recv": "2FDA6F0"
},
"9.9.23-42430-x64": {
"send": "0A01A34",
"recv": "1D1CFF9"
},
"9.9.25-42744-x64": {
"send": "0A0D104",
"recv": "1D3E7F9"
},
"6.9.86-42744-arm64": {
"send": "23DFEF0",
"recv": "095FD80"
},
"9.9.25-42905-x64": {
"send": "0A12E74",
"recv": "1D450FD"
},
"6.9.86-42905-arm64": {
"send": "2342408",
"recv": "09639B8"
},
"3.2.22-42941-x64": {
"send": "5BC1630",
"recv": "3011E00"
},
"3.2.22-42941-arm64": {
"send": "3DC90AC",
"recv": "1497A70"
},
"9.9.25-42941-x64": {
"send": "0A131D4",
"recv": "1D4547D"
},
"6.9.86-42941-arm64": {
"send": "2346108",
"recv": "09675F0"
},
"9.9.26-44175-x64": {
"send": "0A0F2EC",
"recv": "1D3AD4D"
},
"9.9.26-44343-x64": {
"send": "0A0F7BC",
"recv": "1D3C3CD"
},
"3.2.23-44343-arm64": {
"send": "3C867DC",
"recv": "1404938"
},
"3.2.23-44343-x64": {
"send": "59A27B0",
"recv": "2FFBE90"
},
"6.9.87-44343-arm64": {
"send": "2311B68",
"recv": "9673A4"
},
"9.9.26-44498-x64": {
"send": "0A1051C",
"recv": "1D3BC0D"
},
"9.9.26-44725-x64": {
"send": "0A18D0C",
"recv": "1D4BF0D"
},
"9.9.27-45627-x64": {
"send": "0A697CC",
"recv": "1E86AC1"
},
"6.9.88-44725-x64": {
"send": "2756EF6",
"recv": "A36152"
},
"6.9.88-44725-arm64": {
"send": "2313C68",
"recv": "09693E4"
},
"3.2.25-45758-x64": {
"send": "5CF8F30",
"recv": "31667E0"
},
"3.2.25-45758-arm64": {
"send": "3EB6554",
"recv": "149AF0C"
},
"6.9.89-45758-arm64": {
"send": "2425228",
"recv": "9B6770"
},
"9.9.27-45758-x64": {
"send": "0A6A6BC",
"recv": "1E87EC1"
},
"9.9.28-46494-x64": {
"send": "0A6D66C",
"recv": "1E9890D"
},
"3.2.26-46494-x64": {
"send": "5D30910",
"recv": "31814B0"
},
"3.2.26-46494-arm64": {
"send": "3EDC654",
"recv": "14A3124"
},
"6.9.90-46494-arm64": {
"send": "2438B4C",
"recv": "9BBE00"
},
"3.2.26-46928-x64": {
"send": "5D30910",
"recv": "31814B0"
},
"3.2.26-46928-arm64": {
"send": "3EDC654",
"recv": "14A3124"
},
"6.9.91-46928-arm64": {
"send": "2438B4C",
"recv": "9BBE00"
},
"9.9.28-46928-x64": {
"send": "0A6D66C",
"recv": "1E9890D"
},
"3.2.27-47256-x64": {
"send": "5D389C0",
"recv": "318D090"
},
"3.2.27-47354-x64": {
"send": "5D38930",
"recv": "318D090"
},
"3.2.27-47354-arm64": {
"send": "3EE8B24",
"recv": "14D422C"
},
"9.9.29-47354-x64": {
"send": "0A70B3C",
"recv": "1EAFD51"
},
"6.9.93-47354-arm64": {
"send": "244066C",
"recv": "09BCCF8"
},
"6.9.93-47354-x64": {
"send": "28ADE46",
"recv": "A8FD5A"
},
"9.9.30-48517-x64": {
"send": "0A9B1BC",
"recv": "1F0324D"
},
"3.2.28-48517-x64": {
"send": "5E41890",
"recv": "3216D10"
},
"3.2.28-48517-arm64": {
"send": "3F9C7E4",
"recv": "14E1AA8"
},
"6.9.95-48517-arm64": {
"send": "25120A8",
"recv": "9DB00C"
},
"9.9.30-48762-x64": {
"send": "0A9B1BC",
"recv": "1F0324D"
},
"9.9.31-49599-x64": {
"send": "0AF885C",
"recv": "20058E1"
},
"9.9.31-49738-x64": {
"send": "0AF9FCC",
"recv": "2007901"
},
"3.2.29-49738-x64": {
"send": "6234600",
"recv": "33D89D0"
},
"3.2.29-49738-arm64": {
"send": "426AA04",
"recv": "1596254"
},
"6.9.96-49738-arm64": {
"send": "2785BB8",
"recv": "A29880"
},
"9.9.32-50828-x64": {
"send": "0B245CC",
"recv": "2073D65"
},
"3.2.30-50828-x64": {
"send": "6340530",
"recv": "346DA50"
},
"3.2.30-50828-arm64": {
"send": "4327764",
"recv": "15e3184"
},
"9.9.32-50969-x64": {
"send": "0B2E2AC",
"recv": "209C709"
},
"3.2.30-50969-x64": {
"send": "63F47B0",
"recv": "34B9E30"
},
"3.2.30-50969-arm64": {
"send": "43A4804",
"recv": "1608CC8"
},
"3.2.31-51102-x64": {
"send": "63F4910",
"recv": "34B9F90"
},
"3.2.31-51102-arm64": {
"send": "43A48E4",
"recv": "1608D98"
},
"6.9.98-51102-arm64": {
"send": "2849378",
"recv": "A66CFC"
},
"9.9.32-51246-x64": {
"send": "B2E37C",
"recv": "209C7D9"
},
"3.2.32-51728-arm64": {
"send": "48CA8F8",
"recv": "17D5A78"
},
"3.2.32-51728-x64": {
"send": "6B9ADD0",
"recv": "38F5F30"
},
"3.2.32-51802-x64": {
"send": "6B9B790",
"recv": "38F68F0"
},
"3.2.32-51802-arm64": {
"send": "48CB078",
"recv": "17D620C"
},
"6.9.99-51802-arm64": {
"send": "2B0B384",
"recv": "B4526C"
},
"9.9.33-51802-x64": {
"send": "BDE15F",
"recv": "237BC91"
},
"9.9.33-52230-x64": {
"send": "BF2B5F",
"recv": "23BB4DD"
},
"3.2.32-52194-x64": {
"send": "6C45D20",
"recv": "393DE00"
},
"3.2.32-52194-arm64": {
"send": "493FAD8",
"recv": "17EA1DC"
},
"7.0.0-52194-arm64": {
"send": "2B3FBA4",
"recv": "B4E85C"
},
"9.9.35-52804-x64": {
"send": "C3614F",
"recv": "246F481"
},
"9.9.35-52892-x64": {
"send": "C361DF",
"recv": "246F3A1"
},
"9.9.36-53489-x64": {
"send": "C3ED0F",
"recv": "24A6235"
},
"3.2.33-52892-x64": {
"send": "6DDB4C0",
"recv": "39DA860"
},
"3.2.33-52892-arm64": {
"send": "4A5D658",
"recv": "1813A34"
},
"7.0.1-52892-arm64": {
"send": "2B3FBCC",
"recv": "B64170"
},
"3.2.34-53644-x64": {
"send": "6E5AB20",
"recv": "3A12CD0"
},
"3.2.34-53644-arm64": {
"send": "4AABA5C",
"recv": "182C534"
},
"7.0.2-53644-arm64": {
"send": "2B82DEC",
"recv": "B71088"
},
"9.9.36-53644-x64": {
"send": "C3EE2F",
"recv": "24A6355"
},
"6.9.82-40768-x64": {
"send": "6587B8",
"recv": "C45C6E"
},
"6.9.82-40990-x64": {
"send": "6587B8",
"recv": "C45C6E"
},
"6.9.83-41679-x64": {
"send": "271A206",
"recv": "A1E04A"
},
"6.9.83-41785-x64": {
"send": "2744756",
"recv": "A1DE4A"
},
"6.9.83-41857-x64": {
"send": "2745186",
"recv": "A1E96E"
},
"6.9.85-42086-x64": {
"send": "27462F6",
"recv": "A1FC0A"
},
"6.9.86-42744-x64": {
"send": "27770E6",
"recv": "A2725A"
},
"6.9.86-42905-x64": {
"send": "2786206",
"recv": "A2D6A6"
},
"6.9.86-42941-x64": {
"send": "2786366",
"recv": "A2D2E2"
},
"6.9.87-44343-x64": {
"send": "2750306",
"recv": "A2F432"
},
"6.9.89-45758-x64": {
"send": "288CBB6",
"recv": "A87816"
},
"6.9.90-46494-x64": {
"send": "28A2F96",
"recv": "A8D2FE"
},
"6.9.91-46928-x64": {
"send": "28A2F96",
"recv": "A8D2FE"
},
"6.9.95-48517-x64": {
"send": "28DC616",
"recv": "AAED2A"
},
"6.9.96-49738-x64": {
"send": "2B8C936",
"recv": "AFF03E"
},
"6.9.98-51102-x64": {
"send": "2C6CD06",
"recv": "B42922"
},
"6.9.99-51802-x64": {
"send": "2F6C1AB",
"recv": "C16A4A"
},
"7.0.0-52194-x64": {
"send": "2FAC58B",
"recv": "C20DB2"
},
"7.0.1-52892-x64": {
"send": "304CC8B",
"recv": "C3A54E"
},
"7.0.2-53644-x64": {
"send": "309B1BB",
"recv": "C47262"
},
"9.9.37-54276-x64": {
"send": "C5927F",
"recv": "24CC2B5"
},
"7.0.3-54276-arm64": {
"send": "2BA91CC",
"recv": "B84728"
},
"7.0.3-54276-x64": {
"send": "30C611B",
"recv": "C5B84E"
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,61 @@
// TODO: further refactor in NapCat.Packet v2
import { NapProtoMsg, ProtoField, ScalarType } from 'napcat-protobuf';
const LikeDetail = {
txt: ProtoField(1, ScalarType.STRING),
uin: ProtoField(3, ScalarType.INT64),
nickname: ProtoField(5, ScalarType.STRING),
};
const LikeMsg = {
times: ProtoField(1, ScalarType.INT32),
time: ProtoField(2, ScalarType.INT32),
detail: ProtoField(3, () => LikeDetail),
};
const ProfileLikeSubTip = {
msg: ProtoField(14, () => LikeMsg),
};
const ProfileLikeTip = {
msgType: ProtoField(1, ScalarType.INT32),
subType: ProtoField(2, ScalarType.INT32),
content: ProtoField(203, () => ProfileLikeSubTip),
};
const SysMessageHeader = {
PeerNumber: ProtoField(1, ScalarType.UINT32),
PeerString: ProtoField(2, ScalarType.STRING),
Uin: ProtoField(5, ScalarType.UINT32),
Uid: ProtoField(6, ScalarType.STRING, true),
};
const SysMessageMsgSpec = {
msgType: ProtoField(1, ScalarType.UINT32),
subType: ProtoField(2, ScalarType.UINT32),
subSubType: ProtoField(3, ScalarType.UINT32),
msgSeq: ProtoField(5, ScalarType.UINT32),
time: ProtoField(6, ScalarType.UINT32),
msgId: ProtoField(12, ScalarType.UINT64),
other: ProtoField(13, ScalarType.UINT32),
};
const SysMessageBodyWrapper = {
wrappedBody: ProtoField(2, ScalarType.BYTES),
};
const SysMessage = {
header: ProtoField(1, () => SysMessageHeader, false, true),
msgSpec: ProtoField(2, () => SysMessageMsgSpec, false, true),
bodyWrapper: ProtoField(3, () => SysMessageBodyWrapper),
};
export function decodeProfileLikeTip (buffer: Uint8Array) {
const msg = new NapProtoMsg(ProfileLikeTip);
return msg.decode(buffer);
}
export function decodeSysMessage (buffer: Uint8Array) {
const msg = new NapProtoMsg(SysMessage);
return msg.decode(buffer);
}
+16
View File
@@ -0,0 +1,16 @@
import path from 'node:path';
import { randomUUID } from 'crypto';
import { LogWrapper } from '@/napcat-core/helper/log';
import { FFmpegService } from '@/napcat-core/helper/ffmpeg/ffmpeg';
export async function encodeSilk (filePath: string, temporaryDirectory: string, logger: LogWrapper) {
const converted = !(await FFmpegService.isSilk(filePath));
const pttPath = converted ? path.join(temporaryDirectory, randomUUID()) : filePath;
if (converted) {
logger.log(`语音文件${filePath}需要转换成silk`);
await FFmpegService.convertToNTSilkTct(filePath, pttPath);
}
const duration = await FFmpegService.getDuration(pttPath);
logger.log('语音文件已就绪:', pttPath, '时长:', duration);
return { converted, path: pttPath, duration };
}
@@ -0,0 +1,84 @@
import path from 'node:path';
import fs from 'node:fs';
import type { NapCatCore } from '@/napcat-core';
import json5 from 'json5';
import { TypeCompiler, TypeCheck } from '@sinclair/typebox/compiler';
import { Value } from '@sinclair/typebox/value';
import { TSchema } from '@sinclair/typebox';
export abstract class ConfigBase<T> {
name: string;
core: NapCatCore;
configPath: string;
configData: T = {} as T;
configSchema: TSchema;
validate: TypeCheck<TSchema>;
protected constructor (name: string, core: NapCatCore, configPath: string, ConfigSchema: TSchema) {
this.name = name;
this.core = core;
this.configPath = configPath;
this.configSchema = ConfigSchema;
this.validate = TypeCompiler.Compile(ConfigSchema);
fs.mkdirSync(this.configPath, { recursive: true });
this.read();
}
getConfigPath (pathName?: string): string {
const filename = pathName ? `${this.name}_${pathName}.json` : `${this.name}.json`;
return path.join(this.configPath, filename);
}
read (): T {
const configPath = this.getConfigPath(this.core.selfInfo.uin);
const defaultConfigPath = this.getConfigPath();
if (!fs.existsSync(configPath)) {
if (fs.existsSync(defaultConfigPath)) {
this.configData = this.loadConfig(defaultConfigPath);
}
this.save();
return this.configData;
}
return this.loadConfig(configPath);
}
private loadConfig (configPath: string): T {
try {
const newConfigData = json5.parse(fs.readFileSync(configPath, 'utf-8'));
let data: unknown = newConfigData;
data = Value.Parse(this.configSchema, data);
if (!this.validate.Check(data)) {
throw new Error([...this.validate.Errors(data)].map(e => e.message).join(', '));
}
this.configData = data as T;
this.core.context.logger.logDebug(`[Core] [Config] 配置文件${configPath}加载`, this.configData);
return this.configData;
} catch (e: unknown) {
this.handleError(e, '读取配置文件时发生错误');
return {} as T;
}
}
save (newConfigData: T = this.configData): void {
const configPath = this.getConfigPath(this.core.selfInfo.uin);
let data: unknown = newConfigData;
data = Value.Parse(this.configSchema, data);
if (!this.validate.Check(data)) {
throw new Error([...this.validate.Errors(data)].map(e => e.message).join(', '));
}
this.configData = data as T;
try {
fs.writeFileSync(configPath, JSON.stringify(this.configData, null, 2));
} catch (e: unknown) {
this.handleError(e, `保存配置文件 ${configPath} 时发生错误:`);
}
}
private handleError (e: unknown, message: string): void {
if (e instanceof SyntaxError) {
this.core.context.logger.logError('[Core] [Config] 操作配置文件格式错误,请检查配置文件:', e.message);
} else {
this.core.context.logger.logError(`[Core] [Config] ${message}:`, (e as Error).message);
}
}
}
+56
View File
@@ -0,0 +1,56 @@
import { ConfigBase } from '@/napcat-core/helper/config-base';
import { NapCatCore } from '@/napcat-core/index';
import { Type, Static, TSchema } from '@sinclair/typebox';
import { Value } from '@sinclair/typebox/value';
import path from 'node:path';
import fs from 'node:fs';
import json5 from 'json5';
export const BypassOptionsSchema = Type.Object({
hook: Type.Boolean({ default: true }),
window: Type.Boolean({ default: true }),
module: Type.Boolean({ default: true }),
process: Type.Boolean({ default: true }),
container: Type.Boolean({ default: true }),
js: Type.Boolean({ default: true }),
});
export const NapcatConfigSchema = Type.Object({
fileLog: Type.Boolean({ default: false }),
consoleLog: Type.Boolean({ default: true }),
fileLogLevel: Type.String({ default: 'debug' }),
consoleLogLevel: Type.String({ default: 'info' }),
packetBackend: Type.String({ default: 'auto' }),
packetServer: Type.String({ default: '' }),
o3HookMode: Type.Number({ default: 0 }),
autoTimeSync: Type.Boolean({ default: true }),
bypass: Type.Optional(BypassOptionsSchema),
});
export type NapcatConfig = Static<typeof NapcatConfigSchema>;
/**
* 从指定配置目录读取 napcat.json,按 NapcatConfigSchema 校验并填充默认值
* 用于登录前(无 NapCatCore 实例时)的早期配置读取
*/
export function loadNapcatConfig (configPath: string): NapcatConfig {
let data: Record<string, unknown> = {};
try {
const configFile = path.join(configPath, 'napcat.json');
if (fs.existsSync(configFile)) {
data = json5.parse(fs.readFileSync(configFile, 'utf-8'));
}
} catch {
// 读取失败时使用 schema 默认值
}
data = Value.Parse(NapcatConfigSchema, data) as Record<string, unknown>;
return data as NapcatConfig;
}
export class NapCatConfigLoader extends ConfigBase<NapcatConfig> {
constructor (core: NapCatCore, configPath: string, schema: TSchema) {
super('napcat', core, configPath, schema);
}
}
+272
View File
@@ -0,0 +1,272 @@
import { NodeIQQNTWrapperSession } from '@/napcat-core/wrapper';
import { randomUUID } from 'crypto';
import { ListenerNamingMapping, ServiceNamingMapping } from '@/napcat-core/index';
interface InternalMapKey {
timeout: number;
createtime: number;
func: (...arg: any[]) => any;
checker: ((...args: any[]) => boolean) | undefined;
}
type EnsureFunc<T> = T extends (...args: any) => any ? T : never;
type FuncKeys<T> = Extract<
{
[K in keyof T]: EnsureFunc<T[K]> extends never ? never : K;
}[keyof T],
string
>;
export type ListenerClassBase = Record<string, string>;
export class NTEventWrapper {
private readonly WrapperSession: NodeIQQNTWrapperSession | undefined; // WrapperSession
private readonly listenerManager: Map<string, ListenerClassBase> = new Map<string, ListenerClassBase>(); // ListenerName-Unique -> Listener实例
private readonly EventTask = new Map<string, Map<string, Map<string, InternalMapKey>>>(); // tasks ListenerMainName -> ListenerSubName-> uuid -> {timeout,createtime,func}
constructor (
wrapperSession: NodeIQQNTWrapperSession
) {
this.WrapperSession = wrapperSession;
}
createProxyDispatch (ListenerMainName: string) {
const dispatcherListenerFunc = this.dispatcherListener.bind(this);
return new Proxy(
{},
{
get (target: any, prop: any, receiver: any) {
if (typeof target[prop] === 'undefined') {
// 如果方法不存在,返回一个函数,这个函数调用existentMethod
return (...args: any[]) => {
dispatcherListenerFunc(ListenerMainName, prop, ...args).then();
};
}
// 如果方法存在,正常返回
return Reflect.get(target, prop, receiver);
},
}
);
}
createEventFunction<
Service extends keyof ServiceNamingMapping,
ServiceMethod extends FuncKeys<ServiceNamingMapping[Service]>,
T extends (...args: any) => any = EnsureFunc<ServiceNamingMapping[Service][ServiceMethod]>
>(eventName: `${Service}/${ServiceMethod}`): T | undefined {
const eventNameArr = eventName.split('/');
type eventType = {
[key: string]: () => { [key: string]: (...params: Parameters<T>) => Promise<ReturnType<T>>; };
};
if (eventNameArr.length > 1) {
const serviceName = 'get' + (eventNameArr[0]?.replace('NodeIKernel', '') ?? '');
const eventName = eventNameArr[1];
const services = (this.WrapperSession as unknown as eventType)[serviceName]?.();
if (!services || !eventName) {
return undefined;
}
let event = services[eventName];
// 重新绑定this
event = event?.bind(services);
if (event) {
return event as T;
}
return undefined;
}
return undefined;
}
createListenerFunction<T> (listenerMainName: string, uniqueCode: string = ''): T {
const existListener = this.listenerManager.get(listenerMainName + uniqueCode);
if (!existListener) {
const Listener = this.createProxyDispatch(listenerMainName);
const ServiceSubName = /^NodeIKernel(.*?)Listener$/.exec(listenerMainName)![1];
const Service = `NodeIKernel${ServiceSubName}Service/addKernel${ServiceSubName}Listener`;
// @ts-ignore
this.createEventFunction(Service)(Listener as T);
this.listenerManager.set(listenerMainName + uniqueCode, Listener);
return Listener as T;
}
return existListener as T;
}
// 统一回调清理事件
async dispatcherListener (ListenerMainName: string, ListenerSubName: string, ...args: any[]) {
this.EventTask.get(ListenerMainName)
?.get(ListenerSubName)
?.forEach((task, uuid) => {
if (task.createtime + task.timeout < Date.now()) {
this.EventTask.get(ListenerMainName)?.get(ListenerSubName)?.delete(uuid);
return;
}
if (task?.checker?.(...args)) {
task.func(...args);
}
});
}
async callNoListenerEvent<
Service extends keyof ServiceNamingMapping,
ServiceMethod extends FuncKeys<ServiceNamingMapping[Service]>,
EventType extends (...args: any) => any = EnsureFunc<ServiceNamingMapping[Service][ServiceMethod]>
>(
serviceAndMethod: `${Service}/${ServiceMethod}`,
...args: Parameters<EventType>
): Promise<Awaited<ReturnType<EventType>>> {
return (this.createEventFunction(serviceAndMethod))!(...args);
}
async registerListen<
Listener extends keyof ListenerNamingMapping,
ListenerMethod extends FuncKeys<ListenerNamingMapping[Listener]>,
ListenerType extends (...args: any) => any = EnsureFunc<ListenerNamingMapping[Listener][ListenerMethod]>
>(
listenerAndMethod: `${Listener}/${ListenerMethod}`,
checker: (...args: Parameters<ListenerType>) => boolean,
waitTimes = 1,
timeout = 5000
) {
return new Promise<Parameters<ListenerType>>((resolve, reject) => {
const ListenerNameList = listenerAndMethod.split('/');
const ListenerMainName = ListenerNameList[0] ?? '';
const ListenerSubName = ListenerNameList[1] ?? '';
const id = randomUUID();
let complete = 0;
let retData: Parameters<ListenerType> | undefined;
function sendDataCallback () {
if (complete === 0) {
reject(new Error(' ListenerName:' + listenerAndMethod + ' timeout'));
} else {
resolve(retData!);
}
}
const timeoutRef = setTimeout(sendDataCallback, timeout);
const eventCallback = {
timeout,
createtime: Date.now(),
checker,
func: (...args: Parameters<ListenerType>) => {
complete++;
retData = args;
if (complete >= waitTimes) {
clearTimeout(timeoutRef);
sendDataCallback();
}
},
};
if (!this.EventTask.get(ListenerMainName)) {
this.EventTask.set(ListenerMainName, new Map());
}
if (!this.EventTask.get(ListenerMainName)?.get(ListenerSubName)) {
this.EventTask.get(ListenerMainName)?.set(ListenerSubName, new Map());
}
this.EventTask.get(ListenerMainName)?.get(ListenerSubName)?.set(id, eventCallback);
this.createListenerFunction(ListenerMainName);
});
}
async callNormalEventV2<
Service extends keyof ServiceNamingMapping,
ServiceMethod extends FuncKeys<ServiceNamingMapping[Service]>,
Listener extends keyof ListenerNamingMapping,
ListenerMethod extends FuncKeys<ListenerNamingMapping[Listener]>,
EventType extends (...args: any) => any = EnsureFunc<ServiceNamingMapping[Service][ServiceMethod]>,
ListenerType extends (...args: any) => any = EnsureFunc<ListenerNamingMapping[Listener][ListenerMethod]>
>(
serviceAndMethod: `${Service}/${ServiceMethod}`,
listenerAndMethod: `${Listener}/${ListenerMethod}`,
args: Parameters<EventType>,
checkerEvent: (ret: Awaited<ReturnType<EventType>>) => boolean = () => true,
checkerListener: (...args: Parameters<ListenerType>) => boolean = () => true,
callbackTimesToWait = 1,
timeout = 5000
) {
const id = randomUUID();
let complete = 0;
let retData: Parameters<ListenerType> | undefined;
let retEvent: any = {};
function sendDataCallback (resolve: any, reject: any) {
if (complete === 0) {
reject(
new Error(
'Timeout: NTEvent serviceAndMethod:' +
serviceAndMethod +
' ListenerName:' +
listenerAndMethod +
' EventRet:\n' +
JSON.stringify(retEvent, null, 4) +
'\n'
)
);
} else {
resolve([retEvent as Awaited<ReturnType<EventType>>, ...retData!]);
}
}
const ListenerNameList = listenerAndMethod.split('/');
const ListenerMainName = ListenerNameList[0] ?? '';
const ListenerSubName = ListenerNameList[1] ?? '';
return new Promise<[EventRet: Awaited<ReturnType<EventType>>, ...Parameters<ListenerType>]>(
(resolve, reject) => {
const timeoutRef = setTimeout(() => sendDataCallback(resolve, reject), timeout);
const eventCallback = {
timeout,
createtime: Date.now(),
checker: checkerListener,
func: (...args: any[]) => {
complete++;
retData = args as Parameters<ListenerType>;
if (complete >= callbackTimesToWait) {
clearTimeout(timeoutRef);
sendDataCallback(resolve, reject);
}
},
};
if (!this.EventTask.get(ListenerMainName)) {
this.EventTask.set(ListenerMainName, new Map());
}
if (!this.EventTask.get(ListenerMainName)?.get(ListenerSubName)) {
this.EventTask.get(ListenerMainName)?.set(ListenerSubName, new Map());
}
this.EventTask.get(ListenerMainName)?.get(ListenerSubName)?.set(id, eventCallback);
this.createListenerFunction(ListenerMainName);
const eventResult = this.createEventFunction(serviceAndMethod)!(...(args));
const eventRetHandle = (eventData: any) => {
retEvent = eventData;
if (!checkerEvent(retEvent) && timeoutRef.hasRef()) {
clearTimeout(timeoutRef);
reject(
new Error(
'EventChecker Failed: NTEvent serviceAndMethod:' +
serviceAndMethod +
' ListenerName:' +
listenerAndMethod +
' EventRet:\n' +
JSON.stringify(retEvent, null, 4) +
'\n'
)
);
}
};
if (eventResult instanceof Promise) {
eventResult.then((eventResult: any) => {
eventRetHandle(eventResult);
})
.catch(reject);
} else {
eventRetHandle(eventResult);
}
}
);
}
}
@@ -0,0 +1,359 @@
// 更正导入语句
import * as fs from 'fs';
import * as path from 'path';
import * as https from 'https';
import * as os from 'os';
import * as compressing from 'compressing'; // 修正导入方式
import { pipeline } from 'stream/promises';
import { fileURLToPath } from 'url';
import { LogWrapper } from '@/napcat-core/helper/log';
const downloadOri = 'https://github.com/NapNeko/ffmpeg-build/releases/download/v1.0.0/ffmpeg-7.1.1-win64.zip';
const urls = [
'https://j.1win.ggff.net/' + downloadOri,
'https://git.yylx.win/' + downloadOri,
'https://ghfile.geekertao.top/' + downloadOri,
'https://gh-proxy.net/' + downloadOri,
'https://ghm.078465.xyz/' + downloadOri,
'https://gitproxy.127731.xyz/' + downloadOri,
'https://jiashu.1win.eu.org/' + downloadOri,
'https://github.tbedu.top/' + downloadOri,
downloadOri,
];
/**
* 测试URL是否可用
* @param url 待测试的URL
* @returns 如果URL可访问返回true,否则返回false
*/
async function testUrl (url: string): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const req = https.get(url, { timeout: 5000 }, (res) => {
// 检查状态码是否表示成功
const statusCode = res.statusCode || 0;
if (statusCode >= 200 && statusCode < 300) {
// 终止请求并返回true
req.destroy();
resolve(true);
} else {
req.destroy();
resolve(false);
}
});
req.on('error', () => {
resolve(false);
});
req.on('timeout', () => {
req.destroy();
resolve(false);
});
});
}
/**
* 查找第一个可用的URL
* @returns 返回第一个可用的URL,如果都不可用则返回null
*/
async function findAvailableUrl (): Promise<string | null> {
for (const url of urls) {
try {
const available = await testUrl(url);
if (available) {
return url;
}
} catch (_error) {
// 忽略错误
}
}
return null;
}
/**
* 下载文件
* @param url 下载URL
* @param destPath 目标保存路径
* @returns 成功返回true,失败返回false
*/
async function downloadFile (url: string, destPath: string, progressCallback?: (percent: number) => void): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const file = fs.createWriteStream(destPath);
const req = https.get(url, (res) => {
const statusCode = res.statusCode || 0;
if (statusCode >= 200 && statusCode < 300) {
// 获取文件总大小
const totalSize = parseInt(res.headers['content-length'] || '0', 10);
let downloadedSize = 0;
let lastReportedPercent = -1; // 上次报告的百分比
let lastReportTime = 0; // 上次报告的时间戳
// 如果有内容长度和进度回调,则添加数据监听
if (totalSize > 0 && progressCallback) {
// 初始报告 0%
progressCallback(0);
lastReportTime = Date.now();
res.on('data', (chunk) => {
downloadedSize += chunk.length;
const currentPercent = Math.floor((downloadedSize / totalSize) * 100);
const now = Date.now();
// 只在以下条件触发回调:
// 1. 百分比变化至少为1%
// 2. 距离上次报告至少500毫秒
// 3. 确保报告100%完成
if ((currentPercent !== lastReportedPercent &&
(currentPercent - lastReportedPercent >= 1 || currentPercent === 100)) &&
(now - lastReportTime >= 1000 || currentPercent === 100)) {
progressCallback(currentPercent);
lastReportedPercent = currentPercent;
lastReportTime = now;
}
});
}
pipeline(res, file)
.then(() => {
// 确保最后报告100%
if (progressCallback && lastReportedPercent !== 100) {
progressCallback(100);
}
resolve(true);
})
.catch(() => resolve(false));
} else {
file.close();
fs.unlink(destPath, () => { });
resolve(false);
}
});
req.on('error', () => {
file.close();
fs.unlink(destPath, () => { });
resolve(false);
});
});
}
/**
* 解压缩zip文件中的特定内容
* 只解压bin目录中的文件到目标目录
* @param zipPath 压缩文件路径
* @param extractDir 解压目标路径
*/
async function extractBinDirectory (zipPath: string, extractDir: string): Promise<void> {
// 确保目标目录存在
if (!fs.existsSync(extractDir)) {
fs.mkdirSync(extractDir, { recursive: true });
}
// 解压文件
const zipStream = new compressing.zip.UncompressStream({ source: zipPath });
return new Promise<void>((resolve, reject) => {
// 监听条目事件
zipStream.on('entry', (header, stream, next) => {
// 获取文件路径
const filePath = header.name;
// 匹配内层bin目录中的文件
// 例如:ffmpeg-n7.1.1-6-g48c0f071d4-win64-lgpl-7.1/bin/ffmpeg.exe
if (filePath.includes('/bin/') && filePath.endsWith('.exe')) {
// 提取文件名
const fileName = path.basename(filePath);
const targetPath = path.join(extractDir, fileName);
// 创建写入流
const writeStream = fs.createWriteStream(targetPath);
// 将流管道连接到文件
stream.pipe(writeStream);
// 监听写入完成事件
writeStream.on('finish', () => {
next();
});
writeStream.on('error', () => {
next();
});
} else {
// 跳过不需要的文件
stream.resume();
next();
}
});
zipStream.on('error', (err) => {
reject(err);
});
zipStream.on('finish', () => {
resolve();
});
});
}
/**
* 下载并设置FFmpeg
* @param destDir 目标安装目录,默认为用户临时目录下的ffmpeg文件夹
* @param tempDir 临时文件目录,默认为系统临时目录
* @returns 返回ffmpeg可执行文件的路径,如果失败则返回null
*/
export async function downloadFFmpeg (
destDir?: string,
tempDir?: string,
progressCallback?: (percent: number, stage: string) => void
): Promise<string | null> {
// 仅限Windows
if (os.platform() !== 'win32') {
return null;
}
const destinationDir = destDir || path.join(os.tmpdir(), 'ffmpeg');
const tempDirectory = tempDir || os.tmpdir();
const zipFilePath = path.join(tempDirectory, 'ffmpeg.zip'); // 临时下载到指定临时目录
const ffmpegExePath = path.join(destinationDir, 'ffmpeg.exe');
// 确保目录存在
if (!fs.existsSync(destinationDir)) {
fs.mkdirSync(destinationDir, { recursive: true });
}
// 确保临时目录存在
if (!fs.existsSync(tempDirectory)) {
fs.mkdirSync(tempDirectory, { recursive: true });
}
// 如果ffmpeg已经存在,直接返回路径
if (fs.existsSync(ffmpegExePath)) {
if (progressCallback) progressCallback(100, '已找到FFmpeg');
return ffmpegExePath;
}
// 查找可用URL
if (progressCallback) progressCallback(0, '查找可用下载源');
const availableUrl = await findAvailableUrl();
if (!availableUrl) {
return null;
}
// 下载文件
if (progressCallback) progressCallback(5, '开始下载FFmpeg');
const downloaded = await downloadFile(
availableUrl,
zipFilePath,
(percent) => {
// 下载占总进度的70%
if (progressCallback) progressCallback(5 + Math.floor(percent * 0.7), '下载FFmpeg');
}
);
if (!downloaded) {
return null;
}
try {
// 直接解压bin目录文件到目标目录
if (progressCallback) progressCallback(75, '解压FFmpeg');
await extractBinDirectory(zipFilePath, destinationDir);
// 清理下载文件
if (progressCallback) progressCallback(95, '清理临时文件');
try {
fs.unlinkSync(zipFilePath);
} catch (_err) {
// 忽略清理临时文件失败的错误
}
// 检查ffmpeg.exe是否成功解压
if (fs.existsSync(ffmpegExePath)) {
if (progressCallback) progressCallback(100, 'FFmpeg安装完成');
return ffmpegExePath;
} else {
return null;
}
} catch (_err) {
return null;
}
}
/**
* 检查系统PATH环境变量中是否存在指定可执行文件
* @param executable 可执行文件名
* @returns 如果找到返回完整路径,否则返回null
*/
function findExecutableInPath (executable: string): string | null {
// 仅适用于Windows系统
if (os.platform() !== 'win32') return null;
// 获取PATH环境变量
const pathEnv = process.env['PATH'] || '';
const pathDirs = pathEnv.split(';');
// 检查每个目录
for (const dir of pathDirs) {
if (!dir) continue;
try {
const filePath = path.join(dir, executable);
if (fs.existsSync(filePath)) {
return filePath;
}
} catch (_error) {
continue;
}
}
return null;
}
export async function downloadFFmpegIfNotExists (log: LogWrapper) {
// 仅限Windows
if (os.platform() !== 'win32') {
return {
path: null,
reset: false,
};
}
const ffmpegInPath = findExecutableInPath('ffmpeg.exe');
const ffprobeInPath = findExecutableInPath('ffprobe.exe');
if (ffmpegInPath && ffprobeInPath) {
const ffmpegDir = path.dirname(ffmpegInPath);
return {
path: ffmpegDir,
reset: true,
};
}
// 如果环境变量中没有,检查项目目录中是否存在
const currentPath = path.dirname(fileURLToPath(import.meta.url));
const ffmpeg_exist = fs.existsSync(path.join(currentPath, 'ffmpeg', 'ffmpeg.exe'));
const ffprobe_exist = fs.existsSync(path.join(currentPath, 'ffmpeg', 'ffprobe.exe'));
if (!ffmpeg_exist || !ffprobe_exist) {
const url = await downloadFFmpeg(path.join(currentPath, 'ffmpeg'), path.join(currentPath, 'cache'), (percentage: number, message: string) => {
log.log(`[FFmpeg] [Download] ${percentage}% - ${message}`);
});
if (!url) {
log.log('[FFmpeg] [Error] 下载FFmpeg失败');
return {
path: null,
reset: false,
};
}
return {
path: path.join(currentPath, 'ffmpeg'),
reset: true,
};
}
return {
path: path.join(currentPath, 'ffmpeg'),
reset: true,
};
}
@@ -0,0 +1,120 @@
/**
* FFmpeg Adapter Factory
* 自动检测并选择最佳的 FFmpeg 适配器
*/
import { LogWrapper } from '@/napcat-core/helper/log';
import { FFmpegAddonAdapter, getAddonPath } from './ffmpeg-addon-adapter';
import { existsSync } from 'node:fs';
import { FFmpegExecAdapter } from './ffmpeg-exec-adapter';
import type { IFFmpegAdapter } from './ffmpeg-adapter-interface';
/**
* FFmpeg 适配器工厂
*/
export class FFmpegAdapterFactory {
private static instance: IFFmpegAdapter | null = null;
private static initPromise: Promise<IFFmpegAdapter> | null = null;
/**
* 初始化并获取最佳的 FFmpeg 适配器
* @param logger 日志记录器
* @param ffmpegPath FFmpeg 可执行文件路径(用于 Exec 适配器)
* @param ffprobePath FFprobe 可执行文件路径(用于 Exec 适配器)
* @param binaryPath 二进制文件路径(来自 pathWrapper.binaryPath,用于 Addon 适配器)
*/
static async getAdapter (
logger: LogWrapper,
ffmpegPath: string = 'ffmpeg',
ffprobePath: string = 'ffprobe',
binaryPath?: string
): Promise<IFFmpegAdapter> {
// 如果已经初始化,直接返回
if (this.instance) {
return this.instance;
}
// 如果正在初始化,等待初始化完成
if (this.initPromise) {
return this.initPromise;
}
// 开始初始化
this.initPromise = this.initialize(logger, ffmpegPath, ffprobePath, binaryPath);
try {
this.instance = await this.initPromise;
return this.instance;
} finally {
this.initPromise = null;
}
}
/**
* 初始化适配器
*/
private static async initialize (
logger: LogWrapper,
ffmpegPath: string,
ffprobePath: string,
binaryPath?: string
): Promise<IFFmpegAdapter> {
const useCommandLine = Boolean(process.env['FFMPEG_PATH'] || process.env['FFPROBE_PATH']);
if (binaryPath && !useCommandLine && existsSync(getAddonPath(binaryPath))) {
const addonAdapter = new FFmpegAddonAdapter(binaryPath);
logger.log('[FFmpeg] 检查 Native Addon 可用性...');
if (await addonAdapter.isAvailable()) {
logger.log('[FFmpeg] ✓ 使用 Native Addon 适配器');
return addonAdapter;
}
throw new Error('FFmpeg Native Addon 加载失败: ' + getAddonPath(binaryPath));
}
const execAdapter = new FFmpegExecAdapter(ffmpegPath, ffprobePath);
logger.log(`[FFmpeg] 检查命令行工具可用性: ${ffmpegPath}`);
if (await execAdapter.isAvailable()) {
logger.log('[FFmpeg] 使用命令行工具适配器 ✓');
return execAdapter;
}
throw new Error('FFmpeg 命令行工具不可用,请检查 ffmpeg 和 ffprobe 路径: ' + ffmpegPath + ', ' + ffprobePath);
}
/**
* 重置适配器(用于测试或重新初始化)
*/
static reset (): void {
this.instance = null;
this.initPromise = null;
}
/**
* 更新 FFmpeg 路径并重新初始化
* @param logger 日志记录器
* @param ffmpegPath FFmpeg 可执行文件路径
* @param ffprobePath FFprobe 可执行文件路径
*/
static async updateFFmpegPath (
logger: LogWrapper,
ffmpegPath: string,
ffprobePath: string
): Promise<IFFmpegAdapter> {
const adapter = new FFmpegExecAdapter(ffmpegPath, ffprobePath);
if (!await adapter.isAvailable()) {
throw new Error('FFmpeg 路径不可用: ' + ffmpegPath + ', ' + ffprobePath);
}
this.instance = adapter;
logger.log('[FFmpeg] 已更新命令行工具路径:', ffmpegPath, ffprobePath);
return adapter;
}
/**
* 获取当前适配器(不初始化)
*/
static getCurrentAdapter (): IFFmpegAdapter | null {
return this.instance;
}
}
@@ -0,0 +1,76 @@
/**
* FFmpeg Adapter Interface
* 定义统一的 FFmpeg 操作接口,支持多种实现方式
*/
/**
* 视频信息结果
*/
export interface VideoInfoResult {
/** 视频宽度(像素) */
width: number;
/** 视频高度(像素) */
height: number;
/** 视频时长(秒) */
duration: number;
/** 容器格式 */
format: string;
/** 缩略图 Buffer */
thumbnail?: Buffer;
}
/**
* FFmpeg 适配器接口
*/
export interface IFFmpegAdapter {
/** 适配器名称 */
readonly name: string;
/** 是否可用 */
isAvailable (): Promise<boolean>;
/**
* 获取视频信息(包含缩略图)
* @param videoPath 视频文件路径
* @returns 视频信息
*/
getVideoInfo (videoPath: string): Promise<VideoInfoResult>;
/**
* 获取音视频文件时长
* @param filePath 文件路径
* @returns 时长(秒)
*/
getDuration (filePath: string): Promise<number>;
/**
* 判断是否为 Silk 格式
* @param filePath 文件路径
*/
isSilk (filePath: string): Promise<boolean>;
/**
* 转换音频为 PCM 格式
* @param filePath 输入文件路径
* @param pcmPath 输出 PCM 文件路径
* @returns PCM 数据 Buffer
*/
convertToPCM (filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number; }>;
/**
* 转换音频文件
* @param inputFile 输入文件路径
* @param outputFile 输出文件路径
* @param format 目标格式 ('amr' | 'silk' 等)
*/
convertFile (inputFile: string, outputFile: string, format: string): Promise<void>;
/**
* 提取视频缩略图
* @param videoPath 视频文件路径
* @param thumbnailPath 缩略图输出路径
*/
extractThumbnail (videoPath: string, thumbnailPath: string): Promise<void>;
convertToNTSilkTct (inputFile: string, outputFile: string): Promise<void>;
}
@@ -0,0 +1,133 @@
/**
* FFmpeg Native Addon Adapter
* 使用原生 Node.js Addon 实现的 FFmpeg 适配器
*/
import path from 'node:path';
import { openSync, readSync, closeSync } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import type { FFmpeg } from './ffmpeg-addon';
import type { IFFmpegAdapter, VideoInfoResult } from './ffmpeg-adapter-interface';
import { dlopen } from 'node:process';
/**
* 获取 Native Addon 路径
* @param binaryPath 二进制文件路径(来自 pathWrapper.binaryPath)
*/
export function getAddonPath (binaryPath: string): string {
const addonFileName: string = process.platform + '.' + process.arch;
const addonPath = path.join(binaryPath, './native/ffmpeg/', `ffmpegAddon.${addonFileName}.node`);
return addonPath;
}
/**
* FFmpeg Native Addon 适配器实现
*/
export class FFmpegAddonAdapter implements IFFmpegAdapter {
public readonly name = 'FFmpegAddon';
private addon: FFmpeg | null = null;
private binaryPath: string;
constructor (binaryPath: string) {
this.binaryPath = binaryPath;
}
/**
* 检查 Addon 是否可用
*/
async isAvailable (): Promise<boolean> {
try {
const temp_addon = { exports: {} };
dlopen(temp_addon, getAddonPath(this.binaryPath));
this.addon = temp_addon.exports as FFmpeg;
return this.addon !== null;
} catch (error) {
console.log('[FFmpegAddonAdapter] Failed to load addon:', error);
return false;
}
}
private ensureAddon (): FFmpeg {
if (!this.addon) {
throw new Error('FFmpeg Addon is not available');
}
return this.addon;
}
/**
* 获取视频信息
*/
async getVideoInfo (videoPath: string): Promise<VideoInfoResult> {
const addon = this.ensureAddon();
const info = await addon.getVideoInfo(videoPath);
const format = info.format.includes(',') ? info.format.split(',')[0] ?? info.format : info.format;
console.log('[FFmpegAddonAdapter] Detected format:', format);
return {
width: info.width,
height: info.height,
duration: info.duration,
format,
thumbnail: info.image,
};
}
/**
* 获取时长
*/
async getDuration (filePath: string): Promise<number> {
const addon = this.ensureAddon();
return addon.getDuration(filePath);
}
/**
* 判断是否为 Silk 格式
*/
async isSilk (filePath: string): Promise<boolean> {
try {
const fd = openSync(filePath, 'r');
const buffer = Buffer.alloc(10);
readSync(fd, buffer, 0, 10, 0);
closeSync(fd);
const header = buffer.toString();
return header.includes('#!SILK') || header.includes('\x02#!SILK');
} catch {
return false;
}
}
/**
* 转换为 PCM
*/
async convertToPCM (filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number; }> {
const addon = this.ensureAddon();
const result = await addon.decodeAudioToPCM(filePath, pcmPath, 24000);
return result;
}
/**
* 转换文件
*/
async convertFile (inputFile: string, outputFile: string, format: string): Promise<void> {
const addon = this.ensureAddon();
console.log('[FFmpegAddonAdapter] Converting file:', inputFile, 'to', outputFile, 'as', format);
await addon.decodeAudioToFmt(inputFile, outputFile, format);
}
async convertToNTSilkTct (inputFile: string, outputFile: string): Promise<void> {
const addon = this.ensureAddon();
await addon.convertToNTSilkTct(inputFile, outputFile);
}
/**
* 提取缩略图
*/
async extractThumbnail (videoPath: string, thumbnailPath: string, format: 'jpg' | 'png' = 'jpg'): Promise<void> {
const addon = this.ensureAddon();
const info = await addon.getVideoInfo(videoPath, format);
// 将缩略图写入文件
await writeFile(thumbnailPath, info.image);
}
}
@@ -0,0 +1,75 @@
/**
* FFmpeg Node.js Native Addon Type Definitions
*
* This addon provides FFmpeg functionality for Node.js including:
* - Video information extraction with thumbnail generation
* - Audio/Video duration detection
* - Audio format conversion to NTSILK
* - Audio decoding to PCM
*/
/**
* Video information result object
*/
export interface VideoInfo {
/** Video width in pixels */
width: number;
/** Video height in pixels */
height: number;
/** Video duration in seconds */
duration: number;
/** Generated thumbnail image format */
format: string;
/** Video codec name (e.g., "h264", "hevc", "vp9") */
videoCodec: string;
/** First frame thumbnail as JPEG image buffer */
image: Buffer;
}
/**
* Audio PCM decoding result object
*/
export interface AudioPCMResult {
/** PCM audio data as 16-bit signed integer samples */
pcm: Buffer;
/** Sample rate in Hz (e.g., 44100, 48000, 24000) */
sampleRate: number;
/** Number of audio channels (1 for mono, 2 for stereo) */
channels: number;
}
/**
* FFmpeg interface providing all audio/video processing methods
*/
export interface FFmpeg {
convertFile (inputFile: string, outputFile: string, format: string): Promise<{ success: boolean; }>;
/**
* Get video information including resolution, duration, format, codec and first frame thumbnail
*/
getVideoInfo (filePath: string, format?: 'jpg' | 'png'): Promise<VideoInfo>;
/**
* Get duration of audio or video file in seconds
*/
getDuration (filePath: string): Promise<number>;
/**
* Convert audio file to NTSILK format (WeChat voice message format)
*/
convertToNTSilkTct (inputPath: string, outputPath: string): Promise<void>;
/**
* Decode audio file to raw PCM data
*/
decodeAudioToPCM (filePath: string, pcmPath: string, sampleRate?: number): Promise<{ result: boolean, sampleRate: number; }>;
decodeAudioToFmt (filePath: string, pcmPath: string, format: string): Promise<{ channels: number; sampleRate: number; format: string; }>;
convertToNTSilkTct (inputFile: string, outputFile: string): Promise<void>;
}
@@ -0,0 +1,223 @@
/**
* FFmpeg Exec Adapter
* 使用 execFile 调用 FFmpeg 命令行工具的适配器实现
*/
import { existsSync, mkdirSync, openSync, readSync, closeSync } from 'fs';
import { dirname } from 'path';
import { execFile } from 'child_process';
import { promisify } from 'util';
import type { IFFmpegAdapter, VideoInfoResult } from './ffmpeg-adapter-interface';
const execFileAsync = promisify(execFile);
/**
* 确保目录存在
*/
function ensureDirExists (filePath: string): void {
const dir = dirname(filePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
}
/**
* FFmpeg 命令行适配器实现
*/
export class FFmpegExecAdapter implements IFFmpegAdapter {
public readonly name = 'FFmpegExec';
constructor (
private ffmpegPath: string = 'ffmpeg',
private ffprobePath: string = 'ffprobe'
) { }
/**
* 检查 FFmpeg 和 FFprobe 是否可用
*/
async isAvailable (): Promise<boolean> {
// 首先检查当前路径
try {
await Promise.all([
execFileAsync(this.ffmpegPath, ['-version'], { timeout: 10000, windowsHide: true }),
execFileAsync(this.ffprobePath, ['-version'], { timeout: 10000, windowsHide: true }),
]);
return true;
} catch {
return false;
}
}
/**
* 设置 FFmpeg 路径
*/
setFFmpegPath (ffmpegPath: string): void {
this.ffmpegPath = ffmpegPath;
}
/**
* 设置 FFprobe 路径
*/
setFFprobePath (ffprobePath: string): void {
this.ffprobePath = ffprobePath;
}
/**
* 获取视频信息
*/
async getVideoInfo (videoPath: string): Promise<VideoInfoResult> {
const [probe, frame] = await Promise.all([
execFileAsync(this.ffprobePath, [
'-v', 'error', '-select_streams', 'v:0',
'-show_entries', 'stream=width,height:format=duration,format_name',
'-of', 'json', videoPath,
], { timeout: 30000, windowsHide: true }),
execFileAsync(this.ffmpegPath, [
'-v', 'error', '-i', videoPath, '-frames:v', '1',
'-f', 'image2pipe', '-vcodec', 'mjpeg', 'pipe:1',
], { encoding: 'buffer', maxBuffer: 16 * 1024 * 1024, timeout: 30000, windowsHide: true }),
]);
const metadata = JSON.parse(probe.stdout);
const stream = metadata.streams[0];
const duration = Number(metadata.format.duration);
if (!stream || !Number.isFinite(duration) || duration < 0 || frame.stdout.length === 0) {
throw new Error('FFmpeg 无法读取视频信息或首帧: ' + videoPath);
}
return {
width: stream.width,
height: stream.height,
duration,
format: metadata.format.format_name,
thumbnail: frame.stdout,
};
}
/**
* 获取时长
*/
async getDuration (filePath: string): Promise<number> {
const { stdout } = await execFileAsync(this.ffprobePath, [
'-v',
'error',
'-show_entries',
'format=duration',
'-of',
'default=noprint_wrappers=1:nokey=1',
filePath,
], { timeout: 30000, windowsHide: true });
const duration = parseFloat(stdout.trim());
if (!Number.isFinite(duration) || duration < 0) {
throw new Error(`ffprobe 返回了无效的时长值: "${stdout.trim()}"`);
}
return duration;
}
/**
* 判断是否为 Silk 格式
*/
async isSilk (filePath: string): Promise<boolean> {
try {
const fd = openSync(filePath, 'r');
const buffer = Buffer.alloc(10);
readSync(fd, buffer, 0, 10, 0);
closeSync(fd);
const header = buffer.toString();
return header.includes('#!SILK') || header.includes('\x02#!SILK');
} catch {
return false;
}
}
/**
* 转换为 PCM
*/
async convertToPCM (filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number; }> {
try {
ensureDirExists(pcmPath);
await execFileAsync(this.ffmpegPath, [
'-y',
'-i', filePath,
'-ar', '24000',
'-ac', '1',
'-f', 's16le',
pcmPath,
], { timeout: 120000, windowsHide: true });
if (!existsSync(pcmPath)) {
throw new Error('转换PCM失败,输出文件不存在');
}
return { result: true, sampleRate: 24000 };
} catch (error: any) {
throw new Error(`FFmpeg处理转换出错: ${error.message}`);
}
}
/**
* 转换文件
*/
async convertFile (inputFile: string, outputFile: string, format: string): Promise<void> {
try {
ensureDirExists(outputFile);
const params = format === 'amr'
? [
'-i', inputFile,
'-vn', '-ac', '1',
'-ar', '8000',
'-b:a', '12.2k',
'-y',
outputFile,
]
: [
'-i', inputFile,
'-vn', '-ac', '1',
...(format === 'spx' ? ['-ar', '32000'] : []),
'-y',
outputFile,
];
await execFileAsync(this.ffmpegPath, params, { timeout: 120000, windowsHide: true });
if (!existsSync(outputFile)) {
throw new Error('转换失败,输出文件不存在');
}
} catch (error) {
console.error('Error converting file:', error);
throw new Error(`文件转换失败: ${(error as Error).message}`);
}
}
/**
* 提取缩略图
*/
async extractThumbnail (videoPath: string, thumbnailPath: string): Promise<void> {
try {
ensureDirExists(thumbnailPath);
const { stderr } = await execFileAsync(this.ffmpegPath, [
'-i', videoPath,
'-vframes', '1',
'-y', // 覆盖输出文件
thumbnailPath,
], { timeout: 30000, windowsHide: true });
if (!existsSync(thumbnailPath)) {
throw new Error(`提取缩略图失败,输出文件不存在: ${stderr}`);
}
} catch (error) {
console.error('Error extracting thumbnail:', error);
throw new Error(`提取缩略图失败: ${(error as Error).message}`);
}
}
async convertToNTSilkTct (inputFile: string, outputFile: string): Promise<void> {
ensureDirExists(outputFile);
await execFileAsync(this.ffmpegPath, [
'-v', 'error', '-y', '-i', inputFile, '-vn', '-ac', '1', '-ar', '24000',
'-c:a', 'ntsilk_s16le', '-f', 'ntsilk_s16le', outputFile,
], { timeout: 120000, windowsHide: true });
}
}
@@ -0,0 +1,176 @@
import { statSync, existsSync, writeFileSync } from 'fs';
import path from 'path';
import type { VideoInfo } from './video';
import { LogWrapper } from '@/napcat-core/helper/log';
import { FFmpegAdapterFactory } from './ffmpeg-adapter-factory';
import type { IFFmpegAdapter } from './ffmpeg-adapter-interface';
import { FFmpegExecAdapter } from './ffmpeg-exec-adapter';
import { FFmpegAddonAdapter } from './ffmpeg-addon-adapter';
export const getFFmpegPath = (tool: 'ffmpeg' | 'ffprobe', binaryPath?: string): string => {
const configuredPath = process.env[tool === 'ffmpeg' ? 'FFMPEG_PATH' : 'FFPROBE_PATH'];
if (configuredPath) return configuredPath;
const executable = tool + (process.platform === 'win32' ? '.exe' : '');
if (binaryPath) {
const localPath = path.join(binaryPath, 'ffmpeg', executable);
if (existsSync(localPath)) return localPath;
}
if (process.platform === 'darwin') {
const directories = (process.env['PATH'] ?? '').split(':').filter(Boolean);
directories.push(process.arch === 'arm64' ? '/opt/homebrew/bin' : '/usr/local/bin');
for (const directory of directories) {
const candidate = path.join(directory, executable);
if (existsSync(candidate)) return candidate;
}
}
return executable;
};
export let FFMPEG_CMD = 'ffmpeg';
export let FFPROBE_CMD = 'ffprobe';
export class FFmpegService {
private static adapter: IFFmpegAdapter | null = null;
private static initialized = false;
private static initializationError: unknown;
/**
* 初始化 FFmpeg 服务
* @param binaryPath 二进制文件路径(来自 pathWrapper.binaryPath)
* @param logger 日志记录器
*/
public static async init (binaryPath: string, logger: LogWrapper): Promise<void> {
if (this.initialized) {
return;
}
// 检查本地 ffmpeg 路径
FFMPEG_CMD = getFFmpegPath('ffmpeg', binaryPath);
FFPROBE_CMD = getFFmpegPath('ffprobe', binaryPath);
try {
this.adapter = await FFmpegAdapterFactory.getAdapter(
logger,
FFMPEG_CMD,
FFPROBE_CMD,
binaryPath
);
} catch (error) {
this.initializationError = error;
logger.logError('[FFmpeg] 媒体功能初始化失败:', error);
}
this.initialized = true;
}
public static getAdapterName (): string {
if (!this.adapter) {
throw this.initializationError ?? new Error('FFmpeg service not initialized. Please call FFmpegService.init() first.');
}
return this.adapter.name;
}
/**
* 获取 FFmpeg 适配器
*/
private static async getAdapter (): Promise<IFFmpegAdapter> {
if (!this.adapter) {
throw this.initializationError ?? new Error('FFmpeg service not initialized. Please call FFmpegService.init() first.');
}
return this.adapter;
}
public static async convertToNTSilkTct (inputFile: string, outputFile: string): Promise<void> {
const adapter = await this.getAdapter();
await adapter.convertToNTSilkTct(inputFile, outputFile);
}
/**
* 设置 FFmpeg 路径并更新适配器
* @deprecated 建议使用 init() 方法初始化
*/
public static async setFfmpegPath (ffmpegPath: string, logger: LogWrapper): Promise<void> {
const suffix = process.platform === 'win32' ? '.exe' : '';
const ffmpegCommand = path.join(ffmpegPath, 'ffmpeg' + suffix);
const ffprobeCommand = path.join(ffmpegPath, 'ffprobe' + suffix);
this.adapter = await FFmpegAdapterFactory.updateFFmpegPath(logger, ffmpegCommand, ffprobeCommand);
FFMPEG_CMD = ffmpegCommand;
FFPROBE_CMD = ffprobeCommand;
this.initializationError = undefined;
this.initialized = true;
}
/**
* 提取视频缩略图
*/
public static async extractThumbnail (videoPath: string, thumbnailPath: string): Promise<void> {
const adapter = await this.getAdapter();
await adapter.extractThumbnail(videoPath, thumbnailPath);
}
/**
* 直接发送到 QQ kernel 的旧视频元素使用 PNG 缩略图。
* Packet/forward 路径继续使用当前适配器生成的 JPEG。
*/
public static async extractLegacyVideoThumbnail (videoPath: string, thumbnailPath: string): Promise<void> {
const currentAdapter = await this.getAdapter();
if (currentAdapter instanceof FFmpegAddonAdapter) {
await currentAdapter.extractThumbnail(videoPath, thumbnailPath, 'png');
return;
}
const adapter = new FFmpegExecAdapter(FFMPEG_CMD, FFPROBE_CMD);
await adapter.extractThumbnail(videoPath, thumbnailPath);
}
/**
* 转换音频文件
*/
public static async convertAudioFmt (inputFile: string, outputFile: string, format: string): Promise<void> {
const adapter = await this.getAdapter();
await adapter.convertFile(inputFile, outputFile, format);
}
/**
* 获取音频时长
*/
public static async getDuration (filePath: string): Promise<number> {
const adapter = await this.getAdapter();
return adapter.getDuration(filePath);
}
/**
* 判断是否为 Silk 格式
*/
public static async isSilk (filePath: string): Promise<boolean> {
const adapter = await this.getAdapter();
return adapter.isSilk(filePath);
}
/**
* 转换为 PCM 格式
*/
public static async convert (filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number; }> {
const adapter = await this.getAdapter();
return adapter.convertToPCM(filePath, pcmPath);
}
/**
* 获取视频信息
*/
public static async getVideoInfo (videoPath: string, thumbnailPath: string): Promise<VideoInfo> {
const adapter = await this.getAdapter();
const fileSize = statSync(videoPath).size;
const videoInfo = await adapter.getVideoInfo(videoPath);
if (thumbnailPath && videoInfo.thumbnail) {
writeFileSync(thumbnailPath, videoInfo.thumbnail);
}
return {
width: videoInfo.width,
height: videoInfo.height,
time: videoInfo.duration,
format: videoInfo.format,
size: fileSize,
filePath: videoPath,
};
}
}
@@ -0,0 +1,11 @@
export const defaultVideoThumbB64 =
'/9j/4AAQSkZJRgABAQAAAQABAAD//gAXR2VuZXJhdGVkIGJ5IFNuaXBhc3Rl/9sAhAAKBwcIBwYKCAgICwoKCw4YEA4NDQ4dFRYRGCMfJSQiHyIhJis3LyYpNCkhIjBBMTQ5Oz4+PiUuRElDPEg3PT47AQoLCw4NDhwQEBw7KCIoOzs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozv/wAARCAF/APADAREAAhEBAxEB/8QBogAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoLEAACAQMDAgQDBQUEBAAAAX0BAgMABBEFEiExQQYTUWEHInEUMoGRoQgjQrHBFVLR8CQzYnKCCQoWFxgZGiUmJygpKjQ1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4eLj5OXm5+jp6vHy8/T19vf4+foBAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKCxEAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDiAayNxwagBwNAC5oAM0xBmgBM0ANJoAjY0AQsaBkTGgCM0DEpAFAC0AFMBaACgAoEJTASgQlACUwCgQ4UAOFADhQA4UAOFADxQIkBqDQUGgBwagBQaBC5pgGaAELUAMLUARs1AETGgBhNAxhoASkAUALQIKYxaBBQAUwEoAQ0CEoASmAUAOoEKKAHCgBwoAeKAHigQ7NZmoZpgLmgBd1Ahd1ABupgNLUAMLUAMY0AMJoAYaAENACUCCgAoAWgAoAWgBKYCUAJQISgApgLQAooEOFACigB4oAeKBDxQAVmaiZpgGaAFzQAbqAE3UAIWpgNJoAYTQIaaAEoAQ0CEoASgBaACgBaACmAUAJQAlAgoAKYC0AKKBCigB4FADgKBDwKAHigBuazNRM0DEzTAM0AJmgAzQAhNAhpNACGmA2gQlACUCEoAKACgBaAFpgFACUAJQAUCCmAUALQIcBQA4CgB4FADgKBDhQA4UAMzWZqNzTGJQAZoATNABmgBKAEoEIaYCUCEoASgQlABQAtABQAtMBKACgAoEFABimAYoEKBQA4CgB4FADwKBDgKAFFADhQBCazNhKAEpgFACUAFACUAFAhDTAbQISgAoEJQAUALQAtMAoAKADFABigQYoAMUALimIUCgBwFAh4FADgKAHUALQAtAENZmwlACUwEoAKAEoAKACgQlMBpoEJQAUCCgBcUAFABTAXFAC4oAMUAGKBBigAxQIKYCigQ8UAOFADhQAtAC0ALQBDWZqJQMSgBKYBQAlABQISgBKYCGgQlAC0CCgBcUAFABTAUCkA7FMAxQAYoEJQAUCCmAooEOFADxQA4UAFAC0ALQBDWZqJQAlACUxhQAlABQIKAEoASmISgBcUCCgBaACgBcUAKBQAuKYC0CEoAQ0AJQISmAooEPFADhQA4UALQAtAC0AQ1maiUAFACUAJTAKAEoAKAEoAMUxBigAxQIWgAoAKAFAoAWgBaYBQIQ0ANNACUCCmIUUAOFADxQA4UALQAtABQBFWZqFACUAFACYpgFACUAFACUAFAgxTEFABQAUALQAooAWgAoAKYDTQIaaAEpiCgQ4UAOFAh4oGOFAC0ALSAKYEdZmglABQAUDDFACUwEoASgAoAKBBQIKYBQAUALQAtAC0AJQAhpgNJoENJoATNMQCgQ8UCHigB4oAWgYtABQAUAMrM0CgAoAKADFACUxiUAJQAlAgoAKYgoAKACgYtAC0AFAhDTAQmgBhNAhpNACZpiFBoEPFAEi0CHigB1ABQAUDEoAbWZoFABQAtABTAQ0ANNAxDQAlAhaAEpiCgAoGFAC0AFABmgBCaYhpNADCaBDSaBBmgABpiJFNAEimgB4NADqAFzQAlACE0AJWZoFAC0AFAC0wEIoAaaAG0AJQAUCCgApjCgAoAKADNABmgBpNMQ0mgBpNAhhNAgzQAoNADwaAHqaAJAaBDgaYC5oATNACZoAWszQKACgBaBDqYCGgBpoAYaBiUCCgBKYBQMKACgAoAM0AITQIaTQA0mmA0mgQ3NAhKAHCgBwNADwaAHg0AOBpiFzQAZoATNAD6zNAoAKAFoEOpgBoAaaAGGmAw0AJmgAzQMM0AGaADNABmgBM0AITQIaTQAhNMQw0AJQIKAFFADhQA4GgBwNADs0xC5oAM0CDNAEtZmoUCCgBaAHUwCgBppgRtQAw0ANzQAZoAM0AGaADNABmgBKAEoAQ0ANNMQhoEJQAlMBaQDgaAFBoAcDTAdmgQuaADNAgzQBPWZqFAgoAWgBaYC0CGmmBG1AyM0ANJoATNACZoAXNABmgAzQAUAJQAhoAQ0xDTQISmAUALQAUgHA0AKDTAdmgQuaBBQAtAFiszQKACgBaAFFMAoEIaYEbUDI2oAYaAEoASgAzQAuaACgAoAKAENMQ00AJTEFAhKACgAoAXNACg0AOBoAWgQtAC0AWazNAoAKACgBaYBQIQ0AMNMYw0AMIoAbQAlMAoAKACgAzSAKYhKAENACUxBQIKACgBKACgBaAHCgQ4UALQAUAWqzNAoAKACgApgFACGgQ00xjTQAwigBCKAG4pgJQAlABQAUCCgBKACgBKYgoEFABQISgAoAWgBRQA4UALQAUCLdZmoUAFABQAlMAoASgBDQA00wENACYoATFMBpFADSKAEoEJQAUAFABQAlMQtAgoASgQUAJQAUAKKAHCgBaBBQBbrM1CgAoAKACmAUAJQAlADaYBQAlACYpgIRQA0igBpFAhtABQAUAFMAoEFABQIKAEoASgQUALQAooAWgQUAW81mbC0CCgApgFACUAIaAEpgJQAUAFABQAhFMBpFADSKAGkUCExQAYoAMUAGKADFMQYoAMUCExSATFABQIKYBQAtABQIt5qDYM0ALmgQtIApgIaAENADaACmAlAC0ALQAUwGkUANIoAaRQAmKBBigAxQAYoAMUAGKBBigBMUAJigQmKAExTAKBC0AFAFnNQaig0AKDQAtAgoASgBDQAlMBKACgAFADhQAtMBCKAGkUAIRQAmKADFABigQmKADFACYoAXFABigQmKAExQAmKBCYpgJigAoAnzUGgZoAcDQAuaBC0AJQAhoASmAlABQAtADhQAtMAoATFACEUAJigAxQAYoATFAhMUAFABQAuKADFABigBpWgBCKBCYpgJigB+ag0DNADgaBDgaAFzQITNACUAJTAKACgBRQAopgOoAWgBKAEoAKACgAoASgBpoEJQAooAWgBaBhigBMUCEIoAQigBMUAJSLCgBQaBDgaQC5oEFACUwCgBKACmAtADhQA4UALQAUAJQAUAJQAUAJQAhoENoAWgBRQAooGLQAUAGKAGkUAIRQIZSKEoGKKBDhQAUCCgAoAKBBQAUwFoGKKAHCgBaACgAoASgAoASgBCaAEoEJmgAoAUGgBQaAHZoGFABQAUANoAjpDEoAWgBaAFoEFACUALQAUCCmAUAOFAxRQAtAC0AJQAUAJQAmaBDSaAEzQAmaYBmgBQaAHA0gFzQAuaBhmgAzQAlAEdIYUALQAtAgoAKAEoEFAC0AFMAoAUUDFFAC0ALQAUAJQAhoENNACE0wEoATNABmgBc0ALmgBc0gDNAC5oATNABmgBKRQlACigB1AgoASgQlABTAWgBKACgBaBi0ALQAZoAM0AFACGgQ00wENACUAJQAUCFzQMM0ALmgAzQAZoAM0AGaQC0igoAUUALQIWgBDQISmAUAFACUAFABQAuaBi5oAM0AGaBBmgBKAEpgIaAG0AJQAUCFoAM0DDNAC5oATNABmgAzQBJUlBQAooAWgQtACGmIaaACgAoASgBKACgBc0DCgQUAGaADNABTASgBDQAlACUAFAgoAKBhQAUAFABQAlAE1SUFAxRQIWgQtMBDQIQ0AJQAlAhKBiUAFABmgBc0AGaADNABTAKACgBKAEoASgQlABQAUAFAC0AFACUAFAE1SaBQAUCHCgQtMBKBCUAJQISgBDQA00DEzQAuaADNMBc0AGaADNABQAUAJQAlABQISgAoAKACgBaACgBKAEoAnqTQSgBRQIcKBC0xCUAJQISgBKAENADDQAmaYwzQAuaADNAC0AFABQAUAFAhKACgBKACgAoAWgAoELQAlAxKAJqk0EoAWgQooELTEFADaBCUABoENNMY00ANNAwzQAZoAXNAC0AFAC0CFoASgAoASgBKACgAoAWgQtABQAUANNAyWpNAoAKBCimIWgQUCEoASmIQ0ANNADTQMaaAEoGLmgAzQAtADhQIWgBaACgQhoASgYlACUALQIWgBaACgBKAENAyWpNBKYBQIcKBC0CEoEJTAKBCUANNADDQMQ0ANoGFAC5oAUGgBwNAhRQIWgBaAENACGgBtAwoAKAFzQIXNABmgAoAQ0DJKRoJQAtAhRQSLQIKYCUCCgBDQA00AMNAxpoGNoAM0AGaAFBoAcDQIcKBDqACgBDQAhoAQ0DEoAKADNAC5oEGaBhmgAoAkpGgUCCgQooELQIKYhKACgBKAGmgBpoGMNAxDQAlAwzQIUUAOFAhwoAcKBC0AJQAhoGNNACUAFABQAZoAXNABQAUAS0ixKACgQoNAhaYgoEFACUABoAaaAGmgYw0DENAxtABQAooEOFADhQIcKAFoASgBDQAhoGJQAUAF Line truncated
export interface VideoInfo {
width: number;
height: number;
time: number;
format: string;
size: number;
filePath: string;
}
@@ -0,0 +1,122 @@
import * as crypto from 'node:crypto';
import { PacketMsg } from '@/napcat-core/packet/message/message';
interface ForwardMsgJson {
app: string;
config: ForwardMsgJsonConfig,
desc: string,
extra: ForwardMsgJsonExtra,
meta: ForwardMsgJsonMeta,
prompt: string,
ver: string,
view: string;
}
interface ForwardMsgJsonConfig {
autosize: number,
forward: number,
round: number,
type: string,
width: number;
}
interface ForwardMsgJsonExtra {
filename: string,
tsum: number,
}
interface ForwardMsgJsonMeta {
detail: ForwardMsgJsonMetaDetail;
}
interface ForwardMsgJsonMetaDetail {
news: {
text: string;
}[],
resid: string,
source: string,
summary: string,
uniseq: string;
}
interface ForwardAdaptMsg {
senderName?: string;
isGroupMsg?: boolean;
msg?: ForwardAdaptMsgElement[];
}
interface ForwardAdaptMsgElement {
preview?: string;
}
export class ForwardMsgBuilder {
private static build (resId: string, msg: ForwardAdaptMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string, uuid?: string): ForwardMsgJson {
const id = uuid ?? crypto.randomUUID();
const isGroupMsg = msg.some(m => m.isGroupMsg);
if (!source) {
source = msg.length === 0 ? '聊天记录' : (isGroupMsg ? '群聊的聊天记录' : msg.map(m => m.senderName).filter((v, i, a) => a.indexOf(v) === i).slice(0, 4).join('和') + '的聊天记录');
}
if (!news) {
news = msg.length === 0
? [{
text: 'Nya~ This message is send from NapCat.Packet!',
}]
: msg.map(m => ({
text: `${m.senderName}: ${m.msg?.map(msg => msg.preview).join('')}`,
}));
}
if (!summary) {
summary = `查看${msg.length}条转发消息`;
}
if (!prompt) {
prompt = '[聊天记录]';
}
return {
app: 'com.tencent.multimsg',
config: {
autosize: 1,
forward: 1,
round: 1,
type: 'normal',
width: 300,
},
desc: prompt,
extra: {
filename: id,
tsum: msg.length,
},
meta: {
detail: {
news,
resid: resId,
source,
summary,
uniseq: id,
},
},
prompt,
ver: '0.0.0.5',
view: 'contact',
};
}
static fromResId (resId: string): ForwardMsgJson {
return this.build(resId, []);
}
static fromPacketMsg (resId: string, packetMsg: PacketMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string, uuid?: string): ForwardMsgJson {
return this.build(resId, packetMsg.map(msg => ({
senderName: msg.senderName,
isGroupMsg: msg.groupId !== undefined,
msg: msg.msg.map(m => ({
preview: m.valid ? m.toPreview() : '[该消息类型暂不支持查看]',
})),
})),
source,
news,
summary,
prompt,
uuid
);
}
}
+61
View File
@@ -0,0 +1,61 @@
import type { NodeIKernelRichMediaService } from '../services/NodeIKernelRichMediaService';
type GroupFileService = Pick<NodeIKernelRichMediaService, 'getGroupFileInfo' | 'deleteGroupFile'>;
function withTimeout<T> (promise: Promise<T>, operation: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`${operation}超时,请重新查询群文件列表确认文件状态`)), 15000);
promise.then(resolve, reject).finally(() => clearTimeout(timer));
});
}
function assertBusId (busId: number): void {
if (!Number.isInteger(busId) || busId <= 0 || busId > 0xFFFFFFFF) {
throw new Error('Invalid bus_id');
}
}
export async function deleteGroupFiles (service: GroupFileService, groupCode: string, files: string[], busIds?: number[]) {
if (!files.length || files.some(fileId => !fileId)) {
throw new Error('Invalid file_id');
}
if (busIds && busIds.length !== files.length) {
throw new Error('bus_id 数量必须与文件数量一致');
}
const resolvedBusIds: number[] = [];
for (const [index, fileId] of files.entries()) {
let busId = busIds?.[index];
if (busId === undefined) {
const info = await withTimeout(service.getGroupFileInfo(groupCode, fileId), '查询群文件信息');
if (info?.result !== 0) {
throw new Error(`查询群文件信息失败: ${info?.errMsg || info?.result}`);
}
const detail = info.groupFileInfoResult;
if (detail?.retCode !== 0 || detail.fileInfo?.fileId !== fileId) {
throw new Error(`查询群文件信息失败: ${detail?.clientWording || detail?.retMsg || '未返回目标文件信息'}`);
}
busId = detail.fileInfo.busId;
}
assertBusId(busId);
resolvedBusIds.push(busId);
}
const response = await withTimeout(service.deleteGroupFile(groupCode, resolvedBusIds, files), '删除群文件');
if (response?.result !== 0) {
throw new Error(`删除群文件失败: ${response?.errMsg || response?.result}`);
}
const detail = response.transGroupFileResult;
if (detail?.result?.retCode !== 0) {
throw new Error(`删除群文件失败: ${detail?.result?.clientWording || detail?.result?.retMsg || detail?.result?.retCode || '缺少删除结果'}`);
}
if (!Array.isArray(detail.successFileIdList) || !Array.isArray(detail.failFileIdList)) {
throw new Error('删除群文件失败: 缺少逐文件删除结果');
}
const missing = files.filter(fileId => !detail.successFileIdList.includes(fileId));
if (detail.failFileIdList.length || missing.length) {
const failed = [...new Set([...detail.failFileIdList, ...missing])];
throw new Error(`删除群文件失败: ${failed.join(', ')}`);
}
return response;
}
+345
View File
@@ -0,0 +1,345 @@
import winston, { format, transports } from 'winston';
import { truncateString } from 'napcat-common/src/helper';
import path from 'node:path';
import fs from 'node:fs/promises';
import { NTMsgAtType, ChatType, ElementType, MessageElement, RawMessage, SelfInfo } from '@/napcat-core/index';
import { ILogWrapper } from 'napcat-common/src/log-interface';
import EventEmitter from 'node:events';
export enum LogLevel {
DEBUG = 'debug',
INFO = 'info',
WARN = 'warn',
ERROR = 'error',
FATAL = 'fatal',
}
function getFormattedTimestamp () {
const now = new Date();
const year = now.getFullYear();
const month = (now.getMonth() + 1).toString().padStart(2, '0');
const day = now.getDate().toString().padStart(2, '0');
const hours = now.getHours().toString().padStart(2, '0');
const minutes = now.getMinutes().toString().padStart(2, '0');
const seconds = now.getSeconds().toString().padStart(2, '0');
const milliseconds = now.getMilliseconds().toString().padStart(3, '0');
return `${year}-${month}-${day}_${hours}-${minutes}-${seconds}.${milliseconds}`;
}
const logEmitter = new EventEmitter();
export type LogListener = (msg: string) => void;
class Subscription {
public static MAX_HISTORY = 100;
public static history: string[] = [];
subscribe (listener: LogListener) {
for (const history of Subscription.history) {
try {
listener(history);
} catch {
// ignore
}
}
logEmitter.on('log', listener);
}
unsubscribe (listener: LogListener) {
logEmitter.off('log', listener);
}
notify (msg: string) {
logEmitter.emit('log', msg);
if (Subscription.history.length >= Subscription.MAX_HISTORY) {
Subscription.history.shift();
}
Subscription.history.push(msg);
}
}
export const logSubscription = new Subscription();
export class LogWrapper implements ILogWrapper {
fileLogEnabled = false;
consoleLogEnabled = true;
logger: winston.Logger;
private logPath: string;
private fileTransportAdded = false;
private fileLogLevel: string = LogLevel.DEBUG;
constructor (logDir: string) {
const filename = `${getFormattedTimestamp()}.log`;
this.logPath = path.join(logDir, filename);
this.logger = winston.createLogger({
level: 'debug',
format: format.combine(
format.timestamp({ format: 'MM-DD HH:mm:ss' }),
format.printf(({ timestamp, level, message, ...meta }) => {
const userInfo = meta['userInfo'] ? `${meta['userInfo']} | ` : '';
return `${timestamp} [${level}] ${userInfo}${message}`;
})
),
transports: [
new transports.Console({
format: format.combine(
format.colorize(),
format.printf(({ timestamp, level, message, ...meta }) => {
const userInfo = meta['userInfo'] ? `${meta['userInfo']} | ` : '';
return `${timestamp} [${level}] ${userInfo}${message}`;
})
),
}),
],
});
this.setLogSelfInfo({ nick: '', uid: '' });
this.cleanOldLogs(logDir);
}
cleanOldLogs (logDir: string) {
const oneWeekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
fs.readdir(logDir).then((files) => {
files.forEach((file) => {
const filePath = path.join(logDir, file);
this.deleteOldLogFile(filePath, oneWeekAgo);
});
}).catch((err) => {
this.logError('Failed to read log directory', err);
});
}
private deleteOldLogFile (filePath: string, oneWeekAgo: number) {
fs.stat(filePath).then((stats) => {
if (stats.mtime.getTime() < oneWeekAgo) {
fs.unlink(filePath).catch((err) => {
if (err) {
if (err.code === 'ENOENT') {
this.logWarn(`File already deleted: ${filePath}`);
} else {
this.logError('Failed to delete old log file', err);
}
} else {
this.log(`Deleted old log file: ${filePath}`);
}
});
}
}).catch((err) => {
this.logError('Failed to get file stats', err);
});
}
setFileAndConsoleLogLevel (fileLogLevel: LogLevel, consoleLogLevel: LogLevel) {
this.fileLogLevel = fileLogLevel;
this.logger.transports.forEach((transport) => {
if (transport instanceof transports.File) {
transport.level = fileLogLevel;
} else if (transport instanceof transports.Console) {
transport.level = consoleLogLevel;
}
});
}
setLogSelfInfo (selfInfo: { nick: string; uid: string; }) {
const userInfo = `${selfInfo.nick}`;
this.logger.defaultMeta = { userInfo };
}
setFileLogEnabled (isEnabled: boolean) {
this.fileLogEnabled = isEnabled;
if (isEnabled && !this.fileTransportAdded) {
this.fileTransportAdded = true;
this.logger.add(new transports.File({
filename: this.logPath,
level: this.fileLogLevel,
maxsize: 5 * 1024 * 1024, // 5MB
maxFiles: 5,
}));
}
this.logger.transports.forEach((transport) => {
if (transport instanceof transports.File) {
transport.silent = !isEnabled;
}
});
}
setConsoleLogEnabled (isEnabled: boolean) {
this.consoleLogEnabled = isEnabled;
this.logger.transports.forEach((transport) => {
if (transport instanceof transports.Console) {
transport.silent = !isEnabled;
}
});
}
formatMsg (msg: any[]) {
return msg
.map((msgItem) => {
if (msgItem instanceof Error) {
return msgItem.stack;
} else if (typeof msgItem === 'object') {
return JSON.stringify(truncateString(JSON.parse(JSON.stringify(msgItem, null, 2))));
}
return msgItem;
})
.join(' ');
}
_log (level: LogLevel, ...args: any[]) {
if (this.consoleLogEnabled || this.fileLogEnabled) {
// eslint-disable-next-line no-control-regex
const message = this.formatMsg(args).replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]|\x1B\[[0-?]*[ -/]*[@-~]|\x1B/g, '');
if (this.consoleLogEnabled && this.fileLogEnabled) {
this.logger.log(level, message);
} else if (this.consoleLogEnabled) {
this.logger.log(level, message);
} else if (this.fileLogEnabled) {
this.logger.log(level, message);
}
logSubscription.notify(JSON.stringify({ level, message }));
}
}
log (...args: any[]) {
this._log(LogLevel.INFO, ...args);
}
logDebug (...args: any[]) {
this._log(LogLevel.DEBUG, ...args);
}
logError (...args: any[]) {
this._log(LogLevel.ERROR, ...args);
}
logWarn (...args: any[]) {
this._log(LogLevel.WARN, ...args);
}
logFatal (...args: any[]) {
this._log(LogLevel.FATAL, ...args);
}
logMessage (msg: RawMessage, selfInfo: SelfInfo) {
const isSelfSent = msg.senderUin === selfInfo.uin;
if (msg.elements[0]?.elementType === ElementType.GreyTip) {
return;
}
this.log(`${isSelfSent ? '发送 ->' : '接收 <-'} ${rawMessageToText(msg)}`);
}
}
export function rawMessageToText (msg: RawMessage, recursiveLevel = 0): string {
if (recursiveLevel > 2) {
return '...';
}
const tokens: string[] = [];
if (msg.chatType === ChatType.KCHATTYPEC2C) {
tokens.push(`私聊 (${msg.peerUin})`);
} else if (msg.chatType === ChatType.KCHATTYPEGROUP) {
if (recursiveLevel < 1) {
tokens.push(`群聊 [${msg.peerName}(${msg.peerUin})]`);
}
if (msg.senderUin !== '0') {
tokens.push(`[${msg.sendMemberName || msg.sendRemarkName || msg.sendNickName}(${msg.senderUin})]`);
}
} else if (msg.chatType === ChatType.KCHATTYPEDATALINE) {
tokens.push('移动设备');
} else {
tokens.push(`临时消息 (${msg.peerUin})`);
}
for (const element of msg.elements) {
tokens.push(msgElementToText(element, msg, recursiveLevel));
}
return tokens.join(' ');
}
function msgElementToText (element: MessageElement, msg: RawMessage, recursiveLevel: number): string {
if (element.textElement) {
return textElementToText(element.textElement);
}
if (element.replyElement) {
return replyElementToText(element.replyElement, msg, recursiveLevel);
}
if (element.picElement) {
return '[图片]';
}
if (element.fileElement) {
if (element.fileElement.fileUuid) {
return `[文件 ${element.fileElement.fileName}]`;
} else if (element.elementType === ElementType.TOFURECORD) {
return `[在线文件 ${element.fileElement.fileName}]`;
} else if (element.elementType === ElementType.ONLINEFOLDER) {
return `[在线文件夹 ${element.fileElement.fileName}/]`;
}
}
if (element.videoElement) {
return '[视频]';
}
if (element.pttElement) {
return `[语音 ${element.pttElement.duration}s]`;
}
if (element.arkElement) {
return '[卡片消息]';
}
if (element.faceElement) {
return `[表情 ${element.faceElement.faceText ?? ''}]`;
}
if (element.marketFaceElement) {
return element.marketFaceElement.faceName;
}
if (element.markdownElement) {
if (element.markdownElement?.mdSummary) {
return element.markdownElement.mdSummary;
} else {
return '[Markdown 消息]';
}
}
if (element.multiForwardMsgElement) {
return '[转发消息]';
}
if (element.elementType === ElementType.GreyTip) {
return '[灰条消息]';
} else if (element.elementType === ElementType.FILE) {
return '[文件发送中]';
}
return `[未实现 (ElementType = ${element.elementType})]`;
}
function textElementToText (textElement: any): string {
if (textElement.atType === NTMsgAtType.ATTYPEUNKNOWN) {
const originalContentLines = textElement.content.split('\n');
return `${originalContentLines[0]}${originalContentLines.length > 1 ? ' ...' : ''}`;
} else if (textElement.atType === NTMsgAtType.ATTYPEALL) {
return '@全体成员';
} else if (textElement.atType === NTMsgAtType.ATTYPEONE) {
return `${textElement.content} (${textElement.atUid})`;
}
return '';
}
function replyElementToText (replyElement: any, msg: RawMessage, recursiveLevel: number): string {
const recordMsgOrNull = msg.records.find((record) => replyElement.sourceMsgIdInRecords === record.msgId);
return `[回复消息 ${recordMsgOrNull && recordMsgOrNull.peerUin !== '284840486' && recordMsgOrNull.peerUin !== '1094950020'
? rawMessageToText(recordMsgOrNull, recursiveLevel + 1)
: `未找到消息记录 (MsgId = ${replyElement.sourceMsgIdInRecords})`
}]`;
}
+14
View File
@@ -0,0 +1,14 @@
import { fileTypeFromFile } from 'file-type';
import { PicType } from '../types';
export async function getFileTypeForSendType (picPath: string): Promise<PicType> {
const fileTypeResult = (await fileTypeFromFile(picPath))?.ext ?? 'jpg';
const picTypeMap: { [key: string]: PicType } = {
// 'webp': PicType.NEWPIC_WEBP,
gif: PicType.NEWPIC_GIF,
// 'png': PicType.NEWPIC_APNG,
// 'jpg': PicType.NEWPIC_JPEG,
// 'jpeg': PicType.NEWPIC_JPEG,
// 'bmp': PicType.NEWPIC_BMP,
};
return picTypeMap[fileTypeResult] ?? PicType.NEWPIC_JPEG;
}
@@ -0,0 +1,58 @@
export interface OfflineRecoveryOptions {
// 当前账号是否在线
isOnline: () => boolean;
// 刚下线:同步 WebUI 状态
onOffline: (tips: string) => void;
// 静默离线期间账号自己恢复了,不需要重启
onRecovered: () => void;
// 重启 Worker;返回 false 或抛错表示失败,之后可以再次触发
restart: (tips: string) => Promise<boolean>;
// 被踢下线后多久重启。常规状态轮询为 3 秒,留出一个轮询周期,确保浏览器先清除旧二维码
kickedDelayMs?: number;
// 静默离线后多久重启。网络短暂断开时 QQ 也可能报离线,先给它自己重连的机会,
// 不然一次网络抖动就会把整个 Worker 重启掉
selfOfflineDelayMs?: number;
}
/**
* 下线恢复:被踢下线(KickedOffLine)和静默离线(SelfOffline)共用。
* 返回的函数在每次收到下线事件时调用,allowSelfRecovery 为 true 表示静默离线。
*/
export function createOfflineRecovery ({
isOnline,
onOffline,
onRecovered,
restart,
kickedDelayMs = 3_500,
selfOfflineDelayMs = 60_000,
}: OfflineRecoveryOptions) {
let restartRequested = false;
let selfRecoveryTimer: ReturnType<typeof setTimeout> | undefined;
return (tips: string, allowSelfRecovery: boolean) => {
onOffline(tips);
if (restartRequested) {
// 被踢时离线状态可能先到:还在等静默离线自行恢复的话,不用再等了
if (allowSelfRecovery || !selfRecoveryTimer) return;
clearTimeout(selfRecoveryTimer);
}
restartRequested = true;
// 不在 RefreshQRcode 请求里直接重启 Worker,否则请求在返回前就被中断,
// 浏览器只能得到 ERR_EMPTY_RESPONSE;延后调度也让前端先看到「二维码已清空、正在恢复」
const timer = setTimeout(() => {
selfRecoveryTimer = undefined;
if (allowSelfRecovery && isOnline()) {
restartRequested = false;
onRecovered();
return;
}
restart(tips).then((ok) => {
if (!ok) restartRequested = false;
}, () => {
restartRequested = false;
});
}, allowSelfRecovery ? selfOfflineDelayMs : kickedDelayMs);
selfRecoveryTimer = allowSelfRecovery ? timer : undefined;
};
}
@@ -0,0 +1,24 @@
/** Self-reported profile region only; never infer a location from other fields. */
export interface ProfileRegion {
country?: string;
province?: string;
city?: string;
}
function text (value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
/** Keep one source together: mixing old/new components can invent a region. */
export function selectProfileRegion (fresh?: ProfileRegion, fallback?: ProfileRegion): Required<ProfileRegion> {
for (const source of [fresh, fallback]) {
const region = { country: text(source?.country), province: text(source?.province), city: text(source?.city) };
if (Object.values(region).some(Boolean)) return region;
}
return { country: '', province: '', city: '' };
}
export function profileRegionArea (source?: ProfileRegion): string {
const region = selectProfileRegion(source);
return [...new Set(Object.values(region).filter(Boolean))].join(' ');
}
@@ -0,0 +1,21 @@
import { LogWrapper } from '@/napcat-core/helper/log';
export function proxyHandlerOf (logger: LogWrapper) {
return {
get (target: any, prop: any, receiver: any) {
if (typeof target[prop] === 'undefined') {
// 如果方法不存在,返回一个函数,这个函数调用existentMethod
return (..._args: unknown[]) => {
logger.logDebug(`${target.constructor.name} has no method ${prop}`);
};
}
// 如果方法存在,正常返回
return Reflect.get(target, prop, receiver);
},
};
}
export function proxiedListenerOf<T extends object> (listener: T, logger: LogWrapper) {
return new Proxy<T>(listener, proxyHandlerOf(logger));
}
@@ -0,0 +1,121 @@
import fs from 'node:fs';
import { systemPlatform } from 'napcat-common/src/system';
import { getDefaultQQVersionConfigInfo, getQQPackageInfoPath, getQQVersionConfigPath, parseAppidFromMajor, parseAppidFromMajorV2 } from 'napcat-common/src/helper';
import AppidTable from '@/napcat-core/external/appid.json';
import { LogWrapper } from './log';
import { getMajorPath } from '@/napcat-core/index';
import { QQAppidTableType, QQPackageInfoType, QQVersionConfigType } from 'napcat-common/src/types';
import path from 'node:path';
export class QQBasicInfoWrapper {
QQMainPath: string | undefined;
QQPackageInfoPath: string | undefined;
QQVersionConfigPath: string | undefined;
isQuickUpdate: boolean | undefined;
QQVersionConfig: QQVersionConfigType | undefined;
QQPackageInfo: QQPackageInfoType | undefined;
QQVersionAppid: string | undefined;
QQVersionQua: string | undefined;
context: { logger: LogWrapper; };
constructor (context: { logger: LogWrapper; }) {
// 基础目录获取
this.context = context;
this.QQMainPath = process.execPath;
if (process.platform === 'darwin' && path.basename(this.QQMainPath) === 'QQ Helper') {
// 实用进程特殊处理 实用进程目录和QQ差远了
this.QQMainPath = path.resolve(path.dirname(this.QQMainPath), '../../../../', 'MacOS', 'QQ');
}
this.QQVersionConfigPath = getQQVersionConfigPath(this.QQMainPath);
// 基础信息获取 无快更则启用默认模板填充
this.isQuickUpdate = !!this.QQVersionConfigPath;
this.QQVersionConfig = this.isQuickUpdate
? JSON.parse(fs.readFileSync(this.QQVersionConfigPath!).toString())
: getDefaultQQVersionConfigInfo();
this.QQPackageInfoPath = getQQPackageInfoPath(this.QQMainPath, this.QQVersionConfig?.curVersion);
this.QQPackageInfo = JSON.parse(fs.readFileSync(this.QQPackageInfoPath).toString());
const { appid: IQQVersionAppid, qua: IQQVersionQua } = this.getAppidV2();
this.QQVersionAppid = IQQVersionAppid;
this.QQVersionQua = IQQVersionQua;
}
// 基础函数
getQQBuildStr () {
return this.QQVersionConfig?.curVersion.split('-')[1] ?? this.QQPackageInfo?.buildVersion;
}
getFullQQVersion () {
const version = this.isQuickUpdate ? this.QQVersionConfig?.curVersion : this.QQPackageInfo?.version;
if (!version) throw new Error('QQ版本获取失败');
return version;
}
requireMinNTQQBuild (buildStr: string) {
const currentBuild = +(this.getQQBuildStr() ?? '0');
if (currentBuild === 0) throw new Error('QQBuildStr获取失败');
return currentBuild >= parseInt(buildStr);
}
// 此方法不要直接使用
getQUAFallback () {
const platformMapping: Partial<Record<NodeJS.Platform, string>> = {
win32: `V1_WIN_${this.getFullQQVersion()}_${this.getQQBuildStr()}_GW_B`,
darwin: `V1_MAC_${this.getFullQQVersion()}_${this.getQQBuildStr()}_GW_B`,
linux: `V1_LNX_${this.getFullQQVersion()}_${this.getQQBuildStr()}_GW_B`,
};
return platformMapping[systemPlatform] ?? (platformMapping.win32)!;
}
getAppIdFallback () {
const platformMapping: Partial<Record<NodeJS.Platform, string>> = {
win32: '537246092',
darwin: '537246140',
linux: '537246140',
};
return platformMapping[systemPlatform] ?? '537246092';
}
getAppidV2 (): { appid: string; qua: string; } {
// 通过已有表 性能好
const appidTable = AppidTable as unknown as QQAppidTableType;
const fullVersion = this.getFullQQVersion();
if (fullVersion) {
// 同一 Linux 版本的 x64 和 arm64 使用不同的 AppID,优先匹配架构。
const data = appidTable[`${fullVersion}-${process.arch}`] ?? appidTable[fullVersion];
if (data) {
return data;
}
}
// 通过Major拉取 性能差
try {
const majorAppid = this.getAppidV2ByMajor(fullVersion);
if (majorAppid) {
this.context.logger.log('[QQ版本兼容性检测] 当前版本Appid未内置 通过Major获取 为了更好的性能请尝试更新NapCat');
return { appid: majorAppid, qua: this.getQUAFallback() };
}
} catch {
this.context.logger.log('[QQ版本兼容性检测] 通过Major 获取Appid异常 请检测NapCat/QQNT是否正常');
}
// 最终兜底为老版本
this.context.logger.log('[QQ版本兼容性检测] 获取Appid异常 请检测NapCat/QQNT是否正常');
this.context.logger.log(`[QQ版本兼容性检测] ${fullVersion} 版本兼容性不佳,可能会导致一些功能无法正常使用`);
return { appid: this.getAppIdFallback(), qua: this.getQUAFallback() };
}
getAppidV2ByMajor (QQVersion: string) {
if (!this.QQMainPath) {
throw new Error('QQMainPath未定义 无法通过Major获取Appid');
}
const majorPath = getMajorPath(this.QQMainPath, QQVersion);
// 优先通过 QQAppId/ 标记搜索
const appidV2 = parseAppidFromMajorV2(majorPath);
if (appidV2) {
return appidV2;
}
// 回落到旧方式
const appid = parseAppidFromMajor(majorPath);
return appid;
}
}
+140
View File
@@ -0,0 +1,140 @@
import { RequestUtil } from 'napcat-common/src/request';
import { LogWrapper } from './log';
interface ServerRkeyData {
group_rkey: string;
private_rkey: string;
expired_time: number;
}
interface OneBotApiRet {
status: string,
retcode: number,
data: ServerRkeyData,
message: string,
wording: string,
}
interface UrlFailureInfo {
count: number;
lastTimestamp: number;
}
export class RkeyManager {
serverUrl: string[] = [];
logger: LogWrapper;
private rkeyData: ServerRkeyData = {
group_rkey: '',
private_rkey: '',
expired_time: 0,
};
private urlFailures: Map<string, UrlFailureInfo> = new Map();
private readonly FAILURE_LIMIT: number = 4;
private readonly ONE_DAY: number = 24 * 60 * 60 * 1000;
constructor (serverUrl: string[], logger: LogWrapper) {
this.logger = logger;
this.serverUrl = serverUrl;
}
async getRkey () {
const availableUrls = this.getAvailableUrls();
if (availableUrls.length === 0) {
this.logger.logError('[Rkey] 所有服务均已禁用, 图片使用FallBack机制');
throw new Error('获取rkey失败:所有服务URL均已被禁用');
}
if (this.isExpired()) {
try {
await this.refreshRkey();
} catch (e) {
throw new Error(`${e}`);
}
}
return this.rkeyData;
}
private getAvailableUrls (): string[] {
return this.serverUrl.filter(url => !this.isUrlDisabled(url));
}
private isUrlDisabled (url: string): boolean {
const failureInfo = this.urlFailures.get(url);
if (!failureInfo) return false;
const now = new Date().getTime();
// 如果已经过了一天,重置失败计数
if (now - failureInfo.lastTimestamp > this.ONE_DAY) {
failureInfo.count = 0;
this.urlFailures.set(url, failureInfo);
return false;
}
return failureInfo.count >= this.FAILURE_LIMIT;
}
private updateUrlFailure (url: string) {
const now = new Date().getTime();
const failureInfo = this.urlFailures.get(url) || { count: 0, lastTimestamp: 0 };
// 如果已经过了一天,重置失败计数
if (now - failureInfo.lastTimestamp > this.ONE_DAY) {
failureInfo.count = 1;
} else {
failureInfo.count++;
}
failureInfo.lastTimestamp = now;
this.urlFailures.set(url, failureInfo);
if (failureInfo.count >= this.FAILURE_LIMIT) {
this.logger.logError(`[Rkey] URL ${url} 已被禁用,失败次数达到 ${this.FAILURE_LIMIT} 次`);
}
}
isExpired (): boolean {
const now = new Date().getTime() / 1000;
return now > this.rkeyData.expired_time;
}
async refreshRkey () {
const availableUrls = this.getAvailableUrls();
if (availableUrls.length === 0) {
this.logger.logError('[Rkey] 所有服务均已禁用');
throw new Error('获取rkey失败:所有服务URL均已被禁用');
}
for (const url of availableUrls) {
try {
let temp = await RequestUtil.HttpGetJson<ServerRkeyData>(url, 'GET');
if (typeof temp === 'object' && temp !== null && 'retcode' in temp) {
// 支持Onebot Ret风格
temp = (temp as unknown as OneBotApiRet).data;
}
if (
typeof temp?.group_rkey !== 'string' ||
typeof temp?.private_rkey !== 'string' ||
typeof temp?.expired_time !== 'number' ||
temp.group_rkey.length <= 6 ||
temp.private_rkey.length <= 6 ||
temp.expired_time <= 0
) {
throw new Error('Rkey 服务返回格式无效:group_rkey/private_rkey/expired_time 缺失或不合法');
}
this.rkeyData = {
group_rkey: temp.group_rkey.slice(6),
private_rkey: temp.private_rkey.slice(6),
expired_time: temp.expired_time,
};
return;
} catch (e) {
this.logger.logError(`[Rkey] 异常服务 ${url} 异常 / `, e);
this.updateUrlFailure(url);
if (url === availableUrls[availableUrls.length - 1]) {
throw new Error(`获取rkey失败: ${e}`);
}
}
}
}
}
@@ -0,0 +1,34 @@
/**
* 服务器时间矫正 - 全局 Hook
*
* 保留 Date.now 原始引用,启动后通过 getServerTime 计算偏移量,
* 直接覆盖 Date.now(),使全局所有调用自动获得矫正后的时间戳。
*/
/** 原始 Date.now 引用,不受 hook 影响 */
export const OriginalDateNow: () => number = Date.now.bind(Date);
/**
* 初始化全局时间矫正,覆盖 Date.now()
* @param getServerTimeFn 获取服务器时间的函数,返回毫秒级时间戳字符串
* @returns 偏移量(毫秒)
*/
export function hookGlobalDateNow (getServerTimeFn: () => string): number {
const start = OriginalDateNow();
const serverTimeStr = getServerTimeFn();
const end = OriginalDateNow();
const serverTimeMs = parseInt(serverTimeStr, 10);
if (isNaN(serverTimeMs) || serverTimeMs <= 0) {
return 0;
}
// 补偿网络延迟:假定往返时间对称,服务器时间对应本地时间的中点 (start + end) / 2
const offsetMs = Math.round(serverTimeMs - (start + end) / 2);
if (Math.abs(offsetMs) > 5000) {
Date.now = () => OriginalDateNow() + offsetMs;
}
return offsetMs;
}
@@ -0,0 +1,140 @@
import { NodeIQQNTWrapperSession } from '@/napcat-core/wrapper';
import { ServiceNamingMapping } from '@/napcat-core/services/index';
import { NTEventWrapper } from './event';
/**
* 创建 Service 方法的代理
* 拦截所有方法调用,通过 EventWrapper 进行调用
*/
function createServiceMethodProxy<S extends keyof ServiceNamingMapping> (
serviceName: S,
originalService: ServiceNamingMapping[S],
eventWrapper: NTEventWrapper
): ServiceNamingMapping[S] {
return new Proxy(originalService as object, {
get (target, prop, receiver) {
const originalValue = Reflect.get(target, prop, receiver);
// 如果不是函数,直接返回原始值
if (typeof originalValue !== 'function') {
return originalValue;
}
const methodName = prop as string;
// 返回一个包装函数,通过 EventWrapper 调用
return function (this: unknown, ...args: unknown[]) {
// 构造 EventWrapper 需要的路径格式: ServiceName/MethodName
const eventPath = `${serviceName}/${methodName}`;
// 尝试通过 EventWrapper 调用
try {
// 使用 callNoListenerEvent 的底层实现逻辑
const eventFunc = (eventWrapper as any).createEventFunction(eventPath);
if (eventFunc) {
return eventFunc(...args);
}
} catch {
// 如果 EventWrapper 调用失败,回退到原始调用
}
// 回退到原始方法调用
return originalValue.apply(originalService, args);
};
},
}) as ServiceNamingMapping[S];
}
/**
* 创建 Session 的双层代理
* 第一层:拦截 getXXXService 方法
* 第二层:拦截 Service 上的具体方法调用
*/
export function createSessionProxy (
session: NodeIQQNTWrapperSession,
eventWrapper: NTEventWrapper
): NodeIQQNTWrapperSession {
// 缓存已代理的 Service,避免重复创建
const serviceProxyCache = new Map<string, unknown>();
return new Proxy(session, {
get (target, prop, receiver) {
const propName = prop as string;
// 检查是否是 getXXXService 方法
if (typeof propName === 'string' && propName.startsWith('get') && propName.endsWith('Service')) {
// 提取 Service 名称: getMsgService -> NodeIKernelMsgService
const servicePart = propName.slice(3); // 移除 'get' 前缀
const serviceName = `NodeIKernel${servicePart}` as keyof ServiceNamingMapping;
// 返回一个函数,该函数返回代理后的 Service
return function () {
// 检查缓存
if (serviceProxyCache.has(serviceName)) {
return serviceProxyCache.get(serviceName);
}
// 获取原始 Service
const originalGetter = Reflect.get(target, prop, receiver) as () => unknown;
const originalService = originalGetter.call(target);
// 检查是否在 ServiceNamingMapping 中定义
if (isKnownService(serviceName)) {
// 创建 Service 方法代理
const proxiedService = createServiceMethodProxy(
serviceName,
originalService as ServiceNamingMapping[typeof serviceName],
eventWrapper
);
serviceProxyCache.set(serviceName, proxiedService);
return proxiedService;
}
// 未知的 Service,直接返回原始对象
serviceProxyCache.set(serviceName, originalService);
return originalService;
};
}
// 非 getXXXService 方法,直接返回原始值
return Reflect.get(target, prop, receiver);
},
});
}
/**
* 检查 Service 名称是否在已知的映射中
*/
function isKnownService (serviceName: string): serviceName is keyof ServiceNamingMapping {
const knownServices: string[] = [
'NodeIKernelAvatarService',
'NodeIKernelBuddyService',
'NodeIKernelFileAssistantService',
'NodeIKernelGroupService',
'NodeIKernelLoginService',
'NodeIKernelMsgService',
'NodeIKernelOnlineStatusService',
'NodeIKernelProfileLikeService',
'NodeIKernelProfileService',
'NodeIKernelTicketService',
'NodeIKernelStorageCleanService',
'NodeIKernelRobotService',
'NodeIKernelRichMediaService',
'NodeIKernelDbToolsService',
'NodeIKernelTipOffService',
'NodeIKernelSearchService',
'NodeIKernelCollectionService',
];
return knownServices.includes(serviceName);
}
/**
* 创建带有 EventWrapper 集成的 InstanceContext
* 这是推荐的使用方式,在创建 context 时自动代理 session
*/
export function createProxiedSession (
session: NodeIQQNTWrapperSession,
eventWrapper: NTEventWrapper
): NodeIQQNTWrapperSession {
return createSessionProxy(session, eventWrapper);
}
+138
View File
@@ -0,0 +1,138 @@
import os from 'node:os';
import EventEmitter from 'node:events';
import { IStatusHelperSubscription } from 'napcat-common/src/status-interface';
export interface SystemStatus {
cpu: {
model: string,
speed: string;
usage: {
system: string;
qq: string;
},
core: number;
},
memory: {
total: string;
usage: {
system: string;
qq: string;
};
},
arch: string;
}
export class StatusHelper {
private psCpuUsage = process.cpuUsage();
private psCurrentTime = process.hrtime();
private cpuTimes = os.cpus().map(cpu => cpu.times);
private replaceNaN (value: number) {
return isNaN(value) ? 0 : value;
}
private sysCpuInfo () {
const currentTimes = os.cpus().map(cpu => cpu.times);
const { total, active } = currentTimes.map((times, index) => {
const prevTimes = this.cpuTimes[index];
const totalCurrent = times.user + times.nice + times.sys + times.idle + times.irq;
const totalPrev = (prevTimes?.user ?? 0) + (prevTimes?.nice ?? 0) + (prevTimes?.sys ?? 0) + (prevTimes?.idle ?? 0) + (prevTimes?.irq ?? 0);
const activeCurrent = totalCurrent - times.idle;
const activePrev = totalPrev - (prevTimes?.idle ?? 0);
return {
total: totalCurrent - totalPrev,
active: activeCurrent - activePrev,
};
}).reduce((acc, cur) => ({
total: acc.total + cur.total,
active: acc.active + cur.active,
}), { total: 0, active: 0 });
this.cpuTimes = currentTimes;
return {
usage: this.replaceNaN(((active / total) * 100)).toFixed(2),
model: os.cpus()[0]?.model ?? 'none',
speed: os.cpus()[0]?.speed ?? 0,
core: os.cpus().length,
};
}
private sysMemoryUsage () {
const { total, free } = { total: os.totalmem(), free: os.freemem() };
return ((total - free) / 1024 / 1024).toFixed(2);
}
private qqUsage () {
const mem = process.memoryUsage();
const numCpus = os.cpus().length;
const usageDiff = process.cpuUsage(this.psCpuUsage);
const endTime = process.hrtime(this.psCurrentTime);
this.psCpuUsage = process.cpuUsage();
this.psCurrentTime = process.hrtime();
const usageMS = (usageDiff.user + usageDiff.system) / 1e3;
const totalMS = endTime[0] * 1e3 + endTime[1] / 1e6;
const normPercent = (usageMS / totalMS / numCpus) * 100;
return {
cpu: this.replaceNaN(normPercent).toFixed(2),
memory: ((mem.heapTotal + mem.external + mem.arrayBuffers) / 1024 / 1024).toFixed(2),
};
}
systemStatus (): SystemStatus {
const qqUsage = this.qqUsage();
const sysCpuInfo = this.sysCpuInfo();
return {
cpu: {
core: sysCpuInfo.core,
model: sysCpuInfo.model,
speed: (sysCpuInfo.speed / 1000).toFixed(2),
usage: {
system: sysCpuInfo.usage,
qq: qqUsage.cpu,
},
},
memory: {
total: (os.totalmem() / 1024 / 1024).toFixed(2),
usage: {
system: this.sysMemoryUsage(),
qq: qqUsage.memory,
},
},
arch: `${os.platform()} ${os.arch()} ${os.release()}`,
};
}
}
class StatusHelperSubscription extends EventEmitter implements IStatusHelperSubscription {
private statusHelper: StatusHelper;
private interval: NodeJS.Timeout | null = null;
constructor (time: number = 3000) {
super();
this.statusHelper = new StatusHelper();
this.on('newListener', (event: string) => {
if (event === 'statusUpdate' && this.listenerCount('statusUpdate') === 0) {
this.startInterval(time);
}
});
this.on('removeListener', (event: string) => {
if (event === 'statusUpdate' && this.listenerCount('statusUpdate') === 0) {
this.stopInterval();
}
});
}
private startInterval (time: number) {
this.interval ??= setInterval(() => {
const status = this.statusHelper.systemStatus();
this.emit('statusUpdate', status);
}, time);
}
private stopInterval () {
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
}
}
export const statusHelperSubscription = new StatusHelperSubscription();
+393
View File
@@ -0,0 +1,393 @@
import {
NTQQFileApi,
NTQQFriendApi,
NTQQGroupApi,
NTQQMsgApi,
NTQQSystemApi,
NTQQUserApi,
NTQQWebApi,
NTQQFlashApi,
NTQQOnlineApi,
NTQQDatabaseApi,
NTQQQzoneApi,
NTQQQzonePublicApi,
} from '@/napcat-core/apis';
import { NTQQCollectionApi } from '@/napcat-core/apis/collection';
import {
NodeIQQNTWrapperSession,
NodeQQNTWrapperUtil,
PlatformType,
VendorType,
WrapperNodeApi,
WrapperSessionInitConfig,
} from '@/napcat-core/wrapper';
import { LogLevel, LogWrapper } from '@/napcat-core/helper/log';
import { QQBasicInfoWrapper } from '@/napcat-core/helper/qq-basic-info';
import { getQQDataDirOverride, NapCatPathWrapper } from 'napcat-common/src/path';
import path from 'node:path';
import fs from 'node:fs';
import { hostname, systemName, systemVersion } from 'napcat-common/src/system';
import { NTEventWrapper } from '@/napcat-core/helper/event';
import { createSessionProxy } from '@/napcat-core/helper/session-proxy';
import { KickedOffLineInfo, RawMessage, SelfInfo, SelfStatusInfo } from '@/napcat-core/types';
import { NapCatConfigLoader, NapcatConfigSchema } from '@/napcat-core/helper/config';
import os from 'node:os';
import { NodeIKernelMsgListener, NodeIKernelProfileListener } from '@/napcat-core/listeners';
import { proxiedListenerOf } from '@/napcat-core/helper/proxy-handler';
import { NTQQPacketApi } from './apis/packet';
import { NativePacketHandler } from './packet/handler/client';
import { Napi2NativeLoader } from './packet/handler/napi2nativeLoader';
import { container, ReceiverServiceRegistry } from './packet/handler/serviceRegister';
import { appEvent } from './packet/handler/eventList';
import { TypedEventEmitter } from './packet/handler/typeEvent';
import { hookGlobalDateNow } from './helper/server-time';
export * from './wrapper';
export * from './types/index';
export * from './services/index';
export * from './listeners/index';
export * from './apis/index';
export * from './helper/log';
export * from './helper/qq-basic-info';
export * from './helper/event';
export * from './helper/config';
export * from './helper/config-base';
export * from './helper/proxy-handler';
export * from './helper/session-proxy';
export * from './helper/server-time';
export enum NapCatCoreWorkingEnv {
Unknown = 0,
Shell = 1,
Framework = 2,
}
function dlopenWrapper (wrapperPath: string): WrapperNodeApi {
const nativemodule: { exports: WrapperNodeApi; } = { exports: {} as WrapperNodeApi };
try {
process.dlopen(nativemodule, wrapperPath);
} catch (error) {
// QQNT.dll 依赖媒体基础(mfplat.dll 等),Windows Server 默认不装这个组件。
// 缺了它系统只会报 The specified module could not be found,看不出缺的是什么
const mfplatPath = path.join(process.env['SystemRoot'] || 'C:\\Windows', 'System32', 'mfplat.dll');
if (os.platform() === 'win32' && !fs.existsSync(mfplatPath)) {
throw new Error(
'加载 wrapper.node 失败:系统缺少「媒体基础」组件(找不到 mfplat.dll),Windows Server 默认不安装它。' +
'请在服务器管理器的「添加角色和功能」里勾选「媒体基础」,' +
'或在管理员 PowerShell 中执行 Install-WindowsFeature Server-Media-Foundation,重启后再启动。' +
`原始错误:${(error as Error).message}`
);
}
throw error;
}
return nativemodule.exports;
}
export function loadQQWrapper (execPath: string | undefined, QQVersion: string): WrapperNodeApi {
if (process.env['NAPCAT_WRAPPER_PATH']) {
return dlopenWrapper(process.env['NAPCAT_WRAPPER_PATH']);
}
if (!execPath) {
throw new Error('无法加载Wrapper,execPath未定义');
}
let appPath;
if (os.platform() === 'darwin') {
appPath = path.resolve(path.dirname(execPath), '../Resources/app');
} else if (os.platform() === 'linux') {
appPath = path.resolve(path.dirname(execPath), './resources/app');
} else {
appPath = path.resolve(path.dirname(execPath), `./versions/${QQVersion}/`);
}
let wrapperNodePath = path.resolve(appPath, 'wrapper.node');
if (!fs.existsSync(wrapperNodePath)) {
wrapperNodePath = path.join(appPath, './resources/app/wrapper.node');
}
// 老版本兼容 未来去掉
if (!fs.existsSync(wrapperNodePath)) {
wrapperNodePath = path.join(path.dirname(execPath), `./resources/app/versions/${QQVersion}/wrapper.node`);
}
const exports = dlopenWrapper(wrapperNodePath);
process.env['NAPCAT_WRAPPER_PATH'] = wrapperNodePath;
return exports;
}
export function getMajorPath (execPath: string, QQVersion: string): string {
// major.node
let appPath;
if (os.platform() === 'darwin') {
appPath = path.resolve(path.dirname(execPath), '../Resources/app');
} else if (os.platform() === 'linux') {
appPath = path.resolve(path.dirname(execPath), './resources/app');
} else {
appPath = path.resolve(path.dirname(execPath), `./versions/${QQVersion}/`);
}
let majorPath = path.resolve(appPath, 'major.node');
if (!fs.existsSync(majorPath)) {
majorPath = path.join(appPath, './resources/app/major.node');
}
// 老版本兼容 未来去掉
if (!fs.existsSync(majorPath)) {
majorPath = path.join(path.dirname(execPath), `./resources/app/versions/${QQVersion}/major.node`);
}
return majorPath;
}
export class NapCatCore {
readonly context: InstanceContext;
readonly eventWrapper: NTEventWrapper;
event = appEvent;
NapCatDataPath: string = '';
NapCatTempPath: string = '';
apis: StableNTApiWrapper;
// runtime info, not readonly
selfInfo: SelfInfo;
util: NodeQQNTWrapperUtil;
configLoader: NapCatConfigLoader;
/** 数据库 passphrase,由 OidbSvcTrpcTcp.0xcde_2 包获取 */
dbPassphrase: string | undefined;
// 通过构造器递过去的 runtime info 应该尽量少
constructor (context: InstanceContext, selfInfo: SelfInfo) {
this.selfInfo = selfInfo;
// 先用原始 session 创建 eventWrapper
this.eventWrapper = new NTEventWrapper(context.session);
// 通过环境变量 NAPCAT_SESSION_PROXY 开启 session 代理
if (process.env['NAPCAT_SESSION_PROXY'] === '1') {
const proxiedSession = createSessionProxy(context.session, this.eventWrapper);
this.context = {
...context,
session: proxiedSession,
};
} else {
this.context = context;
}
this.util = this.context.wrapper.NodeQQNTWrapperUtil;
this.configLoader = new NapCatConfigLoader(this, this.context.pathWrapper.configPath, NapcatConfigSchema);
this.apis = {
FileApi: new NTQQFileApi(this.context, this),
SystemApi: new NTQQSystemApi(this.context, this),
CollectionApi: new NTQQCollectionApi(this.context, this),
PacketApi: new NTQQPacketApi(this.context, this),
WebApi: new NTQQWebApi(this.context, this),
FriendApi: new NTQQFriendApi(this.context, this),
MsgApi: new NTQQMsgApi(this.context, this),
UserApi: new NTQQUserApi(this.context, this),
GroupApi: new NTQQGroupApi(this.context, this),
FlashApi: new NTQQFlashApi(this.context, this),
OnlineApi: new NTQQOnlineApi(this.context, this),
DatabaseApi: new NTQQDatabaseApi(this.context, this),
QzoneApi: new NTQQQzoneApi(this.context, this),
QzonePublicApi: new NTQQQzonePublicApi(this.context, this),
};
container.bind(NapCatCore).toConstantValue(this);
container.bind(TypedEventEmitter).toConstantValue(this.event);
ReceiverServiceRegistry.forEach((ServiceClass, serviceName) => {
container.bind(ServiceClass).toSelf();
// console.log(`Registering service handler for: ${serviceName}`);
this.context.packetHandler.onCmd(serviceName, ({ seq, hex_data }) => {
const serviceInstance = container.get(ServiceClass);
return serviceInstance.handler(seq, hex_data);
});
});
}
async initCore () {
this.NapCatDataPath = path.join(this.dataPath, 'NapCat');
fs.mkdirSync(this.NapCatDataPath, { recursive: true });
this.NapCatTempPath = path.join(this.NapCatDataPath, 'temp');
// 创建临时目录
if (!fs.existsSync(this.NapCatTempPath)) {
fs.mkdirSync(this.NapCatTempPath, { recursive: true });
}
// 通过 getServerTime 全局 hook Date.now() 矫正本地时间偏差
// 可通过环境变量 NAPCAT_DISABLE_TIME_SYNC=1 或配置 autoTimeSync: false 禁用
if (process.env['NAPCAT_DISABLE_TIME_SYNC'] === '1' || !this.configLoader.configData.autoTimeSync) {
this.context.logger.log('[ServerTime] 自动对时已禁用');
} else {
const doTimeSync = () => {
try {
const offsetMs = hookGlobalDateNow(() => this.context.session.getMSFService().getServerTime());
if (Math.abs(offsetMs) > 5000) {
this.context.logger.logWarn(`[ServerTime] 本地时间与服务器时间偏差 ${(offsetMs / 1000).toFixed(1)}s,已自动矫正`);
} else {
this.context.logger.log(`[ServerTime] 时间同步完成,偏差 ${offsetMs}ms`);
}
} catch (e) {
this.context.logger.logError('[ServerTime] 时间矫正失败', e);
}
};
doTimeSync();
// 每小时定期重同步,防止长时间运行后本地时钟漂移触发风控
const syncTimer = setInterval(doTimeSync, 3600000);
syncTimer.unref();
}
// 遍历this.apis[i].initApi 如果存在该函数进行async 调用
for (const apiKey in this.apis) {
const api = this.apis[apiKey as keyof StableNTApiWrapper];
if ('initApi' in api && typeof api.initApi === 'function') {
await api.initApi();
}
}
this.initNapCatCoreListeners().then().catch((e) => this.context.logger.logError(e));
this.context.logger.setFileLogEnabled(
this.configLoader.configData.fileLog
);
this.context.logger.setConsoleLogEnabled(
this.configLoader.configData.consoleLog
);
this.context.logger.setFileAndConsoleLogLevel(
this.configLoader.configData.fileLogLevel as LogLevel,
this.configLoader.configData.consoleLogLevel as LogLevel
);
}
get dataPath (): string {
const override = getQQDataDirOverride();
if (override) return override;
let result = this.context.wrapper.NodeQQNTWrapperUtil.getNTUserDataInfoConfig();
if (!result) {
result = path.resolve(os.homedir(), './.config/QQ');
fs.mkdirSync(result, { recursive: true });
}
return result;
}
// Renamed from 'InitDataListener'
async initNapCatCoreListeners () {
const msgListener = new NodeIKernelMsgListener();
// 在线文件/文件夹消息
msgListener.onRecvOnlineFileMsg = (msgs: RawMessage[]) => {
msgs.forEach(msg => this.context.logger.logMessage(msg, this.selfInfo));
};
msgListener.onKickedOffLine = (Info: KickedOffLineInfo) => {
// 下线通知
const tips = `[KickedOffLine] [${Info.tipsTitle}] ${Info.tipsDesc}`;
this.context.logger.logError(tips);
this.selfInfo.online = false;
this.event.emit('KickedOffLine', tips);
};
msgListener.onRecvMsg = (msgs) => {
msgs.forEach(msg => this.context.logger.logMessage(msg, this.selfInfo));
};
msgListener.onAddSendMsg = (msg) => {
this.context.logger.logMessage(msg, this.selfInfo);
};
this.context.session.getMsgService().addKernelMsgListener(
proxiedListenerOf(msgListener, this.context.logger)
);
const profileListener = new NodeIKernelProfileListener();
profileListener.onProfileDetailInfoChanged = (profile) => {
if (profile.uid === this.selfInfo.uid) {
Object.assign(this.selfInfo, profile);
}
};
profileListener.onSelfStatusChanged = (Info: SelfStatusInfo) => {
if (Info.status === 20) {
// 静默离线:服务端判定会话失效,但不会下发 KickedOffLine 通知。
// 仅在 在线 -> 离线 的跳变上广播一次,供上层复用下线恢复链路。
const wasOnline = this.selfInfo.online === true;
this.selfInfo.online = false;
this.context.logger.log('账号状态变更为离线');
if (wasOnline) {
this.event.emit('SelfOffline', '[SelfOffline] 账号状态变更为离线(未收到 KickedOffLine 通知)');
}
} else {
this.selfInfo.online = true;
}
};
this.context.session.getProfileService().addKernelProfileListener(
proxiedListenerOf(profileListener, this.context.logger)
);
}
}
export async function genSessionConfig (
guid: string,
QQVersionAppid: string,
QQVersion: string,
selfUin: string,
selfUid: string,
account_path: string
): Promise<WrapperSessionInitConfig> {
const downloadPath = path.join(account_path, 'NapCat', 'temp');
fs.mkdirSync(downloadPath, { recursive: true });
const platformMapping: Partial<Record<NodeJS.Platform, PlatformType>> = {
win32: PlatformType.KWINDOWS,
darwin: PlatformType.KMAC,
linux: PlatformType.KLINUX,
};
const systemPlatform = platformMapping[os.platform()] ?? PlatformType.KWINDOWS;
return {
selfUin,
selfUid,
desktopPathConfig: {
account_path, // 可以通过NodeQQNTWrapperUtil().getNTUserDataInfoConfig()获取
},
clientVer: QQVersion,
a2: '',
d2: '',
d2Key: '',
machineId: '',
platform: systemPlatform, // 3是Windows?
platVer: systemVersion, // 系统版本号, 应该可以固定
appid: QQVersionAppid,
rdeliveryConfig: {
appKey: '',
systemId: 0,
appId: '',
logicEnvironment: '',
platform: systemPlatform,
language: '',
sdkVersion: '',
userId: '',
appVersion: '',
osVersion: '',
bundleId: '',
serverUrl: '',
fixedAfterHitKeys: [''],
},
defaultFileDownloadPath: downloadPath,
deviceInfo: {
guid,
buildVer: QQVersion,
localId: 2052,
devName: hostname,
devType: systemName,
vendorName: '',
osVer: systemVersion,
vendorOsName: systemName,
setMute: false,
vendorType: VendorType.KNOSETONIOS,
},
deviceConfig: '{"appearance":{"isSplitViewMode":true},"msg":{}}',
};
}
export interface InstanceContext {
readonly workingEnv: NapCatCoreWorkingEnv;
readonly wrapper: WrapperNodeApi;
readonly session: NodeIQQNTWrapperSession;
readonly logger: LogWrapper;
readonly basicInfoWrapper: QQBasicInfoWrapper;
readonly pathWrapper: NapCatPathWrapper;
readonly packetHandler: NativePacketHandler;
readonly napi2nativeLoader: Napi2NativeLoader;
}
export interface StableNTApiWrapper {
FileApi: NTQQFileApi,
SystemApi: NTQQSystemApi,
PacketApi: NTQQPacketApi,
CollectionApi: NTQQCollectionApi,
WebApi: NTQQWebApi,
FriendApi: NTQQFriendApi,
MsgApi: NTQQMsgApi,
UserApi: NTQQUserApi,
GroupApi: NTQQGroupApi;
FlashApi: NTQQFlashApi,
OnlineApi: NTQQOnlineApi,
DatabaseApi: NTQQDatabaseApi,
QzoneApi: NTQQQzoneApi,
QzonePublicApi: NTQQQzonePublicApi,
}
@@ -0,0 +1,76 @@
import { BuddyCategoryType, FriendRequestNotify } from '@/napcat-core/types';
export type OnBuddyChangeParams = BuddyCategoryType[];
export class NodeIKernelBuddyListener {
onBuddyListChangedV2 (_arg: unknown): any {
}
onAddBuddyNeedVerify (_arg: unknown): any {
}
onAddMeSettingChanged (_arg: unknown): any {
}
onAvatarUrlUpdated (_arg: unknown): any {
}
onBlockChanged (_arg: unknown): any {
}
onBuddyDetailInfoChange (_arg: unknown): any {
}
onBuddyInfoChange (_arg: unknown): any {
}
onBuddyListChange (_arg: OnBuddyChangeParams): any {
}
onBuddyRemarkUpdated (_arg: unknown): any {
}
onBuddyReqChange (_arg: FriendRequestNotify): any {
}
onBuddyReqUnreadCntChange (_arg: unknown): any {
}
onCheckBuddySettingResult (_arg: unknown): any {
}
onDelBatchBuddyInfos (_arg: unknown): any {
}
onDoubtBuddyReqChange (_arg:
{
reqId: string;
cookie: string;
doubtList: Array<{
uid: string;
nick: string;
age: number,
sex: number;
commFriendNum: number;
reqTime: string;
msg: string;
source: string;
reason: string;
groupCode: string;
nameMore?: null;
}>;
}): void | Promise<void> {
}
onDoubtBuddyReqUnreadNumChange (_num: number): void | Promise<void> {
}
onNickUpdated (_arg: unknown): any {
}
onSmartInfos (_arg: unknown): any {
}
onSpacePermissionInfos (_arg: unknown): any {
}
}
@@ -0,0 +1,60 @@
export class NodeIKernelFileAssistantListener {
onFileStatusChanged (_fileStatus: {
id: string,
fileStatus: number,
fileProgress: `${number}`,
fileSize: `${number}`,
fileSpeed: number,
thumbPath: string | null,
filePath: string | null,
}): any {
}
onSessionListChanged (..._args: unknown[]): any {
}
onSessionChanged (..._args: unknown[]): any {
}
onFileListChanged (..._args: unknown[]): any {
}
onFileSearch (_searchResult: SearchResultWrapper): any {
}
}
export type SearchResultWrapper = {
searchId: number,
resultId: number,
hasMore: boolean,
resultItems: SearchResultItem[],
};
export type SearchResultItem = {
id: string,
fileName: string,
fileNameHits: string[],
fileStatus: number,
fileSize: string,
isSend: boolean,
source: number,
fileTime: string,
expTime: string,
session: {
context: null,
uid: string,
nick: string,
remark: string,
memberCard: string,
groupCode: string,
groupName: string,
groupRemark: string,
count: number,
},
thumbPath: string,
filePath: string,
msgId: string,
chatType: number,
peerUid: string,
fileType: number,
};
@@ -0,0 +1,85 @@
import { DataSource, Group, GroupDetailInfo, GroupListUpdateType, GroupMember, GroupNotify, ShutUpGroupMember } from '@/napcat-core/types';
export class NodeIKernelGroupListener {
onGroupListInited (_listEmpty: boolean): any { }
// 发现于Win 9.9.9 23159
onGroupMemberLevelInfoChange (..._args: unknown[]): any {
}
onGetGroupBulletinListResult (..._args: unknown[]): any {
}
onGroupAllInfoChange (..._args: unknown[]): any {
}
onGroupBulletinChange (..._args: unknown[]): any {
}
onGroupBulletinRemindNotify (..._args: unknown[]): any {
}
onGroupArkInviteStateResult (..._args: unknown[]): any {
}
onGroupBulletinRichMediaDownloadComplete (..._args: unknown[]): any {
}
onGroupConfMemberChange (..._args: unknown[]): any {
}
onGroupDetailInfoChange (_detailInfo: GroupDetailInfo): any {
}
onGroupExtListUpdate (..._args: unknown[]): any {
}
onGroupFirstBulletinNotify (..._args: unknown[]): any {
}
onGroupListUpdate (_updateType: GroupListUpdateType, _groupList: Group[]): any {
}
onGroupNotifiesUpdated (_dboubt: boolean, _notifies: GroupNotify[]): any {
}
onGroupBulletinRichMediaProgressUpdate (..._args: unknown[]): any {
}
onGroupNotifiesUnreadCountUpdated (..._args: unknown[]): any {
}
onGroupSingleScreenNotifies (_doubt: boolean, _seq: string, _notifies: GroupNotify[]): any {
}
onGroupsMsgMaskResult (..._args: unknown[]): any {
}
onGroupStatisticInfoChange (..._args: unknown[]): any {
}
onJoinGroupNotify (..._args: unknown[]): any {
}
onJoinGroupNoVerifyFlag (..._args: unknown[]): any {
}
onMemberInfoChange (_groupCode: string, _dateSource: DataSource, _members: Map<string, GroupMember>): any {
}
onMemberListChange (_arg: {
sceneId: string,
ids: string[],
infos: Map<string, GroupMember>, // uid -> GroupMember
hasPrev: boolean,
hasNext: boolean,
hasRobot: boolean;
}): any {
}
onSearchMemberChange (..._args: unknown[]): any {
}
onShutUpMemberListChanged (_groupCode: string, _members: Array<ShutUpGroupMember>): any {
}
}
@@ -0,0 +1,70 @@
export class NodeIKernelLoginListener {
onLoginConnected (): Promise<void> | void {
}
onLoginDisConnected (..._args: any[]): any {
}
onLoginConnecting (..._args: any[]): any {
}
onQRCodeGetPicture (_arg: { pngBase64QrcodeData: string, qrcodeUrl: string; }): any {
// let base64Data: string = arg.pngBase64QrcodeData
// base64Data = base64Data.split("data:image/png;base64,")[1]
// let buffer = Buffer.from(base64Data, 'base64')
// console.log("onQRCodeGetPicture", arg);
}
onQRCodeLoginPollingStarted (..._args: any[]): any {
}
onQRCodeSessionUserScaned (..._args: any[]): any {
}
onQRCodeLoginSucceed (_arg: QRCodeLoginSucceedResult): any {
}
onQRCodeSessionFailed (..._args: any[]): any {
}
onLoginFailed (..._args: any[]): any {
}
onLogoutSucceed (..._args: any[]): any {
}
onLogoutFailed (..._args: any[]): any {
}
onUserLoggedIn (..._args: any[]): any {
}
onQRCodeSessionQuickLoginFailed (..._args: any[]): any {
}
onPasswordLoginFailed (..._args: any[]): any {
}
OnConfirmUnusualDeviceFailed (..._args: any[]): any {
}
onQQLoginNumLimited (..._args: any[]): any {
}
onLoginState (..._args: any[]): any {
}
onLoginRecordUpdate (..._args: any[]): any {
}
}
export interface QRCodeLoginSucceedResult {
account: string;
mainAccount: string;
uin: string; // 拿UIN
uid: string; // 拿UID
nickName: string; // 一般是空的 拿不到
gender: number;
age: number;
faceUrl: string;// 一般是空的 拿不到
}
@@ -0,0 +1,386 @@
import { ChatType, KickedOffLineInfo, RawMessage } from '@/napcat-core/types';
import { CommonFileInfo } from '@/napcat-core/index';
export interface OnRichMediaDownloadCompleteParams {
fileModelId: string,
msgElementId: string,
msgId: string,
fileId: string,
fileProgress: string, // '0'
fileSpeed: string, // '0'
fileErrCode: string, // '0'
fileErrMsg: string,
fileDownType: number, // 暂时未知
thumbSize: number,
filePath: string,
totalSize: string,
trasferStatus: number,
step: number,
commonFileInfo?: CommonFileInfo,
fileSrvErrCode: string,
clientMsg: string,
businessId: number,
userTotalSpacePerDay: unknown,
userUsedSpacePerDay: unknown,
chatType: number,
}
export interface GroupFileInfoUpdateParamType {
retCode: number;
retMsg: string;
clientWording: string;
isEnd: boolean;
item: Array<GroupFileInfoUpdateItem>;
allFileCount: number;
nextIndex: number;
reqId: number;
}
// {
// sessionType: 1,
// chatType: 100,
// peerUid: 'u_PVQ3tl6K78xxxx',
// groupCode: '809079648',
// fromNick: '拾xxxx,
// sig: '0x'
// }
export interface GroupFileInfoUpdateItem {
peerId: string;
type: number;
folderInfo?: {
folderId: string;
parentFolderId: string;
folderName: string;
createTime: number;
modifyTime: number;
createUin: string;
creatorName: string;
totalFileCount: number;
modifyUin: string;
modifyName: string;
usedSpace: string;
},
fileInfo?: {
fileModelId: string;
fileId: string;
fileName: string;
fileSize: string;
busId: number;
uploadedSize: string;
uploadTime: number;
deadTime: number;
modifyTime: number;
downloadTimes: number;
sha: string;
sha3: string;
md5: string;
uploaderLocalPath: string;
uploaderName: string;
uploaderUin: string;
parentFolderId: string;
localPath: string;
transStatus: number;
transType: number;
elementId: string;
isFolder: boolean;
},
}
export interface TempOnRecvParams {
sessionType: number, // 1
chatType: ChatType, // 100
peerUid: string, // uid
groupCode: string, // gc
fromNick: string, // gc name
sig: string,
}
export class NodeIKernelMsgListener {
onAddSendMsg (_msgRecord: RawMessage): any {
}
onBroadcastHelperDownloadComplete (_broadcastHelperTransNotifyInfo: unknown): any {
}
onBroadcastHelperProgressUpdate (_broadcastHelperTransNotifyInfo: unknown): any {
}
onChannelFreqLimitInfoUpdate (_contact: unknown, _z: unknown, _freqLimitInfo: unknown): any {
}
onContactUnreadCntUpdate (_hashMap: unknown): any {
}
onCustomWithdrawConfigUpdate (_customWithdrawConfig: unknown): any {
}
onDraftUpdate (_contact: unknown, _arrayList: unknown, _j2: unknown): any {
}
onEmojiDownloadComplete (_emojiNotifyInfo: unknown): any {
}
onEmojiResourceUpdate (_emojiResourceInfo: unknown): any {
}
onFeedEventUpdate (_firstViewDirectMsgNotifyInfo: unknown): any {
}
onFileMsgCome (_arrayList: unknown): any {
}
onFirstViewDirectMsgUpdate (_firstViewDirectMsgNotifyInfo: unknown): any {
}
onFirstViewGroupGuildMapping (_arrayList: unknown): any {
}
onGrabPasswordRedBag (_i2: unknown, _str: unknown, _i3: unknown, _recvdOrder: unknown, _msgRecord: unknown): any {
}
onGroupFileInfoAdd (_groupItem: unknown): any {
}
onGroupFileInfoUpdate (_groupFileListResult: GroupFileInfoUpdateParamType): any {
}
onGroupGuildUpdate (_groupGuildNotifyInfo: unknown): any {
}
onGroupTransferInfoAdd (_groupItem: unknown): any {
}
onGroupTransferInfoUpdate (_groupFileListResult: unknown): any {
}
onGuildInteractiveUpdate (_guildInteractiveNotificationItem: unknown): any {
}
onGuildMsgAbFlagChanged (_guildMsgAbFlag: unknown): any {
}
onGuildNotificationAbstractUpdate (_guildNotificationAbstractInfo: unknown): any {
}
onHitCsRelatedEmojiResult (_downloadRelateEmojiResultInfo: unknown): any {
}
onHitEmojiKeywordResult (_hitRelatedEmojiWordsResult: unknown): any {
}
onHitRelatedEmojiResult (_relatedWordEmojiInfo: unknown): any {
}
onImportOldDbProgressUpdate (_importOldDbMsgNotifyInfo: unknown): any {
}
onInputStatusPush (_inputStatusInfo: {
chatType: number;
eventType: number;
fromUin: string;
interval: string;
showTime: string;
statusText: string;
timestamp: string;
toUin: string;
}): any {
}
onKickedOffLine (_kickedInfo: KickedOffLineInfo): any {
}
onLineDev (_arrayList: unknown): any {
}
onLogLevelChanged (_j2: unknown): any {
}
onMsgAbstractUpdate (_arrayList: unknown): any {
}
onMsgBoxChanged (_arrayList: unknown): any {
}
onMsgDelete (_contact: unknown, _arrayList: unknown): any {
}
onMsgEventListUpdate (_hashMap: unknown): any {
}
onMsgInfoListAdd (_arrayList: unknown): any {
}
onMsgInfoListUpdate (_msgList: RawMessage[]): any {
}
onMsgQRCodeStatusChanged (_i2: unknown): any {
}
onMsgRecall (_chatType: ChatType, _uid: string, _msgSeq: string): any {
}
onMsgSecurityNotify (_msgRecord: unknown): any {
}
onMsgSettingUpdate (_msgSetting: unknown): any {
}
onNtFirstViewMsgSyncEnd (): any {
}
onNtMsgSyncEnd (): any {
}
onNtMsgSyncStart (): any {
}
onReadFeedEventUpdate (_firstViewDirectMsgNotifyInfo: unknown): any {
}
onRecvGroupGuildFlag (_i2: unknown): any {
}
onRecvMsg (_arrayList: RawMessage[]): any {
}
onRecvMsgSvrRspTransInfo (_j2: unknown, _contact: unknown, _i2: unknown, _i3: unknown, _str: unknown, _bArr: unknown): any {
}
onRecvOnlineFileMsg (_arrayList: unknown): any {
}
onRecvS2CMsg (_arrayList: unknown): any {
}
onRecvSysMsg (_arrayList: Array<number>): any {
}
onRecvUDCFlag (_i2: unknown): any {
}
onRichMediaDownloadComplete (_fileTransNotifyInfo: OnRichMediaDownloadCompleteParams): any {
}
onRichMediaProgerssUpdate (_fileTransNotifyInfo: unknown): any {
}
onRichMediaUploadComplete (_fileTransNotifyInfo: unknown): any {
}
onSearchGroupFileInfoUpdate (_searchGroupFileResult: unknown): any {
}
onSendMsgError (_j2: unknown, _contact: unknown, _i2: unknown, _str: unknown): any {
}
onSysMsgNotification (_i2: unknown, _j2: unknown, _j3: unknown, _arrayList: unknown): any {
}
onTempChatInfoUpdate (_tempChatInfo: TempOnRecvParams): any {
}
onUnreadCntAfterFirstView (_hashMap: unknown): any {
}
onUnreadCntUpdate (_hashMap: unknown): any {
}
onUserChannelTabStatusChanged (_z: unknown): any {
}
onUserOnlineStatusChanged (_z: unknown): any {
}
onUserTabStatusChanged (_arrayList: unknown): any {
}
onlineStatusBigIconDownloadPush (_i2: unknown, _j2: unknown, _str: unknown): any {
}
onlineStatusSmallIconDownloadPush (_i2: unknown, _j2: unknown, _str: unknown): any {
}
// 第一次发现于Linux
onUserSecQualityChanged (..._args: unknown[]): any {
}
onMsgWithRichLinkInfoUpdate (..._args: unknown[]): any {
}
onRedTouchChanged (..._args: unknown[]): any {
}
// 第一次发现于Win 9.9.9-23159
onBroadcastHelperProgerssUpdate (..._args: unknown[]): any {
}
}
@@ -0,0 +1,71 @@
import { User, UserDetailInfoListenerArg } from '@/napcat-core/types';
export class NodeIKernelProfileListener {
onUserDetailInfoChanged (_arg: UserDetailInfoListenerArg): void {
}
onProfileSimpleChanged (..._args: unknown[]): any {
}
onProfileDetailInfoChanged (_profile: User): any {
}
onStatusUpdate (..._args: unknown[]): any {
}
onSelfStatusChanged (..._args: unknown[]): any {
}
onStrangerRemarkChanged (..._args: unknown[]): any {
}
onMemberListChange (..._args: unknown[]): any {
}
onMemberInfoChange (..._args: unknown[]): any {
}
onGroupListUpdate (..._args: unknown[]): any {
}
onGroupAllInfoChange (..._args: unknown[]): any {
}
onGroupDetailInfoChange (..._args: unknown[]): any {
}
onGroupConfMemberChange (..._args: unknown[]): any {
}
onGroupExtListUpdate (..._args: unknown[]): any {
}
onGroupNotifiesUpdated (..._args: unknown[]): any {
}
onGroupNotifiesUnreadCountUpdated (..._args: unknown[]): any {
}
onGroupMemberLevelInfoChange (..._args: unknown[]): any {
}
onGroupBulletinChange (..._args: unknown[]): any {
}
}
@@ -0,0 +1,25 @@
export class NodeIKernelRecentContactListener {
onDeletedContactsNotify (..._args: unknown[]): any {
}
onRecentContactNotification (_msgList: any, _arg0: { msgListUnreadCnt: string; }, _arg1: number): any {
}
onMsgUnreadCountUpdate (..._args: unknown[]): any {
}
onGuildDisplayRecentContactListChanged (..._args: unknown[]): any {
}
onRecentContactListChanged (..._args: unknown[]): any {
}
onRecentContactListChangedVer2 (..._args: unknown[]): any {
}
}
@@ -0,0 +1,13 @@
export class NodeIKernelRobotListener {
onRobotFriendListChanged (..._args: unknown[]): any {
}
onRobotListChanged (..._args: unknown[]): any {
}
onRobotProfileChanged (..._args: unknown[]): any {
}
}
@@ -0,0 +1,120 @@
import { ChatType, RawMessage } from '@/napcat-core/index';
export interface SearchGroupInfo {
groupCode: string;
ownerUid: string;
groupFlag: number;
groupFlagExt: number;
maxMemberNum: number;
memberNum: number;
groupOption: number;
classExt: number;
groupName: string;
fingerMemo: string;
groupQuestion: string;
certType: number;
shutUpAllTimestamp: number;
shutUpMeTimestamp: number;
groupTypeFlag: number;
privilegeFlag: number;
groupSecLevel: number;
groupFlagExt3: number;
isConfGroup: number;
isModifyConfGroupFace: number;
isModifyConfGroupName: number;
noFigerOpenFlag: number;
noCodeFingerOpenFlag: number;
groupFlagExt4: number;
groupMemo: string;
cmdUinMsgSeq: number;
cmdUinJoinTime: number;
cmdUinUinFlag: number;
cmdUinMsgMask: number;
groupSecLevelInfo: number;
cmdUinPrivilege: number;
cmdUinFlagEx2: number;
appealDeadline: number;
remarkName: string;
isTop: boolean;
richFingerMemo: string;
groupAnswer: string;
joinGroupAuth: string;
isAllowModifyConfGroupName: number;
}
export interface GroupInfo {
groupCode: string;
searchGroupInfo: SearchGroupInfo;
privilege: number;
}
export interface GroupSearchResult {
keyWord: string;
errorCode: number;
groupInfos: GroupInfo[];
penetrate: string;
isEnd: boolean;
nextPos: number;
}
export interface NodeIKernelSearchListener {
onSearchGroupResult(params: GroupSearchResult): any;
onSearchFileKeywordsResult(params: {
searchId: string,
hasMore: boolean,
resultItems: {
chatType: ChatType,
buddyChatInfo: any[],
discussChatInfo: any[],
groupChatInfo: {
groupCode: string,
isConf: boolean,
hasModifyConfGroupFace: boolean,
hasModifyConfGroupName: boolean,
groupName: string,
remark: string
}[],
dataLineChatInfo: any[],
tmpChatInfo: any[],
msgId: string,
msgSeq: string,
msgTime: string,
senderUid: string,
senderNick: string,
senderRemark: string,
senderCard: string,
elemId: string,
elemType: number,
fileSize: string,
filePath: string,
fileName: string,
hits: {
start: number,
end: number
}[]
}[]
}): any;
onSearchMsgKeywordsResult(params: {
searchId: string,
hasMore: boolean,
resultItems: Array<{
msgId: string,
msgSeq: string,
msgTime: string,
senderUid: string,
senderUin: string,
senderNick: string,
senderNickHits: unknown[],
senderRemark: string,
senderRemarkHits: unknown[],
senderCard: string,
senderCardHits: unknown[],
fieldType: number,
fieldText: string,
msgRecord: RawMessage;
hitsInfo: Array<unknown>,
msgAbstract: unknown,
}>
}): void | Promise<void>;
}
@@ -0,0 +1,25 @@
export class NodeIKernelSessionListener {
onNTSessionCreate (_args: unknown): any {
}
onGProSessionCreate (_args: unknown): any {
}
onSessionInitComplete (_args: unknown): any {
}
onOpentelemetryInit (_info: { is_init: boolean, is_report: boolean; }): any {
}
onUserOnlineResult (_args: unknown): any {
}
onGetSelfTinyId (_args: unknown): any {
}
}
@@ -0,0 +1,21 @@
export class NodeIKernelStorageCleanListener {
onCleanCacheProgressChanged (_args: unknown): any {
}
onScanCacheProgressChanged (_current_progress: number, _total_progress: number): any {
}
onCleanCacheStorageChanged (_args: unknown): any {
}
onFinishScan (_sizes: Array<`${number}`>): any {
}
onChatCleanDone (_args: unknown): any {
}
}
@@ -0,0 +1,5 @@
export class NodeIKernelTicketListener {
listener (): any {
}
}
Loaded 100 of 1111 files, more files were not shown because too many files have changed in this diff. Show more