PR #31 landed 4 TRPC group-album actions on the legacy runtime
schema encoder. Migrate them to the compile-time codec, matching
the pattern established by the OIDB migration: TS interfaces in
`proto/proton/oidb-action.ts` are monomorphized at build time and
each `protoEncode/Decode` call site now calls `protobuf_encode<T>` /
`protobuf_decode<T>` directly.
- Add 33 proton interfaces for the new TRPC envelopes (GetMediaList,
DoQunComment, DoQunLike, DeleteMedias, plus all nested types) —
inline anonymous schemas in legacy lifted to named interfaces.
- Migrate `actions/group-album.ts` to the proton API; drop the
`(resp as any)` casts now that decoded responses are typed.
- Add 4 wire-byte parity tests for the new envelopes; non-default
field values throughout so byte equivalence holds (proto3 omission
of defaults is the documented divergence).
- Drop dead `GroupAvatarUploadExtSchema` from `proto/highway.ts` and
unused `import * as console from "node:console"` in
`web/group-album.ts` — leftovers from the original PR review.
core: 609/609 tests pass, typecheck clean.
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
Phase 2 of the OIDB migration: one-to-one mirror of every schema in
bridge/proto/oidb-action.ts as a proton interface. Purely additive —
the legacy `*Schema` constants stay in place; no call sites are
switched yet.
Translation is mechanical:
- export const XxxSchema = { ... } satisfies ProtoSchema;
→ export interface Xxx { ... }
- { field: N, type: 'uint32' as const } → pb<N, uint_32>
- { field: N, type: 'repeated_message',
schema: YSchema } → pb_repeated<N, Y>
- … (etc, for every primitive + message + repeated_*)
All section headers (`// --- 0x1253_1: Mute group member ---`) and
inline NTQQ-specific comments (the FE1_2 field-2 note, the NTV2
default-value note, the Lagrange-observed annotations on
GroupAvatarExtra, etc.) preserved verbatim.
Verification:
- 183 interfaces emitted, exact match to the legacy schema count
- tsc --noEmit clean
- 604/604 core test suite passes (no behaviour change — additive
only)
Phase 3 next: refactor runOidb to use protobuf_encode<OidbBase<TReq>>
and migrate the 67 runOidb call sites across 10 files + pipeline.ts.
Phase 1 of the OIDB migration: stages the proton-side type definitions
and pins their wire equivalence to the legacy makeOidbBaseSchema()
runtime factory. No call sites are switched yet — that lands in
phase 2 along with the proton/oidb-action.ts schema mirror and the
runOidb refactor.
The legacy bridge/proto/oidb.ts exports a `makeOidbBaseSchema(body)`
factory function that synthesizes the OidbSvcTrpcTcpBase envelope
ProtoSchema at runtime. Proton is compile-time, so the natural form
is the generic interface
OidbBase<T> { command, subCommand, errorCode, body: T, errorMsg,
properties, reserved }
which the plugin monomorphizes per-instantiation. This is exactly the
"generic template with generic-instantiated field" usage that this
package's earlier edge-case fixes#2 and #3 were designed to support,
and the new parity test pins that they actually work on the real
shape (a fully populated OidbSvcTrpcTcp0xFE5_2Response with nested
groupOwner/customInfo).
Also added:
- OidbProperty (for the envelope.properties repeated field)
- OidbBaseMeta — envelope-only view for fire-and-check OIDB calls
that just need to peek at errorCode without decoding the body
- All 8 concrete schemas from oidb.ts (FriendList, GroupList,
GroupMemberList, GroupRequest families)
Verification:
- 2 new parity tests
* OidbBase<OidbSvcTrpcTcp0xFE5_2Response> ↔ makeOidbBaseSchema
byte equivalence
* round-trip encode → decode preserves nested body
- 602+2 = 604 core tests pass (network-manager file still env-fails
on missing native binary)
- tsc --noEmit clean
Mirrors all 35 schemas in bridge/proto/highway.ts as proton interface
forms and switches the two non-OIDB-wrapped consumers
(highway/highway-client.ts and actions/group-file.ts) over to the new
compile-time API.
Migrated schemas (proton/highway.ts):
DataHighwayHead, SegHead, LoginSigHead, ReqDataHighwayHead,
RespDataHighwayHead, HighwayDomain, HighwayIPv4, HighwayNetwork,
HighwayHash, HighwayMsgInfoBody, NTV2RichMediaHighwayExt,
FileUploadUrl, FileUploadHost, FileUploadHostConfig,
FileUploadNameInfo, FileUploadClientInfo, FileUploadFileEntry,
FileUploadBusiInfo, FileUploadEntry, FileUploadExt,
EncodableMediaExtBizInfo, EncodableMediaMsgInfo, HttpConn,
HttpConn0x6FF501Request, ServerAddr, ServerInfo, HttpConnResponse,
HttpConn0x6FF501Response, NTV2FileType, NTV2FileInfo,
NTV2UploadInfo, NTV2ExtBizInfo, NTV2UploadReq, NTV2C2CUserInfo,
NTV2GroupInfo, NTV2UploadSceneInfo, NTV2CommonHead, NTV2ClientMeta,
NTV2UploadReqHead, NTV2UploadRichMediaReq, NTV2IPv4,
NTV2SubFileInfoResp, NTV2UploadRespMsgInfo, NTV2UploadRespBody,
NTV2UploadRespHead, NTV2UploadRichMediaResp
Consumer switches:
- highway/highway-client.ts: protobuf_encode/decode for HttpConn
session fetch, ReqDataHighwayHead frames, NTV2RichMediaHighwayExt
extend bytes, RespDataHighwayHead per-chunk responses
- actions/group-file.ts: protobuf_encode<FileUploadExt> for both
group + private file upload ext bytes
NOT migrated (depends on the OIDB factory pattern):
- highway/pipeline.ts — uses makeOidbBaseSchema(NTV2UploadRichMediaReqSchema)
to wrap the NTV2 upload in an OIDB envelope at runtime. Waits for
the oidb.ts migration to land a generic OidbBase<T> form.
Verification:
- 5 new parity tests for highway schemas (HttpConn0x6FF501 req+resp
with the high-numbered 0x501 tag, ReqDataHighwayHead with multiple
nested messages, NTV2RichMediaHighwayExt with repeated_bytes,
FileUploadExt with the 100/200/400/600/700 high-numbered tags)
- 602/602 core test suite passes — the existing image / ptt / video
upload tests implicitly validated wire format equivalence
- tsc --noEmit clean
Mirrors all 11 schemas in bridge/proto/action.ts as proton interface
forms and switches the two consumers (element-builder.ts and the
send-message paths in bridge.ts) over to the new compile-time API.
Migrated schemas (proton/action.ts):
RoutingC2C, RoutingGroup, RoutingHead, SendContentHead,
MessageControl, SendRichText, SendMessageBody, SendMessageRequest,
SendMessageResponse, MentionExtraSend, MarkdownData
Consumer switches:
- element-builder.ts: protobuf_encode<MentionExtraSend> for @ mention
pbReserve payload; protobuf_encode<MarkdownData> for markdown elem
- bridge.ts: protobuf_encode<SendMessageRequest> +
protobuf_decode<SendMessageResponse> for both sendGroupMessage and
sendPrivateMessage
The `ProtoElem` type alias in element-builder.ts (Partial<ProtoDecoded<typeof
ElemSchema>>) stays as-is — it's a pure TS type used internally for
building elem objects and doesn't affect runtime behaviour. Replacing
it with the proton Elem type is a larger refactor that can come later
when there's a reason to.
Verification:
- 6 new parity tests for action schemas (RoutingHead c2c+grp,
SendMessageRequest full shape, SendMessageResponse, MentionExtraSend,
MarkdownData)
- 591/591 core test suite passes — the existing send-message and
element-builder tests implicitly validated wire format equivalence
- tsc --noEmit clean
Completes the proton migration started in the previous PoC commit. All
schemas needed by actions/forward.ts are now mirrored as proton interface
forms, the legacy ProtoSchema constants stay in place for back-compat,
and forward.ts has been switched over to the new compile-time codecs.
Migrated schemas:
- element.ts (35 schemas: TextElem, FaceElem, OnlineImage, NotOnlineImage,
TransElem, MarketFace, CustomFace, RichMsg, GroupFileElem, ExtraInfo,
VideoFile, SrcMsg, LightAppElem, CommonElem, GeneralFlags, Elem,
MentionExtra, QFaceExtra, QSmallFaceExtra, FileType, FileInfo,
IndexNode, PicUrlExtInfo, PictureInfo, PicExtData, PicExtBizInfo,
VideoExtBizInfo, PttExtBizInfo, ExtBizInfo, C2cSource, TroopSource,
HashSum, MsgInfoBody, MsgInfo, GroupFileInfo, GroupFileExtraInner,
GroupFileExtra, Preserve, …)
- message.ts (PushMsg, PushMsgBody, ResponseHead, ContentHead, Ptt,
NotOnlineFile, RichText, MessageBody, FileExtra, …)
- longmsg.ts — the three remaining schemas (LongMsgContent / LongMsgAction
/ LongMsgResult) that depend on PushMsgBody
forward.ts now calls:
protobuf_encode<LongMsgResult>(...) // upload payload
protobuf_encode<SendLongMsgReq>(...)
protobuf_decode<SendLongMsgResp>(...)
protobuf_encode<RecvLongMsgReq>(...)
protobuf_decode<RecvLongMsgResp>(...)
protobuf_decode<LongMsgResult>(...) // download payload
protobuf_encode<PushMsg>(...) // re-wrap for parseMsgPush
The unused legacy schema constants are intentionally left in place — they
still back the existing forward.test.ts mock encoder and any other consumer
that hasn't been migrated yet. They will be removed in a follow-up once
every consumer is on the new API.
Verification:
- 18/18 parity tests pass (encode + decode equivalence for every
representative shape across element / message / longmsg)
- 585/585 core test suite passes (network-manager.test.ts fails to load
due to a missing native binary in this environment — pre-existing)
- tsc --noEmit clean
Standalone tsx script benchmarking the codecs generated by
@snowluma/proton against the existing src/protobuf/decode.ts runtime
implementation, using the actual long-message schemas already in the
codebase (the proton interface form + the legacy *Schema constants).
Proton codecs are materialised at bench startup via the public
analyzeSource + generateCode API — exactly what the Vite plugin does at
build time — so there is no chicken-and-egg dependency on a Vite build.
Headline numbers on Node 24 / darwin-arm64:
- simple 4×uint32 encode: ~8.5× speedup
- simple 4×uint32 decode: ~35× speedup
- deeply nested SendLongMsgReq encode: ~9.5×, decode: ~13×
- 64-byte payload RecvLongMsgResp encode: ~4.3×, decode: ~10×
Run with:
pnpm --filter @snowluma/core exec tsx bench/proton-vs-legacy.ts
Covers quick start, primitive types, generic templates, cross-file
wrapper bindings, the proto3 default-value divergence vs the legacy
runtime encoder, build-time diagnostics, and the optional runtime-map
fallback. Also lists every edge case fixed on top of upstream with a
pointer to the corresponding regression test.
Migrates a self-contained subset of bridge/proto/longmsg.ts to the new
@snowluma/proton compile-time DSL form and verifies wire-format parity
against the legacy runtime ProtoSchema encoder. End-to-end proof that
the toolchain works in core's vitest pipeline.
What's covered:
- LongMsgUid / LongMsgSettings / SendLongMsgInfo / SendLongMsgReq /
SendLongMsgResp / SendLongMsgRespResult / RecvLongMsgInfo /
RecvLongMsgReq / RecvLongMsgResp / RecvLongMsgRespResult
- The three schemas that pull in PushMsgBody (LongMsgContent /
LongMsgAction / LongMsgResult) stay on the legacy API for now, since
migrating them requires also migrating message.ts and element.ts.
Toolchain changes:
- @snowluma/proton gets a tsup build step. The ./vite subpath now
points at the built dist/index.js so it is loadable as plain Node
ESM from vite.config.ts / vitest.config.ts (the source-only .ts
layout can't be consumed from outside the Vite transform pipeline).
The . (runtime) subpath stays at TS source — it's only ever
imported from user code, which IS Vite-transformed.
- prepare script ensures dist/ exists after pnpm install.
- @snowluma/core depends on @snowluma/proton (workspace:*) and adds
protobufVitePlugin() to its vitest config so test files get
transformed.
Documented divergence:
proton follows proto3 semantics: fields equal to their default value
(0 / false / "" / empty bytes) are NOT emitted on the wire. The legacy
runtime encoder always emits them. Both forms are round-trippable
through a conforming proto3 reader. The parity test pins this
behaviour explicitly.
8/8 parity tests pass.
Vendors SnowLuma/protobuf-dsl into the monorepo as @snowluma/proton, with
edge-case fixes applied on top of the upstream source. Reference copy of
the unmodified upstream is kept under dev/protobuf-dsl/.
Edge cases fixed (each with a regression test):
- Aliased pb / pb_repeated marker imports (`import { pb as P }`)
— collector now resolves the marker name through the import resolver
- Concrete interface with a generic-instantiated message field
(`wrapped: pb<5, Wrapper<uint_32>>`) — field typeName now resolves
to the mangled form, and the inner instantiation is queued for
monomorphization
- Generic template with a generic-instantiated field
(`interface Outer<U> { x: pb<5, Wrapper<U>> }`) — the inner
instantiation is now re-substituted and re-monomorphized at outer
instantiation time
- Wrapper-binding synthetic SourceFile was empty-text — getText()
returned garbage, breaking mangled name computation downstream
- Cross-file forwarded wrappers (chain → imported base) were not
detected — parseFileForDefinitions now resolves imports recursively
(value imports only, with cycle protection)
- matchForwardedKnownWrapper did not propagate typePattern — chain
treated <X> as the encoded type instead of Wrapper<X>
- Synthetic-SF WeakMap was private to analyzer.ts — replacer could
not see it. Moved to utils.ts so typeNodeToMangledName picks it up
automatically.
Defensive guard: any field whose typeName resolves to neither a primitive
nor a known message now throws at analyze() time, instead of silently
leaking the WireType.Varint placeholder into codegen.
95/95 tests pass; tsc --noEmit clean.
Introduce a new @snowluma/protobuf package (v0.0.4) that provides a lightweight protobuf-ts wrapper (NapProto/NapProtoMsg) with type helpers, ProtoField factory, and encode/decode utilities. Adds package.json, tsconfig.json, and the implementation at src/NapProto.ts. pnpm-lock.yaml was updated to include the @protobuf-ts/runtime dependency resolution.
Adds structured YAML issue forms (bug + feature) and a config that
disables blank issues and links to the QQ group / Telegram channels
already in README.
Follow-up to c62f517. Dropping the obsolete field 2 from the body was
necessary but insufficient: newer QQ NT still rejects the request with
[oidb] one of uid/openid is invaild
The actual trigger is the OIDB envelope-level `reserved` flag.
Lagrange.Core's FetchStrangerByUin sets `Reserved = 1`, our call sent
`Reserved = 0`. The server uses that bit to pick its validation path:
- reserved=1 → UIN-form variant: server translates body.uin to the
internal uid itself
- reserved=0 → UID-form variant: server expects a uid/openid string
in the body and rejects when it can't find one
Our request body has only `uin` + `keys`, so the UID-form path was
always going to fail. Setting `isUid: true` (which is the existing
flag that flips envelope `reserved` to 1; the parameter name is
historical and misleading) puts the call onto the correct branch.
Applied to both call sites that target OidbSvcTrpcTcp.0xfe1_2:
- fetchUserProfile (bridge-contacts.ts) — fixes the
"failed to load self profile for UIN N" warmup warning.
- getStrangerStatus (actions/extras.ts) — same OIDB cmd; would
have failed identically if exercised against newer QQ NT.
Also updated the `isUid` JSDoc on OidbCall to spell out the actual
semantic (UIN-form variant marker, not "this call uses UID-keyed
data") since the name will keep tripping people otherwise.
The adapter edit dialog now ships a dedicated TokenField for the
access-token slot instead of reusing the plain text Field. It:
- defaults to a password-style input so the value stays out of
casual screen reads / screenshots,
- exposes an Eye / EyeOff button that flips the input type rather
than masking the string, so paste / autocomplete still behave like
a native password field,
- exposes a Copy button that pushes the current value to
navigator.clipboard, flashing a check icon for ~1.4 s on success,
and disables itself when the value is empty,
- falls back to a transient textarea + document.execCommand('copy')
for non-secure-context environments (LAN HTTP outside localhost),
routed through a local alias so the deprecation warning on the
live signature doesn't fire at the call site.
Follow-up to a07521d. The reporter's correction (895 friends / 221
groups account) was that turning off only field5003 + all of config2
wasn't enough — field5002 has to go too, otherwise QQ NT still emits
a response large enough to blow the named-pipe write buffer and
fetchGroupList dies with EPIPE.
The response decoder (OidbSvcTrpcTcp0xFE5_2GroupInfoSchema) only knows
about tag numbers 1/2/3/4/5/18/19/30, and fetchGroupList itself only
reads groupName / memberCount / memberMax / customInfo.remark off the
result. Tag 5002 isn't in the response schema and isn't consulted by
any caller, so silencing it on the request side is purely a payload
reduction with no observable behavior change.
A11y checkers (axe-core / jsx-a11y) flag the boolean form because the
ARIA spec defines aria-checked as the enum "true" | "false" | "mixed",
not a boolean. React serialized the boolean to the right wire value at
runtime, but the inspection layer keys off the JSX expression's static
type. Switching to the explicit ternary silences both.
The original page stacked four expanded network sections vertically with
inline editing, so the first view drowned in every field across every
adapter at once. Restructured to a sidebar + tabs + modal layout:
- Account sidebar collapses to a 56px avatar column with name/UIN
tooltips; expanded it keeps the original 260px nickname + UIN layout.
- Right pane swaps the stacked sections for a 5-tab strip (通用设置 /
HTTP API / HTTP 客户端 / WS 服务端 / WS 客户端) with a per-tab badge
count.
- Each network tab is a list of summary cards: name + projected key
info (host:port/path, url · role, etc.) + format/report/token badges
+ inline enable toggle + edit/delete buttons. No live-typed fields.
- Create and edit both route through a single NodeEditDialog with
kind-specific field strips; cancel truly discards the draft.
- Per-kind metadata (title, summary projector, defaultEntry factory)
lives in config/defaults.ts so adding a new adapter kind only needs
one descriptor entry.
Behavior preserved end-to-end: same per-UIN config payload, same
useOneBotInstanceConfig hook, same dirty-switch confirm flow, same
save endpoint.
QQ has been pushing Event0x2DC subType=16 every time someone tapped
an emoji on a message in a group, and we were dropping it silently
(only surfacing the PR5 [MsgPush.Unknown] debug line that prompted
this issue). Now we decode it into a first-class event and fan it
out to OneBot clients.
Wire details (cribbed from NapCat's GroupReactNotify transformer):
- body.msgContent has a 7-byte magic prefix; strip before decoding.
- GroupReactNotify { groupUin(4), field13(13), groupReactionData(44) }.
- field13 is a discriminator — same subType is reused for other
notify variants, only 35 means "emoji react". Other values now
fall through to MsgPush.Unknown so we still spot future variants.
- Inside groupReactionData (3-level nested data wrapper, ugh):
code emoji id (system face = numeric str, market = code)
count usually 1
operatorUid needs identity.findUinByUid to resolve to uin
type 1 = add, else = remove
target.seq msg_seq of the reacted-to message
New event kind `group_msg_emoji_like` flows through the standard
pipeline: bridge event → event-bus → converter → OneBot wire.
Converter emits the gocqhttp / NapCat extension shape:
notice_type: group_msg_emoji_like
sub_type: add | remove
group_id, user_id, operator_id, message_id, message_seq,
likes: [{ emoji_id, count }]
message_id is reconstructed via hashMessageIdInt32(msgSeq, groupId,
GROUP_MESSAGE_EVENT) so it matches whatever id the bot originally
handed out for the message being reacted to, even if it's no longer
in the message store.
formatEvent also gets a case so the [Event] log reads cleanly:
表情回应 [群名(123)] | [Alice(99999)] +[76] msgSeq=4242
7 new tests cover the happy path, type=2 (remove), field13!=35
(routed to MsgPush.Unknown), prefix-too-short edge, converter
shape both for add and remove, and the formatEvent string.
closes#42
On accounts with ~200+ groups, fetchGroupList was asking QQ to fill
in every config flag (all 32 bools in config1 plus all 8 in config2)
even though our response decoder reads only groupName / memberMax /
memberCount / customInfo.remark / a handful of named info fields.
QQ NT obligingly packs every requested field into the response. At
group counts >= ~200 the payload is large enough to blow the
named-pipe write buffer on the QQ side; SnowLuma reads EPIPE and
the entire session dies during warmup:
WARN [Hook] pipe error: PID=23928 err=read EPIPE
INFO [OneBot] session closed: UIN=...
WARN [OneBot] failed to load groups for UIN ...: qq_hook client closed
Issue #42 reporter (airline233) bisected the breakage to two
clusters of unused bool flags:
- config1.field5003 (a 5xxx-range expansive descriptor)
- the whole of config2 (per-group aux info we don't decode)
This commit applies exactly their verified safe combination and
inlines a comment so nobody flips them back on without context.
The decoded response shape (groupName, memberCount, memberMax,
remark, etc.) is unchanged.
QQ now rejects fetchUserProfile() with:
[oidb] one of uid/openid is invaild
The cause: SnowLuma's request schema declared field 2 as a
zero-valued uint32 (`field2: 0`) and our encoder doesn't skip 0
varints. Newer QQ NT versions reinterpret field 2 of OIDB 0xFE1_2 as
`uid: string` / `openid: string`, so a varint 0 there decodes as an
empty/invalid uid → server bounces the whole request.
NapCat's equivalent transformer (Oidb.0XFE1_2.ts) emits only `uin`
(field 1) and `key[]` (field 3) — nothing in field 2. Match that
shape exactly.
Effect: warmUpBridgeState's "self info via profile" fallback path
now succeeds for accounts that QQ omits from the friend list, so
the WebUI shows their nickname instead of leaving it blank.
Even the login page leaks "there's a SnowLuma admin panel at this
IP / hostname" to anyone scanning, and right now that page is
publicly indexable. Three overlapping signals so we don't depend on
any one being honoured:
1. Hono middleware on every response sends
X-Robots-Tag: noindex, nofollow, noarchive, nosnippet, noimageindex
Hardest for compliant bots to ignore — it's a header, not
content. noimageindex also covers logo.png / avatar/* from
showing up in image search.
2. A GET /robots.txt route returns "User-agent: * / Disallow: /"
for crawlers that read it before fetching pages. Sits before
the auth middleware so no token is required (otherwise the
crawler would get 401 here, which it'd then refuse to obey).
3. <meta name="robots"> (plus googlebot / bingbot) in index.html
as a fallback for the case where a reverse proxy strips the
X-Robots-Tag header.
The console / subscriber level was captured at module load
(`const MIN_LEVEL = resolveMinLevel()`), so any change to
SNOWLUMA_LOG_LEVEL after Node started had no effect — restart was
the only way to flip levels, which makes "go to debug for 30s to
see what's happening" pretty painful.
logger.ts now keeps `currentLevel` mutable and exports
setLogLevel() / getLogLevel(). The env var still seeds it on
startup; nothing about the boot-time behavior changes. File
transport remains unaffected — it always sees debug-and-up.
WebUI gets two new endpoints, both behind the existing auth
middleware:
GET /api/logs/level → { level, levels }
POST /api/logs/level body → { level: 'debug' } updates it
Logs page picks up a "服务端级别" row of pills next to the
existing client-side filter pills, so an operator can flip the
console firehose on/off in one click without touching env vars
or restarting the bot. The label clarifies that file logging
stays at debug regardless.
After native unload returns, the verification check
if (pipeWatcher.isPipeLive(pid)) { ... unload-failed message ... }
read PipeWatcher's cached snapshot, which is updated every 1500ms.
On Windows clicking "Unload" in WebUI almost always tripped this:
the native unload succeeded and the kernel pipe object was gone, but
the watcher's cached "live" state from the previous tick was still
fresh, so the session reported "DLL卸载失败:命名管道仍然存在" even
though the DLL was successfully unloaded. The user then sees the
status flip back to connecting and waits for a real (cached) refresh.
PipeWatcher already exposes tickNow() for exactly this situation —
the load path doesn't need it because injection's verification runs
async via onPipeUp, but unload verifies inline.
HookSessionDeps.pipeWatcher gains an optional `tickNow` and
unloadInternal awaits it before reading isPipeLive. Existing test
mocks that pass only `{ isPipeLive }` keep working (optional chain
is a no-op there). New regression test covers the production
scenario — stale-true cache flipped to false by tickNow.
The previous commit (257686e) widened printEvent's signature to take
IdentityService but only updated the synchronous call at line 97.
The async post-refresh dispatch path (line 127, called when a group
member join arrives without a resolved uin) still used the old
two-arg form, which CI caught as TS2554. Local tsc somehow didn't
flag it on the editing machine.
Both sites now pass this.deps.identity so the readable rendering
applies to both prompt and post-refresh dispatch paths.
Notice events (recall / join / leave / poke / admin / mute / essence
/ request / invite) and the received-message log now resolve group
IDs and user IDs against the Identity cache so output reads as
human names instead of raw QQ numbers.
Before:
[Event] 群撤回 12345 | 67890 被 99999 撤回
[Event] 群 12345 | NickName(67890): ID:42 你好 [图片]
After:
[Event] 群撤回 [SnowLuma群(12345)] | [Alice(67890)] 被 [Bob(99999)] 撤回
[Event] 群 [SnowLuma群(12345)] | [Alice(67890)]: ID:42 你好 [图片]
Reply segments now expand one level via messageStore.findEvent —
"[回复:42]" becomes "[回复 [Alice(100)]: 早安]" when the original
is still in the message store, falling back to the bare id when it
isn't.
New file utils/event-format.ts owns:
- formatGroup / formatUser identity-aware name resolution
- formatMessageSegments shared element preview (replaces
the inline switch in
logReceivedMessage)
- formatReply one-level reply chain expansion
- formatEvent notice-class event renderer
Every formatter wraps its lookup in try/catch and degrades to the
prior numeric form on cache miss / throw, so log formatting can
never crash the calling path. The QQ numeric ID is preserved
alongside the name so third-party parsers that scrape uids still
find them.
26 unit tests cover cache hit / miss, malformed input, throw
recovery, and per-event-kind rendering shapes.
Two related cleanups so unexpected failures stop being invisible:
void-promise wrappers
Every `void this.something()` fire-and-forget call now has a
.catch(err => log.warn(...)) attached. Affected sites:
- packet-pipeline.ts: dispatchAfterIdentityRefresh
- instance.ts: openAll / applyConfigDiff / closeAll / emitEvent
- http-post-adapter.ts: postEvent
- ws-client-adapter.ts: handleApiMessage
Most of the network-manager fan-outs already isolate per-adapter
errors, but the outer catches guard against future refactors and
surface stack traces immediately rather than as unhandled rejection
warnings.
silent-catch sweep (Bridge.Web)
bridge/web/group-honor.ts, group-essence.ts, group-notice.ts had
`} catch (e) { return undefined; }` patterns that turned every QQ
Web API failure into a silent empty response. Now log.warn with
the full stack first, then return the same fallback so behavior is
unchanged. The commented-out `// console.error(e)` debris is gone.
Plus one console → logger conversion in message-parser.ts (music
sign fallback path).
Highway upload path now emits per-type entry + shared pipeline ops:
DEBUG [Highway.Image] uploading 234567 bytes md5=abc12345... → group 12345
DEBUG [Highway] image OIDB requires bytes, PUT 234567 bytes (sub=top)
DEBUG [Highway] image PUT done in 1234ms
-- or --
DEBUG [Highway] image fast-upload hit (server already had bytes)
The three pipeline-side lines come from runNtv2Upload() and are
shared across image/video/ptt; the per-type entry log lives in each
*-upload.ts wrapper so the scope identifies the media kind. md5 is
truncated to its first 8 hex chars for line width; PUT latency is
reported in ms.
MsgPush silent dispatch defaults are now visible at debug:
DEBUG [MsgPush.Unknown] no decoder for PkgType=999 subType=0
DEBUG [MsgPush.Unknown] Event0x2DC unknown subType=42
DEBUG [MsgPush.Unknown] Event0x210 unknown subType=42
Captures protocol drift (new NT QQ subtypes) and "we forgot to
register this PkgType" oversights, neither of which used to leave
any trace in the log.
All new lines are debug-level so console output (default info) stays
unchanged; file transport sees them for post-mortem.
Every OneBot action call now emits a single debug line under
[Bridge.Action]:
DEBUG [Bridge.Action] send_group_msg group_id=12345 message=[len=3]
On failure, dispatch logs a warn with the full stack instead of
silently folding the error into the response. Unknown actions also
get a debug entry so "the bot is ignoring me" is no longer invisible.
Notes:
- New summarizeParams() helper caps each value at 40 chars / line at
200 chars to keep arrays / nested payloads from blowing the line
width. Lives in utils/log-summary.ts for reuse (highway will too).
- ApiHandler now takes an optional uin and binds a child logger so
per-account file routing works. OneBotInstance passes its parsed
uin.
- Control-char strip in logger/file-transport was exempting only ESC;
also exempt TAB (0x09) and LF (0x0A) so stack traces stay
multi-line in both console and file output.
- vitest.config sets SNOWLUMA_LOG_LEVEL=debug so the ring buffer
receives the new debug entries that tests assert on.
Switches the per-account classes from module-level loggers to
per-instance ones derived via Logger.child({ uin }) so their log
lines land in both the shared file and the account's logs/<uin>/
sub-file, and the [UIN] slot appears in console output:
- IdentityService (Identity scope)
- IncomingPacketPipeline (Bridge + Event scopes)
- OneBotInstance (Event scope)
- HttpServerAdapter (OneBot.HTTP)
- HttpPostAdapter (OneBot.POST)
- WsClientAdapter (OneBot.WS-Client)
- WsServerAdapter (OneBot.WS-Server)
The standalone printEvent() in packet-pipeline now takes a Logger
argument so it routes through this.eventLog instead of a captured
module-level reference. Behavior unchanged otherwise; bridge.ts,
bridge/manager.ts and process-level scopes (App / WebUI / Hook /
OneBot.Config) keep their module-level loggers since they aren't
bound to a single account.
Logger now supports deriving a UIN-bound child:
const log = createLogger('Bridge');
this.log = log.child({ uin: this.uin });
The child stamps a fixed-width [UIN] slot into rendered lines so the
[Scope] column stays vertically aligned, and routes the same line to
a per-UIN sub-file:
logs/snowluma-YYYY-MM-DD.log (shared, all UINs)
logs/<uin>/snowluma-YYYY-MM-DD.log (per-account)
Both pathways share daily rollover, size cap, and retention.
LogEntry now carries uin so SSE subscribers (WebUI) can filter later.
Other meta keys passed to child() are preserved in opts.meta for
forward compat (requestId / traceId etc.) but not rendered yet.
Module-level callers are unchanged; no callsites migrated in this PR.
SNOWLUMA_LOG_PER_UIN=0 disables the per-account sub-file while
keeping the shared one.
Adds a hand-rolled FileTransport that fans out every log line (debug
and up, regardless of console filter) to
./logs/snowluma-YYYY-MM-DD.log. When the current file exceeds the
configurable size cap it rolls into snowluma-YYYY-MM-DD.1.log, and
files older than the retention window are unlinked at startup and on
each day rollover.
Config (all env, no settings.json):
- SNOWLUMA_LOG_FILE 0 to disable file output (default: on)
- SNOWLUMA_LOG_DIR output directory (default: ./logs)
- SNOWLUMA_LOG_MAX_MB per-file cap, MB (default: 50)
- SNOWLUMA_LOG_RETAIN_DAYS retention window (default: 7)
closeLogger() awaits the WriteStream's finish event; SIGINT and
SIGTERM handlers now await it before process.exit so the buffer
makes it to disk.
vitest config sets SNOWLUMA_LOG_FILE=0 to keep the suite from
polluting cwd; the new tests under tests/log-file-transport.test.ts
flip it back on per-test against a tmpdir.
For a private send_private_forward_msg, the inner image/record/video
needs an OIDB upload scene — but uploadForwardNodes never received the
recipient, so the SendContext fell back to {bridge} only and the
element builder threw "private image target uid is missing".
Plumb userId through uploadForwardNodes → buildForwardPushBody and,
when the nodes contain media, resolve it to a uid via
bridge.resolveUserUid() once up-front. Group forwards are unchanged
(they already passed groupId). Mirrors NapCat's design where the
destination peer is threaded all the way down to createSendElements.