Wait for the canonical self group-message event after AI voice synthesis instead of interpreting undocumented response fields as a message sequence.
Correlate receipts by exact media UUID, buffer response races, and claim each semantic message identity only once across concurrent calls and duplicate multi-process pushes. Reject missing identifiers, invalid sequences, bounded-buffer overflow, and receipt timeouts with explicit diagnostics.
Keep fetchAiVoice focused on returning the media node and add regression coverage for race ordering, unrelated events, identical concurrent audio, duplicate pushes, malformed receipts, and timeout cleanup.
Fixes#250
The package previously named "bridge" houses the protocol-byte layer
(OIDB cmds, msg-push decoders, highway uploaders, element-builder,
event bus). The name "bridge" suggested transport/lifecycle, which it
isn't — that role lives in core/hook and core/bridge/manager. Rename
to match what's actually inside.
This is a pure rename: zero logic changes, all 73 import sites updated
mechanically and TS-verified. core/onebot dependencies key updated.
ESLint override path adjusted for the moved oidb-services directory.
Rename detection threshold: git log --find-renames=20% recommended
when following history across this commit — content-replacement
(@snowluma/bridge → @snowluma/protocol) dropped per-file similarity
below git's default 50% on import-heavy files.
5 logical OIDB cmds → 4 namespace files (group todo's 3 sub-cmds
collapse into one namespace with a dynamic resolver):
- GroupTodo (0xF90_1/2/3) — set/complete/cancel via the
new `action` discriminator
- GetStrangerStatus (0xFE1_2) — uinForm=true; preserves the
(status*10, ext_status) decode
quirk verbatim
- FetchAiVoiceList (0x929D_0)
- FetchAiVoice (0x929B_0) — single poll; retry loop
stays on the facade so callers
can plug their own back-off
Facade keeps the 6 public methods (setGroupTodo / completeGroupTodo /
cancelGroupTodo / getStrangerStatus / fetchAiVoiceList / fetchAiVoice)
as one-line forwarders; only `getStrangerStatus` wraps in try/catch
to preserve its "return null on transport failure" contract, and
`fetchAiVoice` keeps the polling loop.
30 new namespace-level tests; extras.test.ts moved onto the
sendRawPacket assertion model.
Verification:
- typecheck green
- @snowluma/bridge: 24 files / 195 tests
- @snowluma/core: 302 tests
Wire bytes unchanged.
Phase 2 of the 4-package split (RoadMap #3). Moves ~35 files —
events / event-bus / identity-service / packet-pipeline / element-
builder / bridge-oidb / qq-info / msg-push/* / highway/* / web/* +
the bridge-side formatters — out of `packages/core/src/bridge/`
into the new `@snowluma/bridge` workspace package. Everything that
translates QQ wire bytes ↔ typed events/elements now lives there.
Why
---
- `packages/core/src/bridge/` was actually two layers: a low-level
"protocol decoder + I/O" set (msg-push, highway, packet-pipeline,
identity, element-builder) AND the Api-class host (bridge.ts +
apis/*). The Api layer can't move without the protocol layer
moving first, but the two had grown into one directory.
- Bridge-internal consumers (highway uploaders, OIDB envelope
helper, element-builder, msg-push parsers) only ever needed
`bridge.identity` + `bridge.sendRawPacket` + the upload-metadata
cache. Splitting them out lets that minimal surface live in the
bridge package as `BridgeContext` while the api-layer's extended
context (with `apis: ApiHub`) stays in @snowluma/core.
What stays in @snowluma/core (apis layer):
- bridge.ts ── the god-class that composes the protocol
building blocks + mounts apis
- bridge-interface.ts ── public façade for OneBot consumers
- manager.ts ── per-uin BridgeManager hub
- apis/* ── the 13 typed Api classes
- bridge-context.ts (NEW) ── extends @snowluma/bridge's slim
BridgeContext with `apis: ApiHub` so
the 13 Api classes keep their typed
`this.ctx.apis.X.method()` access.
Layout
------
Moved via `git mv` (history preserved):
events / event-bus / identity-service / packet-pipeline /
element-builder / bridge-oidb / qq-info / msg-push/* /
highway/* / web/* → @snowluma/bridge/<sub>
utils/event-format.ts → @snowluma/bridge/format.ts
(the last one renamed because the destination directory only has
bridge-related files; `format.ts` reads cleaner than `event-format`.
Listed as a rename in git's `RM` output — content unchanged.)
Layering fixes (functionality preserved, just type signatures)
--------------------------------------------------------------
- `element-builder.SendContext.bridge` → typed as `BridgeContext`
instead of `Bridge`. Reads only `bridge.identity` and the
upload-cache (both on BridgeContext); the concrete `Bridge`
instance still passes via free upcast.
- `bridge-oidb.runOidb` signature widened from `Bridge` to
`BridgeContext` — same reason.
- `highway/{highway-client,pipeline,image-upload,ptt-upload,
video-upload}.ts` — same.
- `format.ts::formatReply` parameter typed as a new local
`ReplyEventLookup` structural type (`{ findEvent(id): JsonObject |
null }`) instead of `MessageStore` from @snowluma/onebot. The
OneBot `MessageStore` is a structural superset; consumers pass
it through unchanged.
- JSON value types moved to a new `@snowluma/common/json` subpath
(consumed by format.ts) — `onebot/types.ts` re-exports them so
external `import { JsonValue } from '<…>/onebot/types'` paths
keep working.
- `UploadedFileMeta` interface moved from `core/src/bridge/bridge.ts`
into `@snowluma/bridge/bridge-context.ts` — element-builder + the
forward-builder both read the upload cache through BridgeContext,
so the type has to live in @snowluma/bridge. `bridge.ts` keeps
its re-export for back-compat.
Changes
-------
- New package `packages/bridge/` (package.json with per-file subpath
exports mirroring legacy `core/src/bridge/<x>` layout 1:1,
tsconfig.json, typecheck script).
- 35 `.ts` files moved via `git mv` (3 files counted by git as
RM — rename + modify because Bridge → BridgeContext type swap).
- 150+ import sites rewritten across `packages/core/src` and
`packages/core/tests`: `'../bridge/<x>'`, `'../../bridge/<x>'`,
`'../src/bridge/<x>'`, `'@/bridge/web/<x>'`, and vitest
`vi.mock(...)` literals all collapse to `@snowluma/bridge/<x>`.
- `@snowluma/core` depends on `@snowluma/bridge` (workspace:*).
- `@snowluma/bridge` depends on `@snowluma/common`,
`@snowluma/proto-defs`, `@snowluma/proton`.
- `packages/core/src/utils/` directory removed (empty).
Verification
------------
- Full workspace `pnpm -r typecheck`: all 10 projects clean.
- core test suite: 552 passed / 552 total (pre-existing
websocket-darwin-arm64.node addon failure unchanged — same
baseline as the previous refactor commits).
- Vite build (with stubbed native binary): clean — 143 modules,
2.84 MB bundle / 224 KB gzip, identical envelope to the
pre-split build.
Phase 3 (next): OneBot + WebUI move into @snowluma/onebot. The
runnable `index.ts` stays in @snowluma/core as the application
entry, importing `OneBotManager` from `@snowluma/onebot`.
Inlines `actions/extras.ts` into `apis/extras.ts::ExtrasApi`. Covers
the napcat-parity Tier-2 surface: group todo CRUD (0xF90_1/2/3),
stranger online/ext status decode (0xFE1_2), and AI voice list +
synthesis (0x929D / 0x929B).
`StrangerStatus`, `AiVoiceChatType`, `AiVoiceCategory`, `AiVoiceItem`
are now exported from `apis/extras.ts` and re-exported through
`bridge.ts` for backwards compatibility (the OneBot side imports
them via `@snowluma/core`).
No renaming — the names already read cleanly under `apis.extras.X`.
The `fetchAiVoiceList` / `fetchAiVoice` chatType parameter is widened
to `AiVoiceChatType | number` because the OneBot side passes a raw
number from JSON. The wire encoder is happy with any integer; the
strict enum just gives autocompletion at call sites that have it.
Callers updated: `actions/extended.ts` (7 sites).
Tests rewritten:
- `extras.test.ts` → uses `new ExtrasApi(bridge as any).method(...)`
and the it.each dispatch switches to method-name strings.
- `extended-actions.test.ts` APIS_ROUTING gains the extras block.
587/587 tests pass.
Move `packages/core/src/bridge/proto/proton/*.ts` (13 files, ~3.3k
LOC) into a new `@snowluma/proto-defs` workspace package. The proton
spike landed in the previous commit means bare-specifier imports
(`@snowluma/proto-defs/oidb`, `…/message`, …) now resolve correctly
through proton's vite plugin, so call-site replacement still fires.
Why
---
- Lets downstream consumers share the same wire
definitions without copy-pasting them — multiple workspace packages
can now depend on `@snowluma/proto-defs` directly.
- Trims `@snowluma/core`'s surface: the bridge no longer owns the
protocol schema, only its consumers.
- Sets up future #5 (`message-parsing perf`) and #6 (`Api-on-ctx
refactor`) — both want a thinner core.
Layout
------
Per-file subpath exports (mirrors the legacy `proto/proton/<sub>`
shape 1:1). The barrel `index.ts` intentionally skips `highway` and
`oidb-actions/media` because the two files share six NTV2-prefixed
type names — `export *` would silently drop one side. Subpath imports
are unambiguous, which is also why every call site in core uses them.
Changes
-------
- New package `packages/proto-defs/` (package.json, tsconfig.json,
src/index.ts).
- 13 `.ts` files moved via `git mv` to preserve history.
- 44 import sites across 26 source files + 15 tests rewritten via sed
from `./[…/]proto/proton/<sub>` → `@snowluma/proto-defs/<sub>`.
- `@snowluma/core` depends on `@snowluma/proto-defs` (workspace:*).
Verification
------------
- proto-defs typecheck: clean
- core typecheck: clean
- core test suite: 587 passed (same baseline as before the refactor;
one failure is the pre-existing websocket native addon, unrelated)
- proton test suite: 99 passed (8 files including the new
cross-package.test.ts from the spike)
- Full workspace `pnpm -r typecheck`: all 8 projects clean
Note: proton's `dist/index.js` MUST be rebuilt for the vite plugin to
pick up the bare-specifier resolution. The previous commit's source
change is only effective after `pnpm -F @snowluma/proton build`.
Delete proto schema modules under packages/core/src/bridge/proto (action.ts, element.ts, highway.ts, longmsg.ts, message.ts, notify.ts, oidb-action.ts, oidb.ts) and the related test packages/core/tests/proton-parity.test.ts. These files contained protobuf schema definitions for the bridge layer; remove them as part of a cleanup/refactor. Update any imports or references to these modules to their new locations or replacements.
Closes the OIDB phase of the proton migration. Every consumer that
talked to the OIDB service through the old runtime
`makeOidbBaseSchema()` factory now goes through the compile-time
generic `OidbBase<T>` instead — proton monomorphizes the codec per
call-site instantiation.
@snowluma/proton — two analyzer fixes uncovered by the migration:
1. Skip queuing call-site type-args that reference an *enclosing
generic function's* type parameter for monomorphization. Before
this, a wrapper like
export function encodeOidbEnv<T>(env: OidbBase<T>): Uint8Array {
return protobuf_encode<OidbBase<T>>(env);
}
would cause the analyzer to mint a literal `OidbBase__T` registry
entry with `body.typeName = 'T'`, which the post-walk wire-type
guard then tripped on. The enclosing-scope type params are tracked
on a stack as the walker descends into FunctionDeclaration,
FunctionExpression, ArrowFunction, MethodDeclaration nodes; matching
call sites are skipped (the wrapper-binding path is still
responsible for materialising concrete codecs at downstream call
sites).
2. Register *explicitly* empty interfaces (`interface X {}`) as
zero-field proto messages. OIDB has a handful of these
('this call has no meaningful response body' placeholders —
Oidb0xeb7Resp, Oidb0xcd4Resp, Oidb0x112aResp, Oidb0x8a0Resp,
Oidb0xf16Resp, OidbClientKeyReq). Proto3 wire allows zero-field
messages, and downstream code does `protobuf_decode<OidbBase<X>>`
where X is one of these — the wire-type guard would otherwise trip
on the missing `X` registration. Interfaces that have members but
no pb-marked fields are still NOT registered.
@snowluma/core — the OIDB-layer refactor itself:
bridge-oidb.ts ditches the runtime ProtoSchema factory and exposes:
- makeOidbEnvelope<T>(oidbCmd, subCmd, body, isUid) pure helper
- encodeOidbEnv<T>(env) pass-through
- decodeOidbEnv<T>(bytes) pass-through
- runOidb(bridge, cmd, envelopeBytes) non-generic raw
`encodeOidbEnv` / `decodeOidbEnv` are pure pass-through generic
wrappers — proton wrapper-binds them and substitutes every call site
with the inlined `protobuf_encode_OidbBase__X` / `protobuf_decode_…`
function. `runOidb` is non-generic (proton leaves it alone) so it can
be mocked in tests. `makeOidbEnvelope` is plain TS — also mockable for
introspection.
56 `runOidb` call sites across 11 files migrated to the new API:
- bridge-contacts.ts, actions/extras.ts, actions/friend.ts,
actions/group-admin.ts, actions/group-file.ts,
actions/group-message.ts, actions/interaction.ts,
actions/misc.ts, actions/profile.ts, web-actions/cookies.ts,
highway/pipeline.ts
pipeline.ts also drops `makeOidbBaseSchema(NTV2UploadRichMediaReqSchema)`
and the residual `protoEncode({...}, EncodableMediaMsgInfoSchema)` —
both go through proton now.
Tests:
- 8 test files migrated from `vi.mocked(decodeOidbEnv).mockReturnValueOnce`
to `vi.mocked(runOidb).mockResolvedValueOnce(protobuf_encode<OidbBase<X>>({body: …}))`
— the proton substitution means the old mock layer is bypassed at
build time, so the only mockable point is the bytes layer at `runOidb`.
- 2 new parity tests pinning `OidbBase<T>` ↔ legacy
`makeOidbBaseSchema(InnerSchema)` byte equivalence.
Verification:
- @snowluma/proton: 95/95 tests pass, tsc --noEmit clean
- @snowluma/core: 605/605 tests pass (network-manager.test.ts
fails to load due to a missing darwin-arm64 native binary in this
environment — pre-existing), tsc --noEmit clean
Wires 16 OneBot actions that napcat exposes but SnowLuma previously
either lacked or stubbed:
Tier 1 (pure glue over existing Bridge capabilities)
- send_packet / .send_packet: real impl over Bridge.sendRawPacket
- bot_exit, nc_get_packet_status, nc_get_rkey
- get_group_ignored_notifies / get_group_ignore_add_request: project
fetchGroupRequests(filtered=true) into napcat shapes (was empty stub)
- delete_group_folder alias for delete_group_file_folder
Tier 2 (new oidb proto + Bridge method)
- set_/complete_/cancel_group_todo (oidb 0xF90_1/2/3)
- nc_get_user_status (oidb 0xFE1_2 with napcat's status decode)
- get_ai_characters / get_ai_record / send_group_ai_record (oidb
0x929D / 0x929B; AI voice reuses the existing fetchGroupPttUrlByNode)
BridgeInterface now exposes sendRawPacket as the deliberate escape
hatch for send_packet — every other surface method already wraps it.
46 new tests: 13 bridge-action (mocked runOidb dispatch checks) + 33
OneBot-action (ApiHandler.handle end-to-end).