Commit Graph
1191 Commits
Author SHA1 Message Date
DrSmoothl 59cdfa98a2 refactor(oidb): replace 3 positional helpers with one named runOidb
bridge-oidb.ts used to expose three thin functions —
makeOidbRequest / sendOidbAndCheck / sendOidbAndDecode — and every
action file repeated the same five-step envelope assembly with long
positional argument lists. Collapse them into one `runOidb(bridge,
call)` taking an OidbCall object: cmd / oidbCmd / subCmd / request
{ schema, value, isUid? } / response? { schema }.

Behavioural contract is preserved:
  - No `response`: verify success + gotResponse, peek envelope retCode
    iff response data is present (fire-and-check semantics).
  - With `response`: require responseData, decode through the base
    schema, validate retCode, return the inner body typed as TResp.

Migration touches 51 call sites across 9 source files (bridge-contacts,
actions/{group-file, group-admin, profile, group-message, friend,
interaction, misc}, web-actions/cookies) plus 9 test files that used
to assert on positional `.mock.calls[0]![4]` indices and now read
through `.mock.calls[0]![1].request.value` / `.cmd` / `.oidbCmd`.

Net effect: callers now name what each piece is for, and adding
cross-cutting OIDB behaviour (retry / timeout / per-cmd logging) is a
single-site change inside runOidb instead of a sweep across 9 files.
2026-05-15 01:03:25 +08:00
DrSmoothl ac10c663f1 refactor(identity): absorb QQInfo into IdentityService
QQInfo's in-memory state (self identity, friends, group rosters,
profile cache) and read methods (findFriend / findGroup /
findGroupMember / findUserProfile / nickname / uin / selfProfile) now
live directly on IdentityService. The QQInfo class is gone; its
interfaces stay in `qq-info.ts` as plain types shared across the
remember*/find* surface.

This eliminates a long-standing duplication: every observation used to
maintain both QQInfo's in-memory structures and IdentityService's
SQLite mirror in parallel, with the uidByUin/uinByUid Maps tracked
separately. With one Module owning all three layers, every remember*
hydrates one set of structures and `loadSnapshot` repopulates the
in-memory uid↔uin index from SQLite on startup — closing the cold-cache
gap that previously forced findUidByUin to fall through to a SQLite
query after every restart.

Knock-on changes:
  - `Bridge` no longer holds a QQInfo; constructor takes an
    `IdentityService`. The CmdParser signature on the cmd dispatch
    table now passes `IdentityService` to parsers.
  - `msg-push-handler.ts` drops its private `resolveUidToUin(qqInfo, …)`
    helper that bypassed IdentityService's cascade — the parser hot
    path now reads through `identity.findUinByUid(uid, groupId)` and
    benefits from the O(1) `uinByUid` map even on freshly-loaded
    sessions.
  - `BridgeManager`'s session-started callback drops the QQInfo
    parameter; OneBotInstance no longer carries a `qqInfo` field and
    exposes a `nickname` getter for the WebUI listing.
  - `contact-actions` and the per-theme bridge action files stop
    importing QQInfo; they read everything via `bridge.identity.*`.
2026-05-15 00:44:01 +08:00
DrSmoothl 030336a25c refactor(identity): consolidate UID resolution behind IdentityService
Pull the cache→DB→network-fetch fallback cascade for UIN→UID translation
into a single async `resolveUid` on IdentityService, and drop the
duplicated `resolveUserUid` helper that used to live in bridge-oidb. Bridge
injects itself as the IdentityFetcher post-construction so the network
fallback runs through the same OIDB profile fetch the helper used.

Each action module's `resolveUserUid(bridge, uin, groupId?)` call becomes
`bridge.resolveUserUid(uin, groupId?)` — same signature on Bridge, but
internally it now goes through `identity.resolveUid` so future changes
(retry, coalescing, alternative resolvers) only need to land in one place.

This is part 1 of the IdentityService deepening (RoadMap item 1). The
QQInfo bypass in msg-push-handler and the broader QQInfo absorption land
in the follow-up commit.
2026-05-15 00:32:59 +08:00
DrSmoothl 2e4dd9b01e fix(onebot): clarify set_msg_emoji_like error wording
Distinguish "message not found" from "private chats don't support
emoji reactions". The latter previously masqueraded as "not a group
message", which sounded like a missing feature; clarify that the
limitation is on QQ's side, not ours.
2026-05-14 21:32:49 +08:00
DrSmoothl a7fecf2a53 chore(release): add pnpm release one-shot driver
Bumps version, pushes dev with chore(release): prefix to trigger
Promote, waits for the PR to merge, then tags main and pushes
the tag to kick off the release workflow.
2026-05-14 21:04:29 +08:00
snowluma-bot[bot] fe4af70562 Merge pull request #34 from SnowLuma/dev
chore: promote dev → main (564eec2)
v1.7.10
2026-05-14 20:50:46 +08:00
DrSmoothl 564eec21bb fix(bridge): include emoji type in group reaction request
set_msg_emoji_like silently no-op'd for unicode emoji because the
reaction request didn't tell the server which emoji namespace the
code belongs to (QQ face vs unicode codepoint). Derive the type
from the code length — short numeric ids are QQ faces, longer
codepoint strings are unicode.
2026-05-14 20:45:37 +08:00
H3CoF6 b176e929b9 feat: implement upload group avatar 2026-05-14 04:09:55 +08:00
DrSmoothl 00a70b1ccc fix(bridge): preserve full ext-biz-info on media upload round-trip
The NTV2 upload-response decoder used stub schemas that omitted fields
QQ NT echoes back (notably video.fromScene/toScene, pic.fromScene/
toScene/oldFileId, the ptt scene/waveform set), so finalizeMediaMsgInfo
re-encoded the commonElem.pbElem with those fields missing. For c2c
video this triggered `send private message rejected: result=79` on
every send_private_msg [{type:'video'}] call — the upload itself
fast-pathed fine but the follow-up SendMessage was rejected by NT.

Drop the duplicate NTV2 placeholder schemas and route NTV2ExtBizInfo
through the element-side PicExtBizInfo / VideoExtBizInfo /
PttExtBizInfo, which are field-complete. Also zero out the main
video sub-file's width/height/time/videoFormat in the upload request
to mirror NapCat — those fields belong on the thumb, not the source
mp4, and non-zero values trip schema validation on c2c sends.
2026-05-13 22:19:45 +08:00
DrSmoothl e438f353af fix(onebot): resolve self nickname via user profile when friend list omits self
The session warmup only set qqInfo.nickname when QQ's friend-list
response happened to echo self back, which it omits for some
accounts / versions / multi-account warmup races. The WebUI online
sessions card then rendered a blank nickname row above the UIN.
Fall back to a direct user-profile fetch when the friend-list scan
misses self, and seed qqInfo.nickname with the UIN at session
creation so the panel never paints empty while warmup is in flight.
2026-05-13 21:11:26 +08:00
DrSmoothl ea5a1d505a feat: add forward message preview metadata support 2026-05-13 20:53:26 +08:00
snowluma-bot[bot] 545a732385 Merge pull request #30 from SnowLuma/dev
chore: promote dev → main (ada591b)
v1.7.9
2026-05-13 16:55:55 +08:00
DrSmoothl ada591b1b4 bump 1.7.9 2026-05-13 16:52:19 +08:00
DrSmoothl 61e3867197 chore(release): single-source app version + add pnpm bump
- WebUI sidebar/login display and OneBot get_version_info now read the
  root package.json version via Vite `define`, removing hardcoded
  literals in webui/src/types.ts and core/onebot/actions/info.ts.
- tools/bump-version.mjs (exposed as `pnpm bump <ver>`) syncs the
  version field across the root and all five workspace packages.
- Bump everything to 1.7.8.
2026-05-13 16:51:44 +08:00
DrSmoothl c9fa119a50 fix(onebot): align forward message wrapping with OneBot 11 spec
get_forward_msg now returns full OB11Message objects (self_id, time,
message_id, sender, etc.) instead of node-segment wrappers, and event
emission keeps the resId on incoming forward segments instead of
dropping it via the unknown-element fallback. Extends ForwardNodePayload
with optional context fields so download/upload paths preserve enough
state to synthesize the OB11Message shape NapCat clients expect.
2026-05-13 16:49:29 +08:00
snowluma-bot[bot] c2f22d8daf Merge pull request #29 from SnowLuma/dev
chore: promote dev → main (6abde40)
v1.7.8
2026-05-13 16:23:42 +08:00
DrSmoothl 6abde40b9c [merge] fix Linux auto load issue 2026-05-13 16:20:43 +08:00
DrSmoothl 6760008c5c chore: refresh prebuilt linux-x64 native binary 2026-05-13 16:18:21 +08:00
DrSmoothl 37b835c537 chore: refresh prebuilt linux-x64 native binary 2026-05-13 16:18:21 +08:00
DrSmoothl 9068ce3108 fix(core): skip Electron child processes during hook auto-load
With SNOWLUMA_HOOK_AUTOLOAD=1, the QQ window came up blank — no QR code,
login stuck. Cause: the native enumerator (get_all_main_process_ids)
returned the main QQ pid plus 2 zygote subprocesses because their
cmdline contains "qq" and they transiently inherit wrapper.node via
fork copy-on-write. Auto-load fired loadModuleManual for all three,
spawning an in-process resolver thread inside each zygote. Renderer
processes Electron later forked from those zygotes then inherited
those resolver threads + the partially-initialised hook state, which
breaks QQ's IPC and prevents the login UI from rendering.

Add `shouldAutoLoadPid` which reads /proc/<pid>/cmdline and returns
false when it contains `--type=` (Electron child process marker:
zygote / renderer / gpu-process / utility). Manual loadProcess() is
unaffected — operators picking PIDs from WebUI keep full control.
SNOWLUMA_HOOK_AUTOLOAD_ALL=1 disables the filter as an escape hatch.

Verified on tempserver: container restart with autoload=1 now shows
the QR-code login screen as expected, and only the main qq pid gets
the mojo.<pid>.*.sock pair (zygotes stay clean).
2026-05-13 00:57:48 +08:00
snowluma-bot[bot] 9216b54427 Merge pull request #28 from SnowLuma/dev
chore: promote dev → main (1b2dd83)
v1.7.7
2026-05-12 22:06:52 +08:00
DrSmoothl 1b2dd836b8 [merge] upd & refactor 2026-05-12 22:06:04 +08:00
DrSmoothl fbb7504afe feat(core): auto-inject hook on QQ process discovery
Headless / Docker deployments previously needed a human to click
"Load" in WebUI for every newly-discovered QQ process. Add an opt-in
HookManager.autoLoadOnDiscovery flag wired up through two layers:

  - SNOWLUMA_HOOK_AUTOLOAD env var (highest priority)
  - hookAutoLoad in runtime.json (persistent fallback)

When enabled, every 'process-discovered' event from PipeWatcher fires
session.load() fire-and-forget; failures stay scoped to the session via
existing error-state handling and don't break the watcher loop.

Default stays off, so non-Docker installs see no behaviour change.
2026-05-12 22:04:11 +08:00
DrSmoothl dd02535a73 chore(core): flatten single-file dirs + drop dead writeFailed surface
Two small cleanups that came out of the round-2 architecture review
and are sub-five-minutes each — bundled because each one is too
small to justify its own commit.

#5 — flatten single-file directories
  packages/core/src/bridge/builders/element-builder.ts
    -> packages/core/src/bridge/element-builder.ts
  packages/core/src/bridge/handlers/msg-push-handler.ts
    -> packages/core/src/bridge/msg-push-handler.ts

  Both dirs had exactly one file in them — a smell that says
  "planned a sibling that never landed". Moving up one level
  removes the wrapper directory.

  Touch list:
    - bridge.ts:                  2 import paths
    - bridge/actions/forward.ts:  2 import paths
    - msg-push-handler.ts:        internal ../X -> ./X for events,
                                  proto/element, proto/message (the
                                  dynamic-import-typeof references too)
    - element-builder.ts:         internal ../X -> ./X for bridge,
                                  events, highway/*
    - 4 test files:               import paths (2 regular, 2 vi.mock)

#6 — drop IdentityService.hasWriteFailure
  identity-service.ts had a `private writeFailed = false` field set
  by the runWrite catch block and exposed via `get hasWriteFailure()`,
  but a full-repo grep showed zero callers of the getter. It was a
  public API stub for a feature that hadn't been wired (a webui
  health indicator, presumably).

  Removed:
    - the field
    - the getter
    - the assignment in runWrite's catch block (the error log on
      the next line is the actual observable behavior; nothing else
      reads the flag)

  If we want a "did any write fail this session" surface later,
  it's trivial to add back with an explicit consumer in mind.

Behavior preserved end-to-end. No public API change outside the
moved files. 369 tests pass (unchanged). 3 stability runs.
2026-05-12 20:59:25 +08:00
DrSmoothl 069548918b refactor(core): split web-actions.ts into themed modules + barrel
web-actions.ts was a 330-line monolith with 14 exported functions
covering 4 distinct themes: cookie/credential primitives (the 8
functions every web call builds on), group honor leaderboards, group
essence (highlighted messages), and group notices. Same shape problem
the OIDB bridge-actions.ts split fixed three commits back, just smaller.

New layout under packages/core/src/bridge/web-actions/ :

  cookies.ts        (161 lines, 8 fns)
    forceFetchClientKey / getPSkey / getCookies / getSKey /
    getBknFromSKey / getCookiesStr / getCsrfToken / getCredentials
    — the ptlogin2-jump + bkn-derivation primitives.

  group-honor.ts    (45 lines, 1 fn)
    getGroupHonorInfo (talkative / performer / legend / emotion / ALL)

  group-essence.ts  (27 lines, 2 fns)
    getGroupEssence (paged) + getGroupEssenceAll

  group-notice.ts   (118 lines, 3 fns)
    sendGroupNotice (with optional image-from-URL-or-file upload),
    getGroupNotice (OneBot shape flatten), deleteGroupNoticeByFid

  index.ts          (27 lines)
    Barrel re-export. `bridge.ts` keeps its existing
    `from './web-actions'` import — TS resolves the directory's
    index.ts so no caller changes.

The lower-level web/* helpers (request-util, group-essence,
group-honor, group-notice) stay where they were — they're the
direct HTTP/cookie wrappers, distinct from these Bridge-level
orchestrators.

Behavior preserved end-to-end. Same ptlogin2 URL construction,
same fall-back-to-getPSkey-when-p_skey-missing path in getCookies,
same default keyIndex='19' / expireTime='1800', same djb2 bkn hash,
same NapCat-derived default 540x300 notice image dimensions, same
chinese error messages ('设置群公告失败', '获取公告失败').

Adds 35 happy-path tests across 4 new files in tests/web-actions/ :

  cookies         (13)  forceFetchClientKey response shape + defaults,
                        getPSkey pskeyItems folding, getCookies ptlogin2
                        URL params, p_skey fallback path (both success
                        and throwing), getSKey success / no-clientKey /
                        no-skey throws, getBknFromSKey determinism +
                        range, getCookiesStr formatting, getCsrfToken
                        composition, getCredentials full + empty paths.
  group-honor      (7)  TALKATIVE list vs empty list behaviour,
                        PERFORMER / LEGEND / EMOTION per-type dispatch,
                        ALL fetches all four types, groupId stringification.
  group-essence    (4)  pageStart/pageLimit forwarding, defaults,
                        null-result fallback envelope, getAll delegation.
  group-notice    (11)  text-only dispatch + defaults, options override,
                        http image fetch + upload + thread, fetch failure,
                        ec≠0 surfaces em, generic error fallback,
                        getGroupNotice feed flattening + pics mapping,
                        null-result throw, empty-feeds case,
                        deleteGroupNoticeByFid argument forwarding.

Tests use vi.mock on bridge-oidb / web/request-util / web/group-*
to isolate each theme from real HTTP, mirroring the pattern from
the bridge-actions split.

369 tests pass (was 334); 3 stability runs, same pre-existing
macOS-arm64 websocket addon failure, unrelated.
2026-05-12 20:49:36 +08:00
DrSmoothl 8a49f0c21d refactor(core): extract MediaUrlResolver + MediaIndexer from OneBotInstance
The OneBotInstance constructor still carried two non-trivial closures
inside its converterCtx object — a 26-line mediaUrlResolver that
branched bridge URL fetches by element.type + isGroup, and a 60-line
mediaSegmentSink that fanned out to mediaStore.rememberImage / Record /
Video with subtly different field maps. Both closed over `this` and
were untestable without instantiating the whole instance.

Extracted into two small, focused modules:

  onebot/media-url-resolver.ts  (64 lines)
    class MediaUrlResolver(bridge, rkeyCache)
      .resolve(element, isGroup, sessionId): Promise<string>
    Picks the right Bridge.fetchXxx based on element.type + isGroup,
    mutates element.url in-place (preserved behavior), then applies
    the cached RKey via rkeyCache.resolveMediaUrl. Bridge-fetch
    errors are swallowed so the RKey pass always runs.

  onebot/media-indexer.ts  (90 lines)
    class MediaIndexer(mediaStore)
      .remember(mediaType, element, data, isGroup, sessionId): void
    One method, dispatches to mediaStore.rememberImage / Record /
    Video with the right field map per mediaType. Defaults are
    preserved exactly (?? 0 for missing numbers, ?? '' for strings).

OneBotInstance constructor shrinks from 134 lines to ~50 lines (-62%).
The converterCtx is now visually obvious — four callbacks each one
line, delegating to the right collaborator:

    const mediaUrlResolver = new MediaUrlResolver(this.bridge, this.rkeyCache);
    const mediaIndexer = new MediaIndexer(this.mediaStore);
    this.converterCtx = {
      selfId: parseInt(this.uin, 10) || 0,
      imageUrlResolver: (el, ig) => this.rkeyCache.resolveImageUrl(this.bridge, el, ig),
      mediaUrlResolver: (el, ig, sid) => mediaUrlResolver.resolve(el, ig, sid),
      messageIdResolver: (ig, sid, seq, name) => hashMessageIdInt32(...),
      mediaSegmentSink: (mt, el, d, ig, sid) => mediaIndexer.remember(mt, el, d, ig, sid),
    };

Adds 19 happy-path tests:

  media-url-resolver.test.ts  (12 tests):
    - file element: group / c2c-with-hash / c2c-without-hash
    - record element: group / c2c
    - video element: group / c2c
    - record without mediaNode: no bridge call
    - element.url already set: short-circuit, only rkey runs
    - bridge throws: swallowed, rkey still applied
    - rkey is always called at the end

  media-indexer.test.ts  (7 tests):
    - image: full field map, file falls back to fileId
    - record: full field map, file falls back to fileName
    - video: full field map, data.file overrides element fields
    - record: file falls back to fileId when fileName also missing
    - missing numeric / string fields default to 0 / ''
    - only the matching rememberX is called

Behavior preserved end-to-end: same bridge methods, same field
mappings into MediaStore, same in-place element.url mutation, same
RKey-always-runs guarantee, same best-effort error swallowing.
The existing media-store integration test (which still wires the
sink through the real converter) continues to pass unchanged.

334 tests pass (was 317); 3 stability runs, same pre-existing
macOS-arm64 websocket addon failure, unrelated.
2026-05-12 20:35:12 +08:00
DrSmoothl 49281726f1 refactor(core): split EventConverter god switch into themed pure functions
event-converter.ts (541 lines) was a class with a 16-case switch
mapping bridge events to OneBot wire JSON, plus a 13-case helper
mapping bridge elements to OneBot segments, plus four lazily-injected
resolver setters (image-url / media-url / message-id / media-segment
sink). Testing one case required instantiating the class and wiring
four setters; 22 of the 29 cases (10 notices + 2 requests + 13 element
types) had zero tests.

This commit kills the class and replaces it with a pure-function
family that takes a ConverterContext (a plain object holding selfId
+ the four resolver callbacks). Each per-event handler becomes a
small typed function that's trivial to call from a test with two
lines of setup.

New layout: packages/core/src/onebot/event-converter/

  index.ts        133  ConverterContext + four resolver types +
                       convertEvent dispatcher + re-exports.
  to-message.ts   112  friend / group / temp message  (3 handlers)
  to-notice.ts    189  group_member_join / leave / mute / admin /
                       friend_recall / group_recall / friend_poke /
                       group_poke / group_essence / group_file_upload /
                       friend_add                     (11 handlers)
  to-request.ts    37  friend_request / group_invite  (2 handlers)
  to-segment.ts   145  13 element -> segment cases + elementsToJson +
                       elementsToOneBotSegments
  utils.ts        111  parseSelfId / isSameActor / applyMessageIdResolver /
                       resolveReplyId / segmentsToRawMessage + CQ encoding

Caller migration (3 files, all inside onebot/):

  instance.ts:        replaces `new EventConverter()` + four setter
                      calls with a single `converterCtx: ConverterContext`
                      object built once at construction. The four
                      closures that used to be passed through setters
                      are now properties of the context.
  instance-context.ts: `eventConverter: EventConverter` field renamed
                      to `converterCtx: ConverterContext`.
  event-pipeline.ts:  `ctx.eventConverter.convert(ctx.uin, event)` is
                      now `convertEvent(ctx.converterCtx, event)`.

External callers (modules/message-actions.ts) keep working unchanged:
elementsToOneBotSegments is re-exported from the new index.ts with
the same signature.

Adds 28 new tests covering every previously-untested case:

  message kinds (already partly covered):  7 tests
    - friend / group / temp_message peer + self post_type tagging
    - messageIdResolver override
  notice kinds (new):                     11 tests
    - all 11 cases, including the sub-type axes
      (approve/invite, leave/kick/kick_me, ban/lift_ban,
       set/unset, add/delete, etc.)
  request kinds (new):                     2 tests
    - friend_request, group_invite (with sub_type default)
  element segments (new):                 13 tests
    - text / face / image (with + without resolver) / at /
      at-all / reply (with + without resolver) / record /
      video / json / xml (with + without subType) / file /
      mface / poke
  mediaSegmentSink wiring (new):           1 test

Plus 3 tests in media-store.test.ts and 1 in event-pipeline.test.ts
adapted to the new API.

317 tests pass (was 285); 3 stability runs, same pre-existing
macOS-arm64 websocket addon failure, unrelated.
2026-05-12 18:45:28 +08:00
DrSmoothl 269cd6f081 refactor(core): extract NTV2 upload pipeline shared by image/ptt/video
The three highway uploaders — image-upload, ptt-upload, video-upload —
each implemented the same NTV2 envelope assembly, OIDB response decode,
error handling, and Highway PUT loop, with the only real per-format
differences being the file-info fields, the extBizInfo blob, and the
Highway command id. The duplicated boilerplate had drifted slightly
between files and any future NTQQ protocol change would have needed
parallel edits in three places.

New: packages/core/src/bridge/highway/pipeline.ts (249 lines).

  runNtv2Upload({ bridge, isGroup, targetIdOrUid, oidbCmd, serviceCmd,
                  requestId, businessType, uploadInfo, compatQmsgSceneType,
                  extBizInfo, uploads, label })
    -> awaited upload object

    Builds the full reqHead + upload envelope, sends through OIDB,
    decodes the response, runs the per-sub-file Highway PUTs (caching
    the Highway session across them — video uses two PUTs and now only
    fetches the session once), and surfaces a typed fastOnlyError when
    the server demands bytes but the caller passed a fingerprint.

  finalizeMediaMsgInfo(upload, defaultPic?) -> Uint8Array

    Encodes the outgoing commonElem MsgInfo. `defaultPic` is the
    image-only fallback: image fills bizType + textSummary when the
    server omits the pic ext-biz-info; ptt / video pass undefined.

  hexToBytes(hex), makeClientRandomId(): shared helpers used by every
  uploader's fingerprint payload + client-randomId generation.

MediaSubFileUpload models per-sub-file routing:
  - source: 'top'   -> upload.uKey + upload.ipv4s (main file)
  - source: N       -> upload.subFileInfos[N].uKey + .ipv4s (video thumb)
  - cmdId, bytes, md5, sha1, subFileIndex, fastOnlyError

Each upload file shrinks to its format-specific logic only:

  image-upload.ts:  237 -> 163  (-31%)
    format detection, picFormat mapping, pic/reserveTroop/reserveC2c
    extBizInfo, group/c2c command id choice, finalize with defaultPic.

  ptt-upload.ts:    300 -> 205  (-32%)
    silk transcoding, temp-file cleanup, NapCat-style bytesGeneralFlags
    (different per group/c2c), voiceFormat honouring.

  video-upload.ts:  584 -> 507  (-13%)
    Sha1StreamState (per-1MB streaming SHA1 for the main Highway PUT),
    ffmpeg-driven thumb extraction with FALLBACK_THUMB, the two-sub-file
    OIDB body (main mp4 + thumb jpg with subFileType 100), and the
    fastOnlyError asymmetry (main file has it; thumb always has bytes).
    Video stays the largest because its load step is genuinely the most
    work — the share-out gain here is in concentration, not line count.

Behavior preserved end-to-end. Same NTV2 OIDB cmd numbers (image
0x11C4/5, ptt 0x126E/D, video 0x11EA/9), same service-cmd strings,
same requestId (image=1, ptt=group:1/c2c:4, video=3), same
compatQmsgSceneType (image+ptt: isGroup?2:1; video: 2), same
NapCat-derived bytesGeneralFlags, same fastUpload fallback strings.

Adds 42 happy-path tests across 4 new files in tests/highway/ :

  pipeline.test.ts          22  envelope construction, response decode,
                                OIDB error paths, group vs c2c scene,
                                Highway PUT trigger + fast-path skip,
                                sub-file routing, session caching,
                                fastOnlyError fire / silent-skip,
                                finalize with/without defaultPic, helpers.
  image-upload.test.ts       7  group/c2c oidbCmd + cmdId mapping,
                                fingerprint payload shape, picFormat /
                                dimensions wiring, subType -> bizType.
  ptt-upload.test.ts         6  group requestId=1 / c2c requestId=4,
                                per-scene bytesGeneralFlags, voiceFormat
                                default and override, fingerprint
                                preconditions, finalize-without-pic.
  video-upload.test.ts       7  two-sub-file shape, thumb subFileInfos[0]
                                routing, fastOnlyError asymmetry,
                                uploadInfo[mp4+jpg] with subFileType 0+100,
                                FALLBACK_THUMB always has bytes.

Tests mock highway-client for transport and the pipeline module (for
per-uploader tests) so each suite hits exactly the contract it's
testing. Pipeline tests roundtrip a real OIDB envelope through
protobuf encode/decode to exercise the actual decode path.

285 tests pass (was 244). Same pre-existing macOS-arm64 websocket
addon failure, unrelated.
2026-05-12 17:46:56 +08:00
DrSmoothl b32a8110af refactor(core): make ApiActionContext required, drop 80 dead defensive guards
ApiActionContext had 81 optional method fields (\`name?: ...\`) plus 4
required ones — but every single optional field was wired in
buildApiContext (verified by diffing the two lists). The optionality
was dead: nothing else implemented this interface, no test ever
swapped fields out, and the action handlers all paid for the
fiction with defensive guards like:

    if (!ctx.sendGroupMessage) {
      return failedResponse(RETCODE.ACTION_FAILED, 'send_group_msg is not implemented');
    }

— 80 such guards total across the action files (78 in the 3-line
form, 1 single-line, 1 \`if (!(ctx.a && ctx.b))\` compound). None of
them could ever fire.

This commit deletes the dead optionality. Two mechanical changes:

1. api-handler.ts: 81 \`name?: ...\` -> \`name: ...\` in ApiActionContext.
   The MessageSendResult optionals (meta?, echoEvent?) are unrelated
   and stay as-is. After: 85 required fields, 0 optionals.

2. actions/*.ts: drop the 80 guard blocks. Net -129 lines across
   extended, group-admin, group-file, group-info, friend, message,
   request. info.ts had none to begin with.

What this buys:

- The type system now enforces the wiring contract. If
  buildApiContext forgets to wire a field, tsc fails at the call
  site — the bug we were ostensibly catching at runtime in 80 places
  is now caught at compile time in 0 places, for free.
- Action handler bodies shrink visibly (avg ~3 lines per handler).
  extended.ts goes 1021 -> 935.
- AI / human readers no longer skim past dead code.

Caught one TS2774 ("This condition will always return true since
this function is always defined") on a compound guard in
extended.ts:186 that the regex missed; removed it manually. That
diagnostic is the type system actually doing its job — exactly the
point of the change.

244 tests pass (was 244). Same pre-existing macOS-arm64 websocket
addon failure, unrelated.
2026-05-12 17:09:56 +08:00
DrSmoothl 8506b60d07 refactor(core): split bridge-actions.ts (1814 lines) into themed action modules
bridge-actions.ts was a single 1814-line file holding 55 unrelated
exported functions — admin/file/forward/interaction/recall/profile/
status/translate/sign/avatar all jumbled together. Each function
already takes `bridge: Bridge` as its first parameter, so the
"deepening" target (per-UIN state object threaded into themed action
functions) was structurally there — what was missing was a coherent
file-level grouping that AI tools and humans could navigate.

This commit is candidate #2 from the deepening list, A-only variant:
pure file split, no facade change, no caller change outside the
bridge module.

New layout under packages/core/src/bridge/actions/ :

  shared.ts        82  private helpers (toInt, ensureRetCodeZero,
                       resolveSelfUid) and the MediaIndexNode type.
  group-admin.ts  153  mute/kick/leave + setGroup{Admin/Card/Name/
                       SpecialTitle/AddOption/Search/Remark/AddRequest}
                       + getGroupAtAllRemain.                (14 fns)
  group-file.ts   821  upload/list/url/delete/move/folder ops, both
                       group + private files, media-URL fetchers for
                       ptt/video, fetchGroupFileCount. Owns file-
                       specific helpers (normalizeDirectory,
                       buildGroupFileUploadExt, normalizeMediaNode,
                       fetchNtv2DownloadUrl, ...).            (15 fns)
  group-message   101  recall* / markRead* / setGroupEssence.  (5 fns)
  interaction.ts   61  poke / like / reaction / emoji-likes.   (4 fns)
  forward.ts      197  uploadForwardNodes, fetchForwardNodes +
                       in-memory resId cache.                  (2 fns)
  friend.ts        53  setFriendAddRequest, deleteFriend,
                       setFriendRemark.                        (3 fns)
  profile.ts      269  setOnlineStatus, setProfile, setSelfLongNick,
                       setInputStatus, setAvatar, fetchCustomFace,
                       getProfileLike, getUnidirectionalFriendList.
                                                              (8 fns)
  misc.ts         173  translateEn2Zh, getMiniAppArk,
                       clickInlineKeyboardButton, sendGroupSign.
                                                              (4 fns)

bridge.ts updates its imports — one themed import block per action
module instead of one giant block from bridge-actions.ts. GroupFilesResult
and MediaIndexNode type imports now point at actions/group-file and
actions/shared. Public Bridge facade API is unchanged: every caller
outside `bridge/` keeps the same `bridge.X(...)` surface and the
delegated function it points at.

bridge-actions.ts is deleted. The new shared.ts holds the three
helpers that span themes; everything else lives co-located with the
functions that use it.

Adds 60 happy-path tests across tests/actions/ — one .test.ts per
themed file plus a shared `_helpers.ts` (mockBridge fixture):

  group-admin     10  command + payload dispatch, UID resolution,
                      flag-driven sub-command selection (mute on/off,
                      add-request filtered/unfiltered).
  group-file      13  fetchGroupFileCount, upload happy path with
                      and without highway, OIDB error bubbling, file
                      listing pagination + folder/file split, URL
                      builder shape, delete/move/folder dispatch,
                      NTV2 URL assembly.
  group-message    5  recall (group/private), markRead, essence on/off,
                      transport failure propagation.
  interaction      6  poke (group vs friend payload), like, reaction
                      on/off, emoji-likes cookie/isLast logic.
  forward          5  empty-array rejection, dispatch + res_id return,
                      transport failure, cache reuse after upload.
  friend           5  numeric flag → UID resolution path, opaque flag
                      passthrough, delete + best-effort cache refresh.
  profile          9  status dispatch, setProfile no-op when undefined,
                      setAvatar highway cmd 90, getProfileLike +
                      getUnidirectionalFriendList JSON parsing.
  misc             6  translate, mini-app type guard, click + sign
                      dispatch.

Tests use vi.mock on `bridge-oidb` (and `highway-client`/`highway-utils`
where relevant) so each action runs against a tiny fake Bridge, no
native pipe / real OIDB exchange needed. Previously these 55 functions
had zero unit-test coverage.

244 tests pass (was 184); only failing suite remains the pre-existing
macOS-arm64 missing websocket native addon, unrelated.
2026-05-12 16:51:34 +08:00
DrSmoothl e91a506bf5 feat(onebot): implement forward_{group,friend}_single_msg via md5 fast-upload
Captures protocol-level media fingerprints (md5/sha1/dims/format) on the
receive side and persists them alongside existing MediaStore entries, so a
forward can re-send the original message via QQ's md5 fast-upload path
without re-downloading the source CDN bytes. Avoids the stale-rkey trap
where the cached imageUrl 404s a few hours after receipt.

- MessageElement: add md5Hex / sha1Hex / pic|video|voiceFormat /
  noByteFallback optional fields.
- MediaStore: extend CachedImage / CachedRecord with fingerprints, add
  CachedVideo + rememberVideo / findVideo / updateVideoUrl. Per-row growth
  ~80-90 bytes; LRU cap stays at 4096 per type.
- msg-push-handler: extract fingerprints from NTV2 image/record/video and
  legacy NotOnlineImage/CustomFace/Ptt paths.
- event-converter: invoke mediaSegmentSink for video too.
- highway image/ptt/video upload: add fast-only branch that skips
  loadBinarySource/encodeSilk/stageVideoSource when noByteFallback is set,
  and fails fast if the server still returns uKey. Video forward uses the
  100-byte FALLBACK_THUMB as its subFile (original thumb not preserved in
  onebot semantics).
- message-actions: add forwardSingleMessage which looks up the cached
  event, enriches each media element with cached fingerprints, and
  dispatches via the existing sendGroup/sendPrivateMessage path. Files
  reject explicitly (separate upload protocol, out of scope).
- extended actions: wire forward_friend_single_msg / forward_group_single_msg
  to the new context method, replacing the not-implemented stubs.
2026-05-12 16:22:16 +08:00
DrSmoothl ef4a321175 refactor(core): drop NtqqHandler shim, route packets directly to BridgeManager
NtqqHandler was a 54-line "shallow module": half of its public surface
(registerCmd by service-cmd) had zero callers — cmdHandlers was a Map
that nothing ever wrote to. The remaining half (registerCmdAll) had
exactly one subscriber: BridgeManager.bind() wiring its private
onPacket. So NtqqHandler was, in effect, a one-listener event emitter
plus a five-line field-rename from QqHookPacket to PacketInfo.

The deletion test concentrates the change rather than scattering it:

- HookSession.handlePacket inlines the field rename and pushes
  straight into a PacketSink dependency (PacketInfo => void).
  HookSession's ctor now takes `onPacket?: PacketSink` instead of
  `ntqq?: NtqqHandler` — the new shape is exactly what tests want
  (a vi.fn() spy with no event-emitter scaffolding).

- HookManager's `ntqq: NtqqHandler` constructor dep becomes
  `onPacket?: PacketSink`, defaulting to `bridgeManager.onPacket` so
  the common-case wiring stays one line at the call site.

- BridgeManager.bind(ntqq) is deleted; BridgeManager.onPacket becomes
  public (it was always the real entry point). The file header
  comment now describes onPacket as the single packet entry rather
  than "wires NtqqHandler".

- index.ts shrinks: no `new NtqqHandler()` and no `bridgeManager.bind`.
  `new HookManager({ bridgeManager })` covers both wires.

- packages/core/src/protocol/ntqq-handler.ts deleted.

Behavior preserved: same field rename (Buffer.from(packet.body) for
the body copy that QqHookClient already does, kept to match
byte-for-byte), same login + real-uin filter, same logging scope is
gone (`createLogger('NTQQ')` had no remaining call sites). PacketSink
type alias added to protocol/types.ts so the dep type stays readable
across HookSession + HookManager.

Adds 5 unit tests for the inlined packet path that the deleted module
never had: PacketInfo shape, session-uin fallback when packet.uin is
empty, pre-login drop, non-real-uin drop, and a no-sink no-op.

184 tests pass (was 179); only failing suite is the pre-existing
macOS-arm64 missing websocket native addon, unrelated.
2026-05-12 14:56:38 +08:00
DrSmoothl 4fc52578a5 refactor(core): split HookManager into PipeWatcher + HookSession
HookManager was a 515-line god class with four tangled responsibilities
(state machine, watcher loop, per-PID serialization, lifecycle plumbing)
and no tests — every code path needed a real QQ.exe to exercise.

Split into three modules:

- PipeWatcher: the only module that polls the OS for QQ.exe processes
  and live SnowLuma named pipes. Emits diff events (process-discovered,
  process-gone, pipe-up, pipe-down, tick). Native list functions are
  constructor deps so tests can drive ticks with in-memory fakes.

- HookSession: per-PID lifecycle. Owns its own state machine, its own
  promise chain (serializes load/unload/refresh/onPipeUp/onPipeDown so
  rapid user clicks don't race), and its own QqHookClient. Emits
  high-level events (login, disconnected, status-changed, disposed)
  that the manager forwards instead of reaching into BridgeManager
  directly. Injector and pipe-client factory are deps for testability.

- HookManager: shrinks to a thin orchestrator (227 lines, was 515).
  Routes user commands and watcher events to sessions, forwards session
  events to BridgeManager, retries stuck-in-connecting sessions on every
  watcher tick (preserves the original tickWatcher reconcile pass).
  Public API (listProcesses/loadProcess/unloadProcess/refreshProcess)
  unchanged for WebUI callers; only the constructor moves to a deps
  object: new HookManager({ ntqq, bridgeManager }).

Behavior preserved including all the edge cases the original code
handles: re-using a SnowLuma DLL that survived a process restart
(fast-path via pipeWatcher.isPipeLive), stale close events from a
replaced client, DLL-unload-failed fallback to "watcher will
reconnect", QQ.exe killed with a live pipe (pipe-down emitted before
process-gone so disconnect runs before dispose).

Adds 27 unit tests across two new vitest suites (hook-session.test.ts
and pipe-watcher.test.ts) covering load fast-path, login emission,
duplicate-login suppression, connect retry on failure, pipe up/down
cycles, unload while logged in, unload verification failure, user
command serialization, and process-gone handling — none of which
previously had test coverage.
2026-05-12 14:43:30 +08:00
DrSmoothl 5ce8aa60de refactor onebot action context modules 2026-05-12 14:40:48 +08:00
snowluma-bot[bot] fd9a239241 Merge pull request #27 from SnowLuma/dev
chore: promote dev → main (8636222)
v1.7.6
2026-05-11 15:32:46 +00:00
DrSmoothl 8636222d91 [merge] fix & upd 2026-05-11 23:30:08 +08:00
DrSmoothl 6f506ff52c fix onebot video file sending 2026-05-11 23:26:45 +08:00
DrSmoothl e917720d9d fix linux hook stale socket detection 2026-05-11 23:14:27 +08:00
DrSmoothl af80855d2d fix(core): persist identities from request events 2026-05-11 19:16:39 +08:00
DrSmoothl 9a30a40a1f feat(core): add persistent identity service 2026-05-11 19:09:05 +08:00
H3CoF6 df5e3a26ac implement some onebot actions (#26)
* feat: implement more group admin action (kick members and set search and set add type)

* feat: implement some user extend func (set group mark \\ get custom emoji)

* feat: implement get send emoji his
2026-05-11 16:00:25 +08:00
DrSmoothl bf0a89e221 fix(core): resolve group join member identity 2026-05-10 17:38:05 +08:00
snowluma-bot[bot] 5a4028d5dc Merge pull request #25 from SnowLuma/dev
chore: promote dev → main (6f6ce11)
v1.7.5
2026-05-10 10:17:23 +08:00
H3CoF6 6f6ce1144b 补全一些onebot action实现 2 (#24)
* feat: implement get profile like

* feat: implement get at all remain

* feat: implement get undirectional friends

* feat: implement set self long nick

* feat: implement set input status

* feat: implement EN 2 ZH

* feat: implement get mini app ark

* feat: implement click inline button

* feat: implement set and send group sign

* feat: implement set qq avatar

* feat: group notice with pic and delete notice implement

* fix(group-notice): POST api and fix new bkn logic
chore.onebot2
2026-05-10 10:14:41 +08:00
snowluma-bot[bot] d02e7cc3ab Merge pull request #23 from SnowLuma/dev
chore: promote dev → main (bec23c4)
2026-05-10 01:32:20 +08:00
DrSmoothl bec23c4dbb [merge] promote SDK changes 2026-05-10 01:29:57 +08:00
DrSmoothl c343021318 feat: add SnowLuma TypeScript SDK 2026-05-10 01:26:35 +08:00
DrSmoothl 9ab4128799 Implement video message upload 2026-05-09 23:06:55 +08:00
手瓜一十雪 95df5234fb Support forced group member cache refresh
Add a `force` flag to member cache refresh logic so events that affect the bot itself trigger an immediate refresh. When the event userUin matches the bot's UIN, `triggerMemberCacheRefresh` sets `force = true` and forwards it to `fetchGroupMemberList`. `fetchGroupMemberList` now accepts an optional `force` parameter (default false) and bypasses the TTL cache when `force` is true, ensuring up-to-date member data after join/leave events.
2026-05-09 19:54:20 +08:00
snowluma-bot[bot] 9938545788 Merge pull request #21 from SnowLuma/dev
chore: promote dev → main (a7b3809)
v1.7.3
2026-05-09 19:40:04 +08:00