Files
DrSmoothl 9a30df9907 refactor(onebot): extract OneBot protocol translation into @snowluma/onebot
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).
2026-05-21 08:52:00 +08:00

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);
});
});