From 3e5dca1bd43beeb2b8c18732d69ebba2c08de091 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Wed, 20 May 2026 22:17:47 +0800 Subject: [PATCH] refactor(api): introduce MessageApi + BridgeContext + ApiHub skeleton (#6 commit 1/13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First of 13 commits decomposing the Bridge god-object into typed Api classes following NapCat's `ctx.apis.XxxApi.method()` pattern. This commit lays the foundation (BridgeContext, ApiHub, eager construction) plus moves the message-send/recall/mark-read methods onto MessageApi. Subsequent commits will move the other 12 areas (group admin, files, profile, …) similarly. Architecture ------------ * `bridge-context.ts` — new interface, the surface Api classes see. Carries identity / events / apis / sendRawPacket / resolveUserUid / next{Message,Client}Sequence helpers / upload meta cache. Replaces the previous `BridgeInterface` for Api-class consumption (the legacy interface still exists, just slimmed; full removal happens in commit 13). * `apis/index.ts` — `ApiHub` interface + `buildApiHub(ctx)` factory. Each commit grows `ApiHub` by one entry; today only `message` is populated. * `apis/message.ts` — `MessageApi` with seven methods inlined from the previous Bridge implementation: sendGroup ← Bridge.sendGroupMessage sendPrivate ← Bridge.sendPrivateMessage sendC2cFile ← Bridge.sendC2cFileMessage recallGroup ← actions/group-message.ts::recallGroupMessage recallPrivate ← actions/group-message.ts::recallPrivateMessage markGroupRead ← actions/group-message.ts::markGroupMessageRead markPrivateRead ← actions/group-message.ts::markPrivateMessageRead (Names trimmed: the `apis.message` prefix already signals the context, so `apis.message.sendGroupMessage` would be redundant.) Bridge changes -------------- * Removed the 7 methods listed above (moved to MessageApi). * Removed unused imports + `SEND_MSG_CMD` constant + `buildSendElems` + `protobuf_encode/decode` (now used only inside MessageApi). * Added `readonly apis: ApiHub` field, built eagerly in constructor. * Promoted `nextMessageRandom` / `nextClientSequence` from private → public (Api classes need them to build SendMessageRequest packets). * `BridgeInterface` now also lists `apis: ApiHub` + the two generators; the 7 deleted method signatures are gone. Caller-site updates (~9 files) ------------------------------ * `bridge/actions/group-file.ts` — `bridge.sendC2cFileMessage(...)` → `bridge.apis.message.sendC2cFile(...)`. * `onebot/instance-context.ts` — markGroupMsgAsRead / markPrivateMsgAsRead bindings now route through `apis.message.markGroup/PrivateRead`. * `onebot/modules/message-actions.ts` — every send/recall site rerouted; `typeof ref.bridge.sendXxx` type refs also updated. * `onebot/actions/extended.ts` — same. Tests (~5 files) ---------------- * `tests/actions/_helpers.ts` — `mockBridge()` grows an `apis` field built by `mockApiHub()`; the previous flat `sendGroupMessage` etc. vi.fn stubs are replaced with `mockMessageApi()`. * All test files using direct `bridge.sendXxx` / `bridge.recallXxx` / `bridge.markXxxAsRead` calls or stubs updated to `bridge.apis.message.*`. * No behaviour changes — 587/587 pass (same baseline; pre-existing network-manager native-addon failure unchanged). --- .../core/src/bridge/actions/group-file.ts | 2 +- packages/core/src/bridge/apis/index.ts | 60 +++ packages/core/src/bridge/apis/message.ts | 345 ++++++++++++++++++ packages/core/src/bridge/bridge-context.ts | 67 ++++ packages/core/src/bridge/bridge-interface.ts | 37 +- packages/core/src/bridge/bridge.ts | 283 ++------------ packages/core/src/onebot/actions/extended.ts | 8 +- packages/core/src/onebot/instance-context.ts | 4 +- .../src/onebot/modules/message-actions.ts | 22 +- packages/core/tests/actions/_helpers.ts | 54 ++- .../core/tests/actions/group-file.test.ts | 10 +- .../core/tests/bridge-private-routing.test.ts | 4 +- .../core/tests/onebot/forward-nested.test.ts | 10 +- .../core/tests/onebot/send-group-file.test.ts | 6 +- .../onebot/send-private-c2c-file.test.ts | 18 +- 15 files changed, 598 insertions(+), 332 deletions(-) create mode 100644 packages/core/src/bridge/apis/index.ts create mode 100644 packages/core/src/bridge/apis/message.ts create mode 100644 packages/core/src/bridge/bridge-context.ts diff --git a/packages/core/src/bridge/actions/group-file.ts b/packages/core/src/bridge/actions/group-file.ts index fc51a037..8649da60 100644 --- a/packages/core/src/bridge/actions/group-file.ts +++ b/packages/core/src/bridge/actions/group-file.ts @@ -617,7 +617,7 @@ export async function uploadPrivateFile( // the recipient sees nothing. if (uploadFile) { try { - await bridge.sendC2cFileMessage(userId, targetUid, { + await bridge.apis.message.sendC2cFile(userId, targetUid, { fileId, fileName, fileSize: loaded.bytes.length, diff --git a/packages/core/src/bridge/apis/index.ts b/packages/core/src/bridge/apis/index.ts new file mode 100644 index 00000000..4a4b1505 --- /dev/null +++ b/packages/core/src/bridge/apis/index.ts @@ -0,0 +1,60 @@ +// ApiHub — the per-instance bag of typed Api classes hanging off +// `bridge.apis`. Mirrors NapCat's `NapCatCore.apis = { MsgApi, …}` +// pattern but uses camelCase keys (`apis.message.sendGroup`) to match +// the rest of the SnowLuma TypeScript style. +// +// Each Api class is constructed once when the parent `Bridge` is +// constructed (eager — every account that connects gets its own +// `apis.*` set immediately). Api classes receive a `BridgeContext` +// reference, NOT the concrete `Bridge`, so they remain easy to mock +// without standing up a full Bridge instance. +// +// Adding a new Api: +// 1. Create `bridge/apis/.ts` exporting a `class XxxApi`. +// 2. Add a property here. +// 3. Construct it inside `buildApiHub(ctx)` below. +// 4. Move the relevant methods OFF Bridge and onto the new Api. +// +// This file evolves commit-by-commit as `#6 NapCat-style Api on ctx` +// lands the 13 areas. Until all 13 are in place, ApiHub will have a +// mix of "done" entries and "TODO" markers below — the TODO list +// shrinks as each commit lands. + +import type { BridgeContext } from '../bridge-context'; +import { MessageApi } from './message'; + +export interface ApiHub { + /** Send/recall/markRead operations across c2c + group + c2c-file. */ + readonly message: MessageApi; + // Pending — added as later commits land: + // readonly contacts: ContactsApi; + // readonly groupAdmin: GroupAdminApi; + // readonly groupFile: GroupFileApi; + // readonly groupAlbum: GroupAlbumApi; + // readonly friend: FriendApi; + // readonly interaction: InteractionApi; + // readonly profile: ProfileApi; + // readonly forward: ForwardApi; + // readonly misc: MiscApi; + // readonly extras: ExtrasApi; + // readonly web: WebApi; +} + +/** + * Construct the ApiHub for a Bridge. Called once from the Bridge + * constructor. Eager construction means every Bridge instance pays + * the cost up-front (a few object allocations) — there's no lazy + * path because that would require either a thunk-based wrapper or + * runtime-mutated `apis.xxx` slots, neither of which is worth the + * complexity for ~13 small classes. + */ +export function buildApiHub(ctx: BridgeContext): ApiHub { + return { + message: new MessageApi(ctx), + }; +} + +// Re-export the Api classes themselves so callers can write +// `import type { MessageApi } from '@snowluma/core/.../apis'` for +// signature use. Concrete instances always come from `bridge.apis.*`. +export { MessageApi } from './message'; diff --git a/packages/core/src/bridge/apis/message.ts b/packages/core/src/bridge/apis/message.ts new file mode 100644 index 00000000..c77844ac --- /dev/null +++ b/packages/core/src/bridge/apis/message.ts @@ -0,0 +1,345 @@ +// MessageApi — send / recall / mark-read operations. +// +// Folds together the previous shapes: +// - `Bridge.sendGroupMessage` / `sendPrivateMessage` / `sendC2cFileMessage` +// (formerly inline on the Bridge class). +// - `actions/group-message.ts` (recallGroup / recallPrivate / +// markGroupRead / markPrivateRead). +// +// All implementations are inlined here — the previous indirection +// through `actions/group-message.ts` exported functions was just +// `(bridge, args) → method body`; once methods live on a typed Api +// class the wrapper layer doesn't earn its keep. + +import { protobuf_decode, protobuf_encode } from '@snowluma/proton'; +import type { + SendMessageRequest, + SendMessageResponse, +} from '@snowluma/proto-defs/action'; +import type { FileExtra } from '@snowluma/proto-defs/message'; +import type { + C2CRecallRequest, + GroupRecallRequest, + SsoReadedReportReq, +} from '@snowluma/proto-defs/oidb-actions/base'; +import type { MessageElement } from '../events'; +import { buildSendElems } from '../element-builder'; +import type { BridgeContext } from '../bridge-context'; +// `Bridge` is imported as a type only so we can narrow `ctx` back to +// the concrete Bridge instance when passing it to `buildSendElems` +// (which still takes `Bridge` because the highway upload helpers it +// transitively calls each take `Bridge` — refactoring those is a +// separate concern that doesn't need to land alongside the Api split). +// +// At runtime `ctx` IS the Bridge instance that constructed this +// MessageApi — `buildApiHub(ctx)` passes the Bridge itself. +import type { Bridge, SendMessageReceipt } from '../bridge'; + +const SEND_MSG_CMD = 'MessageSvc.PbSendMsg'; + +export class MessageApi { + constructor(private readonly ctx: BridgeContext) {} + + /** + * Send a message to a QQ group. + * + * Wraps `MessageSvc.PbSendMsg` with `routingHead.grp.groupCode`. + * Media elements (image / record / video) trigger highway uploads + * inside `buildSendElems` — see `element-builder.ts`. + * + * Returns a `SendMessageReceipt` carrying the assigned messageId, + * group sequence, and timestamps; callers cache this for later + * `recall` / reply lookups. + */ + async sendGroup(groupId: number, elements: MessageElement[]): Promise { + if (elements.length === 0) throw new Error('message is empty'); + + const protoElems = await buildSendElems(elements, { bridge: this.ctx as unknown as Bridge, groupId }); + const random = this.ctx.nextMessageRandom(); + + const request = protobuf_encode({ + routingHead: { + grp: { groupCode: BigInt(groupId) }, + }, + contentHead: { + type: 1, + }, + messageBody: { + richText: { + elems: protoElems, + }, + } as any, + clientSequence: 0, + random, + syncCookie: new Uint8Array(0), + via: 0, + dataStatist: 0, + multiSendSeq: 0, + }); + + const result = await this.ctx.sendRawPacket(SEND_MSG_CMD, request); + if (!result.success || !result.gotResponse || !result.responseData) { + throw new Error(`send group message failed: ${result.errorMessage || 'no response'}`); + } + + const response = protobuf_decode(result.responseData); + if (!response) throw new Error('failed to decode SendMessageResponse'); + if (response.result != null && response.result !== 0) { + throw new Error(`send group message rejected: result=${response.result} err=${response.errMsg ?? ''}`); + } + + const seq = response.groupSequence ?? 0; + const messageId = (random & 0x7FFFFFFF) || seq; + const timestamp = response.timestamp1 ?? Math.floor(Date.now() / 1000); + return { messageId, sequence: seq, clientSequence: 0, random, timestamp }; + } + + /** + * Send a c2c (private) message. + * + * Resolves the recipient's UID only when the message carries media + * — text-only messages skip the lookup. Routes through `c2c.uin` + * (and optionally `c2c.uid` for the media case). + */ + async sendPrivate(userUin: number, elements: MessageElement[]): Promise { + if (elements.length === 0) throw new Error('message is empty'); + + let userUid = ''; + const hasMedia = elements.some(e => e.type === 'image' || e.type === 'record' || e.type === 'video'); + if (hasMedia) { + userUid = await this.ctx.resolveUserUid(userUin); + } + + const protoElems = await buildSendElems(elements, { bridge: this.ctx as unknown as Bridge, userUid }); + const random = this.ctx.nextMessageRandom(); + const clientSeq = this.ctx.nextClientSequence(); + + const request = protobuf_encode({ + routingHead: { + c2c: { + uin: userUin, + ...(userUid ? { uid: userUid } : {}), + }, + }, + contentHead: { + type: 1, + subType: 0, + c2cCmd: 11, + }, + messageBody: { + richText: { + elems: protoElems, + }, + } as any, + clientSequence: clientSeq, + random, + syncCookie: new Uint8Array(0), + via: 0, + dataStatist: 0, + ctrl: { + msgFlag: Math.floor(Date.now() / 1000), + }, + multiSendSeq: 0, + }); + + const result = await this.ctx.sendRawPacket(SEND_MSG_CMD, request); + if (!result.success || !result.gotResponse || !result.responseData) { + throw new Error(`send private message failed: ${result.errorMessage || 'no response'}`); + } + + const response = protobuf_decode(result.responseData); + if (!response) throw new Error('failed to decode SendMessageResponse'); + if (response.result != null && response.result !== 0) { + throw new Error(`send private message rejected: result=${response.result} err=${response.errMsg ?? ''}`); + } + + const seq = response.privateSequence ?? 0; + const messageId = (random & 0x7FFFFFFF) || seq; + const timestamp = response.timestamp1 ?? Math.floor(Date.now() / 1000); + return { messageId, sequence: seq, clientSequence: clientSeq, random, timestamp }; + } + + /** + * Send a c2c file as a chat message. + * + * The wire shape isn't the same as a regular c2c message — the c2c + * file path uses three slots that differ from a normal text/image + * send (verified against `dev/Lagrange.Core/.../MessagePacker.cs: + * BuildPacketBase` + `FileEntity.PackMessageContent`): + * + * 1. `routingHead.trans0x211 { ccCmd: 4, uid: peer }` instead of + * `routingHead.c2c { uin, uid }`. The server rejects c2c file + * messages routed through the regular c2c slot. + * 2. `messageBody.msgContent` carries the serialised + * `FileExtra { file: NotOnlineFile }` bytes. NOT + * `richText.notOnlineFile` — the receiver doesn't read that + * slot for file metadata. + * 3. `contentHead.c2cCmd` left at 0 (Lagrange's default). The + * previous `c2cCmd: 11` was a stale go-cqhttp value the QQ-NT + * server doesn't recognise. + * + * NotOnlineFile carries three required-on-send fields the receiver + * itself ignores but the server's intake validator checks: + * - `subcmd: 1` — c2c file send command code + * - `dangerEvel: 0` — virus-scan severity, always 0 client-side + * - `expireTime` — 7 days from now (Lagrange convention) + */ + async sendC2cFile( + userUin: number, + userUid: string, + info: { fileId: string; fileName: string; fileSize: number; fileMd5: Uint8Array; fileHash?: string }, + ): Promise { + const random = this.ctx.nextMessageRandom(); + const clientSeq = this.ctx.nextClientSequence(); + + const nowSec = Math.floor(Date.now() / 1000); + const sevenDaysSec = 7 * 24 * 60 * 60; + const fileExtraBytes = protobuf_encode({ + file: { + fileType: 0, + fileUuid: info.fileId, + fileMd5: info.fileMd5, + fileName: info.fileName, + fileSize: BigInt(info.fileSize), + subcmd: 1, + dangerEvel: 0, + expireTime: nowSec + sevenDaysSec, + fileHash: info.fileHash ?? '', + }, + }); + + const request = protobuf_encode({ + routingHead: { + trans0x211: { ccCmd: 4, uid: userUid }, + }, + contentHead: { + type: 1, + subType: 0, + }, + messageBody: { + msgContent: fileExtraBytes, + }, + clientSequence: clientSeq, + random, + syncCookie: new Uint8Array(0), + via: 0, + dataStatist: 0, + ctrl: { msgFlag: nowSec }, + multiSendSeq: 0, + }); + + // `userUin` is part of the public contract (the OneBot layer + // threads it through for symmetry with `sendPrivate`) but the + // wire shape only needs the uid. Silence the unused-parameter + // lint without changing the signature. + void userUin; + + const result = await this.ctx.sendRawPacket(SEND_MSG_CMD, request); + if (!result.success || !result.gotResponse || !result.responseData) { + throw new Error(`send c2c file message failed: ${result.errorMessage || 'no response'}`); + } + const response = protobuf_decode(result.responseData); + if (!response) throw new Error('failed to decode SendMessageResponse'); + if (response.result != null && response.result !== 0) { + throw new Error(`send c2c file message rejected: result=${response.result} err=${response.errMsg ?? ''}`); + } + const seq = response.privateSequence ?? 0; + const messageId = (random & 0x7FFFFFFF) || seq; + const timestamp = response.timestamp1 ?? Math.floor(Date.now() / 1000); + return { messageId, sequence: seq, clientSequence: clientSeq, random, timestamp }; + } + + /** + * Recall (revoke) a group message by sequence number. Server-side + * the message ages out after the standard 2-minute window unless + * the user is an admin/owner. + */ + async recallGroup(groupId: number, sequence: number): Promise { + const request = protobuf_encode({ + type: 1, + groupUin: groupId, + info: { sequence, random: 0, field3: 0 }, + settings: { field1: 0 }, + }); + const result = await this.ctx.sendRawPacket('trpc.msg.msg_svc.MsgService.SsoGroupRecallMsg', request); + if (!result.success) throw new Error(result.errorMessage || 'recall group message failed'); + } + + /** + * Recall a c2c (private) message. Needs more positional info than + * the group variant because c2c messages are identified by the + * `(clientSequence, msgSequence, random, timestamp)` tuple rather + * than a single group-side sequence. + */ + async recallPrivate( + userUin: number, + clientSeq: number, + msgSeq: number, + random: number, + timestamp: number, + ): Promise { + const targetUid = await this.ctx.resolveUserUid(userUin); + const request = protobuf_encode({ + type: 1, + targetUid, + info: { + clientSequence: clientSeq, + random, + messageId: BigInt((0x01000000 * 0x100000000) + random), + timestamp, + field5: 0, + messageSequence: msgSeq, + }, + settings: { field1: false, field2: false }, + field6: false, + }); + const result = await this.ctx.sendRawPacket('trpc.msg.msg_svc.MsgService.SsoC2CRecallMsg', request); + if (!result.success) throw new Error(result.errorMessage || 'recall private message failed'); + } + + /** + * Push a group read-report (`trpc.msg.msg_svc.MsgService.SsoReadedReport`) + * advancing the bot's last-read marker to `sequence`. Mirrors the QQ + * NT client's own "scrolled to bottom" behaviour so subsequent + * sync packets don't replay messages we've already processed. + */ + async markGroupRead(groupId: number, sequence: number): Promise { + const request = protobuf_encode({ + groupList: [ + { + groupUin: BigInt(groupId), + lastReadSeq: BigInt(sequence), + }, + ], + }); + const result = await this.ctx.sendRawPacket('trpc.msg.msg_svc.MsgService.SsoReadedReport', request); + if (!result.success) { + throw new Error(result.errorMessage || 'mark group message read failed'); + } + } + + /** + * Push a c2c read-report. Same wire-cmd as the group variant but + * keyed by friend uid (resolved from uin via IdentityService). + * `lastReadTime` defaults to "now" — matches the QQ NT client + * which also uses wall-clock seconds rather than the read-message + * timestamp. + */ + async markPrivateRead(userId: number, sequence: number): Promise { + const uid = await this.ctx.resolveUserUid(userId); + const request = protobuf_encode({ + c2cList: [ + { + uid, + lastReadTime: BigInt(Math.floor(Date.now() / 1000)), + lastReadSeq: BigInt(sequence), + }, + ], + }); + const result = await this.ctx.sendRawPacket('trpc.msg.msg_svc.MsgService.SsoReadedReport', request); + if (!result.success) { + throw new Error(result.errorMessage || 'mark private message read failed'); + } + } +} + diff --git a/packages/core/src/bridge/bridge-context.ts b/packages/core/src/bridge/bridge-context.ts new file mode 100644 index 00000000..8a68653b --- /dev/null +++ b/packages/core/src/bridge/bridge-context.ts @@ -0,0 +1,67 @@ +// BridgeContext — the surface that Api classes (and any external +// consumer that doesn't need the full Bridge concrete class) see. +// +// Replaces the previous `BridgeInterface`, which had grown to ~170 lines +// listing every Bridge method one-by-one and was a maintenance burden +// (every new feature meant +1 signature here, +1 method on Bridge, +1 +// stub in `mockBridge`). The new design splits business methods OUT to +// `apis.xxx.method()` — this interface now carries only the +// "infrastructure" surface every Api class needs: +// +// - state: identity / events / apis (per-instance) +// - raw I/O: sendRawPacket, resolveUserUid +// - protocol helpers: nextMessageRandom / nextClientSequence +// - upload metadata cache: rememberUploadedFile / recallUploadedFile +// +// Anything beyond that lives in `apis..method()` — see +// `bridge/apis/index.ts` for the ApiHub shape. +// +// The concrete `Bridge` class still `implements BridgeContext`, so +// passing a `Bridge` where a `BridgeContext` is expected is a free +// upcast. Test fakes go the other direction: build a small object that +// only stubs the fields under test (instead of the previous 70+ method +// `MockBridge` god-object) and pass it as `BridgeContext`. + +import type { IdentityService } from './identity-service'; +import type { BridgeEventBus } from './event-bus'; +import type { ApiHub } from './apis'; +import type { SendPacketResult } from '../protocol/packet-sender'; +import type { UploadedFileMeta } from './bridge'; + +export interface BridgeContext { + // ── Per-instance state (one set per QQ account in a multi-account + // runtime; `BridgeManager` allocates one `Bridge` per uin and + // each Bridge owns its own identity/events/pipeline/apis). ── + readonly identity: IdentityService; + readonly events: BridgeEventBus; + readonly apis: ApiHub; + + // ── Raw I/O ── + /** Escape hatch for arbitrary protocol calls (used by Api classes + * and by the `send_packet` OneBot action). Every typed wrapper on an + * Api class eventually routes through here. */ + sendRawPacket(serviceCmd: string, body: Uint8Array, timeoutMs?: number): Promise; + /** uin → uid lookup (cached in IdentityService). `groupId` is an + * optional hint that lets the resolver fall back to a group-member + * roster when the friend list doesn't have the uin. */ + resolveUserUid(uin: number, groupId?: number): Promise; + + // ── Send-side protocol helpers (Api classes that build a + // `SendMessageRequest` need these monotonic generators to fill in + // `clientSequence` and `random`). They were previously `private` + // on Bridge — promoted to public so the new Api classes can call + // them without `(bridge as any).nextXxx()`. ── + nextMessageRandom(): number; + nextClientSequence(): number; + + // ── Uploaded-file metadata cache ── + // + // Bookkeeping shared between `GroupFileApi.upload*File` (writes the + // tuple at upload time) and `MessageApi.sendPrivateMessage` (reads + // it on later `send_msg` calls that carry only a file_id). Lives at + // this layer rather than inside `GroupFileApi` because the message + // send path is what queries the cache, and it shouldn't have to + // reach into a different Api class for an infrastructure lookup. + rememberUploadedFile(meta: UploadedFileMeta): void; + recallUploadedFile(fileId: string): UploadedFileMeta | undefined; +} diff --git a/packages/core/src/bridge/bridge-interface.ts b/packages/core/src/bridge/bridge-interface.ts index 1da16ba3..9ae7afc3 100644 --- a/packages/core/src/bridge/bridge-interface.ts +++ b/packages/core/src/bridge/bridge-interface.ts @@ -1,14 +1,14 @@ import type { SendPacketResult } from '../protocol/packet-sender'; +import type { ApiHub } from './apis'; import type { GroupFilesResult } from './actions/group-file'; import type { MediaIndexNode } from './actions/shared'; import type { ClientKeyInfo, DownloadRKeyInfo, - SendMessageReceipt, UploadedFileMeta, } from './bridge'; import type { BridgeEventBus } from './event-bus'; -import type { ForwardNodePayload, MessageElement } from './events'; +import type { ForwardNodePayload } from './events'; import type { IdentityService } from './identity-service'; import type { FriendInfo, @@ -24,6 +24,13 @@ export interface BridgeInterface { readonly identity: IdentityService; readonly events: BridgeEventBus; readonly activePid: number | null; + /** + * Typed Api hub — `apis.message.sendGroup(...)`, `apis.message.recallGroup(...)`, + * etc. The shape of `ApiHub` grows commit-by-commit as the #6 + * refactor moves the remaining areas (group-admin, group-file, + * profile, …) off Bridge and onto their dedicated Api classes. + */ + readonly apis: ApiHub; // ─── Resolution ─── resolveUserUid(uin: number, groupId?: number): Promise; @@ -34,21 +41,13 @@ export interface BridgeInterface { // routes through here. sendRawPacket(serviceCmd: string, body: Uint8Array, timeoutMs?: number): Promise; + // ─── Send-side protocol helpers (used by Api classes) ─── + nextMessageRandom(): number; + nextClientSequence(): number; + // ─── Send (messages) ─── - sendGroupMessage(groupId: number, elements: MessageElement[]): Promise; - sendPrivateMessage(userUin: number, elements: MessageElement[]): Promise; - /** - * Send a c2c file as a chat message. Bypasses the regular elems[] - * pipeline because c2c files live on `RichText.notOnlineFile`, not - * inside the elems array. Group files go through `sendGroupMessage` - * with a `{type:'file', fileId, fileName, fileSize, md5Hex, sha1Hex}` - * element instead. - */ - sendC2cFileMessage( - userUin: number, - userUid: string, - info: { fileId: string; fileName: string; fileSize: number; fileMd5: Uint8Array; fileHash?: string }, - ): Promise; + // Moved to `apis.message.{sendGroup, sendPrivate, sendC2cFile, + // recallGroup, recallPrivate, markGroupRead, markPrivateRead}`. // ─── Fetch (contacts / profile / system) ─── fetchFriendList(): Promise; @@ -119,11 +118,7 @@ export interface BridgeInterface { uploadForwardNodes(nodes: ForwardNodePayload[], groupId?: number, userId?: number): Promise; fetchForwardNodes(resId: string): Promise; - // ─── Message ops ─── - recallGroupMessage(groupId: number, sequence: number): Promise; - recallPrivateMessage(userUin: number, clientSeq: number, msgSeq: number, random: number, timestamp: number): Promise; - markGroupMsgAsRead(groupId: number, sequence: number): Promise; - markPrivateMsgAsRead(userId: number, sequence: number): Promise; + // ─── Message ops (moved to apis.message) ─── // ─── Interaction ─── sendPoke(isGroup: boolean, peerUin: number, targetUin?: number): Promise; diff --git a/packages/core/src/bridge/bridge.ts b/packages/core/src/bridge/bridge.ts index 29263c96..8897b4ba 100644 --- a/packages/core/src/bridge/bridge.ts +++ b/packages/core/src/bridge/bridge.ts @@ -1,18 +1,12 @@ -import { protobuf_decode, protobuf_encode } from '@snowluma/proton'; import type { PacketSender, SendPacketResult } from '../protocol/packet-sender'; import type { PacketInfo } from '../protocol/types'; import type { BridgeInterface } from './bridge-interface'; -import { buildSendElems } from './element-builder'; -import type { ForwardNodePayload, MessageElement } from './events'; +import type { ForwardNodePayload } from './events'; import { IdentityService } from './identity-service'; import { MSG_PUSH_CMD, parseMsgPush } from './msg-push'; import { IncomingPacketPipeline, type CmdParser } from './packet-pipeline'; -import type { - SendMessageRequest, - SendMessageResponse, -} from '@snowluma/proto-defs/action'; -import type { FileExtra } from '@snowluma/proto-defs/message'; import type { FriendInfo, GroupMemberInfo, GroupRequestInfo, QQGroupInfo, UserProfileInfo } from './qq-info'; +import { type ApiHub, buildApiHub } from './apis'; // Delegated modules import { @@ -78,16 +72,6 @@ import { uploadPrivateFile as uploadPrivateFile_, } from './actions/group-file'; import { - // ⚠️ pre-fix history note: these two aliases were swapped here - // (`markPrivateMessageRead as markGroupMsgAsRead_` / - // `markGroupMessageRead as markPrivateMsgAsRead_`), meaning - // `bridge.markGroupMsgAsRead(groupId, …)` actually fired the c2c - // read-report wire shape with `groupId` in the `userId` slot, and - // vice versa. Realigned with their intended names. - markGroupMessageRead as markGroupMsgAsRead_, - markPrivateMessageRead as markPrivateMsgAsRead_, - recallGroupMessage as recallGroupMessage_, - recallPrivateMessage as recallPrivateMessage_, setGroupEssence as setGroupEssence_, } from './actions/group-message'; import { @@ -188,8 +172,6 @@ export interface ClientKeyInfo { } export class Bridge implements BridgeInterface { - private static readonly SEND_MSG_CMD = 'MessageSvc.PbSendMsg'; - readonly identity: IdentityService; private pids_ = new Set(); /** @@ -198,6 +180,14 @@ export class Bridge implements BridgeInterface { * care about and the pipeline fans out in parallel. */ readonly events = new BridgeEventBus(); + /** + * Typed Api hub. Each entry is a class encapsulating one logical + * area of the QQ protocol (sending messages, group admin, file + * uploads, etc.). Built eagerly in the constructor — every Bridge + * instance gets its own `apis.*` set with `this` (typed as + * `BridgeContext`) injected. See `apis/index.ts`. + */ + readonly apis: ApiHub; private readonly pipeline: IncomingPacketPipeline; private packetClient_: PacketSender | null = null; // Throttle for fetchGroupMemberList(groupId): coalesces in-flight calls @@ -245,6 +235,10 @@ export class Bridge implements BridgeInterface { this.refreshMemberCache(groupId, refreshGroupList, forceMemberList), }); this.pipeline.registerCmd(MSG_PUSH_CMD, parseMsgPush); + // Build Api hub eagerly. The Bridge instance IS the BridgeContext + // — `this implements BridgeContext` is enforced via the + // `BridgeInterface` declaration, which itself extends BridgeContext. + this.apis = buildApiHub(this); } dispose(): void { @@ -326,12 +320,18 @@ export class Bridge implements BridgeInterface { } // --- Sequence / random generators --- + // + // `public` (formerly `private`) because the Api classes in + // `apis/*.ts` need them to build `SendMessageRequest` packets. Part + // of the `BridgeContext` surface, so a third party that only sees + // `BridgeContext` can still synthesise wire packets without reaching + // into the concrete Bridge class. - private nextClientSequence(): number { + nextClientSequence(): number { return ++this.clientSeq_; } - private nextMessageRandom(): number { + nextMessageRandom(): number { this.msgRandom_ = (this.msgRandom_ + 0x9E3779B9) >>> 0; return this.msgRandom_ & 0x7FFFFFFF; } @@ -348,236 +348,10 @@ export class Bridge implements BridgeInterface { return this.packetClient_.sendPacket(serviceCmd, Buffer.from(body), timeoutMs); } - // --- Send message (high-level) --- - - async sendGroupMessage(groupId: number, elements: MessageElement[]): Promise { - if (elements.length === 0) throw new Error('message is empty'); - - const protoElems = await buildSendElems(elements, { bridge: this, groupId }); - const random = this.nextMessageRandom(); - - const request = protobuf_encode({ - routingHead: { - grp: { groupCode: BigInt(groupId) }, - }, - contentHead: { - type: 1, - }, - messageBody: { - richText: { - elems: protoElems, - }, - } as any, - clientSequence: 0, - random, - syncCookie: new Uint8Array(0), - via: 0, - dataStatist: 0, - multiSendSeq: 0, - }); - - const result = await this.sendRawPacket(Bridge.SEND_MSG_CMD, request); - - if (!result.success || !result.gotResponse || !result.responseData) { - throw new Error(`send group message failed: ${result.errorMessage || 'no response'}`); - } - - const response = protobuf_decode(result.responseData); - if (!response) { - throw new Error('failed to decode SendMessageResponse'); - } - if (response.result != null && response.result !== 0) { - throw new Error(`send group message rejected: result=${response.result} err=${response.errMsg ?? ''}`); - } - - const seq = response.groupSequence ?? 0; - const messageId = (random & 0x7FFFFFFF) || seq; - const timestamp = response.timestamp1 ?? Math.floor(Date.now() / 1000); - - return { - messageId, - sequence: seq, - clientSequence: 0, - random, - timestamp, - }; - } - - async sendPrivateMessage(userUin: number, elements: MessageElement[]): Promise { - if (elements.length === 0) throw new Error('message is empty'); - - // Resolve UID for media upload and the final c2c routing head. - let userUid = ''; - const hasMedia = elements.some(e => e.type === 'image' || e.type === 'record' || e.type === 'video'); - if (hasMedia) { - userUid = await this.resolveUserUid(userUin); - } - - const protoElems = await buildSendElems(elements, { bridge: this, userUid }); - const random = this.nextMessageRandom(); - const clientSeq = this.nextClientSequence(); - - const request = protobuf_encode({ - routingHead: { - c2c: { - uin: userUin, - ...(userUid ? { uid: userUid } : {}), - }, - }, - contentHead: { - type: 1, - subType: 0, - c2cCmd: 11, - }, - messageBody: { - richText: { - elems: protoElems, - }, - } as any, - clientSequence: clientSeq, - random, - syncCookie: new Uint8Array(0), - via: 0, - dataStatist: 0, - ctrl: { - msgFlag: Math.floor(Date.now() / 1000), - }, - multiSendSeq: 0, - }); - - const result = await this.sendRawPacket(Bridge.SEND_MSG_CMD, request); - - if (!result.success || !result.gotResponse || !result.responseData) { - throw new Error(`send private message failed: ${result.errorMessage || 'no response'}`); - } - - const response = protobuf_decode(result.responseData); - if (!response) { - throw new Error('failed to decode SendMessageResponse'); - } - if (response.result != null && response.result !== 0) { - throw new Error(`send private message rejected: result=${response.result} err=${response.errMsg ?? ''}`); - } - - const seq = response.privateSequence ?? 0; - const messageId = (random & 0x7FFFFFFF) || seq; - const timestamp = response.timestamp1 ?? Math.floor(Date.now() / 1000); - - return { - messageId, - sequence: seq, - clientSequence: clientSeq, - random, - timestamp, - }; - } - - /** - * Send a c2c file as a chat message. - * - * The wire shape isn't the same as a regular c2c message — the c2c - * file path uses three slots that differ from a normal text/image - * send (verified against `dev/Lagrange.Core/.../MessagePacker.cs: - * BuildPacketBase` + `FileEntity.PackMessageContent`): - * - * 1. `routingHead.trans0x211 { ccCmd: 4, uid: peer }` instead of - * `routingHead.c2c { uin, uid }`. The server rejects c2c file - * messages routed through the regular c2c slot. - * 2. `messageBody.msgContent` carries the serialised - * `FileExtra { file: NotOnlineFile }` bytes. NOT - * `richText.notOnlineFile` — the receiver doesn't read that - * slot for file metadata. - * 3. `contentHead.c2cCmd` left at 0 (Lagrange's default). The - * previous `c2cCmd: 11` was a stale go-cqhttp value the QQ-NT - * server doesn't recognise. - * - * NotOnlineFile carries three required-on-send fields the receiver - * itself ignores but the server's intake validator checks: - * - `subcmd: 1` — c2c file send command code - * - `dangerEvel: 0` — virus-scan severity, always 0 client-side - * - `expireTime` — 7 days from now (Lagrange convention) - */ - async sendC2cFileMessage( - userUin: number, - userUid: string, - info: { fileId: string; fileName: string; fileSize: number; fileMd5: Uint8Array; fileHash?: string }, - ): Promise { - const random = this.nextMessageRandom(); - const clientSeq = this.nextClientSequence(); - - const nowSec = Math.floor(Date.now() / 1000); - const sevenDaysSec = 7 * 24 * 60 * 60; - // Serialise `FileExtra { file: NotOnlineFile }` for `msgContent`. - // The NotOnlineFile field tags (1/3/4/5/6/9/50/55/57) are shared - // between send and receive — the schema is symmetric. - const fileExtraBytes = protobuf_encode({ - file: { - fileType: 0, - fileUuid: info.fileId, - fileMd5: info.fileMd5, - fileName: info.fileName, - fileSize: BigInt(info.fileSize), - subcmd: 1, - dangerEvel: 0, - expireTime: nowSec + sevenDaysSec, - fileHash: info.fileHash ?? '', - }, - }); - - const request = protobuf_encode({ - routingHead: { - // c2c-file scene: route through `trans0x211` (field 15) with - // ccCmd=4. The regular `c2c { uin, uid }` routing slot causes - // the server to reject this with a routing-mismatch error. - trans0x211: { ccCmd: 4, uid: userUid }, - }, - contentHead: { - type: 1, - subType: 0, - // c2cCmd intentionally omitted (defaults to 0). Was `11`, - // which produced an unknown-command error in the QQ-NT - // server's c2c file handler. `userUin` is unused on this path - // (routing carries the uid only) but kept in the function - // signature for symmetry with the OneBot caller. - }, - messageBody: { - // No elems — the file metadata lives in msgContent below. - msgContent: fileExtraBytes, - }, - clientSequence: clientSeq, - random, - syncCookie: new Uint8Array(0), - via: 0, - dataStatist: 0, - ctrl: { - msgFlag: nowSec, - }, - multiSendSeq: 0, - }); - - // Silence the unused-parameter lint — `userUin` is part of our - // BridgeInterface contract (the OneBot layer threads it through) - // but the wire shape only needs the uid. - void userUin; - - const result = await this.sendRawPacket(Bridge.SEND_MSG_CMD, request); - if (!result.success || !result.gotResponse || !result.responseData) { - throw new Error(`send c2c file message failed: ${result.errorMessage || 'no response'}`); - } - - const response = protobuf_decode(result.responseData); - if (!response) { - throw new Error('failed to decode SendMessageResponse'); - } - if (response.result != null && response.result !== 0) { - throw new Error(`send c2c file message rejected: result=${response.result} err=${response.errMsg ?? ''}`); - } - - const seq = response.privateSequence ?? 0; - const messageId = (random & 0x7FFFFFFF) || seq; - const timestamp = response.timestamp1 ?? Math.floor(Date.now() / 1000); - return { messageId, sequence: seq, clientSequence: clientSeq, random, timestamp }; - } + // `Bridge.SEND_MSG_CMD` and the inline `sendGroupMessage` / + // `sendPrivateMessage` / `sendC2cFileMessage` implementations were + // moved to `apis/message.ts::MessageApi` as part of the #6 + // Api-on-ctx refactor. Callers route through `bridge.apis.message.*`. // --- Delegated: OIDB helpers --- @@ -657,10 +431,7 @@ export class Bridge implements BridgeInterface { async sendLike(userId: number, count: number): Promise { return sendLike_(this, userId, count); } async setGroupEssence(groupId: number, sequence: number, random: number, enable: boolean): Promise { return setGroupEssence_(this, groupId, sequence, random, enable); } async setGroupReaction(groupId: number, sequence: number, code: string, isSet: boolean): Promise { return setGroupReaction_(this, groupId, sequence, code, isSet); } - async recallGroupMessage(groupId: number, sequence: number): Promise { return recallGroupMessage_(this, groupId, sequence); } - async recallPrivateMessage(userUin: number, clientSeq: number, msgSeq: number, random: number, timestamp: number): Promise { return recallPrivateMessage_(this, userUin, clientSeq, msgSeq, random, timestamp); } - async markGroupMsgAsRead(groupId: number, sequence: number): Promise { return markGroupMsgAsRead_(this, groupId, sequence); } - async markPrivateMsgAsRead(userId: number, sequence: number): Promise { return markPrivateMsgAsRead_(this, userId, sequence); } + // recall* / markRead* moved to apis/message.ts::MessageApi. async setFriendRemark(userId: number, remark: string): Promise { return setFriendRemark_(this, userId, remark); } async setGroupRemark(groupId: number, remark: string): Promise { return setGroupRemark_(this, groupId, remark); } async getGroupHonorInfo(groupId: number, type: WebHonorType | string): Promise { diff --git a/packages/core/src/onebot/actions/extended.ts b/packages/core/src/onebot/actions/extended.ts index a2b711f4..123fdb66 100644 --- a/packages/core/src/onebot/actions/extended.ts +++ b/packages/core/src/onebot/actions/extended.ts @@ -217,7 +217,7 @@ export function register(h: ApiHandler, ctx: ApiActionContext): void { return failedResponse(RETCODE.BAD_REQUEST, 'group_id does not match message session'); } - await ctx.bridge.markGroupMsgAsRead(meta.targetId, meta.sequence); + await ctx.bridge.apis.message.markGroupRead(meta.targetId, meta.sequence); return okResponse(); }); @@ -237,7 +237,7 @@ export function register(h: ApiHandler, ctx: ApiActionContext): void { return failedResponse(RETCODE.BAD_REQUEST, 'user_id does not match message session'); } - await ctx.bridge.markPrivateMsgAsRead(meta.targetId, meta.sequence); + await ctx.bridge.apis.message.markPrivateRead(meta.targetId, meta.sequence); return okResponse(); }); @@ -256,9 +256,9 @@ export function register(h: ApiHandler, ctx: ApiActionContext): void { } if (meta.isGroup) { - await ctx.bridge.markGroupMsgAsRead(meta.targetId, meta.sequence); + await ctx.bridge.apis.message.markGroupRead(meta.targetId, meta.sequence); } else { - await ctx.bridge.markPrivateMsgAsRead(meta.targetId, meta.sequence); + await ctx.bridge.apis.message.markPrivateRead(meta.targetId, meta.sequence); } return okResponse(); }); diff --git a/packages/core/src/onebot/instance-context.ts b/packages/core/src/onebot/instance-context.ts index ad8b57c8..721c7b3d 100644 --- a/packages/core/src/onebot/instance-context.ts +++ b/packages/core/src/onebot/instance-context.ts @@ -178,8 +178,8 @@ export function buildApiContext(ref: OneBotInstanceContext): ApiActionContext { if (!meta.isGroup) throw new Error('emoji reactions are not supported on private messages'); await bridge.setGroupReaction(meta.targetId, meta.sequence, emojiId, set); }, - markGroupMsgAsRead: (groupId, sequence) => bridge.markGroupMsgAsRead(groupId, sequence), - markPrivateMsgAsRead: (userId, sequence) => bridge.markPrivateMsgAsRead(userId, sequence), + markGroupMsgAsRead: (groupId, sequence) => bridge.apis.message.markGroupRead(groupId, sequence), + markPrivateMsgAsRead: (userId, sequence) => bridge.apis.message.markPrivateRead(userId, sequence), setOnlineStatus: (status: number, extStatus?: number, batteryStatus?: number) => bridge.setOnlineStatus(status, extStatus, batteryStatus), setProfile: (nickname?: string, personalNote?: string) => bridge.setProfile(nickname, personalNote), diff --git a/packages/core/src/onebot/modules/message-actions.ts b/packages/core/src/onebot/modules/message-actions.ts index 7eb74764..9c1abf45 100644 --- a/packages/core/src/onebot/modules/message-actions.ts +++ b/packages/core/src/onebot/modules/message-actions.ts @@ -65,9 +65,9 @@ export async function getFriendMsgHistory( export async function deleteMessage(bridge: BridgeInterface, meta: MessageMeta): Promise { if (meta.isGroup) { - await bridge.recallGroupMessage(meta.targetId, meta.sequence); + await bridge.apis.message.recallGroup(meta.targetId, meta.sequence); } else { - await bridge.recallPrivateMessage( + await bridge.apis.message.recallPrivate( meta.targetId, meta.clientSequence, meta.sequence, @@ -155,9 +155,9 @@ export async function sendPrivateMessage( log.warn('[OneBot] private file segment without file_id — skipped (upload first via upload_private_file)'); } - let lastReceipt: Awaited> | undefined; + let lastReceipt: Awaited> | undefined; if (nonFileElements.length > 0) { - lastReceipt = await ref.bridge.sendPrivateMessage(userId, nonFileElements); + lastReceipt = await ref.bridge.apis.message.sendPrivate(userId, nonFileElements); logSentMessage(false, userId, nonFileElements); } if (fileElements.length > 0) { @@ -189,7 +189,7 @@ export async function sendPrivateMessage( fileEl.fileId, ); } - lastReceipt = await ref.bridge.sendC2cFileMessage(userId, userUid, { + lastReceipt = await ref.bridge.apis.message.sendC2cFile(userId, userUid, { fileId: fileEl.fileId!, fileName, fileSize, @@ -258,9 +258,9 @@ export async function sendGroupMessage( log.warn('[OneBot] group file segment without file_id — skipped (upload first via upload_group_file)'); } - let lastReceipt: Awaited> | undefined; + let lastReceipt: Awaited> | undefined; if (nonFileElements.length > 0) { - lastReceipt = await ref.bridge.sendGroupMessage(groupId, nonFileElements); + lastReceipt = await ref.bridge.apis.message.sendGroup(groupId, nonFileElements); logSentMessage(true, groupId, nonFileElements); } for (const fileEl of fileElements) { @@ -324,7 +324,7 @@ export async function sendGroupForwardMessage( const nodes = await parseForwardNodes(ref, messages, { groupId }); const forwardId = await ref.bridge.uploadForwardNodes(nodes, groupId); const previewElement = buildForwardPreviewElement(forwardId, nodes, true, meta); - const receipt = await ref.bridge.sendGroupMessage(groupId, [previewElement]); + const receipt = await ref.bridge.apis.message.sendGroup(groupId, [previewElement]); const messageId = hashMessageIdInt32(receipt.sequence, groupId, GROUP_MESSAGE_EVENT); ref.cacheMessageMeta(messageId, { @@ -352,7 +352,7 @@ export async function sendPrivateForwardMessage( // has no target uid and the element builder bails). const forwardId = await ref.bridge.uploadForwardNodes(nodes, undefined, userId); const previewElement = buildForwardPreviewElement(forwardId, nodes, false, meta); - const receipt = await ref.bridge.sendPrivateMessage(userId, [previewElement]); + const receipt = await ref.bridge.apis.message.sendPrivate(userId, [previewElement]); const messageId = hashMessageIdInt32(receipt.sequence, userId, PRIVATE_MESSAGE_EVENT); ref.cacheMessageMeta(messageId, { @@ -411,7 +411,7 @@ export async function forwardSingleMessage( let receipt; let messageIdOut: number; if (target.groupId) { - receipt = await ref.bridge.sendGroupMessage(target.groupId, elements); + receipt = await ref.bridge.apis.message.sendGroup(target.groupId, elements); messageIdOut = hashMessageIdInt32(receipt.sequence, target.groupId, GROUP_MESSAGE_EVENT); ref.cacheMessageMeta(messageIdOut, { isGroup: true, @@ -423,7 +423,7 @@ export async function forwardSingleMessage( timestamp: receipt.timestamp, }); } else { - receipt = await ref.bridge.sendPrivateMessage(target.userId!, elements); + receipt = await ref.bridge.apis.message.sendPrivate(target.userId!, elements); messageIdOut = hashMessageIdInt32(receipt.sequence, target.userId!, PRIVATE_MESSAGE_EVENT); ref.cacheMessageMeta(messageIdOut, { isGroup: false, diff --git a/packages/core/tests/actions/_helpers.ts b/packages/core/tests/actions/_helpers.ts index 85c2d402..74b29dcd 100644 --- a/packages/core/tests/actions/_helpers.ts +++ b/packages/core/tests/actions/_helpers.ts @@ -6,10 +6,52 @@ // // `mockBridge()` returns a minimal stand-in: enough state for the // actions to thread but no real packet/event machinery. +// +// As the #6 Api-on-ctx refactor moves business methods OFF Bridge and +// onto `bridge.apis..method()`, the mock grows an `apis` block +// matching the ApiHub shape. Each Api gets its own stub helper +// (e.g. `mockMessageApi()`) and tests can override individual entries +// via `mockBridge({ apis: { message: ... } })`. import { vi } from 'vitest'; import type { SendPacketResult } from '../../src/protocol/packet-sender'; +/** Default receipt returned by sendGroup / sendPrivate / sendC2cFile mocks. */ +const STUB_RECEIPT = { messageId: 1, sequence: 1, clientSequence: 0, random: 1, timestamp: 0 }; + +export interface MockMessageApi { + sendGroup: ReturnType; + sendPrivate: ReturnType; + sendC2cFile: ReturnType; + recallGroup: ReturnType; + recallPrivate: ReturnType; + markGroupRead: ReturnType; + markPrivateRead: ReturnType; +} + +export function mockMessageApi(): MockMessageApi { + return { + sendGroup: vi.fn(async () => STUB_RECEIPT), + sendPrivate: vi.fn(async () => STUB_RECEIPT), + sendC2cFile: vi.fn(async () => STUB_RECEIPT), + recallGroup: vi.fn(async () => undefined), + recallPrivate: vi.fn(async () => undefined), + markGroupRead: vi.fn(async () => undefined), + markPrivateRead: vi.fn(async () => undefined), + }; +} + +export interface MockApiHub { + message: MockMessageApi; + // additional Apis added commit-by-commit as #6 progresses +} + +export function mockApiHub(overrides: Partial = {}): MockApiHub { + return { + message: overrides.message ?? mockMessageApi(), + }; +} + export interface MockBridge { identity: { uin: string; @@ -19,18 +61,12 @@ export interface MockBridge { findUinByUid: ReturnType; findGroupMember: ReturnType; }; + apis: MockApiHub; sendRawPacket: ReturnType; fetchFriendList: ReturnType; fetchGroupMemberList: ReturnType; fetchUserProfile: ReturnType; resolveUserUid: ReturnType; - // High-level message senders. Default to no-op resolves so actions - // that fire off a follow-up chat message after their main work - // (e.g. `upload_group_file` → `sendGroupFileMessage` to publish the - // file in chat) don't crash test setups that don't care about it. - sendGroupMessage: ReturnType; - sendPrivateMessage: ReturnType; - sendC2cFileMessage: ReturnType; sendGroupFileMessage: ReturnType; // Uploaded-file metadata cache helpers — actions like uploadGroupFile // / uploadPrivateFile call these to remember the upload, so tests @@ -57,14 +93,12 @@ export function mockBridge(overrides: Partial = {}): MockBridge { findGroupMember: vi.fn(() => null), ...(overrides.identity ?? {}), } as MockBridge['identity'], + apis: overrides.apis ?? mockApiHub(), sendRawPacket: vi.fn(async () => defaultResp), fetchFriendList: vi.fn(async () => []), fetchGroupMemberList: vi.fn(async () => []), fetchUserProfile: vi.fn(async () => ({ uid: 'profile-uid' })), resolveUserUid: vi.fn(async () => 'resolved-uid'), - sendGroupMessage: vi.fn(async () => ({ messageId: 1, sequence: 1, clientSequence: 0, random: 1, timestamp: 0 })), - sendPrivateMessage: vi.fn(async () => ({ messageId: 1, sequence: 1, clientSequence: 0, random: 1, timestamp: 0 })), - sendC2cFileMessage: vi.fn(async () => ({ messageId: 1, sequence: 1, clientSequence: 0, random: 1, timestamp: 0 })), sendGroupFileMessage: vi.fn(async () => undefined), rememberUploadedFile: vi.fn(), recallUploadedFile: vi.fn(() => undefined), diff --git a/packages/core/tests/actions/group-file.test.ts b/packages/core/tests/actions/group-file.test.ts index 61df590b..61b7498a 100644 --- a/packages/core/tests/actions/group-file.test.ts +++ b/packages/core/tests/actions/group-file.test.ts @@ -159,7 +159,7 @@ describe('actions/group-file', () => { expect(fileId).toBe('fid-pub'); // sendGroupMessage must NOT be touched — the previous wire shape // (PbSendMsg w/ transElem(24)) is the bug we're guarding against. - expect(bridge.sendGroupMessage).not.toHaveBeenCalled(); + expect(bridge.apis.message.sendGroup).not.toHaveBeenCalled(); }); it('uploadGroupFile skips the chat post when uploadFile=false', async () => { @@ -272,12 +272,12 @@ describe('actions/group-file', () => { }), ); await groupFile.uploadPrivateFile(bridge as any, 67890, '/path/private-file.bin', 'doc.pdf'); - expect(bridge.sendC2cFileMessage).toHaveBeenCalledOnce(); - const [userUin, userUid, info] = bridge.sendC2cFileMessage.mock.calls[0]!; + expect(bridge.apis.message.sendC2cFile).toHaveBeenCalledOnce(); + const [userUin, userUid, info] = bridge.apis.message.sendC2cFile.mock.calls[0]!; expect(userUin).toBe(67890); expect(userUid).toBe('target-uid'); expect(info).toMatchObject({ fileId: 'pfid', fileName: 'doc.pdf', fileHash: 'phash' }); - expect(bridge.sendPrivateMessage).not.toHaveBeenCalled(); + expect(bridge.apis.message.sendPrivate).not.toHaveBeenCalled(); }); it('uploadPrivateFile caches the upload metadata for later resend by file_id', async () => { @@ -314,7 +314,7 @@ describe('actions/group-file', () => { }), ); await groupFile.uploadPrivateFile(bridge as any, 67890, '/path/file', '', false); - expect(bridge.sendC2cFileMessage).not.toHaveBeenCalled(); + expect(bridge.apis.message.sendC2cFile).not.toHaveBeenCalled(); }); it('uploadPrivateFile reads host from rtpMediaPlatformUploadAddress[0].inIP when populated', async () => { diff --git a/packages/core/tests/bridge-private-routing.test.ts b/packages/core/tests/bridge-private-routing.test.ts index fdb75a3a..ef64f81d 100644 --- a/packages/core/tests/bridge-private-routing.test.ts +++ b/packages/core/tests/bridge-private-routing.test.ts @@ -40,7 +40,7 @@ describe('Bridge private media routing', () => { } const bridge = new TestBridge(IdentityService.memory('10000')); - await bridge.sendPrivateMessage(12345, [{ type: 'video', url: 'file:///tmp/clip.mp4' } as any]); + await bridge.apis.message.sendPrivate(12345, [{ type: 'video', url: 'file:///tmp/clip.mp4' } as any]); expect(bridge.capturedBody).toBeInstanceOf(Uint8Array); const request = protobuf_decode(bridge.capturedBody as Uint8Array); @@ -77,7 +77,7 @@ describe('Bridge private media routing', () => { const bridge = new TestBridge(IdentityService.memory('10000')); const fileMd5 = new Uint8Array([0xde, 0xad, 0xbe, 0xef]); - await bridge.sendC2cFileMessage(67890, 'u_peer_xyz', { + await bridge.apis.message.sendC2cFile(67890, 'u_peer_xyz', { fileId: 'uuid-abc-123', fileName: 'doc.txt', fileSize: 1024, diff --git a/packages/core/tests/onebot/forward-nested.test.ts b/packages/core/tests/onebot/forward-nested.test.ts index 39863dd5..54a493de 100644 --- a/packages/core/tests/onebot/forward-nested.test.ts +++ b/packages/core/tests/onebot/forward-nested.test.ts @@ -53,7 +53,7 @@ describe('forward — nested {type:"node"} content', () => { messageId: 1, sequence: 100, clientSequence: 0, random: 1, timestamp: 1700000000, })); - const bridge = fakeBridge({ uploadForwardNodes, sendGroupMessage } as any); + const bridge = fakeBridge({ uploadForwardNodes, apis: { message: { sendGroup: sendGroupMessage } } } as any); const ctx = makeCtx(bridge); const messages = [{ @@ -105,7 +105,7 @@ describe('forward — nested {type:"node"} content', () => { messageId: 1, sequence: 100, clientSequence: 0, random: 1, timestamp: 1700000000, })); - const bridge = fakeBridge({ uploadForwardNodes, sendPrivateMessage } as any); + const bridge = fakeBridge({ uploadForwardNodes, apis: { message: { sendPrivate: sendPrivateMessage } } } as any); const ctx = makeCtx(bridge); const messages = [{ @@ -143,7 +143,7 @@ describe('forward — nested {type:"node"} content', () => { const sendGroupMessage = vi.fn(async () => ({ messageId: 1, sequence: 100, clientSequence: 0, random: 1, timestamp: 0, })); - const bridge = fakeBridge({ uploadForwardNodes, sendGroupMessage } as any); + const bridge = fakeBridge({ uploadForwardNodes, apis: { message: { sendGroup: sendGroupMessage } } } as any); const ctx = makeCtx(bridge); function wrap(content: any, depth: number): any { @@ -167,7 +167,7 @@ describe('forward — nested {type:"node"} content', () => { const sendGroupMessage = vi.fn(async () => ({ messageId: 1, sequence: 100, clientSequence: 0, random: 1, timestamp: 0, })); - const bridge = fakeBridge({ uploadForwardNodes, sendGroupMessage } as any); + const bridge = fakeBridge({ uploadForwardNodes, apis: { message: { sendGroup: sendGroupMessage } } } as any); const ctx = makeCtx(bridge); const messages = [ @@ -195,7 +195,7 @@ describe('forward — nested {type:"node"} content', () => { const sendGroupMessage = vi.fn(async () => ({ messageId: 1, sequence: 100, clientSequence: 0, random: 1, timestamp: 0, })); - const bridge = fakeBridge({ uploadForwardNodes, sendGroupMessage } as any); + const bridge = fakeBridge({ uploadForwardNodes, apis: { message: { sendGroup: sendGroupMessage } } } as any); const ctx = makeCtx(bridge); const messages = [{ diff --git a/packages/core/tests/onebot/send-group-file.test.ts b/packages/core/tests/onebot/send-group-file.test.ts index 3625c938..a4089c7e 100644 --- a/packages/core/tests/onebot/send-group-file.test.ts +++ b/packages/core/tests/onebot/send-group-file.test.ts @@ -55,7 +55,7 @@ describe('send_group_msg with {type:"file"} segment', () => { // a tsc error under noUncheckedIndexedAccess). const sendGroupFileMessage = vi.fn(async (_groupId: number, _fileId: string) => undefined); const bridge = fakeBridge({ - sendGroupMessage: sendGroupMessage_bridge, + apis: { message: { sendGroup: sendGroupMessage_bridge } } as any, sendGroupFileMessage, resolveUserUid: vi.fn(), } as any); @@ -77,7 +77,7 @@ describe('send_group_msg with {type:"file"} segment', () => { const sendGroupMessage_bridge = vi.fn(async (_gid: number, _elements: any[]) => goodReceipt); const sendGroupFileMessage = vi.fn(async (_groupId: number, _fileId: string) => undefined); const bridge = fakeBridge({ - sendGroupMessage: sendGroupMessage_bridge, + apis: { message: { sendGroup: sendGroupMessage_bridge } } as any, sendGroupFileMessage, resolveUserUid: vi.fn(async () => 'u_peer'), } as any); @@ -106,7 +106,7 @@ describe('send_group_msg with {type:"file"} segment', () => { const sendGroupMessage_bridge = vi.fn(async (_gid: number, _elements: any[]) => goodReceipt); const sendGroupFileMessage = vi.fn(); const bridge = fakeBridge({ - sendGroupMessage: sendGroupMessage_bridge, + apis: { message: { sendGroup: sendGroupMessage_bridge } } as any, sendGroupFileMessage, resolveUserUid: vi.fn(), } as any); diff --git a/packages/core/tests/onebot/send-private-c2c-file.test.ts b/packages/core/tests/onebot/send-private-c2c-file.test.ts index 3b626cfd..10f48c83 100644 --- a/packages/core/tests/onebot/send-private-c2c-file.test.ts +++ b/packages/core/tests/onebot/send-private-c2c-file.test.ts @@ -52,8 +52,7 @@ describe('send_private_msg with {type:"file"} segment', () => { const resolveUserUid = vi.fn(async () => 'u_peer'); const recallUploadedFile = vi.fn(() => undefined); const bridge = fakeBridge({ - sendPrivateMessage: sendPrivateMessage_bridge, - sendC2cFileMessage, + apis: { message: { sendPrivate: sendPrivateMessage_bridge, sendC2cFile: sendC2cFileMessage } } as any, resolveUserUid, recallUploadedFile, } as any); @@ -98,8 +97,7 @@ describe('send_private_msg with {type:"file"} segment', () => { rememberedAt: 0, } : undefined); const bridge = fakeBridge({ - sendPrivateMessage: vi.fn(), - sendC2cFileMessage, + apis: { message: { sendPrivate: vi.fn(), sendC2cFile: sendC2cFileMessage } } as any, resolveUserUid: vi.fn(async () => 'u_peer'), recallUploadedFile, } as any); @@ -137,8 +135,7 @@ describe('send_private_msg with {type:"file"} segment', () => { rememberedAt: 0, })); const bridge = fakeBridge({ - sendPrivateMessage: vi.fn(), - sendC2cFileMessage, + apis: { message: { sendPrivate: vi.fn(), sendC2cFile: sendC2cFileMessage } } as any, resolveUserUid: vi.fn(async () => 'u_peer'), recallUploadedFile, } as any); @@ -162,8 +159,7 @@ describe('send_private_msg with {type:"file"} segment', () => { const sendC2cFileMessage = vi.fn(async (_uin: number, _uid: string, _info: any) => goodReceipt); const resolveUserUid = vi.fn(async () => 'u_peer'); const bridge = fakeBridge({ - sendPrivateMessage: sendPrivateMessage_bridge, - sendC2cFileMessage, + apis: { message: { sendPrivate: sendPrivateMessage_bridge, sendC2cFile: sendC2cFileMessage } } as any, resolveUserUid, recallUploadedFile: vi.fn(() => undefined), } as any); @@ -196,8 +192,7 @@ describe('send_private_msg with {type:"file"} segment', () => { const sendPrivateMessage_bridge = vi.fn(async (_uin: number, _elements: any[]) => goodReceipt); const sendC2cFileMessage = vi.fn(); const bridge = fakeBridge({ - sendPrivateMessage: sendPrivateMessage_bridge, - sendC2cFileMessage, + apis: { message: { sendPrivate: sendPrivateMessage_bridge, sendC2cFile: sendC2cFileMessage } } as any, resolveUserUid: vi.fn(), recallUploadedFile: vi.fn(() => undefined), } as any); @@ -220,8 +215,7 @@ describe('send_private_msg with {type:"file"} segment', () => { // an empty uid. const sendC2cFileMessage = vi.fn(); const bridge = fakeBridge({ - sendPrivateMessage: vi.fn(), - sendC2cFileMessage, + apis: { message: { sendPrivate: vi.fn(), sendC2cFile: sendC2cFileMessage } } as any, resolveUserUid: vi.fn(async () => ''), // intentional empty recallUploadedFile: vi.fn(() => undefined), } as any);