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).
130 lines
5.1 KiB
TypeScript
130 lines
5.1 KiB
TypeScript
// Regression coverage for the group-file-in-send_group_msg bug.
|
|
//
|
|
// Symptom in the wild: bot uploaded a group file via `upload_group_file`,
|
|
// then sent `{type:'file', file_id}` through `send_group_msg`; the
|
|
// element-builder wrapped it as `transElem(elemType=24, ...)` inside
|
|
// `richText.elems` and shipped a regular `MessageSvc.PbSendMsg`. The
|
|
// QQ-NT server rejected the message with `result=79` because that wire
|
|
// shape is RECEIVE-side only (rich-body-decoder unpacks it into a
|
|
// FileEntity for inbound messages, but the server's intake validator
|
|
// flags it on the send side).
|
|
//
|
|
// Fix: split file segments off at the OneBot layer (same pattern as
|
|
// the c2c-file split for private messages) and dispatch through
|
|
// `bridge.apis.groupFile.publish`, which calls dedicated OIDB
|
|
// `OidbSvcTrpcTcp.0x6d9_4` — Lagrange.Core V2's
|
|
// `GroupSendFileService.cs`.
|
|
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import type { BridgeInterface } from '../../src/bridge/bridge-interface';
|
|
import type { OneBotInstanceContext } from '../src/instance-context';
|
|
import { sendGroupMessage } 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, resolveReplySequence: () => 0 } as any,
|
|
cacheMessageMeta: vi.fn(),
|
|
mediaStore: {} as any,
|
|
musicSignUrl: '',
|
|
} as unknown as OneBotInstanceContext;
|
|
}
|
|
|
|
const goodReceipt = {
|
|
messageId: 1, sequence: 100, clientSequence: 0, random: 1, timestamp: 1700000000,
|
|
};
|
|
|
|
describe('send_group_msg with {type:"file"} segment', () => {
|
|
it('file-only message routes through apis.groupFile.publish (not apis.message.sendGroup)', async () => {
|
|
// Pure-file case — the elems[] path used to ship a transElem(24)
|
|
// and got result=79. The dedicated OIDB-0x6d9_4 route must take
|
|
// over for group file publishing.
|
|
const sendGroupMessage_bridge = vi.fn();
|
|
// Declare params on the fn so `mock.calls[0]` infers a tuple of
|
|
// [groupId, fileId] instead of `[]` (which makes the destructuring
|
|
// a tsc error under noUncheckedIndexedAccess).
|
|
const publish = vi.fn(async (_groupId: number, _fileId: string) => undefined);
|
|
const bridge = fakeBridge({
|
|
apis: {
|
|
message: { sendGroup: sendGroupMessage_bridge },
|
|
groupFile: { publish },
|
|
} as any,
|
|
resolveUserUid: vi.fn(),
|
|
} as any);
|
|
const ctx = makeCtx(bridge);
|
|
|
|
await sendGroupMessage(ctx, 12345, [{
|
|
type: 'file', data: { file_id: 'gfid-abc', name: 'doc.txt', size: 123 },
|
|
}] as any, false);
|
|
|
|
expect(publish).toHaveBeenCalledOnce();
|
|
expect(sendGroupMessage_bridge).not.toHaveBeenCalled();
|
|
|
|
const [groupId, fileId] = publish.mock.calls[0]!;
|
|
expect(groupId).toBe(12345);
|
|
expect(fileId).toBe('gfid-abc');
|
|
});
|
|
|
|
it('mixed text + file splits across two sends (text via elems[], file via OIDB)', async () => {
|
|
const sendGroupMessage_bridge = vi.fn(async (_gid: number, _elements: any[]) => goodReceipt);
|
|
const publish = vi.fn(async (_groupId: number, _fileId: string) => undefined);
|
|
const bridge = fakeBridge({
|
|
apis: {
|
|
message: { sendGroup: sendGroupMessage_bridge },
|
|
groupFile: { publish },
|
|
} as any,
|
|
resolveUserUid: vi.fn(async () => 'u_peer'),
|
|
} as any);
|
|
const ctx = makeCtx(bridge);
|
|
|
|
await sendGroupMessage(ctx, 12345, [
|
|
{ type: 'text', data: { text: 'here is the file:' } },
|
|
{ type: 'file', data: { file_id: 'gfid-xyz', name: 'pkg.zip' } },
|
|
] as any, false);
|
|
|
|
expect(sendGroupMessage_bridge).toHaveBeenCalledOnce();
|
|
expect(publish).toHaveBeenCalledOnce();
|
|
|
|
const [textGid, textElements] = sendGroupMessage_bridge.mock.calls[0]!;
|
|
expect(textGid).toBe(12345);
|
|
expect(textElements).toEqual([{ type: 'text', text: 'here is the file:' }]);
|
|
|
|
const [fileGid, fileFileId] = publish.mock.calls[0]!;
|
|
expect(fileGid).toBe(12345);
|
|
expect(fileFileId).toBe('gfid-xyz');
|
|
});
|
|
|
|
it('file segment without file_id is skipped (with a warn-level log)', async () => {
|
|
// Same upload-by-reference contract as the c2c file path —
|
|
// missing file_id means there's nothing to publish.
|
|
const sendGroupMessage_bridge = vi.fn(async (_gid: number, _elements: any[]) => goodReceipt);
|
|
const publish = vi.fn();
|
|
const bridge = fakeBridge({
|
|
apis: {
|
|
message: { sendGroup: sendGroupMessage_bridge },
|
|
groupFile: { publish },
|
|
} as any,
|
|
resolveUserUid: vi.fn(),
|
|
} as any);
|
|
const ctx = makeCtx(bridge);
|
|
|
|
await sendGroupMessage(ctx, 12345, [
|
|
{ type: 'text', data: { text: 'with bad file segment' } },
|
|
{ type: 'file', data: {} }, // no file_id
|
|
] as any, false);
|
|
|
|
expect(sendGroupMessage_bridge).toHaveBeenCalledOnce();
|
|
expect(publish).not.toHaveBeenCalled();
|
|
});
|
|
});
|