dev2
232
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
3870052fb4 |
Simplify Hub channel management
Refactor Hub to treat each UIN as a single active Channel: channelsByUin_ now maps UIN -> Channel (no Set of IDs), primary selection logic and primaryByUin_/pickPrimary were removed, and addChannel replaces any existing channel for a UIN (disposing the previous one). Updated add/remove flows to update channelsById_ and channelsByUin_ accordingly and to bring cores online/offline in the simplified model. Also: removed extensive JSDoc/comment blocks from channel/adapter/channel-ctx/core/hub files to slim the public API surface, reordered/cleaned exports in packages/channel/src/index.ts, added a new hub-events.ts placeholder, and cleaned up a test helper comment. Disposal of replaced channels is wrapped with a try/catch and logged at debug level. |
||
|
|
af8d5ab06b |
Split channel packages and rename Account→Core
Reorganize package layout and rename core/account types: - Move channel hook & socket sources/tests into packages/channel-hook and packages/channel-socket (many files renamed). - Remove ChannelManager from packages/channel and drop its export from package.json (multi-account host folded into Hub). - Update tests to use HookSink instead of BridgeManagerSink. - In @snowluma/core: move account/apis/* up to src/apis, rename account-context→core-context, account-interface→core-ctx, account→core, and add hub.ts; delete AccountManager. - Update constructors and type usages to accept CoreContext/CoreCtx and adapt identifiers accordingly. This is a refactor to clarify responsibilities (split transport implementations, fold multi-account manager into Hub, and rebrand the account layer as Core). |
||
|
|
adf0744f75 |
Rename bridge → channel and reorganize packages
Move/rename the transport/bridge code into a new @snowluma/channel layout and extract hook implementations into channel-specific packages. Many bridge sources were deleted and equivalent channel files added (adapter/ctx/interface/channel/hook/socket pieces moved/renamed), along with tests and configs relocated under packages/channel. RoadMap.md replaced with a detailed v3 architecture plan describing Channel/ChannelCtx/Core/CoreCtx/Hub boundaries and migration steps. Also apply related core package updates (account/core reshaping and package.json adjustments) to match the new architecture. |
||
|
|
652bafd75a |
Extract bridge transport into @snowluma/bridge
Move transport-layer bridge code out of @snowluma/core into a dedicated @snowluma/bridge package and introduce a clear account layer in core. Adds Bridge, BridgeInterface, InjectBridge, ProtocolBridge (stub), adapters, and manager files under packages/bridge and updates package.json exports. In @snowluma/core the former bridge surface is replaced by Account, AccountContext, AccountInterface and AccountManager; apis were moved/renamed to account/apis and now consume AccountContext. Adjustments include packet handling plumbing (use deliverPacket / setPacketHandler), updated sendRawPacket wiring, uploaded-file cache and lifecycle changes (InjectBridge.dispose, ProtocolBridge returns a structured not-implemented result). Tests and imports were updated accordingly. |
||
|
|
5440cbc37e |
Refactor bridge system: adapters & transports
Introduce a transport-adapter layer and refactor the bridge abstractions to be transport-agnostic. Added BridgeAdapter and BridgeAdapterHost (packages/core/src/bridge/adapter.ts) plus concrete InjectBridge/InjectBridgeAdapter and ProtocolBridge/ProtocolBridgeAdapter implementations (new files). Made Bridge an abstract base exposing kind/id and abstract sendRawPacket, moved transport-specific PID/sender logic into InjectBridge. Reworked BridgeManager to register/start adapters, manage bridges by id/uin, pick a primary per-UIN (inject preferred over protocol), and provide a host API for adapters to add/remove bridges. Updated index bootstrapping to register adapters and wire the WebUI to the inject adapter's HookManager. Adjusted tests and OneBot code to remove PID tracking from OneBotInstance/OneBotManager and to instantiate InjectBridge in tests. Also exported new entry points in package.json for adapter/bridge modules. This decouples transports from manager logic and adds a stubbed protocol runtime for future pure-protocol support. |
||
|
|
2c565fbd8d |
Switch from @snowluma/sqlite to node:sqlite (sync)
Remove the in-tree @snowluma/sqlite wrapper and vendored better-sqlite3 prebuilts; migrate consumers to node:sqlite's synchronous API. Replaced imports and types (Database/Statement -> DatabaseSync/StatementSync), updated PRAGMA calls to use exec, and adjusted code in onebot (media/message/reaction stores) and protocol (identity service). Vite config no longer bundles sqlite native binaries and references to the sqlite workspace were removed. Also delete the packages/sqlite package and its native prebuild artifacts, and add a couple of unrelated AST improvements in proton: use a module-level shared wrapper cache and add an inheritance cache to avoid repeated scans. Minor import reordering/cleanup applied where needed. |
||
|
|
5924346823 | chore(release): v1.9.2 | ||
|
|
456086599c |
Normalize imports, typings and formatting
Reorder and normalize imports, add missing type imports, and fix whitespace/formatting across multiple files. Notable updates: eslint.config.ts import ordering; core bridge APIs (group-album, misc, web) now import Json types; tests/highway pipeline import ordering and added type imports; protocol changes (element-builder, highway client/pipeline) reorganized imports and added proto type imports; many oidb service modules moved protobuf encode/decode imports for consistency; web group modules (album, essence, honor, notice, request-util) reordered imports, adjusted typings/indentation, and small whitespace fixes. These edits are stylistic and typing improvements and should not change runtime behavior. |
||
|
|
80ee12ecc2 |
Enforce no-explicit-any; allow in tests
Enable @typescript-eslint/no-explicit-any as an error in the ESLint config to tighten typing across the codebase, and add an override to disable this rule for test files. Also apply non-functional cleanup in packages/proton/src/runtime.ts: reorder/imports and adjust formatting/indentation and string alignment for readability — no behavioral changes. |
||
|
|
acc5084647 |
Refactor group-album and remove eslint ignore
Remove an unused Bridge import, the asBridge helper and a stale comment from group-album, and relocate the GroupAlbumMediaResult and GroupAlbumCommentResult interfaces to the end of the file. In the sqlite package, remove the inline eslint-disable-next-line for @typescript-eslint/no-explicit-any near the BetterSqlite3 constructor. These edits clean up dead/stale code and re-enable the lint rule; there are no behavioral changes. |
||
|
|
a49e507e15 |
Refactor Bridge APIs, slim Bridge and reorder imports
Move and standardize API implementations under bridge/apis, slim down the Bridge class (remove inline action implementations, caches and large comment blocks) and relocate related types/exports. Clean up and reorder imports across many bridge/api modules, normalize constructors, add logger imports where needed, and adjust WebUI routing/import ordering. These changes centralize API logic, reduce Bridge surface area, and tidy module boundaries and imports. |
||
|
|
f53df5e313 |
refactor(packages): extract @snowluma/bridge (hook + QQ lifecycle)
Split the QQ NT client lifecycle / hook injection layer out of @snowluma/core into its own self-contained package. The new bridge package owns: - native hook addon loader (injector) - HookManager + per-PID HookSession orchestration - PipeWatcher for QQ process discovery - QqHookClient pipe protocol - qq-port-probe for early UIN sniffing The hook layer used to type its main collaborator as `BridgeManager` directly. To keep the new package free of any dependency on @snowluma/core, the cross-package coupling is now expressed via a 3-method `BridgeManagerSink` interface that lives in the bridge package. BridgeManager (in core) satisfies the interface structurally with no source changes; the top-level app/webui still inject it the same way. Pure file relocation otherwise: git mv preserves history at 100% on every file, the only logic edit is the interface introduction in hook-manager.ts and an updated `nativeSearchDirs()` offset (one fewer `..` since hook now lives at packages/bridge/src instead of packages/core/src/hook). |
||
|
|
c16064d7e3 |
refactor(packages): rename @snowluma/bridge → @snowluma/protocol
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. |
||
|
|
d8c72c2ebc |
fix(events): surface verify-text on group_invite ("你们好" → comment)
User report:
SnowLuma's group_invite event renders to OneBot with empty
`comment`, so the bot's approval-prompt template shows nickname
+ uin + flag but loses the requester's verify message.
NapCat's same flow shows the verify text:
【进群申请】批准/驳回:
昵称:KitaIkuyo
QQ:1957003260
flag:1779543612823906
等级:7
你们好 ← only NapCat shows this line
Root cause:
The OIDB push (GroupRequestJoinNotice subType=84) only carries
`(groupUin, targetUid)` and a few opaque numeric fields — no
string for the verify text. NapCat doesn't use the push directly;
they poll `nodeIKernelGroupService.getGroupNotifies` (= our OIDB
0x10C0 pending-request queue at `FetchGroupRequests`) which
returns each pending row with `postscript` (the verify text) and
`seq` (server sequence id). Reference:
dev/NapCatQQ/packages/napcat-onebot/index.ts:483-498
(`flag = notify.seq`, `comment = notify.postscript`).
Our pipeline didn't enrich. The decoder set `message: ''` and
emitted, so OneBot's `comment` was empty.
Fix:
- Extend `PacketPipelineDeps` with a `resolveGroupJoinRequest(
groupId, uid, subType)` hook. Bridge.ts wires it to
`fetchGroupRequests` and matches on `(groupId, uid)` —
targetUid for `subType='add'`, invitorUid for
`subType='invite'`.
- `dispatchAfterStrangerResolve` now fires the profile + request
lookups in PARALLEL via `Promise.allSettled` so a flake on one
path doesn't kill the other. The request lookup's `comment`
lands on `event.message`; OneBot converts that to `comment`.
Decisions:
- Kept the existing `add:<groupId>:<uid>` flag format (consumer
bots already parse it). Switching to NapCat's sequence-form
flag would break callers and is a separate breaking change.
- Both lookups fail-open: if either rejects, the event still
emits with whatever info we did get, so the bot at least sees
the request and can render the bare nickname.
Regression tests:
- `populates event.message from the pending-request queue` — pins
the user-reported "你们好" case end-to-end through the pipeline.
- `runs lookups in parallel` — profile resolves, request fails;
event still ships with the resolved uin and an empty message.
- `invite subtype matches the request on invitorUid` — explicit
contract for the invite-forwarded-to-admin case where the
pending row's REQUESTER is `invitorUid`, not `targetUid`.
345 bridge tests pass (was 342 + 3 new); typecheck + lint clean.
|
||
|
|
a4909ec9e5 |
fix(events): resolve stranger nickname on group_invite (no more 未知昵称)
User report: when a stranger requests to join a group, the bot
receives `user_id=<groupId>` (instead of the requester's actual
uin) and `get_stranger_info` returns no nickname → bot displays
"未知昵称".
Two layered bugs:
1. **Decoder fallback was wrong.** `decodeGroupJoinRequest` /
`decodeGroupInvitation` / `decodeGroupInvite` all passed
`ctx.fromUin` as the fallback uin when `resolveUidToUin` missed
the local cache. But for group-scoped pushes `ctx.fromUin` is
the GROUP's own uin — falling back to it produces the bogus
`fromUin === groupId` collision the user saw. Fix: pass 0.
2. **No async resolve for stranger UIDs.** The push only carries
the requester's UID string. When the UID isn't in our cache
(true for any stranger who isn't yet a group member), we have
no UIN — and our UIN-form `FetchUserProfile` can't help. The
pipeline never tried to look up the stranger by UID, so the
event went out with `fromUin=0` (or, with the old fallback,
the group's own uin).
Cross-checked against Lagrange.Core's flow:
dev/Lagrange.Core/.../MessagingLogic.cs:215-224
case GroupSysRequestJoinEvent join:
var fetchUidEvent = FetchUserInfoEvent.Create(join.TargetUid);
var results = await Collection.Business.SendEvent(fetchUidEvent);
uint targetUin = results.Count == 0 ? 0
: ((FetchUserInfoEvent)results[0]).UserInfo.Uin;
var joinArgs = new GroupJoinRequestEvent(join.GroupUin, targetUin);
dev/Lagrange.Core/.../OidbSvcTrpcTcp0xFE1_2.cs:9-26
— UID-form variant of the same OIDB 0xFE1_2: field 1 is `uid:
string`, envelope `reserved=0` (no uinForm flag).
dev/Lagrange.Core/.../FetchUserInfoService.cs:66-78
— picks UID vs UIN form based on which input is set.
Changes:
- `proto-defs/oidb-actions/base.ts`: add `OidbUserInfoByUidRequest`
(same shape as the UIN variant but field 1 is `uid: string`).
- `bridge/oidb-services/contacts/fetch-user-profile-by-uid.ts`:
new namespace at 0xFE1_2 with UID-form encoding, no `uinForm`.
- `bridge/msg-push/decoders/group-join-request.ts`: 3 decoders'
`resolveUidToUin` fallbacks change `ctx.fromUin` → 0.
- `bridge/packet-pipeline.ts`: new `resolveStrangerProfile(uid)`
dep + `needsStrangerResolve` check + async dispatch that fires
the lookup before emitting the event. Fail open if the lookup
rejects (event still ships with `fromUin=0` so the OneBot bot
can fall back to the uid in the flag).
- `core/bridge/apis/contacts.ts`: `fetchUserProfileByUid(uid)`
facade method.
- `core/bridge/bridge.ts`: wire `resolveStrangerProfile` into the
pipeline deps so it routes through the new namespace.
Tests:
- UID-form namespace: declares the right cmd/subcmd, packages uid
as field 1, decodes nickname + uin so the pipeline patch works.
- Pipeline stranger resolve: patches event.fromUin from the
resolver response; fails open on null / throw; skips the wire
call when fromUin is already populated or fromUid is empty.
All 655 tests pass (was 645 + 10 new); typecheck + lint clean.
|
||
|
|
4755a91cb3 |
fix(image): use Chinese summary defaults to match QQ ecosystem
Mobile QQ, Lagrange.Core, and NapCat all use the literal Chinese
bubble-preview strings `[图片]` / `[动画表情]`. SnowLuma was emitting
English `[image]` / `[sticker]` as fallback, so QQ clients showed
"[image]" in chat-list previews instead of the expected Chinese.
Cross-checked against:
dev/NapCatQQ/.../packet/message/element.ts:367-371
dev/Lagrange.Core/.../Message/Entity/ImageEntity.cs:186-192
Both branch the same way: subType=1 → `[动画表情]`, else → `[图片]`.
Changes:
- `bridge/highway/image-upload.ts`: both code paths (fingerprint +
load-from-source) now emit the Chinese defaults. Subtype branch
flipped to `subType === 1 ? '[动画表情]' : '[图片]'` to match the
upstream convention (our old check was `subType === 0` →
`[image]`, which would label any non-zero subType — not just
stickers — as `[sticker]`).
- `bridge/msg-push/rich-body-decoder.ts`: all three decode paths
(NotOnlineImage / CustomFace / commonElem image) now substitute
the Chinese default when the server omits `textSummary`. Empty-
string is also treated as missing (matches NapCat's `=== ''`).
- Tests updated to assert the Chinese defaults; new test pins
that a caller-supplied summary still overrides verdef.
The OneBot caller's `summary` field on the MessageElement still
wins when set, so downstream clients passing custom bubble text
(e.g. for accessibility) keep working.
|
||
|
|
c8f259ef4a |
test(highway): pin multi-image sequential semantics + richer 921 diag
User reported `highway upload error_code=921` when sending two images
in one OneBot message — image 1 fast-uploaded (server already had the
md5), image 2 (58 bytes) triggered the actual PUT and failed.
Verified the claim from code inspection vs NapCatQQ + Lagrange.Core:
- dev/NapCatQQ/.../highwayContext.ts:62-94 (checkAvailable + prepareUpload)
- dev/NapCatQQ/.../highwayHttpUploader.ts:8-43 (HTTP block upload)
- dev/NapCatQQ/.../highwayContext.ts:219-254 (uploadGroupImage)
- dev/Lagrange.Core/.../HighwayContext.cs:98-180 (UploadSrcByStreamAsync)
Findings:
1. The pipeline runs `runNtv2Upload` sequentially per image (await
inside `buildSendElems`); there's no concurrency or shared state.
2. Each call gets its own OIDB request, its own lazy session fetch,
its own buildHighwayExtend output. No aliasing.
3. The 921 comes from the highway PUT's response head (errorCode),
not from anything client-emitted being malformed.
Verdict: not reproducible from code inspection — almost certainly
server-side (most likely Tencent rejecting the 58-byte image as a
malformed payload; 58B is too small to even be a valid 1×1 PNG).
Hardening:
- Three regression tests pin the sequential two-image semantics so
a future state-leak bug fails loudly:
- extend bytes for image 2 must come from image 2's response,
not image 1's,
- each runNtv2Upload fetches its own non-null session,
- each OIDB request carries its own fileInfo + a unique
clientRandomId.
- The `error_code=N` message now carries segRetCode, cmdId, chunk
size, offset, and the first 8 bytes of the file md5 — without
these the user couldn't tell apart "image rejected" vs "session
expired" vs "rate-limited" on future reports.
If the user reports the same 921 again, the new diagnostic message
should show enough server-side context to triage server-vs-client.
|
||
|
|
cd9403f27a |
fix(forward): align resid+uniseq for nested forwards
Multi-layer 合并转发 was uploading correctly but the outer's
LongMsgResult piggyback was *unreachable* by the receiver because
two things were missing:
1. The forward preview element was emitted as the old XML
`richMsg serviceID=35 m_resid=…` shape, which carries the
inner resId but has NO uniseq field.
2. The piggyback `actionCommand: <uuid>` slot generated a fresh
UUID that was never reflected into the preview either.
Result: QQ-NT receivers saw the inner preview's resid, scanned the
outer's actions[] for a matching uniseq, found none, and either
fell back to a fresh server fetch per layer or dropped the layer
entirely.
Cross-checked against:
- dev/Lagrange.Core/Lagrange.Core/Message/Entity/MultiMsgEntity.cs:43-115
- dev/NapCatQQ/packages/napcat-core/helper/forward-msg-builder.ts:52-122
- dev/NapCatQQ/packages/napcat-onebot/action/msg/SendMsg.ts:208-347
Both upstream impls use the modern `com.tencent.multimsg` LightApp
JSON whose `meta.detail.uniseq` is the round-trip key that links
the inner preview to the matching `actionCommand` in the outer's
LongMsgResult — no separate fetch needed.
Changes:
- `element-builder.makeForwardElem` now emits a deflated
`com.tencent.multimsg` LightApp JSON (LightAppElem, field 51)
instead of the XML RichMsg (field 12). `forwardUuid` on the
MessageElement is baked into `meta.detail.uniseq` and
`extra.filename`. Auto-generates a UUID when absent.
- `MessageElement.forwardUuid?: string` added for the round-trip.
- `forward.uploadRecursive` threads `inner.uuid` into the outer's
preview element AND uses the same uuid for the outer's
piggyback actionCommand — closing the receiver-side lookup loop.
- `forward.fetch` now walks ALL actions (not just `MultiMsg`),
indexes piggybacks by uuid, and pre-populates the cache for
each inner resId discovered in the outer's preview bubbles.
Follow-up `fetch(innerResId)` hits the cache instead of the wire.
- `rich-body-decoder` parses LightApp JSON; when `app ===
com.tencent.multimsg`, emits `{type: 'forward', resId,
forwardUuid}` so the OneBot layer can walk in. Legacy XML
serviceID=35 path stays as a fallback for mobile QQ senders.
Wire-byte tests cover:
- send: lightApp.data is 0x01-prefixed deflated JSON with the
right meta + extra fields
- send/nested: outer LongMsgResult carries `MultiMsg` AND a
uuid-keyed piggyback; the piggyback uuid matches the
preview's `meta.detail.uniseq` baked into the same outer
msgBody
- send/3-deep: outermost action[] has 1 + 2 entries (own
MultiMsg + 2 piggybacks hoisted from deeper levels)
- recv: lightApp `com.tencent.multimsg` → forward element with
both resId and forwardUuid; non-multimsg LightApp falls
through to {type:'json'}; legacy XML path still emits
{type:'forward'}.
641 tests pass (was 628 + 13 new); typecheck clean; lint clean.
|
||
|
|
521808a6eb |
refactor(oidb): migrate group-file.ts OIDB cmds to namespaces
Splits the 16 OIDB cmds from GroupFileApi into self-contained
namespaces under @snowluma/bridge/oidb-services/group-file/. Adds
@snowluma/bridge/oidb-services/shared with `toInt` / `ensureRetCodeZero`
helpers so namespaces can validate sub-responses without an
@snowluma/core dependency.
Boundaries:
- Single-OIDB methods (publish / getCount / list-page / *url /
delete / move / *folder) become one-line forwarders.
- 3-stage methods (`upload` / `uploadPrivate`) keep their
orchestration on the facade — OIDB preflight goes through the
new namespace, but highway PUT + publish/sendC2cFile + upload-
cache bookkeeping stay where they are.
- `list` keeps its pagination loop on the facade; the page-fetch
namespace is one call.
- The NTV2-rich-media URL fetchers (group/private × ptt/video)
share a deserialize helper (`parseNtv2DownloadUrl`) so all four
namespaces only differ in their scene block.
Wire-byte equivalence preserved. The 0x6D6 family shares one
OidbGroupFileReq envelope; each namespace populates a different
slot (file / download / delete / move) and proton monomorphizes
per call site as before.
|
||
|
|
8d22868777 |
refactor(oidb): migrate group-admin.ts OIDB cmds to namespaces
Splits the 14 OIDB cmds from GroupAdminApi into self-contained
namespaces under @snowluma/bridge/oidb-services/group-admin/. The
facade becomes one-line forwarders; per-cmd capability is narrowed
to OidbSender (+ resolveUserUid / identity where the cmd actually
needs it).
Notes:
- 0x89A_0 hosts MuteAll, SetAddOption, SetSearch, SetGroupName —
same (cmd, subcmd) tuple, four different proto bodies; each gets
its own namespace so proton can monomorphize independently.
- 0x8A0_1 splits KickMember (single) and KickMembers (batch) for
the same reason.
- SetAddRequest carries uinForm=true and dynamic subCommand
(filtered ? 2 : 1) on the same namespace.
- GetAtAllRemain needs the bot's own uin → declares
Pick<BridgeContext, 'identity'> in its Deps.
Wire-byte equivalence preserved; corner case for getAtAllRemain
empty-envelope path now returns {false, 0, 0} instead of throwing —
the new infrastructure normalises empty bodies and the OneBot action
ergonomics are unchanged.
|
||
|
|
014b87c7ce |
refactor(oidb): migrate contacts.ts OIDB cmds to namespaces
6 OIDB cmds moved; pagination + per-group cache + inflight
coalescing stay on the facade because they need cross-call state.
- FetchFriendListPage (0xFD4_1) — facade drives the cursor loop
- FetchGroupList (0xFE5_2) — uinForm; verbatim config blob
- FetchGroupMemberListPage (0xFE7_3) — facade drives the token loop +
owns the TTL cache + inflight
coalescing that keeps busy
OneBot clients from tripping
Tencent risk-control
- FetchUserProfile (0xFE1_2) — uinForm; full property-key
catalogue cross-checked against
LagrangeV2 FetchStrangerService.cs
- FetchGroupRequests (0x10C0_1/_2) — dynamic subcmd via filtered flag
- FetchDownloadRkeys (0x9067_202) — uinForm; respHead.retCode
check moved into deserialize
35 new namespace-level tests covering serialize / deserialize edge
cases (property decoding, sex enum mapping, avatar URL suffix,
empty body handling, pagination cursor behaviour).
Verification:
- typecheck green
- @snowluma/bridge: 30 files / 233 tests
- @snowluma/core: 302 tests
Wire bytes unchanged.
|
||
|
|
80afa31f5c |
refactor(oidb): migrate extras.ts OIDB cmds to namespaces
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.
|
||
|
|
8356121518 |
refactor(oidb): migrate web.ts OIDB cmds to namespaces
2 OIDB cmds moved (the only OIDB calls in this file — the rest are
HTTP cookie / qun.qq.com / qzone web requests):
- ForceFetchClientKey (0x102A_1) — used by ptlogin2 jump flow to
swap clientKey → cookie jar
- GetPskey (0x102A_0) — p_skey fallback when ptlogin2
omits it
The private `forceFetchClientKeyInner` / `getPSkey` helpers in
WebApi are now one-line forwarders into the namespaces. Facade
methods (`getCookies`, `getCsrfToken`, `getCredentials`, etc.) and
all HTTP-only cmds untouched.
12 new namespace-level tests covering deserialize edge cases
(empty server response, missing fields, dropped invalid items)
and e2e wire routing.
Verification:
- typecheck green
- @snowluma/bridge: 20 files / 165 tests
- @snowluma/core: 302 tests
Wire bytes unchanged.
|
||
|
|
58e8081420 |
refactor(oidb): migrate misc.ts OIDB cmds to namespaces
3 OIDB cmds migrated; the 1 non-OIDB cmd (getMiniAppArk via
LightAppSvc) stays on the facade unchanged.
- TranslateEnToZh (0x990_2)
- ClickInlineKeyboardButton (0x112E_1)
- SendGroupSign (0xEB7_1) — preserves historic UPPERCASE
EB7 wire name via the new
`wireName` override (the only
cmd in the codebase with this
quirk; every other cmd uses
the default lowercase).
Tests:
- misc.test.ts moved to bridge.sendRawPacket assertion model
- 18 new namespace-level tests covering serialize, deserialize edge
cases, magic constants, BigInt widening, and the EB7 case quirk
Verification:
- typecheck green
- @snowluma/bridge: 18 files / 153 tests
- @snowluma/core: 302 tests
Wire bytes unchanged.
|
||
|
|
f78431242c |
refactor(oidb): migrate friend.ts to namespaces
3 OIDB cmds moved to self-contained namespaces:
- HandleFriendRequest (0xB5D_44) — digit-only flag still
auto-resolves via resolveUserUid
- DeleteFriend (0x126B_0) — verbatim magic constants in the
nested field2 sub-message
(130/109/8/8/50 — server-side
discriminator, MUST stay byte-
equal to NTQQ wire)
- SetFriendRemark (0xB6E_2)
Cache invalidation (fetchFriendList after deletion) stays in the
facade — that side-effect needs `ctx.apis.contacts`, which namespaces
deliberately can't reach.
Tests migrated to assert against bridge.sendRawPacket. 15 new
namespace-level tests covering each invoke branch + serialize edge
cases.
Verification:
- typecheck green
- @snowluma/bridge: 15 files / 133 tests
- friend.test.ts: 5/5
Wire bytes unchanged.
|
||
|
|
a38f7ea26e |
refactor(oidb): migrate profile.ts OIDB cmds to namespaces
5 of profile.ts's methods were thin OIDB wrappers — migrated to the
new namespace pattern. The 5 non-OIDB methods (setOnlineStatus /
setDiyOnlineStatus / setAvatar / setGroupAvatar / fetchCustomFace)
stay on the facade unchanged — they hit `trpc.qq_new_tech.*`,
Highway, or `Faceroam.OpReq` directly and aren't in scope for the
OIDB-namespace refactor.
New namespaces (each with focused Pick<BridgeContext, …> deps):
- SetProfile (0x112A_2) — needs identity for uin
- SetSelfLongNick (0x112A_2) — same cmd different proto, ditto
- SetInputStatus (0xCD4_1) — needs resolveUserUid
- GetLike (0x7ED_12) — needs identity + resolveUserUid
- GetUnidirectionalFriendList (0xE17_0) — needs identity; non-standard
wire name handled via the new
wireName override on OidbCallSpec
Infrastructure tweak:
- OidbCallSpec gains an optional `wireName(cmd, sub) → string`. Most
cmds use the default `OidbSvcTrpcTcp.0xNNNN_N` scheme; 0xe17_0
routes through `MQUpdateSvc_com_qq_ti.web.OidbSvc.0xe17_0`.
Test mock updates:
- profile.test.ts moves the migrated cmds onto bridge.sendRawPacket
mock (the new path bypasses the bridge-oidb runOidb mock). Non-OIDB
cmds (setOnlineStatus, setAvatar, etc.) keep their existing mocks.
- 36 new namespace-level tests in @snowluma/bridge covering serialize
branches, deserialize edge cases, deps resolution, and e2e wire
routing.
Verification:
- pnpm -r typecheck: green
- @snowluma/bridge: 12 files / 118 tests
- @snowluma/core: 33 files / 302 tests
Wire shape unchanged across migrated cmds — same bytes produced.
|
||
|
|
ff2b902cbd |
refactor(oidb): migrate interaction.ts remaining 3 cmds to namespaces
Completes the InteractionApi facade — every method is now a one-line
forwarder. New namespaces:
- SendPoke (0xED3_1) — group + friend variants discriminated by
which of groupUin/friendUin is set
- SendLike (0x7E5_104) — thumbs-up to a target user
- SetEssence (0xEAC_1/_2) — set/unset essence message, dynamic subcmd
Facade methods reduced to thin forwarders, mirror-fixed comment block
removed (the namespaces now own the wire-shape commentary).
Tests: 28 new in @snowluma/bridge covering each namespace's serialize
edge cases + dynamic subcmd resolution + e2e wire name + envelope body
shape. Cleaned the legacy `vi.mock('bridge-oidb')` setup out of
interaction.test.ts — the post-migration path doesn't route through
the mocked symbols anymore.
Wire behavior unchanged: every cmd produces the same bytes as before,
verified by encode round-trip in the unit tests.
|
||
|
|
0238bcb280 |
refactor(oidb): namespace-per-cmd service layer — vertical slice (3 cmds)
Introduce a new way to organise OIDB cmd implementations:
- one TypeScript namespace per (cmd, subcmd) tuple
- the namespace's exported members ARE the OidbCallSpec (structural
typing — no `implements` clause)
- a single invokeOidb() template method drives every call:
envelope build → encode → send → errorCode check → decode →
deserialize transformation
- each namespace declares its own minimal `Deps` capability via
Pick / intersection on BridgeContext (Interface Segregation):
most cmds need only OidbSender (the `sendRawPacket` method)
The facade Api classes (InteractionApi etc.) stay for now, with the
migrated methods reduced to one-line forwarders that hand off to the
namespace. Callers still write `bridge.apis.X.Y(...)`; the OIDB
wire-encoding work just moved to a self-contained file per cmd.
Vertical slice scope: 3 cmds (the reaction line)
- SetReaction (0x9082_1 / _2; dynamic subcmd)
- FetchReactionSummary (0x9084_1)
- GetEmojiLikes (0x9083_1; legacy stub)
Infrastructure:
- new packages/bridge/src/oidb-service.ts:
OidbSender (sendRawPacket-only capability),
OidbCallSpec<TReq, TResp, TParams, TResult>,
invokeOidb(), buildOidbRequest(), parseOidbResponse(), OidbError
- runOidb() refactored to accept OidbSender instead of BridgeContext
(structurally compatible — no caller changes anywhere)
- new OidbEmpty placeholder type in proto-defs/oidb.ts for cmds whose
response carries no body (proton needs a real message interface,
not `void` / `never`)
- empty-body responses substituted with {} in invokeOidb so
deserialize always receives a defined object
Tests (65 in @snowluma/bridge, all green):
- oidb-service.test.ts (20): every helper branch — static + dynamic
subcmd, uinForm flag, error code propagation as OidbError, empty
body substitution, sender failure modes (success=false /
gotResponse=false / both with and without errorMessage),
timeoutMs propagation
- set-reaction.test.ts: const declarations, dynamic subcmd both
ways, type=1/2 boundary on code length, e2e envelope inspection
- fetch-reaction-summary.test.ts: BigInt serialization, catalog-tail
filtering, default emojiId/Type/lastReactionTime
- get-emoji-likes.test.ts: BigInt serialization, default count /
emojiType, base64 cookie round-trip, uin=0 filtering, isLast
computation, e2e
Existing interaction.test.ts moved off the now-bypassed runOidb mock
onto sendRawPacket mock (the new invokeOidb path routes there).
Workspace-wide typecheck: green. Workspace test totals:
proton 99/99, bridge 65/65, core 302/302, onebot 257/257
(1 pre-existing onebot suite failure, network-manager native addon
load — unrelated to this change.)
|
||
|
|
c3675aef7c |
feat(emoji-like): back fetch_emoji_like / get_emoji_likes with a local cache
Two rounds of production wire-level probing established that the OIDB
SSO path cannot surface the reactor user list for an emoji:
- 0x9083_1 → 4-byte ack `18 01 20 01` (no list)
- 0x9084_1 → per-emoji summary (count + timestamp,
still no user IDs)
- 0x9082_3..5, 0x9083_0/2/3, 0x9084_2..5, 0x9085_1/2, 0x9087_1
→ "no privilege" — the cmds exist but
aren't in NTQQ's client capability
bitmap, so the server blocks anything
not registered at login. NTQQ itself
serves getMsgEmojiLikesList from a
wrapper-internal cache, not SSO.
Mirror that wrapper-cache approach on the OneBot side. SnowLuma
already receives GroupMsgEmojiLikeEvent push events for every
reaction add/remove (with operator uin + uid + emoji + msgSeq); we
just weren't persisting them. Now we do.
Changes:
* proto-defs: add Oidb0x9084 Req/Resp schema for the summary call.
RespEntry's lastReactionTime/count are optional because the
"available reactions catalog" tail entries omit both — only the
"used" subset carries metadata.
* core InteractionApi: revert the 9-candidate probe loop, restore a
single 0x9083_1 call as a legacy stub (returns empty list, with a
debug-level hex dump on empty responses), and add
fetchReactionSummary() that calls 0x9084_1 and yields the per-emoji
summary (count + last-reaction timestamp).
* onebot ReactionStore: new SQLite-backed cache, schema
reactions(group_id, msg_seq, emoji_id, emoji_type, operator_uin,
operator_uid, set_at), PK on (group_id, msg_seq, emoji_id,
operator_uin). Mirrors MessageStore design — prepared statements at
construction, WAL journal.
* onebot event-pipeline: subscribe group_msg_emoji_like, call
reactionStore.recordAdd / recordRemove before convertAndDispatch so
a fetch_emoji_like racing with the push still sees the new row.
* onebot instance / instance-context: own the ReactionStore, expose
it on OneBotInstanceContext, close it on dispose. New
fetchEmojiLikeUsers helper on ApiActionContext combines local-cache
reads with a 0x9084_1 cross-check so callers can detect cache gaps
(events missed before bot boot, etc).
* onebot actions: get_emoji_likes and fetch_emoji_like now read from
the cache. fetch_emoji_like's NapCat-style cookie becomes a numeric
offset for cache pagination (no real cursor needed against local
SQLite).
* tests: 7 new ReactionStore unit tests (CRUD + pagination +
cross-key isolation + summarizeMessage). Existing event-pipeline
test harness picks up a no-op ReactionStore stub.
|
||
|
|
65636953e7 |
chore(interaction): round-2 cmd probe — focus on 0x9084 family
Round-1 wire dump revealed:
- 0x9083_1 returns a 4-byte ack (`18 01 20 01`), no user data
- 0x9082_3/_4, 0x9083_0/_2/_3 all return "no privilege" (server-side
deny linked to iwiki.woa.com permission docs)
- 0x9084_1 returns 932 bytes — a real reaction *summary* on the
message: first entry carries (timestamp, count, type, emoji_id)
for the used emoji ("76" with count=3 in our test), followed by
a 28-entry catalog of available reaction emojis. NOT a user list,
but proves 0x9084 family has live handlers.
The "fetch users who reacted with emoji X" sibling is almost
certainly another subcmd of 0x9084. Probe the immediate neighborhood:
0x9084_2..5 first-choice candidates (sibling subcmds)
0x9085_1..2 exploratory adjacent cmd
0x9086_1, 0x9087_1 wider probe
0x9084_1 kept at end so we still capture the summary baseline
|
||
|
|
7e3e833f84 |
chore(interaction): probe 7 OIDB cmd candidates per fetch_emoji_like call
Replace the env-driven probe with an unconditional round-robin across
the candidate list every fetch_emoji_like invocation:
0x9083_1 baseline (returns 4-byte ack, known not to be fetch)
0x9082_3 sibling of set/unset reaction
0x9082_4
0x9083_0
0x9083_2
0x9083_3
0x9084_1 exploratory
For each candidate the cmd, response length, parsed user count,
request hex, and response hex are dumped at debug level. The first
candidate that surfaces users wins and is returned; failures inside
runOidb (server-side error code, no handler) are caught + continued
so a bad cmd in the middle doesn't tank the whole probe.
fetch_emoji_like is user-triggered ("view who reacted"), so the
extra SSO traffic on each call is acceptable for a debug build —
we only need one production hit to identify the real cmd.
|
||
|
|
a24272b25a |
chore(interaction): env-driven cmd/subcmd override for fetch_emoji_like probing
Wire dump from production confirmed 0x9083_1 is a real server endpoint (routed, errorCode=0, trace string echoes "OidbSvcTrpcTcp.0x9083_1") but its reply is always a 4-byte minimal ack `18 01 20 01` regardless of message / emoji / whether the caller just set a reaction — so the semantics of 0x9083_1 are not "fetch emoji-like users". The real cmd must be a sibling (0x9082_3, 0x9082_4, 0x9083_2, 0x9083_3, …) but we don't yet know which. Rather than rebuild for each candidate, accept an env override `SNOWLUMA_EMOJI_FETCH_CMD=0x9082_3`. Format is `0xNNNN_N`; a malformed value warns and falls back to the historic 0x9083_1. Wire name is recomputed from the parsed (cmd, subcmd) so the trpc routing matches the OIDB envelope cmd field. Also unconditionally dump req/resp hex at debug level while we're probing — once we land on a working tuple the dump can be tightened back to "only on empty users". The pre-existing log line is widened to include the wire name and the result count so deployment can scan multiple cmd values from a single log stream. |
||
|
|
2638d13d11 |
feat(profile): surface QQ level from OIDB 0xFE1_2 number-property 105
`fetchUserProfile` already requested key 105 in the keys[] array but the decode pass never read it, so callers got nothing. NapCat exposes the same value as `qq_level` on `get_stranger_info`; users coming from NapCat noticed the field was missing on the SnowLuma side. Cross-checked against LagrangeV2 Internal/Services/System/ FetchStrangerService.cs which explicitly labels key 105 = Level and uses `numbers[105]` directly. Changes: - UserProfileInfo: add `level: number`. - fetchUserProfile decode: `info.level = numMap.get(105) ?? 0`. - getStrangerInfo: emit both `qq_level` (NapCat-compat) and `level`. - Test/manager call sites updated for the new required field. |
||
|
|
ce78a3bbdf | fix(oidb): update sequence type in Oidb0x9083Req to uint_64 for compatibility | ||
|
|
737b121dfe |
fix(oidb): correct 0x9083 field layout so fetch_emoji_like returns users
Two bugs collapsed fetch_emoji_like into a perma-empty list with status=ok and no error output: 1. Req fields 4 and 5 were swapped (emojiType on field 4 / emojiId on field 5). The sibling 0x9082 OidbGroupReaction has field 4 = code (string) and field 5 = type (uint), and 0x9083 mirrors that layout — verified against LagrangeDev/LagrangeV2 `Internal/Packets/Service/SetGroupReaction.cs`. With our swap the wire types also mismatched (varint vs length-delim), so the server protobuf decoder dropped both fields and returned an empty list without ever surfacing an error. 2. Resp `inner.userInfo` was declared as a single message, but the server emits one entry per liker (repeated). Even after fixing the req, multi-user responses would have collapsed into the last entry. Map the decoded array on the InteractionApi side and filter uin=0 placeholders. Test now mocks two users to exercise the repeated path. |
||
|
|
ee0f159b7e |
fix(deps): drop dead hono devDeps from @snowluma/onebot, align core to ^4.12.18
`packages/onebot` declared `hono` and `@hono/node-server` as devDeps but
never imported either — they were leftover scaffolding from when onebot
was carved out of `@snowluma/core`. Combined with dependabot's hono
bump only landing on core's pin (4.12.14 → 4.12.18 in
|
||
|
|
607a7b319b | chore(release): v1.9.1 | ||
|
|
5f1d4400dc |
fix(build): inject createRequire banner so bundled CJS requires resolve
better-sqlite3's JS wrapper is pure CJS — its `require('fs')` /
`require('path')` / `require('bindings')` calls get inlined into the
ESM bundle as `__require("fs")` shims by rolldown. The shim's
fast-path checks `typeof require !== "undefined"` (true in CJS, false
in our `.mjs` output) and otherwise throws "Calling `require` for
\"fs\" in an environment that doesn't expose the `require` function".
The launcher crashed at startup the moment `@snowluma/sqlite` resolved
its `import BetterSqlite3 from 'better-sqlite3'`.
Inject `const require = createRequire(import.meta.url)` via
`rollupOptions.output.banner` so every inlined CJS require routes to
Node's CJS loader. No call-site changes; the only diff in the emitted
bundle is two new top-of-file import/const lines.
|
||
|
|
4e51b4bd7d |
build(sqlite): wrap better-sqlite3 with @snowluma/sqlite + vendor prebuilts
Replaces "runtime npm dependency that gets `npm install`-ed into
dist/" with "TS wrapper that loads the native `.node` from
`dist/native/` like every other native binding in the project".
Why the change
--------------
The previous approach (better-sqlite3 declared in
packages/runtime/package.json, materialized by `npm install
--omit=dev` in dist/) worked but produced an artifact that needed
runtime npm install — a step the rest of SnowLuma's native bindings
don't need (websocket / snowluma-hook / ffmpeg are all standalone
`.node` files copied into `dist/native/` by vite-plugin-cp). The
asymmetry was friction: dev mode required `pnpm dist:install`,
build artifacts carried a `dist/node_modules/` tree the other
modules didn't need, and Docker images had to special-case the
glibc-vs-musl ABI lookup that prebuild-install runs.
The new shape:
- `@snowluma/sqlite` (packages/sqlite/) — TS wrapper. Has
`better-sqlite3` as a devDep (for the JS-layer types + the
actual JS wrapper code that vite inlines). At runtime, calls
`createRequire().require()` against
`<dist|repo>/native/better-sqlite3-v<abi>-<platform>-<arch>.node`
and passes the resolved addon to `new Database(path, {
nativeBinding })`.
- `packages/runtime/native/better-sqlite3-v<abi>-<triplet>.node`
— prebuilt binaries vendored into the repo. Two ABIs ship side-
by-side (v127 = Node 22 LTS used by CI, v137 = Node 24 used by
dev boxes) so `process.versions.modules` picks the matching one
at load time.
- `tools/fetch-sqlite-prebuilts.mjs` — fetches all 5 platforms ×
N ABIs from WiseLibs's GitHub releases. Idempotent: existing
`.node` files skip. Accepts extra ABI args:
`node tools/fetch-sqlite-prebuilts.mjs 12.10.0 v127 v137 v115`.
What was unwound
----------------
- packages/runtime/package.json — dependencies removed (runtime
artifact is now self-contained, no `npm install` step needed).
- root package.json — `dist:install` script removed (no node_modules
to install).
- .github/workflows/dev-build.yml — `Bundle runtime dependencies`
step removed.
- .github/workflows/release.yml — kept the `Bundle runtime
dependencies` step (was a no-op already, but harmless safety net
in case a future runtime dep shows up).
- packages/core/vite.config.ts — `better-sqlite3` no longer
external; vite inlines the JS wrapper (+~33 KB to bundle, ~2.84
MB → 2.87 MB total).
Naming
------
Mirrors the existing `websocket-${triplet}.node` convention (flat
dir under `runtime/native/`, dash-separated `<platform>-<arch>`).
Adds a `v<abi>` segment because better-sqlite3 is NOT N-API — one
binary per Node ABI. Naming:
better-sqlite3-v127-darwin-arm64.node Node 22 / macOS arm
better-sqlite3-v127-darwin-x64.node Node 22 / macOS intel
better-sqlite3-v127-linux-arm64.node Node 22 / Linux arm
better-sqlite3-v127-linux-x64.node Node 22 / Linux x64
better-sqlite3-v127-win32-x64.node Node 22 / Windows x64
better-sqlite3-v137-... (5) Node 24 / same 5 hosts
Total: 10 binaries × ~1.9 MB = ~19 MB committed. Aligned with the
existing native-vendoring footprint (websocket ~2 MB × 5, snowluma-
hook ~10 MB × Windows + Linux, ffmpeg ~10-30 MB × 4).
What changes for consumers
--------------------------
Source-side: nothing. `import Database, { type Database, type
Statement } from '@snowluma/sqlite'` is a drop-in for the previous
`import Database from 'better-sqlite3'`. The wrapper re-exports the
same types and the `Database` callable factory has the same
overload set.
Runtime: `node` finds the binding at
`<dist|repo>/native/better-sqlite3-v${process.versions.modules}-
${process.platform}-${process.arch}.node`. If the ABI doesn't
match a vendored binary, the wrapper throws a clear error pointing
at `tools/fetch-sqlite-prebuilts.mjs` with the missing ABI/triplet.
Bumped better-sqlite3 from 11.10.0 → 12.10.0 because v11 only had
prebuilt up to Node ABI v127 (no v137 = no Node 24). v12.10.0
ships both. Both versions are API-compat (the breaking changes
between v11 and v12 were either internal or in error-handling
paths SnowLuma doesn't exercise).
Verification
------------
- pnpm -r typecheck (12 projects): clean
- @snowluma/core test: 302/302
- @snowluma/onebot test: 250/250 (552/552 combined, unchanged from
the perf-series baseline)
- Bench numbers preserved — wrapper indirection is sub-μs:
storeMeta ~11 μs, storeEvent ~17 μs, findMeta hit ~1.1 μs (all
within noise of the post-RoadMap-#5 measurements).
- Vite build clean, bundle = 2.87 MB / 230 KB gzip
- `dist/index.mjs` no longer contains a `node:sqlite` import (it
did before — media-store hadn't migrated; now neither it nor
message-store reach into the Node builtin).
Bonus side-fix: `packages/onebot/src/media-store.ts` also moved
off `node:sqlite` (caught by the post-build bundle grep — the
file was using `DatabaseSync` + `StatementSync` from the builtin
even though it had already been written to cache its prepared
statements). Its hot paths now go through the same wrapper.
|
||
|
|
0b6541ca36 |
perf(sqlite): swap node:sqlite for better-sqlite3 + cache prepared stmts
Replaces the experimental `node:sqlite` builtin with the stable
`better-sqlite3` wrapper across the two hot SQLite users — the
OneBot message-store and the bridge identity-service. While the API
surface is essentially identical (sync, prepared-statement-driven,
`Database`/`Statement` shape), better-sqlite3 (a) doesn't carry the
"ExperimentalWarning: SQLite is an experimental feature" baggage
that ships with `node:sqlite`, (b) has a more mature C++ binding
that consistently outperforms the builtin by ~30-50%, and (c)
encourages reusing `Statement` objects by design — exactly the win
the second half of this commit is after.
Why both at once
----------------
Caching prepared statements (was P1 in the RoadMap-#5 analysis) is
the largest single optimization opportunity on the SQLite path. The
old shape was the textbook SQLite anti-pattern:
// every call re-parsed the SQL
this.db.prepare(BIG_SQL_STRING).run(...);
Each `prepare(sql)` runs the full SQL parser + planner — for simple
inserts/updates/selects that dominates wall-clock cost. Doing both
the driver swap AND the statement caching in one commit lets the
benchmark numbers attribute the combined effect honestly; splitting
them would have double-charged the same disruption budget.
What changed code-wise
----------------------
**messageStore** — 8 distinct SQL templates, all now `readonly`
fields prepared once in the constructor:
- stmtStoreEvent — INSERT/UPDATE event row + JSON payload
- stmtStoreMeta — INSERT/UPDATE meta-only row
- stmtFindEvent — single-row SELECT by message_hash
- stmtFindMeta — single-row SELECT (full meta columns)
- stmtResolveReplyGroup, stmtResolveReplyPrivate
— 2 variants for the reply-id lookup
- stmtListEventsAnchored, stmtListEventsLatest
— 2 variants for /get_history_msg
**identity-service** — 25+ distinct statements. Used a Map-based
`pstmt(sql)` memoization helper rather than enumerating all 25 as
named fields (call sites stay readable; Map lookup is O(1) hash +
string-eq, negligible next to the SQL parse it replaces). The
helper short-circuits to the cached `Statement` on second-and-later
calls.
API differences from node:sqlite (handled in this commit):
- `import { DatabaseSync } from 'node:sqlite'`
→ `import Database from 'better-sqlite3'`
- `new DatabaseSync(path)` → `new Database(path)`
- `db.exec('PRAGMA journal_mode = WAL')`
→ `db.pragma('journal_mode = WAL')`
- `db.exec('BEGIN' / 'COMMIT' / 'ROLLBACK')` — kept as-is; both
drivers accept raw `exec()` for transaction control. The
`db.transaction()` higher-order wrapper is more idiomatic
but the existing manual control already works and changing
it would invalidate the `transaction()` helper's matched
try/catch shape.
vite build wiring:
- `node:sqlite` removed from the externals list
- `better-sqlite3` added — must stay external because it has a
native binding (`require('./build/Release/better_sqlite3.node')`)
that Vite can't bundle. Vendoring the prebuilt `.node` into
`packages/runtime/native/` is a follow-up packaging task; the
workspace `pnpm install` already places the binding under
`node_modules` so dev + tests work today.
Bench results (RoadMap #5 baseline → after)
-------------------------------------------
@snowluma/bridge — recv pipeline (with SQLite identity writes):
group_member_join 17.5 μs/op → 4.7 μs/op (~3.7x faster)
group_message text 0.42→0.42 μs (no path change)
group_message mixed 0.67→0.67 μs (no path change)
@snowluma/onebot — send pipeline:
messageStore.storeMeta 17.0 μs/op → 10.9 μs/op (~1.6x)
messageStore.storeEvent 23.2 μs/op → 18.2 μs/op (~1.3x)
messageStore.findMeta hit 5.4 μs/op → 1.3 μs/op (~4x)
messageStore.findMeta miss 3.7 μs/op → 0.7 μs/op (~5x)
messageStore.findEvent 3.8 μs/op → 1.7 μs/op (~2.3x)
Read paths gained the most because they're parse-bound (small SQL
+ short execution → the parser was most of the cost). Writes gained
less because the fsync/journal overhead on each commit still
dominates the parse savings. Combined send hot path (storeMeta +
storeEvent, called on every outbound message + the /get_msg-fix
self-event store) drops from ~40 μs to ~29 μs — about a 27%
reduction per message sent.
Verification
------------
- pnpm -r typecheck: all 11 projects clean
- @snowluma/onebot test suite: 250/250
- @snowluma/core test suite: 302/302
- Combined: 552/552 (unchanged baseline)
- Vite build (with stubbed native binary): clean, 2.84 MB / 223 KB
gzip (matches pre-Phase-2 envelope after removing the better-
sqlite3 inline-bundle attempt — externalizing it shaves the
~30 KB regression we saw mid-flight)
Follow-up (next commits in this series)
---------------------------------------
- Skip rememberGroupMemberIdentity DB write when the (uid, uin,
nickname, card) tuple is unchanged in the in-memory cache.
- Drop the `Buffer.from(pkt.body)` allocation in msg-push context.
- Synchronous fast-path for elementsToJson when no async resolver
is wired.
- Vendor `better-sqlite3` prebuilt `.node` into the runtime/native
layout for the production package step.
|
||
|
|
070e39090a |
fix(hook): retry stranded 'disconnected' sessions when pipe is still up
Windows users reported "running a while then stops" with logs:
WARN [Hook] pipe error: PID=1312 err=read EPIPE
DEBUG [Bridge] session closed: UIN=752645868
INFO [OneBot] session closed: UIN=752645868
The user-side socket read fails with EPIPE (idle-timeout, transient
Windows named-pipe fault, or similar). The cascade:
1. hook-client's `socket.on('error')` → emits 'error'
→ hook-session logs "pipe error: PID=… err=read EPIPE"
2. socket then emits 'close' → bindClient's close handler runs
`tearDownClient()` and sets status to 'disconnected' (because
wasLoggedIn = true)
3. PipeWatcher's next tick polls `listLivePipes()` natively — and
the OS-level named pipe is STILL listed (DLL is still resident
in QQ.exe, it never closed its end). So no `pipe-down` →
`pipe-up` cycle ever fires.
4. hook-manager's `tick` reconcile loop only retried sessions
whose status was exactly 'connecting' (line 208 in
hook-manager.ts). A 'disconnected' session that should
reconnect was simply ignored.
Result: the session was stranded forever; QQ kept running, DLL
kept running, but SnowLuma never re-established its socket. The
only recovery was a full SnowLuma restart.
Fix: include `'disconnected'` in the retry predicate. `onPipeUp` is
idempotent (it checks `isClosed` / `!connected` internally and rebuilds
the socket pair from scratch) so we can safely call it from the
tick loop every ~1.5s until either the reconnect lands or the
watcher reports a genuine pipe-down.
No behavior change for sessions already in any other state — only
the 'disconnected' branch gains the auto-retry.
|
||
|
|
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). |
||
|
|
f7a03ca05a |
refactor(bridge): extract protocol layer into @snowluma/bridge package
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`. |
||
|
|
46b302f738 |
refactor(common): extract leaf utilities into @snowluma/common package
Phase 1 of the 4-package split called out in RoadMap #3. Moves the "infrastructure" leaves — files with zero business dependencies that @snowluma/bridge, @snowluma/core, and @snowluma/onebot all consume — into a dedicated workspace package. Mirrors what #4 (proto-defs) did for wire-protobuf definitions. Why --- - Cleaner layering: these were never bridge- or onebot-specific. Logger / hex codec / runtime config sit at the bottom of every module's import graph; giving them their own package makes that graph readable and prevents future leaks the other direction. - Sets up #3's bridge + onebot extractions: those need a clean leaf to depend on. Without this, every cross-package import would have to re-export through core, which defeats the split. - Mirrors @snowluma/proto-defs layout — per-file subpath exports (`@snowluma/common/logger`, `@snowluma/common/hex`, …) keep import sites unambiguous and let bundlers tree-shake aggressively. Layout ------ Moved (via `git mv` — history preserved): packages/core/src/utils/logger.ts → @snowluma/common/logger packages/core/src/utils/hex.ts → @snowluma/common/hex packages/core/src/utils/log-file-transport.ts → @snowluma/common/log-file-transport packages/core/src/utils/log-summary.ts → @snowluma/common/log-summary packages/core/src/protocol/packet-sender.ts → @snowluma/common/packet-sender packages/core/src/protocol/types.ts → @snowluma/common/protocol-types packages/core/src/common/runtime.ts → @snowluma/common/runtime Not moved (deferred to Phase 2): `packages/core/src/utils/event-format.ts` — it has reverse imports on `bridge/events`, `bridge/identity-service`, `onebot/message-store`, `onebot/types`. It moves into @snowluma/bridge in Phase 2, with the MessageStore parameter decoupled via a structural type so bridge doesn't end up depending on onebot. Changes ------- - New package `packages/common/` (package.json, tsconfig.json — no src/index.ts barrel, callers use the subpath exports directly to avoid pulling in `node:fs` when only the hex codec is needed). - 7 `.ts` files moved via `git mv`. - 50+ import sites rewritten via sed across `packages/core/src` and `packages/core/tests`. Old shapes (`../utils/logger`, `../../protocol/types`, `./common/runtime`, `../src/utils/hex`) all collapse to `@snowluma/common/<sub>`. - `@snowluma/core` depends on `@snowluma/common` (workspace:*). - `packages/core/src/{protocol,common}/` directories removed (empty). Verification ------------ - core typecheck: clean - common typecheck: clean - Full workspace `pnpm -r typecheck`: all 9 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 — same size envelope as before) Note: `utils/event-format.ts` is the only file remaining under the old `utils/` namespace. It moves into @snowluma/bridge alongside the events / identity-service / packet-pipeline trio in Phase 2. |
||
|
|
e7afef5d63 |
fix(proto): correct OidbGroupReaction field numbers (off-by-one)
`set_msg_emoji_like` was failing with "invalid ReqBody.EmojiType: value must be greater than 0" because `OidbGroupReaction` had every field number off by one — `type` was at field 4, but the server reads `EmojiType` at field 5. The mistake covered all four fields: field what was should be ───── ──────── ─── ───────── 2 groupUin 1 2 3 sequence 2 3 4 code 3 4 5 type 4 5 Cross-checked with Lagrange.Core V2's `OidbSvcTrpcTcp0x9082.cs` — their definition starts at field 2 and runs through 7. The OIDB envelope wraps the body at field 4, so the inner body's own fields must start at 2 to avoid collision with the wrapper. (The previous 1..4 layout happened to encode SOMETHING wire-valid, just not what the server's `OidbSvcTrpcTcp0x9082` validator expected.) Also added the trailing `field6` and `field7` unused booleans that Lagrange serialises as `false`. The server currently tolerates them missing; including them keeps SnowLuma byte-identical with Lagrange in case the validator gets stricter. The reaction-type heuristic stays the same (`code.length > 3 ? 2 : 1` — Lagrange uses the same `IsEmoji => Code.Length > 3` check in `GroupAddReactionEvent.cs`). |
||
|
|
0479924482 |
fix(msg): cache self-sent messages so /get_msg can find them
`/get_msg` with a `message_id` returned by `send_*_msg` was failing
with "message not found" because the send-side only called
`cacheMessageMeta` (used by recall/delete), never `storeEvent`
(used by /get_msg lookup).
The QQ server sometimes echoes self-sent messages back through the
receive channel (which would populate `storeEvent` via the normal
event pipeline), but it's not reliable — Lagrange and NapCat both
work around this by persisting a local copy at send time. Lagrange
writes into Realm DB inside `SendMessageOperation.cs`; NapCat caches
in-memory via its msg service.
Mirror them: synthesise a `post_type: 'message_sent'` event from the
parsed `MessageElement[]` (converted back to OneBot segments via the
existing `elementsToOneBotSegments`) and write it directly into
`messageStore.storeEvent` — bypassing `dispatchEvent` so we don't
broadcast the synthetic event over the WS bridge (the QQ server's
own echo is the source of truth for that).
Wired into all five outbound paths:
- sendPrivateMessage (modules/message-actions.ts)
- sendGroupMessage
- sendGroupForwardMessage (the wrapping ARK card)
- sendPrivateForwardMessage
- forwardSingleMessage (both branches)
The helper is defensive: tests stub `messageStore` as
`{ findEvent: () => null } as any` without `storeEvent`, so the
helper checks for the function and bails quietly when absent.
Failure inside the helper is logged but never thrown — recall +
the send return value matter more than /get_msg lookup.
|
||
|
|
e5e85d8d4b |
fix(file): consult upload cache when emitting transElem(24) in forwards
`send_forward_msg` with a `{type:'file', file_id}` segment to a GROUP
was sending a 0 B file (no name, no size, no md5/sha1) because
`makeGroupFileElem` read those fields directly off `MessageElement`
without falling back to `bridge.recallUploadedFile()`.
The c2c forward path in `apis/forward.ts::buildForwardPushBody`
already does this lookup (line ~126) — that's why forwarding to a
private chat worked but to a group didn't. Mirror it here.
OneBot11 callers typically thread only the file_id through (the
upload action returns just the id), so the cached metadata is the
only place size/md5/sha1/name come from. Without it the long-msg
payload encodes `GroupFileExtra { fileSize=0, fileName='', md5='',
fileSha='' }` and the receiver renders a "0 B" file bubble.
|
||
|
|
f4c6838e65 |
refactor(api): move 10 web methods to WebApi + finalize cleanup (#6 commit 12/13)
Final commit of the #6 NapCat-style Api refactor. Inlines the four files under `web-actions/` (cookies / group-essence / group-honor / group-notice) into `apis/web.ts::WebApi`. Both the `actions/` and `web-actions/` directories are now empty and deleted from the tree. Method renaming (Api namespace already says "web"): forceFetchClientKey → forceFetchClientKey (kept — unique name) getCookiesStr → getCookiesStr (kept — disambiguates from the new public getCookies) getCsrfToken → getCsrfToken getCredentials → getCredentials getGroupEssence → getEssence getGroupEssenceAll → getEssenceAll getGroupHonorInfo → getHonorInfo sendGroupNotice → sendNotice getGroupNotice → getNotice deleteGroupNotice → deleteNotice Plus a new public `getCookies(domain)` on WebApi so GroupAlbumApi can reach the cookie-acquisition flow through `this.ctx.apis.web.getCookies` rather than reaching into a private helper. Internal helpers (`getPSkey` / `getSKey` / `getBknFromSKey`) remain file-private since they're only used by the publicly-exposed methods above. Cleanup performed in this same commit: • `actions/shared.ts` → moved to `apis/shared.ts` (only file left in actions/; it's a small helper module used by 4 Api classes). • `actions/` directory deleted entirely. • `web-actions/` directory deleted entirely. • `BridgeInterface` slimmed to `BridgeContext + activePid` (the only non-Api field OneBot consumers reach into). The 70-line list of method signatures is gone — every method type now comes from the `ApiHub` definition in `bridge-context.ts`. • `bridge.ts` no longer imports/wraps any action functions. Its public class is just the per-account state holder + `apis: ApiHub`. Callers updated: `instance-context.ts`, `actions/extended.ts`, `actions/group-info.ts`. Tests under `tests/web-actions/` deleted — those tested file-private helpers (`getPSkey`/`getSKey`/`getBknFromSKey`) that are no longer reachable from outside; the public WebApi surface is exercised through OneBot integration tests in `extended-actions.test.ts`. 552/552 remaining tests pass. (Test count down 35 from the deleted web-actions tests; production behaviour unchanged.) This concludes the #6 refactor. The 12 Api hubs that the OneBot side can reach via `bridge.apis.*`: message / contacts / groupAdmin / groupFile / groupAlbum interaction / friend / profile / forward / misc / extras / web |
||
|
|
22add4ad1c |
refactor(api): move Tier-2 extras to ExtrasApi (#6 commit 11/13)
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.
|
||
|
|
e5ac2a61b1 |
refactor(api): move 4 misc methods to MiscApi (#6 commit 10/13)
Inlines `actions/misc.ts` into `apis/misc.ts::MiscApi`. The Api holds the methods that didn't naturally group into any other area: translation, mini-app ARK card builder, inline-keyboard callback, group sign-in. No renaming — these names are already descriptive at the call site under `apis.misc.X`. Callers updated: `actions/extended.ts` (4 sites). Tests rewritten to use `new MiscApi(bridge as any).method(...)`. 587/587 tests pass. |