Commit Graph
1191 Commits
Author SHA1 Message Date
DrSmoothl 76b87f6b27 feat(proton): migrate group-album actions (PR #31) to proton codec
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.
2026-05-19 08:28:11 +08:00
DrSmoothl 5802812392 feat(proton): finish OIDB migration — runOidb on protobuf_encode<OidbBase<T>>
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
2026-05-19 08:19:14 +08:00
DrSmoothl 6f3646e956 feat(proton): add proton/oidb-action.ts (183 schema mirrors)
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.
2026-05-19 08:19:14 +08:00
DrSmoothl c543ce9227 feat(proton): add proton/oidb.ts with generic OidbBase<T> envelope
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
2026-05-19 08:19:14 +08:00
DrSmoothl 7526622917 feat(proton): migrate highway.ts schemas + highway-client + group-file
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
2026-05-19 08:19:14 +08:00
DrSmoothl ccc31a760c feat(proton): migrate action.ts schemas + bridge.ts + element-builder.ts
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
2026-05-19 08:19:14 +08:00
DrSmoothl 9f32dbd60c feat(proton): migrate notify.ts schemas + all 6 msg-push consumers
Mirrors all 27 notify.ts schemas as proton interface forms and switches
the entire msg-push decoder family over to the new compile-time API.

Migrated schemas (proton/notify.ts):
  OperatorField1, OperatorInfo, GroupChange, GroupAdminExtra,
  GroupAdminBody, GroupAdmin, InvitationInner, InvitationInfo,
  GroupInvitation, GroupInvite, GroupJoin, FriendRequestInfo,
  FriendRequest, FriendRecallTipInfo, FriendRecallInfo, FriendRecall,
  GroupMuteState, GroupMuteData, GroupMute, RecallMessage,
  GroupRecallTipInfo, GroupRecall, TemplParam, GeneralGrayTipInfo,
  EssenceMessage, NotifyMessageBody, GroupReactionDataInnerDataTarget,
  GroupReactionDataContent, GroupReactionDataInnerData,
  GroupReactionDataInner, GroupReactionData, GroupReactNotify

Consumer files switched to protobuf_decode<T>():
  - msg-push/helpers.ts (decodeOperatorUid)
  - msg-push/decoders/event-0x2dc.ts (5 call sites: GroupMute,
    GroupRecall, GroupGreyTip, GroupEssence, GroupMsgEmojiLike)
  - msg-push/decoders/event-0x210.ts (3 call sites: FriendRequest,
    FriendRecall, FriendPoke)
  - msg-push/decoders/group-admin.ts
  - msg-push/decoders/group-member-change.ts (join + leave)
  - msg-push/decoders/group-join-request.ts (join request + invitation
    + invite)

The notify.ts FriendRecall interface collides with events.ts's own
FriendRecall event type; the decoder imports it under the local alias
FriendRecallNotify to keep the existing event type intact.

Verification:
  - 6 new parity tests pinning notify wire equivalence
  - 591/591 core test suite passes (the existing decoder tests —
    group-msg-emoji-like, group-admin, etc. — implicitly validated
    wire format equivalence against real captured QQ payloads)
  - tsc --noEmit clean
2026-05-19 08:19:14 +08:00
DrSmoothl b7d8952163 feat(proton): full longmsg + element + message migration; forward.ts on new API
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
2026-05-19 08:19:14 +08:00
DrSmoothl d323318a7e bench(proton): proton vs legacy runtime encoder/decoder
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
2026-05-19 08:19:14 +08:00
DrSmoothl 31079673c6 docs(proton): add README
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.
2026-05-19 08:19:14 +08:00
DrSmoothl 3d739c255a feat(proton): proof-of-concept migration — longmsg parity 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.
2026-05-19 08:19:13 +08:00
DrSmoothl 194165f49d feat(proton): add @snowluma/proton — compile-time protobuf DSL
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.
2026-05-19 08:19:13 +08:00
墨梓柒 02d1848d39 Merge pull request #31 from H3CoF6/feat/onebot
Implement group album action
2026-05-19 08:18:39 +08:00
H3CoF6 0d40a03f28 Merge upstream/dev into feat/onebot 2026-05-19 02:40:14 +08:00
H3CoF6 64530cc287 Implement some actions: comment, send and cancel like, delete media for group album! 2026-05-19 02:37:11 +08:00
手瓜一十雪 df691c4e8f Revert "feat: Add @snowluma/protobuf package"
This reverts commit 9f9db8d16f.
2026-05-18 20:43:32 +08:00
手瓜一十雪 9f9db8d16f feat: Add @snowluma/protobuf package
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.
2026-05-18 19:31:42 +08:00
DrSmoothl 4d7302a13f chore(github): add issue templates for bug reports and feature requests
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.
2026-05-18 18:01:57 +08:00
墨梓柒 f316073431 Merge pull request #43 from airline233/dev
feat(http-server): support parse content-type of urlencoded
2026-05-18 17:51:41 +08:00
墨梓柒 692a1b7665 Merge pull request #52 from Stlara-F/dev
fix(request-actions): match group add/invite flags by UID and add regression tests
2026-05-18 15:19:15 +08:00
snowluma-bot[bot] fe567caeec Merge pull request #50 from SnowLuma/dev
chore: promote dev → main (941e60d)
v1.8.5
2026-05-18 07:04:16 +00:00
DrSmoothl 941e60dea5 chore(release): v1.8.5 2026-05-18 15:03:45 +08:00
stla b99f5c0bf1 fix(request-actions): match group add/invite flags by UID and add regression tests 2026-05-18 07:03:45 +00:00
DrSmoothl 562fd4f7f9 fix(oidb): set envelope reserved=1 on OIDB 0xFE1_2 (user / stranger)
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.
2026-05-18 14:58:58 +08:00
DrSmoothl ba899782fe feat(webui): mask the access token and add copy + visibility buttons
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.
2026-05-18 14:47:33 +08:00
DrSmoothl 6a7c9a5de1 fix(bridge): also drop config1.field5002 from the OIDB 0xFE5_2 mask (#42)
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.
2026-05-18 14:36:20 +08:00
DrSmoothl 7a2de230bf fix(webui): pass aria-checked as a string literal on switch buttons
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.
2026-05-18 14:31:08 +08:00
DrSmoothl a460433175 refactor(webui): redesign per-UIN config page into sidebar + tabs + modal
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.
2026-05-18 14:29:52 +08:00
DrSmoothl 24056c3de0 feat(msg-push): decode group message emoji reactions (Event0x2DC subType=16)
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.
2026-05-18 14:06:44 +08:00
DrSmoothl a07521d9de fix(bridge): drop bloated fields from OIDB 0xFE5_2 group-list request
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.
2026-05-18 13:48:13 +08:00
DrSmoothl c62f51750a fix(oidb): drop the obsolete field 2 from OIDB 0xFE1_2 (user profile)
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.
2026-05-18 13:39:54 +08:00
snowluma-bot[bot] 231d60411b Merge pull request #48 from SnowLuma/dev
chore: promote dev → main (b94775c)
v1.8.4
2026-05-18 05:31:37 +00:00
DrSmoothl b94775cb6f chore(release): v1.8.4 2026-05-18 13:31:17 +08:00
DrSmoothl 5524c73eed chore(webui): keep search engines out of the admin surface
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.
2026-05-18 13:18:31 +08:00
DrSmoothl 4c513c015d feat(logger): runtime log level toggle via WebUI
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.
2026-05-18 13:15:05 +08:00
DrSmoothl 4a49c55775 fix(hook): refresh pipe snapshot before verifying unload took
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.
2026-05-18 13:07:10 +08:00
DrSmoothl e04a539b26 fix(logger): pass identity to the second printEvent call site
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.
2026-05-18 12:47:34 +08:00
DrSmoothl 257686eac6 feat(logger): identity-aware readable event rendering
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.
2026-05-18 12:32:18 +08:00
DrSmoothl 84e746c278 chore(observability): defensive depth pass
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).
2026-05-18 01:36:12 +08:00
DrSmoothl 7ec479bb88 feat(logger): protocol-layer observability for highway and msg-push
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.
2026-05-18 01:29:06 +08:00
DrSmoothl 1ba45e2659 feat(logger): observability for OneBot action dispatch
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.
2026-05-18 01:19:30 +08:00
DrSmoothl 1d66693cc3 refactor(logger): bind per-account loggers in target classes
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.
2026-05-18 00:57:46 +08:00
DrSmoothl 93b9b03667 feat(logger): add .child({ uin }) API and per-UIN file fan-out
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.
2026-05-18 00:50:15 +08:00
DrSmoothl 51f46e15fd feat(logger): add file transport with daily rotation and size cap
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.
2026-05-18 00:44:30 +08:00
snowluma-bot[bot] bee1e67c60 Merge pull request #46 from SnowLuma/dev
chore: promote dev → main (c6f4d5c)
v1.8.3
2026-05-17 16:11:03 +00:00
DrSmoothl c6f4d5c5a2 chore(release): v1.8.3 2026-05-18 00:10:43 +08:00
DrSmoothl 82b52a3b2d update README 2026-05-18 00:10:12 +08:00
DrSmoothl 360fe8bd49 fix(forward): carry image/video in private merged forward
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.
2026-05-17 23:47:12 +08:00
snowluma-bot[bot] 71b0bda8f4 Merge pull request #44 from SnowLuma/dev
chore: promote dev → main (90884df)
v1.8.2
2026-05-17 11:29:18 +00:00
DrSmoothl 90884dfbb1 chore(release): v1.8.2 2026-05-17 19:28:58 +08:00