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).
221 lines
9.6 KiB
TypeScript
221 lines
9.6 KiB
TypeScript
// Regression coverage for nested forward (forward-inside-forward).
|
|
//
|
|
// `parseForwardNodes` detects an all-`{type:'node'}` content array,
|
|
// recursively builds the inner `ForwardNodePayload[]`, and attaches it
|
|
// to the outer node as `innerForward`. `uploadForwardNodes` then drives
|
|
// the recursive long-msg upload + ARK-preview generation + msgBody
|
|
// piggyback in one pass over the tree — modelled on NapCat's
|
|
// `uploadForwardedNodesPacket` (`dev/NapCatQQ/.../SendMsg.ts:208`).
|
|
//
|
|
// These tests stub `bridge.uploadForwardNodes` so we can inspect the
|
|
// payload that the upload pipeline receives: outer nodes carry an
|
|
// `innerForward` field holding the parsed inner chain, and the
|
|
// upload helper is invoked exactly ONCE (the recursive walk happens
|
|
// inside the real implementation; mocks see only the top call).
|
|
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import type { BridgeInterface } from '../../src/bridge/bridge-interface';
|
|
import type { OneBotInstanceContext } from '../src/instance-context';
|
|
import {
|
|
sendGroupForwardMessage,
|
|
sendPrivateForwardMessage,
|
|
} from '../src/modules/message-actions';
|
|
|
|
function fakeBridge(overrides: Partial<BridgeInterface> = {}): BridgeInterface {
|
|
return new Proxy(overrides as BridgeInterface, {
|
|
get(target, prop) {
|
|
if (prop in target) return (target as any)[prop];
|
|
throw new Error(`fakeBridge: '${String(prop)}' was not stubbed for this test`);
|
|
},
|
|
});
|
|
}
|
|
|
|
function makeCtx(bridge: BridgeInterface): OneBotInstanceContext {
|
|
return {
|
|
uin: '10001',
|
|
bridge,
|
|
messageStore: { findEvent: () => null } as any,
|
|
cacheMessageMeta: vi.fn(),
|
|
mediaStore: {} as any,
|
|
musicSignUrl: '',
|
|
} as unknown as OneBotInstanceContext;
|
|
}
|
|
|
|
describe('forward — nested {type:"node"} content', () => {
|
|
it('group: nested forward attaches inner chain to outer node via `innerForward`', async () => {
|
|
// Single upload call, but the outer payload now carries an
|
|
// `innerForward` field with the recursively-parsed inner chain.
|
|
// The real `uploadForwardNodes` (the production module) walks
|
|
// that tree and does the recursive long-msg uploads with NapCat
|
|
// piggyback — we're just testing the parse-side handoff here.
|
|
const uploadForwardNodes = vi.fn(async (_nodes: any[], _groupId?: number, _userId?: number) => 'OUTER_RESID');
|
|
const sendGroupMessage = vi.fn(async () => ({
|
|
messageId: 1, sequence: 100, clientSequence: 0, random: 1, timestamp: 1700000000,
|
|
}));
|
|
|
|
const bridge = fakeBridge({ apis: { message: { sendGroup: sendGroupMessage }, forward: { upload: uploadForwardNodes } } } as any);
|
|
const ctx = makeCtx(bridge);
|
|
|
|
const messages = [{
|
|
type: 'node',
|
|
data: {
|
|
user_id: 111, nickname: 'outer',
|
|
content: [{
|
|
type: 'node',
|
|
data: {
|
|
user_id: 222, nickname: 'inner',
|
|
content: [{ type: 'text', data: { text: 'hello from inner' } }],
|
|
},
|
|
}],
|
|
},
|
|
}];
|
|
|
|
const result = await sendGroupForwardMessage(ctx, 12345, messages as any);
|
|
|
|
// Exactly one upload call: the production-side recursive walker
|
|
// owns the inner upload, the OneBot parser just hands off the tree.
|
|
expect(uploadForwardNodes).toHaveBeenCalledOnce();
|
|
|
|
const [outerNodes, outerGroupId] = uploadForwardNodes.mock.calls[0]!;
|
|
expect(outerGroupId).toBe(12345);
|
|
const outerNode = (outerNodes as any[])[0]!;
|
|
expect(outerNode.userUin).toBe(111);
|
|
// Outer node's `elements` is empty — the upload pipeline replaces
|
|
// it with the real ARK preview once it has the inner res_id.
|
|
expect(outerNode.elements).toEqual([]);
|
|
// The inner chain rides along as `innerForward`.
|
|
expect(Array.isArray(outerNode.innerForward)).toBe(true);
|
|
expect(outerNode.innerForward).toHaveLength(1);
|
|
expect(outerNode.innerForward[0]).toMatchObject({
|
|
userUin: 222,
|
|
nickname: 'inner',
|
|
elements: [{ type: 'text', text: 'hello from inner' }],
|
|
});
|
|
|
|
expect(result.forwardId).toBe('OUTER_RESID');
|
|
expect(sendGroupMessage).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('private: nested forward threads userId into uploadForwardNodes', async () => {
|
|
// Same shape but routed via sendPrivateForwardMessage. The c2c path
|
|
// passes `userId` instead of `groupId` so the upload pipeline can
|
|
// pick up the recipient's UID scene for any inner media element.
|
|
const uploadForwardNodes = vi.fn(async (_nodes: any[], _groupId?: number, _userId?: number) => 'RESID');
|
|
const sendPrivateMessage = vi.fn(async () => ({
|
|
messageId: 1, sequence: 100, clientSequence: 0, random: 1, timestamp: 1700000000,
|
|
}));
|
|
|
|
const bridge = fakeBridge({ apis: { message: { sendPrivate: sendPrivateMessage }, forward: { upload: uploadForwardNodes } } } as any);
|
|
const ctx = makeCtx(bridge);
|
|
|
|
const messages = [{
|
|
type: 'node',
|
|
data: {
|
|
user_id: 111, nickname: 'outer',
|
|
content: [{
|
|
type: 'node',
|
|
data: {
|
|
user_id: 222, nickname: 'inner',
|
|
content: [{ type: 'text', data: { text: 'hi' } }],
|
|
},
|
|
}],
|
|
},
|
|
}];
|
|
|
|
await sendPrivateForwardMessage(ctx, 67890, messages as any);
|
|
|
|
// Single call, c2c-scoped (groupId undefined, userId = recipient).
|
|
expect(uploadForwardNodes).toHaveBeenCalledOnce();
|
|
const call = uploadForwardNodes.mock.calls[0]!;
|
|
expect(call[1]).toBeUndefined();
|
|
expect(call[2]).toBe(67890);
|
|
// Inner chain attached via `innerForward`.
|
|
const outerNode = (call[0] as any[])[0]!;
|
|
expect(outerNode.innerForward).toHaveLength(1);
|
|
expect(outerNode.innerForward[0]!.userUin).toBe(222);
|
|
});
|
|
|
|
it('rejects nesting deeper than 3 levels', async () => {
|
|
// Build a 4-level nested chain. NapCat caps at 3 too, going further
|
|
// wastes long-msg uploads and risks one inner upload timing out
|
|
// and aborting the whole tree. Better to fail loud here.
|
|
const uploadForwardNodes = vi.fn(async (_nodes: any[], _groupId?: number, _userId?: number) => 'X');
|
|
const sendGroupMessage = vi.fn(async () => ({
|
|
messageId: 1, sequence: 100, clientSequence: 0, random: 1, timestamp: 0,
|
|
}));
|
|
const bridge = fakeBridge({ apis: { message: { sendGroup: sendGroupMessage }, forward: { upload: uploadForwardNodes } } } as any);
|
|
const ctx = makeCtx(bridge);
|
|
|
|
function wrap(content: any, depth: number): any {
|
|
if (depth === 0) return [{ type: 'text', data: { text: 'leaf' } }];
|
|
return [{
|
|
type: 'node',
|
|
data: { user_id: 100 + depth, nickname: `lvl-${depth}`, content: wrap(content, depth - 1) },
|
|
}];
|
|
}
|
|
|
|
await expect(
|
|
sendGroupForwardMessage(ctx, 12345, wrap(null, 4) as any),
|
|
).rejects.toThrow(/depth/);
|
|
});
|
|
|
|
it('flat content (no nesting) still goes through the unchanged path', async () => {
|
|
// Backwards-compat: a single-level forward with plain text nodes
|
|
// calls uploadForwardNodes exactly once and never touches the
|
|
// recursive branch.
|
|
const uploadForwardNodes = vi.fn(async (_nodes: any[], _groupId?: number, _userId?: number) => 'RES');
|
|
const sendGroupMessage = vi.fn(async () => ({
|
|
messageId: 1, sequence: 100, clientSequence: 0, random: 1, timestamp: 0,
|
|
}));
|
|
const bridge = fakeBridge({ apis: { message: { sendGroup: sendGroupMessage }, forward: { upload: uploadForwardNodes } } } as any);
|
|
const ctx = makeCtx(bridge);
|
|
|
|
const messages = [
|
|
{ type: 'node', data: { user_id: 111, nickname: 'a', content: [{ type: 'text', data: { text: 'one' } }] } },
|
|
{ type: 'node', data: { user_id: 222, nickname: 'b', content: [{ type: 'text', data: { text: 'two' } }] } },
|
|
];
|
|
|
|
await sendGroupForwardMessage(ctx, 12345, messages as any);
|
|
expect(uploadForwardNodes).toHaveBeenCalledOnce();
|
|
const [nodes] = uploadForwardNodes.mock.calls[0]!;
|
|
expect((nodes as any[])).toHaveLength(2);
|
|
expect((nodes as any[])[0]!.elements).toEqual([{ type: 'text', text: 'one' }]);
|
|
expect((nodes as any[])[1]!.elements).toEqual([{ type: 'text', text: 'two' }]);
|
|
});
|
|
|
|
it('mixed content (some {type:"node"} + non-node) falls back to flat parsing — does NOT recurse', async () => {
|
|
// Recursion only kicks in when *all* content entries are nodes —
|
|
// mixed content is ambiguous (do the non-node parts belong to the
|
|
// outer node or are they parallel siblings?), so we keep the legacy
|
|
// behaviour and let parseMessage handle it. Non-node parts come
|
|
// through, node parts get parsed via `case 'node':` and then
|
|
// dropped by element-builder. A user who wants nested forward
|
|
// should pass a pure node list.
|
|
const uploadForwardNodes = vi.fn(async (_nodes: any[], _groupId?: number, _userId?: number) => 'RES');
|
|
const sendGroupMessage = vi.fn(async () => ({
|
|
messageId: 1, sequence: 100, clientSequence: 0, random: 1, timestamp: 0,
|
|
}));
|
|
const bridge = fakeBridge({ apis: { message: { sendGroup: sendGroupMessage }, forward: { upload: uploadForwardNodes } } } as any);
|
|
const ctx = makeCtx(bridge);
|
|
|
|
const messages = [{
|
|
type: 'node',
|
|
data: {
|
|
user_id: 111, nickname: 'mixed',
|
|
content: [
|
|
{ type: 'text', data: { text: 'a sibling' } },
|
|
{ type: 'node', data: { user_id: 222, content: [{ type: 'text', data: { text: 'lost' } }] } },
|
|
],
|
|
},
|
|
}];
|
|
|
|
await sendGroupForwardMessage(ctx, 12345, messages as any);
|
|
expect(uploadForwardNodes).toHaveBeenCalledOnce();
|
|
// Only the text element survives — the legacy "node MessageElement"
|
|
// that parseMessage produces is opaque to the element-builder.
|
|
const [nodes] = uploadForwardNodes.mock.calls[0]!;
|
|
const elements = (nodes as any[])[0]!.elements;
|
|
expect(elements.some((e: any) => e.type === 'text' && e.text === 'a sibling')).toBe(true);
|
|
});
|
|
});
|