Phase 3 of the 4-package split (RoadMap #3). Moves the entire `packages/core/src/onebot/` subtree (43 source files + 20 test files spanning network adapters, action handlers, event converters, message parser, message store, media store, instance + manager) into the new `@snowluma/onebot` workspace package. The remaining `@snowluma/core` now hosts only the api-layer (Bridge class + 13 Api classes), the hook tooling, the WebUI server, and the runnable `index.ts`. Why --- - The OneBot adapter is the public-facing OneBot11 protocol surface; it has its own version cadence and its own consumers (third-party bots talking OneBot WS / HTTP). Splitting it lets that cadence move independently of the Api-on-ctx layer. - The two were sharing the `packages/core/` build pipeline only because they happened to live in the same directory. Nothing in the api-layer reaches into OneBot internals. - Mirrors NapCat / Lagrange.OneBot's repo split (their OneBot adapter is a separate assembly from the Core protocol library). Layout ------ Moved via `git mv` (history preserved): packages/core/src/onebot/* → @snowluma/onebot/src/<x> packages/core/tests/onebot/* (6 files) → @snowluma/onebot/tests/ packages/core/tests/{message-parser,message-store,event-pipeline, media-store,media-url-resolver,event-converter, http-server-hot-reload,http-server-content-type, api-handler-logging,message-id,group-msg-emoji-like, config,media-indexer,network-manager}.test.ts (14 files) → @snowluma/onebot/tests/ What stays in @snowluma/core ---------------------------- - `bridge/{bridge,bridge-interface,bridge-context,manager}.ts` - `bridge/apis/*` (the 13 typed Api classes) - `hook/*` (debugger/injector — tooling for the core protocol layer) - `webui/*` (admin UI server — application wiring, not the OneBot adapter itself) - `index.ts` (application entry that wires BridgeManager + OneBotManager + HookManager + WebUI together) Changes ------- - New package `packages/onebot/` (package.json with subpath exports mirroring legacy `core/src/onebot/<x>` 1:1, tsconfig.json, vitest.config.ts with `SNOWLUMA_LOG_LEVEL=debug` env so the action- dispatch logging tests see debug-level subscribers — copied verbatim from core's vitest.config.ts). - @snowluma/core grows new subpath exports for the api-layer files OneBot's type-only imports need: `./bridge`, `./bridge-interface`, `./bridge-context`, `./manager`, `./apis`. - `@snowluma/onebot` depends on `@snowluma/bridge`, `@snowluma/common`, `@snowluma/core`, `@snowluma/proto-defs`, `@snowluma/proton`, `@snowluma/websocket`. - `@snowluma/core` depends on `@snowluma/onebot` (for index.ts + webui/server.ts wiring). - 100+ import sites rewritten via sed: * OneBot → core: `'../bridge/bridge'` → `'@snowluma/core/bridge'`, `'../bridge/manager'` → `'@snowluma/core/manager'`, `'../bridge/bridge-interface'` → `'@snowluma/core/bridge-interface'`, `'@/bridge/bridge'` (vite path alias) → `'@snowluma/core/bridge'`. * Core → OneBot: `'./onebot/manager'` → `'@snowluma/onebot/manager'`, `'../onebot/config'` → `'@snowluma/onebot/config'`, etc. * Test relative paths: `'../../src/onebot/<x>'` → `'../src/<x>'` after the test file move (one level shallower). - New `packages/onebot/src/env.d.ts` declaring `__APP_VERSION__` and `__BUILD_WEBUI__` (the vite-injected globals `info.ts` reads). Workspace cycle --------------- core ↔ onebot is allowed by pnpm workspaces. The runtime direction is one-way: core → onebot (index.ts constructs OneBotManager, webui/server reads OneBot config). OneBot only reaches into core for TYPES (Bridge, BridgeInterface, BridgeManager, ClientKeyInfo, DownloadRKeyInfo); those imports use `import type` so they compile out at runtime and don't introduce a real cycle. Verification ------------ - Full workspace `pnpm -r typecheck`: all 11 projects clean. - `@snowluma/core` test suite: 302/302 pass (33 test files). - `@snowluma/onebot` test suite: 250/250 pass (20 test files; pre-existing `network-manager.test.ts` native-addon failure unchanged — same baseline as before the split). - Combined: 552/552 — identical pass count to the pre-Phase-3 baseline. - Vite build with stubbed native binary: clean, 2.84 MB bundle / 224 KB gzip (identical envelope).
191 lines
6.0 KiB
TypeScript
191 lines
6.0 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { MessageStore } from '../src/message-store';
|
|
import { hashMessageIdInt32, GROUP_MESSAGE_EVENT, PRIVATE_MESSAGE_EVENT } from '../src/message-id';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
describe('MessageStore', () => {
|
|
const testDbPath = path.join('data', 'test', 'messages-test.db');
|
|
let store: MessageStore;
|
|
|
|
beforeEach(() => {
|
|
// Clean up any existing test database
|
|
try {
|
|
fs.unlinkSync(testDbPath);
|
|
} catch {
|
|
// Ignore if file doesn't exist
|
|
}
|
|
store = new MessageStore(testDbPath);
|
|
});
|
|
|
|
afterEach(() => {
|
|
store.close();
|
|
// Clean up test database
|
|
try {
|
|
fs.unlinkSync(testDbPath);
|
|
} catch {
|
|
// Ignore cleanup errors
|
|
}
|
|
});
|
|
|
|
describe('resolveReplySequence', () => {
|
|
it('resolves group message reply sequence', () => {
|
|
const groupId = 123456;
|
|
const sequence = 100;
|
|
const messageId = hashMessageIdInt32(sequence, groupId, GROUP_MESSAGE_EVENT);
|
|
|
|
// Store a group message
|
|
store.storeMeta(messageId, {
|
|
isGroup: true,
|
|
targetId: groupId,
|
|
sequence,
|
|
eventName: GROUP_MESSAGE_EVENT,
|
|
clientSequence: 0,
|
|
random: 0,
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
// Resolve the reply sequence
|
|
const resolved = store.resolveReplySequence(true, groupId, messageId);
|
|
expect(resolved).toBe(sequence);
|
|
});
|
|
|
|
it('resolves private message reply sequence without session_id matching', () => {
|
|
// Simulate receiving a private message from user 111111
|
|
const senderUin = 111111;
|
|
const sequence = 200;
|
|
const messageId = hashMessageIdInt32(sequence, senderUin, PRIVATE_MESSAGE_EVENT);
|
|
|
|
// Store the received message (session_id is sender's UIN)
|
|
store.storeMeta(messageId, {
|
|
isGroup: false,
|
|
targetId: senderUin,
|
|
sequence,
|
|
eventName: PRIVATE_MESSAGE_EVENT,
|
|
clientSequence: 0,
|
|
random: 0,
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
// When replying to this message, we send to the sender (who becomes the recipient)
|
|
// The key fix: resolveReplySequence should work even when sessionId doesn't match
|
|
const resolved = store.resolveReplySequence(false, senderUin, messageId);
|
|
expect(resolved).toBe(sequence);
|
|
|
|
// More importantly: it should also work when we pass a different sessionId
|
|
// (which was the bug - we were passing recipient UIN instead of sender UIN)
|
|
const differentUin = 999999;
|
|
const resolvedWithDifferentSession = store.resolveReplySequence(false, differentUin, messageId);
|
|
expect(resolvedWithDifferentSession).toBe(sequence);
|
|
});
|
|
|
|
it('returns null for non-existent message', () => {
|
|
const resolved = store.resolveReplySequence(true, 123456, 999999);
|
|
expect(resolved).toBeNull();
|
|
});
|
|
|
|
it('returns null for invalid messageId', () => {
|
|
const resolved = store.resolveReplySequence(true, 123456, 0);
|
|
expect(resolved).toBeNull();
|
|
});
|
|
|
|
it('returns null for invalid sessionId', () => {
|
|
const messageId = hashMessageIdInt32(100, 123456, GROUP_MESSAGE_EVENT);
|
|
const resolved = store.resolveReplySequence(true, 0, messageId);
|
|
expect(resolved).toBeNull();
|
|
});
|
|
|
|
it('distinguishes between group and private messages', () => {
|
|
const sessionId = 123456;
|
|
const sequence = 300;
|
|
|
|
// Store a group message
|
|
const groupMessageId = hashMessageIdInt32(sequence, sessionId, GROUP_MESSAGE_EVENT);
|
|
store.storeMeta(groupMessageId, {
|
|
isGroup: true,
|
|
targetId: sessionId,
|
|
sequence,
|
|
eventName: GROUP_MESSAGE_EVENT,
|
|
clientSequence: 0,
|
|
random: 0,
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
// Store a private message with same sequence but different hash
|
|
const privateMessageId = hashMessageIdInt32(sequence, sessionId, PRIVATE_MESSAGE_EVENT);
|
|
store.storeMeta(privateMessageId, {
|
|
isGroup: false,
|
|
targetId: sessionId,
|
|
sequence,
|
|
eventName: PRIVATE_MESSAGE_EVENT,
|
|
clientSequence: 0,
|
|
random: 0,
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
// Should resolve correctly based on isGroup flag
|
|
const groupResolved = store.resolveReplySequence(true, sessionId, groupMessageId);
|
|
expect(groupResolved).toBe(sequence);
|
|
|
|
const privateResolved = store.resolveReplySequence(false, sessionId, privateMessageId);
|
|
expect(privateResolved).toBe(sequence);
|
|
|
|
// Should not cross-resolve
|
|
const wrongGroupResolve = store.resolveReplySequence(true, sessionId, privateMessageId);
|
|
expect(wrongGroupResolve).toBeNull();
|
|
|
|
const wrongPrivateResolve = store.resolveReplySequence(false, sessionId, groupMessageId);
|
|
expect(wrongPrivateResolve).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('storeEvent and findEvent', () => {
|
|
it('stores and retrieves event data', () => {
|
|
const messageId = 12345;
|
|
const event = {
|
|
post_type: 'message',
|
|
message_type: 'group',
|
|
message_id: messageId,
|
|
group_id: 123456,
|
|
message: 'test message',
|
|
time: Date.now(),
|
|
};
|
|
|
|
store.storeEvent(messageId, true, 123456, 100, GROUP_MESSAGE_EVENT, event);
|
|
|
|
const retrieved = store.findEvent(messageId);
|
|
expect(retrieved).toEqual(event);
|
|
});
|
|
|
|
it('returns null for non-existent event', () => {
|
|
const retrieved = store.findEvent(999999);
|
|
expect(retrieved).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('storeMeta and findMeta', () => {
|
|
it('stores and retrieves message meta', () => {
|
|
const messageId = 54321;
|
|
const meta = {
|
|
isGroup: true,
|
|
targetId: 123456,
|
|
sequence: 100,
|
|
eventName: GROUP_MESSAGE_EVENT,
|
|
clientSequence: 1,
|
|
random: 12345,
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
store.storeMeta(messageId, meta);
|
|
|
|
const retrieved = store.findMeta(messageId);
|
|
expect(retrieved).toEqual(meta);
|
|
});
|
|
|
|
it('returns null for non-existent meta', () => {
|
|
const retrieved = store.findMeta(999999);
|
|
expect(retrieved).toBeNull();
|
|
});
|
|
});
|
|
});
|