refactor(api): introduce MessageApi + BridgeContext + ApiHub skeleton (#6 commit 1/13)
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).
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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/<name>.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';
|
||||
@@ -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<SendMessageReceipt> {
|
||||
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<SendMessageRequest>({
|
||||
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<SendMessageResponse>(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<SendMessageReceipt> {
|
||||
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<SendMessageRequest>({
|
||||
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<SendMessageResponse>(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<SendMessageReceipt> {
|
||||
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<FileExtra>({
|
||||
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<SendMessageRequest>({
|
||||
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<SendMessageResponse>(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<void> {
|
||||
const request = protobuf_encode<GroupRecallRequest>({
|
||||
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<void> {
|
||||
const targetUid = await this.ctx.resolveUserUid(userUin);
|
||||
const request = protobuf_encode<C2CRecallRequest>({
|
||||
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<void> {
|
||||
const request = protobuf_encode<SsoReadedReportReq>({
|
||||
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<void> {
|
||||
const uid = await this.ctx.resolveUserUid(userId);
|
||||
const request = protobuf_encode<SsoReadedReportReq>({
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.<area>.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<SendPacketResult>;
|
||||
/** 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<string>;
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
@@ -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<string>;
|
||||
@@ -34,21 +41,13 @@ export interface BridgeInterface {
|
||||
// routes through here.
|
||||
sendRawPacket(serviceCmd: string, body: Uint8Array, timeoutMs?: number): Promise<SendPacketResult>;
|
||||
|
||||
// ─── Send-side protocol helpers (used by Api classes) ───
|
||||
nextMessageRandom(): number;
|
||||
nextClientSequence(): number;
|
||||
|
||||
// ─── Send (messages) ───
|
||||
sendGroupMessage(groupId: number, elements: MessageElement[]): Promise<SendMessageReceipt>;
|
||||
sendPrivateMessage(userUin: number, elements: MessageElement[]): Promise<SendMessageReceipt>;
|
||||
/**
|
||||
* 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<SendMessageReceipt>;
|
||||
// Moved to `apis.message.{sendGroup, sendPrivate, sendC2cFile,
|
||||
// recallGroup, recallPrivate, markGroupRead, markPrivateRead}`.
|
||||
|
||||
// ─── Fetch (contacts / profile / system) ───
|
||||
fetchFriendList(): Promise<FriendInfo[]>;
|
||||
@@ -119,11 +118,7 @@ export interface BridgeInterface {
|
||||
uploadForwardNodes(nodes: ForwardNodePayload[], groupId?: number, userId?: number): Promise<string>;
|
||||
fetchForwardNodes(resId: string): Promise<ForwardNodePayload[]>;
|
||||
|
||||
// ─── Message ops ───
|
||||
recallGroupMessage(groupId: number, sequence: number): Promise<void>;
|
||||
recallPrivateMessage(userUin: number, clientSeq: number, msgSeq: number, random: number, timestamp: number): Promise<void>;
|
||||
markGroupMsgAsRead(groupId: number, sequence: number): Promise<void>;
|
||||
markPrivateMsgAsRead(userId: number, sequence: number): Promise<void>;
|
||||
// ─── Message ops (moved to apis.message) ───
|
||||
|
||||
// ─── Interaction ───
|
||||
sendPoke(isGroup: boolean, peerUin: number, targetUin?: number): Promise<void>;
|
||||
|
||||
@@ -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<number>();
|
||||
/**
|
||||
@@ -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<SendMessageReceipt> {
|
||||
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<SendMessageRequest>({
|
||||
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<SendMessageResponse>(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<SendMessageReceipt> {
|
||||
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<SendMessageRequest>({
|
||||
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<SendMessageResponse>(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<SendMessageReceipt> {
|
||||
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<FileExtra>({
|
||||
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<SendMessageRequest>({
|
||||
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<SendMessageResponse>(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<void> { return sendLike_(this, userId, count); }
|
||||
async setGroupEssence(groupId: number, sequence: number, random: number, enable: boolean): Promise<void> { return setGroupEssence_(this, groupId, sequence, random, enable); }
|
||||
async setGroupReaction(groupId: number, sequence: number, code: string, isSet: boolean): Promise<void> { return setGroupReaction_(this, groupId, sequence, code, isSet); }
|
||||
async recallGroupMessage(groupId: number, sequence: number): Promise<void> { return recallGroupMessage_(this, groupId, sequence); }
|
||||
async recallPrivateMessage(userUin: number, clientSeq: number, msgSeq: number, random: number, timestamp: number): Promise<void> { return recallPrivateMessage_(this, userUin, clientSeq, msgSeq, random, timestamp); }
|
||||
async markGroupMsgAsRead(groupId: number, sequence: number): Promise<void> { return markGroupMsgAsRead_(this, groupId, sequence); }
|
||||
async markPrivateMsgAsRead(userId: number, sequence: number): Promise<void> { return markPrivateMsgAsRead_(this, userId, sequence); }
|
||||
// recall* / markRead* moved to apis/message.ts::MessageApi.
|
||||
async setFriendRemark(userId: number, remark: string): Promise<void> { return setFriendRemark_(this, userId, remark); }
|
||||
async setGroupRemark(groupId: number, remark: string): Promise<void> { return setGroupRemark_(this, groupId, remark); }
|
||||
async getGroupHonorInfo(groupId: number, type: WebHonorType | string): Promise<any> {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -65,9 +65,9 @@ export async function getFriendMsgHistory(
|
||||
|
||||
export async function deleteMessage(bridge: BridgeInterface, meta: MessageMeta): Promise<void> {
|
||||
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<ReturnType<typeof ref.bridge.sendPrivateMessage>> | undefined;
|
||||
let lastReceipt: Awaited<ReturnType<typeof ref.bridge.apis.message.sendPrivate>> | 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<ReturnType<typeof ref.bridge.sendGroupMessage>> | undefined;
|
||||
let lastReceipt: Awaited<ReturnType<typeof ref.bridge.apis.message.sendGroup>> | 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,
|
||||
|
||||
@@ -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.<area>.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<typeof vi.fn>;
|
||||
sendPrivate: ReturnType<typeof vi.fn>;
|
||||
sendC2cFile: ReturnType<typeof vi.fn>;
|
||||
recallGroup: ReturnType<typeof vi.fn>;
|
||||
recallPrivate: ReturnType<typeof vi.fn>;
|
||||
markGroupRead: ReturnType<typeof vi.fn>;
|
||||
markPrivateRead: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
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> = {}): MockApiHub {
|
||||
return {
|
||||
message: overrides.message ?? mockMessageApi(),
|
||||
};
|
||||
}
|
||||
|
||||
export interface MockBridge {
|
||||
identity: {
|
||||
uin: string;
|
||||
@@ -19,18 +61,12 @@ export interface MockBridge {
|
||||
findUinByUid: ReturnType<typeof vi.fn>;
|
||||
findGroupMember: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
apis: MockApiHub;
|
||||
sendRawPacket: ReturnType<typeof vi.fn>;
|
||||
fetchFriendList: ReturnType<typeof vi.fn>;
|
||||
fetchGroupMemberList: ReturnType<typeof vi.fn>;
|
||||
fetchUserProfile: ReturnType<typeof vi.fn>;
|
||||
resolveUserUid: ReturnType<typeof vi.fn>;
|
||||
// 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<typeof vi.fn>;
|
||||
sendPrivateMessage: ReturnType<typeof vi.fn>;
|
||||
sendC2cFileMessage: ReturnType<typeof vi.fn>;
|
||||
sendGroupFileMessage: ReturnType<typeof vi.fn>;
|
||||
// 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> = {}): 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),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<SendMessageRequest>(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,
|
||||
|
||||
@@ -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 = [{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user