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).
204 lines
8.6 KiB
TypeScript
204 lines
8.6 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
import fs from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import { loadOneBotConfig, makeDefaultOneBotConfig, saveOneBotConfig } from '../src/config';
|
|
|
|
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
|
|
describe('makeDefaultOneBotConfig', () => {
|
|
it('returns default unified networks structure', () => {
|
|
const config = makeDefaultOneBotConfig();
|
|
expect(config.networks.httpServers).toHaveLength(1);
|
|
expect(config.networks.httpServers[0].name).toBe('http-default');
|
|
expect(config.networks.httpServers[0].host).toBe('0.0.0.0');
|
|
expect(config.networks.httpServers[0].port).toBe(3000);
|
|
expect(config.networks.httpServers[0].accessToken).toMatch(TOKEN_PATTERN);
|
|
expect(config.networks.httpServers[0].messageFormat).toBe('array');
|
|
expect(config.networks.httpServers[0].reportSelfMessage).toBe(false);
|
|
expect(config.networks.httpClients).toEqual([]);
|
|
expect(config.networks.wsServers).toHaveLength(1);
|
|
expect(config.networks.wsServers[0].port).toBe(3001);
|
|
expect(config.networks.wsServers[0].role).toBe('Universal');
|
|
expect(config.networks.wsServers[0].accessToken).toMatch(TOKEN_PATTERN);
|
|
expect(config.networks.wsServers[0].messageFormat).toBe('array');
|
|
expect(config.networks.wsServers[0].reportSelfMessage).toBe(false);
|
|
expect(config.networks.wsClients).toEqual([]);
|
|
expect(config.musicSignUrl).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('loadOneBotConfig', () => {
|
|
let tempDir: string;
|
|
let prevCwd: string;
|
|
|
|
beforeEach(() => {
|
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'snowluma-onebot-config-'));
|
|
prevCwd = process.cwd();
|
|
process.chdir(tempDir);
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.chdir(prevCwd);
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('creates and persists default config when none exists', () => {
|
|
const uin = '10001';
|
|
const config = loadOneBotConfig(uin, { persistDefaults: true });
|
|
expect(config.networks.httpServers).toHaveLength(1);
|
|
|
|
const onDisk = JSON.parse(fs.readFileSync(path.join(tempDir, 'config', `onebot_${uin}.json`), 'utf8'));
|
|
expect(onDisk.networks).toBeDefined();
|
|
expect(Array.isArray(onDisk.networks.httpServers)).toBe(true);
|
|
expect(onDisk.networks.httpServers[0].name).toBe('http-default');
|
|
expect(onDisk.networks.httpServers[0].accessToken).toMatch(TOKEN_PATTERN);
|
|
expect(onDisk.networks.wsServers[0].accessToken).toMatch(TOKEN_PATTERN);
|
|
// Legacy keys must not appear in the persisted file.
|
|
expect(onDisk.httpServers).toBeUndefined();
|
|
expect(onDisk.wsServers).toBeUndefined();
|
|
});
|
|
|
|
it('migrates legacy per-type arrays into networks groups', () => {
|
|
const uin = '10002';
|
|
const dir = path.join(tempDir, 'config');
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
const legacy = {
|
|
httpServers: [{ host: '0.0.0.0', port: 3100, path: '/', accessToken: 'tok' }],
|
|
httpPostEndpoints: [{ name: 'main-bot', url: 'http://127.0.0.1:5700' }],
|
|
wsServers: [{ host: '0.0.0.0', port: 3101 }],
|
|
wsClients: [{ url: 'ws://127.0.0.1:8080' }],
|
|
musicSignUrl: 'https://example.com/sign',
|
|
messageFormat: 'string',
|
|
reportSelfMessage: true,
|
|
};
|
|
fs.writeFileSync(path.join(dir, `onebot_${uin}.json`), JSON.stringify(legacy), 'utf8');
|
|
|
|
const config = loadOneBotConfig(uin, { persistDefaults: true });
|
|
expect(config.networks.httpServers).toHaveLength(1);
|
|
expect(config.networks.httpServers[0].port).toBe(3100);
|
|
expect(config.networks.httpServers[0].accessToken).toBe('tok');
|
|
expect(config.networks.httpServers[0].messageFormat).toBe('string');
|
|
expect(config.networks.httpServers[0].reportSelfMessage).toBe(true);
|
|
|
|
expect(config.networks.httpClients).toHaveLength(1);
|
|
expect(config.networks.httpClients[0].name).toBe('main-bot');
|
|
expect(config.networks.httpClients[0].url).toBe('http://127.0.0.1:5700');
|
|
expect(config.networks.httpClients[0].messageFormat).toBe('string');
|
|
expect(config.networks.httpClients[0].reportSelfMessage).toBe(true);
|
|
|
|
expect(config.networks.wsServers).toHaveLength(1);
|
|
expect(config.networks.wsServers[0].port).toBe(3101);
|
|
expect(config.networks.wsServers[0].messageFormat).toBe('string');
|
|
expect(config.networks.wsServers[0].reportSelfMessage).toBe(true);
|
|
|
|
expect(config.networks.wsClients).toHaveLength(1);
|
|
expect(config.networks.wsClients[0].url).toBe('ws://127.0.0.1:8080');
|
|
expect(config.networks.wsClients[0].messageFormat).toBe('string');
|
|
expect(config.networks.wsClients[0].reportSelfMessage).toBe(true);
|
|
|
|
expect(config.musicSignUrl).toBe('https://example.com/sign');
|
|
|
|
// File should now be in unified format on disk.
|
|
const onDisk = JSON.parse(fs.readFileSync(path.join(dir, `onebot_${uin}.json`), 'utf8'));
|
|
expect(onDisk.networks).toBeDefined();
|
|
expect(onDisk.httpServers).toBeUndefined();
|
|
expect(onDisk.httpPostEndpoints).toBeUndefined();
|
|
expect(onDisk.messageFormat).toBeUndefined();
|
|
expect(onDisk.reportSelfMessage).toBeUndefined();
|
|
expect(onDisk.networks.httpServers[0].messageFormat).toBe('string');
|
|
expect(onDisk.networks.httpServers[0].reportSelfMessage).toBe(true);
|
|
});
|
|
|
|
it('round-trips per-adapter overrides through save/load', () => {
|
|
const uin = '10003';
|
|
const config = makeDefaultOneBotConfig();
|
|
config.networks.httpClients.push({
|
|
name: 'self-mirror',
|
|
url: 'http://127.0.0.1:9000',
|
|
messageFormat: 'string',
|
|
reportSelfMessage: true,
|
|
});
|
|
saveOneBotConfig(uin, config);
|
|
|
|
const reloaded = loadOneBotConfig(uin);
|
|
expect(reloaded.networks.httpClients).toHaveLength(1);
|
|
expect(reloaded.networks.httpClients[0].name).toBe('self-mirror');
|
|
expect(reloaded.networks.httpClients[0].messageFormat).toBe('string');
|
|
expect(reloaded.networks.httpClients[0].reportSelfMessage).toBe(true);
|
|
});
|
|
|
|
it('does not write to disk by default (read-only contract)', () => {
|
|
const uin = '10006';
|
|
const cfgPath = path.join(tempDir, 'config', `onebot_${uin}.json`);
|
|
|
|
// Default call must not materialize the file or mint a fresh access token.
|
|
const config = loadOneBotConfig(uin);
|
|
expect(config.networks.httpServers).toHaveLength(1);
|
|
expect(fs.existsSync(cfgPath)).toBe(false);
|
|
|
|
// Explicit opt-in still writes.
|
|
loadOneBotConfig(uin, { persistDefaults: true });
|
|
expect(fs.existsSync(cfgPath)).toBe(true);
|
|
});
|
|
|
|
it('respects an operator-emptied adapter list instead of seeding defaults back', () => {
|
|
const uin = '10007';
|
|
const dir = path.join(tempDir, 'config');
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(path.join(dir, `onebot_${uin}.json`), JSON.stringify({
|
|
networks: { httpServers: [], httpClients: [], wsServers: [], wsClients: [] },
|
|
musicSignUrl: '',
|
|
}), 'utf8');
|
|
|
|
const config = loadOneBotConfig(uin, { persistDefaults: true });
|
|
expect(config.networks.httpServers).toEqual([]);
|
|
expect(config.networks.wsServers).toEqual([]);
|
|
});
|
|
|
|
it('migrates legacy lowercase WsRole values into canonical uppercase form', () => {
|
|
const uin = '10005';
|
|
const dir = path.join(tempDir, 'config');
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(path.join(dir, `onebot_${uin}.json`), JSON.stringify({
|
|
networks: {
|
|
httpServers: [],
|
|
httpClients: [],
|
|
wsServers: [
|
|
{ name: 'ws-legacy', host: '0.0.0.0', port: 3201, path: '/', role: 'universal', messageFormat: 'array', reportSelfMessage: false },
|
|
],
|
|
wsClients: [
|
|
{ name: 'wsc-legacy', url: 'ws://127.0.0.1:8080', role: 'event', messageFormat: 'array', reportSelfMessage: false },
|
|
],
|
|
},
|
|
}), 'utf8');
|
|
|
|
const config = loadOneBotConfig(uin);
|
|
expect(config.networks.wsServers).toHaveLength(1);
|
|
expect(config.networks.wsServers[0].role).toBe('Universal');
|
|
expect(config.networks.wsClients).toHaveLength(1);
|
|
expect(config.networks.wsClients[0].role).toBe('Event');
|
|
});
|
|
|
|
it('auto-generates names for legacy entries that lack one', () => {
|
|
const uin = '10004';
|
|
const dir = path.join(tempDir, 'config');
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(path.join(dir, `onebot_${uin}.json`), JSON.stringify({
|
|
httpServers: [
|
|
{ port: 3000 },
|
|
{ port: 3001 },
|
|
],
|
|
}), 'utf8');
|
|
|
|
const config = loadOneBotConfig(uin);
|
|
const names = config.networks.httpServers.map((n) => n.name);
|
|
expect(names).toHaveLength(2);
|
|
expect(new Set(names).size).toBe(2); // unique
|
|
expect(names.every((n) => n.length > 0)).toBe(true);
|
|
expect(config.networks.httpServers.every((n) => n.messageFormat === 'array')).toBe(true);
|
|
expect(config.networks.httpServers.every((n) => n.reportSelfMessage === false)).toBe(true);
|
|
});
|
|
});
|
|
|