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

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

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

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
# NapCat-Test
## 测试方向
测试过程不涉及账号登录
@@ -0,0 +1,124 @@
import { describe, expect, test } from 'vitest';
import {
applyGroupManagementSettings,
createGroupDetailInfoV2Param,
createGroupManagementRequests,
} from '../snowcat-core/data/group';
import { assertGroupManagementResults } from '../snowcat-onebot/action/group/GroupOperationHelpers';
describe('NTQQ group operations', () => {
test('reports the exact native setting that failed', () => {
expect(() => assertGroupManagementResults('设置群成员功能权限', [{
setting: 'allow_member_create_group',
result: { result: 1006, errMsg: '未知错误' },
}])).toThrow(/allow_member_create_group.*1006.*未知错误/);
expect(() => assertGroupManagementResults('设置群成员功能权限', []))
.not.toThrow();
});
test('builds the NTQQ permission masks without changing unrelated flags', async () => {
const request = createGroupDetailInfoV2Param('123456');
expect(applyGroupManagementSettings(request, {
memberInvite: 'no_approval',
allowMemberUploadAlbum: false,
allowMemberTemporarySession: true,
allowMemberCreateGroup: false,
newMembersSeeRecentHistory: true,
})).toBe(true);
expect(request.groupCode).toBe('123456');
expect(request.filter).toMatchObject({
allowMemberInvite: 1,
appPrivilegeFlag: 1,
appPrivilegeMask: 1,
groupFlagExt4: 1,
groupFlagExt4Mask: 1,
});
expect(request.modifyInfo).toMatchObject({
allowMemberInvite: 1,
appPrivilegeFlag: 0x00108001,
appPrivilegeMask: 0x06118001,
groupFlagExt3: 0,
groupFlagExt3Mask: 0,
groupFlagExt4: 0x4,
groupFlagExt4Mask: 0x4,
});
});
test('maps every member invite policy to the native app privilege value', async () => {
const expected = {
disabled: 0x04000000,
require_approval: 0,
no_approval: 0x00100000,
no_approval_under_100: 0x02000000,
} as const;
for (const [memberInvite, privilegeFlag] of Object.entries(expected)) {
const request = createGroupDetailInfoV2Param('123456');
applyGroupManagementSettings(request, { memberInvite: memberInvite as keyof typeof expected });
expect(request.modifyInfo.allowMemberInvite).toBe(memberInvite === 'disabled' ? 0 : 1);
expect(request.modifyInfo.appPrivilegeFlag).toBe(privilegeFlag);
expect(request.modifyInfo.appPrivilegeMask).toBe(0x06100000);
}
});
test('uses NTQQ privilege operation type 8 for album, temporary-session, and create-group flags', () => {
const requests = createGroupManagementRequests('123456', {
memberInvite: 'require_approval',
allowMemberUploadAlbum: true,
allowMemberTemporarySession: true,
allowMemberCreateGroup: true,
}, 0x18001);
expect(requests.map(request => request.operationType)).toEqual([0, 8, 8, 8]);
expect(requests.map(request => request.setting)).toEqual([
'member_invite',
'allow_member_upload_album',
'allow_member_temporary_session',
'allow_member_create_group',
]);
expect(requests.slice(1).map(request => request.param.modifyInfo.appPrivilegeMask))
.toEqual([0x1, 0x10000, 0x8000]);
expect(requests[1]?.param.modifyInfo).toMatchObject({
appPrivilegeFlag: 0x18000,
appPrivilegeMask: 0x1,
groupFlagExt3: 0,
groupFlagExt3Mask: 0,
});
expect(requests[2]?.param.modifyInfo).toMatchObject({
appPrivilegeFlag: 0x8000,
appPrivilegeMask: 0x10000,
groupFlagExt3: 0,
groupFlagExt3Mask: 0,
});
expect(requests[3]?.param.modifyInfo).toMatchObject({
appPrivilegeFlag: 0,
appPrivilegeMask: 0x8000,
groupFlagExt3: 0,
groupFlagExt3Mask: 0,
});
});
test('preserves the complete privilege value when clearing Linux NTQQ permission bits', () => {
const requests = createGroupManagementRequests('123456', {
allowMemberUploadAlbum: false,
allowMemberTemporarySession: false,
allowMemberCreateGroup: false,
}, 0);
expect(requests.map(request => request.param.modifyInfo.appPrivilegeFlag))
.toEqual([0x1, 0x10001, 0x18001]);
expect(requests.map(request => request.param.modifyInfo.appPrivilegeMask))
.toEqual([0x1, 0x10000, 0x8000]);
});
test('skips privilege requests whose effective value is already current', () => {
const requests = createGroupManagementRequests('123456', {
allowMemberUploadAlbum: true,
allowMemberTemporarySession: true,
allowMemberCreateGroup: true,
newMembersSeeRecentHistory: true,
}, 0x00100000, 0x4);
expect(requests).toEqual([]);
});
});
@@ -0,0 +1,59 @@
import { describe, expect, test, vi } from 'vitest';
import { NapProtoMsg } from 'snowcat-protobuf';
import { OidbSvcTrpcTcpBase } from '@/snowcat-core/packet/transformer/proto/oidb/OidbBase';
import { OidbSvcTrpcTcp0XF90 } from '@/snowcat-core/packet/transformer/proto/oidb/Oidb.0xf90';
import SetGroupTodo from '@/snowcat-core/packet/transformer/action/SetGroupTodo';
import CompleteGroupTodo from '@/snowcat-core/packet/transformer/action/CompleteGroupTodo';
import CancelGroupTodo from '@/snowcat-core/packet/transformer/action/CancelGroupTodo';
vi.mock('@/snowcat-core/packet/transformer/base', () => ({
PacketTransformer: class PacketTransformer {
protected msgBuilder: unknown;
constructor () {
this.msgBuilder = undefined;
}
},
PacketBufBuilder: (str: Uint8Array) => Buffer.from(str),
}));
const decodePacket = (data: Buffer) => {
const base = new NapProtoMsg(OidbSvcTrpcTcpBase).decode(data);
const body = new NapProtoMsg(OidbSvcTrpcTcp0XF90).decode(base.body);
return { base, body };
};
describe('Group todo packet builder', () => {
test('builds OIDB 0xF90 subCommand 1 for set', () => {
const packet = SetGroupTodo.build(123456, '789');
const { base, body } = decodePacket(packet.data);
expect(packet.cmd).toBe('OidbSvcTrpcTcp.0xF90_1');
expect(base.command).toBe(0xF90);
expect(base.subCommand).toBe(1);
expect(body.groupUin).toBe(123456);
expect(body.msgSeq).toBe('789');
});
test('builds OIDB 0xF90 subCommand 2 for complete', () => {
const packet = CompleteGroupTodo.build(123456, '789');
const { base, body } = decodePacket(packet.data);
expect(packet.cmd).toBe('OidbSvcTrpcTcp.0xF90_2');
expect(base.command).toBe(0xF90);
expect(base.subCommand).toBe(2);
expect(body.groupUin).toBe(123456);
expect(body.msgSeq).toBe('789');
});
test('builds OIDB 0xF90 subCommand 3 for cancel', () => {
const packet = CancelGroupTodo.build(123456, '789');
const { base, body } = decodePacket(packet.data);
expect(packet.cmd).toBe('OidbSvcTrpcTcp.0xF90_3');
expect(base.command).toBe(0xF90);
expect(base.subCommand).toBe(3);
expect(body.groupUin).toBe(123456);
expect(body.msgSeq).toBe('789');
});
});
+346
View File
@@ -0,0 +1,346 @@
import { describe, it, expect } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import {
detectImageTypeFromBuffer,
imageSizeFromBuffer,
imageSizeFromBufferFallBack,
imageSizeFromFile,
matchMagic,
ImageType,
} from '@/snowcat-image-size/src';
// resource 目录路径
const resourceDir = path.resolve(__dirname, '../snowcat-image-size/resource');
// 测试用的 Buffer 数据
const testBuffers = {
// PNG 测试图片 (100x200)
png: Buffer.from([
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00, 0xC8,
0x08, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
]),
// JPEG 测试图片 (320x240)
jpeg: Buffer.from([
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10,
0x4A, 0x46, 0x49, 0x46, 0x00,
0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00,
0xFF, 0xC0, 0x00, 0x0B, 0x08,
0x00, 0xF0, 0x01, 0x40, 0x03, 0x01, 0x22, 0x00,
]),
// BMP 测试图片 (640x480)
bmp: (() => {
const buf = Buffer.alloc(54);
buf.write('BM', 0);
buf.writeUInt32LE(54, 2);
buf.writeUInt32LE(0, 6);
buf.writeUInt32LE(54, 10);
buf.writeUInt32LE(40, 14);
buf.writeUInt32LE(640, 18);
buf.writeUInt32LE(480, 22);
buf.writeUInt16LE(1, 26);
buf.writeUInt16LE(24, 28);
return buf;
})(),
// GIF87a 测试图片 (800x600)
gif87a: Buffer.from([
0x47, 0x49, 0x46, 0x38, 0x37, 0x61,
0x20, 0x03, 0x58, 0x02, 0x00, 0x00, 0x00,
]),
// GIF89a 测试图片 (1024x768)
gif89a: Buffer.from([
0x47, 0x49, 0x46, 0x38, 0x39, 0x61,
0x00, 0x04, 0x00, 0x03, 0x00, 0x00, 0x00,
]),
// WebP VP8 测试图片 (1920x1080)
webpVP8: (() => {
const buf = Buffer.alloc(32);
buf.write('RIFF', 0);
buf.writeUInt32LE(24, 4);
buf.write('WEBP', 8);
buf.write('VP8 ', 12);
buf.writeUInt32LE(14, 16);
buf.writeUInt8(0x9D, 20);
buf.writeUInt8(0x01, 21);
buf.writeUInt8(0x2A, 22);
buf.writeUInt16LE(1920 & 0x3FFF, 26);
buf.writeUInt16LE(1080 & 0x3FFF, 28);
return buf;
})(),
// WebP VP8L 测试图片 (256x128)
webpVP8L: (() => {
const buf = Buffer.alloc(32);
buf.write('RIFF', 0);
buf.writeUInt32LE(24, 4);
buf.write('WEBP', 8);
buf.write('VP8L', 12);
buf.writeUInt32LE(10, 16);
buf.writeUInt8(0x2F, 20);
const vp8lBits = (256 - 1) | ((128 - 1) << 14);
buf.writeUInt32LE(vp8lBits, 21);
return buf;
})(),
// WebP VP8X 测试图片 (512x384)
webpVP8X: (() => {
const buf = Buffer.alloc(32);
buf.write('RIFF', 0);
buf.writeUInt32LE(24, 4);
buf.write('WEBP', 8);
buf.write('VP8X', 12);
buf.writeUInt32LE(10, 16);
buf.writeUInt8((512 - 1) & 0xFF, 24);
buf.writeUInt8(((512 - 1) >> 8) & 0xFF, 25);
buf.writeUInt8(((512 - 1) >> 16) & 0xFF, 26);
buf.writeUInt8((384 - 1) & 0xFF, 27);
buf.writeUInt8(((384 - 1) >> 8) & 0xFF, 28);
buf.writeUInt8(((384 - 1) >> 16) & 0xFF, 29);
return buf;
})(),
// TIFF Little Endian 测试图片
tiffLE: Buffer.from([
0x49, 0x49, 0x2A, 0x00, // II + magic
0x08, 0x00, 0x00, 0x00, // IFD offset = 8
0x02, 0x00, // 2 entries
// Entry 1: ImageWidth = 100
0x00, 0x01, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00,
// Entry 2: ImageHeight = 200
0x01, 0x01, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0xC8, 0x00, 0x00, 0x00,
]),
// TIFF Big Endian 测试图片
tiffBE: Buffer.from([
0x4D, 0x4D, 0x00, 0x2A, // MM + magic
0x00, 0x00, 0x00, 0x08, // IFD offset = 8
0x00, 0x02, // 2 entries
// Entry 1: ImageWidth = 100
0x01, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x64, 0x00, 0x00,
// Entry 2: ImageHeight = 200
0x01, 0x01, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0xC8, 0x00, 0x00,
]),
invalid: Buffer.from('This is not an image file'),
empty: Buffer.alloc(0),
};
describe('snowcat-image-size', () => {
describe('matchMagic', () => {
it('should match magic bytes at the beginning', () => {
const buffer = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47])).toBe(true);
});
it('should match magic bytes at offset', () => {
const buffer = Buffer.from([0x00, 0x00, 0x89, 0x50, 0x4E, 0x47]);
expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47], 2)).toBe(true);
});
it('should return false for non-matching magic', () => {
const buffer = Buffer.from([0x00, 0x00, 0x00, 0x00]);
expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47])).toBe(false);
});
it('should return false for buffer too short', () => {
const buffer = Buffer.from([0x89, 0x50]);
expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47])).toBe(false);
});
it('should return false for offset beyond buffer', () => {
const buffer = Buffer.from([0x89, 0x50, 0x4E, 0x47]);
expect(matchMagic(buffer, [0x89, 0x50], 10)).toBe(false);
});
});
describe('detectImageTypeFromBuffer', () => {
it('should detect PNG image type', () => {
expect(detectImageTypeFromBuffer(testBuffers.png)).toBe(ImageType.PNG);
});
it('should detect JPEG image type', () => {
expect(detectImageTypeFromBuffer(testBuffers.jpeg)).toBe(ImageType.JPEG);
});
it('should detect BMP image type', () => {
expect(detectImageTypeFromBuffer(testBuffers.bmp)).toBe(ImageType.BMP);
});
it('should detect GIF87a image type', () => {
expect(detectImageTypeFromBuffer(testBuffers.gif87a)).toBe(ImageType.GIF);
});
it('should detect GIF89a image type', () => {
expect(detectImageTypeFromBuffer(testBuffers.gif89a)).toBe(ImageType.GIF);
});
it('should detect WebP VP8 image type', () => {
expect(detectImageTypeFromBuffer(testBuffers.webpVP8)).toBe(ImageType.WEBP);
});
it('should detect WebP VP8L image type', () => {
expect(detectImageTypeFromBuffer(testBuffers.webpVP8L)).toBe(ImageType.WEBP);
});
it('should detect WebP VP8X image type', () => {
expect(detectImageTypeFromBuffer(testBuffers.webpVP8X)).toBe(ImageType.WEBP);
});
it('should detect TIFF Little Endian image type', () => {
expect(detectImageTypeFromBuffer(testBuffers.tiffLE)).toBe(ImageType.TIFF);
});
it('should detect TIFF Big Endian image type', () => {
expect(detectImageTypeFromBuffer(testBuffers.tiffBE)).toBe(ImageType.TIFF);
});
it('should return UNKNOWN for invalid data', () => {
expect(detectImageTypeFromBuffer(testBuffers.invalid)).toBe(ImageType.UNKNOWN);
});
it('should return UNKNOWN for empty buffer', () => {
expect(detectImageTypeFromBuffer(testBuffers.empty)).toBe(ImageType.UNKNOWN);
});
});
describe('imageSizeFromBuffer', () => {
it('should parse PNG image size correctly', async () => {
expect(await imageSizeFromBuffer(testBuffers.png)).toEqual({ width: 100, height: 200 });
});
it('should parse JPEG image size correctly', async () => {
expect(await imageSizeFromBuffer(testBuffers.jpeg)).toEqual({ width: 320, height: 240 });
});
it('should parse BMP image size correctly', async () => {
expect(await imageSizeFromBuffer(testBuffers.bmp)).toEqual({ width: 640, height: 480 });
});
it('should parse GIF87a image size correctly', async () => {
expect(await imageSizeFromBuffer(testBuffers.gif87a)).toEqual({ width: 800, height: 600 });
});
it('should parse GIF89a image size correctly', async () => {
expect(await imageSizeFromBuffer(testBuffers.gif89a)).toEqual({ width: 1024, height: 768 });
});
it('should parse WebP VP8 image size correctly', async () => {
expect(await imageSizeFromBuffer(testBuffers.webpVP8)).toEqual({ width: 1920, height: 1080 });
});
it('should parse WebP VP8L image size correctly', async () => {
expect(await imageSizeFromBuffer(testBuffers.webpVP8L)).toEqual({ width: 256, height: 128 });
});
it('should parse WebP VP8X image size correctly', async () => {
expect(await imageSizeFromBuffer(testBuffers.webpVP8X)).toEqual({ width: 512, height: 384 });
});
it('should parse TIFF Little Endian image size correctly', async () => {
expect(await imageSizeFromBuffer(testBuffers.tiffLE)).toEqual({ width: 100, height: 200 });
});
it('should parse TIFF Big Endian image size correctly', async () => {
expect(await imageSizeFromBuffer(testBuffers.tiffBE)).toEqual({ width: 100, height: 200 });
});
it('should return undefined for invalid data', async () => {
expect(await imageSizeFromBuffer(testBuffers.invalid)).toBeUndefined();
});
it('should return undefined for empty buffer', async () => {
expect(await imageSizeFromBuffer(testBuffers.empty)).toBeUndefined();
});
});
describe('imageSizeFromBufferFallBack', () => {
it('should return actual size for valid image', async () => {
expect(await imageSizeFromBufferFallBack(testBuffers.png)).toEqual({ width: 100, height: 200 });
});
it('should return default fallback for invalid data', async () => {
expect(await imageSizeFromBufferFallBack(testBuffers.invalid)).toEqual({ width: 1024, height: 1024 });
});
it('should return custom fallback for invalid data', async () => {
expect(await imageSizeFromBufferFallBack(testBuffers.invalid, { width: 500, height: 300 })).toEqual({ width: 500, height: 300 });
});
it('should return default fallback for empty buffer', async () => {
expect(await imageSizeFromBufferFallBack(testBuffers.empty)).toEqual({ width: 1024, height: 1024 });
});
it('should return custom fallback for empty buffer', async () => {
expect(await imageSizeFromBufferFallBack(testBuffers.empty, { width: 800, height: 600 })).toEqual({ width: 800, height: 600 });
});
});
describe('ImageType enum', () => {
it('should have correct enum values', () => {
expect(ImageType.JPEG).toBe('jpeg');
expect(ImageType.PNG).toBe('png');
expect(ImageType.BMP).toBe('bmp');
expect(ImageType.GIF).toBe('gif');
expect(ImageType.WEBP).toBe('webp');
expect(ImageType.TIFF).toBe('tiff');
expect(ImageType.UNKNOWN).toBe('unknown');
});
});
describe('Real image files from resource directory', () => {
it('should detect and parse test-20x20.jpg', async () => {
const filePath = path.join(resourceDir, 'test-20x20.jpg');
const buffer = fs.readFileSync(filePath);
expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.JPEG);
const size = await imageSizeFromBuffer(buffer);
expect(size).toEqual({ width: 20, height: 20 });
});
it('should detect and parse test-20x20.png', async () => {
const filePath = path.join(resourceDir, 'test-20x20.png');
const buffer = fs.readFileSync(filePath);
expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.PNG);
const size = await imageSizeFromBuffer(buffer);
expect(size).toEqual({ width: 20, height: 20 });
});
it('should detect and parse test-20x20.tiff', async () => {
const filePath = path.join(resourceDir, 'test-20x20.tiff');
const buffer = fs.readFileSync(filePath);
expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.TIFF);
const size = await imageSizeFromBuffer(buffer);
expect(size).toEqual({ width: 20, height: 20 });
});
it('should detect and parse test-20x20.webp', async () => {
const filePath = path.join(resourceDir, 'test-20x20.webp');
const buffer = fs.readFileSync(filePath);
expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.WEBP);
const size = await imageSizeFromBuffer(buffer);
expect(size).toEqual({ width: 20, height: 20 });
});
it('should detect and parse test-490x498.gif', async () => {
const filePath = path.join(resourceDir, 'test-490x498.gif');
const buffer = fs.readFileSync(filePath);
expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.GIF);
const size = await imageSizeFromBuffer(buffer);
expect(size).toEqual({ width: 490, height: 498 });
});
it('should parse real images using imageSizeFromFile', async () => {
expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.jpg'))).toEqual({ width: 20, height: 20 });
expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.png'))).toEqual({ width: 20, height: 20 });
expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.tiff'))).toEqual({ width: 20, height: 20 });
expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.webp'))).toEqual({ width: 20, height: 20 });
expect(await imageSizeFromFile(path.join(resourceDir, 'test-490x498.gif'))).toEqual({ width: 490, height: 498 });
});
});
});
+21
View File
@@ -0,0 +1,21 @@
{
"name": "snowcat-test",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui"
},
"devDependencies": {
"vitest": "^4.0.9"
},
"dependencies": {
"@sinclair/typebox": "^0.34.41",
"snowcat-core": "workspace:*",
"snowcat-image-size": "workspace:*",
"snowcat-onebot": "workspace:*",
"snowcat-rpc": "workspace:*",
"snowcat-protobuf": "workspace:*"
}
}
+72
View File
@@ -0,0 +1,72 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { QQBasicInfoWrapper } from '@/snowcat-core/helper/qq-basic-info';
vi.mock('@/snowcat-core/index', () => ({ getMajorPath: vi.fn() }));
describe('QQ AppID lookup', () => {
let info: QQBasicInfoWrapper;
const archDescriptor = Object.getOwnPropertyDescriptor(process, 'arch')!;
beforeEach(() => {
Object.defineProperty(process, 'arch', { ...archDescriptor, value: 'x64' });
info = Object.create(QQBasicInfoWrapper.prototype);
info.context = { logger: { log: vi.fn() } } as QQBasicInfoWrapper['context'];
vi.spyOn(info, 'getFullQQVersion');
vi.spyOn(info, 'getAppidV2ByMajor').mockReturnValue('537999999');
});
afterEach(() => {
Object.defineProperty(process, 'arch', archDescriptor);
vi.restoreAllMocks();
});
it.each([
['3.2.30-50969', 'x64', 537376344],
['3.2.30-50969', 'arm64', 537376345],
['3.2.32-52194', 'x64', 537379447],
['3.2.32-52194', 'arm64', 537379448],
['3.2.33-52892', 'x64', 537382855],
['3.2.33-52892', 'arm64', 537382856],
] as const)('uses the scanned AppID for Linux %s %s', (version, arch, appid) => {
Object.defineProperty(process, 'arch', { ...archDescriptor, value: arch });
vi.mocked(info.getFullQQVersion).mockReturnValue(version);
expect(info.getAppidV2()).toEqual({
appid,
qua: `V1_LNX_NQ_${version.replace('-', '_')}_GW_B`,
});
expect(info.getAppidV2ByMajor).not.toHaveBeenCalled();
});
it.each([
['9.9.33-52230', 537379411],
['9.9.35-52892', 537382819],
] as const)('finds Windows %s by its actual package version', (version, appid) => {
vi.mocked(info.getFullQQVersion).mockReturnValue(version);
expect(info.getAppidV2()).toEqual({
appid,
qua: `V1_WIN_NQ_${version.replace('-', '_')}_GW_B`,
});
expect(info.getAppidV2ByMajor).not.toHaveBeenCalled();
});
it('preserves version-only entries when an architecture override is absent', () => {
Object.defineProperty(process, 'arch', { ...archDescriptor, value: 'arm64' });
vi.mocked(info.getFullQQVersion).mockReturnValue('3.2.29-49738');
expect(info.getAppidV2()).toEqual({
appid: 537355867,
qua: 'V1_LNX_NQ_3.2.29_49738_GW_B',
});
expect(info.getAppidV2ByMajor).not.toHaveBeenCalled();
});
it('still scans major.node for an unknown version', () => {
vi.mocked(info.getFullQQVersion).mockReturnValue('3.2.99-99999');
vi.spyOn(info, 'getQUAFallback').mockReturnValue('fallback-qua');
expect(info.getAppidV2()).toEqual({ appid: '537999999', qua: 'fallback-qua' });
expect(info.getAppidV2ByMajor).toHaveBeenCalledWith('3.2.99-99999');
});
});
+120
View File
@@ -0,0 +1,120 @@
import { describe, expect, test } from 'vitest';
import {
buildQzoneDeleteBody,
buildQzonePublishBody,
buildQzoneUploadImageBody,
parseQzoneDeleteResponse,
parseQzonePublishResponse,
parseQzoneUploadImageResponseText,
toQzoneRichval,
} from '@/snowcat-core/data/qzone';
describe('Qzone data helpers', () => {
test('buildQzoneUploadImageBody encodes required fields', () => {
const body = buildQzoneUploadImageBody({
uin: '123456',
skey: 'sk',
pskey: 'pk',
g_tk: '999',
base64: 'AAAA',
});
const params = new URLSearchParams(body);
expect(params.get('uin')).toBe('123456');
expect(params.get('skey')).toBe('sk');
expect(params.get('p_skey')).toBe('pk');
expect(params.get('picfile')).toBe('AAAA');
expect(params.get('uploadtype')).toBe('1');
expect(params.get('albumtype')).toBe('7');
expect(params.get('base64')).toBe('1');
expect(params.get('qzreferrer')).toContain('123456');
});
test('parseQzoneUploadImageResponseText parses valid callback wrapper', () => {
const raw = 'frameElement.callback({"code":0,"data":{"albumid":"a1","lloc":"l1","type":"22","height":"100","width":"200"}});</script>';
const parsed = parseQzoneUploadImageResponseText(raw);
expect(parsed.code).toBe(0);
expect(parsed.data?.albumid).toBe('a1');
expect(parsed.data?.lloc).toBe('l1');
});
test('parseQzoneUploadImageResponseText throws on malformed response', () => {
expect(() => parseQzoneUploadImageResponseText('not a valid response')).toThrow();
});
test('toQzoneRichval builds expected format', () => {
const richval = toQzoneRichval({ albumid: 'a1', lloc: 'l1', type: '22', height: '100', width: '200' });
expect(richval).toBe(',a1,l1,l1,22,100,200,,100,200');
});
test('buildQzonePublishBody without images omits richval/richtype', () => {
const body = buildQzonePublishBody({
hostuin: '123456',
content: 'hello world',
ugcRight: 1,
});
const params = new URLSearchParams(body);
expect(params.get('con')).toBe('hello world');
expect(params.get('ugc_right')).toBe('1');
expect(params.has('richval')).toBe(false);
expect(params.has('richtype')).toBe(false);
expect(params.has('allow_uins')).toBe(false);
});
test('buildQzonePublishBody with images and allow_uins sets richtype and who', () => {
const body = buildQzonePublishBody({
hostuin: '123456',
content: 'with pic',
richval: ',a1,l1,l1,22,100,200,,100,200\t,a2,l2,l2,22,100,200,,100,200',
ugcRight: 16,
allowUins: '10001|10002',
});
const params = new URLSearchParams(body);
expect(params.get('richtype')).toBe('1');
expect(params.get('richval')).toContain('\t');
expect(params.get('ugc_right')).toBe('16');
expect(params.get('allow_uins')).toBe('10001|10002');
expect(params.get('who')).toBe('1');
});
test('parseQzonePublishResponse extracts tid on success', () => {
const result = parseQzonePublishResponse({ subcode: 0, tid: 'tid123' });
expect(result.tid).toBe('tid123');
});
test('parseQzonePublishResponse prefers t1_tid over tid', () => {
const result = parseQzonePublishResponse({ subcode: 0, tid: 'tid123', t1_tid: 't1tid456' });
expect(result.tid).toBe('t1tid456');
});
test('parseQzonePublishResponse throws when subcode indicates failure', () => {
expect(() => parseQzonePublishResponse({ subcode: -200, message: '权限不足' })).toThrow('权限不足');
});
test('parseQzonePublishResponse throws when tid missing', () => {
expect(() => parseQzonePublishResponse({ subcode: 0 })).toThrow();
});
test('buildQzoneDeleteBody encodes required fields', () => {
const body = buildQzoneDeleteBody({ hostuin: '123456', tid: 'tid123' });
const params = new URLSearchParams(body);
expect(params.get('hostuin')).toBe('123456');
expect(params.get('tid')).toBe('tid123');
expect(params.get('t1_source')).toBe('1');
expect(params.get('code_version')).toBe('1');
expect(params.get('format')).toBe('json');
expect(params.get('qzreferrer')).toContain('123456');
});
test('parseQzoneDeleteResponse succeeds when subcode is 0', () => {
const result = parseQzoneDeleteResponse({ subcode: 0 });
expect(result.success).toBe(true);
});
test('parseQzoneDeleteResponse throws when subcode indicates failure', () => {
expect(() => parseQzoneDeleteResponse({ subcode: -200, message: '权限不足' })).toThrow('权限不足');
});
test('parseQzoneDeleteResponse throws with default message when subcode indicates failure without message', () => {
expect(() => parseQzoneDeleteResponse({ subcode: -200 })).toThrow('subcode=-200');
});
});
+309
View File
@@ -0,0 +1,309 @@
import { describe, expect, test, vi } from 'vitest';
import { ChatType, NTMsgType, type RawMessage } from 'snowcat-core';
import { NapCatOneBot11Adapter } from '@/snowcat-onebot/index';
vi.mock('../snowcat-webui-backend/index', () => ({
pendingTokenToSend: undefined,
}));
vi.mock('../snowcat-webui-backend/src/helper/Data', () => ({
WebUiDataRuntime: {},
}));
vi.mock('@/snowcat-onebot/config', () => ({
OB11ConfigLoader: class {},
}));
vi.mock('@/snowcat-onebot/network', () => ({
OB11HttpClientAdapter: class {},
OB11WebSocketClientAdapter: class {},
OB11NetworkManager: class {},
OB11NetworkReloadType: {},
OB11HttpServerAdapter: class {},
OB11WebSocketServerAdapter: class {},
}));
vi.mock('@/snowcat-onebot/api', () => ({
OneBotFriendApi: class {},
OneBotGroupApi: class {},
OneBotMsgApi: class {},
OneBotQuickActionApi: class {},
OneBotUserApi: class {},
}));
vi.mock('@/snowcat-onebot/action', () => ({
createActionMap: vi.fn(),
}));
vi.mock('@/snowcat-onebot/network/adapter', () => ({
IOB11NetworkAdapter: class {},
}));
vi.mock('@/snowcat-onebot/network/http-server-sse', () => ({
OB11HttpSSEServerAdapter: class {},
}));
vi.mock('@/snowcat-onebot/network/plugin-manger', () => ({
OB11PluginMangerAdapter: class {},
}));
vi.mock('@/snowcat-onebot/api/file', () => ({
OneBotFileApi: class {},
}));
interface TestMsgListener {
onMsgRecall (chatType: ChatType, uid: string, msgSeq: string): Promise<void> | void;
onMsgInfoListUpdate (messages: RawMessage[]): Promise<void> | void;
}
const friendUid = 'u_friend';
const friendUin = '123456';
const recalledMsgId = 'recalled-message-id';
const recalledMsgSeq = '100';
const botUin = '999999';
function createMessage (overrides: Partial<RawMessage>): RawMessage {
return {
msgId: 'message-id',
msgRandom: '1',
msgSeq: '101',
cntSeq: '101',
chatType: ChatType.KCHATTYPEC2C,
msgType: NTMsgType.KMSGTYPEUNKNOWN,
subMsgType: 0,
sendStatus: 2,
senderUid: friendUid,
senderUin: friendUin,
peerUid: friendUid,
peerUin: friendUin,
peerName: 'friend',
sendNickName: 'friend',
sendRemarkName: 'friend',
msgTime: '1',
recallTime: '0',
elements: [],
records: [],
sourceType: 0,
isOnlineMsg: true,
...overrides,
} as RawMessage;
}
function createRecallGrayTip (): RawMessage {
return createMessage({
msgId: recalledMsgId,
msgSeq: recalledMsgSeq,
msgType: NTMsgType.KMSGTYPEGRAYTIPS,
recallTime: Math.floor(Date.now() / 1000).toString(),
elements: [{
elementType: 8,
elementId: 'recall-element',
grayTipElement: {
revokeElement: {
operatorRole: '0',
operatorUid: friendUid,
operatorNick: 'friend',
operatorRemark: '',
isSelfOperate: false,
wording: '',
},
},
} as unknown as RawMessage['elements'][number]],
});
}
function createHarness (queryResult: RawMessage[], operatorUin = '654321') {
let listener: TestMsgListener | undefined;
const emitEvent = vi.fn(async (_event: unknown): Promise<void> => undefined);
const queryMsgsWithFilterExWithSeq = vi.fn(async () => ({ msgList: queryResult }));
const registerListen = vi.fn();
const logger = {
logDebug: vi.fn(),
logError: vi.fn(),
logMessage: vi.fn(),
};
const context = {
logger,
session: {
getMsgService: () => ({
addKernelMsgListener: (registeredListener: TestMsgListener) => {
listener = registeredListener;
},
}),
},
};
const core = {
selfInfo: { uin: botUin },
apis: {
MsgApi: { queryMsgsWithFilterExWithSeq },
UserApi: { getUinByUidV2: vi.fn(async () => operatorUin) },
},
eventWrapper: { registerListen },
};
const adapter = Object.create(NapCatOneBot11Adapter.prototype) as NapCatOneBot11Adapter;
Object.assign(adapter, {
core,
context,
networkManager: { emitEvent },
bootTime: Date.now() / 1000,
recallEventCache: new Map(),
reportedRecallEventCache: new Map(),
processingRecallEvents: new Map(),
});
(adapter as unknown as { initMsgListener (): void }).initMsgListener();
if (!listener) {
throw new Error('message listener was not registered');
}
return { adapter, listener, emitEvent, logger, queryMsgsWithFilterExWithSeq, registerListen };
}
describe('friend recall reporting', () => {
test('reports a recalled non-latest private message from its gray-tip update', async () => {
const botReply = createMessage({
msgId: 'bot-reply-id',
msgSeq: '101',
senderUid: 'u_bot',
senderUin: botUin,
});
const grayTip = createRecallGrayTip();
const { listener, emitEvent } = createHarness([botReply]);
await listener.onMsgRecall(ChatType.KCHATTYPEC2C, friendUid, recalledMsgSeq);
await listener.onMsgInfoListUpdate([grayTip]);
expect(emitEvent).toHaveBeenCalledTimes(1);
expect(emitEvent).toHaveBeenCalledWith(expect.objectContaining({
notice_type: 'friend_recall',
user_id: Number(friendUin),
}));
});
test('deduplicates the same recall from the gray-tip update and recall callback', async () => {
const grayTip = createRecallGrayTip();
const { listener, emitEvent } = createHarness([grayTip]);
await listener.onMsgInfoListUpdate([grayTip]);
await listener.onMsgRecall(ChatType.KCHATTYPEC2C, friendUid, recalledMsgSeq);
await listener.onMsgInfoListUpdate([grayTip]);
expect(emitEvent).toHaveBeenCalledTimes(1);
});
test('deduplicates concurrent update and callback processing', async () => {
const grayTip = createRecallGrayTip();
const { listener, emitEvent } = createHarness([grayTip]);
let finishEmit: (() => void) | undefined;
emitEvent.mockImplementationOnce(() => new Promise<void>((resolve) => {
finishEmit = resolve;
}));
const updateTask = listener.onMsgInfoListUpdate([grayTip]);
await vi.waitFor(() => expect(emitEvent).toHaveBeenCalledTimes(1));
const callbackTask = listener.onMsgRecall(ChatType.KCHATTYPEC2C, friendUid, recalledMsgSeq);
finishEmit?.();
await Promise.all([updateTask, callbackTask]);
expect(emitEvent).toHaveBeenCalledTimes(1);
});
test('uses a concurrent duplicate callback to retry a failed report', async () => {
const grayTip = createRecallGrayTip();
const { listener, emitEvent, logger } = createHarness([grayTip]);
let failFirstEmit: (() => void) | undefined;
emitEvent
.mockImplementationOnce(() => new Promise<void>((_resolve, reject) => {
failFirstEmit = () => reject(new Error('network unavailable'));
}))
.mockResolvedValueOnce(undefined);
const updateTask = listener.onMsgInfoListUpdate([grayTip]);
await vi.waitFor(() => expect(emitEvent).toHaveBeenCalledTimes(1));
const callbackTask = listener.onMsgRecall(ChatType.KCHATTYPEC2C, friendUid, recalledMsgSeq);
failFirstEmit?.();
await Promise.all([updateTask, callbackTask]);
expect(emitEvent).toHaveBeenCalledTimes(2);
expect(logger.logError).toHaveBeenCalledWith('处理消息撤回失败', expect.any(Error));
});
test('reports one self recall without waiting for a duplicate callback', async () => {
const grayTip = createRecallGrayTip();
const revokeElement = grayTip.elements[0]?.grayTipElement?.revokeElement;
if (!revokeElement) throw new Error('missing revoke element');
revokeElement.isSelfOperate = true;
const { adapter, listener, emitEvent, registerListen } = createHarness([grayTip]);
const originTimeout = setTimeout(() => undefined, 60_000);
adapter.recallEventCache.set(recalledMsgId, originTimeout);
await listener.onMsgRecall(ChatType.KCHATTYPEC2C, friendUid, recalledMsgSeq);
await listener.onMsgRecall(ChatType.KCHATTYPEC2C, friendUid, recalledMsgSeq);
expect(emitEvent).toHaveBeenCalledTimes(1);
expect(adapter.recallEventCache.has(recalledMsgId)).toBe(false);
expect(registerListen).not.toHaveBeenCalled();
});
test('retries reporting when the first network emission fails', async () => {
const grayTip = createRecallGrayTip();
const { listener, emitEvent, logger } = createHarness([grayTip]);
emitEvent
.mockRejectedValueOnce(new Error('network unavailable'))
.mockResolvedValueOnce(undefined);
await listener.onMsgInfoListUpdate([grayTip]);
await listener.onMsgInfoListUpdate([grayTip]);
expect(emitEvent).toHaveBeenCalledTimes(2);
expect(logger.logError).toHaveBeenCalledWith('处理消息撤回失败', expect.any(Error));
});
test('ignores historical recall rows replayed during startup sync', async () => {
const grayTip = createRecallGrayTip();
grayTip.recallTime = Math.floor(Date.now() / 1000 - 120).toString();
const { listener, emitEvent } = createHarness([]);
await listener.onMsgInfoListUpdate([grayTip]);
expect(emitEvent).not.toHaveBeenCalled();
});
test('ignores ordinary message updates', async () => {
const ordinaryMessage = createMessage({ recallTime: Math.floor(Date.now() / 1000).toString() });
const { listener, emitEvent } = createHarness([]);
await listener.onMsgInfoListUpdate([ordinaryMessage]);
expect(emitEvent).not.toHaveBeenCalled();
});
});
describe('group recall reporting', () => {
test('preserves group, sender and operator fields', async () => {
const grayTip = createRecallGrayTip();
const groupUin = '112233';
const senderUin = '223344';
grayTip.chatType = ChatType.KCHATTYPEGROUP;
grayTip.peerUid = groupUin;
grayTip.peerUin = groupUin;
grayTip.senderUin = senderUin;
const { listener, emitEvent } = createHarness([], '334455');
await listener.onMsgInfoListUpdate([grayTip]);
expect(emitEvent).toHaveBeenCalledTimes(1);
expect(emitEvent).toHaveBeenCalledWith(expect.objectContaining({
notice_type: 'group_recall',
group_id: Number(groupUin),
user_id: Number(senderUin),
operator_id: 334455,
}));
});
});
File diff suppressed because it is too large. Load diff
+165
View File
@@ -0,0 +1,165 @@
import { describe, expect, test } from 'vitest';
import { TypeCompiler } from '@sinclair/typebox/compiler';
import { Value } from '@sinclair/typebox/value';
import { Type } from '@sinclair/typebox';
import { OB11MessageDataSchema, OB11MessageSchema, OB11PostSendMsgSchema } from '../snowcat-onebot/types/message';
import { OB11MessageSchema as OB11ActionMessageSchema } from '../snowcat-onebot/action/schemas';
import { OneBotConfigSchema, loadConfig as loadOneBotConfig, OneBotConfig } from '../snowcat-onebot/config';
import { SnowCatConfigSchema } from '../snowcat-core/helper/config';
describe('SnowCat Schemas Compilation', () => {
test('should compile OB11MessageDataSchema without duplicate id error', () => {
expect(() => TypeCompiler.Compile(OB11MessageDataSchema)).not.toThrow();
});
test('should compile OB11MessageSchema without duplicate id error', () => {
expect(() => TypeCompiler.Compile(OB11MessageSchema)).not.toThrow();
});
test('should compile OB11ActionMessageSchema without duplicate id error', () => {
expect(() => TypeCompiler.Compile(OB11ActionMessageSchema)).not.toThrow();
});
test('should compile OB11PostSendMsgSchema without error', () => {
expect(() => TypeCompiler.Compile(OB11PostSendMsgSchema)).not.toThrow();
});
test('should compile QuickAction hybrid schema without error', () => {
const QASchema = Type.Object({
message: OB11PostSendMsgSchema,
quick_action: OB11ActionMessageSchema,
});
expect(() => TypeCompiler.Compile(QASchema)).not.toThrow();
});
});
describe('SnowCat Schemas Validation & Coercion', () => {
test('should coerce numeric user_id to string in OB11PostSendMsgSchema', () => {
const payload = {
message_type: 'private',
user_id: 123456,
message: [{ type: 'text', data: { text: 'hello' } }],
};
let data: unknown = structuredClone(payload);
data = Value.Parse(OB11PostSendMsgSchema, data);
const compiler = TypeCompiler.Compile(OB11PostSendMsgSchema);
expect(compiler.Check(data)).toBe(true);
expect((data as Record<string, unknown>)['user_id']).toBe('123456');
});
test('should validate complex mixed messages correctly', () => {
const payload = {
message_type: 'group',
group_id: '654321',
message: 'this is a string message',
};
let data: unknown = structuredClone(payload);
data = Value.Parse(OB11PostSendMsgSchema, data);
const compiler = TypeCompiler.Compile(OB11PostSendMsgSchema);
expect(compiler.Check(data)).toBe(true);
});
});
describe('SnowCat Configuration Loaders', () => {
test('OneBotConfig schemas should compile', () => {
expect(() => TypeCompiler.Compile(OneBotConfigSchema)).not.toThrow();
});
test('OneBotConfig should load and apply defaults correctly', () => {
const partialConfig: Partial<OneBotConfig> = {
network: {
httpServers: [{
port: 3000,
enable: true,
name: 'test',
host: '127.0.0.1',
enableCors: true,
enableWebsocket: false,
messagePostFormat: 'array',
token: '',
debug: false,
}],
httpSseServers: [],
httpClients: [],
websocketServers: [],
websocketClients: [],
plugins: [],
},
};
const loaded = loadOneBotConfig(partialConfig);
expect(loaded.network.httpServers[0]?.host).toBe('127.0.0.1');
expect(loaded.network.httpServers[0]?.enableCors).toBe(true);
expect(loaded.musicSignUrl).toBe('');
});
test('OneBotConfig websocketClients should apply verifyCertificate default', () => {
const partialConfig: Partial<OneBotConfig> = {
network: {
httpServers: [],
httpSseServers: [],
httpClients: [],
websocketServers: [],
websocketClients: [{
name: 'test-ws-client',
enable: true,
url: 'wss://example.com',
messagePostFormat: 'array',
reportSelfMessage: false,
reconnectInterval: 5000,
token: '',
debug: false,
heartInterval: 30000,
}],
plugins: [],
},
};
const loaded = loadOneBotConfig(partialConfig);
expect(loaded.network.websocketClients[0]?.verifyCertificate).toBe(true);
});
test('OneBotConfig websocketClients should preserve explicit verifyCertificate false', () => {
const partialConfig: Partial<OneBotConfig> = {
network: {
httpServers: [],
httpSseServers: [],
httpClients: [],
websocketServers: [],
websocketClients: [{
name: 'test-ws-client',
enable: true,
url: 'wss://example.com',
messagePostFormat: 'array',
reportSelfMessage: false,
reconnectInterval: 5000,
token: '',
debug: false,
heartInterval: 30000,
verifyCertificate: false,
}],
plugins: [],
},
};
const loaded = loadOneBotConfig(partialConfig);
expect(loaded.network.websocketClients[0]?.verifyCertificate).toBe(false);
});
test('NapcatConfig should compile and apply defaults', () => {
let compiled: ReturnType<typeof TypeCompiler.Compile> | undefined;
expect(() => {
compiled = TypeCompiler.Compile(SnowCatConfigSchema);
}).not.toThrow();
let data: unknown = {};
data = Value.Parse(SnowCatConfigSchema, data);
expect(compiled?.Check(data)).toBe(true);
const resolved = data as Record<string, unknown>;
expect(resolved['consoleLog']).toBe(true);
expect(resolved['consoleLogLevel']).toBe('info');
});
});
+87
View File
@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest';
import crypto from 'crypto';
import { Sha1Stream } from 'snowcat-core/packet/utils/crypto/sha1Stream';
describe('Sha1Stream', () => {
it('should compute correct SHA-1 hash for empty string', () => {
const sha1Stream = new Sha1Stream();
const hash = sha1Stream.final();
const expected = crypto.createHash('sha1').update('').digest();
expect(hash).toEqual(expected);
});
it('should compute correct SHA-1 hash for simple string', () => {
const testData = 'Hello, World!';
const sha1Stream = new Sha1Stream();
sha1Stream.update(Buffer.from(testData));
const hash = sha1Stream.final();
const expected = crypto.createHash('sha1').update(testData).digest();
expect(hash).toEqual(expected);
});
it('should compute correct SHA-1 hash for binary data', () => {
const testData = Buffer.from([0x00, 0x01, 0x02, 0x03, 0x04, 0x05]);
const sha1Stream = new Sha1Stream();
sha1Stream.update(testData);
const hash = sha1Stream.final();
const expected = crypto.createHash('sha1').update(testData).digest();
expect(hash).toEqual(expected);
});
it('should handle multiple update calls', () => {
const part1 = 'Hello';
const part2 = ', World!';
const sha1Stream = new Sha1Stream();
sha1Stream.update(Buffer.from(part1));
sha1Stream.update(Buffer.from(part2));
const hash = sha1Stream.final();
const expected = crypto.createHash('sha1').update(part1 + part2).digest();
expect(hash).toEqual(expected);
});
it('should handle large data correctly', () => {
const testData = crypto.randomBytes(1024 * 10); // 10KB random data
const sha1Stream = new Sha1Stream();
sha1Stream.update(testData);
const hash = sha1Stream.final();
const expected = crypto.createHash('sha1').update(testData).digest();
expect(hash).toEqual(expected);
});
it('should produce consistent results for same input', () => {
const testData = 'Consistent test data';
const sha1Stream1 = new Sha1Stream();
sha1Stream1.update(Buffer.from(testData));
const hash1 = sha1Stream1.final();
const sha1Stream2 = new Sha1Stream();
sha1Stream2.update(Buffer.from(testData));
const hash2 = sha1Stream2.final();
expect(hash1).toEqual(hash2);
});
it('should handle edge case with exact block size', () => {
// SHA-1 block size is 64 bytes
const testData = 'a'.repeat(64);
const sha1Stream = new Sha1Stream();
sha1Stream.update(Buffer.from(testData));
const hash = sha1Stream.final();
const expected = crypto.createHash('sha1').update(testData).digest();
expect(hash).toEqual(expected);
});
it('should handle random data correctly', () => {
// Run multiple random tests (reduced from 100000 to 100 for performance)
for (let i = 0; i < 100; i++) {
const randomLength = Math.floor(Math.random() * 1024);
const randomData = crypto.randomBytes(randomLength);
const sha1Stream = new Sha1Stream();
sha1Stream.update(randomData);
const hash = sha1Stream.final();
const expectedDigest = crypto.createHash('sha1').update(randomData).digest();
expect(hash).toEqual(expectedDigest);
}
});
});
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".."
},
"include": [
"*.ts",
"**/*.ts"
],
"exclude": [
"node_modules",
"dist"
]
}
+170
View File
@@ -0,0 +1,170 @@
import { describe, expect, it, vi } from 'vitest';
import { NapProtoDecodeStructType, NapProtoEncodeStructType, NapProtoMsg } from 'snowcat-protobuf';
import { ElementType, NTVideoType } from '@/snowcat-core/types/msg';
import { SendVideoElement } from '@/snowcat-core/types/element';
import {
PacketMsgPicElement,
PacketMsgVideoElement,
} from '@/snowcat-core/packet/message/element';
import { PacketMsgConverter } from '@/snowcat-core/packet/message/converter';
import { assertUploadResults } from '@/snowcat-core/packet/context/uploadResult';
import { Elem, MsgInfo } from '@/snowcat-core/packet/transformer/proto';
import { NTV2RichMediaReq } from '@/snowcat-core/packet/transformer/proto/oidb/common/Ntv2.RichMediaReq';
import UploadGroupVideo from '@/snowcat-core/packet/transformer/highway/UploadGroupVideo';
import OidbBase from '@/snowcat-core/packet/transformer/oidb/oidbBase';
function createVideoMsgInfo (): NapProtoEncodeStructType<typeof MsgInfo> {
return {
msgInfoBody: [
{
index: {
info: {
fileSize: 1024,
fileHash: '00112233445566778899aabbccddeeff',
fileSha1: '00112233445566778899aabbccddeeff00112233',
fileName: 'clip.mp4',
type: {
type: 2,
videoFormat: NTVideoType.VIDEO_FORMAT_MP4,
},
width: 1920,
height: 1080,
time: 12,
},
fileUuid: 'video-uuid',
},
},
{
index: {
info: {
fileSize: 2048,
fileHash: 'ffeeddccbbaa99887766554433221100',
fileSha1: 'ffeeddccbbaa99887766554433221100ffeeddcc',
fileName: 'thumb.jpg',
type: {
type: 1,
picFormat: 0,
},
width: 640,
height: 360,
},
fileUuid: 'thumb-uuid',
},
},
],
};
}
function createSendVideoElement (): SendVideoElement {
return {
elementType: ElementType.VIDEO,
elementId: '',
videoElement: {
filePath: 'clip.mp4',
fileName: 'clip.mp4',
videoMd5: '00112233445566778899aabbccddeeff',
thumbMd5: 'ffeeddccbbaa99887766554433221100',
fileTime: 12,
fileFormat: NTVideoType.VIDEO_FORMAT_MP4,
fileSize: '1024',
fileWidth: 1920,
fileHeight: 1080,
thumbWidth: 640,
thumbHeight: 360,
thumbSize: 2048,
thumbPath: new Map([[0, 'thumb.jpg']]),
},
};
}
function decodeElem (
elem: NapProtoEncodeStructType<typeof Elem>
): NapProtoDecodeStructType<typeof Elem> {
const codec = new NapProtoMsg(Elem);
return codec.decode(codec.encode(elem));
}
function createCommonVideoElem (businessType: 11 | 21): NapProtoDecodeStructType<typeof Elem> {
return decodeElem({
commonElem: {
serviceType: 48,
businessType,
pbElem: new NapProtoMsg(MsgInfo).encode(createVideoMsgInfo()),
},
});
}
describe('packet video forwarding', () => {
it('emits only the common rich-media element so mobile rendering is not blocked', () => {
const video = new PacketMsgVideoElement(createSendVideoElement());
video.msgInfo = createVideoMsgInfo();
video.businessType = 11;
const elems = video.buildElement();
expect(elems).toHaveLength(1);
expect(elems[0]?.videoFile).toBeUndefined();
expect(elems[0]?.commonElem?.serviceType).toBe(48);
expect(elems[0]?.commonElem?.businessType).toBe(11);
expect(video.valid).toBe(true);
});
it('parses common business type 11/21 as video instead of image', () => {
for (const businessType of [11, 21] as const) {
const commonElem = createCommonVideoElem(businessType);
expect(PacketMsgPicElement.parseElement(commonElem)).toBeUndefined();
const parsed = new PacketMsgConverter().packetMsgToRaw([commonElem]);
expect(parsed).toHaveLength(1);
expect(parsed[0]?.[0].elementType).toBe(ElementType.VIDEO);
expect(parsed[0]?.[0].picElement).toBeUndefined();
expect(parsed[0]?.[0].videoElement).toMatchObject({
fileName: 'clip.mp4',
fileTime: 12,
fileWidth: 1920,
fileHeight: 1080,
thumbWidth: 640,
thumbHeight: 360,
fileUuid: 'video-uuid',
});
}
});
it('preserves video metadata in the rich-media upload request', () => {
const video = new PacketMsgVideoElement(createSendVideoElement());
video.fileSha1 = '00112233445566778899aabbccddeeff00112233';
video.thumbSha1 = 'ffeeddccbbaa99887766554433221100ffeeddcc';
const packet = UploadGroupVideo.build(123456, video);
const envelope = OidbBase.parse(packet.data);
const request = new NapProtoMsg(NTV2RichMediaReq).decode(envelope.body);
const videoInfo = request.upload?.uploadInfo[0]?.fileInfo;
const thumbInfo = request.upload?.uploadInfo[1]?.fileInfo;
expect(videoInfo).toMatchObject({
fileName: 'clip.mp4',
fileSize: 1024,
width: 1920,
height: 1080,
time: 12,
});
expect(videoInfo?.type.videoFormat).toBe(NTVideoType.VIDEO_FORMAT_MP4);
expect(thumbInfo?.fileName).toBe('ffeeddccbbaa99887766554433221100.jpg');
});
it('propagates resource upload failures instead of silently continuing', () => {
const uploadError = new Error('thumb upload failed');
const logger = {
warn: vi.fn(),
error: vi.fn(),
};
expect(() => assertUploadResults([
{ status: 'fulfilled', value: undefined },
{ status: 'rejected', reason: uploadError },
], logger)).toThrow('上传转发消息资源失败');
expect(logger.warn).toHaveBeenCalledWith('上传资源2个,失败1个');
expect(logger.error).toHaveBeenCalledOnce();
});
});
+131
View File
@@ -0,0 +1,131 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { OneBotFileApi } from '@/snowcat-onebot/api/file';
import { FFmpegService } from '@/snowcat-core/helper/ffmpeg/ffmpeg';
import { NTVideoType } from '@/snowcat-core/types/msg';
const MP4_HEADER = Buffer.from([
0x00, 0x00, 0x00, 0x18,
0x66, 0x74, 0x79, 0x70,
0x69, 0x73, 0x6f, 0x6d,
0x00, 0x00, 0x00, 0x00,
0x69, 0x73, 0x6f, 0x6d,
]);
const PNG_HEADER = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const JPEG_HEADER = Buffer.from([0xff, 0xd8, 0xff, 0xe0]);
describe('OneBot video send modes', () => {
let tempDir: string;
let sourceVideoPath: string;
let uploadedVideoPath: string;
let fileApi: OneBotFileApi;
beforeEach(async () => {
tempDir = await mkdtemp(path.join(tmpdir(), 'snowcat-video-send-mode-'));
sourceVideoPath = path.join(tempDir, 'clip.mp4');
uploadedVideoPath = path.join(tempDir, 'cache', 'Ori', 'clip.mp4');
await mkdir(path.dirname(uploadedVideoPath), { recursive: true });
await Promise.all([
writeFile(sourceVideoPath, MP4_HEADER),
writeFile(uploadedVideoPath, MP4_HEADER),
]);
const core = {
SnowCatTempPath: tempDir,
context: {
logger: {
logError: vi.fn(),
},
},
apis: {
FileApi: {
uploadFile: vi.fn().mockResolvedValue({
fileName: 'clip.mp4',
path: uploadedVideoPath,
fileSize: MP4_HEADER.length,
md5: '00112233445566778899aabbccddeeff',
}),
copyFile: vi.fn(),
},
},
} as unknown as ConstructorParameters<typeof OneBotFileApi>[1];
fileApi = new OneBotFileApi(
{} as ConstructorParameters<typeof OneBotFileApi>[0],
core
);
});
afterEach(async () => {
vi.restoreAllMocks();
await rm(tempDir, { recursive: true, force: true });
});
it('uses a PNG thumbnail and legacy minimal metadata for direct kernel sends', async () => {
const getVideoInfo = vi.spyOn(FFmpegService, 'getVideoInfo').mockResolvedValue({
width: 480,
height: 852,
time: 3.6,
format: 'jpg',
size: MP4_HEADER.length,
filePath: sourceVideoPath,
});
const extractLegacyThumbnail = vi
.spyOn(FFmpegService, 'extractLegacyVideoThumbnail')
.mockImplementation(async (_videoPath, thumbnailPath) => {
await writeFile(thumbnailPath, PNG_HEADER);
});
const context = {
deleteAfterSentFiles: [],
peer: {},
} as unknown as Parameters<OneBotFileApi['createValidSendVideoElement']>[0];
const element = await fileApi.createValidSendVideoElement(context, sourceVideoPath);
const thumbnailPath = element.videoElement.thumbPath?.get(0) as string;
expect(getVideoInfo).toHaveBeenCalledWith(sourceVideoPath, '');
expect(extractLegacyThumbnail).toHaveBeenCalledWith(sourceVideoPath, thumbnailPath);
expect(path.extname(thumbnailPath)).toBe('.png');
expect((await readFile(thumbnailPath)).subarray(0, PNG_HEADER.length)).toEqual(PNG_HEADER);
expect(element.videoElement.fileTime).toBe(3.6);
expect('fileFormat' in element.videoElement).toBe(false);
expect('fileWidth' in element.videoElement).toBe(false);
expect('fileHeight' in element.videoElement).toBe(false);
});
it('keeps JPEG thumbnails and complete metadata for packet forward uploads', async () => {
const getVideoInfo = vi
.spyOn(FFmpegService, 'getVideoInfo')
.mockImplementation(async (videoPath, thumbnailPath) => {
await writeFile(thumbnailPath, JPEG_HEADER);
return {
width: 480,
height: 852,
time: 3.6,
format: 'jpg',
size: MP4_HEADER.length,
filePath: videoPath,
};
});
const extractLegacyThumbnail = vi.spyOn(FFmpegService, 'extractLegacyVideoThumbnail');
const context = {
deleteAfterSentFiles: [],
peer: {},
usePacketVideoMetadata: true,
} as unknown as Parameters<OneBotFileApi['createValidSendVideoElement']>[0];
const element = await fileApi.createValidSendVideoElement(context, sourceVideoPath);
const thumbnailPath = element.videoElement.thumbPath?.get(0) as string;
expect(getVideoInfo).toHaveBeenCalledWith(sourceVideoPath, thumbnailPath);
expect(extractLegacyThumbnail).not.toHaveBeenCalled();
expect(path.extname(thumbnailPath)).toBe('.jpg');
expect((await readFile(thumbnailPath)).subarray(0, JPEG_HEADER.length)).toEqual(JPEG_HEADER);
expect(element.videoElement.fileTime).toBe(4);
expect(element.videoElement.fileFormat).toBe(NTVideoType.VIDEO_FORMAT_MP4);
expect(element.videoElement.fileWidth).toBe(480);
expect(element.videoElement.fileHeight).toBe(852);
});
});
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'vitest/config';
import { resolve } from 'path';
export default defineConfig({
test: {
environment: 'node',
globals: true,
},
resolve: {
alias: {
'@/snowcat-rpc': resolve(__dirname, '../snowcat-rpc'),
'@/snowcat-image-size': resolve(__dirname, '../snowcat-image-size'),
'@/snowcat-test': resolve(__dirname, '.'),
'@/snowcat-common': resolve(__dirname, '../snowcat-common'),
'@/snowcat-core': resolve(__dirname, '../snowcat-core'),
'@/snowcat-onebot': resolve(__dirname, '../snowcat-onebot'),
'snowcat-protobuf': resolve(__dirname, '../snowcat-protobuf/NapProto.ts'),
},
},
});