SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
This commit is contained in:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -0,0 +1,16 @@
|
||||
# Per-instance runtime state written by the dev server / production daemon.
|
||||
# `runtime.json` is the only tracked file (the deploy-time overrides for
|
||||
# webuiPort, hookAutoLoad, etc); everything else here is operator-private:
|
||||
# * webui.json — admin password hash + rotation state
|
||||
# * consent.json — EULA/PRIVACY acceptance per agreement version
|
||||
# * notifications.json — per-account webhook destinations
|
||||
# * onebot_<uin>.json — per-uin OneBot transport binding (HTTP/WS ports etc)
|
||||
# * ui.json — WebUI panel layout per operator
|
||||
#
|
||||
# Without this ignore file, every `pnpm dev:mode` / `node dist/index.mjs`
|
||||
# leaves a 5-file untracked surface in `packages/core/config/` that pollutes
|
||||
# `git status` and would accidentally commit local creds if `git add -A` is used.
|
||||
|
||||
*
|
||||
!.gitignore
|
||||
!runtime.json
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"webuiPort": 8080
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"name": "@snowluma/core",
|
||||
"version": "2.4.2",
|
||||
"description": "SnowLuma core - NTQQ packet sniffer with Node.js native addon",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "../../dist/index.mjs",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "../../dist/index.mjs"
|
||||
},
|
||||
"./bridge": {
|
||||
"types": "./src/bridge/bridge.ts",
|
||||
"default": "./src/bridge/bridge.ts"
|
||||
},
|
||||
"./bridge-interface": {
|
||||
"types": "./src/bridge/bridge-interface.ts",
|
||||
"default": "./src/bridge/bridge-interface.ts"
|
||||
},
|
||||
"./bridge-context": {
|
||||
"types": "./src/bridge/bridge-context.ts",
|
||||
"default": "./src/bridge/bridge-context.ts"
|
||||
},
|
||||
"./manager": {
|
||||
"types": "./src/bridge/manager.ts",
|
||||
"default": "./src/bridge/manager.ts"
|
||||
},
|
||||
"./apis": {
|
||||
"types": "./src/bridge/apis/index.ts",
|
||||
"default": "./src/bridge/apis/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"build:all": "cross-env BUILD_WEBUI=true pnpm build",
|
||||
"dev": "tsx src/index.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@snowluma/bridge": "workspace:*",
|
||||
"@snowluma/common": "workspace:*",
|
||||
"@snowluma/onebot": "workspace:*",
|
||||
"@snowluma/proto-defs": "workspace:*",
|
||||
"@snowluma/protocol": "workspace:*",
|
||||
"@snowluma/proton": "workspace:*",
|
||||
"@snowluma/websocket": "workspace:*",
|
||||
"fflate": "^0.8.3",
|
||||
"otpauth": "^9.5.1",
|
||||
"tar": "^7.5.22"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hono/node-server": "^2.1.4",
|
||||
"@types/node": "catalog:",
|
||||
"cross-env": "^10.1.0",
|
||||
"hono": "^4.13.11",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-cp": "^6.0.3",
|
||||
"vitest": "4.1.12",
|
||||
"@snowluma/sqlite": "workspace:*"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^24.22.1 || >=26.12.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { GetPskey } from '@snowluma/protocol/oidb-services/web/get-pskey';
|
||||
import { getCollectionList, type CollectionListResult } from '@snowluma/protocol/web/collection';
|
||||
import type { BridgeContext } from '../bridge-context';
|
||||
|
||||
const COLLECTION_DOMAIN = 'weiyun.com';
|
||||
|
||||
/** Read-only access to the account's QQ collection list. */
|
||||
export class CollectionApi {
|
||||
constructor(private readonly ctx: BridgeContext) {}
|
||||
|
||||
async list(category = 0, count = 50): Promise<CollectionListResult> {
|
||||
const { domainPskeyMap } = await GetPskey.invoke(this.ctx, {
|
||||
domainList: [COLLECTION_DOMAIN],
|
||||
});
|
||||
const pskey = domainPskeyMap.get(COLLECTION_DOMAIN);
|
||||
|
||||
if (!pskey) {
|
||||
throw new Error(`QQ did not return a p_skey for ${COLLECTION_DOMAIN}`);
|
||||
}
|
||||
|
||||
return getCollectionList({
|
||||
uin: this.ctx.identity.uin,
|
||||
pskey,
|
||||
category,
|
||||
count,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,734 @@
|
||||
import { GroupInviteTracker } from '@snowluma/protocol/group-invite-tracker';
|
||||
import { groupRequestSubType } from '@snowluma/protocol/qq-info';
|
||||
import { FetchDownloadRkeys } from '@snowluma/protocol/oidb-services/contacts/fetch-download-rkeys';
|
||||
import {
|
||||
FetchFriendListPage,
|
||||
type FriendListPageCategory,
|
||||
type FriendListPageEntry,
|
||||
} from '@snowluma/protocol/oidb-services/contacts/fetch-friend-list-page';
|
||||
import { FetchGroupDetail } from '@snowluma/protocol/oidb-services/contacts/fetch-group-detail';
|
||||
import { FetchGroupList } from '@snowluma/protocol/oidb-services/contacts/fetch-group-list';
|
||||
import { FetchGroupMemberListPage } from '@snowluma/protocol/oidb-services/contacts/fetch-group-member-list-page';
|
||||
import {
|
||||
FetchGroupRequests,
|
||||
FetchGroupRequestsByUid,
|
||||
groupRequestNotifyType,
|
||||
type DecodedGroupRequest,
|
||||
} from '@snowluma/protocol/oidb-services/contacts/fetch-group-requests';
|
||||
import {
|
||||
FetchRobotUinRanges,
|
||||
type RobotUinRange,
|
||||
type RobotUinRangeSnapshot,
|
||||
} from '@snowluma/protocol/oidb-services/contacts/fetch-robot-uin-ranges';
|
||||
import { FetchUserProfile } from '@snowluma/protocol/oidb-services/contacts/fetch-user-profile';
|
||||
import { FetchUserProfileByUid } from '@snowluma/protocol/oidb-services/contacts/fetch-user-profile-by-uid';
|
||||
import {
|
||||
fetchQidianCorpInfo as fetchQidianCorpInfoWire,
|
||||
type QidianCorpInfo,
|
||||
} from '@snowluma/protocol/services/qidian/fetch-corp-info';
|
||||
import { GetBuddyRecommendArk } from '@snowluma/protocol/oidb-services/contacts/get-buddy-recommend-ark';
|
||||
import { GetGroupRecommendArk } from '@snowluma/protocol/oidb-services/contacts/get-group-recommend-ark';
|
||||
import { GetGroupShareLink } from '@snowluma/protocol/oidb-services/contacts/get-group-share-link';
|
||||
import {
|
||||
SendTuwenArk,
|
||||
type SendTuwenArkParams,
|
||||
} from '@snowluma/protocol/oidb-services/contacts/send-tuwen-ark';
|
||||
import { SetFriendCategory } from '@snowluma/protocol/oidb-services/contacts/set-friend-category';
|
||||
import { toHex } from '@snowluma/common/hex';
|
||||
import { createLogger } from '@snowluma/common/logger';
|
||||
import {
|
||||
createSingleFlightCache,
|
||||
type SingleFlightCache,
|
||||
} from '@snowluma/common/single-flight-cache';
|
||||
import type {
|
||||
FriendCategoryInfo,
|
||||
FriendInfo,
|
||||
GroupMemberInfo,
|
||||
GroupRequestInfo,
|
||||
PublicProfileFields,
|
||||
QQGroupInfo,
|
||||
UserProfileInfo,
|
||||
} from '@snowluma/protocol/qq-info';
|
||||
import type { DownloadRKeyInfo } from '../bridge';
|
||||
import type { BridgeContext } from '../bridge-context';
|
||||
|
||||
const log = createLogger('Bridge.Contacts');
|
||||
|
||||
// Official client stores group mute as an expire timestamp (internal 60027 /
|
||||
// JS groupShutupExpireTime): 0 = off, 0xFFFFFFFF = permanent, otherwise unix
|
||||
// seconds. `> 0` is wrong — a leftover past expire still looks "on".
|
||||
function isGroupAllMuted(expireTs?: number, nowSec = Math.floor(Date.now() / 1000)): boolean {
|
||||
return (expireTs ?? 0) > nowSec;
|
||||
}
|
||||
|
||||
interface FriendRoster {
|
||||
entries: FriendListPageEntry[];
|
||||
categories: FriendListPageCategory[];
|
||||
}
|
||||
|
||||
export interface SetFriendCategoryParams {
|
||||
uin: number;
|
||||
categoryId?: number;
|
||||
categoryName?: string;
|
||||
}
|
||||
|
||||
export function isRobotUin(uin: number, ranges: readonly RobotUinRange[]): boolean {
|
||||
return (
|
||||
Number.isInteger(uin) &&
|
||||
uin > 0 &&
|
||||
ranges.some(({ minUin, maxUin }) => uin >= minUin && uin <= maxUin)
|
||||
);
|
||||
}
|
||||
|
||||
function groupRequestMetadata(
|
||||
raw: Pick<
|
||||
DecodedGroupRequest,
|
||||
'sequence' | 'state' | 'eventType' | 'comment' | 'operateTransInfo'
|
||||
>,
|
||||
groupId: number,
|
||||
filtered: boolean,
|
||||
) {
|
||||
const sequence = Number(raw.sequence ?? 0);
|
||||
|
||||
if (!Number.isSafeInteger(sequence) || sequence <= 0) {
|
||||
throw new Error(
|
||||
`invalid group-request sequence: group=${groupId} sequence=${String(raw.sequence ?? 0)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const eventType = raw.eventType ?? 0;
|
||||
const notifyType = groupRequestNotifyType(eventType);
|
||||
|
||||
if (notifyType === undefined) {
|
||||
log.warn(
|
||||
'unsupported group-request wire type: group=%d sequence=%d type=%d',
|
||||
groupId,
|
||||
sequence,
|
||||
eventType,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
sequence,
|
||||
state: raw.state ?? 0,
|
||||
eventType,
|
||||
notifyType,
|
||||
comment: raw.comment ?? '',
|
||||
filtered,
|
||||
operateTransInfo: raw.operateTransInfo?.length ? raw.operateTransInfo : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const MEMBER_LIST_TTL_MS = 60_000;
|
||||
const FRIEND_PROFILE_TTL_MS = 5 * 60_000;
|
||||
const FRIEND_PROFILE_CONCURRENCY = 4;
|
||||
|
||||
export interface FriendListOptions {
|
||||
/** Explicitly query profiles; ordinary roster refreshes never fan out. */
|
||||
withProfile?: boolean;
|
||||
}
|
||||
|
||||
export class ContactsApi {
|
||||
/**
|
||||
* Per-group TTL + single-flight cache for `fetchGroupMemberList`, keyed by
|
||||
* groupId, living for the lifetime of the Bridge.
|
||||
*
|
||||
* Without this, a busy OneBot client (e.g. MaiBot calling
|
||||
* `get_group_member_info` once per inbound message) triggers one
|
||||
* OIDB 0xfe7_3 per chat message — sustained >1k/h, which trips
|
||||
* Tencent risk-control and gets the account banned for 7 days.
|
||||
*/
|
||||
private readonly memberListCache: SingleFlightCache<number, GroupMemberInfo[]> =
|
||||
createSingleFlightCache({
|
||||
ttlMs: MEMBER_LIST_TTL_MS,
|
||||
load: (groupId) => this.fetchGroupMemberListUncached(groupId),
|
||||
});
|
||||
private robotUinRangesPromise_: Promise<RobotUinRangeSnapshot> | null = null;
|
||||
readonly groupInvites = new GroupInviteTracker();
|
||||
private friendRosterGeneration = 0;
|
||||
private rememberedFriendRosterGeneration = 0;
|
||||
private activeProfiles = 0;
|
||||
private readonly profileWaiters: (() => void)[] = [];
|
||||
private readonly friendProfiles: SingleFlightCache<number, PublicProfileFields> =
|
||||
createSingleFlightCache({
|
||||
ttlMs: FRIEND_PROFILE_TTL_MS,
|
||||
load: (uin) => this.loadFriendProfile(uin),
|
||||
});
|
||||
|
||||
constructor(private readonly ctx: BridgeContext) {}
|
||||
|
||||
dispose(): void {
|
||||
// Late roster responses must not start new profile loads after shutdown.
|
||||
this.memberListCache.dispose();
|
||||
this.friendProfiles.dispose();
|
||||
}
|
||||
|
||||
private fetchRobotUinRanges(groupId: number): Promise<RobotUinRangeSnapshot> {
|
||||
if (this.robotUinRangesPromise_) {
|
||||
return this.robotUinRangesPromise_;
|
||||
}
|
||||
|
||||
const pending = FetchRobotUinRanges.invoke(this.ctx).then((snapshot) => {
|
||||
log.info(
|
||||
'loaded robot UIN ranges uin=%s version=%d count=%d',
|
||||
this.ctx.identity.uin,
|
||||
snapshot.version,
|
||||
snapshot.ranges.length,
|
||||
);
|
||||
return snapshot;
|
||||
});
|
||||
this.robotUinRangesPromise_ = pending;
|
||||
|
||||
// A failed first load must be observable and retryable. Keep the rejection
|
||||
// visible to the caller instead of converting every member to false.
|
||||
void pending.catch((err: unknown) => {
|
||||
if (this.robotUinRangesPromise_ === pending) {
|
||||
this.robotUinRangesPromise_ = null;
|
||||
}
|
||||
|
||||
log.error(
|
||||
'failed to load robot UIN ranges: uin=%s group=%d err=%s',
|
||||
this.ctx.identity.uin,
|
||||
groupId,
|
||||
err instanceof Error ? (err.stack ?? err.message) : String(err),
|
||||
);
|
||||
});
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Server-built ARK share card (JSON string) recommending a friend. */
|
||||
getBuddyRecommendArk(userId: number, phoneNumber = ''): Promise<string> {
|
||||
return GetBuddyRecommendArk.invoke(this.ctx, { userId, phoneNumber });
|
||||
}
|
||||
|
||||
/** Server-built ARK share card (JSON string) recommending a group. */
|
||||
getGroupRecommendArk(groupId: number): Promise<string> {
|
||||
return GetGroupRecommendArk.invoke(this.ctx, { groupId });
|
||||
}
|
||||
|
||||
getGroupShareLink(
|
||||
groupId: number,
|
||||
needShortUrl = true,
|
||||
srcId = 73,
|
||||
additionalParam = '',
|
||||
): Promise<string> {
|
||||
return GetGroupShareLink.invoke(this.ctx, {
|
||||
groupId,
|
||||
needShortUrl,
|
||||
srcId,
|
||||
additionalParam,
|
||||
});
|
||||
}
|
||||
|
||||
/** Send a custom 图文 ark card to a C2C peer or group (0xdc2_34). */
|
||||
sendTuwenArk(params: SendTuwenArkParams): Promise<void> {
|
||||
return SendTuwenArk.invoke(this.ctx, params);
|
||||
}
|
||||
|
||||
private async loadFriendProfile(uin: number): Promise<PublicProfileFields> {
|
||||
if (this.activeProfiles >= FRIEND_PROFILE_CONCURRENCY) {
|
||||
await new Promise<void>((resolve) => this.profileWaiters.push(resolve));
|
||||
} else {
|
||||
this.activeProfiles++;
|
||||
}
|
||||
|
||||
try {
|
||||
const profile = await this.fetchUserProfile(uin);
|
||||
|
||||
return profile.profile ?? {};
|
||||
} catch (cause) {
|
||||
log.warn(
|
||||
'friend profile query failed: user=%d error=%s',
|
||||
uin,
|
||||
cause instanceof Error ? cause.message : String(cause),
|
||||
);
|
||||
throw new Error(`friend profile query failed for ${uin}`, { cause });
|
||||
} finally {
|
||||
const next = this.profileWaiters.shift();
|
||||
|
||||
if (next) {
|
||||
next();
|
||||
} else {
|
||||
this.activeProfiles--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async enrichFriendProfiles(entries: FriendListPageEntry[]): Promise<void> {
|
||||
const started = Date.now();
|
||||
|
||||
for (let offset = 0; offset < entries.length; offset += FRIEND_PROFILE_CONCURRENCY) {
|
||||
const batch = entries.slice(offset, offset + FRIEND_PROFILE_CONCURRENCY);
|
||||
const results = await Promise.allSettled(
|
||||
batch.map(async ({ friend }) => {
|
||||
const extra = await this.friendProfiles.get(friend.uin);
|
||||
friend.profile = { ...structuredClone(extra), ...friend.profile };
|
||||
}),
|
||||
);
|
||||
const errors = results.filter(
|
||||
(result): result is PromiseRejectedResult => result.status === 'rejected',
|
||||
);
|
||||
|
||||
if (errors.length > 0) {
|
||||
log.warn(
|
||||
'friend profile enrichment failed: batch=%d failures=%d elapsedMs=%d',
|
||||
offset,
|
||||
errors.length,
|
||||
Date.now() - started,
|
||||
);
|
||||
throw new AggregateError(
|
||||
errors.map((result) => result.reason),
|
||||
'friend profile enrichment failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
log.debug(
|
||||
'friend profiles loaded: friends=%d elapsedMs=%d',
|
||||
entries.length,
|
||||
Date.now() - started,
|
||||
);
|
||||
}
|
||||
|
||||
private async fetchFriendRoster(options: FriendListOptions = {}): Promise<FriendRoster> {
|
||||
const generation = ++this.friendRosterGeneration;
|
||||
const entries: FriendListPageEntry[] = [];
|
||||
const categories = new Map<number, FriendListPageCategory>();
|
||||
const seenCookies = new Set<string>();
|
||||
let cookie: Uint8Array | undefined;
|
||||
|
||||
for (;;) {
|
||||
const page = await FetchFriendListPage.invoke(this.ctx, { cookie });
|
||||
entries.push(...page.entries);
|
||||
|
||||
for (const category of page.categories) {
|
||||
categories.set(category.categoryId, category);
|
||||
}
|
||||
|
||||
const next = page.cookie;
|
||||
|
||||
if (!next?.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const key = toHex(next);
|
||||
|
||||
if (seenCookies.has(key)) {
|
||||
throw new Error(`repeated friend-list cookie ${key}`);
|
||||
}
|
||||
|
||||
seenCookies.add(key);
|
||||
cookie = next;
|
||||
}
|
||||
|
||||
if (options.withProfile) {
|
||||
await this.enrichFriendProfiles(entries);
|
||||
}
|
||||
|
||||
if (generation > this.rememberedFriendRosterGeneration) {
|
||||
this.ctx.identity.rememberFriends(entries.map((entry) => entry.friend));
|
||||
this.rememberedFriendRosterGeneration = generation;
|
||||
}
|
||||
|
||||
return { entries, categories: [...categories.values()] };
|
||||
}
|
||||
|
||||
async fetchFriendList(options: FriendListOptions = {}): Promise<FriendInfo[]> {
|
||||
const roster = await this.fetchFriendRoster(options);
|
||||
return roster.entries.map((entry) => entry.friend);
|
||||
}
|
||||
|
||||
async fetchFriendCategories(options: FriendListOptions = {}): Promise<FriendCategoryInfo[]> {
|
||||
const roster = await this.fetchFriendRoster(options);
|
||||
const buckets = new Map(
|
||||
roster.categories.map((category) => [category.categoryId, [] as FriendInfo[]]),
|
||||
);
|
||||
|
||||
for (const entry of roster.entries) {
|
||||
const bucket = buckets.get(entry.categoryId);
|
||||
|
||||
if (!bucket) {
|
||||
throw new Error(
|
||||
`friend roster references missing category ${entry.categoryId} ` +
|
||||
`(${roster.entries.length} friends total)`,
|
||||
);
|
||||
}
|
||||
|
||||
bucket.push(entry.friend);
|
||||
}
|
||||
|
||||
return roster.categories.map((category) => {
|
||||
const friends = buckets.get(category.categoryId)!;
|
||||
|
||||
if (friends.length !== category.memberCount) {
|
||||
log.warn(
|
||||
'friend category member count mismatch category=%d server=%d assembled=%d',
|
||||
category.categoryId,
|
||||
category.memberCount,
|
||||
friends.length,
|
||||
);
|
||||
}
|
||||
|
||||
return { ...category, friends };
|
||||
});
|
||||
}
|
||||
|
||||
async setFriendCategory(params: SetFriendCategoryParams): Promise<void> {
|
||||
const selectorCount =
|
||||
Number(params.categoryId !== undefined) + Number(params.categoryName !== undefined);
|
||||
|
||||
if (selectorCount !== 1) {
|
||||
throw new Error('exactly one of categoryId or categoryName is required');
|
||||
}
|
||||
|
||||
const categories = await this.fetchFriendCategories();
|
||||
const friendCategory = categories.find((category) =>
|
||||
category.friends.some((friend) => friend.uin === params.uin),
|
||||
);
|
||||
const friend = friendCategory?.friends.find((entry) => entry.uin === params.uin);
|
||||
|
||||
if (!friend) {
|
||||
throw new Error(`friend ${params.uin} is not in the live roster`);
|
||||
}
|
||||
|
||||
if (!friend.uid) {
|
||||
throw new Error(`friend ${params.uin} has no UID in the live roster`);
|
||||
}
|
||||
|
||||
let target: FriendCategoryInfo | undefined;
|
||||
|
||||
if (params.categoryId !== undefined) {
|
||||
target = categories.find((category) => category.categoryId === params.categoryId);
|
||||
|
||||
if (!target) {
|
||||
throw new Error(`friend category ${params.categoryId} does not exist`);
|
||||
}
|
||||
} else {
|
||||
const matches = categories.filter(
|
||||
(category) => category.categoryName === params.categoryName,
|
||||
);
|
||||
|
||||
if (matches.length === 0) {
|
||||
throw new Error(`friend category name "${params.categoryName}" does not exist`);
|
||||
}
|
||||
|
||||
if (matches.length > 1) {
|
||||
throw new Error(`friend category name "${params.categoryName}" is ambiguous`);
|
||||
}
|
||||
|
||||
target = matches[0]!;
|
||||
}
|
||||
|
||||
await SetFriendCategory.invoke(this.ctx, {
|
||||
uid: friend.uid,
|
||||
categoryId: target.categoryId,
|
||||
});
|
||||
log.info(
|
||||
'friend category updated: uin=%s friend=%d category=%d',
|
||||
this.ctx.identity.uin,
|
||||
params.uin,
|
||||
target.categoryId,
|
||||
);
|
||||
}
|
||||
|
||||
async fetchGroupList(): Promise<QQGroupInfo[]> {
|
||||
const resp = await FetchGroupList.invoke(this.ctx);
|
||||
const groups: QQGroupInfo[] = [];
|
||||
|
||||
for (const raw of resp.groups ?? []) {
|
||||
groups.push({
|
||||
groupId: raw.groupUin ?? 0,
|
||||
groupName: raw.info?.groupName ?? '',
|
||||
remark: raw.customInfo?.remark ?? '',
|
||||
memberCount: raw.info?.memberCount ?? 0,
|
||||
memberMax: raw.info?.memberMax ?? 0,
|
||||
members: new Map(),
|
||||
// #197: the list already carries these — no extra fetch needed. `level`
|
||||
// is not in the list (0x88D_0 detail only), so it stays undefined here.
|
||||
createTime: raw.info?.createdTime ?? 0,
|
||||
memo: raw.info?.announcement || raw.info?.description || '',
|
||||
// #490: expose the description alongside `memo`. QQ fills in either
|
||||
// field alone, so folding both into `memo` made the description
|
||||
// unreachable whenever an announcement was also set.
|
||||
description: raw.info?.description ?? '',
|
||||
allMuted: isGroupAllMuted(raw.info?.shutUpAllTimestamp),
|
||||
});
|
||||
}
|
||||
|
||||
this.ctx.identity.rememberGroups(groups);
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single group's public detail by id via `0x88D_0` — works even for a
|
||||
* group the bot hasn't joined (used to resolve a group-invite's name). Returns
|
||||
* null when the server has no such group / denies the lookup. Deliberately
|
||||
* does NOT `rememberGroups` it — a non-member group must not pollute the
|
||||
* joined-groups roster / get_group_list.
|
||||
*/
|
||||
async fetchGroupDetail(groupId: number): Promise<QQGroupInfo | null> {
|
||||
if (!(groupId > 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resp = await FetchGroupDetail.invoke(this.ctx, { groupUin: groupId });
|
||||
const r = resp.groupInfo?.results;
|
||||
|
||||
if (!r) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
groupId,
|
||||
groupName: r.name ?? '',
|
||||
remark: '',
|
||||
memberCount: Number(r.memberCount ?? 0n),
|
||||
memberMax: Number(r.maxMemberCount ?? 0n),
|
||||
members: new Map(),
|
||||
// #197: the detail is the only source of `level`; it also carries
|
||||
// createTime + the notice preview (memo). `description` is deliberately
|
||||
// left unset — 0x88D_0 has no description tag, so for a group the bot
|
||||
// hasn't joined there is nothing to report (#490).
|
||||
createTime: Number(r.createTime ?? 0n),
|
||||
level: Number(r.level ?? 0n),
|
||||
memo: r.noticePreview ?? '',
|
||||
allMuted: isGroupAllMuted(r.shutUpAllTimestamp),
|
||||
};
|
||||
}
|
||||
|
||||
fetchGroupMemberList(
|
||||
groupId: number,
|
||||
options: { force?: boolean } = {},
|
||||
): Promise<GroupMemberInfo[]> {
|
||||
return this.memberListCache.get(groupId, { force: options.force });
|
||||
}
|
||||
|
||||
private async fetchGroupMemberListUncached(groupId: number): Promise<GroupMemberInfo[]> {
|
||||
// QQ's native group service obtains the robot classification separately
|
||||
// from 0xFE7_3. Start both reads together so the metadata adds no serial
|
||||
// round trip to paginated member fetches.
|
||||
const robotSnapshotPromise = this.fetchRobotUinRanges(groupId);
|
||||
const members: GroupMemberInfo[] = [];
|
||||
let token = '';
|
||||
|
||||
do {
|
||||
const page = await FetchGroupMemberListPage.invoke(this.ctx, { groupId, token });
|
||||
members.push(...page.members);
|
||||
token = page.token;
|
||||
} while (token);
|
||||
|
||||
const robotSnapshot = await robotSnapshotPromise;
|
||||
|
||||
for (const member of members) {
|
||||
member.isRobot = isRobotUin(member.uin, robotSnapshot.ranges);
|
||||
}
|
||||
|
||||
this.ctx.identity.rememberGroupMembers(groupId, members);
|
||||
return members;
|
||||
}
|
||||
|
||||
async fetchUserProfile(uin: number): Promise<UserProfileInfo> {
|
||||
const info = await FetchUserProfile.invoke(this.ctx, { uin });
|
||||
this.ctx.identity.rememberUserProfile(info);
|
||||
return info;
|
||||
}
|
||||
|
||||
/** Look up a user profile by UID (string form). Used for strangers
|
||||
* whose UIN we don't have yet — typically the requester on a
|
||||
* group join request push. Mirrors Lagrange's `FetchUserInfoEvent
|
||||
* .Create(targetUid)` path. */
|
||||
async fetchUserProfileByUid(uid: string): Promise<UserProfileInfo> {
|
||||
const info = await FetchUserProfileByUid.invoke(this.ctx, { uid });
|
||||
|
||||
if (info.uin > 0) {
|
||||
this.ctx.identity.rememberUserProfile(info);
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/** 企点企业资料卡(#404 后续 PR)。仅在判断命中的是企点账号后再调用。
|
||||
* best-effort:非企点账号或拉取失败返回 null,不影响上层资料读取。 */
|
||||
async fetchQidianCorpInfo(uin: number): Promise<QidianCorpInfo | null> {
|
||||
return fetchQidianCorpInfoWire(this.ctx, uin);
|
||||
}
|
||||
|
||||
async fetchGroupRequests(
|
||||
filtered = false,
|
||||
count = 50,
|
||||
cursor = 0n,
|
||||
): Promise<GroupRequestInfo[]> {
|
||||
const resp = await FetchGroupRequests.invoke(this.ctx, { filtered, count, cursor });
|
||||
const requests: GroupRequestInfo[] = [];
|
||||
|
||||
for (const raw of resp.requests ?? []) {
|
||||
const groupId = raw.group?.groupUin ?? 0;
|
||||
const targetUin = raw.target?.uin ?? 0;
|
||||
const invitorUin = raw.invitor?.uin ?? 0;
|
||||
const operatorUin = raw.operatorUser?.uin ?? 0;
|
||||
const targetUid =
|
||||
raw.target?.uid ||
|
||||
this.ctx.identity.findUidByUin(targetUin, groupId) ||
|
||||
this.ctx.identity.findUidByUin(targetUin) ||
|
||||
'';
|
||||
const invitorUid =
|
||||
raw.invitor?.uid ||
|
||||
this.ctx.identity.findUidByUin(invitorUin, groupId) ||
|
||||
this.ctx.identity.findUidByUin(invitorUin) ||
|
||||
'';
|
||||
const operatorUid =
|
||||
raw.operatorUser?.uid ||
|
||||
this.ctx.identity.findUidByUin(operatorUin, groupId) ||
|
||||
this.ctx.identity.findUidByUin(operatorUin) ||
|
||||
'';
|
||||
|
||||
requests.push({
|
||||
groupId,
|
||||
groupName: raw.group?.groupName ?? '',
|
||||
targetUid,
|
||||
targetUin:
|
||||
targetUin ||
|
||||
this.ctx.identity.findUinByUid(targetUid, groupId) ||
|
||||
this.ctx.identity.findUinByUid(targetUid) ||
|
||||
0,
|
||||
targetName: raw.target?.name ?? '',
|
||||
invitorUid,
|
||||
invitorUin:
|
||||
invitorUin ||
|
||||
this.ctx.identity.findUinByUid(invitorUid, groupId) ||
|
||||
this.ctx.identity.findUinByUid(invitorUid) ||
|
||||
0,
|
||||
invitorName: raw.invitor?.name ?? '',
|
||||
operatorUid,
|
||||
operatorUin:
|
||||
operatorUin ||
|
||||
this.ctx.identity.findUinByUid(operatorUid, groupId) ||
|
||||
this.ctx.identity.findUinByUid(operatorUid) ||
|
||||
0,
|
||||
operatorName: raw.operatorUser?.name ?? '',
|
||||
...groupRequestMetadata(raw, groupId, filtered),
|
||||
});
|
||||
}
|
||||
|
||||
this.ctx.identity.rememberGroupRequests(requests);
|
||||
return requests;
|
||||
}
|
||||
|
||||
/** UID-form request list retained for correlating incoming UID-only pushes.
|
||||
* Explicit list/actions use fetchGroupRequests(), which matches QQ's native
|
||||
* numeric-account path and does not need profile lookups. */
|
||||
async fetchGroupRequestsByUid(
|
||||
filtered = false,
|
||||
count = 50,
|
||||
cursor = 0n,
|
||||
): Promise<GroupRequestInfo[]> {
|
||||
const resp = await FetchGroupRequestsByUid.invoke(this.ctx, { filtered, count, cursor });
|
||||
const requests: GroupRequestInfo[] = [];
|
||||
|
||||
for (const raw of resp.requests ?? []) {
|
||||
const groupId = raw.group?.groupUin ?? 0;
|
||||
const targetUid = raw.target?.uid ?? '';
|
||||
const invitorUid = raw.invitor?.uid ?? '';
|
||||
const operatorUid = raw.operatorUser?.uid ?? '';
|
||||
|
||||
requests.push({
|
||||
groupId,
|
||||
groupName: raw.group?.groupName ?? '',
|
||||
targetUid,
|
||||
targetUin:
|
||||
this.ctx.identity.findUinByUid(targetUid, groupId) ??
|
||||
this.ctx.identity.findUinByUid(targetUid) ??
|
||||
0,
|
||||
targetName: raw.target?.name ?? '',
|
||||
invitorUid,
|
||||
invitorUin:
|
||||
this.ctx.identity.findUinByUid(invitorUid, groupId) ??
|
||||
this.ctx.identity.findUinByUid(invitorUid) ??
|
||||
0,
|
||||
invitorName: raw.invitor?.name ?? '',
|
||||
operatorUid,
|
||||
operatorUin:
|
||||
this.ctx.identity.findUinByUid(operatorUid, groupId) ??
|
||||
this.ctx.identity.findUinByUid(operatorUid) ??
|
||||
0,
|
||||
operatorName: raw.operatorUser?.name ?? '',
|
||||
...groupRequestMetadata(raw, groupId, filtered),
|
||||
});
|
||||
}
|
||||
|
||||
this.ctx.identity.rememberGroupRequests(requests);
|
||||
return requests;
|
||||
}
|
||||
|
||||
async resolveGroupJoinRequest(
|
||||
groupId: number,
|
||||
uid: string,
|
||||
subType: 'add' | 'invite',
|
||||
invitedUid?: string,
|
||||
): Promise<GroupRequestInfo | null> {
|
||||
const [main, filtered] = await Promise.allSettled([
|
||||
this.fetchGroupRequestsByUid(false),
|
||||
this.fetchGroupRequestsByUid(true),
|
||||
]);
|
||||
|
||||
if (main.status === 'rejected') {
|
||||
throw main.reason;
|
||||
}
|
||||
|
||||
if (filtered.status === 'rejected') {
|
||||
throw filtered.reason;
|
||||
}
|
||||
|
||||
return (
|
||||
[...main.value, ...filtered.value].find((request) => {
|
||||
if (
|
||||
request.groupId !== groupId ||
|
||||
request.state !== 1 ||
|
||||
groupRequestSubType(request) !== subType
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return subType === 'add'
|
||||
? request.targetUid === uid
|
||||
: request.invitorUid === uid && request.targetUid === invitedUid;
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns whether this live card introduces a new approval sequence. */
|
||||
rememberGroupInviteCardSequence(groupId: number, sequence: number): boolean {
|
||||
return this.groupInvites.receiveCard(groupId, sequence);
|
||||
}
|
||||
|
||||
getGroupInviteCardSequence(groupId: number): number | undefined {
|
||||
return this.groupInvites.getCardSequence(groupId);
|
||||
}
|
||||
|
||||
findGroupInviteCardGroupBySequence(sequence: number, groupId?: number): number | undefined {
|
||||
return this.groupInvites.findCardGroup(sequence, groupId);
|
||||
}
|
||||
|
||||
async fetchDownloadRKeys(): Promise<DownloadRKeyInfo[]> {
|
||||
const resp = await FetchDownloadRkeys.invoke(this.ctx);
|
||||
|
||||
const result: DownloadRKeyInfo[] = [];
|
||||
|
||||
for (const entry of resp.downloadRkey?.rkeys ?? []) {
|
||||
const rkey = entry.rkey ?? '';
|
||||
const type = entry.type ?? 0;
|
||||
|
||||
if (rkey && type) {
|
||||
result.push({
|
||||
rkey,
|
||||
ttlSeconds: Number(entry.rkeyTtlSec ?? 0),
|
||||
storeId: entry.storeId ?? 0,
|
||||
createTime: entry.rkeyCreateTime ?? 0,
|
||||
type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
import { FetchAiVoice } from '@snowluma/protocol/oidb-services/extras/fetch-ai-voice';
|
||||
import {
|
||||
FetchAiVoiceList,
|
||||
type AiVoiceCategory as NamespaceAiVoiceCategory,
|
||||
} from '@snowluma/protocol/oidb-services/extras/fetch-ai-voice-list';
|
||||
import {
|
||||
GetStrangerStatus,
|
||||
type StrangerStatus as NamespaceStrangerStatus,
|
||||
} from '@snowluma/protocol/oidb-services/extras/get-stranger-status';
|
||||
import {
|
||||
GetGroupTodoList,
|
||||
type GroupTodoListItem as NamespaceGroupTodoListItem,
|
||||
} from '@snowluma/protocol/oidb-services/extras/get-group-todo-list';
|
||||
import { GroupTodo } from '@snowluma/protocol/oidb-services/extras/group-todo';
|
||||
import { convertAudioBytes } from '@snowluma/protocol/highway/ffmpeg-addon';
|
||||
import { loadBinarySource } from '@snowluma/protocol/highway/utils';
|
||||
import { createLogger } from '@snowluma/common/logger';
|
||||
import type { QQEventVariant } from '@snowluma/protocol/events';
|
||||
import type { PttTransReq, PttTransResp } from '@snowluma/proto-defs/ptt-trans';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { BridgeContext } from '../bridge-context';
|
||||
import type { MediaIndexNode } from './shared';
|
||||
|
||||
/** Inputs for a voice-to-text request, gathered from the received `record`
|
||||
* element + its message meta. `uuid`/`md5Hex` come from the cached record;
|
||||
* the uins + scene from the message meta. */
|
||||
export interface PttTransInput {
|
||||
isGroup: boolean;
|
||||
msgId: number;
|
||||
senderUin: number;
|
||||
/** Receiver uin (c2c) or group uin (group). */
|
||||
peerUin: number;
|
||||
uuid: string;
|
||||
md5Hex: string;
|
||||
duration: number;
|
||||
size: number;
|
||||
format: number;
|
||||
/** Group ptt numeric file id (group only; optional). */
|
||||
fileId?: number;
|
||||
}
|
||||
|
||||
// ─────────────── public types (re-exported from bridge.ts as before) ───
|
||||
|
||||
export type StrangerStatus = NamespaceStrangerStatus;
|
||||
export type GroupTodoListItem = NamespaceGroupTodoListItem;
|
||||
|
||||
export const AiVoiceChatType = {
|
||||
Unknown: 0,
|
||||
Sound: 1,
|
||||
Sing: 2,
|
||||
} as const;
|
||||
export type AiVoiceChatType = (typeof AiVoiceChatType)[keyof typeof AiVoiceChatType];
|
||||
|
||||
export type AiVoiceCategory = NamespaceAiVoiceCategory;
|
||||
export interface AiVoiceItem {
|
||||
voiceId: string;
|
||||
voiceDisplayName: string;
|
||||
voiceExampleUrl: string;
|
||||
}
|
||||
|
||||
export interface AiVoiceSendReceipt {
|
||||
sequence: number;
|
||||
}
|
||||
|
||||
const log = createLogger('Bridge.Extras');
|
||||
const AI_VOICE_RECEIPT_TIMEOUT_MS = 5_000;
|
||||
const AI_VOICE_CANDIDATE_LIMIT = 64;
|
||||
const AI_VOICE_CLAIM_LIMIT = 256;
|
||||
|
||||
type GroupMessageEvent = Extract<QQEventVariant, { kind: 'group_message' }>;
|
||||
|
||||
function normalizeMediaUuid(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function matchesAiVoiceEvent(node: MediaIndexNode, event: GroupMessageEvent): boolean {
|
||||
const expectedUuid = normalizeMediaUuid(node.fileUuid);
|
||||
|
||||
if (!expectedUuid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const element of event.elements) {
|
||||
if (element.type !== 'record') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const actualUuid = normalizeMediaUuid(element.mediaNode?.fileUuid || element.fileId);
|
||||
|
||||
if (actualUuid === expectedUuid) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function describeAiVoiceFingerprint(node: MediaIndexNode): string {
|
||||
const uuid = normalizeMediaUuid(node.fileUuid);
|
||||
|
||||
if (uuid) {
|
||||
return `uuid:${uuid.slice(-12)}`;
|
||||
}
|
||||
|
||||
return 'missing';
|
||||
}
|
||||
|
||||
export class ExtrasApi {
|
||||
private readonly claimedAiVoiceReceiptKeys = new Set<string>();
|
||||
|
||||
constructor(private readonly ctx: BridgeContext) {}
|
||||
|
||||
// ─────────────── Group todo query / mutations (0x9474 / 0xF90) ───────────────
|
||||
|
||||
async getGroupTodoList(groupId: number): Promise<GroupTodoListItem[]> {
|
||||
const items = await GetGroupTodoList.invoke(this.ctx, { groupId });
|
||||
log.debug('group todo list fetched group=%d count=%d', groupId, items.length);
|
||||
return items;
|
||||
}
|
||||
|
||||
setGroupTodo(groupId: number, msgSeq: bigint): Promise<void> {
|
||||
return GroupTodo.invoke(this.ctx, { groupId, msgSeq, action: 'set' });
|
||||
}
|
||||
|
||||
completeGroupTodo(groupId: number, msgSeq: bigint): Promise<void> {
|
||||
return GroupTodo.invoke(this.ctx, { groupId, msgSeq, action: 'complete' });
|
||||
}
|
||||
|
||||
cancelGroupTodo(groupId: number, msgSeq: bigint): Promise<void> {
|
||||
return GroupTodo.invoke(this.ctx, { groupId, msgSeq, action: 'cancel' });
|
||||
}
|
||||
|
||||
// ─────────────── Stranger online/ext status (0xFE1_2) ───────────────
|
||||
|
||||
/**
|
||||
* Keeps the action response user-safe while retaining the underlying
|
||||
* failure in the service log for diagnosis.
|
||||
*/
|
||||
async getStrangerStatus(uin: number): Promise<StrangerStatus | null> {
|
||||
try {
|
||||
const status = await GetStrangerStatus.invoke(this.ctx, { uin });
|
||||
|
||||
if (!status) {
|
||||
log.warn('user status query returned no status target=%d', uin);
|
||||
}
|
||||
|
||||
return status;
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
'user status query failed target=%d: %s',
|
||||
uin,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────── AI voice (0x929D / 0x929B) ───────────────
|
||||
|
||||
fetchAiVoiceList(
|
||||
groupId: number,
|
||||
chatType: AiVoiceChatType | number,
|
||||
): Promise<AiVoiceCategory[]> {
|
||||
return FetchAiVoiceList.invoke(this.ctx, { groupId, chatType });
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger AI voice synthesis. Server may return an empty msgInfo while
|
||||
* the render is in-flight; we retry until a node materialises or the
|
||||
* cap is hit. napcat uses the same 30-retry budget.
|
||||
*
|
||||
* The returned node plugs directly into
|
||||
* `apis.groupFile.getPttUrl`, which already handles every other
|
||||
* download-URL fetch in SnowLuma. The synthesis response does not contain a
|
||||
* reliable message sequence; callers that need one must use `sendAiVoice`,
|
||||
* which correlates the subsequent self-message receipt.
|
||||
*/
|
||||
async fetchAiVoice(
|
||||
groupId: number,
|
||||
voiceId: string,
|
||||
text: string,
|
||||
chatType: AiVoiceChatType | number,
|
||||
maxRetries = 30,
|
||||
): Promise<MediaIndexNode> {
|
||||
// Random 32-bit session id — server uses this to deduplicate polls.
|
||||
const sessionId = Math.floor(Math.random() * 0xffffffff) >>> 0;
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
const node = await FetchAiVoice.invoke(this.ctx, {
|
||||
groupId,
|
||||
voiceId,
|
||||
text,
|
||||
chatType,
|
||||
sessionId,
|
||||
});
|
||||
|
||||
if (node) {
|
||||
log.debug(
|
||||
'AI voice synthesis completed group=%d media=%s polls=%d',
|
||||
groupId,
|
||||
describeAiVoiceFingerprint(node),
|
||||
i + 1,
|
||||
);
|
||||
return node as MediaIndexNode;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`AI voice synthesis did not complete after ${maxRetries} polls`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish an AI voice and wait for QQ's canonical self-message receipt.
|
||||
*
|
||||
* The 0x929B completion response carries the media node but, on live QQ,
|
||||
* omits the group message sequence. Subscribe before publishing so an echo
|
||||
* that wins the response race is buffered, then correlate by the exact media
|
||||
* UUID. Never guess from content hashes or "the next voice in the group":
|
||||
* concurrent requests can legitimately render byte-identical audio.
|
||||
*/
|
||||
async sendAiVoice(
|
||||
groupId: number,
|
||||
voiceId: string,
|
||||
text: string,
|
||||
chatType: AiVoiceChatType | number,
|
||||
receiptTimeoutMs = AI_VOICE_RECEIPT_TIMEOUT_MS,
|
||||
): Promise<AiVoiceSendReceipt> {
|
||||
const candidates: GroupMessageEvent[] = [];
|
||||
let candidatesOverflowed = false;
|
||||
let expectedNode: MediaIndexNode | null = null;
|
||||
let receiptSettled = false;
|
||||
let resolveReceipt: ((event: GroupMessageEvent) => void) | null = null;
|
||||
const receiptPromise = new Promise<GroupMessageEvent>((resolve) => {
|
||||
resolveReceipt = resolve;
|
||||
});
|
||||
|
||||
const claim = (event: GroupMessageEvent): boolean => {
|
||||
if (receiptSettled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const key = `${event.groupId}:${event.msgSeq}:${event.msgId}`;
|
||||
|
||||
if (this.claimedAiVoiceReceiptKeys.has(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.claimedAiVoiceReceiptKeys.add(key);
|
||||
|
||||
if (this.claimedAiVoiceReceiptKeys.size > AI_VOICE_CLAIM_LIMIT) {
|
||||
const oldest = this.claimedAiVoiceReceiptKeys.values().next().value;
|
||||
|
||||
if (oldest !== undefined) {
|
||||
this.claimedAiVoiceReceiptKeys.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
receiptSettled = true;
|
||||
return true;
|
||||
};
|
||||
|
||||
const accept = (event: GroupMessageEvent): void => {
|
||||
if (event.groupId !== groupId || event.senderUin !== event.selfUin) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.elements.some((element) => element.type === 'record')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (expectedNode && matchesAiVoiceEvent(expectedNode, event) && claim(event)) {
|
||||
resolveReceipt?.(event);
|
||||
return;
|
||||
}
|
||||
|
||||
candidates.push(event);
|
||||
|
||||
if (candidates.length > AI_VOICE_CANDIDATE_LIMIT) {
|
||||
candidates.shift();
|
||||
candidatesOverflowed = true;
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribe = this.ctx.events.on('group_message', accept);
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
try {
|
||||
const node = await this.fetchAiVoice(groupId, voiceId, text, chatType);
|
||||
expectedNode = node;
|
||||
const fingerprint = describeAiVoiceFingerprint(node);
|
||||
|
||||
if (fingerprint === 'missing') {
|
||||
throw new Error(
|
||||
'AI voice was published but QQ returned no exact media identifier for receipt correlation',
|
||||
);
|
||||
}
|
||||
|
||||
const buffered = candidates.find(
|
||||
(event) => matchesAiVoiceEvent(node, event) && claim(event),
|
||||
);
|
||||
|
||||
if (!buffered && candidatesOverflowed) {
|
||||
throw new Error(
|
||||
`AI voice was published but receipt correlation overflowed after ${AI_VOICE_CANDIDATE_LIMIT} ` +
|
||||
`unmatched self voice messages (group=${groupId} media=${fingerprint})`,
|
||||
);
|
||||
}
|
||||
|
||||
const event =
|
||||
buffered ??
|
||||
(await Promise.race([
|
||||
receiptPromise,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
reject(
|
||||
new Error(
|
||||
`AI voice was published but no matching group-message receipt arrived within ${receiptTimeoutMs}ms ` +
|
||||
`(group=${groupId} media=${fingerprint})`,
|
||||
),
|
||||
);
|
||||
}, receiptTimeoutMs);
|
||||
timeout.unref();
|
||||
}),
|
||||
]));
|
||||
|
||||
if (!Number.isInteger(event.msgSeq) || event.msgSeq <= 0) {
|
||||
throw new Error(
|
||||
`AI voice receipt carried invalid message sequence: ${event.msgSeq}`,
|
||||
);
|
||||
}
|
||||
|
||||
log.debug(
|
||||
'AI voice receipt matched group=%d media=%s sequence=%d random=%d',
|
||||
groupId,
|
||||
fingerprint,
|
||||
event.msgSeq,
|
||||
event.msgId,
|
||||
);
|
||||
return {
|
||||
sequence: event.msgSeq,
|
||||
};
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────── Voice-to-text (pttTrans.Trans{C2C,Group}PttReq) ───────────────
|
||||
|
||||
/**
|
||||
* Send ONE `pttTrans.Trans{C2C,Group}PttReq` to trigger transcription and
|
||||
* return the recognised text IF the response carries it inline (the
|
||||
* already-transcribed case). For a freshly-received voice the response is an
|
||||
* empty ack and the text is delivered later via the async Event 0x210
|
||||
* subType-61 push — callers should treat `''` as "pending" and wait for the
|
||||
* `ptt_trans_result` event (correlated by msgId).
|
||||
*/
|
||||
async translatePttToText(input: PttTransInput): Promise<string> {
|
||||
// QQ NT sends the md5 as a 32-char lowercase HEX STRING, not the raw 16
|
||||
// bytes (RE'd from wrapper.linux.node EncodeTroopPtt/EncodeC2CPtt: the md5
|
||||
// field goes through a bytes→hex helper). Sending raw bytes → errCode -1079.
|
||||
const md5 = input.md5Hex.toLowerCase();
|
||||
const req: PttTransReq = input.isGroup
|
||||
? {
|
||||
type: 1,
|
||||
groupItem: {
|
||||
msgId: BigInt(input.msgId),
|
||||
senderUin: BigInt(input.senderUin),
|
||||
groupUin: BigInt(input.peerUin),
|
||||
fileId: input.fileId ?? 0,
|
||||
md5,
|
||||
duration: input.duration,
|
||||
size: input.size,
|
||||
format: input.format,
|
||||
uuid: input.uuid,
|
||||
},
|
||||
}
|
||||
: {
|
||||
type: 2,
|
||||
c2cItem: {
|
||||
msgId: BigInt(input.msgId),
|
||||
senderUin: BigInt(input.senderUin),
|
||||
receiverUin: BigInt(input.peerUin),
|
||||
uuid: input.uuid,
|
||||
duration: input.duration,
|
||||
size: input.size,
|
||||
format: input.format,
|
||||
md5,
|
||||
},
|
||||
};
|
||||
const cmd = input.isGroup ? 'pttTrans.TransGroupPttReq' : 'pttTrans.TransC2CPttReq';
|
||||
|
||||
const result = await this.ctx.sendRawPacket(cmd, protobuf_encode<PttTransReq>(req));
|
||||
|
||||
if (!result.success || !result.gotResponse || !result.responseData) {
|
||||
throw new Error(result.errorMessage || 'ptt translate request failed');
|
||||
}
|
||||
|
||||
const resp = protobuf_decode<PttTransResp>(result.responseData);
|
||||
const item = input.isGroup ? resp?.groupResult : resp?.c2cResult;
|
||||
|
||||
if (item?.errCode) {
|
||||
// Diagnostic (#165 备选): correlate QQ's errCode with the request fields we
|
||||
// sourced from cache, to tell a server-side ASR failure apart from our own
|
||||
// missing/zero field (md5/format/uuid). Behaviour unchanged — still throws.
|
||||
log.warn(
|
||||
'ptt translate errCode=%d isGroup=%s md5=%s format=%d uuid=%s dur=%d size=%d',
|
||||
item.errCode,
|
||||
input.isGroup,
|
||||
input.md5Hex ? `${input.md5Hex.length / 2}B` : 'EMPTY',
|
||||
input.format,
|
||||
input.uuid ? 'set' : 'EMPTY',
|
||||
input.duration,
|
||||
input.size,
|
||||
);
|
||||
throw new Error(`ptt translate failed: error=${item.errCode}`);
|
||||
}
|
||||
|
||||
return item?.text ?? ''; // '' = transcribing async; caller awaits the push
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcode a voice record (`get_record out_format`, #165). `source` is a
|
||||
* URL / local path / `base64://…` (the record's download URL in practice);
|
||||
* it is loaded then transcoded to `format` via the bundled SILK-capable
|
||||
* ffmpeg addon. Returns base64 + size. Throws on unsupported format or a
|
||||
* failed conversion.
|
||||
*/
|
||||
async convertRecord(source: string, format: string): Promise<{ base64: string; size: number }> {
|
||||
// Voices are tiny — cap the download well below loadBinarySource's 1 GiB
|
||||
// default so a tampered/oversized source can't be pulled in.
|
||||
const { bytes } = await loadBinarySource(source, 'record', 64 * 1024 * 1024);
|
||||
return convertAudioBytes(bytes, format);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,69 @@
|
||||
import { createLogger } from '@snowluma/common/logger';
|
||||
import type { ForwardNodePayload } from '@snowluma/protocol/events';
|
||||
|
||||
const MAX_CACHED_FORWARDS = 512;
|
||||
const log = createLogger('Bridge.Forward');
|
||||
type ForwardLayers = Map<string, ForwardNodePayload[]>;
|
||||
|
||||
interface ForwardCacheHit {
|
||||
rootId: string;
|
||||
resId: string;
|
||||
layers: ForwardLayers;
|
||||
nodes: ForwardNodePayload[];
|
||||
}
|
||||
|
||||
/** Nested results share the lifetime and recency of the result that supplied them. */
|
||||
export class ForwardResultCache {
|
||||
private readonly entries = new Map<string, ForwardLayers>();
|
||||
|
||||
get(resId: string): ForwardCacheHit | undefined {
|
||||
let hit: ForwardCacheHit | undefined;
|
||||
|
||||
// A nested result can be supplied by several retained outer results.
|
||||
for (const [rootId, layers] of this.entries) {
|
||||
const nodes = layers.get(resId);
|
||||
|
||||
if (nodes) {
|
||||
hit = { rootId, resId, layers, nodes };
|
||||
}
|
||||
}
|
||||
|
||||
if (hit) {
|
||||
this.entries.delete(hit.rootId);
|
||||
this.entries.set(hit.rootId, hit.layers);
|
||||
}
|
||||
|
||||
return hit;
|
||||
}
|
||||
|
||||
set(rootId: string, layers: ReadonlyMap<string, ForwardNodePayload[]>): void {
|
||||
if (!layers.has(rootId)) {
|
||||
throw new Error('forward cache entry is missing its outer result');
|
||||
}
|
||||
|
||||
this.entries.delete(rootId);
|
||||
this.entries.set(rootId, new Map(layers));
|
||||
|
||||
while (this.entries.size > MAX_CACHED_FORWARDS) {
|
||||
const oldest = this.entries.entries().next().value!;
|
||||
this.entries.delete(oldest[0]);
|
||||
log.trace(
|
||||
'forward cache evicted: res_id=%s layers=%d remaining=%d',
|
||||
oldest[0],
|
||||
oldest[1].size,
|
||||
this.entries.size,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
update(hit: ForwardCacheHit, nodes: ForwardNodePayload[]): void {
|
||||
// Enrichment may finish after eviction or replacement. It must not revive
|
||||
// an old result or turn a nested result into an independent cache entry.
|
||||
if (
|
||||
this.entries.get(hit.rootId) === hit.layers &&
|
||||
hit.layers.get(hit.resId) === hit.nodes
|
||||
) {
|
||||
hit.layers.set(hit.resId, nodes);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,123 @@
|
||||
import { createLogger } from '@snowluma/common/logger';
|
||||
import { findDatalineDeviceByUin } from '@snowluma/protocol/dataline/device-contacts';
|
||||
import { ApproveDoubtBuddyReq } from '@snowluma/protocol/oidb-services/friend/approve-doubt-buddy-req';
|
||||
import { ClearFriendRemark } from '@snowluma/protocol/oidb-services/friend/clear-friend-remark';
|
||||
import { DeleteFriend } from '@snowluma/protocol/oidb-services/friend/delete-friend';
|
||||
import {
|
||||
GetDoubtBuddyReq,
|
||||
type DoubtBuddyRequest,
|
||||
} from '@snowluma/protocol/oidb-services/friend/get-doubt-buddy-req';
|
||||
import { HandleFriendRequest } from '@snowluma/protocol/oidb-services/friend/handle-friend-request';
|
||||
import { RejectDoubtBuddyReq } from '@snowluma/protocol/oidb-services/friend/reject-doubt-buddy-req';
|
||||
import { SetFriendRemark } from '@snowluma/protocol/oidb-services/friend/set-friend-remark';
|
||||
import type { BridgeContext } from '../bridge-context';
|
||||
|
||||
export type { DoubtBuddyRequest };
|
||||
|
||||
const log = createLogger('Bridge.Friend');
|
||||
|
||||
export class FriendApi {
|
||||
constructor(private readonly ctx: BridgeContext) {}
|
||||
|
||||
/**
|
||||
* Accept or reject an inbound friend request. `uidOrFlag` is either a
|
||||
* pre-resolved UID string or a numeric uin (then resolved on the fly).
|
||||
*/
|
||||
handleRequest(uidOrFlag: string, approve: boolean): Promise<void> {
|
||||
return HandleFriendRequest.invoke(this.ctx, { uidOrFlag, approve });
|
||||
}
|
||||
|
||||
async delete(userId: number, block = false): Promise<void> {
|
||||
if (findDatalineDeviceByUin(userId)) {
|
||||
throw new Error('this contact cannot be deleted');
|
||||
}
|
||||
|
||||
await DeleteFriend.invoke(this.ctx, { userId, block });
|
||||
|
||||
// The server-side delete has already completed. A refresh failure must be
|
||||
// visible, but throwing here would misreport the delete itself as failed
|
||||
// and encourage callers to repeat a destructive request.
|
||||
try {
|
||||
await this.ctx.apis.contacts.fetchFriendList();
|
||||
} catch (err: unknown) {
|
||||
log.warn(
|
||||
'friend-list refresh failed after deleting user=%d: %s',
|
||||
userId,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async setRemark(userId: number, remark: string): Promise<void> {
|
||||
if (findDatalineDeviceByUin(userId)) {
|
||||
throw new Error('this contact does not support remarks');
|
||||
}
|
||||
|
||||
if (remark === '') {
|
||||
await ClearFriendRemark.invoke(this.ctx, { userId });
|
||||
return;
|
||||
}
|
||||
|
||||
await SetFriendRemark.invoke(this.ctx, { userId, remark });
|
||||
}
|
||||
|
||||
/** List doubtful friend-add requests (可能认识的人). */
|
||||
async getDoubtRequests(count: number): Promise<DoubtBuddyRequest[]> {
|
||||
const list = await GetDoubtBuddyReq.invoke(this.ctx, { count });
|
||||
return Promise.all(
|
||||
list.map(async (item) => {
|
||||
let uid = item.uid;
|
||||
let userId = item.user_id;
|
||||
|
||||
if (userId <= 0 && uid) {
|
||||
userId = this.ctx.identity.findUinByUid(uid) ?? 0;
|
||||
}
|
||||
|
||||
// Applicants need not be friends or share a group. Use Identity's
|
||||
// profile lookup when the list only supplies an account number.
|
||||
if (!uid && userId > 0) {
|
||||
try {
|
||||
uid = await this.ctx.resolveUserUid(userId);
|
||||
} catch (err: unknown) {
|
||||
log.warn(
|
||||
'doubt-request uid resolve failed: user=%d err=%s',
|
||||
userId,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { ...item, uid: uid || '', user_id: userId };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Approve a doubtful friend-add request. `uidOrFlag` is the list uid, or a digit-only account number. */
|
||||
async approveDoubtRequest(uidOrFlag: string): Promise<void> {
|
||||
log.trace('doubt-request approval: resolving applicant');
|
||||
const uid = await this.resolveDoubtFlag(uidOrFlag);
|
||||
log.trace('doubt-request approval: submitting request');
|
||||
await ApproveDoubtBuddyReq.invoke(this.ctx, { uid });
|
||||
log.trace('doubt-request approval: completed');
|
||||
}
|
||||
|
||||
/** Reject (delete/decline) a doubtful friend-add request. `uidOrFlag` matches `approveDoubtRequest`. */
|
||||
async rejectDoubtRequest(uidOrFlag: string): Promise<void> {
|
||||
return RejectDoubtBuddyReq.invoke(this.ctx, {
|
||||
uid: await this.resolveDoubtFlag(uidOrFlag),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Digit-only flags are account numbers and must be translated before the
|
||||
* approval/reject packet is built. A raw number in that packet is rejected
|
||||
* by the server. Same rule as inbound friend-request handling.
|
||||
*/
|
||||
private resolveDoubtFlag(uidOrFlag: string): Promise<string> {
|
||||
if (!/^\d+$/.test(uidOrFlag)) {
|
||||
return Promise.resolve(uidOrFlag);
|
||||
}
|
||||
|
||||
return this.ctx.resolveUserUid(parseInt(uidOrFlag, 10));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
import { GetAtAllRemain } from '@snowluma/protocol/oidb-services/group-admin/get-at-all-remain';
|
||||
import { FetchGroupDetail } from '@snowluma/protocol/oidb-services/contacts/fetch-group-detail';
|
||||
import { KickMember } from '@snowluma/protocol/oidb-services/group-admin/kick-member';
|
||||
import { KickMembers } from '@snowluma/protocol/oidb-services/group-admin/kick-members';
|
||||
import { LeaveGroup } from '@snowluma/protocol/oidb-services/group-admin/leave-group';
|
||||
import { MuteAll } from '@snowluma/protocol/oidb-services/group-admin/mute-all';
|
||||
import { MuteMember } from '@snowluma/protocol/oidb-services/group-admin/mute-member';
|
||||
import { SetAddOption } from '@snowluma/protocol/oidb-services/group-admin/set-add-option';
|
||||
import { SetAddRequest } from '@snowluma/protocol/oidb-services/group-admin/set-add-request';
|
||||
import { SetAdmin } from '@snowluma/protocol/oidb-services/group-admin/set-admin';
|
||||
import { GetAdminPermissions } from '@snowluma/protocol/oidb-services/group-admin/get-admin-permissions';
|
||||
import { GetAdminReceiveVerify } from '@snowluma/protocol/oidb-services/group-admin/get-admin-receive-verify';
|
||||
import {
|
||||
SetAdminPermissions,
|
||||
type GroupAdminPermissionPatch,
|
||||
} from '@snowluma/protocol/oidb-services/group-admin/set-admin-permissions';
|
||||
import { SetAdminReceiveVerify } from '@snowluma/protocol/oidb-services/group-admin/set-admin-receive-verify';
|
||||
import { createLogger } from '@snowluma/common/logger';
|
||||
import { SetGroupName } from '@snowluma/protocol/oidb-services/group-admin/set-group-name';
|
||||
import { SetGroupRemark } from '@snowluma/protocol/oidb-services/group-admin/set-group-remark';
|
||||
import { SetMemberCard } from '@snowluma/protocol/oidb-services/group-admin/set-member-card';
|
||||
import { FetchGroupExtInfo } from '@snowluma/protocol/oidb-services/group-admin/get-group-ext-info';
|
||||
import {
|
||||
decodeMemberInvitePolicy,
|
||||
MEMBER_INVITE_PRIVILEGE_MASK,
|
||||
mergeMemberInvitePrivilegeFlag,
|
||||
SetMemberInvitePolicy,
|
||||
type GroupMemberInvitePolicy,
|
||||
} from '@snowluma/protocol/oidb-services/group-admin/set-member-invite-policy';
|
||||
import {
|
||||
decodeGroupHistoryVisibility,
|
||||
SetNewMemberHistoryVisibility,
|
||||
} from '@snowluma/protocol/oidb-services/group-admin/set-new-member-history-visibility';
|
||||
import {
|
||||
decodeGroupMemberPermissions,
|
||||
GROUP_MEMBER_PERMISSION_MASKS,
|
||||
mergeGroupMemberPermission,
|
||||
SetMemberPermission,
|
||||
type GroupMemberPermission,
|
||||
} from '@snowluma/protocol/oidb-services/group-admin/set-member-permission';
|
||||
import { SetSearch } from '@snowluma/protocol/oidb-services/group-admin/set-search';
|
||||
import { GetGroupCategoryTags } from '@snowluma/protocol/oidb-services/group-admin/get-group-category-tags';
|
||||
import { AddGroupCategoryTag } from '@snowluma/protocol/oidb-services/group-admin/add-group-category-tag';
|
||||
import { DeleteGroupCategoryTag } from '@snowluma/protocol/oidb-services/group-admin/delete-group-category-tag';
|
||||
import { SetSpecialTitle } from '@snowluma/protocol/oidb-services/group-admin/set-special-title';
|
||||
import { ModifyGroupExtInfo } from '@snowluma/protocol/oidb-services/group-admin/modify-group-ext-info';
|
||||
import { OidbError } from '@snowluma/protocol/oidb-service';
|
||||
import type { BridgeContext } from '../bridge-context';
|
||||
|
||||
const log = createLogger('Bridge.GroupAdmin');
|
||||
|
||||
/** QQ only accepts this write from the group owner (#411). */
|
||||
export const MEMBER_PERMISSIONS_OWNER_ONLY =
|
||||
'only the group owner can change group member permissions';
|
||||
|
||||
export type GroupAdminSettings = {
|
||||
add_type: number;
|
||||
group_question: string;
|
||||
group_answer: string;
|
||||
robot_member_switch: number;
|
||||
robot_member_examine: number;
|
||||
member_invite_policy: GroupMemberInvitePolicy;
|
||||
allow_member_upload_album: boolean;
|
||||
allow_member_temporary_session: boolean;
|
||||
allow_member_create_group: boolean;
|
||||
new_member_history_visible: boolean;
|
||||
no_finger_open: number;
|
||||
no_code_finger_open: number;
|
||||
};
|
||||
|
||||
export interface GroupMemberPermissions {
|
||||
allowMemberUploadAlbum?: boolean;
|
||||
allowMemberTemporarySession?: boolean;
|
||||
allowMemberCreateGroup?: boolean;
|
||||
}
|
||||
|
||||
export class GroupAdminApi {
|
||||
constructor(private readonly ctx: BridgeContext) {}
|
||||
|
||||
/** Set the group's robot-add option (switch / examine) via group ext-info. */
|
||||
setRobotAddOption(
|
||||
groupId: number,
|
||||
robotMemberSwitch?: number,
|
||||
robotMemberExamine?: number,
|
||||
): Promise<void> {
|
||||
return ModifyGroupExtInfo.invoke(this.ctx, {
|
||||
groupId,
|
||||
robotMemberSwitch,
|
||||
robotMemberExamine,
|
||||
});
|
||||
}
|
||||
|
||||
async getAdminSettings(groupId: number): Promise<GroupAdminSettings> {
|
||||
const [detail, robot] = await Promise.all([
|
||||
FetchGroupDetail.invoke(this.ctx, { groupUin: groupId }),
|
||||
FetchGroupExtInfo.invoke(this.ctx, { groupId }),
|
||||
]);
|
||||
const results = detail.groupInfo?.results;
|
||||
|
||||
if (!results) {
|
||||
throw new Error(`unable to read group admin settings for group ${groupId}`);
|
||||
}
|
||||
|
||||
const privilegeFlag = results.privilegeFlag ?? 0;
|
||||
const permissions = decodeGroupMemberPermissions(privilegeFlag);
|
||||
return {
|
||||
add_type: results.addType ?? 0,
|
||||
group_question: results.question ?? '',
|
||||
group_answer: results.answer ?? '',
|
||||
robot_member_switch: robot.robotMemberSwitch,
|
||||
robot_member_examine: robot.robotMemberExamine,
|
||||
member_invite_policy: decodeMemberInvitePolicy(privilegeFlag),
|
||||
allow_member_upload_album: permissions.allowMemberUploadAlbum,
|
||||
allow_member_temporary_session: permissions.allowMemberTemporarySession,
|
||||
allow_member_create_group: permissions.allowMemberCreateGroup,
|
||||
new_member_history_visible: decodeGroupHistoryVisibility(results.groupFlagExt4 ?? 0),
|
||||
no_finger_open: results.noFingerOpen ?? 0,
|
||||
no_code_finger_open: results.noCodeFingerOpen ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
muteMember(groupId: number, userId: number, duration: number): Promise<void> {
|
||||
return MuteMember.invoke(this.ctx, { groupId, userId, duration });
|
||||
}
|
||||
|
||||
muteAll(groupId: number, enable: boolean): Promise<void> {
|
||||
return MuteAll.invoke(this.ctx, { groupId, enable });
|
||||
}
|
||||
|
||||
setAddOption(
|
||||
groupId: number,
|
||||
addType: number,
|
||||
groupQuestion?: string,
|
||||
groupAnswer?: string,
|
||||
): Promise<void> {
|
||||
return SetAddOption.invoke(this.ctx, { groupId, addType, groupQuestion, groupAnswer });
|
||||
}
|
||||
|
||||
setSearch(groupId: number, noFingerOpen?: number, noCodeFingerOpen?: number): Promise<void> {
|
||||
return SetSearch.invoke(this.ctx, { groupId, noFingerOpen, noCodeFingerOpen });
|
||||
}
|
||||
|
||||
listCategoryTags(
|
||||
groupClass: number,
|
||||
): Promise<{ tags: { id: number; name: string; type: number }[] }> {
|
||||
return GetGroupCategoryTags.invoke(this.ctx, { groupClass });
|
||||
}
|
||||
|
||||
addCategoryTag(groupClass: number, name: string): Promise<void> {
|
||||
return AddGroupCategoryTag.invoke(this.ctx, { groupClass, name });
|
||||
}
|
||||
|
||||
removeCategoryTag(groupClass: number, name: string): Promise<void> {
|
||||
return DeleteGroupCategoryTag.invoke(this.ctx, { groupClass, name });
|
||||
}
|
||||
|
||||
async setMemberInvitePolicy(groupId: number, policy: GroupMemberInvitePolicy): Promise<void> {
|
||||
const currentPrivilegeFlag = await this.fetchGroupPrivilegeFlag(
|
||||
groupId,
|
||||
'group member invite policy',
|
||||
'before update',
|
||||
);
|
||||
const expectedPrivilegeFlag = mergeMemberInvitePrivilegeFlag(currentPrivilegeFlag, policy);
|
||||
|
||||
await SetMemberInvitePolicy.invoke(this.ctx, {
|
||||
groupId,
|
||||
currentPrivilegeFlag,
|
||||
policy,
|
||||
});
|
||||
|
||||
const actualPrivilegeFlag = await this.fetchGroupPrivilegeFlag(
|
||||
groupId,
|
||||
'group member invite policy',
|
||||
'after update',
|
||||
);
|
||||
const mask = BigInt(MEMBER_INVITE_PRIVILEGE_MASK);
|
||||
|
||||
if ((BigInt(actualPrivilegeFlag) & mask) !== (BigInt(expectedPrivilegeFlag) & mask)) {
|
||||
throw new Error(`group member invite policy was not applied for group ${groupId}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchGroupPrivilegeFlag(
|
||||
groupId: number,
|
||||
setting: string,
|
||||
phase: string,
|
||||
): Promise<number> {
|
||||
return (await this.fetchGroupPrivilegeState(groupId, setting, phase)).privilegeFlag;
|
||||
}
|
||||
|
||||
private async fetchGroupPrivilegeState(
|
||||
groupId: number,
|
||||
setting: string,
|
||||
phase: string,
|
||||
): Promise<{ privilegeFlag: number; ownerUid?: string }> {
|
||||
const detail = await FetchGroupDetail.invoke(this.ctx, { groupUin: groupId });
|
||||
const results = detail.groupInfo?.results;
|
||||
|
||||
if (!results) {
|
||||
throw new Error(`unable to read ${setting} ${phase} for group ${groupId}`);
|
||||
}
|
||||
|
||||
return {
|
||||
privilegeFlag: results.privilegeFlag ?? 0,
|
||||
ownerUid: results.ownerUid,
|
||||
};
|
||||
}
|
||||
|
||||
async setMemberPermissions(
|
||||
groupId: number,
|
||||
permissions: GroupMemberPermissions,
|
||||
): Promise<void> {
|
||||
const updates: ReadonlyArray<readonly [GroupMemberPermission, boolean | undefined]> = [
|
||||
['upload_album', permissions.allowMemberUploadAlbum],
|
||||
['temporary_session', permissions.allowMemberTemporarySession],
|
||||
['create_group', permissions.allowMemberCreateGroup],
|
||||
];
|
||||
|
||||
if (!updates.some(([, allow]) => allow !== undefined)) {
|
||||
throw new Error('at least one member permission must be specified');
|
||||
}
|
||||
|
||||
const before = await this.fetchGroupPrivilegeState(
|
||||
groupId,
|
||||
'group member permissions',
|
||||
'before update',
|
||||
);
|
||||
const selfUid = this.ctx.identity.selfUid;
|
||||
|
||||
if (selfUid && before.ownerUid && selfUid !== before.ownerUid) {
|
||||
throw new Error(MEMBER_PERMISSIONS_OWNER_ONLY);
|
||||
}
|
||||
|
||||
let expectedPrivilegeFlag = before.privilegeFlag;
|
||||
let combinedMask = 0n;
|
||||
|
||||
for (const [permission, allow] of updates) {
|
||||
if (allow === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await SetMemberPermission.invoke(this.ctx, {
|
||||
groupId,
|
||||
currentPrivilegeFlag: expectedPrivilegeFlag,
|
||||
permission,
|
||||
allow,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof OidbError && error.code === 1287) {
|
||||
throw new Error(MEMBER_PERMISSIONS_OWNER_ONLY);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
expectedPrivilegeFlag = mergeGroupMemberPermission(
|
||||
expectedPrivilegeFlag,
|
||||
permission,
|
||||
allow,
|
||||
);
|
||||
combinedMask |= BigInt(GROUP_MEMBER_PERMISSION_MASKS[permission]);
|
||||
}
|
||||
|
||||
const actualPrivilegeFlag = await this.fetchGroupPrivilegeFlag(
|
||||
groupId,
|
||||
'group member permissions',
|
||||
'after update',
|
||||
);
|
||||
|
||||
if (
|
||||
(BigInt(actualPrivilegeFlag) & combinedMask) !==
|
||||
(BigInt(expectedPrivilegeFlag) & combinedMask)
|
||||
) {
|
||||
throw new Error(`group member permissions were not applied for group ${groupId}`);
|
||||
}
|
||||
}
|
||||
|
||||
async setNewMemberHistoryVisibility(groupId: number, visible: boolean): Promise<void> {
|
||||
await SetNewMemberHistoryVisibility.invoke(this.ctx, { groupId, visible });
|
||||
|
||||
const actualGroupFlagExt4 = await this.fetchGroupFlagExt4(groupId, 'after update');
|
||||
|
||||
if (decodeGroupHistoryVisibility(actualGroupFlagExt4) !== visible) {
|
||||
throw new Error(`new-member history visibility was not applied for group ${groupId}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchGroupFlagExt4(groupId: number, phase: string): Promise<number> {
|
||||
const detail = await FetchGroupDetail.invoke(this.ctx, { groupUin: groupId });
|
||||
const results = detail.groupInfo?.results;
|
||||
|
||||
if (!results) {
|
||||
throw new Error(
|
||||
`unable to read new-member history visibility ${phase} for group ${groupId}`,
|
||||
);
|
||||
}
|
||||
|
||||
return results.groupFlagExt4 ?? 0;
|
||||
}
|
||||
|
||||
setAddRequest(
|
||||
groupId: number,
|
||||
sequence: number,
|
||||
eventType: number,
|
||||
approve: boolean,
|
||||
reason = '',
|
||||
filtered = false,
|
||||
operateTransInfo?: Uint8Array,
|
||||
): Promise<void> {
|
||||
return SetAddRequest.invoke(this.ctx, {
|
||||
groupId,
|
||||
sequence,
|
||||
eventType,
|
||||
approve,
|
||||
reason,
|
||||
filtered,
|
||||
operateTransInfo,
|
||||
});
|
||||
}
|
||||
|
||||
kickMember(groupId: number, userId: number, reject: boolean, reason = ''): Promise<void> {
|
||||
return KickMember.invoke(this.ctx, { groupId, userId, reject, reason });
|
||||
}
|
||||
|
||||
kickMembers(groupId: number, userIds: number[], reject: boolean): Promise<void> {
|
||||
return KickMembers.invoke(this.ctx, { groupId, userIds, reject });
|
||||
}
|
||||
|
||||
async leave(groupId: number): Promise<void> {
|
||||
await LeaveGroup.invoke(this.ctx, { groupId });
|
||||
// The server doesn't push a member-decrease back to the member who left of
|
||||
// its own accord, so synthesize the self group_member_leave here — otherwise
|
||||
// downstream never sees notice.group_decrease and keeps a "zombie" group in
|
||||
// its cache (#133). Also drop the group from our own roster.
|
||||
const selfUin = Number(this.ctx.identity.uin) || 0;
|
||||
const selfUid = this.ctx.identity.selfUid ?? '';
|
||||
await this.ctx.events.emit({
|
||||
kind: 'group_member_leave',
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
selfUin,
|
||||
groupId,
|
||||
userUin: selfUin,
|
||||
operatorUin: selfUin,
|
||||
userUid: selfUid,
|
||||
operatorUid: selfUid,
|
||||
leaveType: 'leave',
|
||||
});
|
||||
this.ctx.identity.forgetGroup(groupId);
|
||||
}
|
||||
|
||||
setAdmin(groupId: number, userId: number, enable: boolean): Promise<void> {
|
||||
return SetAdmin.invoke(this.ctx, { groupId, userId, enable });
|
||||
}
|
||||
|
||||
async getAdminPermissions(groupId: number, userId: number) {
|
||||
const logger = log.child({ uin: Number(this.ctx.identity.uin) });
|
||||
logger.debug('Reading administrator permissions group=%s user=%s', groupId, userId);
|
||||
const [snapshot, receiveVerify] = await Promise.all([
|
||||
GetAdminPermissions.invoke(this.ctx, { groupId, userId }),
|
||||
GetAdminReceiveVerify.invoke(this.ctx, { groupId, userId }),
|
||||
]);
|
||||
logger.debug(
|
||||
'Administrator permissions received group=%s user=%s recall=%s kick=%s limit=%s receiveVerify=%s',
|
||||
groupId,
|
||||
userId,
|
||||
snapshot.recallMemberMessage,
|
||||
snapshot.kickMember,
|
||||
snapshot.kickMemberPerDay,
|
||||
receiveVerify,
|
||||
);
|
||||
return {
|
||||
group_id: groupId,
|
||||
user_id: userId,
|
||||
recall_member_message: snapshot.recallMemberMessage,
|
||||
kick_member: snapshot.kickMember,
|
||||
kick_member_per_day: snapshot.kickMemberPerDay,
|
||||
receive_verify: receiveVerify,
|
||||
};
|
||||
}
|
||||
|
||||
async setAdminPermissions(
|
||||
groupId: number,
|
||||
userId: number,
|
||||
patch: GroupAdminPermissionPatch,
|
||||
): Promise<void> {
|
||||
const logger = log.child({ uin: Number(this.ctx.identity.uin) });
|
||||
logger.debug(
|
||||
'Setting administrator permissions group=%s user=%s recall=%s kick=%s limit=%s',
|
||||
groupId,
|
||||
userId,
|
||||
patch.recallMemberMessage,
|
||||
patch.kickMember,
|
||||
patch.kickMemberPerDay,
|
||||
);
|
||||
await SetAdminPermissions.invoke(this.ctx, { groupId, userId, ...patch });
|
||||
logger.debug('Administrator permissions acknowledged group=%s user=%s', groupId, userId);
|
||||
}
|
||||
|
||||
async setAdminReceiveVerify(groupId: number, userId: number, enable: boolean): Promise<void> {
|
||||
const logger = log.child({ uin: Number(this.ctx.identity.uin) });
|
||||
logger.debug(
|
||||
'Setting administrator receive-verify group=%s user=%s enable=%s',
|
||||
groupId,
|
||||
userId,
|
||||
enable,
|
||||
);
|
||||
await SetAdminReceiveVerify.invoke(this.ctx, { groupId, userId, enable });
|
||||
logger.debug('Administrator receive-verify confirmed group=%s user=%s', groupId, userId);
|
||||
}
|
||||
|
||||
setCard(groupId: number, userId: number, card: string): Promise<void> {
|
||||
return SetMemberCard.invoke(this.ctx, { groupId, userId, card });
|
||||
}
|
||||
|
||||
setName(groupId: number, name: string): Promise<void> {
|
||||
return SetGroupName.invoke(this.ctx, { groupId, name });
|
||||
}
|
||||
|
||||
setSpecialTitle(groupId: number, userId: number, title: string): Promise<void> {
|
||||
return SetSpecialTitle.invoke(this.ctx, { groupId, userId, title });
|
||||
}
|
||||
|
||||
/**
|
||||
* The bot's local-only label for a group. Lives here rather than
|
||||
* `FriendApi` because the semantic is "operate on a group" rather
|
||||
* than "operate on a contact list".
|
||||
*/
|
||||
setRemark(groupId: number, remark: string): Promise<void> {
|
||||
return SetGroupRemark.invoke(this.ctx, { groupId, remark });
|
||||
}
|
||||
|
||||
getAtAllRemain(groupId: number): Promise<{
|
||||
can_at_all: boolean;
|
||||
remain_at_all_count_for_group: number;
|
||||
remain_at_all_count_for_uin: number;
|
||||
}> {
|
||||
return GetAtAllRemain.invoke(this.ctx, { groupId });
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,849 @@
|
||||
import { createLogger } from '@snowluma/common/logger';
|
||||
import type { FileUploadExt } from '@snowluma/proto-defs/highway';
|
||||
import {
|
||||
FileChunkSource,
|
||||
fetchHighwaySession,
|
||||
uploadHighwayHttp,
|
||||
} from '@snowluma/protocol/highway';
|
||||
import { FILE_UPLOAD_MAX_BYTES } from '@snowluma/protocol/highway/utils';
|
||||
import { stageSourceToDisk } from '@snowluma/protocol/highway/stage';
|
||||
import { hashFileStreaming } from '@snowluma/protocol/highway/hash-file';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import { promises as fsp } from 'fs';
|
||||
import type { BridgeContext } from '../bridge-context';
|
||||
import { resolveSelfUid, toInt, type MediaIndexNode } from './shared';
|
||||
|
||||
import { CreateGroupFolder } from '@snowluma/protocol/oidb-services/group-file/create-group-folder';
|
||||
import { DeleteGroupFile } from '@snowluma/protocol/oidb-services/group-file/delete-group-file';
|
||||
import { DeleteGroupFolder } from '@snowluma/protocol/oidb-services/group-file/delete-group-folder';
|
||||
import { GetGroupFileCount } from '@snowluma/protocol/oidb-services/group-file/get-group-file-count';
|
||||
import { GetGroupFileSpace } from '@snowluma/protocol/oidb-services/group-file/get-group-file-space';
|
||||
import { GetGroupFileUrl } from '@snowluma/protocol/oidb-services/group-file/get-group-file-url';
|
||||
import { GetGroupPttUrl } from '@snowluma/protocol/oidb-services/group-file/get-group-ptt-url';
|
||||
import { GetGroupVideoUrl } from '@snowluma/protocol/oidb-services/group-file/get-group-video-url';
|
||||
import { GetPrivateFileUrl } from '@snowluma/protocol/oidb-services/group-file/get-private-file-url';
|
||||
import { GetPrivatePttUrl } from '@snowluma/protocol/oidb-services/group-file/get-private-ptt-url';
|
||||
import { GetPrivateVideoUrl } from '@snowluma/protocol/oidb-services/group-file/get-private-video-url';
|
||||
import { ListGroupFilesPage } from '@snowluma/protocol/oidb-services/group-file/list-group-files-page';
|
||||
import { MoveGroupFile } from '@snowluma/protocol/oidb-services/group-file/move-group-file';
|
||||
import { PublishGroupFile } from '@snowluma/protocol/oidb-services/group-file/publish-group-file';
|
||||
import { RenameGroupFile } from '@snowluma/protocol/oidb-services/group-file/rename-group-file';
|
||||
import { RenameGroupFolder } from '@snowluma/protocol/oidb-services/group-file/rename-group-folder';
|
||||
import {
|
||||
TransGroupFile,
|
||||
type TransGroupFileResult,
|
||||
} from '@snowluma/protocol/oidb-services/group-file/trans-group-file';
|
||||
import { UploadGroupFileRequest } from '@snowluma/protocol/oidb-services/group-file/upload-group-file-request';
|
||||
import { UploadPrivateFileRequest } from '@snowluma/protocol/oidb-services/group-file/upload-private-file-request';
|
||||
import { ensureRetCodeZero } from '@snowluma/protocol/oidb-services/shared';
|
||||
|
||||
const log = createLogger('GroupFile');
|
||||
|
||||
// ─────────────── public result types ───────────────
|
||||
|
||||
export interface GroupFileInfo {
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
busId: number;
|
||||
fileSize: number;
|
||||
uploadTime: number;
|
||||
deadTime: number;
|
||||
modifyTime: number;
|
||||
downloadTimes: number;
|
||||
uploader: number;
|
||||
uploaderName: string;
|
||||
}
|
||||
|
||||
export interface GroupFolderInfo {
|
||||
folderId: string;
|
||||
folderName: string;
|
||||
createTime: number;
|
||||
creator: number;
|
||||
creatorName: string;
|
||||
totalFileCount: number;
|
||||
lastUploadTime: number;
|
||||
lastUploader: number;
|
||||
lastUploaderName: string;
|
||||
}
|
||||
|
||||
export interface GroupFilesResult {
|
||||
files: GroupFileInfo[];
|
||||
folders: GroupFolderInfo[];
|
||||
}
|
||||
|
||||
export interface UploadFileResult {
|
||||
fileId: string | null;
|
||||
fileHash?: string | null;
|
||||
}
|
||||
|
||||
export interface GroupFileUploadResult {
|
||||
fileId: string;
|
||||
busId: number;
|
||||
}
|
||||
|
||||
export interface GroupFileUploadOptions {
|
||||
/** False when the enclosing message contains content a temporary file cannot carry. */
|
||||
allowTemporary?: boolean;
|
||||
}
|
||||
|
||||
// Re-export so callers don't have to dig into apis/shared.
|
||||
export type { MediaIndexNode } from './shared';
|
||||
|
||||
// ─────────────── file-specific helpers ───────────────
|
||||
|
||||
function normalizeDirectory(dir?: string): string {
|
||||
if (!dir || !dir.trim()) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
return dir;
|
||||
}
|
||||
|
||||
// Reverses acidify's `Int.toIpString()`: the 32-bit IP arrives
|
||||
// little-endian-packed (byte0 = first dotted octet) and we unpack it the
|
||||
// same way. Force-unsigned the shift to keep negative ints (high bit set)
|
||||
// rendering correctly — JS `>>` is arithmetic and would turn 0xFF000000
|
||||
// into a negative number.
|
||||
function int32ToIpv4Dotted(value: number): string {
|
||||
const b1 = value & 0xff;
|
||||
const b2 = (value >>> 8) & 0xff;
|
||||
const b3 = (value >>> 16) & 0xff;
|
||||
const b4 = (value >>> 24) & 0xff;
|
||||
return `${b1}.${b2}.${b3}.${b4}`;
|
||||
}
|
||||
|
||||
function normalizeUploadFileName(name: string, fallback: string): string {
|
||||
const trimmed = name.trim();
|
||||
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
const safeFallback = fallback.trim();
|
||||
return safeFallback || 'file.bin';
|
||||
}
|
||||
|
||||
// QQ's "first 10 MB" checksum uses the magic limit 0x98A000 (10002432) —
|
||||
// NOT 10*1024*1024 and not 10^7. Using the wrong limit makes the server's
|
||||
// first-block verification fail for files larger than 0x98A000, so the
|
||||
// offline file uploads but never finalises as downloadable (#157).
|
||||
const MD5_HEAD_LIMIT = 10002432;
|
||||
|
||||
/** Fail cleanly if the staged file changed since the streaming hash pass (a
|
||||
* local source mutated mid-send). The server's per-chunk md5 during the PUT is
|
||||
* the backstop; this is the cheap early check. */
|
||||
async function assertUnchanged(
|
||||
filePath: string,
|
||||
baseline: { size: number; mtimeMs: number },
|
||||
): Promise<void> {
|
||||
const now = await fsp.stat(filePath);
|
||||
|
||||
if (now.size !== baseline.size || now.mtimeMs !== baseline.mtimeMs) {
|
||||
throw new Error('file source changed during send (mutated between hashing and upload)');
|
||||
}
|
||||
}
|
||||
|
||||
function buildGroupFileUploadExt(
|
||||
senderUin: number,
|
||||
groupId: number,
|
||||
busId: number,
|
||||
fileName: string,
|
||||
fileSize: number,
|
||||
md5: Uint8Array,
|
||||
fileId: string,
|
||||
uploadKey: Uint8Array,
|
||||
checkKey: Uint8Array,
|
||||
uploadHost: string,
|
||||
uploadPort: number,
|
||||
): Uint8Array {
|
||||
return protobuf_encode<FileUploadExt>({
|
||||
unknown1: 100,
|
||||
unknown2: 1,
|
||||
entry: {
|
||||
busiBuff: {
|
||||
busId,
|
||||
senderUin: BigInt(senderUin),
|
||||
receiverUin: BigInt(groupId),
|
||||
groupCode: BigInt(groupId),
|
||||
},
|
||||
fileEntry: {
|
||||
fileSize: BigInt(Math.max(0, fileSize)),
|
||||
md5,
|
||||
md5S2: md5,
|
||||
checkKey,
|
||||
fileId,
|
||||
uploadKey,
|
||||
},
|
||||
clientInfo: {
|
||||
clientType: 3,
|
||||
appId: '100',
|
||||
terminalType: 3,
|
||||
clientVer: '1.1.1',
|
||||
unknown: 4,
|
||||
},
|
||||
fileNameInfo: {
|
||||
fileName,
|
||||
},
|
||||
host: {
|
||||
hosts: [
|
||||
{
|
||||
url: {
|
||||
host: uploadHost,
|
||||
unknown: 1,
|
||||
},
|
||||
port: uploadPort,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
unknown200: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function buildPrivateFileUploadExt(
|
||||
senderUin: number,
|
||||
fileName: string,
|
||||
fileSize: number,
|
||||
md5: Uint8Array,
|
||||
sha1: Uint8Array,
|
||||
fileId: string,
|
||||
uploadKey: Uint8Array,
|
||||
uploadHost: string,
|
||||
uploadPort: number,
|
||||
): Uint8Array {
|
||||
return protobuf_encode<FileUploadExt>({
|
||||
unknown1: 100,
|
||||
unknown2: 1,
|
||||
entry: {
|
||||
busiBuff: {
|
||||
// #157: NapCat/Lagrange (and the QQ client, confirmed by packet
|
||||
// capture) emit a busiBuff with ONLY senderUin — no busId. A stale
|
||||
// busId:102 here made the offline-file server accept the highway
|
||||
// upload but never finalise it as downloadable once the file got
|
||||
// big (>5 MiB): the receiver saw the file but every download failed.
|
||||
// (The other half of that bug was the wrong md5-head limit above.)
|
||||
senderUin: BigInt(senderUin),
|
||||
receiverUin: 0n,
|
||||
groupCode: 0n,
|
||||
},
|
||||
fileEntry: {
|
||||
fileSize: BigInt(Math.max(0, fileSize)),
|
||||
md5,
|
||||
md5S2: md5,
|
||||
checkKey: sha1,
|
||||
fileId,
|
||||
uploadKey,
|
||||
},
|
||||
clientInfo: {
|
||||
clientType: 3,
|
||||
appId: '100',
|
||||
terminalType: 3,
|
||||
clientVer: '1.1.1',
|
||||
unknown: 4,
|
||||
},
|
||||
fileNameInfo: {
|
||||
fileName,
|
||||
},
|
||||
host: {
|
||||
hosts: [
|
||||
{
|
||||
url: {
|
||||
host: uploadHost,
|
||||
unknown: 1,
|
||||
},
|
||||
port: uploadPort,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
unknown3: 0,
|
||||
unknown200: 1,
|
||||
});
|
||||
}
|
||||
|
||||
export class GroupFileApi {
|
||||
constructor(private readonly ctx: BridgeContext) {}
|
||||
|
||||
// ─────────────── publish (group file → chat) ───────────────
|
||||
|
||||
publish(groupId: number, fileId: string, busId?: number): Promise<void> {
|
||||
if (!fileId) {
|
||||
throw new Error('publish requires fileId');
|
||||
}
|
||||
|
||||
return PublishGroupFile.invoke(this.ctx, {
|
||||
groupId,
|
||||
fileId,
|
||||
busId: busId ?? this.cachedBusId(groupId, fileId),
|
||||
});
|
||||
}
|
||||
|
||||
private cachedBusId(groupId: number, fileId: string): number {
|
||||
const cached = this.ctx.recallUploadedFile(fileId);
|
||||
return cached?.scope === 'group' && cached.groupId === groupId
|
||||
? (cached.busId ?? 102)
|
||||
: 102;
|
||||
}
|
||||
|
||||
trans(groupId: number, fileId: string): Promise<TransGroupFileResult> {
|
||||
if (!fileId) {
|
||||
throw new Error('trans requires fileId');
|
||||
}
|
||||
|
||||
return TransGroupFile.invoke(this.ctx, { groupId, fileId });
|
||||
}
|
||||
|
||||
// ─────────────── count ───────────────
|
||||
|
||||
getCount(groupId: number): Promise<{ fileCount: number; maxCount: number }> {
|
||||
return GetGroupFileCount.invoke(this.ctx, { groupId });
|
||||
}
|
||||
|
||||
getSpace(groupId: number): Promise<{ usedSpace: number; totalSpace: number }> {
|
||||
return GetGroupFileSpace.invoke(this.ctx, { groupId });
|
||||
}
|
||||
|
||||
// ─────────────── upload (3-stage: OIDB preflight → highway → publish) ───────────────
|
||||
|
||||
async upload(
|
||||
groupId: number,
|
||||
source: string,
|
||||
name = '',
|
||||
folderId = '/',
|
||||
uploadFile = true,
|
||||
publishFile = uploadFile,
|
||||
options: GroupFileUploadOptions = {},
|
||||
): Promise<GroupFileUploadResult> {
|
||||
const bridge = this.ctx;
|
||||
// Group/private files may legitimately be up to 4 GiB on QQ's wire, so
|
||||
// override the default 1 GiB cap with the protocol ceiling. Stage onto a
|
||||
// local disk path and stream-hash it — the (up to 4 GiB) file is never
|
||||
// buffered in RAM.
|
||||
const staged = await stageSourceToDisk(source, FILE_UPLOAD_MAX_BYTES);
|
||||
|
||||
try {
|
||||
if (staged.fileSize === 0) {
|
||||
throw new Error('group file is empty');
|
||||
}
|
||||
|
||||
const fileName = normalizeUploadFileName(name, staged.fileName);
|
||||
const guardStat = await fsp.stat(staged.filePath); // mutation-guard baseline
|
||||
const hashes = await hashFileStreaming(staged.filePath);
|
||||
|
||||
const params: UploadGroupFileRequest.Params = {
|
||||
groupId,
|
||||
fileName,
|
||||
folderId: normalizeDirectory(folderId),
|
||||
fileSize: staged.fileSize,
|
||||
fileSha1: hashes.sha1,
|
||||
fileMd5: hashes.md5,
|
||||
busId: 102,
|
||||
};
|
||||
let upload = await UploadGroupFileRequest.invoke(this.ctx, params);
|
||||
|
||||
// These are the storage-limit responses for which QQ switches to
|
||||
// temporary storage. Never retry transport, permission or arbitrary
|
||||
// service errors, and never retry the temporary request itself.
|
||||
if (upload.retCode === -403 || upload.retCode === -9801 || upload.retCode === -98090) {
|
||||
if (options.allowTemporary === false) {
|
||||
throw new Error(
|
||||
`temporary group files require only file or image segments; remove text and other content before retrying (group file upload code=${upload.retCode})`,
|
||||
);
|
||||
}
|
||||
|
||||
log.warn(
|
||||
'Group %d permanent file storage rejected upload (code=%d); retrying as a temporary file',
|
||||
groupId,
|
||||
upload.retCode,
|
||||
);
|
||||
params.busId = 104;
|
||||
upload = await UploadGroupFileRequest.invoke(this.ctx, params);
|
||||
}
|
||||
|
||||
ensureRetCodeZero(
|
||||
'group file upload',
|
||||
upload.retCode,
|
||||
upload.retMsg,
|
||||
upload.clientWording,
|
||||
);
|
||||
|
||||
const busId = upload.busId || params.busId;
|
||||
|
||||
if (busId !== 102 && busId !== 104) {
|
||||
throw new Error(`group file upload returned unsupported storage type: ${busId}`);
|
||||
}
|
||||
|
||||
if (busId === 104 && options.allowTemporary === false) {
|
||||
throw new Error(
|
||||
'temporary group files require only file or image segments; remove text and other content before retrying',
|
||||
);
|
||||
}
|
||||
|
||||
const fileId =
|
||||
typeof upload.fileId === 'string' && upload.fileId ? upload.fileId : null;
|
||||
|
||||
if (!fileId) {
|
||||
throw new Error('group file upload response missing file_id');
|
||||
}
|
||||
|
||||
if (!upload.boolFileExist && uploadFile) {
|
||||
const senderUin = toInt(this.ctx.identity.uin);
|
||||
|
||||
if (senderUin <= 0) {
|
||||
throw new Error('invalid self uin for group file upload');
|
||||
}
|
||||
|
||||
const uploadHost =
|
||||
(typeof upload.uploadIp === 'string' && upload.uploadIp) ||
|
||||
(typeof upload.serverDns === 'string' && upload.serverDns) ||
|
||||
'';
|
||||
const uploadPort = toInt(upload.uploadPort);
|
||||
|
||||
if (!uploadHost || uploadPort <= 0) {
|
||||
throw new Error('group file upload host is invalid');
|
||||
}
|
||||
|
||||
const ext = buildGroupFileUploadExt(
|
||||
senderUin,
|
||||
groupId,
|
||||
busId,
|
||||
fileName,
|
||||
staged.fileSize,
|
||||
hashes.md5,
|
||||
fileId,
|
||||
upload.fileKey instanceof Uint8Array ? upload.fileKey : new Uint8Array(0),
|
||||
upload.checkKey instanceof Uint8Array ? upload.checkKey : new Uint8Array(0),
|
||||
uploadHost,
|
||||
uploadPort,
|
||||
);
|
||||
|
||||
// Fail cleanly if the source changed since hashing, then stream the PUT
|
||||
// from disk. Fetch the session BEFORE opening the handle so
|
||||
// uploadHighwayHttp (the handle's owner) is the next call and always
|
||||
// closes it.
|
||||
await assertUnchanged(staged.filePath, guardStat);
|
||||
const session = await fetchHighwaySession(bridge);
|
||||
const chunkSource = await FileChunkSource.open(staged.filePath, staged.fileSize);
|
||||
await uploadHighwayHttp(bridge, session, 71, chunkSource, hashes.md5, ext);
|
||||
}
|
||||
|
||||
// Retain the storage type for later file_id publication and download.
|
||||
// A failed byte transfer must not leave a usable-looking cache entry.
|
||||
this.ctx.rememberUploadedFile({
|
||||
fileId,
|
||||
scope: 'group',
|
||||
groupId,
|
||||
busId,
|
||||
fileName,
|
||||
fileSize: staged.fileSize,
|
||||
fileMd5: hashes.md5,
|
||||
fileSha1: hashes.sha1,
|
||||
rememberedAt: Date.now(),
|
||||
});
|
||||
|
||||
// Stage 3: file is on the server, now publish it as a chat message.
|
||||
//
|
||||
// Without this, OIDB 0x6D6_0 + highway PUT only stages the bytes —
|
||||
// the chat shows nothing. The publish step goes via a dedicated OIDB
|
||||
// call (0x6D9_4), NOT via `MessageSvc.PbSendMsg` with a transElem(24)
|
||||
// payload — the QQ-NT server rejects that with `result=79`. Mirrors
|
||||
// Lagrange.Core V2's `GroupSendFileService.cs`. Suppressed when a
|
||||
// caller only needs a valid file_id embedded elsewhere, e.g. a
|
||||
// forward-message long-msg payload. Routes through `this.publish` so
|
||||
// tests can mock at the same Api boundary.
|
||||
if (publishFile) {
|
||||
try {
|
||||
await this.publish(groupId, fileId, busId);
|
||||
} catch (err) {
|
||||
// Keep the completed upload identifiable, but a requested chat
|
||||
// publication must not be reported as a successful send.
|
||||
throw new Error(
|
||||
`group file uploaded (fileId=${fileId}) but chat post failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { fileId, busId };
|
||||
} finally {
|
||||
await staged.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async uploadPrivate(
|
||||
userId: number,
|
||||
source: string,
|
||||
name = '',
|
||||
uploadFile = true,
|
||||
publishFile = uploadFile,
|
||||
): Promise<UploadFileResult> {
|
||||
const bridge = this.ctx;
|
||||
// Stage onto a local disk path + stream-hash — the (up to 4 GiB) file is
|
||||
// never buffered in RAM. headLimit = MD5_HEAD_LIMIT yields the offline-file
|
||||
// md510MCheckSum in the same single pass.
|
||||
const staged = await stageSourceToDisk(source, FILE_UPLOAD_MAX_BYTES);
|
||||
|
||||
try {
|
||||
if (staged.fileSize === 0) {
|
||||
throw new Error('private file is empty');
|
||||
}
|
||||
|
||||
const targetUid = await this.ctx.resolveUserUid(userId);
|
||||
const selfUid = await resolveSelfUid(this.ctx);
|
||||
|
||||
const senderUin = toInt(this.ctx.identity.uin);
|
||||
|
||||
if (senderUin <= 0) {
|
||||
throw new Error('invalid self uin for private file upload');
|
||||
}
|
||||
|
||||
const fileName = normalizeUploadFileName(name, staged.fileName);
|
||||
const guardStat = await fsp.stat(staged.filePath); // mutation-guard baseline
|
||||
const hashes = await hashFileStreaming(staged.filePath, { headLimit: MD5_HEAD_LIMIT });
|
||||
|
||||
const upload = await UploadPrivateFileRequest.invoke(this.ctx, {
|
||||
senderUid: selfUid,
|
||||
receiverUid: targetUid,
|
||||
fileName,
|
||||
fileSize: staged.fileSize,
|
||||
fileSha1: hashes.sha1,
|
||||
fileMd5: hashes.md5,
|
||||
md510MCheckSum: hashes.headMd5!,
|
||||
});
|
||||
ensureRetCodeZero('private file upload', upload.retCode, upload.retMsg, undefined);
|
||||
|
||||
const fileId = typeof upload.uuid === 'string' && upload.uuid ? upload.uuid : null;
|
||||
const fileHash =
|
||||
typeof upload.fileAddon === 'string' && upload.fileAddon ? upload.fileAddon : null;
|
||||
|
||||
if (!fileId) {
|
||||
throw new Error('private file upload response missing file_id');
|
||||
}
|
||||
|
||||
// Cache the metadata so a later `send_private_msg` carrying just
|
||||
// `{type:'file', file_id}` can resurrect the full c2c-file packet
|
||||
// (NotOnlineFile { fileSize, fileMd5, fileName, fileHash }). Without
|
||||
// this the recipient sees a 0-byte file because the OneBot send path
|
||||
// has no way to recover those fields from the file_id alone.
|
||||
this.ctx.rememberUploadedFile({
|
||||
fileId,
|
||||
scope: 'private',
|
||||
userId,
|
||||
fileName,
|
||||
fileSize: staged.fileSize,
|
||||
fileMd5: hashes.md5,
|
||||
fileSha1: hashes.sha1,
|
||||
fileHash: fileHash ?? '',
|
||||
rememberedAt: Date.now(),
|
||||
});
|
||||
|
||||
if (!upload.boolFileExist && uploadFile) {
|
||||
// Host selection.
|
||||
//
|
||||
// Current QQ-NT server rollout has stopped populating the legacy
|
||||
// `uploadIp` (field 60) entirely. The host now arrives as the first
|
||||
// entry of `rtpMediaPlatformUploadAddress` (field 210, repeated
|
||||
// IPv4 message) — same place acidify reads it from since their
|
||||
// 2026-04 protobuf refactor. Each IPv4 has paired `inIP`/`inPort`
|
||||
// (LAN, same DC as the OIDB endpoint) and `outIP`/`outPort` (WAN);
|
||||
// acidify uses `inIP`/`inPort` exclusively and so do we, because
|
||||
// that's the address the highway PUT actually needs to reach.
|
||||
//
|
||||
// The 32-bit IPs are little-endian-packed (byte0 = first octet)
|
||||
// per acidify's `Int.toIpString()`. Cross-checked the byte order
|
||||
// by inspecting their highway flow — there's no separate htonl
|
||||
// step, so the integer is already in network-octet-first order.
|
||||
//
|
||||
// Older server versions still populate the legacy string fields
|
||||
// (uploadIp / uploadDomain / uploadIpList[0] / uploadHttpsDomain /
|
||||
// uploadDns), so we fall through to those after rtpMediaPlatform.
|
||||
// Pair an HTTPS-flavored host with `uploadHttpsPort` if that's
|
||||
// what we picked.
|
||||
const rtpFirst =
|
||||
Array.isArray(upload.rtpMediaPlatformUploadAddress) &&
|
||||
upload.rtpMediaPlatformUploadAddress[0]
|
||||
? upload.rtpMediaPlatformUploadAddress[0]
|
||||
: null;
|
||||
const rtpInIP =
|
||||
rtpFirst && typeof rtpFirst.inIP === 'number' && rtpFirst.inIP !== 0
|
||||
? int32ToIpv4Dotted(rtpFirst.inIP)
|
||||
: '';
|
||||
const rtpInPort =
|
||||
rtpFirst && typeof rtpFirst.inPort === 'number' ? rtpFirst.inPort : 0;
|
||||
const ipListFirst =
|
||||
Array.isArray(upload.uploadIpList) && upload.uploadIpList[0]
|
||||
? upload.uploadIpList[0]
|
||||
: '';
|
||||
const uploadHost =
|
||||
rtpInIP ||
|
||||
(typeof upload.uploadIp === 'string' && upload.uploadIp) ||
|
||||
(typeof upload.uploadDomain === 'string' && upload.uploadDomain) ||
|
||||
ipListFirst ||
|
||||
(typeof upload.uploadHttpsDomain === 'string' && upload.uploadHttpsDomain) ||
|
||||
(typeof upload.uploadDns === 'string' && upload.uploadDns) ||
|
||||
'';
|
||||
const httpsHostUsed =
|
||||
!rtpInIP &&
|
||||
!upload.uploadIp &&
|
||||
!upload.uploadDomain &&
|
||||
!ipListFirst &&
|
||||
typeof upload.uploadHttpsDomain === 'string' &&
|
||||
!!upload.uploadHttpsDomain;
|
||||
const uploadPort =
|
||||
rtpInIP && rtpInPort > 0
|
||||
? rtpInPort
|
||||
: httpsHostUsed && toInt(upload.uploadHttpsPort) > 0
|
||||
? toInt(upload.uploadHttpsPort)
|
||||
: toInt(upload.uploadPort);
|
||||
|
||||
if (!uploadHost || uploadPort <= 0) {
|
||||
const rtpDump = Array.isArray(upload.rtpMediaPlatformUploadAddress)
|
||||
? JSON.stringify(
|
||||
upload.rtpMediaPlatformUploadAddress.map((e) => ({
|
||||
outIP: e.outIP,
|
||||
outPort: e.outPort,
|
||||
inIP: e.inIP,
|
||||
inPort: e.inPort,
|
||||
iPType: e.iPType,
|
||||
})),
|
||||
)
|
||||
: '[]';
|
||||
log.warn(
|
||||
'private file upload host missing — rtp=%s ip=%s domain=%s ipList=%s httpsDomain=%s dns=%s lanip=%s port=%s httpsPort=%s',
|
||||
rtpDump,
|
||||
upload.uploadIp ?? '',
|
||||
upload.uploadDomain ?? '',
|
||||
JSON.stringify(upload.uploadIpList ?? []),
|
||||
upload.uploadHttpsDomain ?? '',
|
||||
upload.uploadDns ?? '',
|
||||
upload.uploadLanip ?? '',
|
||||
upload.uploadPort ?? 0,
|
||||
upload.uploadHttpsPort ?? 0,
|
||||
);
|
||||
throw new Error('private file upload host is invalid');
|
||||
}
|
||||
|
||||
const ext = buildPrivateFileUploadExt(
|
||||
senderUin,
|
||||
fileName,
|
||||
staged.fileSize,
|
||||
hashes.md5,
|
||||
hashes.sha1,
|
||||
fileId,
|
||||
upload.mediaPlatformUploadKey instanceof Uint8Array
|
||||
? upload.mediaPlatformUploadKey
|
||||
: upload.uploadKey instanceof Uint8Array
|
||||
? upload.uploadKey
|
||||
: new Uint8Array(0),
|
||||
uploadHost,
|
||||
uploadPort,
|
||||
);
|
||||
|
||||
// Fail cleanly if the source changed since hashing, then stream the PUT
|
||||
// from disk. Session BEFORE opening the handle so uploadHighwayHttp (the
|
||||
// handle's owner) is the next call and always closes it.
|
||||
await assertUnchanged(staged.filePath, guardStat);
|
||||
const session = await fetchHighwaySession(bridge);
|
||||
const chunkSource = await FileChunkSource.open(staged.filePath, staged.fileSize);
|
||||
await uploadHighwayHttp(bridge, session, 95, chunkSource, hashes.md5, ext);
|
||||
}
|
||||
|
||||
// Stage 3: publish the file as a c2c chat message. C2C files use
|
||||
// `RichText.notOnlineFile` (parallel to `elems`), so we go through
|
||||
// the dedicated `sendC2cFile` on MessageApi instead of `sendPrivate`
|
||||
// which only knows about elems[]. NapCat does the same atomic
|
||||
// upload+send dance — without it the file sits on the server and
|
||||
// the recipient sees nothing.
|
||||
if (publishFile) {
|
||||
try {
|
||||
await this.ctx.apis.message.sendC2cFile(userId, targetUid, {
|
||||
fileId,
|
||||
fileName,
|
||||
fileSize: staged.fileSize,
|
||||
fileMd5: hashes.md5,
|
||||
fileHash: fileHash ?? '',
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
'private file uploaded (fileId=%s) but chat post failed: %s',
|
||||
fileId,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { fileId, fileHash };
|
||||
} finally {
|
||||
await staged.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────── list (paginated loop) ───────────────
|
||||
|
||||
async list(groupId: number, folderId = '/'): Promise<GroupFilesResult> {
|
||||
const targetDirectory = normalizeDirectory(folderId);
|
||||
const files: GroupFileInfo[] = [];
|
||||
const folders: GroupFolderInfo[] = [];
|
||||
|
||||
const pageSize = 20;
|
||||
let startIndex = 0;
|
||||
|
||||
for (let page = 0; page < 200; page++) {
|
||||
const list = await ListGroupFilesPage.invoke(this.ctx, {
|
||||
groupId,
|
||||
targetDirectory,
|
||||
startIndex,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
if (!list) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (const item of list.items) {
|
||||
const type = toInt(item?.type);
|
||||
|
||||
if (type === 1 && item?.fileInfo) {
|
||||
const file = item.fileInfo;
|
||||
const uploader = toInt(file.uploaderUin);
|
||||
const cached = this.ctx.identity.findGroupMember(groupId, uploader);
|
||||
files.push({
|
||||
fileId: typeof file.fileId === 'string' ? file.fileId : '',
|
||||
fileName: typeof file.fileName === 'string' ? file.fileName : '',
|
||||
busId: toInt(file.busId),
|
||||
fileSize: toInt(file.fileSize),
|
||||
uploadTime: toInt(file.uploadedTime),
|
||||
deadTime: toInt(file.expireTime),
|
||||
modifyTime: toInt(file.modifiedTime),
|
||||
downloadTimes: toInt(file.downloadedTimes),
|
||||
uploader,
|
||||
uploaderName:
|
||||
(typeof file.uploaderName === 'string' && file.uploaderName) ||
|
||||
cached?.card ||
|
||||
cached?.nickname ||
|
||||
'',
|
||||
});
|
||||
} else if (type === 2 && item?.folderInfo) {
|
||||
const folder = item.folderInfo;
|
||||
const creator = toInt(folder.creatorUin);
|
||||
const cached = this.ctx.identity.findGroupMember(groupId, creator);
|
||||
const lastUploader = toInt(folder.modifierUin);
|
||||
const lastUploaderCached = this.ctx.identity.findGroupMember(
|
||||
groupId,
|
||||
lastUploader,
|
||||
);
|
||||
folders.push({
|
||||
folderId: typeof folder.folderId === 'string' ? folder.folderId : '',
|
||||
folderName: typeof folder.folderName === 'string' ? folder.folderName : '',
|
||||
createTime: toInt(folder.createTime),
|
||||
creator,
|
||||
creatorName:
|
||||
(typeof folder.creatorName === 'string' && folder.creatorName) ||
|
||||
cached?.card ||
|
||||
cached?.nickname ||
|
||||
'',
|
||||
totalFileCount: toInt(folder.totalFileCount),
|
||||
lastUploadTime: toInt(folder.modifiedTime),
|
||||
lastUploader,
|
||||
lastUploaderName:
|
||||
(typeof folder.modifierName === 'string' && folder.modifierName) ||
|
||||
lastUploaderCached?.card ||
|
||||
lastUploaderCached?.nickname ||
|
||||
'',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (list.isEnd) {
|
||||
break;
|
||||
}
|
||||
|
||||
startIndex += pageSize;
|
||||
}
|
||||
|
||||
return { files, folders };
|
||||
}
|
||||
|
||||
// ─────────────── url fetch (group / private files) ───────────────
|
||||
|
||||
async getUrl(groupId: number, fileId: string, busId?: number): Promise<string> {
|
||||
return GetGroupFileUrl.invoke(this.ctx, {
|
||||
groupId,
|
||||
fileId,
|
||||
busId: busId ?? this.cachedBusId(groupId, fileId),
|
||||
});
|
||||
}
|
||||
|
||||
async getPrivateUrl(userId: number, fileId: string, fileHash: string): Promise<string> {
|
||||
const selfUid = await resolveSelfUid(this.ctx);
|
||||
void userId;
|
||||
return GetPrivateFileUrl.invoke(this.ctx, { selfUid, fileId, fileHash });
|
||||
}
|
||||
|
||||
// ─────────────── delete / move ───────────────
|
||||
|
||||
delete(groupId: number, fileId: string): Promise<void> {
|
||||
return DeleteGroupFile.invoke(this.ctx, { groupId, fileId });
|
||||
}
|
||||
|
||||
move(
|
||||
groupId: number,
|
||||
fileId: string,
|
||||
parentDirectory: string,
|
||||
targetDirectory: string,
|
||||
): Promise<void> {
|
||||
return MoveGroupFile.invoke(this.ctx, {
|
||||
groupId,
|
||||
fileId,
|
||||
parentDirectory,
|
||||
targetDirectory,
|
||||
});
|
||||
}
|
||||
|
||||
rename(
|
||||
groupId: number,
|
||||
fileId: string,
|
||||
parentDirectory: string,
|
||||
newFileName: string,
|
||||
): Promise<void> {
|
||||
return RenameGroupFile.invoke(this.ctx, { groupId, fileId, parentDirectory, newFileName });
|
||||
}
|
||||
|
||||
// ─────────────── folders ───────────────
|
||||
|
||||
createFolder(groupId: number, name: string, parentId = '/'): Promise<CreateGroupFolder.Result> {
|
||||
return CreateGroupFolder.invoke(this.ctx, {
|
||||
groupId,
|
||||
parentId: normalizeDirectory(parentId),
|
||||
folderName: name,
|
||||
});
|
||||
}
|
||||
|
||||
deleteFolder(groupId: number, folderId: string): Promise<void> {
|
||||
return DeleteGroupFolder.invoke(this.ctx, { groupId, folderId });
|
||||
}
|
||||
|
||||
renameFolder(groupId: number, folderId: string, newFolderName: string): Promise<void> {
|
||||
return RenameGroupFolder.invoke(this.ctx, { groupId, folderId, newFolderName });
|
||||
}
|
||||
|
||||
// ─────────────── rich-media URL by node ───────────────
|
||||
|
||||
getPttUrl(groupId: number, node: MediaIndexNode): Promise<string> {
|
||||
return GetGroupPttUrl.invoke(this.ctx, { groupId, node });
|
||||
}
|
||||
|
||||
async getPrivatePttUrl(node: MediaIndexNode): Promise<string> {
|
||||
const selfUid = await resolveSelfUid(this.ctx);
|
||||
return GetPrivatePttUrl.invoke(this.ctx, { selfUid, node });
|
||||
}
|
||||
|
||||
getVideoUrl(groupId: number, node: MediaIndexNode): Promise<string> {
|
||||
return GetGroupVideoUrl.invoke(this.ctx, { groupId, node });
|
||||
}
|
||||
|
||||
async getPrivateVideoUrl(node: MediaIndexNode): Promise<string> {
|
||||
const selfUid = await resolveSelfUid(this.ctx);
|
||||
return GetPrivateVideoUrl.invoke(this.ctx, { selfUid, node });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
export interface HistoryRequestGateClock {
|
||||
now(): number;
|
||||
sleep(ms: number, signal?: AbortSignal): Promise<void>;
|
||||
}
|
||||
|
||||
const SYSTEM_CLOCK: HistoryRequestGateClock = {
|
||||
now: () => Date.now(),
|
||||
sleep: (ms, signal) => abortableSleep(ms, signal),
|
||||
};
|
||||
|
||||
/**
|
||||
* Process-wide history request gate.
|
||||
*
|
||||
* The gate owns the network operation, not just a preceding delay. This keeps
|
||||
* concurrent history callers serialized and preserves the stricter spacing on
|
||||
* both sides of an automatic history-sync request.
|
||||
*/
|
||||
export class HistoryRequestGate {
|
||||
private tail: Promise<void> = Promise.resolve();
|
||||
private lastStartedAt: number | null = null;
|
||||
private nextAllowedAt = 0;
|
||||
|
||||
constructor(private readonly clock: HistoryRequestGateClock = SYSTEM_CLOCK) {}
|
||||
|
||||
run<T>(minimumGapMs: number, operation: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
if (!Number.isSafeInteger(minimumGapMs) || minimumGapMs < 0) {
|
||||
return Promise.reject(
|
||||
new Error(`history request gap is invalid: ${String(minimumGapMs)}`),
|
||||
);
|
||||
}
|
||||
|
||||
let operationStarted = false;
|
||||
const result = this.tail.then(async () => {
|
||||
throwIfAborted(signal);
|
||||
const now = this.clock.now();
|
||||
const requestedBoundary =
|
||||
this.lastStartedAt === null ? now : this.lastStartedAt + minimumGapMs;
|
||||
const wait = Math.max(this.nextAllowedAt, requestedBoundary) - now;
|
||||
|
||||
if (wait > 0) {
|
||||
await this.clock.sleep(wait, signal);
|
||||
}
|
||||
|
||||
throwIfAborted(signal);
|
||||
const startedAt = this.clock.now();
|
||||
this.lastStartedAt = startedAt;
|
||||
this.nextAllowedAt = startedAt + minimumGapMs;
|
||||
operationStarted = true;
|
||||
return operation();
|
||||
});
|
||||
|
||||
this.tail = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return signal ? rejectWhileQueuedOnAbort(result, signal, () => operationStarted) : result;
|
||||
}
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) {
|
||||
throw signal.reason ?? new Error('history request aborted');
|
||||
}
|
||||
}
|
||||
|
||||
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
throwIfAborted(signal);
|
||||
|
||||
if (!signal) {
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
reject(signal.reason ?? new Error('history request aborted'));
|
||||
};
|
||||
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function rejectWhileQueuedOnAbort<T>(
|
||||
result: Promise<T>,
|
||||
signal: AbortSignal,
|
||||
hasStarted: () => boolean,
|
||||
): Promise<T> {
|
||||
if (signal.aborted && !hasStarted()) {
|
||||
return Promise.reject(signal.reason ?? new Error('history request aborted'));
|
||||
}
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const onAbort = () => {
|
||||
if (!hasStarted()) {
|
||||
reject(signal.reason ?? new Error('history request aborted'));
|
||||
}
|
||||
};
|
||||
|
||||
signal.addEventListener('abort', onAbort);
|
||||
result.then(resolve, reject).finally(() => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { BridgeContext } from '../bridge-context';
|
||||
import { CollectionApi } from './collection';
|
||||
import { ContactsApi } from './contacts';
|
||||
import { ExtrasApi } from './extras';
|
||||
import { FlashTransferApi } from './flash-transfer';
|
||||
import { ForwardApi } from './forward';
|
||||
import { FriendApi } from './friend';
|
||||
import { GroupAdminApi } from './group-admin';
|
||||
import { GroupAlbumApi } from './group-album';
|
||||
import { GroupFileApi } from './group-file';
|
||||
import { InteractionApi } from './interaction';
|
||||
import { MessageApi } from './message';
|
||||
import { MiscApi } from './misc';
|
||||
import { ProfileApi } from './profile';
|
||||
import { QzoneApi } from './qzone';
|
||||
import { WebApi } from './web';
|
||||
import { SystemFaceApi } from './system-face';
|
||||
|
||||
export interface ApiHub {
|
||||
/** Personal QQ collections: authenticated list retrieval and pagination. */
|
||||
readonly collection: CollectionApi;
|
||||
/** Send/recall/markRead operations across c2c + group + c2c-file. */
|
||||
readonly message: MessageApi;
|
||||
/** Friend / group / member roster + user-profile + group-request-list + download-rkey. */
|
||||
readonly contacts: ContactsApi;
|
||||
/** Group moderation: mute/kick/admin/card/name/title/leave + join-policy. */
|
||||
readonly groupAdmin: GroupAdminApi;
|
||||
/** Group file CRUD + private (c2c) file upload + media URL resolvers. */
|
||||
readonly groupFile: GroupFileApi;
|
||||
/** Group photo album: list/upload/comment/like/delete + media listing. */
|
||||
readonly groupAlbum: GroupAlbumApi;
|
||||
/** Personal QQ-Zone (个人空间): 说说 list + (future) publish/like/comment. */
|
||||
readonly qzone: QzoneApi;
|
||||
/** Interactive engagement: poke / like / reaction / essence / emoji-like-list. */
|
||||
readonly interaction: InteractionApi;
|
||||
/** Friend roster mutations: handleRequest / delete / setRemark. */
|
||||
readonly friend: FriendApi;
|
||||
/** Personal profile: status / avatar / nickname / likes / custom-faces. */
|
||||
readonly profile: ProfileApi;
|
||||
/** QQ 闪传(fileset 文件集): 上传/查询/分享/发送/删除/重命名。 */
|
||||
readonly flashTransfer: FlashTransferApi;
|
||||
/** Long-message (forward / 聊天记录) upload + retrieval with NapCat piggyback. */
|
||||
readonly forward: ForwardApi;
|
||||
/** Odds & ends: translate / mini-app ARK / inline-keyboard / group sign. */
|
||||
readonly misc: MiscApi;
|
||||
/** Tier-2 napcat-parity: group todo / stranger status / AI voice. */
|
||||
readonly extras: ExtrasApi;
|
||||
/** Cookie-backed HTTP: essence / honor / notice / client-key / csrf-token. */
|
||||
readonly web: WebApi;
|
||||
/** QQ system-face directory: durable catalog, id lookup, and search. */
|
||||
readonly systemFace: SystemFaceApi;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
collection: new CollectionApi(ctx),
|
||||
message: new MessageApi(ctx),
|
||||
contacts: new ContactsApi(ctx),
|
||||
groupAdmin: new GroupAdminApi(ctx),
|
||||
groupFile: new GroupFileApi(ctx),
|
||||
groupAlbum: new GroupAlbumApi(ctx),
|
||||
qzone: new QzoneApi(ctx),
|
||||
interaction: new InteractionApi(ctx),
|
||||
friend: new FriendApi(ctx),
|
||||
profile: new ProfileApi(ctx),
|
||||
flashTransfer: new FlashTransferApi(ctx),
|
||||
forward: new ForwardApi(ctx),
|
||||
misc: new MiscApi(ctx),
|
||||
extras: new ExtrasApi(ctx),
|
||||
web: new WebApi(ctx),
|
||||
systemFace: new SystemFaceApi(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 { ContactsApi } from './contacts';
|
||||
export { CollectionApi } from './collection';
|
||||
export { ExtrasApi } from './extras';
|
||||
export { ForwardApi } from './forward';
|
||||
export { FriendApi } from './friend';
|
||||
export { GroupAdminApi } from './group-admin';
|
||||
export { GroupAlbumApi } from './group-album';
|
||||
export { GroupFileApi } from './group-file';
|
||||
export { InteractionApi } from './interaction';
|
||||
export {
|
||||
MessageApi,
|
||||
LOGIN_HISTORY_SYNC_PROTOCOL_LIMITS,
|
||||
type GroupHistorySyncState,
|
||||
type HistorySyncPrivateTarget,
|
||||
type HistorySyncState,
|
||||
type PrivateHistorySyncState,
|
||||
} from './message';
|
||||
export { MiscApi } from './misc';
|
||||
export { ProfileApi } from './profile';
|
||||
export { QzoneApi } from './qzone';
|
||||
export { FlashTransferApi, type FlashFileInfo } from './flash-transfer';
|
||||
export { WebApi } from './web';
|
||||
export { SystemFaceApi } from './system-face';
|
||||
@@ -0,0 +1,70 @@
|
||||
// InteractionApi — facade over interactive-engagement OIDB cmds
|
||||
// (poke / like / reaction / essence / emoji-like-list). Every method
|
||||
// is a one-line forwarder to a self-contained namespace under
|
||||
// @snowluma/protocol/oidb-services. The facade exists purely to keep
|
||||
// the `bridge.apis.interaction.X(...)` ergonomic call style; all wire
|
||||
// work (envelope build / encode / send / decode) lives in the
|
||||
// namespace files.
|
||||
|
||||
import { SendLike } from '@snowluma/protocol/oidb-services/interaction/send-like';
|
||||
import { SendPoke } from '@snowluma/protocol/oidb-services/interaction/send-poke';
|
||||
import { SetEssence } from '@snowluma/protocol/oidb-services/interaction/set-essence';
|
||||
import { FetchReactionSummary } from '@snowluma/protocol/oidb-services/reaction/fetch-reaction-summary';
|
||||
import { GetEmojiLikes } from '@snowluma/protocol/oidb-services/reaction/get-emoji-likes';
|
||||
import { SetReaction } from '@snowluma/protocol/oidb-services/reaction/set-reaction';
|
||||
import type { BridgeContext } from '../bridge-context';
|
||||
|
||||
export class InteractionApi {
|
||||
constructor(private readonly ctx: BridgeContext) {}
|
||||
|
||||
sendPoke(isGroup: boolean, peerUin: number, targetUin?: number): Promise<void> {
|
||||
return SendPoke.invoke(this.ctx, { isGroup, peerUin, targetUin });
|
||||
}
|
||||
|
||||
sendLike(userId: number, count: number): Promise<void> {
|
||||
return SendLike.invoke(this.ctx, { userId, count });
|
||||
}
|
||||
|
||||
setReaction(groupId: number, sequence: number, code: string, isSet: boolean): Promise<void> {
|
||||
return SetReaction.invoke(this.ctx, { groupId, sequence, code, isSet });
|
||||
}
|
||||
|
||||
setEssence(groupId: number, sequence: number, random: number, enable: boolean): Promise<void> {
|
||||
return SetEssence.invoke(this.ctx, { groupId, sequence, random, enable });
|
||||
}
|
||||
|
||||
/** Fetch the reactor list for one emoji on a group message. */
|
||||
async getEmojiLikes(
|
||||
groupId: number,
|
||||
sequence: number,
|
||||
emojiId: string,
|
||||
emojiType = 1,
|
||||
count = 10,
|
||||
cookie = '',
|
||||
): Promise<{ users: Array<{ uin: number }>; cookie: string; isLast: boolean }> {
|
||||
try {
|
||||
return await GetEmojiLikes.invoke(this.ctx, {
|
||||
groupId,
|
||||
sequence,
|
||||
emojiId,
|
||||
emojiType,
|
||||
count,
|
||||
cookie,
|
||||
});
|
||||
} catch {
|
||||
return { users: [], cookie: '', isLast: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent-used emoji catalog (0x9084_1). Not a per-message reaction list.
|
||||
*/
|
||||
fetchReactionSummary(
|
||||
groupId: number,
|
||||
sequence: number,
|
||||
): Promise<
|
||||
Array<{ emojiId: string; emojiType: number; count: number; lastReactionTime: number }>
|
||||
> {
|
||||
return FetchReactionSummary.invoke(this.ctx, { groupId, sequence });
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,117 @@
|
||||
import type { JsonObject, JsonValue } from '@snowluma/common/json';
|
||||
import type { MiniAppShareReq, MiniAppShareResp } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { BridgeContext } from '../bridge-context';
|
||||
// Migrated OIDB cmds — facade methods are one-line forwarders.
|
||||
import { ClickInlineKeyboardButton } from '@snowluma/protocol/oidb-services/misc/click-inline-keyboard-button';
|
||||
import { SendGroupSign } from '@snowluma/protocol/oidb-services/misc/send-group-sign';
|
||||
import { TranslateEnToZh } from '@snowluma/protocol/oidb-services/misc/translate-en-to-zh';
|
||||
import { RequestDbKey } from '@snowluma/protocol/oidb-services/misc/request-decrypt-key';
|
||||
import { ImageOcr, type OcrResult } from '@snowluma/protocol/oidb-services/misc/image-ocr';
|
||||
|
||||
export class MiscApi {
|
||||
constructor(private readonly ctx: BridgeContext) {}
|
||||
|
||||
translateEn2Zh(words: string[]): Promise<string[]> {
|
||||
return TranslateEnToZh.invoke(this.ctx, { words });
|
||||
}
|
||||
|
||||
ocrImage(imageUrl: string): Promise<OcrResult> {
|
||||
return ImageOcr.invoke(this.ctx, { imageUrl });
|
||||
}
|
||||
|
||||
async getMiniAppArk(
|
||||
type: string,
|
||||
title: string,
|
||||
desc: string,
|
||||
picUrl: string,
|
||||
jumpUrl: string,
|
||||
): Promise<JsonObject> {
|
||||
let appid = '1109937557'; // default: bilibili
|
||||
let iconUrl =
|
||||
'http://miniapp.gtimg.cn/public/appicon/51f90239b78a2e4994c11215f4c4ba15_200.jpg';
|
||||
|
||||
if (type === 'weibo') {
|
||||
appid = '1109224783';
|
||||
iconUrl =
|
||||
'http://miniapp.gtimg.cn/public/appicon/35bbb44dc68e65194cfacfb206b8f1f7_200.jpg';
|
||||
} else if (type !== 'bili') {
|
||||
throw new Error(`unsupported type: ${type}, only support bili and weibo`);
|
||||
}
|
||||
|
||||
const request = protobuf_encode<MiniAppShareReq>({
|
||||
sdkVersion: 'V1_PC_MINISDK_99.99.99_1_APP_A',
|
||||
body: { appid, title, desc, picUrl, jumpUrl, iconUrl },
|
||||
});
|
||||
|
||||
const result = await this.ctx.sendRawPacket(
|
||||
'LightAppSvc.mini_app_share.AdaptShareInfo',
|
||||
request,
|
||||
);
|
||||
|
||||
if (!result.success || !result.responseData) {
|
||||
throw new Error(result.errorMessage || 'get mini app ark failed');
|
||||
}
|
||||
|
||||
const decoded = protobuf_decode<MiniAppShareResp>(result.responseData);
|
||||
const jsonStr = decoded?.body?.jsonStr;
|
||||
|
||||
if (!jsonStr) {
|
||||
throw new Error('mini app share json empty');
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(jsonStr) as Record<
|
||||
string,
|
||||
JsonObject | string | number | boolean | null
|
||||
>;
|
||||
|
||||
return {
|
||||
data: {
|
||||
ver: parsed.ver,
|
||||
prompt: parsed.prompt,
|
||||
config: parsed.config,
|
||||
app: parsed.appName,
|
||||
view: parsed.appView,
|
||||
meta: parsed.metaData,
|
||||
miniappShareOrigin: 3,
|
||||
miniappOpenRefer: '10002',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
clickInlineKeyboardButton(
|
||||
groupId: number,
|
||||
botAppid: number,
|
||||
buttonId: string,
|
||||
callbackData: string,
|
||||
msgSeq: number,
|
||||
): Promise<JsonValue> {
|
||||
return ClickInlineKeyboardButton.invoke(this.ctx, {
|
||||
groupId,
|
||||
botAppid,
|
||||
buttonId,
|
||||
callbackData,
|
||||
msgSeq,
|
||||
});
|
||||
}
|
||||
|
||||
sendGroupSign(groupId: number): Promise<void> {
|
||||
return SendGroupSign.invoke(this.ctx, { groupId });
|
||||
}
|
||||
|
||||
async getDecryptKey(dbSalt: string): Promise<string> {
|
||||
const result = await RequestDbKey.invoke(this.ctx, { db_salt: dbSalt });
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
result.errorMsg || `Failed to get decrypt key (code: ${result.errorCode})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!result.dbKey) {
|
||||
throw new Error('Decrypt key is empty');
|
||||
}
|
||||
|
||||
return result.dbKey;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import type {
|
||||
GroupAvatarExtra,
|
||||
SetStatusReq,
|
||||
SetStatusResp,
|
||||
} from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import { AddCustomFace } from '@snowluma/protocol/oidb-services/custom-face/add-custom-face';
|
||||
import { DeleteCustomFace } from '@snowluma/protocol/oidb-services/custom-face/delete-custom-face';
|
||||
import { FetchCustomFaceDetail } from '@snowluma/protocol/oidb-services/custom-face/fetch-custom-face-detail';
|
||||
import { FetchCustomFaceList } from '@snowluma/protocol/oidb-services/custom-face/fetch-custom-face-list';
|
||||
import { ModifyCustomFace } from '@snowluma/protocol/oidb-services/custom-face/modify-custom-face';
|
||||
import { MoveCustomFace } from '@snowluma/protocol/oidb-services/custom-face/move-custom-face';
|
||||
import { OrderCustomFace } from '@snowluma/protocol/oidb-services/custom-face/order-custom-face';
|
||||
import {
|
||||
BufferChunkSource,
|
||||
fetchHighwaySession,
|
||||
uploadHighwayHttp,
|
||||
} from '@snowluma/protocol/highway';
|
||||
import { computeHashes, loadBinarySource } from '@snowluma/protocol/highway/utils';
|
||||
import { GetLike, type LikeInfo } from '@snowluma/protocol/oidb-services/profile/get-like';
|
||||
import {
|
||||
GetUnidirectionalFriendList,
|
||||
type UnidirectionalFriendEntry,
|
||||
} from '@snowluma/protocol/oidb-services/profile/get-unidirectional-friend-list';
|
||||
import { SetInputStatus } from '@snowluma/protocol/oidb-services/profile/set-input-status';
|
||||
import { SetProfile } from '@snowluma/protocol/oidb-services/profile/set-profile';
|
||||
import { SetSelfLongNick } from '@snowluma/protocol/oidb-services/profile/set-self-long-nick';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { Bridge } from '../bridge';
|
||||
import type { BridgeContext } from '../bridge-context';
|
||||
|
||||
function asBridge(ctx: BridgeContext): Bridge {
|
||||
return ctx as unknown as Bridge;
|
||||
}
|
||||
|
||||
export interface CustomFaceDetail {
|
||||
emojiId: string;
|
||||
url: string;
|
||||
md5: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
export class ProfileApi {
|
||||
constructor(private readonly ctx: BridgeContext) {}
|
||||
|
||||
// ─────────────── status / profile setters ───────────────
|
||||
|
||||
async setOnlineStatus(status: number, extStatus = 0, batteryStatus = 100): Promise<void> {
|
||||
await this.dispatchSetStatus({ status, extStatus, batteryStatus });
|
||||
}
|
||||
|
||||
/**
|
||||
* DIY (custom) online status. napcat fixes status=10 / extStatus=2000
|
||||
* — the values QQ associates with "I have a custom status string" —
|
||||
* and threads the faceId / wording / faceType through the customExt
|
||||
* sub-message of the same SetStatus wire call.
|
||||
*/
|
||||
async setDiyOnlineStatus(faceId: number, wording: string, faceType: number): Promise<void> {
|
||||
await this.dispatchSetStatus({
|
||||
status: 10,
|
||||
extStatus: 2000,
|
||||
batteryStatus: 0,
|
||||
customExt: { faceId, text: wording, faceType },
|
||||
});
|
||||
}
|
||||
|
||||
private async dispatchSetStatus(value: {
|
||||
status: number;
|
||||
extStatus: number;
|
||||
batteryStatus: number;
|
||||
customExt?: { faceId: number; text: string; faceType: number };
|
||||
}): Promise<void> {
|
||||
const request = protobuf_encode<SetStatusReq>(value);
|
||||
const result = await this.ctx.sendRawPacket(
|
||||
'trpc.qq_new_tech.status_svc.StatusService.SetStatus',
|
||||
request,
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.errorMessage || 'set online status failed (network/timeout)');
|
||||
}
|
||||
|
||||
if (result.responseData && result.responseData.length > 0) {
|
||||
const resp = protobuf_decode<SetStatusResp>(result.responseData);
|
||||
|
||||
if (!resp) {
|
||||
throw new Error(
|
||||
result.errorMessage || 'set online status failed (network/timeout)',
|
||||
);
|
||||
}
|
||||
|
||||
const message = resp.errMsg ?? '';
|
||||
|
||||
if (message !== '' && message !== 'set status success') {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setProfile(nickname?: string, personalNote?: string, sex?: number): Promise<void> {
|
||||
return SetProfile.invoke(this.ctx, { nickname, personalNote, sex });
|
||||
}
|
||||
|
||||
setSelfLongNick(longNick: string): Promise<void> {
|
||||
return SetSelfLongNick.invoke(this.ctx, { longNick });
|
||||
}
|
||||
|
||||
setInputStatus(userId: number, eventType: number): Promise<void> {
|
||||
return SetInputStatus.invoke(this.ctx, { userId, eventType });
|
||||
}
|
||||
|
||||
async setAvatar(source: string): Promise<void> {
|
||||
const bridge = asBridge(this.ctx);
|
||||
const loaded = await loadBinarySource(source, 'avatar');
|
||||
|
||||
if (!loaded.bytes.length) {
|
||||
throw new Error('avatar file is empty');
|
||||
}
|
||||
|
||||
const hashes = computeHashes(loaded.bytes);
|
||||
const session = await fetchHighwaySession(bridge);
|
||||
await uploadHighwayHttp(
|
||||
bridge,
|
||||
session,
|
||||
90,
|
||||
new BufferChunkSource(loaded.bytes),
|
||||
hashes.md5,
|
||||
new Uint8Array(0),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set group avatar. Mirrors Lagrange.Core's GroupSetAvatar:
|
||||
* - same highway HTTP upload as personal avatar
|
||||
* - cmdId 3000 (instead of 90)
|
||||
* - GroupAvatarExtra proto carried as the `extend` blob, with the
|
||||
* four protocol-prescribed constants (type=101, field5=3, field6=1,
|
||||
* field3.field1=1) and the target groupUin.
|
||||
*
|
||||
* Source ref: Lagrange.Core/Internal/Context/Logic/Implementation/OperationLogic.cs#GroupSetAvatar.
|
||||
*/
|
||||
async setGroupAvatar(groupId: number, source: string): Promise<void> {
|
||||
const bridge = asBridge(this.ctx);
|
||||
const loaded = await loadBinarySource(source, 'group-avatar');
|
||||
|
||||
if (!loaded.bytes.length) {
|
||||
throw new Error('group avatar file is empty');
|
||||
}
|
||||
|
||||
const hashes = computeHashes(loaded.bytes);
|
||||
const session = await fetchHighwaySession(bridge);
|
||||
const extra = protobuf_encode<GroupAvatarExtra>({
|
||||
type: 101,
|
||||
groupUin: groupId,
|
||||
field3: { field1: 1 },
|
||||
field5: 3,
|
||||
field6: 1,
|
||||
});
|
||||
await uploadHighwayHttp(
|
||||
bridge,
|
||||
session,
|
||||
3000,
|
||||
new BufferChunkSource(loaded.bytes),
|
||||
hashes.md5,
|
||||
extra,
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────── queries on me / my contacts ───────────────
|
||||
|
||||
getLike(userId?: number, start = 0, limit = 10): Promise<LikeInfo> {
|
||||
return GetLike.invoke(this.ctx, { userId, start, limit });
|
||||
}
|
||||
|
||||
getUnidirectionalFriendList(): Promise<UnidirectionalFriendEntry[]> {
|
||||
return GetUnidirectionalFriendList.invoke(this.ctx);
|
||||
}
|
||||
|
||||
async fetchCustomFace(count = 10): Promise<string[]> {
|
||||
const faceIds = await this.fetchCustomFaceIds(count);
|
||||
return faceIds.map((id) => this.customFaceUrl(id));
|
||||
}
|
||||
|
||||
async fetchCustomFaceDetails(count = 10): Promise<CustomFaceDetail[]> {
|
||||
const faceIds = await this.fetchCustomFaceIds(count);
|
||||
|
||||
if (faceIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const emojis = faceIds.map((emojiId) => ({
|
||||
emojiId,
|
||||
md5: md5FromEmojiId(emojiId),
|
||||
}));
|
||||
const details = await FetchCustomFaceDetail.invoke(this.ctx, { emojis });
|
||||
const descriptions = new Map(details.map((detail) => [detail.emojiId, detail.desc]));
|
||||
|
||||
return emojis.map(({ emojiId, md5 }) => ({
|
||||
emojiId,
|
||||
url: this.customFaceUrl(emojiId),
|
||||
md5,
|
||||
// The detail response may omit entries which have never had a remark.
|
||||
desc: descriptions.get(emojiId) ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
async fetchCustomFaceIds(count = 10): Promise<string[]> {
|
||||
if (!Number.isInteger(count) || count < 0) {
|
||||
throw new Error('custom face count must be a non-negative integer');
|
||||
}
|
||||
|
||||
if (count === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const faceIds = await FetchCustomFaceList.invoke(this.ctx, { uin: this.ctx.identity.uin });
|
||||
return faceIds.slice(0, count);
|
||||
}
|
||||
|
||||
private customFaceUrl(emojiId: string): string {
|
||||
return `https://p.qpic.cn/qq_expression/${this.ctx.identity.uin}/${emojiId}/0`;
|
||||
}
|
||||
|
||||
/** Resolve an existing saved sticker by its resource id or content MD5. */
|
||||
async resolveCustomFace(
|
||||
identifier: string,
|
||||
): Promise<Pick<CustomFaceDetail, 'emojiId' | 'url' | 'md5'>> {
|
||||
const wanted = identifier.trim();
|
||||
|
||||
if (!wanted) {
|
||||
throw new Error('custom face identifier is empty');
|
||||
}
|
||||
|
||||
const ids = await FetchCustomFaceList.invoke(this.ctx, { uin: this.ctx.identity.uin });
|
||||
const byMd5 = /^[a-fA-F0-9]{32}$/.test(wanted);
|
||||
const emojiId = ids.find((id) =>
|
||||
byMd5 ? md5FromEmojiId(id).toLowerCase() === wanted.toLowerCase() : id === wanted,
|
||||
);
|
||||
|
||||
if (!emojiId) {
|
||||
throw new Error('custom face is not in the saved sticker list');
|
||||
}
|
||||
|
||||
return { emojiId, url: this.customFaceUrl(emojiId), md5: md5FromEmojiId(emojiId) };
|
||||
}
|
||||
|
||||
/** 删除一个收藏表情(custom face)。emoji_id 来自收藏列表。 */
|
||||
deleteCustomFace(emojiId: string): Promise<void> {
|
||||
return DeleteCustomFace.invoke(this.ctx, { uin: this.ctx.identity.uin, emojiId });
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加收藏表情(custom face)。imageSource 支持 file:///、base64://、http(s)://
|
||||
* (复用 highway utils 的 loadBinarySource)。返回新 emoji_id。
|
||||
*/
|
||||
async addCustomFace(imageSource: string): Promise<string> {
|
||||
const { bytes } = await loadBinarySource(imageSource, 'custom-face');
|
||||
return AddCustomFace.invoke(this.ctx, { uin: this.ctx.identity.uin, imageBytes: bytes });
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改收藏表情(custom face)备注。emoji_id 来自收藏列表;md5 从 emoji_id
|
||||
* 中段解析,无需调用方单独提供。desc 为空串则清空备注。
|
||||
*/
|
||||
modifyCustomFace(emojiId: string, desc: string): Promise<void> {
|
||||
return ModifyCustomFace.invoke(this.ctx, {
|
||||
emojiId,
|
||||
md5: md5FromEmojiId(emojiId),
|
||||
desc,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 收藏表情(custom face)移到最前。QQ 客户端只有"移动到最前",协议层
|
||||
* (0x902f 的 f3=1)也只支持最前,不支持移到其他位置。两步流程:先 0x902f
|
||||
* 移动指令,再 0x902e opType=2 上传新顺序(fetch 当前列表把目标挪到第一)。
|
||||
* 两步都发才生效。
|
||||
*/
|
||||
async moveCustomFaceToFront(emojiId: string): Promise<void> {
|
||||
// 1. fetch 当前完整列表(fetch 顺序即可,不需要 DB 显示顺序)
|
||||
const ids = await this.fetchCustomFaceIds(1000);
|
||||
const idx = ids.indexOf(emojiId);
|
||||
|
||||
if (idx < 0) {
|
||||
throw new Error(`emoji_id not in list: ${emojiId}`);
|
||||
}
|
||||
|
||||
// 新顺序:目标挪到第一,其余按原顺序
|
||||
const reordered = ids.slice();
|
||||
reordered.splice(idx, 1);
|
||||
reordered.unshift(emojiId);
|
||||
// 2. 0x902f 移动指令(f3=1 = 移到最前)
|
||||
await OrderCustomFace.invoke(this.ctx, { emojiId, position: 1 });
|
||||
// 3. 0x902e opType=2 上传新顺序
|
||||
await MoveCustomFace.invoke(this.ctx, {
|
||||
emojis: reordered.map((id) => ({ emojiId: id, md5: md5FromEmojiId(id) })),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 emoji_id `<uin>_0_0_0_<MD5>_0_0` 提取中段 32 位大写 hex MD5。
|
||||
* 格式固定(fetch/delete/move 共用),取第 5 段。解析失败抛错——比静默
|
||||
* 传空串让服务端拒绝更早定位问题。
|
||||
*/
|
||||
function md5FromEmojiId(emojiId: string): string {
|
||||
const parts = emojiId.split('_');
|
||||
// 期望形如 `<uin>_0_0_0_<md5>_0_0`,共 7 段,md5 在 index 4。
|
||||
const md5 = parts[4];
|
||||
|
||||
if (!md5 || md5.length !== 32 || !/^[0-9a-fA-F]{32}$/.test(md5)) {
|
||||
throw new Error(`invalid emoji_id (cannot extract md5): ${emojiId}`);
|
||||
}
|
||||
|
||||
return md5.toUpperCase();
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import type { JsonObject } from '@snowluma/common/json';
|
||||
import {
|
||||
commentQzoneMsg,
|
||||
deleteQzoneMsg,
|
||||
getQzoneFeeds,
|
||||
getQzoneMsgDetail,
|
||||
getQzoneMsgList,
|
||||
getQzoneProfile,
|
||||
publishQzoneMsg,
|
||||
replyQzoneComment,
|
||||
setQzoneBlack,
|
||||
setQzoneLike,
|
||||
setQzoneProfile,
|
||||
updateQzoneMsgRight,
|
||||
uploadQzoneImageFromSource,
|
||||
type QzoneCommentResult,
|
||||
type QzoneFeedsPage,
|
||||
type QzoneMsgDetailResult,
|
||||
type QzoneMsgListResult,
|
||||
type QzoneProfile,
|
||||
type QzoneProfileUpdate,
|
||||
type QzonePublishResult,
|
||||
type QzoneReplyTarget,
|
||||
type QzoneUpdateRightResult,
|
||||
type QzoneUploadImageResult,
|
||||
} from '@snowluma/protocol/web/qzone';
|
||||
import {
|
||||
getQzonePublicCapabilities,
|
||||
getQzoneHomeFeeds,
|
||||
getQzoneFeedDetail,
|
||||
type QzoneFeedDetail,
|
||||
getQzoneShareArk,
|
||||
type QzoneShareArk,
|
||||
getQzonePublicFeeds,
|
||||
getQzonePublicFeedDetail,
|
||||
getQzonePublicComments,
|
||||
getQzonePublicReplies,
|
||||
setQzonePublicUpvote,
|
||||
setQzonePublicLike,
|
||||
setQzonePublicCommentLike,
|
||||
commentQzonePublicFeed,
|
||||
replyQzonePublicFeed,
|
||||
deleteQzonePublicComment,
|
||||
deleteQzonePublicReply,
|
||||
setQzonePublicFollow,
|
||||
forwardQzonePublicFeed,
|
||||
type QzonePublicContent,
|
||||
type QzonePublicFeedsPage,
|
||||
} from '@snowluma/protocol/web/qzone-public';
|
||||
import type { BridgeContext } from '../bridge-context';
|
||||
|
||||
/**
|
||||
* Personal QQ-Zone (个人空间) web API: 说说, public recommendations, likes,
|
||||
* comments and image upload over the cookie-backed Qzone web interfaces,
|
||||
* reusing the same `getCookies('qzone.qq.com')` plumbing as GroupAlbumApi.
|
||||
* Distinct from the group-album surface, which lives on GroupAlbumApi.
|
||||
*/
|
||||
export class QzoneApi {
|
||||
constructor(private readonly ctx: BridgeContext) {}
|
||||
|
||||
async getProfile(targetUin?: number): Promise<QzoneProfile> {
|
||||
const cookieObject = await this.ctx.apis.web.getCookies('qzone.qq.com');
|
||||
return getQzoneProfile(cookieObject, this.ctx.identity.uin, targetUin?.toString());
|
||||
}
|
||||
|
||||
async setProfile(profile: QzoneProfileUpdate): Promise<void> {
|
||||
const cookieObject = await this.ctx.apis.web.getCookies('qzone.qq.com');
|
||||
await setQzoneProfile(cookieObject, this.ctx.identity.uin, profile);
|
||||
}
|
||||
|
||||
async getPublicCapabilities(): Promise<JsonObject> {
|
||||
const cookies = await this.ctx.apis.web.getCookies('qzone.qq.com');
|
||||
return getQzonePublicCapabilities(cookies, this.ctx.identity.uin);
|
||||
}
|
||||
|
||||
async getPublicFeeds(cursor?: string): Promise<QzonePublicFeedsPage> {
|
||||
const cookies = await this.ctx.apis.web.getCookies('qzone.qq.com');
|
||||
return getQzonePublicFeeds(cookies, this.ctx.identity.uin, cursor);
|
||||
}
|
||||
|
||||
async getHomeFeeds(targetUin?: number, cursor?: string): Promise<QzonePublicFeedsPage> {
|
||||
const cookies = await this.ctx.apis.web.getCookies('qzone.qq.com');
|
||||
return getQzoneHomeFeeds(cookies, this.ctx.identity.uin, targetUin?.toString(), cursor);
|
||||
}
|
||||
|
||||
async getFeedDetail(tid: string): Promise<QzoneFeedDetail> {
|
||||
const cookies = await this.ctx.apis.web.getCookies('qzone.qq.com');
|
||||
return getQzoneFeedDetail(cookies, tid);
|
||||
}
|
||||
|
||||
async getShareArk(feed: JsonObject): Promise<QzoneShareArk> {
|
||||
const cookies = await this.ctx.apis.web.getCookies('qzone.qq.com');
|
||||
return getQzoneShareArk(cookies, feed);
|
||||
}
|
||||
|
||||
async getPublicFeedDetail(feed: JsonObject): Promise<JsonObject> {
|
||||
const cookies = await this.ctx.apis.web.getCookies('qzone.qq.com');
|
||||
return getQzonePublicFeedDetail(cookies, feed);
|
||||
}
|
||||
|
||||
async getPublicComments(feed: JsonObject, cursor: string): Promise<JsonObject> {
|
||||
const cookies = await this.ctx.apis.web.getCookies('qzone.qq.com');
|
||||
return getQzonePublicComments(cookies, feed, cursor);
|
||||
}
|
||||
|
||||
async getPublicReplies(
|
||||
feed: JsonObject,
|
||||
comment: JsonObject,
|
||||
cursor = '',
|
||||
pageSize = 20,
|
||||
): Promise<JsonObject> {
|
||||
const cookies = await this.ctx.apis.web.getCookies('qzone.qq.com');
|
||||
return getQzonePublicReplies(cookies, feed, comment, cursor, pageSize);
|
||||
}
|
||||
|
||||
async setPublicUpvote(feed: JsonObject, enable = true): Promise<JsonObject> {
|
||||
const cookies = await this.ctx.apis.web.getCookies('qzone.qq.com');
|
||||
return setQzonePublicUpvote(cookies, feed, enable);
|
||||
}
|
||||
|
||||
async setPublicLike(feed: JsonObject, enable = true): Promise<JsonObject> {
|
||||
const cookies = await this.ctx.apis.web.getCookies('qzone.qq.com');
|
||||
return setQzonePublicLike(cookies, feed, enable);
|
||||
}
|
||||
|
||||
async setPublicCommentLike(
|
||||
feed: JsonObject,
|
||||
comment: JsonObject,
|
||||
enable = true,
|
||||
reply?: JsonObject,
|
||||
): Promise<JsonObject> {
|
||||
const cookies = await this.ctx.apis.web.getCookies('qzone.qq.com');
|
||||
return setQzonePublicCommentLike(cookies, feed, comment, enable, reply);
|
||||
}
|
||||
|
||||
async commentPublicFeed(feed: JsonObject, content: QzonePublicContent): Promise<JsonObject> {
|
||||
const cookies = await this.ctx.apis.web.getCookies('qzone.qq.com');
|
||||
const user = {
|
||||
uin: this.ctx.identity.uin,
|
||||
uid: this.ctx.identity.selfUid ?? '',
|
||||
nick: this.ctx.identity.nickname,
|
||||
};
|
||||
return commentQzonePublicFeed(cookies, feed, user, content);
|
||||
}
|
||||
|
||||
async replyPublicFeed(
|
||||
feed: JsonObject,
|
||||
comment: JsonObject,
|
||||
content: QzonePublicContent,
|
||||
reply?: JsonObject,
|
||||
): Promise<JsonObject> {
|
||||
const cookies = await this.ctx.apis.web.getCookies('qzone.qq.com');
|
||||
const user = {
|
||||
uin: this.ctx.identity.uin,
|
||||
uid: this.ctx.identity.selfUid ?? '',
|
||||
nick: this.ctx.identity.nickname,
|
||||
};
|
||||
return replyQzonePublicFeed(cookies, feed, user, comment, content, reply);
|
||||
}
|
||||
|
||||
async deletePublicComment(feed: JsonObject, comment: JsonObject): Promise<JsonObject> {
|
||||
const cookies = await this.ctx.apis.web.getCookies('qzone.qq.com');
|
||||
return deleteQzonePublicComment(cookies, feed, comment);
|
||||
}
|
||||
|
||||
async deletePublicReply(
|
||||
feed: JsonObject,
|
||||
comment: JsonObject,
|
||||
reply: JsonObject,
|
||||
): Promise<JsonObject> {
|
||||
const cookies = await this.ctx.apis.web.getCookies('qzone.qq.com');
|
||||
return deleteQzonePublicReply(cookies, feed, comment, reply);
|
||||
}
|
||||
|
||||
async setPublicFollow(feed: JsonObject, enable = true): Promise<JsonObject> {
|
||||
const cookies = await this.ctx.apis.web.getCookies('qzone.qq.com');
|
||||
return setQzonePublicFollow(cookies, feed, enable);
|
||||
}
|
||||
|
||||
async forwardPublicFeed(feed: JsonObject, reason = ''): Promise<JsonObject> {
|
||||
const cookies = await this.ctx.apis.web.getCookies('qzone.qq.com');
|
||||
return forwardQzonePublicFeed(cookies, feed, reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取说说列表。`targetUin` 省略时取机器人自己的空间。
|
||||
* `pos` 为起始偏移,`num` 为本页数量(服务端上限约 20/页)。
|
||||
*/
|
||||
async getMsgList(targetUin?: number, pos = 0, num = 20): Promise<QzoneMsgListResult> {
|
||||
const uin = targetUin && targetUin > 0 ? targetUin.toString() : this.ctx.identity.uin;
|
||||
const cookieObject = await this.ctx.apis.web.getCookies('qzone.qq.com');
|
||||
return getQzoneMsgList(cookieObject, uin, pos, num);
|
||||
}
|
||||
|
||||
/** 获取说说详情和一页评论。省略 targetUin 时使用当前账号。 */
|
||||
async getMsgDetail(
|
||||
tid: string,
|
||||
targetUin?: number,
|
||||
pos = 0,
|
||||
num = 20,
|
||||
): Promise<QzoneMsgDetailResult> {
|
||||
const uin = targetUin === undefined ? this.ctx.identity.uin : String(targetUin);
|
||||
const cookieObject = await this.ctx.apis.web.getCookies('qzone.qq.com');
|
||||
return getQzoneMsgDetail(cookieObject, uin, tid, pos, num);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取好友动态。页码调用从首页顺序定位;续页优先传上一页的 cursor。
|
||||
* 始终以机器人自己的身份拉取好友动态。
|
||||
*/
|
||||
async getFeeds(pageNum = 1, count = 10, cursor?: string): Promise<QzoneFeedsPage> {
|
||||
const cookieObject = await this.ctx.apis.web.getCookies('qzone.qq.com');
|
||||
return getQzoneFeeds(cookieObject, this.ctx.identity.uin, pageNum, count, cursor);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从来源上传图片到 QQ 空间。
|
||||
* `source` 支持: file:// 本地路径 | http(s):// URL | base64:// base64数据
|
||||
*/
|
||||
async uploadImageFromSource(source: string): Promise<QzoneUploadImageResult> {
|
||||
const cookieObject = await this.ctx.apis.web.getCookies('qzone.qq.com');
|
||||
return uploadQzoneImageFromSource(cookieObject, this.ctx.identity.uin, source);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发表说说,返回新说说的 tid。始终发到机器人自己的空间。
|
||||
* `richType` / `richval`: 带图说说时传 richType=1 和 richval 字符串
|
||||
* (多图用 `\t` 连接多个 richval)。纯文字说说省略这两个参数。
|
||||
* `ugcRight` / `targetUins`: 查看权限及其作用名单(ugcRight=16/128 时必填)。
|
||||
*/
|
||||
async publish(
|
||||
content: string,
|
||||
richType?: number,
|
||||
richval?: string,
|
||||
ugcRight = 1,
|
||||
targetUins?: string,
|
||||
): Promise<QzonePublishResult> {
|
||||
const cookieObject = await this.ctx.apis.web.getCookies('qzone.qq.com');
|
||||
return publishQzoneMsg(
|
||||
cookieObject,
|
||||
this.ctx.identity.uin,
|
||||
content,
|
||||
richType,
|
||||
richval,
|
||||
ugcRight,
|
||||
targetUins,
|
||||
);
|
||||
}
|
||||
|
||||
/** 删除机器人自己空间的一条说说(按 tid)。 */
|
||||
async delete(tid: string): Promise<void> {
|
||||
const cookieObject = await this.ctx.apis.web.getCookies('qzone.qq.com');
|
||||
await deleteQzoneMsg(cookieObject, this.ctx.identity.uin, tid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改机器人自己空间一条已发说说的查看权限(按 tid)。
|
||||
* `ugcRight` / `targetUins` 含义同 publish(16/128 时 targetUins 必填)。
|
||||
*/
|
||||
async updateRight(
|
||||
tid: string,
|
||||
ugcRight: number,
|
||||
targetUins?: string,
|
||||
): Promise<QzoneUpdateRightResult> {
|
||||
const cookieObject = await this.ctx.apis.web.getCookies('qzone.qq.com');
|
||||
return updateQzoneMsgRight(cookieObject, this.ctx.identity.uin, tid, ugcRight, targetUins);
|
||||
}
|
||||
|
||||
/**
|
||||
* 给一条说说点赞/取消赞。`targetUin` 省略时点赞机器人自己空间的说说;
|
||||
* 点赞好友说说时传好友 uin(tid 来自 get_qzone_feeds / get_qzone_msg_list)。
|
||||
*/
|
||||
async like(
|
||||
tid: string,
|
||||
targetUin: number | undefined,
|
||||
like: boolean,
|
||||
abstime = 0,
|
||||
): Promise<void> {
|
||||
const selfUin = this.ctx.identity.uin;
|
||||
const owner = targetUin && targetUin > 0 ? targetUin.toString() : selfUin;
|
||||
const cookieObject = await this.ctx.apis.web.getCookies('qzone.qq.com');
|
||||
await setQzoneLike(cookieObject, selfUin, owner, tid, like, abstime);
|
||||
}
|
||||
|
||||
/**
|
||||
* 评论一条说说。`targetUin` 为说说所属 QQ 号(省略=机器人自己空间);
|
||||
* 始终以机器人身份发表评论。返回新评论 id(尽力而为)。
|
||||
* `richType` / `richval`: 带图评论时传 richType=1 和图片直链 URL
|
||||
* (从 uploadImageFromSource 的 url 字段获取)。
|
||||
* `reply` 指定父评论及可选回复对象;楼中楼回复仅支持文字。
|
||||
*/
|
||||
async comment(
|
||||
tid: string,
|
||||
content: string,
|
||||
targetUin: number | undefined,
|
||||
richType?: number,
|
||||
richval?: string,
|
||||
reply?: QzoneReplyTarget,
|
||||
): Promise<QzoneCommentResult> {
|
||||
if (reply !== undefined && (richType !== undefined || richval !== undefined)) {
|
||||
throw new Error('images are not supported for Qzone comment replies');
|
||||
}
|
||||
|
||||
const selfUin = this.ctx.identity.uin;
|
||||
const owner = targetUin && targetUin > 0 ? targetUin.toString() : selfUin;
|
||||
const cookieObject = await this.ctx.apis.web.getCookies('qzone.qq.com');
|
||||
|
||||
if (reply !== undefined) {
|
||||
return replyQzoneComment(cookieObject, selfUin, owner, tid, content, reply);
|
||||
}
|
||||
|
||||
return commentQzoneMsg(cookieObject, selfUin, owner, tid, content, richType, richval);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉黑或解除拉黑某人(修改机器人自身 QQ 空间黑名单)。
|
||||
* `ban=true` 拉黑,`ban=false` 解除拉黑。
|
||||
*/
|
||||
async setBlack(targetUin: number, ban: boolean): Promise<void> {
|
||||
const selfUin = this.ctx.identity.uin;
|
||||
const cookieObject = await this.ctx.apis.web.getCookies('qzone.qq.com');
|
||||
await setQzoneBlack(cookieObject, selfUin, targetUin.toString(), ban);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export { toInt } from '@snowluma/protocol/oidb-services/shared';
|
||||
|
||||
// The bot's-own-UID resolver is the single source of truth in @snowluma/protocol
|
||||
// (a Bridge satisfies its BridgeContext slice). Re-exported here so the existing
|
||||
// `resolveSelfUid(bridge)` call sites in this package keep importing from shared.
|
||||
export { resolveSelfUid } from '@snowluma/protocol/self-uid';
|
||||
|
||||
/**
|
||||
* Index describing a server-side rich-media object (image / video /
|
||||
* voice). Returned by message parsers, consumed by the media-URL
|
||||
* fetchers in group-file.ts.
|
||||
*/
|
||||
export interface MediaIndexNode {
|
||||
info?: {
|
||||
fileSize?: number;
|
||||
fileHash?: string;
|
||||
fileSha1?: string;
|
||||
fileName?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
time?: number;
|
||||
original?: number;
|
||||
type?: {
|
||||
type?: number;
|
||||
picFormat?: number;
|
||||
videoFormat?: number;
|
||||
voiceFormat?: number;
|
||||
};
|
||||
};
|
||||
fileUuid?: string;
|
||||
storeId?: number;
|
||||
uploadTime?: number;
|
||||
ttl?: number;
|
||||
subType?: number;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
sysFaceStore,
|
||||
type SysFaceEntry,
|
||||
type SysFacePackEntry,
|
||||
} from '@snowluma/protocol/sys-face-store';
|
||||
import { isSuperFaceEntry } from '@snowluma/protocol/oidb-services/sys-faces/fetch-sys-faces';
|
||||
import type { BridgeContext } from '../bridge-context';
|
||||
|
||||
/** QQ's account-independent system-face directory. The shared store owns
|
||||
* persistence and refresh coalescing; this API binds it to one live sender. */
|
||||
export class SystemFaceApi {
|
||||
constructor(private readonly ctx: BridgeContext) {}
|
||||
|
||||
fetchCatalog(refresh = false): Promise<SysFacePackEntry[]> {
|
||||
return sysFaceStore.getCatalog(this.ctx, refresh);
|
||||
}
|
||||
|
||||
async fetchFace(faceId: number, refresh = false): Promise<SysFaceEntry | null> {
|
||||
if (refresh) {
|
||||
await sysFaceStore.refresh(this.ctx);
|
||||
return sysFaceStore.lookup(faceId);
|
||||
}
|
||||
|
||||
return sysFaceStore.resolve(this.ctx, faceId);
|
||||
}
|
||||
|
||||
async search(query: string): Promise<SysFaceEntry[]> {
|
||||
await sysFaceStore.ensureReady(this.ctx);
|
||||
return sysFaceStore.search(query);
|
||||
}
|
||||
|
||||
async isSuper(faceId: number, refresh = false): Promise<boolean> {
|
||||
const face = await this.fetchFace(faceId, refresh);
|
||||
return face !== null && isSuperFaceEntry(face);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
import { loadBinarySource } from '@snowluma/protocol/highway/utils';
|
||||
import {
|
||||
ForceFetchClientKey,
|
||||
type ClientKeyInfo as NamespaceClientKeyInfo,
|
||||
} from '@snowluma/protocol/oidb-services/web/force-fetch-client-key';
|
||||
import { GetPskey } from '@snowluma/protocol/oidb-services/web/get-pskey';
|
||||
import {
|
||||
getGroupEssenceMsg,
|
||||
getGroupEssenceMsgAll,
|
||||
type GroupEssenceMsgRet,
|
||||
} from '@snowluma/protocol/web/group-essence';
|
||||
import { getFriendDressWebAPI, type WebFriendDress } from '@snowluma/protocol/web/friend-dress';
|
||||
import {
|
||||
getHonorListWebAPI,
|
||||
WebHonorType,
|
||||
type WebHonorItem,
|
||||
} from '@snowluma/protocol/web/group-honor';
|
||||
import { getDaySignedListWebAPI, type SignedListMember } from '@snowluma/protocol/web/group-signin';
|
||||
import {
|
||||
deleteGroupNotice as deleteGroupNoticeHttp,
|
||||
GROUP_NOTICE_TYPE_NEW_MEMBERS,
|
||||
GROUP_NOTICE_TYPE_NORMAL,
|
||||
getGroupNoticeWebAPI,
|
||||
groupNoticeImageUrl,
|
||||
setGroupNoticeWebAPI,
|
||||
uploadGroupNoticeImage,
|
||||
type SetNoticeRetSuccess,
|
||||
type WebApiGroupNoticeCollection,
|
||||
type WebApiGroupNoticeFeed,
|
||||
type WebApiGroupNoticeRet,
|
||||
} from '@snowluma/protocol/web/group-notice';
|
||||
import { RequestUtil } from '@snowluma/protocol/web/request-util';
|
||||
import type { Bridge } from '../bridge';
|
||||
import type { BridgeContext } from '../bridge-context';
|
||||
|
||||
function asBridge(ctx: BridgeContext): Bridge {
|
||||
return ctx as unknown as Bridge;
|
||||
}
|
||||
|
||||
export type ClientKeyInfo = NamespaceClientKeyInfo;
|
||||
|
||||
export interface WebHonorInfo {
|
||||
[key: string]: import('@snowluma/common/json').JsonValue;
|
||||
group_id: number;
|
||||
current_talkative: WebHonorItem | null;
|
||||
talkative_list: WebHonorItem[];
|
||||
performer_list: WebHonorItem[];
|
||||
legend_list: WebHonorItem[];
|
||||
emotion_list: WebHonorItem[];
|
||||
strong_newbie_list: WebHonorItem[];
|
||||
}
|
||||
|
||||
export interface WebNoticeInfo {
|
||||
[key: string]: import('@snowluma/common/json').JsonValue;
|
||||
notice_id: string;
|
||||
sender_id: number;
|
||||
publish_time: number;
|
||||
message: {
|
||||
text: string;
|
||||
image: Array<{ id: string; url: string; height: number; width: number }>;
|
||||
images: Array<{ id: string; url: string; height: number; width: number }>;
|
||||
};
|
||||
settings: import('@snowluma/common/json').JsonValue;
|
||||
read_num: number;
|
||||
/** Server response type. Regular notices are commonly returned as 6. */
|
||||
type: number;
|
||||
pinned: number;
|
||||
send_to_new_members: boolean;
|
||||
}
|
||||
|
||||
export interface SendNoticeOptions {
|
||||
image?: string;
|
||||
pinned?: number;
|
||||
type?: number;
|
||||
sendToNewMembers?: boolean;
|
||||
isShowEditCard?: number;
|
||||
tipWindowType?: number;
|
||||
confirmRequired?: number;
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown, field: string): number {
|
||||
const parsed =
|
||||
typeof value === 'number'
|
||||
? value
|
||||
: typeof value === 'string' && value.trim() !== ''
|
||||
? Number(value)
|
||||
: Number.NaN;
|
||||
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw new Error(`invalid group notice response: ${field} must be a finite number`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function collectionValues(
|
||||
collection: WebApiGroupNoticeCollection | undefined,
|
||||
field: 'feeds' | 'inst',
|
||||
): WebApiGroupNoticeFeed[] {
|
||||
if (collection === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(collection)) {
|
||||
return collection;
|
||||
}
|
||||
|
||||
if (collection === null || typeof collection !== 'object') {
|
||||
throw new Error(`invalid group notice response: ${field} must be an array or object`);
|
||||
}
|
||||
|
||||
return Object.values(collection);
|
||||
}
|
||||
|
||||
function mapGroupNoticeFeed(feed: WebApiGroupNoticeFeed, sendToNewMembers: boolean): WebNoticeInfo {
|
||||
if (!feed || typeof feed !== 'object') {
|
||||
throw new Error('invalid group notice response: feed must be an object');
|
||||
}
|
||||
|
||||
if (typeof feed.fid !== 'string' || feed.fid.length === 0) {
|
||||
throw new Error('invalid group notice response: feed.fid must be a non-empty string');
|
||||
}
|
||||
|
||||
if (!feed.msg || typeof feed.msg !== 'object' || typeof feed.msg.text !== 'string') {
|
||||
throw new Error(`invalid group notice response: feed ${feed.fid} has no message text`);
|
||||
}
|
||||
|
||||
if (feed.settings === undefined) {
|
||||
throw new Error(`invalid group notice response: feed ${feed.fid} has no settings`);
|
||||
}
|
||||
|
||||
const image = (feed.msg.pics ?? []).map((pic, index) => {
|
||||
if (!pic || typeof pic !== 'object' || typeof pic.id !== 'string') {
|
||||
throw new Error(
|
||||
`invalid group notice response: feed ${feed.fid} image ${index} is malformed`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: pic.id,
|
||||
url: groupNoticeImageUrl(pic.id),
|
||||
height: finiteNumber(pic.h, `feed ${feed.fid} image ${index}.h`),
|
||||
width: finiteNumber(pic.w, `feed ${feed.fid} image ${index}.w`),
|
||||
};
|
||||
});
|
||||
const responseType =
|
||||
feed.type === undefined
|
||||
? sendToNewMembers
|
||||
? GROUP_NOTICE_TYPE_NEW_MEMBERS
|
||||
: GROUP_NOTICE_TYPE_NORMAL
|
||||
: finiteNumber(feed.type, `feed ${feed.fid}.type`);
|
||||
|
||||
return {
|
||||
notice_id: feed.fid,
|
||||
sender_id: finiteNumber(feed.u, `feed ${feed.fid}.u`),
|
||||
publish_time: finiteNumber(feed.pubt, `feed ${feed.fid}.pubt`),
|
||||
message: {
|
||||
text: feed.msg.text,
|
||||
image,
|
||||
images: image,
|
||||
},
|
||||
settings: feed.settings,
|
||||
read_num: finiteNumber(feed.read_num, `feed ${feed.fid}.read_num`),
|
||||
type: responseType,
|
||||
pinned:
|
||||
feed.pinned === undefined ? 0 : finiteNumber(feed.pinned, `feed ${feed.fid}.pinned`),
|
||||
send_to_new_members: sendToNewMembers || responseType === GROUP_NOTICE_TYPE_NEW_MEMBERS,
|
||||
};
|
||||
}
|
||||
|
||||
/** Merge QQ's regular (`feeds`) and new-member (`inst`) announcement lists. */
|
||||
export function mapGroupNoticeResponse(ret: WebApiGroupNoticeRet): WebNoticeInfo[] {
|
||||
if (ret.ec !== 0) {
|
||||
throw new Error(`获取公告失败: ec=${ret.ec}, em=${ret.em ?? 'missing'}`);
|
||||
}
|
||||
|
||||
const notices: WebNoticeInfo[] = [];
|
||||
const indexByNoticeId = new Map<string, number>();
|
||||
|
||||
const append = (
|
||||
collection: WebApiGroupNoticeCollection | undefined,
|
||||
sendToNewMembers: boolean,
|
||||
field: 'feeds' | 'inst',
|
||||
) => {
|
||||
for (const feed of collectionValues(collection, field)) {
|
||||
const mapped = mapGroupNoticeFeed(feed, sendToNewMembers);
|
||||
const existingIndex = indexByNoticeId.get(mapped.notice_id);
|
||||
|
||||
if (existingIndex === undefined) {
|
||||
indexByNoticeId.set(mapped.notice_id, notices.length);
|
||||
notices.push(mapped);
|
||||
} else if (sendToNewMembers) {
|
||||
// If QQ returns the same item in both collections, `inst` is the
|
||||
// authoritative signal that it targets new members.
|
||||
notices[existingIndex] = mapped;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
append(ret.feeds, false, 'feeds');
|
||||
append(ret.inst, true, 'inst');
|
||||
return notices;
|
||||
}
|
||||
|
||||
// ─────────────── private helpers (cookie acquisition) ───────────────
|
||||
|
||||
function forceFetchClientKeyInner(bridge: Bridge): Promise<ClientKeyInfo> {
|
||||
return ForceFetchClientKey.invoke(bridge);
|
||||
}
|
||||
|
||||
function getPSkey(
|
||||
bridge: Bridge,
|
||||
domainList: string[],
|
||||
): Promise<{ domainPskeyMap: Map<string, string> }> {
|
||||
return GetPskey.invoke(bridge, { domainList });
|
||||
}
|
||||
|
||||
async function getCookies(bridge: Bridge, domain: string): Promise<Record<string, string>> {
|
||||
const ClientKeyData = await forceFetchClientKeyInner(bridge);
|
||||
|
||||
// Build the ptlogin2 jump URL: this is the canonical way for the
|
||||
// bot to swap its clientKey for cookie-jar entries on a given
|
||||
// qq.com subdomain.
|
||||
const requestUrl =
|
||||
'https://ssl.ptlogin2.qq.com/jump?ptlang=1033&clientuin=' +
|
||||
bridge.identity.uin +
|
||||
'&clientkey=' +
|
||||
ClientKeyData.clientKey +
|
||||
'&u1=https%3A%2F%2F' +
|
||||
domain +
|
||||
'%2F' +
|
||||
bridge.identity.uin +
|
||||
'%2Finfocenter&keyindex=' +
|
||||
ClientKeyData.keyIndex;
|
||||
|
||||
const data = await RequestUtil.HttpsGetCookies(requestUrl);
|
||||
|
||||
if (!data['p_skey'] || data['p_skey'].length === 0) {
|
||||
// ptlogin2 sometimes omits p_skey; fall back to OIDB getPSkey
|
||||
// for the same domain. Errors are swallowed so the caller can
|
||||
// still proceed with whatever cookies it did get.
|
||||
try {
|
||||
const pskeyData = await getPSkey(bridge, [domain]);
|
||||
const pskey = pskeyData.domainPskeyMap.get(domain);
|
||||
|
||||
if (pskey) {
|
||||
data['p_skey'] = pskey;
|
||||
}
|
||||
} catch {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async function getSKey(bridge: Bridge): Promise<string> {
|
||||
const ClientKeyData = await forceFetchClientKeyInner(bridge);
|
||||
|
||||
if (!ClientKeyData.clientKey) {
|
||||
throw new Error('getClientKey Error: clientKey is empty');
|
||||
}
|
||||
|
||||
const u1 = encodeURIComponent(
|
||||
'https://h5.qzone.qq.com/qqnt/qzoneinpcqq/friend?refresh=0&clientuin=0&darkMode=0',
|
||||
);
|
||||
const requestUrl =
|
||||
'https://ssl.ptlogin2.qq.com/jump?ptlang=1033' +
|
||||
'&clientuin=' +
|
||||
bridge.identity.uin +
|
||||
'&clientkey=' +
|
||||
ClientKeyData.clientKey +
|
||||
'&u1=' +
|
||||
u1 +
|
||||
'&keyindex=' +
|
||||
ClientKeyData.keyIndex;
|
||||
|
||||
const cookies: { [key: string]: string } = await RequestUtil.HttpsGetCookies(requestUrl);
|
||||
const skey = cookies['skey'];
|
||||
|
||||
if (!skey) {
|
||||
throw new Error('SKey is Empty');
|
||||
}
|
||||
|
||||
return skey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard QQ bkn hash (also known as token / csrf_token) derived from
|
||||
* skey or p_skey. djb2 hash truncated to 31 bits.
|
||||
*/
|
||||
function getBknFromSKey(skey: string): number {
|
||||
let hash = 5381;
|
||||
|
||||
for (let i = 0; i < skey.length; i++) {
|
||||
hash += (hash << 5) + skey.charCodeAt(i);
|
||||
}
|
||||
|
||||
return hash & 2147483647;
|
||||
}
|
||||
|
||||
export class WebApi {
|
||||
constructor(private readonly ctx: BridgeContext) {}
|
||||
|
||||
// ─────────────── cookie / token primitives ───────────────
|
||||
|
||||
async forceFetchClientKey(): Promise<ClientKeyInfo> {
|
||||
return forceFetchClientKeyInner(asBridge(this.ctx));
|
||||
}
|
||||
|
||||
/** Fetch the cookie jar for a `qq.com` subdomain. Used by GroupAlbumApi
|
||||
* (qzone.qq.com) and by every other web helper here. */
|
||||
async getCookies(domain: string): Promise<Record<string, string>> {
|
||||
return getCookies(asBridge(this.ctx), domain);
|
||||
}
|
||||
|
||||
/** Cookies for `domain` joined into the canonical "k=v; k=v" header form. */
|
||||
async getCookiesStr(domain: string): Promise<string> {
|
||||
const cookieObject = await getCookies(asBridge(this.ctx), domain);
|
||||
return Object.entries(cookieObject)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
/** CSRF token == bkn(skey) — used by qzone / qun web APIs. */
|
||||
async getCsrfToken(): Promise<number> {
|
||||
const skey = await getSKey(asBridge(this.ctx));
|
||||
|
||||
if (!skey) {
|
||||
throw new Error('SKey is Empty');
|
||||
}
|
||||
|
||||
return getBknFromSKey(skey);
|
||||
}
|
||||
|
||||
/** Returns the OneBot `get_credentials` payload (cookie string + bkn). */
|
||||
async getCredentials(
|
||||
domain: string,
|
||||
): Promise<{ cookies: string; token: number; csrf_token: number }> {
|
||||
const cookieObject = await getCookies(asBridge(this.ctx), domain);
|
||||
const cookiesStr = Object.entries(cookieObject)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('; ');
|
||||
|
||||
const skey = cookieObject['p_skey'] || cookieObject['skey'] || '';
|
||||
const token = skey ? getBknFromSKey(skey) : 0;
|
||||
|
||||
return {
|
||||
cookies: cookiesStr,
|
||||
token,
|
||||
csrf_token: token,
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────── group essence ───────────────
|
||||
|
||||
/** Paginated fetch — `pageStart` is 0-indexed, `pageLimit` is server-capped at 50. */
|
||||
async getEssence(groupId: number, pageStart = 0, pageLimit = 50): Promise<GroupEssenceMsgRet> {
|
||||
const bridge = asBridge(this.ctx);
|
||||
const groupCode = groupId.toString();
|
||||
const cookieObject = await getCookies(bridge, 'qun.qq.com');
|
||||
const essenceData = await getGroupEssenceMsg(cookieObject, groupCode, pageStart, pageLimit);
|
||||
return essenceData || { retcode: -1, data: { is_end: true, msg_list: [] } };
|
||||
}
|
||||
|
||||
/** Walks every page and returns the concatenated result. */
|
||||
async getEssenceAll(groupId: number): Promise<GroupEssenceMsgRet[]> {
|
||||
const bridge = asBridge(this.ctx);
|
||||
const groupCode = groupId.toString();
|
||||
const cookieObject = await getCookies(bridge, 'qun.qq.com');
|
||||
return await getGroupEssenceMsgAll(cookieObject, groupCode);
|
||||
}
|
||||
|
||||
// ─────────────── group honor ───────────────
|
||||
|
||||
async getHonorInfo(groupId: number, type: WebHonorType | string): Promise<WebHonorInfo> {
|
||||
const bridge = asBridge(this.ctx);
|
||||
const groupCode = groupId.toString();
|
||||
const cookieObject = await getCookies(bridge, 'qun.qq.com');
|
||||
|
||||
const honorInfo: WebHonorInfo = {
|
||||
group_id: groupId,
|
||||
current_talkative: null,
|
||||
talkative_list: [],
|
||||
performer_list: [],
|
||||
legend_list: [],
|
||||
emotion_list: [],
|
||||
strong_newbie_list: [],
|
||||
};
|
||||
|
||||
if (type === WebHonorType.TALKATIVE || type === WebHonorType.ALL) {
|
||||
const talkativeList = await getHonorListWebAPI(cookieObject, groupCode, 1);
|
||||
|
||||
if (talkativeList.length > 0) {
|
||||
honorInfo.current_talkative = talkativeList[0];
|
||||
honorInfo.talkative_list = talkativeList;
|
||||
}
|
||||
}
|
||||
|
||||
if (type === WebHonorType.PERFORMER || type === WebHonorType.ALL) {
|
||||
honorInfo.performer_list = await getHonorListWebAPI(cookieObject, groupCode, 2);
|
||||
}
|
||||
|
||||
if (type === WebHonorType.LEGEND || type === WebHonorType.ALL) {
|
||||
honorInfo.legend_list = await getHonorListWebAPI(cookieObject, groupCode, 3);
|
||||
}
|
||||
|
||||
if (type === WebHonorType.EMOTION || type === WebHonorType.ALL) {
|
||||
honorInfo.emotion_list = await getHonorListWebAPI(cookieObject, groupCode, 6);
|
||||
}
|
||||
|
||||
return honorInfo;
|
||||
}
|
||||
|
||||
// ─────────────── group sign-in (打卡) ───────────────
|
||||
|
||||
/** Today's group sign-in list via qun.qq.com. [] when the group has no
|
||||
* sign-ins; THROWS on transport / auth failure (caller maps to failed). */
|
||||
async getSignedList(groupId: number): Promise<SignedListMember[]> {
|
||||
const bridge = asBridge(this.ctx);
|
||||
const cookieObject = await getCookies(bridge, 'qun.qq.com');
|
||||
return getDaySignedListWebAPI(
|
||||
cookieObject,
|
||||
groupId.toString(),
|
||||
bridge.identity.uin.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────── group notice ───────────────
|
||||
|
||||
async sendNotice(
|
||||
groupId: number,
|
||||
content: string,
|
||||
options?: SendNoticeOptions,
|
||||
): Promise<SetNoticeRetSuccess> {
|
||||
const bridge = asBridge(this.ctx);
|
||||
const groupCode = groupId.toString();
|
||||
const cookieObject = await getCookies(bridge, 'qun.qq.com');
|
||||
|
||||
let picId = '';
|
||||
let imgWidth = 540;
|
||||
let imgHeight = 300;
|
||||
|
||||
if (options?.image) {
|
||||
// Route through loadBinarySource so the group-notice image download
|
||||
// gets the same browser-UA + Referer-retry hardening as every other
|
||||
// media fetch — and http/file/base64 source handling for free.
|
||||
const loaded = await loadBinarySource(options.image, 'group-notice image');
|
||||
const imageBuffer = Buffer.from(loaded.bytes);
|
||||
|
||||
const picInfo = await uploadGroupNoticeImage(cookieObject, imageBuffer);
|
||||
picId = picInfo.id;
|
||||
imgWidth = picInfo.width;
|
||||
imgHeight = picInfo.height;
|
||||
}
|
||||
|
||||
const ret = await setGroupNoticeWebAPI(cookieObject, groupCode, content, {
|
||||
pinned: options?.pinned,
|
||||
type: options?.type,
|
||||
sendToNewMembers: options?.sendToNewMembers,
|
||||
isShowEditCard: options?.isShowEditCard,
|
||||
tipWindowType: options?.tipWindowType,
|
||||
confirmRequired: options?.confirmRequired,
|
||||
picId,
|
||||
imgWidth,
|
||||
imgHeight,
|
||||
});
|
||||
|
||||
if (ret.ec !== 0) {
|
||||
throw new Error(`设置群公告失败: ec=${ret.ec ?? 'missing'}, em=${ret.em ?? 'missing'}`);
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
async getNotice(groupId: number): Promise<WebNoticeInfo[]> {
|
||||
const bridge = asBridge(this.ctx);
|
||||
const groupCode = groupId.toString();
|
||||
const cookieObject = await getCookies(bridge, 'qun.qq.com');
|
||||
|
||||
const ret = await getGroupNoticeWebAPI(cookieObject, groupCode);
|
||||
return mapGroupNoticeResponse(ret);
|
||||
}
|
||||
|
||||
async deleteNotice(groupId: number, fid: string): Promise<boolean> {
|
||||
const bridge = asBridge(this.ctx);
|
||||
const groupCode = groupId.toString();
|
||||
const cookieObject = await getCookies(bridge, 'qun.qq.com');
|
||||
return await deleteGroupNoticeHttp(cookieObject, groupCode, fid);
|
||||
}
|
||||
|
||||
// ─────────────── friend dress (好友装扮) ───────────────
|
||||
|
||||
/** 查某人正在用的好友装扮(挂件/名片/来电/输入状态等)。走 vip.qq.com 域,
|
||||
* 纯 cookie 鉴权、抓 SSR HTML。「查到但没装扮」返回空 items;网络/未登录态/
|
||||
* 风控/页面改版/串号数据抛 FriendDressError(kind 区分具体环节)。 */
|
||||
async getFriendDress(targetUin: number): Promise<WebFriendDress> {
|
||||
const bridge = asBridge(this.ctx);
|
||||
const cookieObject = await getCookies(bridge, 'vip.qq.com');
|
||||
return getFriendDressWebAPI(cookieObject, targetUin.toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { BridgeContext as BaseBridgeContext } from '@snowluma/protocol/bridge-context';
|
||||
import type { ApiHub } from './apis';
|
||||
|
||||
export interface BridgeContext extends BaseBridgeContext {
|
||||
readonly apis: ApiHub;
|
||||
}
|
||||
export type { UploadedFileMeta } from '@snowluma/protocol/bridge-context';
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { OnlineDeviceInfo } from '@snowluma/protocol/events';
|
||||
import type { BridgeContext } from './bridge-context';
|
||||
|
||||
export interface RosterWarmupResult {
|
||||
friendsLoaded: boolean;
|
||||
groupsLoaded: boolean;
|
||||
}
|
||||
|
||||
export interface BridgeInterface extends BridgeContext {
|
||||
readonly activePid: number | null;
|
||||
/** Settles once for this Bridge session after first-login roster fetch. */
|
||||
whenRosterWarmupSettled(): Promise<RosterWarmupResult>;
|
||||
/** False only when every attached QQ process has a confirmed receive or
|
||||
* request-path failure. An unarmed watchdog remains true for compatibility. */
|
||||
readonly receiveHealthy: boolean;
|
||||
/** Latest device snapshot observed on this Bridge lifecycle. `null` means
|
||||
* QQ has not emitted its cache notification since SnowLuma attached. */
|
||||
getOnlineClients(): readonly Readonly<OnlineDeviceInfo>[] | null;
|
||||
}
|
||||
|
||||
export interface BridgeSessionListener {
|
||||
started?(uin: string, bridge: BridgeInterface): void;
|
||||
closed?(uin: string, bridge: BridgeInterface, pid: number): void;
|
||||
}
|
||||
|
||||
export interface BridgeSessionSource {
|
||||
subscribeSessions(listener: BridgeSessionListener): () => void;
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
import { createLogger, renderTraceBytes, runWithTraceRequest } from '@snowluma/common/logger';
|
||||
import type { PacketSender, SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import { packetTimeoutMs } from '@snowluma/bridge/packet-timeout';
|
||||
import type { PacketInfo } from '@snowluma/common/protocol-types';
|
||||
import { BridgeEventBus } from '@snowluma/protocol/event-bus';
|
||||
import { IdentityService } from '@snowluma/protocol/identity-service';
|
||||
import {
|
||||
MSG_PUSH_CMD,
|
||||
parseMsgPushOrThrow,
|
||||
SysMsgDedup,
|
||||
decodeClientMessageReceipt,
|
||||
fetchClientMessage,
|
||||
} from '@snowluma/protocol/msg-push';
|
||||
import { KICK_NT_CMD, parseKickNT } from '@snowluma/protocol/kick-nt';
|
||||
import { IncomingPacketPipeline, type CmdParser } from '@snowluma/protocol/packet-pipeline';
|
||||
import type { OnlineDeviceInfo } from '@snowluma/protocol/events';
|
||||
import { buildApiHub, type ApiHub } from './apis';
|
||||
import { AiVoiceChatType, type AiVoiceCategory, type StrangerStatus } from './apis/extras';
|
||||
import type { BridgeInterface, RosterWarmupResult } from './bridge-interface';
|
||||
import type { UploadedFileMeta } from './bridge-context';
|
||||
export type { UploadedFileMeta } from './bridge-context';
|
||||
|
||||
const log = createLogger('Bridge');
|
||||
const runtimeLog = createLogger('Bridge.Runtime');
|
||||
const VERBOSE_WARMUP = process.env.SNOWLUMA_VERBOSE_WARMUP === '1';
|
||||
|
||||
interface ProcessConnection {
|
||||
pid: number;
|
||||
client: PacketSender;
|
||||
receiveHealthy: boolean;
|
||||
}
|
||||
|
||||
export class Bridge implements BridgeInterface {
|
||||
readonly identity: IdentityService;
|
||||
/** Insertion order tracks login recency; the newest connection sends packets. */
|
||||
private readonly connections = new Map<number, ProcessConnection>();
|
||||
readonly events = new BridgeEventBus();
|
||||
readonly apis: ApiHub;
|
||||
private readonly pipeline: IncomingPacketPipeline;
|
||||
private static readonly UPLOADED_FILE_CACHE_MAX = 1024;
|
||||
private uploadedFileMeta_ = new Map<string, UploadedFileMeta>();
|
||||
private clientSeq_ = 100000000 + (Date.now() % 1000000000);
|
||||
private msgRandom_ = (Date.now() & 0xffffffff) >>> 0;
|
||||
// Per-account dedup for QQ NT's already-deduped system pushes (#137).
|
||||
private readonly sysMsgDedup_ = new SysMsgDedup();
|
||||
private onlineClients_: readonly Readonly<OnlineDeviceInfo>[] | null = null;
|
||||
private disposePromise_: Promise<void> | null = null;
|
||||
private readonly pendingPackets_ = new Set<Promise<void>>();
|
||||
private readonly clientMessageTasks_ = new Map<
|
||||
string,
|
||||
{ connection: ProcessConnection; work: Promise<void> }
|
||||
>();
|
||||
private rosterWarmupStarted_ = false;
|
||||
private settleRosterWarmup!: (result: RosterWarmupResult) => void;
|
||||
private readonly rosterWarmup = new Promise<RosterWarmupResult>((resolve) => {
|
||||
this.settleRosterWarmup = resolve;
|
||||
});
|
||||
|
||||
constructor(identity: IdentityService) {
|
||||
this.identity = identity;
|
||||
this.apis = buildApiHub(this);
|
||||
this.identity.setFetcher({
|
||||
fetchProfile: (uin) => this.apis.contacts.fetchUserProfile(uin),
|
||||
fetchProfileByUid: (uid) => this.apis.contacts.fetchUserProfileByUid(uid),
|
||||
fetchGroupMemberList: (gid) => this.apis.contacts.fetchGroupMemberList(gid),
|
||||
});
|
||||
this.pipeline = new IncomingPacketPipeline({
|
||||
identity: this.identity,
|
||||
events: this.events,
|
||||
fetchGroupList: async () => {
|
||||
await this.apis.contacts.fetchGroupList();
|
||||
},
|
||||
fetchGroupMemberList: async (groupId) => {
|
||||
await this.apis.contacts.fetchGroupMemberList(groupId);
|
||||
},
|
||||
resolveGroupJoinRequest: (groupId, uid, subType, invitedUid) =>
|
||||
this.apis.contacts.resolveGroupJoinRequest(groupId, uid, subType, invitedUid),
|
||||
groupInvites: this.apis.contacts.groupInvites,
|
||||
});
|
||||
this.pipeline.registerCmd(MSG_PUSH_CMD, (pkt, identity) =>
|
||||
parseMsgPushOrThrow(pkt, identity, this.sysMsgDedup_),
|
||||
);
|
||||
this.pipeline.registerCmd(KICK_NT_CMD, parseKickNT);
|
||||
this.events.on('online_devices_changed', (event) => {
|
||||
const snapshot = event.devices.map((device) => Object.freeze({ ...device }));
|
||||
this.onlineClients_ = Object.freeze(snapshot);
|
||||
log.debug(
|
||||
'online-device snapshot updated: uin=%s clients=%d types=%s',
|
||||
this.identity.uin,
|
||||
snapshot.length,
|
||||
snapshot.map((device) => device.clientType).join(','),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
dispose(): Promise<void> {
|
||||
if (this.disposePromise_) {
|
||||
return this.disposePromise_;
|
||||
}
|
||||
|
||||
this.settleRosterWarmup({ friendsLoaded: false, groupsLoaded: false });
|
||||
this.connections.clear();
|
||||
this.onlineClients_ = null;
|
||||
this.disposePromise_ = (async () => {
|
||||
try {
|
||||
await Promise.allSettled(this.pendingPackets_);
|
||||
this.apis.contacts.dispose();
|
||||
await this.identity.close();
|
||||
} finally {
|
||||
this.events.clear();
|
||||
}
|
||||
})();
|
||||
return this.disposePromise_;
|
||||
}
|
||||
|
||||
registerCmd(cmd: string, parser: CmdParser): void {
|
||||
this.pipeline.registerCmd(cmd, parser);
|
||||
}
|
||||
|
||||
handlesCmd(cmd: string): boolean {
|
||||
return this.pipeline.handlesCmd(cmd);
|
||||
}
|
||||
|
||||
bindPid(pid: number, client: PacketSender): void {
|
||||
const previous = this.activeConnection;
|
||||
const existing = this.connections.get(pid);
|
||||
this.connections.delete(pid);
|
||||
// A new login stays healthy until the watchdog confirms a failure.
|
||||
const connection =
|
||||
existing?.client === client ? existing : { pid, client, receiveHealthy: true };
|
||||
connection.receiveHealthy = true;
|
||||
this.connections.set(pid, connection);
|
||||
|
||||
if (previous?.pid !== pid || previous.client !== client) {
|
||||
runtimeLog.trace(
|
||||
'bridge_sender_fact event=selected uin=%j pid=%d previousPid=%j',
|
||||
this.identity.uin,
|
||||
pid,
|
||||
previous?.pid ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
this.maybeStartRosterWarmup();
|
||||
}
|
||||
|
||||
/** @internal BridgeManager lookup used when an incoming packet is the first
|
||||
* observation that a live PID changed UIN. */
|
||||
packetClientForPid(pid: number): PacketSender | null {
|
||||
return this.connections.get(pid)?.client ?? null;
|
||||
}
|
||||
|
||||
detachPid(pid: number): void {
|
||||
const wasActive = this.activePid === pid;
|
||||
this.connections.delete(pid);
|
||||
const active = this.activeConnection;
|
||||
|
||||
if (wasActive && active) {
|
||||
runtimeLog.trace(
|
||||
'bridge_sender_fact event=fallback uin=%j detachedPid=%d fallbackPid=%d',
|
||||
this.identity.uin,
|
||||
pid,
|
||||
active.pid,
|
||||
);
|
||||
log.debug(
|
||||
'packet sender changed: UIN=%s PID=%d→%d',
|
||||
this.identity.uin,
|
||||
pid,
|
||||
active.pid,
|
||||
);
|
||||
}
|
||||
}
|
||||
hasPid(pid: number): boolean {
|
||||
return this.connections.has(pid);
|
||||
}
|
||||
get empty(): boolean {
|
||||
return this.connections.size === 0;
|
||||
}
|
||||
private get activeConnection(): ProcessConnection | undefined {
|
||||
return [...this.connections.values()].at(-1);
|
||||
}
|
||||
get activePid(): number | null {
|
||||
return this.activeConnection?.pid ?? null;
|
||||
}
|
||||
get receiveHealthy(): boolean {
|
||||
return [...this.connections.values()].some((connection) => connection.receiveHealthy);
|
||||
}
|
||||
setPidReceiveHealthy(pid: number, healthy: boolean): void {
|
||||
const connection = this.connections.get(pid);
|
||||
|
||||
if (!connection) {
|
||||
throw new Error(`Bridge receive-health invariant violated: PID=${pid} is not attached`);
|
||||
}
|
||||
|
||||
connection.receiveHealthy = healthy;
|
||||
}
|
||||
getOnlineClients(): readonly Readonly<OnlineDeviceInfo>[] | null {
|
||||
return this.onlineClients_;
|
||||
}
|
||||
|
||||
whenRosterWarmupSettled(): Promise<RosterWarmupResult> {
|
||||
return this.rosterWarmup;
|
||||
}
|
||||
|
||||
onPacket(pkt: PacketInfo): Promise<void> {
|
||||
if (this.disposePromise_) {
|
||||
return Promise.reject(new Error('Bridge is disposed'));
|
||||
}
|
||||
|
||||
const connection = this.connections.get(pkt.pid);
|
||||
const work = this.identity.initialization.then(() =>
|
||||
pkt.source === 'client-message'
|
||||
? this.processClientMessage(pkt, connection)
|
||||
: this.pipeline.process(pkt),
|
||||
);
|
||||
this.pendingPackets_.add(work);
|
||||
void work.finally(() => this.pendingPackets_.delete(work)).catch(() => undefined);
|
||||
return work;
|
||||
}
|
||||
|
||||
private async processClientMessage(
|
||||
pkt: PacketInfo,
|
||||
connection: ProcessConnection | undefined,
|
||||
): Promise<void> {
|
||||
const requireConnection = (): ProcessConnection => {
|
||||
if (
|
||||
this.disposePromise_ ||
|
||||
!connection ||
|
||||
this.connections.get(pkt.pid) !== connection
|
||||
) {
|
||||
throw new Error('Client message receipt belongs to an inactive connection');
|
||||
}
|
||||
|
||||
return connection;
|
||||
};
|
||||
|
||||
const current = requireConnection();
|
||||
const receipt = decodeClientMessageReceipt(pkt, this.identity.uin);
|
||||
const key = JSON.stringify([
|
||||
pkt.pid,
|
||||
receipt.peerUid,
|
||||
receipt.sequence,
|
||||
receipt.clientSequence,
|
||||
receipt.timestamp,
|
||||
]);
|
||||
const pending = this.clientMessageTasks_.get(key);
|
||||
|
||||
if (pending?.connection === current) {
|
||||
return pending.work;
|
||||
}
|
||||
|
||||
if (this.clientMessageTasks_.size >= 256) {
|
||||
throw new Error('Too many pending client message lookups');
|
||||
}
|
||||
|
||||
runtimeLog.trace(
|
||||
'client_message_lookup_start uin=%j pid=%d seqId=%d',
|
||||
this.identity.uin,
|
||||
pkt.pid,
|
||||
pkt.seqId,
|
||||
);
|
||||
const work = (async () => {
|
||||
const source: PacketSender = {
|
||||
sendPacket: (cmd, body, timeout) =>
|
||||
requireConnection().client.sendPacket(cmd, body, timeout),
|
||||
};
|
||||
const event = await fetchClientMessage(source, this.identity, receipt);
|
||||
requireConnection();
|
||||
await this.pipeline.processEvents(pkt, [event]);
|
||||
runtimeLog.trace(
|
||||
'client_message_lookup_complete uin=%j pid=%d seqId=%d',
|
||||
this.identity.uin,
|
||||
pkt.pid,
|
||||
pkt.seqId,
|
||||
);
|
||||
})();
|
||||
const task = { connection: current, work };
|
||||
this.clientMessageTasks_.set(key, task);
|
||||
|
||||
try {
|
||||
await work;
|
||||
} finally {
|
||||
if (this.clientMessageTasks_.get(key) === task) {
|
||||
this.clientMessageTasks_.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rememberUploadedFile(meta: UploadedFileMeta): void {
|
||||
if (!meta.fileId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.uploadedFileMeta_.size >= Bridge.UPLOADED_FILE_CACHE_MAX) {
|
||||
// Map iteration order is insertion order — drop the oldest.
|
||||
const oldest = this.uploadedFileMeta_.keys().next().value;
|
||||
|
||||
if (oldest !== undefined) {
|
||||
this.uploadedFileMeta_.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
this.uploadedFileMeta_.set(meta.fileId, meta);
|
||||
}
|
||||
|
||||
recallUploadedFile(fileId: string): UploadedFileMeta | undefined {
|
||||
if (!fileId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.uploadedFileMeta_.get(fileId);
|
||||
}
|
||||
|
||||
nextClientSequence(): number {
|
||||
return ++this.clientSeq_;
|
||||
}
|
||||
|
||||
nextMessageRandom(): number {
|
||||
this.msgRandom_ = (this.msgRandom_ + 0x9e3779b9) >>> 0;
|
||||
return this.msgRandom_ & 0x7fffffff;
|
||||
}
|
||||
async sendRawPacket(
|
||||
serviceCmd: string,
|
||||
body: Uint8Array,
|
||||
timeoutMs = packetTimeoutMs(),
|
||||
): Promise<SendPacketResult> {
|
||||
const connection = this.activeConnection;
|
||||
|
||||
if (!connection) {
|
||||
return runWithTraceRequest(() => {
|
||||
const startedAt = Date.now();
|
||||
runtimeLog.trace(() => [
|
||||
'bridge_send_start serviceCmd=%j uin=%j activePid=%j timeoutMs=%d length=%d body=%s',
|
||||
serviceCmd,
|
||||
this.identity.uin,
|
||||
this.activePid,
|
||||
timeoutMs,
|
||||
body.byteLength,
|
||||
renderTraceBytes(body),
|
||||
]);
|
||||
log.warn('packet %s dropped: no packet sender attached', serviceCmd);
|
||||
runtimeLog.trace(() => [
|
||||
'bridge_send_terminal serviceCmd=%j uin=%j activePid=%j outcome=dropped reason=no_sender length=%d body=%s elapsedMs=%d',
|
||||
serviceCmd,
|
||||
this.identity.uin,
|
||||
this.activePid,
|
||||
body.byteLength,
|
||||
renderTraceBytes(body),
|
||||
Date.now() - startedAt,
|
||||
]);
|
||||
return {
|
||||
success: false,
|
||||
gotResponse: false,
|
||||
errorCode: -1,
|
||||
errorMessage: 'no packet sender attached',
|
||||
responseData: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
const result = await connection.client.sendPacket(serviceCmd, Buffer.from(body), timeoutMs);
|
||||
const elapsed = Date.now() - startedAt;
|
||||
const respLen = result.responseData ? result.responseData.length : 0;
|
||||
|
||||
if (!result.success || result.errorCode !== 0) {
|
||||
// QQ-side rejection or transport failure — the usual root cause when an
|
||||
// action misbehaves. Warn (persisted) with cmd + code so a user's log
|
||||
// shows exactly where the chain broke.
|
||||
log.warn(
|
||||
'packet %s failed: code=%d gotResponse=%s %s (uin=%s, %dB, %dms)',
|
||||
serviceCmd,
|
||||
result.errorCode,
|
||||
result.gotResponse,
|
||||
result.errorMessage ?? '',
|
||||
this.identity.uin,
|
||||
body.length,
|
||||
elapsed,
|
||||
);
|
||||
} else {
|
||||
// Happy path — memory-only trace so the full chain shows under the
|
||||
// request's [req#N] when debugging, without flooding disk.
|
||||
log.trace(() => [
|
||||
`packet ${serviceCmd} ok (${body.length}B⇄${respLen}B, ${elapsed}ms)`,
|
||||
]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async resolveUserUid(uin: number, groupId?: number): Promise<string> {
|
||||
return this.identity.resolveUid(uin, groupId);
|
||||
}
|
||||
|
||||
private maybeStartRosterWarmup(): void {
|
||||
if (this.disposePromise_ || this.rosterWarmupStarted_ || this.empty) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.rosterWarmupStarted_ = true;
|
||||
|
||||
if (!this.identity.nickname) {
|
||||
this.identity.nickname = this.identity.uin;
|
||||
}
|
||||
|
||||
void this.runRosterWarmup().then(
|
||||
(result) =>
|
||||
this.settleRosterWarmup(
|
||||
this.disposePromise_ ? { friendsLoaded: false, groupsLoaded: false } : result,
|
||||
),
|
||||
(err) => {
|
||||
log.warn(
|
||||
'roster warmup failed: uin=%s err=%s',
|
||||
this.identity.uin,
|
||||
err instanceof Error ? (err.stack ?? err.message) : String(err),
|
||||
);
|
||||
this.settleRosterWarmup({ friendsLoaded: false, groupsLoaded: false });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private stillOpen(): boolean {
|
||||
return this.disposePromise_ === null;
|
||||
}
|
||||
|
||||
private async runRosterWarmup(): Promise<RosterWarmupResult> {
|
||||
await this.identity.initialization;
|
||||
|
||||
if (this.disposePromise_) {
|
||||
return { friendsLoaded: false, groupsLoaded: false };
|
||||
}
|
||||
|
||||
const uin = this.identity.uin;
|
||||
const selfUin = parseInt(uin, 10) || 0;
|
||||
let selfResolved = false;
|
||||
let friendsLoaded = false;
|
||||
|
||||
try {
|
||||
const friends = await this.apis.contacts.fetchFriendList();
|
||||
|
||||
if (!this.stillOpen()) {
|
||||
return { friendsLoaded: false, groupsLoaded: false };
|
||||
}
|
||||
|
||||
friendsLoaded = true;
|
||||
log.info('friends loaded: UIN=%s count=%d', uin, friends.length);
|
||||
|
||||
for (const friend of friends) {
|
||||
if (friend.uin === selfUin) {
|
||||
this.identity.setSelfProfile({
|
||||
uin: friend.uin,
|
||||
uid: friend.uid,
|
||||
nickname: friend.nickname || uin,
|
||||
remark: '',
|
||||
qid: '',
|
||||
sex: 'unknown',
|
||||
age: 0,
|
||||
sign: '',
|
||||
avatar: '',
|
||||
level: 0,
|
||||
qidianMasterFlag: 0,
|
||||
qidianCrewFlag: 0,
|
||||
qidianCrewFlag2: 0,
|
||||
});
|
||||
this.identity.nickname = friend.nickname || uin;
|
||||
log.debug(
|
||||
'self info: UIN=%s uid=%s nickname=%s',
|
||||
uin,
|
||||
friend.uid,
|
||||
friend.nickname ?? '',
|
||||
);
|
||||
selfResolved = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (!this.stillOpen()) {
|
||||
return { friendsLoaded: false, groupsLoaded: false };
|
||||
}
|
||||
|
||||
log.warn(
|
||||
'failed to load friends for UIN %s: %s',
|
||||
uin,
|
||||
e instanceof Error ? e.message : String(e),
|
||||
);
|
||||
}
|
||||
|
||||
if (!selfResolved && selfUin > 0 && this.stillOpen()) {
|
||||
try {
|
||||
const profile = await this.apis.contacts.fetchUserProfile(selfUin);
|
||||
|
||||
if (!this.stillOpen()) {
|
||||
return { friendsLoaded: false, groupsLoaded: false };
|
||||
}
|
||||
|
||||
this.identity.setSelfProfile(profile);
|
||||
this.identity.nickname = profile.nickname || uin;
|
||||
log.debug(
|
||||
'self info via profile: UIN=%s uid=%s nickname=%s',
|
||||
uin,
|
||||
profile.uid,
|
||||
profile.nickname,
|
||||
);
|
||||
} catch (e) {
|
||||
if (!this.stillOpen()) {
|
||||
return { friendsLoaded: false, groupsLoaded: false };
|
||||
}
|
||||
|
||||
log.warn(
|
||||
'failed to load self profile for UIN %s: %s',
|
||||
uin,
|
||||
e instanceof Error ? e.message : String(e),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let groups: { groupId: number }[] = [];
|
||||
let groupsLoaded = false;
|
||||
|
||||
try {
|
||||
groups = await this.apis.contacts.fetchGroupList();
|
||||
|
||||
if (!this.stillOpen()) {
|
||||
return { friendsLoaded: false, groupsLoaded: false };
|
||||
}
|
||||
|
||||
groupsLoaded = true;
|
||||
log.info('groups loaded: UIN=%s count=%d', uin, groups.length);
|
||||
} catch (e) {
|
||||
if (!this.stillOpen()) {
|
||||
return { friendsLoaded: false, groupsLoaded: false };
|
||||
}
|
||||
|
||||
log.warn(
|
||||
'failed to load groups for UIN %s: %s',
|
||||
uin,
|
||||
e instanceof Error ? e.message : String(e),
|
||||
);
|
||||
}
|
||||
|
||||
let loadedGroupCount = 0;
|
||||
let loadedMemberCount = 0;
|
||||
let failedGroupCount = 0;
|
||||
|
||||
for (const group of groups) {
|
||||
if (!this.stillOpen()) {
|
||||
return { friendsLoaded: false, groupsLoaded: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const members = await this.apis.contacts.fetchGroupMemberList(group.groupId);
|
||||
|
||||
if (!this.stillOpen()) {
|
||||
return { friendsLoaded: false, groupsLoaded: false };
|
||||
}
|
||||
|
||||
loadedGroupCount += 1;
|
||||
loadedMemberCount += members.length;
|
||||
|
||||
if (VERBOSE_WARMUP) {
|
||||
log.debug('members loaded: group=%d count=%d', group.groupId, members.length);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!this.stillOpen()) {
|
||||
return { friendsLoaded: false, groupsLoaded: false };
|
||||
}
|
||||
|
||||
failedGroupCount += 1;
|
||||
log.warn(
|
||||
'failed to load members for group %d: %s',
|
||||
group.groupId,
|
||||
e instanceof Error ? e.message : String(e),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.stillOpen()) {
|
||||
return { friendsLoaded: false, groupsLoaded: false };
|
||||
}
|
||||
|
||||
log.info(
|
||||
'member warmup completed: UIN=%s groups=%d/%d members=%d failed=%d',
|
||||
uin,
|
||||
loadedGroupCount,
|
||||
groups.length,
|
||||
loadedMemberCount,
|
||||
failedGroupCount,
|
||||
);
|
||||
return { friendsLoaded, groupsLoaded };
|
||||
}
|
||||
}
|
||||
export interface SendMessageReceipt {
|
||||
messageId: number;
|
||||
sequence: number;
|
||||
clientSequence: number;
|
||||
random: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface DownloadRKeyInfo {
|
||||
rkey: string;
|
||||
ttlSeconds: number;
|
||||
storeId: number;
|
||||
createTime: number;
|
||||
type: number;
|
||||
}
|
||||
|
||||
export interface ClientKeyInfo {
|
||||
clientKey: string;
|
||||
expireTime: string;
|
||||
keyIndex: string;
|
||||
}
|
||||
export { AiVoiceChatType };
|
||||
export type { AiVoiceCategory, StrangerStatus };
|
||||
@@ -0,0 +1,453 @@
|
||||
import { createLogger, renderTraceBytes, runWithTraceRequest } from '@snowluma/common/logger';
|
||||
import type { PacketSender } from '@snowluma/common/packet-sender';
|
||||
import type { PacketInfo } from '@snowluma/common/protocol-types';
|
||||
import { isRealUin } from '@snowluma/common/uin';
|
||||
import { IdentityService } from '@snowluma/protocol/identity-service';
|
||||
import { Bridge } from './bridge';
|
||||
import type { BridgeSessionListener, BridgeSessionSource } from './bridge-interface';
|
||||
|
||||
const log = createLogger('Bridge');
|
||||
const packetLog = createLogger('Bridge.Packet');
|
||||
const runtimeLog = createLogger('Bridge.Runtime');
|
||||
|
||||
export class BridgeManager implements BridgeSessionSource {
|
||||
private readonly bridges = new Map<string, Bridge>();
|
||||
private readonly pidToUin_ = new Map<number, string>();
|
||||
private readonly closingSessions_ = new Map<string, Set<Promise<void>>>();
|
||||
private readonly sessionSubscriptions = new Set<{ listener: BridgeSessionListener }>();
|
||||
private disposal_: Promise<void> | null = null;
|
||||
|
||||
/** Hook callbacks remain synchronous; shutdown awaits every detached account. */
|
||||
dispose(): Promise<void> {
|
||||
this.sessionSubscriptions.clear();
|
||||
// Finish synchronous lifecycle observers before collecting detached sessions.
|
||||
return (this.disposal_ ??= Promise.resolve().then(() => this.disposeSessions()));
|
||||
}
|
||||
|
||||
private async disposeSessions(): Promise<void> {
|
||||
for (const [uin, session] of this.bridges) {
|
||||
this.closeSession(uin, session);
|
||||
}
|
||||
|
||||
this.bridges.clear();
|
||||
this.pidToUin_.clear();
|
||||
const results = await Promise.allSettled(
|
||||
[...this.closingSessions_.values()].flatMap((sessions) => [...sessions]),
|
||||
);
|
||||
const errors = results.flatMap((result) =>
|
||||
result.status === 'rejected' ? [result.reason] : [],
|
||||
);
|
||||
|
||||
if (errors.length) {
|
||||
throw new AggregateError(errors, 'Bridge persistence shutdown failed');
|
||||
}
|
||||
}
|
||||
|
||||
private closeSession(uin: string, bridge: Bridge): void {
|
||||
const sessions = this.closingSessions_.get(uin) ?? new Set<Promise<void>>();
|
||||
const closing = bridge.dispose();
|
||||
sessions.add(closing);
|
||||
this.closingSessions_.set(uin, sessions);
|
||||
void closing.then(
|
||||
() => {
|
||||
sessions.delete(closing);
|
||||
|
||||
if (sessions.size === 0) {
|
||||
this.closingSessions_.delete(uin);
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
log.error(
|
||||
'identity close failed: UIN=%s err=%s',
|
||||
uin,
|
||||
error instanceof Error ? (error.stack ?? error.message) : String(error),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Listener changes take effect on the next event. Close observers run before
|
||||
* Bridge disposal, so the final process and account identity remain readable. */
|
||||
subscribeSessions(listener: BridgeSessionListener): () => void {
|
||||
const subscription = { listener };
|
||||
this.sessionSubscriptions.add(subscription);
|
||||
|
||||
return () => {
|
||||
this.sessionSubscriptions.delete(subscription);
|
||||
};
|
||||
}
|
||||
|
||||
private fireSessionStarted(uin: string, bridge: Bridge): void {
|
||||
for (const { listener } of [...this.sessionSubscriptions]) {
|
||||
try {
|
||||
listener.started?.(uin, bridge);
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
'session-started listener threw: %s',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fireSessionClosed(uin: string, bridge: Bridge, pid: number): void {
|
||||
for (const { listener } of [...this.sessionSubscriptions]) {
|
||||
try {
|
||||
listener.closed?.(uin, bridge, pid);
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
'session-closed listener threw: %s',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onPidDisconnected(pid: number): void {
|
||||
const uin = this.pidToUin_.get(pid);
|
||||
|
||||
if (!uin) {
|
||||
return;
|
||||
}
|
||||
|
||||
runWithTraceRequest(() => {
|
||||
const startedAt = Date.now();
|
||||
runtimeLog.trace('bridge_detach_start pid=%d uin=%j', pid, uin);
|
||||
|
||||
try {
|
||||
const closed = this.detachPidFromSession(pid, uin);
|
||||
const activePid = this.bridges.get(uin)?.activePid ?? null;
|
||||
|
||||
if (closed) {
|
||||
runtimeLog.trace(
|
||||
'bridge_detach_branch pid=%d uin=%j branch=session_closed',
|
||||
pid,
|
||||
uin,
|
||||
);
|
||||
}
|
||||
|
||||
runtimeLog.trace(
|
||||
'bridge_detach_terminal pid=%d uin=%j outcome=completed reason=%s activePid=%j elapsedMs=%d',
|
||||
pid,
|
||||
uin,
|
||||
closed ? 'session_closed' : 'pid_detached',
|
||||
activePid,
|
||||
Date.now() - startedAt,
|
||||
);
|
||||
} catch (error) {
|
||||
runtimeLog.trace(
|
||||
'bridge_detach_terminal pid=%d uin=%j outcome=failed reason=invariant_violation error=%j elapsedMs=%d',
|
||||
pid,
|
||||
uin,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
Date.now() - startedAt,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onPidReceiveHealthChanged(pid: number, healthy: boolean): void {
|
||||
if (this.disposal_) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uin = this.pidToUin_.get(pid);
|
||||
|
||||
if (!uin) {
|
||||
const message = `BridgeManager invariant violated: receive health for unmapped PID=${pid}`;
|
||||
log.error(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const session = this.bridges.get(uin);
|
||||
|
||||
if (!session || !session.hasPid(pid)) {
|
||||
const message = `BridgeManager invariant violated: receive health PID=${pid} is not owned by UIN=${uin}`;
|
||||
log.error(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
session.setPidReceiveHealthy(pid, healthy);
|
||||
}
|
||||
|
||||
onHookLogin(pid: number, uin: string, packetClient: PacketSender): void {
|
||||
if (this.disposal_ || !isRealUin(uin)) {
|
||||
return;
|
||||
}
|
||||
|
||||
runWithTraceRequest(() => {
|
||||
const startedAt = Date.now();
|
||||
const previousUin = this.pidToUin_.get(pid) ?? null;
|
||||
runtimeLog.trace(
|
||||
'bridge_binding_start pid=%d uin=%j source=login previousUin=%j',
|
||||
pid,
|
||||
uin,
|
||||
previousUin,
|
||||
);
|
||||
|
||||
try {
|
||||
const { session, created } = this.bindPid(pid, uin, packetClient, 'login');
|
||||
|
||||
if (previousUin && previousUin !== uin) {
|
||||
runtimeLog.trace(
|
||||
'bridge_binding_branch pid=%d uin=%j branch=account_rebind previousUin=%j',
|
||||
pid,
|
||||
uin,
|
||||
previousUin,
|
||||
);
|
||||
const previousSession = this.bridges.get(previousUin);
|
||||
runtimeLog.trace(
|
||||
'bridge_binding_branch pid=%d uin=%j branch=%s previousUin=%j activePid=%j',
|
||||
pid,
|
||||
uin,
|
||||
previousSession ? 'previous_session_retained' : 'previous_session_closed',
|
||||
previousUin,
|
||||
previousSession?.activePid ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
if (created) {
|
||||
runtimeLog.trace(
|
||||
'bridge_binding_branch pid=%d uin=%j branch=session_created',
|
||||
pid,
|
||||
uin,
|
||||
);
|
||||
log.debug('session started: UIN=%s', uin);
|
||||
this.fireSessionStarted(uin, session);
|
||||
}
|
||||
|
||||
runtimeLog.trace(
|
||||
'bridge_binding_terminal pid=%d uin=%j source=login outcome=completed reason=%s created=%s activePid=%j elapsedMs=%d',
|
||||
pid,
|
||||
uin,
|
||||
previousUin && previousUin !== uin ? 'rebound' : 'bound',
|
||||
created,
|
||||
session.activePid,
|
||||
Date.now() - startedAt,
|
||||
);
|
||||
} catch (error) {
|
||||
runtimeLog.trace(
|
||||
'bridge_binding_terminal pid=%d uin=%j source=login outcome=failed reason=binding_failed error=%j elapsedMs=%d',
|
||||
pid,
|
||||
uin,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
Date.now() - startedAt,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onPacket(pkt: PacketInfo): void {
|
||||
if (this.disposal_) {
|
||||
return;
|
||||
}
|
||||
|
||||
runWithTraceRequest(() => this.onPacketInContext(pkt));
|
||||
}
|
||||
|
||||
private onPacketInContext(pkt: PacketInfo): void {
|
||||
const startedAt = Date.now();
|
||||
packetLog.trace(() => [
|
||||
'packet_push serviceCmd=%j seqId=%d retCode=%d fromClient=%s pid=%d uin=%j length=%d body=%s',
|
||||
pkt.serviceCmd,
|
||||
pkt.seqId,
|
||||
pkt.retCode,
|
||||
pkt.fromClient,
|
||||
pkt.pid,
|
||||
pkt.uin,
|
||||
pkt.body.byteLength,
|
||||
renderTraceBytes(pkt.body),
|
||||
]);
|
||||
|
||||
if (!pkt.uin || !isRealUin(pkt.uin)) {
|
||||
packetLog.trace(() => [
|
||||
'packet_terminal serviceCmd=%j seqId=%d outcome=dropped reason=invalid_uin events=0 dispatched=0 elapsedMs=%d',
|
||||
pkt.serviceCmd,
|
||||
pkt.seqId,
|
||||
Date.now() - startedAt,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const uin = pkt.uin;
|
||||
|
||||
let completion: Promise<void>;
|
||||
let handedToPipeline = false;
|
||||
|
||||
try {
|
||||
// A packet may be the first trustworthy observation that a live PID moved
|
||||
// to another UIN. Apply the exact same ownership transition as login so a
|
||||
// PID can never remain attached to two Bridges.
|
||||
const client = pkt.pid > 0 ? this.packetClientForPid(pkt.pid) : null;
|
||||
const { session, created } = client
|
||||
? this.bindPid(pkt.pid, uin, client, 'packet')
|
||||
: this.ensureSession(uin);
|
||||
|
||||
// Notify session started on first real packet
|
||||
if (created) {
|
||||
log.debug('session started: UIN=%s', uin);
|
||||
this.fireSessionStarted(uin, session);
|
||||
}
|
||||
|
||||
// Dispatch packet to bridge. The pipeline preserves synchronous parsing and
|
||||
// dispatch; its promise only marks completion of any async enrichment.
|
||||
handedToPipeline = true;
|
||||
completion = session.onPacket(pkt);
|
||||
} catch (error) {
|
||||
this.tracePacketFailure(
|
||||
pkt,
|
||||
startedAt,
|
||||
handedToPipeline ? 'pipeline_failed' : 'routing_failed',
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
void completion.catch((error) => {
|
||||
this.tracePacketFailure(pkt, startedAt, 'pipeline_failed', error);
|
||||
});
|
||||
}
|
||||
|
||||
private tracePacketFailure(
|
||||
pkt: PacketInfo,
|
||||
startedAt: number,
|
||||
reason: 'routing_failed' | 'pipeline_failed',
|
||||
error: unknown,
|
||||
): void {
|
||||
packetLog.trace(() => [
|
||||
'packet_terminal serviceCmd=%j seqId=%d outcome=failed reason=%s error=%j events=0 dispatched=0 elapsedMs=%d',
|
||||
pkt.serviceCmd,
|
||||
pkt.seqId,
|
||||
reason,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
Date.now() - startedAt,
|
||||
]);
|
||||
log.error(
|
||||
'packet %s failed for %s: %s',
|
||||
reason === 'routing_failed' ? 'routing' : 'pipeline',
|
||||
pkt.serviceCmd,
|
||||
error instanceof Error ? (error.stack ?? error.message) : String(error),
|
||||
);
|
||||
}
|
||||
|
||||
/** Bind PID + sender as one state transition. If the PID changed accounts,
|
||||
* the old Bridge is fully detached (and, when empty, closed) before the new
|
||||
* Bridge is created. */
|
||||
private bindPid(
|
||||
pid: number,
|
||||
uin: string,
|
||||
packetClient: PacketSender,
|
||||
source: 'login' | 'packet',
|
||||
): { session: Bridge; created: boolean } {
|
||||
const previousUin = this.pidToUin_.get(pid);
|
||||
|
||||
if (previousUin && previousUin !== uin) {
|
||||
log.warn(
|
||||
'PID ownership changed: PID=%d UIN=%s -> UIN=%s source=%s',
|
||||
pid,
|
||||
previousUin,
|
||||
uin,
|
||||
source,
|
||||
);
|
||||
this.detachPidFromSession(pid, previousUin);
|
||||
}
|
||||
|
||||
const result = this.ensureSession(uin);
|
||||
result.session.bindPid(pid, packetClient);
|
||||
this.pidToUin_.set(pid, uin);
|
||||
return result;
|
||||
}
|
||||
|
||||
private packetClientForPid(pid: number): PacketSender | null {
|
||||
const uin = this.pidToUin_.get(pid);
|
||||
|
||||
if (!uin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const session = this.bridges.get(uin);
|
||||
|
||||
if (!session) {
|
||||
const message = `BridgeManager invariant violated: PID=${pid} references missing UIN=${uin} session`;
|
||||
log.error(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const packetClient = session.packetClientForPid(pid);
|
||||
|
||||
if (packetClient) {
|
||||
return packetClient;
|
||||
}
|
||||
|
||||
const message = `BridgeManager invariant violated: PID=${pid} has no sender in UIN=${uin} session`;
|
||||
log.error(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
private detachPidFromSession(pid: number, uin: string): boolean {
|
||||
const mappedUin = this.pidToUin_.get(pid);
|
||||
|
||||
if (mappedUin !== uin) {
|
||||
const message = `BridgeManager invariant violated: PID=${pid} detach expected UIN=${uin}, mapped UIN=${mappedUin ?? 'none'}`;
|
||||
log.error(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const session = this.bridges.get(uin);
|
||||
|
||||
if (!session) {
|
||||
const message = `BridgeManager invariant violated: PID=${pid} references missing UIN=${uin} session`;
|
||||
log.error(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
if (!session.hasPid(pid)) {
|
||||
const message = `BridgeManager invariant violated: PID=${pid} is mapped to UIN=${uin}, but Bridge does not own the PID`;
|
||||
log.error(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
// Validation is complete. Mutate both sides only after the ownership
|
||||
// invariant is known to hold, so a fail-fast error preserves the evidence.
|
||||
this.pidToUin_.delete(pid);
|
||||
session.detachPid(pid);
|
||||
|
||||
if (!session.empty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.bridges.delete(uin);
|
||||
log.debug('session closed: UIN=%s', uin);
|
||||
// Fire before dispose() so listeners can still read bridge.identity.
|
||||
this.fireSessionClosed(uin, session, pid);
|
||||
this.closeSession(uin, session);
|
||||
return true;
|
||||
}
|
||||
|
||||
private ensureSession(uin: string): { session: Bridge; created: boolean } {
|
||||
const session = this.bridges.get(uin);
|
||||
|
||||
if (session) {
|
||||
return { session, created: false };
|
||||
}
|
||||
|
||||
const closing = this.closingSessions_.get(uin);
|
||||
// allSettled, not all: a failed close stays in the bookkeeping (so
|
||||
// dispose can still report it) but must not reject this gate — a
|
||||
// rejected gate would keep the identity DB of every future session
|
||||
// for this UIN closed until the process restarts.
|
||||
const beforeOpen = closing
|
||||
? Promise.allSettled(closing).then(() => undefined)
|
||||
: undefined;
|
||||
const bridge = new Bridge(IdentityService.openForUin(uin, 'data', beforeOpen));
|
||||
this.bridges.set(uin, bridge);
|
||||
|
||||
return { session: bridge, created: true };
|
||||
}
|
||||
|
||||
getBridge(uin: string): Bridge | null {
|
||||
return this.bridges.get(uin) ?? null;
|
||||
}
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
declare const __BUILD_WEBUI__: boolean;
|
||||
declare const __PACKAGED__: boolean;
|
||||
@@ -0,0 +1,264 @@
|
||||
import { getRuntimeIntegrity, HookManager } from '@snowluma/bridge';
|
||||
import { configureFileTransport } from '@snowluma/common/log-file-transport';
|
||||
import { closeLogger, createLogger } from '@snowluma/common/logger';
|
||||
import {
|
||||
DEFAULT_LOG_MAX_TOTAL_MB,
|
||||
DEFAULT_LOG_PER_UIN,
|
||||
DEFAULT_LOG_RETAIN_DAYS,
|
||||
loadRuntimeConfig,
|
||||
} from '@snowluma/common/runtime';
|
||||
import { OneBotManager } from '@snowluma/onebot/manager';
|
||||
import { migrateGlobalSettings } from '@snowluma/onebot/global-config';
|
||||
import { cleanupInvalidPerUinConfigs } from '@snowluma/onebot/config';
|
||||
import {
|
||||
isMessageStoreMigrationWorkerData,
|
||||
runMessageStoreMigrationWorker,
|
||||
} from '@snowluma/onebot/message-store-migration-worker';
|
||||
import { workerData } from 'node:worker_threads';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { BridgeManager } from './bridge/manager';
|
||||
import { createNotificationManager } from './notifications/manager';
|
||||
import { createStateWiring } from './webui/state-wiring';
|
||||
import { initWebUI } from './webui/server';
|
||||
import { bindSystemFaceCatalog } from './sys-face-catalog';
|
||||
import { logRuntimeIntegrity } from './runtime-integrity-log';
|
||||
import { RESTART_EXIT_CODE, runApplication } from './update/startup';
|
||||
|
||||
const log = createLogger('App');
|
||||
const updateRoot =
|
||||
typeof __PACKAGED__ === 'undefined' ? undefined : path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function installProcessGuards(): void {
|
||||
// Last-resort process guards. This is a long-running daemon: a hooked QQ pipe
|
||||
// can drop at any moment, and a stray rejection on a detached callback (or a
|
||||
// throw that escapes one) must NOT take the whole process down — otherwise
|
||||
// Docker restarts it (dropping every session) and a bare Windows run just dies
|
||||
// ("connection pipe closed", bot offline). Log and keep serving; the bridge's
|
||||
// own watcher reconnects the dropped pipe. Targeted handlers still cover the
|
||||
// known paths (e.g. the hook-client send deferreds) — these only catch what
|
||||
// slips through, and surface its stack so the real source can be fixed.
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
log.error(
|
||||
'unhandledRejection (kept alive): %s',
|
||||
reason instanceof Error ? (reason.stack ?? reason.message) : String(reason),
|
||||
);
|
||||
});
|
||||
process.on('uncaughtException', (error) => {
|
||||
log.error('uncaughtException (kept alive): %s', error.stack ?? error.message);
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const runtimeConfig = loadRuntimeConfig();
|
||||
await configureFileTransport({
|
||||
maxTotalMb: runtimeConfig.logMaxTotalMb ?? DEFAULT_LOG_MAX_TOTAL_MB,
|
||||
retainDays: runtimeConfig.logRetainDays ?? DEFAULT_LOG_RETAIN_DAYS,
|
||||
perUinEnabled: runtimeConfig.logPerUin ?? DEFAULT_LOG_PER_UIN,
|
||||
});
|
||||
log.info('SnowLuma starting');
|
||||
logRuntimeIntegrity(await getRuntimeIntegrity());
|
||||
|
||||
// One-shot: lift a legacy per-UIN musicSignUrl into the global store before
|
||||
// any session (and thus any per-UIN config rewrite) can drop it.
|
||||
migrateGlobalSettings();
|
||||
|
||||
// One-shot: purge any persisted phantom accounts (onebot_<garbage>.json) the
|
||||
// native hook may have created with a timestamp-shaped UIN (issue #162).
|
||||
cleanupInvalidPerUinConfigs();
|
||||
|
||||
const bridgeManager = new BridgeManager();
|
||||
bindSystemFaceCatalog(bridgeManager);
|
||||
const oneBotManager = new OneBotManager();
|
||||
oneBotManager.bind(bridgeManager);
|
||||
const autoLoadOnDiscovery = resolveAutoLoad(runtimeConfig.hookAutoLoad);
|
||||
|
||||
// WebUI state-push wiring: HookManager + BridgeManager edges publish
|
||||
// `processes` / `qq-list` / `connections` invalidations into a bus the
|
||||
// /api/state/stream SSE handler subscribes to (no REST polling required).
|
||||
const stateWiring = createStateWiring(bridgeManager);
|
||||
const hookManager = new HookManager({
|
||||
bridgeManager,
|
||||
autoLoadOnDiscovery,
|
||||
onSessionsChanged: stateWiring.onSessionsChanged,
|
||||
});
|
||||
|
||||
if (autoLoadOnDiscovery) {
|
||||
log.info('hook auto-load enabled: every discovered QQ process will be injected');
|
||||
}
|
||||
|
||||
// Global notification subsystem (account up/down → webhook). Bound AFTER
|
||||
// OneBotManager so the nickname fallback is already populated when it observes.
|
||||
// Lifecycle events identify their PID; an older process with the same UIN
|
||||
// must not supply facts for a recovered account.
|
||||
const notificationManager = createNotificationManager(async (uin, pid) => {
|
||||
const found = (await hookManager.listProcesses()).find(
|
||||
(p) => p.pid === pid && p.uin === uin,
|
||||
);
|
||||
return found ? { status: found.status, pid: found.pid, error: found.error } : undefined;
|
||||
});
|
||||
notificationManager.bind(bridgeManager);
|
||||
|
||||
let stopWebUI: (() => Promise<void>) | undefined;
|
||||
|
||||
if (
|
||||
(typeof __BUILD_WEBUI__ !== 'undefined' && __BUILD_WEBUI__) ||
|
||||
process.env.SNOWLUMA_DEV_WEBUI === '1'
|
||||
) {
|
||||
try {
|
||||
const started = await initWebUI(
|
||||
runtimeConfig.webuiPort || 5099,
|
||||
oneBotManager,
|
||||
hookManager,
|
||||
notificationManager,
|
||||
{
|
||||
host: runtimeConfig.webuiHost,
|
||||
tlsEnabled: runtimeConfig.webuiTls?.enabled,
|
||||
trustProxy: runtimeConfig.trustProxy,
|
||||
stateBus: stateWiring.bus,
|
||||
updateRoot,
|
||||
restart: () => {
|
||||
void shutdown('update', RESTART_EXIT_CODE);
|
||||
},
|
||||
},
|
||||
);
|
||||
stopWebUI = started.stop;
|
||||
} catch (err) {
|
||||
log.error('Failed to start WebUI: ', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Graceful shutdown: dispose managers, await log flush, then exit.
|
||||
// SIGINT (Ctrl-C) and SIGTERM (Docker/systemd) take the same path.
|
||||
let shutdownStarted = false;
|
||||
|
||||
const shutdown = async (signal: string, requestedExitCode = 0) => {
|
||||
if (shutdownStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
shutdownStarted = true;
|
||||
log.warn(`Shutting down (${signal})...`);
|
||||
let exitCode = requestedExitCode;
|
||||
|
||||
try {
|
||||
await oneBotManager.dispose();
|
||||
} catch (error) {
|
||||
exitCode = 1;
|
||||
log.error(
|
||||
'OneBot shutdown failed: %s',
|
||||
error instanceof Error ? (error.stack ?? error.message) : String(error),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
notificationManager.dispose();
|
||||
} catch (error) {
|
||||
exitCode = 1;
|
||||
log.error(
|
||||
'notification shutdown failed: %s',
|
||||
error instanceof Error ? (error.stack ?? error.message) : String(error),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (stopWebUI) {
|
||||
await stopWebUI();
|
||||
}
|
||||
} catch (error) {
|
||||
exitCode = 1;
|
||||
log.error(
|
||||
'WebUI shutdown failed: %s',
|
||||
error instanceof Error ? (error.stack ?? error.message) : String(error),
|
||||
);
|
||||
}
|
||||
|
||||
// Observers go deaf before Hook teardown. Hook dispose then emits the
|
||||
// same disconnected → PID detach → Bridge closed path as a live drop;
|
||||
// those closed edges must not look like a runtime offline.
|
||||
try {
|
||||
stateWiring.dispose();
|
||||
} catch (error) {
|
||||
exitCode = 1;
|
||||
log.error(
|
||||
'state wiring shutdown failed: %s',
|
||||
error instanceof Error ? (error.stack ?? error.message) : String(error),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
hookManager.dispose();
|
||||
} catch (error) {
|
||||
exitCode = 1;
|
||||
log.error(
|
||||
'hook shutdown failed: %s',
|
||||
error instanceof Error ? (error.stack ?? error.message) : String(error),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await bridgeManager.dispose();
|
||||
} catch (error) {
|
||||
exitCode = 1;
|
||||
log.error(
|
||||
'bridge shutdown failed: %s',
|
||||
error instanceof Error ? (error.stack ?? error.message) : String(error),
|
||||
);
|
||||
}
|
||||
|
||||
await closeLogger();
|
||||
process.exit(exitCode);
|
||||
};
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
void shutdown('SIGINT');
|
||||
});
|
||||
process.on('SIGTERM', () => {
|
||||
void shutdown('SIGTERM');
|
||||
});
|
||||
|
||||
if (process.send) {
|
||||
process.on('message', (message) => {
|
||||
if (message === 'shutdown') {
|
||||
void shutdown('launcher');
|
||||
}
|
||||
});
|
||||
process.on('disconnect', () => {
|
||||
void shutdown('launcher disconnected');
|
||||
});
|
||||
process.send('ready');
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAutoLoad(fromConfig: boolean | undefined): boolean {
|
||||
const envRaw = process.env.SNOWLUMA_HOOK_AUTOLOAD;
|
||||
|
||||
if (typeof envRaw === 'string' && envRaw.trim()) {
|
||||
const v = envRaw.trim().toLowerCase();
|
||||
|
||||
if (v === '1' || v === 'true' || v === 'yes' || v === 'on') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (v === '0' || v === 'false' || v === 'no' || v === 'off') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return fromConfig === true;
|
||||
}
|
||||
|
||||
if (isMessageStoreMigrationWorkerData(workerData)) {
|
||||
runMessageStoreMigrationWorker(workerData).catch(() => {
|
||||
process.exitCode = 1;
|
||||
});
|
||||
} else if (updateRoot && !process.env.SNOWLUMA_UPDATE_ROOT) {
|
||||
void runApplication(updateRoot);
|
||||
} else {
|
||||
installProcessGuards();
|
||||
main().catch(async (error) => {
|
||||
log.error(error instanceof Error ? (error.stack ?? error.message) : String(error));
|
||||
await closeLogger();
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
// Global channels are selected per account by OneBotConfig.notifications.channelIds.
|
||||
import { boolOr, clampInt, isObject } from '@snowluma/common/coerce';
|
||||
import { createLogger } from '@snowluma/common/logger';
|
||||
import {
|
||||
DEFAULT_WEBHOOK_BODY,
|
||||
DEFAULT_EMAIL_BODY,
|
||||
DEFAULT_EMAIL_SUBJECT,
|
||||
} from '@snowluma/common/notification-templates';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { extractAddress, parseRecipients } from './smtp';
|
||||
|
||||
const log = createLogger('Notifications.Config');
|
||||
|
||||
const CONFIG_DIR = 'config';
|
||||
const NOTIFICATIONS_CONFIG_PATH = path.join(CONFIG_DIR, 'notifications.json');
|
||||
|
||||
export const NOTIFICATIONS_CONFIG_VERSION = 1 as const;
|
||||
|
||||
export const DEBOUNCE_SECONDS_MIN = 0;
|
||||
export const DEBOUNCE_SECONDS_MAX = 3600;
|
||||
export const DEFAULT_DEBOUNCE_SECONDS = 30;
|
||||
|
||||
/** A channel id is a slug: it is referenced by per-UIN `channelIds`, so it must
|
||||
* be safe to use as a stable key. Kept in sync with `normalizeChannelIds` in
|
||||
* packages/onebot/src/config.ts (cross-package; can't share without a circular
|
||||
* dep — core depends on onebot, not the reverse). */
|
||||
export const CHANNEL_ID_RE = /^[\w.-]+$/;
|
||||
const CHANNEL_ID_MAX = 64;
|
||||
const CHANNEL_NAME_MAX = 128;
|
||||
const BODY_TEMPLATE_MAX = 8192;
|
||||
const HEADER_NAME_MAX = 128;
|
||||
const HEADER_VALUE_MAX = 1024;
|
||||
const HEADERS_MAX = 16;
|
||||
const HEADER_NAME_RE = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
||||
|
||||
/** event ∈ {offline, online}; rendered verbatim into `{event}`. */
|
||||
export type NotificationEvent = 'offline' | 'online';
|
||||
|
||||
export type NotificationChannelType = 'webhook' | 'email';
|
||||
|
||||
export interface NotificationChannel {
|
||||
id: string;
|
||||
name: string;
|
||||
type: NotificationChannelType;
|
||||
/** http(s) webhook target. Empty on email channels. */
|
||||
url: string;
|
||||
bodyTemplate: string;
|
||||
enabled: boolean;
|
||||
/** Extra outbound request headers (Authorization, etc.). Webhook only. */
|
||||
headers?: Record<string, string>;
|
||||
smtpHost?: string;
|
||||
smtpPort?: number;
|
||||
smtpSecure?: boolean;
|
||||
smtpUser?: string;
|
||||
smtpPass?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
subjectTemplate?: string;
|
||||
}
|
||||
|
||||
export function isEmailChannel(ch: NotificationChannel): ch is NotificationChannel & {
|
||||
smtpHost: string;
|
||||
smtpPort: number;
|
||||
smtpSecure: boolean;
|
||||
from: string;
|
||||
to: string;
|
||||
subjectTemplate: string;
|
||||
} {
|
||||
return ch.type === 'email';
|
||||
}
|
||||
|
||||
export interface NotificationsConfig {
|
||||
version: typeof NOTIFICATIONS_CONFIG_VERSION;
|
||||
debounceSeconds: number;
|
||||
channels: NotificationChannel[];
|
||||
}
|
||||
|
||||
const SMTP_HOST_MAX = 253;
|
||||
const SMTP_USER_MAX = 256;
|
||||
const SMTP_PASS_MAX = 256;
|
||||
const EMAIL_FIELD_MAX = 1024;
|
||||
const SUBJECT_TEMPLATE_MAX = 256;
|
||||
const SMTP_PORT_MIN = 1;
|
||||
const SMTP_PORT_MAX = 65535;
|
||||
|
||||
export function defaultNotificationsConfig(): NotificationsConfig {
|
||||
return {
|
||||
version: NOTIFICATIONS_CONFIG_VERSION,
|
||||
debounceSeconds: DEFAULT_DEBOUNCE_SECONDS,
|
||||
channels: [],
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Template renderer ──────────────────────────────────────────────────────
|
||||
|
||||
/** Preserve unknown placeholders; escape substituted strings in JSON templates. */
|
||||
export function renderTemplate(template: string, vars: Record<string, string>): string {
|
||||
if (typeof template !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
let isJson = false;
|
||||
|
||||
try {
|
||||
JSON.parse(template);
|
||||
isJson = true;
|
||||
} catch {
|
||||
// not JSON — plain text mode
|
||||
}
|
||||
|
||||
return template.replace(/\{(\w+)\}/g, (match, key: string) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(vars, key)) {
|
||||
return match;
|
||||
}
|
||||
|
||||
const val = vars[key];
|
||||
return isJson ? JSON.stringify(val).slice(1, -1) : val;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Normalization helpers ──────────────────────────────────────────────────
|
||||
// isObject / boolOr / clampInt come from @snowluma/common/coerce. strOr is
|
||||
// notifications-only (string truncation to maxLen), so it stays here.
|
||||
|
||||
function strOr(value: unknown, fallback: string, maxLen: number): string {
|
||||
return typeof value === 'string' ? value.slice(0, maxLen) : fallback;
|
||||
}
|
||||
|
||||
function normalizeChannelId(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const v = value.trim();
|
||||
|
||||
if (v.length === 0 || v.length > CHANNEL_ID_MAX) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!CHANNEL_ID_RE.test(v)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return v;
|
||||
}
|
||||
|
||||
function isHttpUrl(value: string): boolean {
|
||||
try {
|
||||
const u = new URL(value);
|
||||
return u.protocol === 'http:' || u.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHeaders(value: unknown): Record<string, string> | undefined {
|
||||
if (!isObject(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const out: Record<string, string> = {};
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const [rawName, rawValue] of Object.entries(value)) {
|
||||
if (Object.keys(out).length >= HEADERS_MAX) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (typeof rawValue !== 'string') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = rawName.trim();
|
||||
|
||||
if (name.length === 0 || name.length > HEADER_NAME_MAX) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!HEADER_NAME_RE.test(name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const folded = name.toLowerCase();
|
||||
|
||||
if (seen.has(folded)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const headerValue = rawValue.trim();
|
||||
|
||||
if (headerValue.length === 0 || headerValue.length > HEADER_VALUE_MAX) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (headerValue.includes('\r') || headerValue.includes('\n')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(folded);
|
||||
out[name] = headerValue;
|
||||
}
|
||||
|
||||
return Object.keys(out).length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
function normalizeSmtpHost(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const v = value.trim();
|
||||
|
||||
if (!v || v.length > SMTP_HOST_MAX) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (/\s/.test(v) || v.includes('/') || v.includes('://')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return v;
|
||||
}
|
||||
|
||||
function normalizeFrom(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const v = value.trim().slice(0, EMAIL_FIELD_MAX);
|
||||
return extractAddress(v) ? v : null;
|
||||
}
|
||||
|
||||
function normalizeTo(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const addrs = parseRecipients(value.trim().slice(0, EMAIL_FIELD_MAX));
|
||||
return addrs.length > 0 ? addrs.join(', ') : null;
|
||||
}
|
||||
|
||||
function normalizeChannelType(value: unknown): NotificationChannelType | null {
|
||||
if (value === 'email') {
|
||||
return 'email';
|
||||
}
|
||||
|
||||
if (value === 'webhook' || value === undefined || value === '') {
|
||||
return 'webhook';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeEmailChannel(
|
||||
raw: Record<string, unknown>,
|
||||
id: string,
|
||||
name: string,
|
||||
): NotificationChannel | null {
|
||||
const smtpHost = normalizeSmtpHost(raw.smtpHost);
|
||||
const from = normalizeFrom(raw.from);
|
||||
const to = normalizeTo(raw.to);
|
||||
|
||||
if (!smtpHost || !from || !to) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const portRaw = raw.smtpPort;
|
||||
const portProvided = portRaw !== undefined && portRaw !== null && portRaw !== '';
|
||||
const secureProvided = typeof raw.smtpSecure === 'boolean';
|
||||
const port = portProvided ? clampInt(portRaw, SMTP_PORT_MIN, SMTP_PORT_MAX, 0) : 0;
|
||||
|
||||
if (portProvided && port === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const smtpSecure = secureProvided ? Boolean(raw.smtpSecure) : port === 465 || port === 0;
|
||||
const smtpPort = port === 0 ? (smtpSecure ? 465 : 587) : port;
|
||||
|
||||
const smtpUser =
|
||||
typeof raw.smtpUser === 'string' ? raw.smtpUser.trim().slice(0, SMTP_USER_MAX) : '';
|
||||
const smtpPass = typeof raw.smtpPass === 'string' ? raw.smtpPass.slice(0, SMTP_PASS_MAX) : '';
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
type: 'email',
|
||||
url: '',
|
||||
bodyTemplate: strOr(raw.bodyTemplate, DEFAULT_EMAIL_BODY, BODY_TEMPLATE_MAX),
|
||||
enabled: boolOr(raw.enabled, true),
|
||||
smtpHost,
|
||||
smtpPort,
|
||||
smtpSecure,
|
||||
...(smtpUser ? { smtpUser } : {}),
|
||||
...(smtpPass ? { smtpPass } : {}),
|
||||
from,
|
||||
to,
|
||||
subjectTemplate:
|
||||
strOr(raw.subjectTemplate, DEFAULT_EMAIL_SUBJECT, SUBJECT_TEMPLATE_MAX) ||
|
||||
DEFAULT_EMAIL_SUBJECT,
|
||||
};
|
||||
}
|
||||
|
||||
/** A channel is usable only with a valid id AND a deliverable target —
|
||||
* http(s) URL for webhooks, SMTP host + from/to for email. Anything else
|
||||
* is dropped (total normalize). */
|
||||
function normalizeChannel(raw: unknown): NotificationChannel | null {
|
||||
if (!isObject(raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = normalizeChannelId(raw.id);
|
||||
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const type = normalizeChannelType(raw.type);
|
||||
|
||||
if (!type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = strOr(raw.name, id, CHANNEL_NAME_MAX).trim() || id;
|
||||
|
||||
if (type === 'email') {
|
||||
return normalizeEmailChannel(raw, id, name);
|
||||
}
|
||||
|
||||
const url = typeof raw.url === 'string' ? raw.url.trim() : '';
|
||||
|
||||
if (!isHttpUrl(url)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const headers = normalizeHeaders(raw.headers);
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
type: 'webhook',
|
||||
url,
|
||||
bodyTemplate: strOr(raw.bodyTemplate, DEFAULT_WEBHOOK_BODY, BODY_TEMPLATE_MAX),
|
||||
enabled: boolOr(raw.enabled, true),
|
||||
...(headers ? { headers } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeChannels(value: unknown): NotificationChannel[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const out: NotificationChannel[] = [];
|
||||
|
||||
for (const raw of value) {
|
||||
const ch = normalizeChannel(raw);
|
||||
|
||||
if (!ch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seen.has(ch.id)) {
|
||||
continue;
|
||||
} // dedupe by id — first occurrence wins
|
||||
|
||||
seen.add(ch.id);
|
||||
out.push(ch);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeNotificationsConfig(value: unknown): NotificationsConfig {
|
||||
const v = isObject(value) ? value : {};
|
||||
return {
|
||||
version: NOTIFICATIONS_CONFIG_VERSION,
|
||||
debounceSeconds: clampInt(
|
||||
v.debounceSeconds,
|
||||
DEBOUNCE_SECONDS_MIN,
|
||||
DEBOUNCE_SECONDS_MAX,
|
||||
DEFAULT_DEBOUNCE_SECONDS,
|
||||
),
|
||||
channels: normalizeChannels(v.channels),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Persistence (atomic write + module-level cache) ────────────────────────
|
||||
|
||||
function ensureConfigDir(): void {
|
||||
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function enforceSensitiveFileMode(file: string): void {
|
||||
// POSIX mode bits do not model Windows ACLs; on Windows the inherited ACL
|
||||
// remains authoritative. Avoid pretending chmod provides equivalent policy.
|
||||
if (process.platform !== 'win32') {
|
||||
fs.chmodSync(file, 0o600);
|
||||
}
|
||||
}
|
||||
|
||||
function atomicWrite(config: NotificationsConfig): void {
|
||||
ensureConfigDir();
|
||||
const tmp = NOTIFICATIONS_CONFIG_PATH + '.tmp';
|
||||
fs.writeFileSync(tmp, JSON.stringify(config, null, 2), {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
});
|
||||
enforceSensitiveFileMode(tmp);
|
||||
fs.renameSync(tmp, NOTIFICATIONS_CONFIG_PATH);
|
||||
enforceSensitiveFileMode(NOTIFICATIONS_CONFIG_PATH);
|
||||
}
|
||||
|
||||
let cached: NotificationsConfig | null = null;
|
||||
|
||||
/** Load + normalize the channel store, creating it from defaults if absent. */
|
||||
export function loadNotificationsConfig(): NotificationsConfig {
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
ensureConfigDir();
|
||||
|
||||
if (!fs.existsSync(NOTIFICATIONS_CONFIG_PATH)) {
|
||||
const fresh = defaultNotificationsConfig();
|
||||
|
||||
try {
|
||||
atomicWrite(fresh);
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
'failed to write initial notifications.json: %s',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
|
||||
cached = fresh;
|
||||
return fresh;
|
||||
}
|
||||
|
||||
// Webhook URLs commonly embed credentials. Repair older permissive files
|
||||
// before reading them into memory so upgrades close the exposure promptly.
|
||||
enforceSensitiveFileMode(NOTIFICATIONS_CONFIG_PATH);
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(NOTIFICATIONS_CONFIG_PATH, 'utf8');
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
const normalized = normalizeNotificationsConfig(parsed);
|
||||
|
||||
// Self-heal on disk only if normalization changed something (corrupt/old).
|
||||
if (JSON.stringify(parsed) !== JSON.stringify(normalized)) {
|
||||
try {
|
||||
atomicWrite(normalized);
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
'failed to normalize notifications.json on disk: %s',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
cached = normalized;
|
||||
return normalized;
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
'notifications.json unreadable; using defaults: %s',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
const fresh = defaultNotificationsConfig();
|
||||
cached = fresh;
|
||||
return fresh;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a (possibly partial) client-supplied config. A missing `channels` or
|
||||
* `debounceSeconds` keeps the current on-disk value — section-level merge, same
|
||||
* as `saveUiConfig`. Returns the stored, normalized config.
|
||||
*/
|
||||
export function saveNotificationsConfig(incoming: unknown): NotificationsConfig {
|
||||
const current = loadNotificationsConfig();
|
||||
const v = isObject(incoming) ? incoming : {};
|
||||
const next: NotificationsConfig = {
|
||||
version: NOTIFICATIONS_CONFIG_VERSION,
|
||||
debounceSeconds:
|
||||
v.debounceSeconds !== undefined
|
||||
? clampInt(
|
||||
v.debounceSeconds,
|
||||
DEBOUNCE_SECONDS_MIN,
|
||||
DEBOUNCE_SECONDS_MAX,
|
||||
DEFAULT_DEBOUNCE_SECONDS,
|
||||
)
|
||||
: current.debounceSeconds,
|
||||
channels: Array.isArray(v.channels) ? normalizeChannels(v.channels) : current.channels,
|
||||
};
|
||||
atomicWrite(next);
|
||||
cached = next;
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Offline must survive the debounce window before it can emit a recovery pair.
|
||||
// The manager owns timers and delivery; this machine only decides transitions.
|
||||
import type { NotificationEvent } from './config';
|
||||
|
||||
export type DebounceDecision =
|
||||
| { kind: 'none' }
|
||||
| { kind: 'schedule'; delayMs: number }
|
||||
| { kind: 'cancel' }
|
||||
| { kind: 'emit'; event: NotificationEvent };
|
||||
|
||||
export class DebounceMachine {
|
||||
private readonly states = new Map<string, 'pending' | 'offline'>();
|
||||
|
||||
/** Account went offline. `debounceSeconds` is read fresh from config so a
|
||||
* reload takes effect on the next transition. */
|
||||
onOffline(uin: string, debounceSeconds: number): DebounceDecision {
|
||||
if (this.states.has(uin)) {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
|
||||
if (!Number.isFinite(debounceSeconds) || debounceSeconds <= 0) {
|
||||
this.states.set(uin, 'offline');
|
||||
return { kind: 'emit', event: 'offline' };
|
||||
}
|
||||
|
||||
this.states.set(uin, 'pending');
|
||||
return { kind: 'schedule', delayMs: debounceSeconds * 1000 };
|
||||
}
|
||||
|
||||
/** Account came online. */
|
||||
onOnline(uin: string): DebounceDecision {
|
||||
const state = this.states.get(uin);
|
||||
this.states.delete(uin);
|
||||
|
||||
if (state === 'pending') {
|
||||
return { kind: 'cancel' };
|
||||
}
|
||||
|
||||
if (state === 'offline') {
|
||||
return { kind: 'emit', event: 'online' };
|
||||
}
|
||||
|
||||
return { kind: 'none' };
|
||||
}
|
||||
|
||||
/** The offline debounce window elapsed (the armed timer fired). */
|
||||
onTimerElapsed(uin: string): DebounceDecision {
|
||||
if (this.states.get(uin) !== 'pending') {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
|
||||
this.states.set(uin, 'offline');
|
||||
return { kind: 'emit', event: 'offline' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
// The global notification subsystem: one singleton watching ALL accounts.
|
||||
//
|
||||
// Subscribes to BridgeManager session online/offline edges, runs a per-UIN
|
||||
// debounce state machine (see debounce.ts), and on a fired transition renders
|
||||
// each opted-in + enabled channel's template and delivers it — recording the
|
||||
// outcome in a bounded in-memory history (lost on restart, by design).
|
||||
//
|
||||
// Side-effecting collaborators (config load, per-UIN channel ids, webhook POST,
|
||||
// SMTP send, the clock) are injected so the class is fully unit-testable; the
|
||||
// real wiring lives in `createNotificationManager()` at the bottom.
|
||||
import { positiveIntEnv } from '@snowluma/common/env';
|
||||
import { errorMessage } from '@snowluma/common/errors';
|
||||
import type { HookProcessInfo } from '@snowluma/bridge/types';
|
||||
import { createLogger } from '@snowluma/common/logger';
|
||||
import { loadOneBotConfig } from '@snowluma/onebot/config';
|
||||
import type { BridgeSessionSource } from '../bridge/bridge-interface';
|
||||
import {
|
||||
isEmailChannel,
|
||||
loadNotificationsConfig,
|
||||
renderTemplate,
|
||||
type NotificationChannel,
|
||||
type NotificationEvent,
|
||||
type NotificationsConfig,
|
||||
} from './config';
|
||||
import { DebounceMachine, type DebounceDecision } from './debounce';
|
||||
import { formatSmtpError, parseRecipients, sendSmtpMail } from './smtp';
|
||||
|
||||
const log = createLogger('Notifications');
|
||||
|
||||
const DEFAULT_HISTORY_LIMIT = 100;
|
||||
const DEFAULT_POST_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** One delivery attempt, retained in bounded in-memory history. */
|
||||
export interface DeliveryRecord {
|
||||
time: number;
|
||||
uin: string;
|
||||
event: NotificationEvent;
|
||||
channelId: string;
|
||||
ok: boolean;
|
||||
status?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PostResult {
|
||||
ok: boolean;
|
||||
status?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface MailPayload {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
user?: string;
|
||||
pass?: string;
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional hook process snapshot attached to a notification. Missing facts
|
||||
* mean unknown, not proof that QQ exited. Hook status describes connectivity;
|
||||
* it does not by itself identify the reason an account went offline.
|
||||
* Undefined fields render empty in templates.
|
||||
*/
|
||||
export type NotificationProcessFacts = Partial<Pick<HookProcessInfo, 'status' | 'pid' | 'error'>>;
|
||||
|
||||
export interface NotificationManagerDeps {
|
||||
/** Global channel store (channels + debounceSeconds). */
|
||||
loadConfig: () => NotificationsConfig;
|
||||
/** The channel ids a UIN has opted into. */
|
||||
loadChannelIds: (uin: string) => string[];
|
||||
/** Webhook delivery. Rejections are recorded as failed attempts. */
|
||||
post: (url: string, body: string, headers?: Record<string, string>) => Promise<PostResult>;
|
||||
/** SMTP delivery — optional so webhook-only tests stay lean. */
|
||||
sendMail?: (mail: MailPayload) => Promise<PostResult>;
|
||||
now: () => number;
|
||||
historyLimit?: number;
|
||||
/**
|
||||
* Optional: look up the lifecycle event's PID and UIN at dispatch time. Omitted
|
||||
* (or resolving to `undefined`) means "process unknown" — the fact
|
||||
* variables then render empty rather than leaving `{status}` in the body.
|
||||
*/
|
||||
resolveProcess?: (
|
||||
uin: string,
|
||||
pid: number | null,
|
||||
) => Promise<NotificationProcessFacts | undefined>;
|
||||
}
|
||||
|
||||
interface AccountNotificationContext {
|
||||
nickname: string;
|
||||
pid: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure: the channels a UIN should be notified through — globally enabled AND
|
||||
* opted into by the UIN — preserving config order. Exported for unit tests.
|
||||
*/
|
||||
export function selectChannels(
|
||||
channels: NotificationChannel[],
|
||||
enabledIds: string[],
|
||||
): NotificationChannel[] {
|
||||
const wanted = new Set(enabledIds);
|
||||
return channels.filter((c) => c.enabled && wanted.has(c.id));
|
||||
}
|
||||
|
||||
export class NotificationManager {
|
||||
private readonly deps: NotificationManagerDeps;
|
||||
private readonly machine = new DebounceMachine();
|
||||
private readonly timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
private readonly accounts = new Map<string, AccountNotificationContext>();
|
||||
private readonly history: DeliveryRecord[] = [];
|
||||
private readonly deliveries = new Map<string, Promise<void>>();
|
||||
private readonly historyLimit: number;
|
||||
private disposed = false;
|
||||
private unsubscribeSessions: (() => void) | null = null;
|
||||
|
||||
constructor(deps: NotificationManagerDeps) {
|
||||
this.deps = deps;
|
||||
this.historyLimit = deps.historyLimit ?? DEFAULT_HISTORY_LIMIT;
|
||||
}
|
||||
|
||||
/** Subscribe to the global session online/offline edges. Register AFTER
|
||||
* OneBotManager so the nickname fallback is already populated. */
|
||||
bind(bridgeManager: BridgeSessionSource): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.unsubscribeSessions?.();
|
||||
this.unsubscribeSessions = bridgeManager.subscribeSessions({
|
||||
started: (uin, bridge) =>
|
||||
this.handleOnline(uin, bridge.identity.nickname, bridge.activePid),
|
||||
closed: (uin, bridge, pid) => this.handleOffline(uin, bridge.identity.nickname, pid),
|
||||
});
|
||||
}
|
||||
|
||||
handleOnline(uin: string, nickname?: string, pid: number | null = null): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.accounts.set(uin, {
|
||||
nickname: nickname || this.accounts.get(uin)?.nickname || uin,
|
||||
pid,
|
||||
});
|
||||
|
||||
this.apply(uin, this.machine.onOnline(uin));
|
||||
}
|
||||
|
||||
handleOffline(uin: string, nickname?: string, pid: number | null = null): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
let debounceSeconds: number;
|
||||
|
||||
try {
|
||||
debounceSeconds = this.deps.loadConfig().debounceSeconds;
|
||||
} catch (err) {
|
||||
log.warn('offline(%s) skipped — config load failed: %s', uin, errorMessage(err));
|
||||
return;
|
||||
}
|
||||
|
||||
this.accounts.set(uin, {
|
||||
nickname: nickname || this.accounts.get(uin)?.nickname || uin,
|
||||
pid,
|
||||
});
|
||||
|
||||
this.apply(uin, this.machine.onOffline(uin, debounceSeconds));
|
||||
}
|
||||
|
||||
private apply(uin: string, decision: DebounceDecision): void {
|
||||
switch (decision.kind) {
|
||||
case 'schedule': {
|
||||
const timer = setTimeout(() => {
|
||||
this.timers.delete(uin);
|
||||
this.apply(uin, this.machine.onTimerElapsed(uin));
|
||||
}, decision.delayMs);
|
||||
|
||||
// Don't let a pending notification timer keep the process alive.
|
||||
timer.unref();
|
||||
this.timers.set(uin, timer);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'cancel': {
|
||||
clearTimeout(this.timers.get(uin));
|
||||
this.timers.delete(uin);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'emit':
|
||||
void this.notify(uin, decision.event);
|
||||
break;
|
||||
case 'none':
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** Queue one transition; channel failures are recorded independently. */
|
||||
notify(uin: string, event: NotificationEvent): Promise<void> {
|
||||
if (this.disposed) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const account = this.accounts.get(uin) ?? { nickname: uin, pid: null };
|
||||
const previous = this.deliveries.get(uin) ?? Promise.resolve();
|
||||
// Both enrichment and every channel delivery belong to this transition.
|
||||
// A slow lookup/send must not let recovery overtake offline for one UIN.
|
||||
const delivery = previous
|
||||
.then(() => this.notifyNow(uin, event, account))
|
||||
.finally(() => {
|
||||
if (this.deliveries.get(uin) === delivery) {
|
||||
this.deliveries.delete(uin);
|
||||
}
|
||||
});
|
||||
this.deliveries.set(uin, delivery);
|
||||
return delivery;
|
||||
}
|
||||
|
||||
private async notifyNow(
|
||||
uin: string,
|
||||
event: NotificationEvent,
|
||||
account: AccountNotificationContext,
|
||||
): Promise<void> {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
let channels: NotificationChannel[];
|
||||
|
||||
try {
|
||||
const cfg = this.deps.loadConfig();
|
||||
channels = selectChannels(cfg.channels, this.deps.loadChannelIds(uin));
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
'notify(%s, %s) skipped — config load failed: %s',
|
||||
uin,
|
||||
event,
|
||||
errorMessage(err),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (channels.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let facts: NotificationProcessFacts | undefined;
|
||||
|
||||
try {
|
||||
facts = await this.deps.resolveProcess?.(uin, account.pid);
|
||||
} catch (err) {
|
||||
log.warn('resolveProcess(%s, %s) failed: %s', uin, account.pid, errorMessage(err));
|
||||
}
|
||||
|
||||
const vars: Record<string, string> = {
|
||||
uin,
|
||||
nickname: account.nickname,
|
||||
event,
|
||||
time: new Date(this.deps.now()).toISOString(),
|
||||
status: facts?.status ?? '',
|
||||
pid: facts?.pid === undefined ? '' : String(facts.pid),
|
||||
error: facts?.error ?? '',
|
||||
};
|
||||
|
||||
for (const ch of channels) {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.deliver(ch, vars);
|
||||
this.record({
|
||||
time: this.deps.now(),
|
||||
uin,
|
||||
event,
|
||||
channelId: ch.id,
|
||||
ok: result.ok,
|
||||
status: result.status,
|
||||
error: result.ok
|
||||
? undefined
|
||||
: (result.error ??
|
||||
(result.status ? `HTTP ${result.status}` : 'delivery failed')),
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
log.warn(
|
||||
'notify %s→%s channel=%s failed: %s',
|
||||
uin,
|
||||
event,
|
||||
ch.id,
|
||||
result.error ?? `HTTP ${result.status ?? '?'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async deliver(
|
||||
ch: NotificationChannel,
|
||||
vars: Record<string, string>,
|
||||
): Promise<PostResult> {
|
||||
try {
|
||||
if (isEmailChannel(ch)) {
|
||||
const send = this.deps.sendMail;
|
||||
|
||||
if (!send) {
|
||||
return { ok: false, error: 'email delivery is not configured' };
|
||||
}
|
||||
|
||||
return await send({
|
||||
host: ch.smtpHost,
|
||||
port: ch.smtpPort,
|
||||
secure: ch.smtpSecure,
|
||||
user: ch.smtpUser,
|
||||
pass: ch.smtpPass,
|
||||
from: ch.from,
|
||||
to: ch.to,
|
||||
subject: renderTemplate(ch.subjectTemplate, vars),
|
||||
text: renderTemplate(ch.bodyTemplate, vars),
|
||||
});
|
||||
}
|
||||
|
||||
return await this.deps.post(ch.url, renderTemplate(ch.bodyTemplate, vars), ch.headers);
|
||||
} catch (err) {
|
||||
return { ok: false, error: errorMessage(err) };
|
||||
}
|
||||
}
|
||||
|
||||
/** Send a one-off test to a single channel by id — ignores `enabled` and the
|
||||
* per-UIN opt-in (you test a channel before wiring it up), with sample
|
||||
* variables. Reuses the real render+deliver path; NOT recorded to history.
|
||||
* `found:false` means the channel id is unknown. */
|
||||
async testSend(channelId: string): Promise<PostResult & { found: boolean }> {
|
||||
let channel: NotificationChannel | undefined;
|
||||
|
||||
try {
|
||||
channel = this.deps.loadConfig().channels.find((c) => c.id === channelId);
|
||||
} catch (err) {
|
||||
return { ok: false, found: false, error: errorMessage(err) };
|
||||
}
|
||||
|
||||
if (!channel) {
|
||||
return { ok: false, found: false };
|
||||
}
|
||||
|
||||
const vars = {
|
||||
uin: '10000',
|
||||
nickname: '测试账号',
|
||||
event: 'offline' as const,
|
||||
time: new Date(this.deps.now()).toISOString(),
|
||||
status: 'disconnected',
|
||||
pid: '1234',
|
||||
error: '',
|
||||
};
|
||||
return { ...(await this.deliver(channel, vars)), found: true };
|
||||
}
|
||||
|
||||
private record(rec: DeliveryRecord): void {
|
||||
this.history.push(rec);
|
||||
|
||||
if (this.history.length > this.historyLimit) {
|
||||
this.history.splice(0, this.history.length - this.historyLimit);
|
||||
}
|
||||
}
|
||||
|
||||
/** Most-recent-first delivery history (capped at the buffer size). */
|
||||
getRecent(limit = this.historyLimit): DeliveryRecord[] {
|
||||
const n = Math.max(0, Math.min(Math.trunc(limit) || 0, this.history.length));
|
||||
return this.history.slice(this.history.length - n).reverse();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.disposed = true;
|
||||
this.unsubscribeSessions?.();
|
||||
this.unsubscribeSessions = null;
|
||||
|
||||
for (const timer of this.timers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
this.timers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/** Default outbound POST: a single attempt with a timeout, JSON content-type
|
||||
* (the common case for 钉钉/Discord/飞书). Never throws. */
|
||||
export function createDefaultPost(
|
||||
timeoutMs = positiveIntEnv('SNOWLUMA_NOTIFICATION_HTTP_TIMEOUT_MS', DEFAULT_POST_TIMEOUT_MS),
|
||||
): NotificationManagerDeps['post'] {
|
||||
return async (url, body, headers) => {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body,
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
return {
|
||||
ok: res.ok,
|
||||
status: res.status,
|
||||
error: res.ok ? undefined : `HTTP ${res.status}`,
|
||||
};
|
||||
} catch (err) {
|
||||
return { ok: false, error: errorMessage(err) };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_SMTP_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** Default SMTP send. Never throws. */
|
||||
export function createDefaultSendMail(
|
||||
timeoutMs = DEFAULT_SMTP_TIMEOUT_MS,
|
||||
): NonNullable<NotificationManagerDeps['sendMail']> {
|
||||
return async (mail) => {
|
||||
try {
|
||||
await sendSmtpMail(
|
||||
{
|
||||
host: mail.host,
|
||||
port: mail.port,
|
||||
secure: mail.secure,
|
||||
user: mail.user,
|
||||
pass: mail.pass,
|
||||
from: mail.from,
|
||||
to: parseRecipients(mail.to),
|
||||
subject: mail.subject,
|
||||
body: mail.text,
|
||||
},
|
||||
{ timeoutMs },
|
||||
);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: formatSmtpError(err, mail.host, mail.port) };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** The real, fully-wired singleton. `resolveProcess` is supplied by the
|
||||
* runtime — this package deliberately knows nothing about HookManager. */
|
||||
export function createNotificationManager(
|
||||
resolveProcess?: NotificationManagerDeps['resolveProcess'],
|
||||
): NotificationManager {
|
||||
return new NotificationManager({
|
||||
loadConfig: loadNotificationsConfig,
|
||||
loadChannelIds: (uin) => loadOneBotConfig(uin).notifications?.channelIds ?? [],
|
||||
post: createDefaultPost(),
|
||||
sendMail: createDefaultSendMail(),
|
||||
now: () => Date.now(),
|
||||
resolveProcess,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
// Minimal SMTP client for notification-channel email. Runtime ships with no
|
||||
// npm dependencies, so this speaks the protocol over node:net / node:tls
|
||||
// (EHLO, optional STARTTLS, AUTH PLAIN/LOGIN, DATA). No extra features.
|
||||
|
||||
import net from 'node:net';
|
||||
import tls from 'node:tls';
|
||||
|
||||
const CRLF = '\r\n';
|
||||
const DEFAULT_TIMEOUT_MS = 15_000;
|
||||
|
||||
export class SmtpError extends Error {
|
||||
readonly responseCode?: number;
|
||||
readonly code?: string;
|
||||
|
||||
constructor(message: string, opts?: { responseCode?: number; code?: string }) {
|
||||
super(message);
|
||||
this.name = 'SmtpError';
|
||||
this.responseCode = opts?.responseCode;
|
||||
this.code = opts?.code;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SmtpMail {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
user?: string;
|
||||
pass?: string;
|
||||
from: string;
|
||||
to: string[];
|
||||
subject: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface SendSmtpOptions {
|
||||
timeoutMs?: number;
|
||||
/** Defaults true. Tests against a self-signed mock pass false. */
|
||||
rejectUnauthorized?: boolean;
|
||||
}
|
||||
|
||||
export function looksLikeEmail(value: string): boolean {
|
||||
return /^[^\s<>@]+@[^\s<>@]+$/.test(value);
|
||||
}
|
||||
|
||||
/** Address from `Name <user@host>` or a bare addr; null if neither is usable. */
|
||||
export function extractAddress(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const angled = /<([^<>]+)>/.exec(trimmed);
|
||||
const addr = (angled ? angled[1] : trimmed).trim();
|
||||
return looksLikeEmail(addr) ? addr : null;
|
||||
}
|
||||
|
||||
/** Split a comma / semicolon / newline list and keep unique addresses. */
|
||||
export function parseRecipients(value: string): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
|
||||
for (const part of value.split(/[,;\n]+/)) {
|
||||
const addr = extractAddress(part);
|
||||
|
||||
if (!addr) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = addr.toLowerCase();
|
||||
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(key);
|
||||
out.push(addr);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function encodeHeader(value: string): string {
|
||||
if (/^[\x20-\x7E]*$/.test(value) && !/[\r\n]/.test(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return `=?UTF-8?B?${Buffer.from(value, 'utf8').toString('base64')}?=`;
|
||||
}
|
||||
|
||||
function rfc5322Date(d: Date): string {
|
||||
return d.toUTCString().replace(/GMT$/, '+0000');
|
||||
}
|
||||
|
||||
function wrap76(b64: string): string {
|
||||
return b64.replace(/.{1,76}/g, (line) => `${line}\r\n`).trimEnd();
|
||||
}
|
||||
|
||||
function isHtmlBody(body: string): boolean {
|
||||
return /^\s*</.test(body);
|
||||
}
|
||||
|
||||
function dotStuff(payload: string): string {
|
||||
return payload.replace(/^\./gm, '..');
|
||||
}
|
||||
|
||||
/** Build a single-part MIME message (plain or HTML). Exported for tests. */
|
||||
export function buildMimeMessage(mail: SmtpMail, now = new Date()): string {
|
||||
const fromAddr = extractAddress(mail.from);
|
||||
const toHeader = mail.to.join(', ');
|
||||
const html = isHtmlBody(mail.body);
|
||||
const id = `<${now.getTime()}.${Math.random().toString(36).slice(2, 10)}@snowluma>`;
|
||||
const headers = [
|
||||
`From: ${mail.from.trim() || (fromAddr ? `<${fromAddr}>` : '')}`,
|
||||
`To: ${toHeader}`,
|
||||
`Subject: ${encodeHeader(mail.subject)}`,
|
||||
`Date: ${rfc5322Date(now)}`,
|
||||
`Message-ID: ${id}`,
|
||||
'MIME-Version: 1.0',
|
||||
`Content-Type: ${html ? 'text/html' : 'text/plain'}; charset=UTF-8`,
|
||||
'Content-Transfer-Encoding: base64',
|
||||
];
|
||||
const body = wrap76(Buffer.from(mail.body, 'utf8').toString('base64'));
|
||||
return `${headers.join(CRLF)}${CRLF}${CRLF}${body}${CRLF}`;
|
||||
}
|
||||
|
||||
interface SmtpReply {
|
||||
code: number;
|
||||
lines: string[];
|
||||
text: string;
|
||||
}
|
||||
|
||||
function consumeReply(buf: string): { reply: SmtpReply; rest: string } | null {
|
||||
let offset = 0;
|
||||
const lines: string[] = [];
|
||||
let code = 0;
|
||||
|
||||
while (offset < buf.length) {
|
||||
const nl = buf.indexOf('\n', offset);
|
||||
|
||||
if (nl === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const line = buf.slice(offset, nl).replace(/\r$/, '');
|
||||
offset = nl + 1;
|
||||
const m = /^(\d{3})([ -])(.*)$/.exec(line);
|
||||
|
||||
if (!m) {
|
||||
continue;
|
||||
}
|
||||
|
||||
code = Number(m[1]);
|
||||
lines.push(m[3]);
|
||||
|
||||
if (m[2] === ' ') {
|
||||
return { reply: { code, lines, text: lines.join('\n') }, rest: buf.slice(offset) };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
type SmtpSock = net.Socket | tls.TLSSocket;
|
||||
|
||||
function parseCapabilities(reply: SmtpReply): { starttls: boolean; auth: Set<string> } {
|
||||
const auth = new Set<string>();
|
||||
let starttls = false;
|
||||
|
||||
for (const line of reply.lines) {
|
||||
const u = line.toUpperCase();
|
||||
|
||||
if (u === 'STARTTLS' || u.startsWith('STARTTLS ')) {
|
||||
starttls = true;
|
||||
}
|
||||
|
||||
if (u.startsWith('AUTH ')) {
|
||||
for (const mech of u.slice(5).split(/\s+/)) {
|
||||
if (mech) {
|
||||
auth.add(mech);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { starttls, auth };
|
||||
}
|
||||
|
||||
function connectSocket(
|
||||
host: string,
|
||||
port: number,
|
||||
secure: boolean,
|
||||
timeoutMs: number,
|
||||
rejectUnauthorized: boolean,
|
||||
): Promise<SmtpSock> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sock: SmtpSock = secure
|
||||
? tls.connect({
|
||||
host,
|
||||
port,
|
||||
servername: net.isIP(host) === 0 ? host : undefined,
|
||||
rejectUnauthorized,
|
||||
})
|
||||
: net.connect({ host, port });
|
||||
|
||||
const fail = (err: Error) => {
|
||||
sock.destroy();
|
||||
reject(err);
|
||||
};
|
||||
|
||||
sock.setTimeout(timeoutMs);
|
||||
sock.once('timeout', () =>
|
||||
fail(new SmtpError('SMTP connection timed out', { code: 'ETIMEDOUT' })),
|
||||
);
|
||||
sock.once('error', fail);
|
||||
sock.once(secure ? 'secureConnect' : 'connect', () => {
|
||||
sock.removeAllListeners('timeout');
|
||||
sock.removeAllListeners('error');
|
||||
resolve(sock);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function upgradeTls(
|
||||
socket: net.Socket,
|
||||
host: string,
|
||||
timeoutMs: number,
|
||||
rejectUnauthorized: boolean,
|
||||
): Promise<tls.TLSSocket> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tlsSocket = tls.connect(
|
||||
{
|
||||
socket,
|
||||
servername: net.isIP(host) === 0 ? host : undefined,
|
||||
rejectUnauthorized,
|
||||
},
|
||||
() => resolve(tlsSocket),
|
||||
);
|
||||
tlsSocket.setTimeout(timeoutMs);
|
||||
tlsSocket.once('timeout', () => {
|
||||
tlsSocket.destroy();
|
||||
reject(new SmtpError('SMTP STARTTLS timed out', { code: 'ETIMEDOUT' }));
|
||||
});
|
||||
tlsSocket.once('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
class SmtpSession {
|
||||
private buf = '';
|
||||
|
||||
constructor(private socket: SmtpSock) {}
|
||||
|
||||
destroy(): void {
|
||||
this.socket.destroy();
|
||||
}
|
||||
|
||||
attachTimeout(timeoutMs: number, onTimeout: () => void): void {
|
||||
this.socket.setTimeout(timeoutMs);
|
||||
this.socket.once('timeout', onTimeout);
|
||||
}
|
||||
|
||||
async read(): Promise<SmtpReply> {
|
||||
const ready = consumeReply(this.buf);
|
||||
|
||||
if (ready) {
|
||||
this.buf = ready.rest;
|
||||
return ready.reply;
|
||||
}
|
||||
|
||||
return new Promise<SmtpReply>((resolve, reject) => {
|
||||
const onData = (chunk: Buffer) => {
|
||||
this.buf += chunk.toString('utf8');
|
||||
const got = consumeReply(this.buf);
|
||||
|
||||
if (!got) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.buf = got.rest;
|
||||
cleanup();
|
||||
resolve(got.reply);
|
||||
};
|
||||
|
||||
const onError = (err: Error) => {
|
||||
cleanup();
|
||||
reject(err);
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
cleanup();
|
||||
reject(new SmtpError('SMTP connection closed'));
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
this.socket.removeListener('data', onData);
|
||||
this.socket.removeListener('error', onError);
|
||||
this.socket.removeListener('close', onClose);
|
||||
};
|
||||
|
||||
this.socket.on('data', onData);
|
||||
this.socket.once('error', onError);
|
||||
this.socket.once('close', onClose);
|
||||
});
|
||||
}
|
||||
|
||||
write(data: string): void {
|
||||
this.socket.write(data);
|
||||
}
|
||||
|
||||
async command(line: string, ok: (code: number) => boolean, label?: string): Promise<SmtpReply> {
|
||||
this.write(line + CRLF);
|
||||
const reply = await this.read();
|
||||
|
||||
if (!ok(reply.code)) {
|
||||
const name = label ?? line.split(' ')[0] ?? 'SMTP';
|
||||
throw new SmtpError(`${name} failed: ${reply.code} ${reply.text}`.trim(), {
|
||||
responseCode: reply.code,
|
||||
});
|
||||
}
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
replaceSocket(next: SmtpSock): void {
|
||||
this.socket = next;
|
||||
this.buf = '';
|
||||
}
|
||||
}
|
||||
|
||||
export function formatSmtpError(err: unknown, host: string, port: number): string {
|
||||
if (err instanceof SmtpError) {
|
||||
if (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND' || err.code === 'ETIMEDOUT') {
|
||||
return `无法连接 SMTP:${host}:${port}`;
|
||||
}
|
||||
|
||||
if (err.responseCode === 535) {
|
||||
return 'SMTP 认证失败';
|
||||
}
|
||||
|
||||
return err.message;
|
||||
}
|
||||
|
||||
if (err instanceof Error) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
|
||||
if (code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'ETIMEDOUT') {
|
||||
return `无法连接 SMTP:${host}:${port}`;
|
||||
}
|
||||
|
||||
if (/self-signed|unable to verify/i.test(err.message)) {
|
||||
return `SMTP 证书不受信任:${host}`;
|
||||
}
|
||||
|
||||
return err.message || '邮件发送失败';
|
||||
}
|
||||
|
||||
return '邮件发送失败';
|
||||
}
|
||||
|
||||
export async function sendSmtpMail(mail: SmtpMail, opts: SendSmtpOptions = {}): Promise<void> {
|
||||
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const rejectUnauthorized = opts.rejectUnauthorized ?? true;
|
||||
const fromAddr = extractAddress(mail.from);
|
||||
|
||||
if (!fromAddr) {
|
||||
throw new SmtpError('invalid from address');
|
||||
}
|
||||
|
||||
if (mail.to.length === 0) {
|
||||
throw new SmtpError('no recipients');
|
||||
}
|
||||
|
||||
let sock = await connectSocket(
|
||||
mail.host,
|
||||
mail.port,
|
||||
mail.secure,
|
||||
timeoutMs,
|
||||
rejectUnauthorized,
|
||||
);
|
||||
const session = new SmtpSession(sock);
|
||||
let finished = false;
|
||||
|
||||
const failTimeout = () => {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
|
||||
session.destroy();
|
||||
};
|
||||
|
||||
session.attachTimeout(timeoutMs, failTimeout);
|
||||
|
||||
try {
|
||||
const greeting = await session.read();
|
||||
|
||||
if (greeting.code !== 220) {
|
||||
throw new SmtpError(`SMTP greeting failed: ${greeting.code} ${greeting.text}`.trim(), {
|
||||
responseCode: greeting.code,
|
||||
});
|
||||
}
|
||||
|
||||
const ehlo = async () => session.command('EHLO [127.0.0.1]', (c) => c === 250);
|
||||
let caps = parseCapabilities(await ehlo());
|
||||
|
||||
if (!mail.secure && caps.starttls) {
|
||||
await session.command('STARTTLS', (c) => c === 220);
|
||||
const upgraded = await upgradeTls(
|
||||
sock as net.Socket,
|
||||
mail.host,
|
||||
timeoutMs,
|
||||
rejectUnauthorized,
|
||||
);
|
||||
session.replaceSocket(upgraded);
|
||||
session.attachTimeout(timeoutMs, failTimeout);
|
||||
sock = upgraded;
|
||||
caps = parseCapabilities(await ehlo());
|
||||
}
|
||||
|
||||
const user = mail.user?.trim() ?? '';
|
||||
|
||||
if (user) {
|
||||
await authenticate(session, caps.auth, user, mail.pass ?? '');
|
||||
}
|
||||
|
||||
await session.command(`MAIL FROM:<${fromAddr}>`, (c) => c === 250);
|
||||
|
||||
for (const rcpt of mail.to) {
|
||||
await session.command(`RCPT TO:<${rcpt}>`, (c) => c === 250 || c === 251);
|
||||
}
|
||||
|
||||
await session.command('DATA', (c) => c === 354);
|
||||
session.write(dotStuff(buildMimeMessage(mail)));
|
||||
await session.command('.', (c) => c === 250, 'DATA');
|
||||
|
||||
try {
|
||||
await session.command('QUIT', (c) => c === 221);
|
||||
} catch {
|
||||
// Some relays drop the socket after 250; delivery already succeeded.
|
||||
}
|
||||
|
||||
finished = true;
|
||||
} catch (err) {
|
||||
finished = true;
|
||||
throw err;
|
||||
} finally {
|
||||
session.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticate(
|
||||
session: SmtpSession,
|
||||
mechs: Set<string>,
|
||||
user: string,
|
||||
pass: string,
|
||||
): Promise<void> {
|
||||
const hasPlain = mechs.has('PLAIN');
|
||||
const hasLogin = mechs.has('LOGIN');
|
||||
|
||||
if (hasPlain || !hasLogin) {
|
||||
try {
|
||||
const token = Buffer.from(`\0${user}\0${pass}`, 'utf8').toString('base64');
|
||||
await session.command(`AUTH PLAIN ${token}`, (c) => c === 235, 'AUTH');
|
||||
return;
|
||||
} catch (err) {
|
||||
if (hasPlain && !hasLogin) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (err instanceof SmtpError && err.responseCode === 535) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await loginAuth(session, user, pass);
|
||||
}
|
||||
|
||||
async function loginAuth(session: SmtpSession, user: string, pass: string): Promise<void> {
|
||||
await session.command('AUTH LOGIN', (c) => c === 334, 'AUTH');
|
||||
await session.command(Buffer.from(user, 'utf8').toString('base64'), (c) => c === 334, 'AUTH');
|
||||
await session.command(Buffer.from(pass, 'utf8').toString('base64'), (c) => c === 235, 'AUTH');
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { hasLowProcessIntegrity, type RuntimeIntegrity } from '@snowluma/bridge';
|
||||
import { createLogger } from '@snowluma/common/logger';
|
||||
|
||||
const log = createLogger('App');
|
||||
const LEVEL_NAMES: Record<number, string> = {
|
||||
0: 'Untrusted',
|
||||
4096: 'Low',
|
||||
8192: 'Medium',
|
||||
8448: 'Medium Plus',
|
||||
12288: 'High',
|
||||
16384: 'System',
|
||||
20480: 'Protected Process',
|
||||
};
|
||||
|
||||
function level(rid: number): string {
|
||||
return `${LEVEL_NAMES[rid] ?? 'Unknown'} (RID=${rid})`;
|
||||
}
|
||||
|
||||
/** Called once by main(), never by WebUI polling or warning dismissal. */
|
||||
export function logRuntimeIntegrity(integrity: RuntimeIntegrity): void {
|
||||
if (integrity.status === 'unsupported') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (integrity.status === 'unavailable') {
|
||||
log.warn('Node integrity: unknown (%s)', integrity.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = integrity.process;
|
||||
const file = integrity.file;
|
||||
const tokenText =
|
||||
token.status === 'ok'
|
||||
? level(token.rid)
|
||||
: `unknown (${token.operation}, Win32=${token.code})`;
|
||||
const fileText =
|
||||
file.status === 'error'
|
||||
? `unknown (${file.operation}, Win32=${file.code})`
|
||||
: file.label
|
||||
? `${level(file.label.rid)}, inherited=${file.label.inherited}, policy=${file.label.policy}`
|
||||
: 'unlabeled';
|
||||
// "token=" is reserved for credentials by the shared log redactor.
|
||||
const report = `Node integrity: pid=${integrity.pid}, process=${tokenText}; EXE=${fileText}`;
|
||||
|
||||
if (
|
||||
token.status === 'error' ||
|
||||
file.status === 'error' ||
|
||||
hasLowProcessIntegrity(integrity) ||
|
||||
(file.label && file.label.rid < 8192)
|
||||
) {
|
||||
log.warn(report);
|
||||
} else {
|
||||
log.info(report);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import path from 'node:path';
|
||||
import { createLogger } from '@snowluma/common/logger';
|
||||
import {
|
||||
SysFaceStore,
|
||||
sysFaceStore,
|
||||
type SysFaceCatalogStorage,
|
||||
} from '@snowluma/protocol/sys-face-store';
|
||||
import { JsonSysFaceCatalogStorage } from '@snowluma/protocol/sys-face-storage';
|
||||
import type { BridgeSessionSource } from './bridge/bridge-interface';
|
||||
|
||||
const log = createLogger('SysFace');
|
||||
|
||||
export interface SystemFaceCatalogBindingOptions {
|
||||
store?: SysFaceStore;
|
||||
storage?: SysFaceCatalogStorage;
|
||||
dataRoot?: string;
|
||||
}
|
||||
|
||||
/** Attach the shared system-face catalog to Bridge login edges. The first
|
||||
* account restores + refreshes it; later accounts reuse the same result. */
|
||||
export function bindSystemFaceCatalog(
|
||||
manager: BridgeSessionSource,
|
||||
options: SystemFaceCatalogBindingOptions = {},
|
||||
): void {
|
||||
const store = options.store ?? sysFaceStore;
|
||||
const storage =
|
||||
options.storage ??
|
||||
new JsonSysFaceCatalogStorage(
|
||||
path.join(options.dataRoot ?? 'data', 'sys-face-catalog.json'),
|
||||
);
|
||||
store.configureStorage(storage);
|
||||
|
||||
manager.subscribeSessions({
|
||||
started: (uin, bridge) => {
|
||||
void store.prewarm(bridge).catch((error) => {
|
||||
log.error(
|
||||
'system face catalog login prewarm failed: uin=%s error=%s',
|
||||
uin,
|
||||
error instanceof Error ? (error.stack ?? error.message) : String(error),
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { APP_VERSION } from '@snowluma/common/version';
|
||||
import { compareVersions, parseVersion } from '@snowluma/common/semver';
|
||||
import { createLogger } from '@snowluma/common/logger';
|
||||
import { createSingleFlightCache } from '@snowluma/common/single-flight-cache';
|
||||
import type { UpdateSourceConfig } from '@snowluma/common/update-source';
|
||||
import { fetchRelease } from './release';
|
||||
import { readUpdateSource } from './sources';
|
||||
|
||||
const log = createLogger('Update');
|
||||
|
||||
const CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6h — comfortably under the rate limit
|
||||
const FETCH_TIMEOUT_MS = 8_000;
|
||||
const NOTES_MAX = 4_000; // cap the release-body we hand back to the client
|
||||
|
||||
export interface UpdateCheckResult {
|
||||
/** The running build's version (no `v` prefix). */
|
||||
current: string;
|
||||
/** Latest stable release version, or null when the check did not complete. */
|
||||
latest: string | null;
|
||||
/** True only when `latest` is strictly newer than `current`. */
|
||||
hasUpdate: boolean;
|
||||
/** GitHub release page URL for the latest release. */
|
||||
htmlUrl: string | null;
|
||||
/** Release notes (markdown), truncated to {@link NOTES_MAX}. */
|
||||
notes: string | null;
|
||||
/** ISO timestamp the latest release was published. */
|
||||
publishedAt: string | null;
|
||||
/** When this result was produced (epoch ms). */
|
||||
checkedAt: number;
|
||||
/** Set when the check was skipped or failed; the UI degrades silently. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function isEnabled(): boolean {
|
||||
const v = (process.env.SNOWLUMA_UPDATE_CHECK ?? '').trim().toLowerCase();
|
||||
return v !== '0' && v !== 'false' && v !== 'off' && v !== 'no';
|
||||
}
|
||||
|
||||
// TTL + single-flight: cache good results for CACHE_TTL_MS (well under GitHub's
|
||||
// 60 req/hr limit) and collapse concurrent checks onto one fetch. Error results
|
||||
// are intentionally not cached (shouldCache) so the next check retries.
|
||||
const updateCache = createSingleFlightCache<string, UpdateCheckResult>({
|
||||
ttlMs: CACHE_TTL_MS,
|
||||
shouldCache: (r) => !r.error,
|
||||
load: async (key) => {
|
||||
// This key is constructed below from our version and normalized settings.
|
||||
const [current, source] = JSON.parse(key) as [string, UpdateSourceConfig];
|
||||
const result = await fetchLatest(current, source);
|
||||
|
||||
if (result.error) {
|
||||
log.debug('update check failed: %s', result.error);
|
||||
} else if (result.hasUpdate) {
|
||||
log.info('a newer release is available: v%s (running v%s)', result.latest, current);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
async function fetchLatest(
|
||||
current: string,
|
||||
source: UpdateSourceConfig,
|
||||
): Promise<UpdateCheckResult> {
|
||||
const base: UpdateCheckResult = {
|
||||
current,
|
||||
latest: null,
|
||||
hasUpdate: false,
|
||||
htmlUrl: null,
|
||||
notes: null,
|
||||
publishedAt: null,
|
||||
checkedAt: Date.now(),
|
||||
};
|
||||
|
||||
try {
|
||||
// `/releases/latest` already excludes drafts and prereleases, so this is
|
||||
// stable-channel by construction.
|
||||
const { data: rel } = await fetchRelease(
|
||||
undefined,
|
||||
source,
|
||||
`SnowLuma/${current}`,
|
||||
FETCH_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
if (!rel || typeof rel !== 'object' || Array.isArray(rel)) {
|
||||
return { ...base, error: 'invalid release info' };
|
||||
}
|
||||
|
||||
const tag =
|
||||
'tag_name' in rel && typeof rel.tag_name === 'string' ? rel.tag_name.trim() : '';
|
||||
|
||||
if (!tag) {
|
||||
return { ...base, error: 'no tag' };
|
||||
}
|
||||
|
||||
const latest = tag.startsWith('v') ? tag.slice(1) : tag;
|
||||
|
||||
if (!parseVersion(latest)) {
|
||||
return { ...base, error: 'invalid release version' };
|
||||
}
|
||||
|
||||
return {
|
||||
current,
|
||||
latest,
|
||||
hasUpdate: compareVersions(latest, current) > 0,
|
||||
htmlUrl: 'html_url' in rel && typeof rel.html_url === 'string' ? rel.html_url : null,
|
||||
notes:
|
||||
'body' in rel && typeof rel.body === 'string' && rel.body
|
||||
? rel.body.slice(0, NOTES_MAX)
|
||||
: null,
|
||||
publishedAt:
|
||||
'published_at' in rel && typeof rel.published_at === 'string'
|
||||
? rel.published_at
|
||||
: null,
|
||||
checkedAt: Date.now(),
|
||||
};
|
||||
} catch (e) {
|
||||
return { ...base, error: e instanceof Error ? e.message : 'network error' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the update-availability result. Cached for {@link CACHE_TTL_MS}; pass
|
||||
* `force` to bypass the cache (the WebUI's "立即检查" button). Never throws —
|
||||
* failures come back as a result with `error` set and `hasUpdate: false`, and
|
||||
* are not cached so the next check retries.
|
||||
*/
|
||||
export async function getUpdateInfo(
|
||||
force = false,
|
||||
source?: UpdateSourceConfig,
|
||||
): Promise<UpdateCheckResult> {
|
||||
const current = APP_VERSION;
|
||||
|
||||
if (!isEnabled()) {
|
||||
return { ...emptyResult(current), error: 'disabled' };
|
||||
}
|
||||
|
||||
try {
|
||||
return await updateCache.get(JSON.stringify([current, source ?? readUpdateSource()]), {
|
||||
force,
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
...emptyResult(current),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function emptyResult(current: string): UpdateCheckResult {
|
||||
return {
|
||||
current,
|
||||
latest: null,
|
||||
hasUpdate: false,
|
||||
htmlUrl: null,
|
||||
notes: null,
|
||||
publishedAt: null,
|
||||
checkedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import { mkdir, readFile, writeFile, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { Readable, Transform } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { unzipSync } from 'fflate';
|
||||
import { x as extractTar } from 'tar';
|
||||
import { isRuntimeEntry, readManifest } from './files';
|
||||
import { assetName, fetchRelease } from './release';
|
||||
export { assetName } from './release';
|
||||
import { readUpdateSource, sourceUrls } from './sources';
|
||||
import type { UpdateSourceConfig } from '@snowluma/common/update-source';
|
||||
|
||||
interface ReleaseAsset {
|
||||
url: string;
|
||||
size: number | null;
|
||||
digest: string;
|
||||
}
|
||||
|
||||
async function releaseAsset(
|
||||
version: string,
|
||||
target: string,
|
||||
source: UpdateSourceConfig,
|
||||
): Promise<ReleaseAsset> {
|
||||
const name = assetName(version, target);
|
||||
const { data: release, fromPage } = await fetchRelease(version, source);
|
||||
|
||||
if (
|
||||
!release ||
|
||||
typeof release !== 'object' ||
|
||||
!('assets' in release) ||
|
||||
!Array.isArray(release.assets)
|
||||
) {
|
||||
throw new Error('发布信息无效');
|
||||
}
|
||||
|
||||
const asset: unknown = release.assets.find(
|
||||
(item: unknown) =>
|
||||
item !== null && typeof item === 'object' && 'name' in item && item.name === name,
|
||||
);
|
||||
const url = `https://github.com/SnowLuma/SnowLuma/releases/download/v${version}/${name}`;
|
||||
|
||||
if (
|
||||
!asset ||
|
||||
typeof asset !== 'object' ||
|
||||
!('size' in asset) ||
|
||||
!(
|
||||
(fromPage && asset.size === null) ||
|
||||
(typeof asset.size === 'number' && Number.isSafeInteger(asset.size) && asset.size > 0)
|
||||
) ||
|
||||
!('digest' in asset) ||
|
||||
typeof asset.digest !== 'string' ||
|
||||
!/^sha256:[a-f0-9]{64}$/.test(asset.digest)
|
||||
) {
|
||||
throw new Error('更新包不存在或缺少校验信息');
|
||||
}
|
||||
|
||||
return { url, size: asset.size, digest: asset.digest };
|
||||
}
|
||||
|
||||
export function archivePath(name: string): string {
|
||||
const normalized = name.replaceAll('\\', '/').replace(/^\.\//, '').replace(/\/$/, '');
|
||||
|
||||
if (normalized === '.' || normalized === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parts = normalized.split('/');
|
||||
|
||||
if (
|
||||
!isRuntimeEntry(parts[0]) ||
|
||||
parts.some((part) => !part || part === '.' || part === '..' || /[:\0]/.test(part))
|
||||
) {
|
||||
throw new Error(`更新包路径无效: ${name}`);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function downloadUpdate(
|
||||
directory: string,
|
||||
version: string,
|
||||
target: string,
|
||||
source = readUpdateSource(),
|
||||
): Promise<void> {
|
||||
const asset = await releaseAsset(version, target, source);
|
||||
const archive = path.join(directory, 'archive');
|
||||
const urls = sourceUrls(asset.url, source);
|
||||
const failures: string[] = [];
|
||||
|
||||
for (const [index, url] of urls.entries()) {
|
||||
let created = false;
|
||||
|
||||
try {
|
||||
const headersTimeout = new AbortController();
|
||||
const timer = setTimeout(() => headersTimeout.abort(), 8_000);
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
signal: AbortSignal.any([
|
||||
headersTimeout.signal,
|
||||
AbortSignal.timeout(10 * 60_000),
|
||||
]),
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
await response.body?.cancel();
|
||||
throw new Error(`下载更新失败: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
if (/text\/html/i.test(response.headers.get('content-type') ?? '')) {
|
||||
await response.body.cancel();
|
||||
throw new Error('下载源返回了 HTML 页面,而不是更新包');
|
||||
}
|
||||
|
||||
const hash = createHash('sha256');
|
||||
const length = response.headers.get('content-length');
|
||||
const size =
|
||||
asset.size ?? (length === null ? null : /^\d+$/.test(length) ? Number(length) : 0);
|
||||
|
||||
if (size !== null && (!Number.isSafeInteger(size) || size <= 0)) {
|
||||
await response.body.cancel();
|
||||
throw new Error('下载源返回了无效的更新包大小');
|
||||
}
|
||||
|
||||
let received = 0;
|
||||
const output = createWriteStream(archive, { flags: 'wx' });
|
||||
output.once('open', () => {
|
||||
created = true;
|
||||
});
|
||||
await pipeline(
|
||||
Readable.fromWeb(response.body),
|
||||
new Transform({
|
||||
transform(chunk: Buffer, _encoding, callback) {
|
||||
received += chunk.length;
|
||||
|
||||
if (received > (size ?? 256 * 1024 * 1024)) {
|
||||
callback(new Error('更新包大小不匹配'));
|
||||
return;
|
||||
}
|
||||
|
||||
hash.update(chunk);
|
||||
callback(null, chunk);
|
||||
},
|
||||
}),
|
||||
output,
|
||||
);
|
||||
|
||||
if (
|
||||
(size !== null && received !== size) ||
|
||||
`sha256:${hash.digest('hex')}` !== asset.digest
|
||||
) {
|
||||
throw new Error('更新包校验失败');
|
||||
}
|
||||
|
||||
break;
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && 'code' in error && error.code === 'EEXIST') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
failures.push(error instanceof Error ? error.message : String(error));
|
||||
|
||||
if (index === urls.length - 1) {
|
||||
throw new Error([...new Set(failures)].join(';'));
|
||||
}
|
||||
|
||||
if (created) {
|
||||
await rm(archive);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const payload = path.join(directory, 'payload');
|
||||
await mkdir(payload);
|
||||
|
||||
if (target.startsWith('win32')) {
|
||||
const files = unzipSync(await readFile(archive), {
|
||||
filter: (entry) => {
|
||||
archivePath(entry.name);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
for (const [name, bytes] of Object.entries(files)) {
|
||||
const relative = archivePath(name);
|
||||
|
||||
if (!relative || name.endsWith('/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const destination = path.join(payload, relative);
|
||||
await mkdir(path.dirname(destination), { recursive: true });
|
||||
await writeFile(destination, bytes, { flag: 'wx' });
|
||||
}
|
||||
} else {
|
||||
let invalidEntry: Error | undefined;
|
||||
await extractTar({
|
||||
file: archive,
|
||||
cwd: payload,
|
||||
strict: true,
|
||||
filter: (name, entry) => {
|
||||
try {
|
||||
archivePath(name);
|
||||
|
||||
if (
|
||||
!('type' in entry) ||
|
||||
(entry.type !== 'File' && entry.type !== 'Directory')
|
||||
) {
|
||||
throw new Error('更新包包含链接或特殊文件');
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
invalidEntry = error instanceof Error ? error : new Error(String(error));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (invalidEntry) {
|
||||
throw invalidEntry;
|
||||
}
|
||||
}
|
||||
|
||||
const manifest = await readManifest(payload);
|
||||
|
||||
if (manifest.version !== version || manifest.target !== target) {
|
||||
throw new Error('更新包版本或平台不匹配');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { access, cp, lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { constants, existsSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { parseVersion } from '@snowluma/common/semver';
|
||||
|
||||
export const MANIFEST_FILE = 'release.json';
|
||||
|
||||
export async function updateDirectory(root: string, temporaryRoot = os.tmpdir()): Promise<string> {
|
||||
const local = path.join(root, 'tmp', 'update');
|
||||
|
||||
// A locally staged update must remain discoverable if system tmp becomes available again.
|
||||
if (existsSync(local)) {
|
||||
return local;
|
||||
}
|
||||
|
||||
const identity = createHash('sha256').update(path.resolve(root)).digest('hex').slice(0, 16);
|
||||
const directory = path.join(temporaryRoot, `snowluma-update-${identity}`);
|
||||
|
||||
try {
|
||||
await mkdir(directory, { recursive: true });
|
||||
await access(directory, constants.W_OK);
|
||||
return directory;
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof Error) ||
|
||||
!('code' in error) ||
|
||||
!['EACCES', 'EPERM', 'EROFS'].includes(String(error.code))
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await mkdir(local, { recursive: true });
|
||||
return local;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ReleaseManifest {
|
||||
version: string;
|
||||
target: string;
|
||||
entries: string[];
|
||||
}
|
||||
|
||||
const runtimeEntries = new Set([
|
||||
'native',
|
||||
'client',
|
||||
'package.json',
|
||||
MANIFEST_FILE,
|
||||
'launcher.bat',
|
||||
'launcher.sh',
|
||||
'check-node-version.cjs',
|
||||
'EULA.md',
|
||||
'PRIVACY.md',
|
||||
]);
|
||||
|
||||
export function isRuntimeEntry(name: string): boolean {
|
||||
return runtimeEntries.has(name) || /^[\w-]+\.mjs(?:\.map)?$/.test(name);
|
||||
}
|
||||
|
||||
function entryList(value: unknown): value is string[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.every((entry) => typeof entry === 'string' && isRuntimeEntry(entry)) &&
|
||||
new Set(value).size === value.length
|
||||
);
|
||||
}
|
||||
|
||||
export async function exists(file: string): Promise<boolean> {
|
||||
try {
|
||||
await lstat(file);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readManifest(root: string): Promise<ReleaseManifest> {
|
||||
const value: unknown = JSON.parse(await readFile(path.join(root, MANIFEST_FILE), 'utf8'));
|
||||
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
!('version' in value) ||
|
||||
typeof value.version !== 'string' ||
|
||||
!parseVersion(value.version) ||
|
||||
!('target' in value) ||
|
||||
typeof value.target !== 'string' ||
|
||||
!/^(win32-x64|linux-(x64|arm64))$/.test(value.target) ||
|
||||
!('entries' in value) ||
|
||||
!entryList(value.entries)
|
||||
) {
|
||||
throw new Error('更新包清单无效');
|
||||
}
|
||||
|
||||
const entries = value.entries;
|
||||
|
||||
if (
|
||||
![
|
||||
'index.mjs',
|
||||
'native',
|
||||
'client',
|
||||
'package.json',
|
||||
MANIFEST_FILE,
|
||||
'check-node-version.cjs',
|
||||
'EULA.md',
|
||||
'PRIVACY.md',
|
||||
value.target.startsWith('win32') ? 'launcher.bat' : 'launcher.sh',
|
||||
].every((entry) => entries.includes(entry))
|
||||
) {
|
||||
throw new Error('更新包清单无效');
|
||||
}
|
||||
|
||||
return { version: value.version, target: value.target, entries: value.entries };
|
||||
}
|
||||
|
||||
async function checkEntryTypes(root: string, entries: string[]): Promise<void> {
|
||||
for (const entry of entries) {
|
||||
const stat = await lstat(path.join(root, entry));
|
||||
const directory = entry === 'native' || entry === 'client';
|
||||
|
||||
if (directory ? !stat.isDirectory() : !stat.isFile()) {
|
||||
throw new Error(`更新包文件类型无效: ${entry}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface Transaction {
|
||||
entries: string[];
|
||||
original: string[];
|
||||
}
|
||||
|
||||
async function readTransaction(file: string): Promise<Transaction> {
|
||||
const value: unknown = JSON.parse(await readFile(file, 'utf8'));
|
||||
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
!('entries' in value) ||
|
||||
!entryList(value.entries) ||
|
||||
!('original' in value) ||
|
||||
!entryList(value.original)
|
||||
) {
|
||||
throw new Error('更新恢复记录无效');
|
||||
}
|
||||
|
||||
const entries = value.entries;
|
||||
|
||||
if (value.original.some((entry) => !entries.includes(entry))) {
|
||||
throw new Error('更新恢复记录无效');
|
||||
}
|
||||
|
||||
return { entries: value.entries, original: value.original };
|
||||
}
|
||||
|
||||
async function restore(root: string, pending: string, transaction: Transaction): Promise<void> {
|
||||
for (const entry of transaction.entries) {
|
||||
const backup = path.join(pending, 'backup', entry);
|
||||
const destination = path.join(root, entry);
|
||||
|
||||
if (transaction.original.includes(entry)) {
|
||||
await replaceEntry(backup, destination, path.join(root, '.update-install'));
|
||||
} else {
|
||||
await rm(destination, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
await rm(path.join(pending, 'transaction.json'));
|
||||
}
|
||||
|
||||
async function replaceEntry(source: string, destination: string, temporary: string): Promise<void> {
|
||||
await rm(temporary, { recursive: true, force: true });
|
||||
await cp(source, temporary, { recursive: true });
|
||||
|
||||
// Files stay present until their replacement is ready, including the entry point.
|
||||
if ((await lstat(source)).isDirectory()) {
|
||||
await rm(destination, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
await rename(temporary, destination);
|
||||
}
|
||||
|
||||
// Called before loading native libraries, after the previous process has exited.
|
||||
export async function applyPendingUpdate(root: string): Promise<string | null> {
|
||||
const directory = await updateDirectory(root);
|
||||
const pending = path.join(directory, 'pending');
|
||||
const completed = path.join(directory, 'completed');
|
||||
await rm(completed, { recursive: true, force: true });
|
||||
await rm(path.join(directory, 'downloading'), { recursive: true, force: true });
|
||||
await rm(path.join(root, '.update-install'), { recursive: true, force: true });
|
||||
|
||||
if (!(await exists(pending))) {
|
||||
await rm(directory, { recursive: true });
|
||||
return null;
|
||||
}
|
||||
|
||||
const journal = path.join(pending, 'transaction.json');
|
||||
|
||||
if (await exists(journal)) {
|
||||
await restore(root, pending, await readTransaction(journal));
|
||||
}
|
||||
|
||||
const payload = path.join(pending, 'payload');
|
||||
const next = await readManifest(payload);
|
||||
const current = await readManifest(root);
|
||||
|
||||
if (next.target !== current.target) {
|
||||
throw new Error('更新包平台不匹配');
|
||||
}
|
||||
|
||||
await checkEntryTypes(payload, next.entries);
|
||||
const entries = [...new Set([...current.entries, ...next.entries])];
|
||||
const original: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (await exists(path.join(root, entry))) {
|
||||
if ((await lstat(path.join(root, entry))).isSymbolicLink()) {
|
||||
throw new Error(`更新目标不能是符号链接: ${entry}`);
|
||||
}
|
||||
|
||||
original.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
const transaction = { entries, original };
|
||||
const backup = path.join(pending, 'backup');
|
||||
await rm(backup, { recursive: true, force: true });
|
||||
await mkdir(backup);
|
||||
|
||||
for (const entry of original) {
|
||||
await cp(path.join(root, entry), path.join(backup, entry), { recursive: true });
|
||||
}
|
||||
|
||||
await writeFile(`${journal}.tmp`, JSON.stringify(transaction));
|
||||
await rename(`${journal}.tmp`, journal);
|
||||
|
||||
try {
|
||||
for (const entry of next.entries) {
|
||||
await replaceEntry(
|
||||
path.join(payload, entry),
|
||||
path.join(root, entry),
|
||||
path.join(root, '.update-install'),
|
||||
);
|
||||
}
|
||||
|
||||
for (const entry of current.entries) {
|
||||
if (!next.entries.includes(entry)) {
|
||||
await rm(path.join(root, entry), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Renaming commits the transaction before cleanup, including across a crash.
|
||||
await rename(pending, completed);
|
||||
} catch (error) {
|
||||
try {
|
||||
await restore(root, pending, transaction);
|
||||
} catch (restoreError) {
|
||||
throw new AggregateError(
|
||||
[error, restoreError],
|
||||
'更新失败,恢复未完成;下次启动将继续恢复',
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
await rm(directory, { recursive: true });
|
||||
return next.version;
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { parseVersion } from '@snowluma/common/semver';
|
||||
import type { UpdateSourceConfig } from '@snowluma/common/update-source';
|
||||
import { RELEASE_ROOT, sourceUrls } from './sources';
|
||||
|
||||
export function assetName(version: string, target: string): string {
|
||||
const platform = target.replace('win32', 'win');
|
||||
return `SnowLuma-v${version}-${platform}-lite.${target.startsWith('win32') ? 'zip' : 'tar.gz'}`;
|
||||
}
|
||||
|
||||
async function readText(response: Response, limit: number): Promise<string> {
|
||||
if (!response.body) {
|
||||
throw new Error('发布信息为空');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
size += value.length;
|
||||
|
||||
if (size > limit) {
|
||||
await reader.cancel();
|
||||
throw new Error('发布信息过大');
|
||||
}
|
||||
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
|
||||
function attribute(tag: string, name: string): string | undefined {
|
||||
return new RegExp(`\\s${name}\\s*=\\s*(["'])(.*?)\\1`, 'i')
|
||||
.exec(tag)?.[2]
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll('"', '"');
|
||||
}
|
||||
|
||||
export function parseLatestReleasePage(html: string) {
|
||||
const meta = html
|
||||
.match(/<meta\b[^>]*>/gi)
|
||||
?.find((tag) => attribute(tag, 'property') === 'og:url');
|
||||
const canonical = meta && attribute(meta, 'content');
|
||||
|
||||
if (!canonical) {
|
||||
throw new Error('发布页面缺少版本信息');
|
||||
}
|
||||
|
||||
const url = new URL(canonical, 'https://github.com');
|
||||
const tag = /^\/SnowLuma\/SnowLuma\/releases\/tag\/([^/]+)$/.exec(url.pathname)?.[1];
|
||||
const decoded = tag && decodeURIComponent(tag);
|
||||
const version = decoded?.startsWith('v') ? decoded.slice(1) : undefined;
|
||||
const parsed = parseVersion(version);
|
||||
|
||||
if (url.origin !== 'https://github.com' || !parsed || parsed.prerelease.length) {
|
||||
throw new Error('发布页面不是 SnowLuma 正式版本');
|
||||
}
|
||||
|
||||
return { tag_name: `v${parsed.raw}`, html_url: `${RELEASE_ROOT}/tag/v${parsed.raw}` };
|
||||
}
|
||||
|
||||
/** Match the digest in the same asset row; never borrow a neighbouring asset's hash. */
|
||||
export function parseReleaseAssetsPage(html: string, version: string) {
|
||||
const assets: { name: string; size: null; digest: string }[] = [];
|
||||
const expected = ['win32-x64', 'linux-x64', 'linux-arm64'].map((target) =>
|
||||
assetName(version, target),
|
||||
);
|
||||
|
||||
for (const row of html.match(/<li\b[^>]*>[\s\S]*?<\/li>/gi) ?? []) {
|
||||
const links = row.match(/<a\b[^>]*>/gi) ?? [];
|
||||
const name = expected.find((candidate) =>
|
||||
links.some((link) => {
|
||||
const href = attribute(link, 'href');
|
||||
return (
|
||||
href === `/SnowLuma/SnowLuma/releases/download/v${version}/${candidate}` ||
|
||||
href === `${RELEASE_ROOT}/download/v${version}/${candidate}`
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const digests = (row.match(/<clipboard-copy\b[^>]*>/gi) ?? [])
|
||||
.map((tag) => attribute(tag, 'value'))
|
||||
.filter((value): value is string =>
|
||||
Boolean(value && /^sha256:[a-f0-9]{64}$/.test(value)),
|
||||
);
|
||||
|
||||
if (digests.length !== 1 || assets.some((asset) => asset.name === name)) {
|
||||
throw new Error('发布页面缺少唯一的文件校验信息');
|
||||
}
|
||||
|
||||
// GitHub renders a rounded size in HTML. Verify the exact digest even
|
||||
// when a proxy streams the asset without Content-Length.
|
||||
assets.push({ name, size: null, digest: digests[0]! });
|
||||
}
|
||||
|
||||
if (!assets.length) {
|
||||
throw new Error('发布页面没有可验证的更新包');
|
||||
}
|
||||
|
||||
return { tag_name: `v${version}`, assets };
|
||||
}
|
||||
|
||||
export interface ReleaseResult {
|
||||
data: unknown;
|
||||
fromPage: boolean;
|
||||
}
|
||||
|
||||
export async function fetchRelease(
|
||||
version: string | undefined,
|
||||
source: UpdateSourceConfig,
|
||||
userAgent = 'SnowLuma',
|
||||
timeoutMs = 8_000,
|
||||
): Promise<ReleaseResult> {
|
||||
const apiUrl = `https://api.github.com/repos/SnowLuma/SnowLuma/releases/${version ? `tags/v${version}` : 'latest'}`;
|
||||
let primaryError: unknown;
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl, {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'User-Agent': userAgent,
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
},
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new Error(`GitHub ${response.status}`);
|
||||
}
|
||||
|
||||
return {
|
||||
data: JSON.parse(await readText(response, 256 * 1024)) as unknown,
|
||||
fromPage: false,
|
||||
};
|
||||
} catch (error) {
|
||||
primaryError = error;
|
||||
}
|
||||
|
||||
const originalUrl = version
|
||||
? `${RELEASE_ROOT}/expanded_assets/v${version}`
|
||||
: `${RELEASE_ROOT}/latest`;
|
||||
const controller = new AbortController();
|
||||
|
||||
try {
|
||||
// Only a parsed release page can win. Fast errors and HTML interstitials
|
||||
// cannot cancel a slower working source.
|
||||
// Download proxies often only support assets. Always retain GitHub's
|
||||
// release page for version/digest lookup, even with a pinned download mirror.
|
||||
const urls = [...new Set([originalUrl, ...sourceUrls(originalUrl, source)])];
|
||||
const data = await Promise.any(
|
||||
urls.map(async (url) => {
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const html = await readText(response, 1024 * 1024);
|
||||
return version
|
||||
? parseReleaseAssetsPage(html, version)
|
||||
: parseLatestReleasePage(html);
|
||||
}),
|
||||
);
|
||||
return { data, fromPage: true };
|
||||
} catch (error) {
|
||||
const failures = error instanceof AggregateError ? error.errors : [error];
|
||||
const details = [primaryError, ...failures]
|
||||
.filter(Boolean)
|
||||
.map((failure: unknown) =>
|
||||
failure instanceof Error ? failure.message : String(failure),
|
||||
);
|
||||
throw new Error(`${[...new Set(details)].join(';')};请在更新来源中检测或指定可用镜像`);
|
||||
} finally {
|
||||
controller.abort();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, rename, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { createLogger } from '@snowluma/common/logger';
|
||||
import { parseVersion } from '@snowluma/common/semver';
|
||||
import { downloadUpdate } from './download';
|
||||
import { exists, MANIFEST_FILE, readManifest, updateDirectory } from './files';
|
||||
|
||||
const log = createLogger('Update');
|
||||
|
||||
export type UpdateState =
|
||||
| { phase: 'unavailable'; message: string }
|
||||
| { phase: 'idle' }
|
||||
| { phase: 'downloading' | 'ready' | 'restarting'; version: string }
|
||||
| { phase: 'error'; message: string };
|
||||
|
||||
export async function createUpdateService(
|
||||
root: string | undefined,
|
||||
current: string,
|
||||
options: { download?: typeof downloadUpdate; isContainer?: boolean } = {},
|
||||
) {
|
||||
const download = options.download ?? downloadUpdate;
|
||||
let state: UpdateState;
|
||||
let target = '';
|
||||
let staging = '';
|
||||
|
||||
if (!root || !existsSync(path.join(root, MANIFEST_FILE))) {
|
||||
state = { phase: 'unavailable', message: '在线更新仅支持发行包' };
|
||||
} else if (
|
||||
options.isContainer ??
|
||||
(existsSync('/.dockerenv') || Boolean(process.env.container))
|
||||
) {
|
||||
state = { phase: 'unavailable', message: '请更新容器镜像' };
|
||||
} else {
|
||||
const manifest = await readManifest(root);
|
||||
target = manifest.target;
|
||||
staging = await updateDirectory(root);
|
||||
const pending = path.join(staging, 'pending', 'payload');
|
||||
state = (await exists(pending))
|
||||
? { phase: 'ready', version: (await readManifest(pending)).version }
|
||||
: { phase: 'idle' };
|
||||
}
|
||||
|
||||
const prepare = async (version: string): Promise<void> => {
|
||||
const directory = path.join(staging, 'downloading');
|
||||
|
||||
try {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
await mkdir(directory, { recursive: true });
|
||||
await download(directory, version, target);
|
||||
await rm(path.join(directory, 'archive'));
|
||||
await rename(directory, path.join(staging, 'pending'));
|
||||
state = { phase: 'ready', version };
|
||||
log.info('v%s 已下载,重启后应用', version);
|
||||
} catch (error) {
|
||||
log.error('下载更新失败: %s', error);
|
||||
let message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
try {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
} catch (cleanupError) {
|
||||
message += `;清理更新文件失败: ${String(cleanupError)}`;
|
||||
log.error('清理更新文件失败: %s', cleanupError);
|
||||
}
|
||||
|
||||
state = { phase: 'error', message };
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
status: () => ({ current, ...state }),
|
||||
start(version: unknown) {
|
||||
if (state.phase !== 'idle' && state.phase !== 'error') {
|
||||
throw new Error(
|
||||
state.phase === 'unavailable' ? state.message : '更新正在进行或等待重启',
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = parseVersion(version);
|
||||
|
||||
if (!parsed || parsed.prerelease.length || parsed.compare(current) <= 0) {
|
||||
throw new Error('请选择更新的正式版本');
|
||||
}
|
||||
|
||||
state = { phase: 'downloading', version: parsed.raw };
|
||||
log.info('下载 v%s', parsed.raw);
|
||||
void prepare(parsed.raw);
|
||||
},
|
||||
restart() {
|
||||
if (state.phase !== 'ready') {
|
||||
throw new Error('更新包尚未就绪');
|
||||
}
|
||||
|
||||
state = { phase: 'restarting', version: state.version };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type UpdateService = Awaited<ReturnType<typeof createUpdateService>>;
|
||||
@@ -0,0 +1,125 @@
|
||||
import { APP_VERSION } from '@snowluma/common/version';
|
||||
import { readRuntimeConfig, updateRuntimeConfig } from '@snowluma/common/runtime';
|
||||
import {
|
||||
normalizeUpdateSourceConfig,
|
||||
type UpdateSourceConfig,
|
||||
} from '@snowluma/common/update-source';
|
||||
|
||||
export const RELEASE_ROOT = 'https://github.com/SnowLuma/SnowLuma/releases';
|
||||
export const DEFAULT_UPDATE_SOURCE: UpdateSourceConfig = {
|
||||
mode: 'auto',
|
||||
mirror: '',
|
||||
mirrors: [
|
||||
'https://ghfast.top',
|
||||
'https://ghproxy.net',
|
||||
'https://gh-proxy.com',
|
||||
'https://github.chenc.dev',
|
||||
'https://fastgit.cc',
|
||||
'https://gh.noki.icu',
|
||||
'https://cors.isteed.cc',
|
||||
'https://gh-proxy.org',
|
||||
'https://githubproxy.gohj99.site',
|
||||
'https://github.xxlab.tech',
|
||||
],
|
||||
};
|
||||
|
||||
export function readUpdateSource(): UpdateSourceConfig {
|
||||
return normalizeUpdateSourceConfig(readRuntimeConfig().updateSource ?? DEFAULT_UPDATE_SOURCE);
|
||||
}
|
||||
|
||||
export function saveUpdateSource(value: unknown): UpdateSourceConfig {
|
||||
const config = normalizeUpdateSourceConfig(value);
|
||||
updateRuntimeConfig({ updateSource: config });
|
||||
return config;
|
||||
}
|
||||
|
||||
export function sourceUrls(url: string, config: UpdateSourceConfig): string[] {
|
||||
if (config.mode === 'direct') {
|
||||
return [url];
|
||||
}
|
||||
|
||||
if (config.mode === 'mirror') {
|
||||
return [`${config.mirror}/${url}`];
|
||||
}
|
||||
|
||||
return [url, ...config.mirrors.map((mirror) => `${mirror}/${url}`)];
|
||||
}
|
||||
|
||||
export interface MirrorProbeResult {
|
||||
mirror: string;
|
||||
available: boolean;
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function testUpdateSources(value: unknown): Promise<MirrorProbeResult[]> {
|
||||
const config = normalizeUpdateSourceConfig(value);
|
||||
const mirrors = [
|
||||
...new Set(['', ...config.mirrors, ...(config.mirror ? [config.mirror] : [])]),
|
||||
];
|
||||
const target = process.platform === 'win32' ? 'win-x64' : `linux-${process.arch}`;
|
||||
const extension = process.platform === 'win32' ? 'zip' : 'tar.gz';
|
||||
const probeUrl = `${RELEASE_ROOT}/download/v${APP_VERSION}/SnowLuma-v${APP_VERSION}-${target}-lite.${extension}`;
|
||||
const results = await Promise.all(
|
||||
mirrors.map(async (mirror): Promise<MirrorProbeResult> => {
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const response = await fetch(mirror ? `${mirror}/${probeUrl}` : probeUrl, {
|
||||
headers: { Range: 'bytes=0-3' },
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
await response.body?.cancel();
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
if (/text\/html/i.test(response.headers.get('content-type') ?? '')) {
|
||||
await response.body.cancel();
|
||||
throw new Error('返回了 HTML 页面,而不是更新包');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const prefix: number[] = [];
|
||||
|
||||
try {
|
||||
while (prefix.length < 4) {
|
||||
const { value, done } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
prefix.push(...value.subarray(0, 4 - prefix.length));
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel();
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
const signature = extension === 'zip' ? [0x50, 0x4b, 0x03, 0x04] : [0x1f, 0x8b];
|
||||
|
||||
if (!signature.every((byte, index) => prefix[index] === byte)) {
|
||||
throw new Error('返回内容不是有效的更新包');
|
||||
}
|
||||
|
||||
return {
|
||||
mirror,
|
||||
available: true,
|
||||
latencyMs: Math.round(performance.now() - start),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
mirror,
|
||||
available: false,
|
||||
latencyMs: Math.round(performance.now() - start),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
return results.sort(
|
||||
(a, b) => Number(b.available) - Number(a.available) || a.latencyMs - b.latencyMs,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { applyPendingUpdate } from './files';
|
||||
|
||||
export const RESTART_EXIT_CODE = 75;
|
||||
|
||||
export async function runApplication(root: string): Promise<void> {
|
||||
let stopping = false;
|
||||
let child: ReturnType<typeof spawn> | undefined;
|
||||
|
||||
const requestShutdown = () => {
|
||||
if (child?.connected) {
|
||||
child.send('shutdown', (error) => {
|
||||
if (error) {
|
||||
console.error('[App] 发送退出请求失败:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
||||
process.on(signal, () => {
|
||||
stopping = true;
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
requestShutdown();
|
||||
} else {
|
||||
child?.kill(signal);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
do {
|
||||
const version = await applyPendingUpdate(root);
|
||||
|
||||
if (version) {
|
||||
console.info(`[Update] 已更新至 v${version}`);
|
||||
}
|
||||
|
||||
if (stopping) {
|
||||
break;
|
||||
}
|
||||
|
||||
const code = await new Promise<number>((resolve, reject) => {
|
||||
child = spawn(
|
||||
process.execPath,
|
||||
[...process.execArgv, path.join(root, 'index.mjs'), ...process.argv.slice(2)],
|
||||
{
|
||||
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
|
||||
env: { ...process.env, SNOWLUMA_UPDATE_ROOT: root },
|
||||
},
|
||||
);
|
||||
child.once('error', reject);
|
||||
child.on('message', (message) => {
|
||||
if (message === 'ready' && stopping) {
|
||||
requestShutdown();
|
||||
}
|
||||
});
|
||||
child.once('exit', (exitCode, signal) =>
|
||||
resolve(exitCode ?? (signal === 'SIGINT' ? 130 : 143)),
|
||||
);
|
||||
});
|
||||
child = undefined;
|
||||
|
||||
if (code !== RESTART_EXIT_CODE || stopping) {
|
||||
process.exitCode = code === RESTART_EXIT_CODE ? 0 : code;
|
||||
break;
|
||||
}
|
||||
} while (!stopping);
|
||||
} catch (error) {
|
||||
console.error('[Update] 启动失败:', error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
import { createLogger } from '@snowluma/common/logger';
|
||||
import { randomBytes, scryptSync, timingSafeEqual } from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import {
|
||||
parseTotpStateLenient,
|
||||
prepareTotpStateForRestore,
|
||||
rewrapTotpSecret,
|
||||
type TotpPersistedState,
|
||||
} from './totp';
|
||||
|
||||
const log = createLogger('WebUI.Auth');
|
||||
|
||||
const CONFIG_DIR = 'config';
|
||||
const WEBUI_CONFIG_PATH = path.join(CONFIG_DIR, 'webui.json');
|
||||
|
||||
const SCRYPT_KEYLEN = 64;
|
||||
const SCRYPT_N = 16384; // cost
|
||||
const SCRYPT_R = 8;
|
||||
const SCRYPT_P = 1;
|
||||
|
||||
const DEV_PASSWORD = 'snowluma-dev';
|
||||
|
||||
export function isDevAuthMode(): boolean {
|
||||
return process.env.SNOWLUMA_DEV_MODE === '1';
|
||||
}
|
||||
|
||||
function envBootstrapPassword(): string | null {
|
||||
const raw = process.env.SNOWLUMA_WEBUI_BOOTSTRAP_PASSWORD;
|
||||
|
||||
if (!raw || typeof raw !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (raw.length < 8) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
export interface WebuiAuthState {
|
||||
passwordHash: string; // hex
|
||||
passwordSalt: string; // hex
|
||||
mustChangePassword: boolean;
|
||||
generatedAt: string;
|
||||
updatedAt: string;
|
||||
totp?: TotpPersistedState;
|
||||
}
|
||||
|
||||
export interface PasswordRule {
|
||||
id: string;
|
||||
label: string;
|
||||
test: (pwd: string) => boolean;
|
||||
}
|
||||
|
||||
export const PASSWORD_RULES: PasswordRule[] = [
|
||||
{ id: 'length', label: '长度不少于 10 位', test: (p) => p.length >= 10 },
|
||||
{ id: 'lower', label: '包含小写字母', test: (p) => /[a-z]/.test(p) },
|
||||
{ id: 'upper', label: '包含大写字母', test: (p) => /[A-Z]/.test(p) },
|
||||
{ id: 'special', label: '包含特殊符号 (!@#$%…)', test: (p) => /[^A-Za-z0-9\s]/.test(p) },
|
||||
{ id: 'no-space', label: '不包含空格', test: (p) => !/\s/.test(p) && p.length > 0 },
|
||||
];
|
||||
|
||||
export function evaluatePasswordRules(
|
||||
password: string,
|
||||
): { id: string; label: string; ok: boolean }[] {
|
||||
return PASSWORD_RULES.map((r) => ({ id: r.id, label: r.label, ok: r.test(password) }));
|
||||
}
|
||||
|
||||
export function isStrongPassword(password: string): boolean {
|
||||
return PASSWORD_RULES.every((r) => r.test(password));
|
||||
}
|
||||
|
||||
function hashPassword(password: string, salt: Buffer): Buffer {
|
||||
return scryptSync(password, salt, SCRYPT_KEYLEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P });
|
||||
}
|
||||
|
||||
function ensureConfigDir(): void {
|
||||
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate credential state before it crosses a persistence boundary.
|
||||
*
|
||||
* The hash/salt lengths are part of the on-disk contract: accepting any hex
|
||||
* string here only postpones failure until password verification, where the
|
||||
* operator would be locked out after a restore.
|
||||
*/
|
||||
export function prepareWebuiAuthStateForRestore(value: unknown): WebuiAuthState {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('credential state must be an object');
|
||||
}
|
||||
|
||||
const v = value as Record<string, unknown>;
|
||||
|
||||
if (typeof v.passwordHash !== 'string' || !/^[0-9a-f]{128}$/i.test(v.passwordHash)) {
|
||||
throw new Error('passwordHash must be exactly 128 hexadecimal characters');
|
||||
}
|
||||
|
||||
if (typeof v.passwordSalt !== 'string' || !/^[0-9a-f]{32}$/i.test(v.passwordSalt)) {
|
||||
throw new Error('passwordSalt must be exactly 32 hexadecimal characters');
|
||||
}
|
||||
|
||||
if (typeof v.mustChangePassword !== 'boolean') {
|
||||
throw new Error('mustChangePassword must be a boolean');
|
||||
}
|
||||
|
||||
if (typeof v.generatedAt !== 'string' || !Number.isFinite(Date.parse(v.generatedAt))) {
|
||||
throw new Error('generatedAt must be a valid timestamp');
|
||||
}
|
||||
|
||||
if (typeof v.updatedAt !== 'string' || !Number.isFinite(Date.parse(v.updatedAt))) {
|
||||
throw new Error('updatedAt must be a valid timestamp');
|
||||
}
|
||||
|
||||
const allowed = new Set([
|
||||
'passwordHash',
|
||||
'passwordSalt',
|
||||
'mustChangePassword',
|
||||
'generatedAt',
|
||||
'updatedAt',
|
||||
'totp',
|
||||
]);
|
||||
const unknown = Object.keys(v).find((key) => !allowed.has(key));
|
||||
|
||||
if (unknown) {
|
||||
throw new Error(`unknown field $.${unknown}`);
|
||||
}
|
||||
|
||||
const state: WebuiAuthState = {
|
||||
passwordHash: v.passwordHash,
|
||||
passwordSalt: v.passwordSalt,
|
||||
mustChangePassword: v.mustChangePassword,
|
||||
generatedAt: v.generatedAt,
|
||||
updatedAt: v.updatedAt,
|
||||
};
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(v, 'totp') || v.totp === undefined) {
|
||||
return state;
|
||||
}
|
||||
|
||||
state.totp = prepareTotpStateForRestore(v.totp);
|
||||
return state;
|
||||
}
|
||||
|
||||
function parseLiveAuthState(value: unknown): WebuiAuthState | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const v = value as Record<string, unknown>;
|
||||
const totpValue = v.totp;
|
||||
const withoutTotp = { ...v };
|
||||
delete withoutTotp.totp;
|
||||
|
||||
try {
|
||||
const state = prepareWebuiAuthStateForRestore(
|
||||
Object.prototype.hasOwnProperty.call(v, 'totp') ? withoutTotp : value,
|
||||
);
|
||||
const totp = parseTotpStateLenient(totpValue);
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(v, 'totp') && totpValue !== undefined && !totp) {
|
||||
log.warn(
|
||||
'webui.json totp field is invalid and will be ignored; password credentials are unchanged',
|
||||
);
|
||||
}
|
||||
|
||||
if (totp) {
|
||||
state.totp = totp;
|
||||
}
|
||||
|
||||
return state;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function generateInitialState(initialPassword: string): WebuiAuthState {
|
||||
const salt = randomBytes(16);
|
||||
const hash = hashPassword(initialPassword, salt);
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
passwordHash: hash.toString('hex'),
|
||||
passwordSalt: salt.toString('hex'),
|
||||
mustChangePassword: true,
|
||||
generatedAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
function backupCorruptConfig(): void {
|
||||
try {
|
||||
const dest = `${WEBUI_CONFIG_PATH}.bak.${Date.now()}`;
|
||||
fs.renameSync(WEBUI_CONFIG_PATH, dest);
|
||||
log.warn('previous webui.json moved to %s', dest);
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
'failed to back up corrupt webui.json: %s',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function atomicWrite(state: WebuiAuthState): void {
|
||||
ensureConfigDir();
|
||||
const tmp = WEBUI_CONFIG_PATH + '.tmp';
|
||||
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), { encoding: 'utf8', mode: 0o600 });
|
||||
|
||||
try {
|
||||
fs.chmodSync(tmp, 0o600);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
fs.renameSync(tmp, WEBUI_CONFIG_PATH);
|
||||
}
|
||||
|
||||
export class WebuiAuth {
|
||||
private state: WebuiAuthState;
|
||||
private initialPlain: string | null;
|
||||
private readonly devMode: boolean;
|
||||
|
||||
private constructor(state: WebuiAuthState, initialPlain: string | null, devMode: boolean) {
|
||||
this.state = state;
|
||||
this.initialPlain = initialPlain;
|
||||
this.devMode = devMode;
|
||||
}
|
||||
|
||||
static load(): WebuiAuth {
|
||||
if (isDevAuthMode()) {
|
||||
const salt = randomBytes(16);
|
||||
const hash = hashPassword(DEV_PASSWORD, salt);
|
||||
const now = new Date().toISOString();
|
||||
const state: WebuiAuthState = {
|
||||
passwordHash: hash.toString('hex'),
|
||||
passwordSalt: salt.toString('hex'),
|
||||
mustChangePassword: false,
|
||||
generatedAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
return new WebuiAuth(state, null, true);
|
||||
}
|
||||
|
||||
ensureConfigDir();
|
||||
|
||||
if (fs.existsSync(WEBUI_CONFIG_PATH)) {
|
||||
try {
|
||||
const raw = fs.readFileSync(WEBUI_CONFIG_PATH, 'utf8');
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
const live = parseLiveAuthState(parsed);
|
||||
|
||||
if (live) {
|
||||
if (live.mustChangePassword) {
|
||||
// Previous start generated a bootstrap password but the operator
|
||||
// never completed the forced-change flow. The plaintext is gone
|
||||
// (only the hash is on disk, and the log line from last run may
|
||||
// be lost), so rotate to a fresh CSPRNG password they can see.
|
||||
const initialPassword = randomBytes(8).toString('hex');
|
||||
const state = generateInitialState(initialPassword);
|
||||
atomicWrite(state);
|
||||
log.warn(
|
||||
'previous bootstrap password was never rotated; regenerated a new one',
|
||||
);
|
||||
return new WebuiAuth(state, initialPassword, false);
|
||||
}
|
||||
|
||||
return new WebuiAuth(live, null, false);
|
||||
}
|
||||
|
||||
log.error('webui.json schema invalid; backing up and regenerating credentials');
|
||||
backupCorruptConfig();
|
||||
} catch (err) {
|
||||
log.error(
|
||||
'webui.json is corrupt and will be regenerated; the previous file is backed up: %s',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
backupCorruptConfig();
|
||||
}
|
||||
}
|
||||
|
||||
const envPassword = envBootstrapPassword();
|
||||
|
||||
if (envPassword !== null) {
|
||||
const salt = randomBytes(16);
|
||||
const hash = hashPassword(envPassword, salt);
|
||||
const now = new Date().toISOString();
|
||||
const state: WebuiAuthState = {
|
||||
passwordHash: hash.toString('hex'),
|
||||
passwordSalt: salt.toString('hex'),
|
||||
mustChangePassword: false,
|
||||
generatedAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
atomicWrite(state);
|
||||
log.info('webui credentials seeded from SNOWLUMA_WEBUI_BOOTSTRAP_PASSWORD');
|
||||
return new WebuiAuth(state, null, false);
|
||||
}
|
||||
|
||||
const initialPassword = randomBytes(8).toString('hex');
|
||||
const state = generateInitialState(initialPassword);
|
||||
atomicWrite(state);
|
||||
return new WebuiAuth(state, initialPassword, false);
|
||||
}
|
||||
|
||||
/** True when SNOWLUMA_DEV_MODE was active at load time. */
|
||||
isDevMode(): boolean {
|
||||
return this.devMode;
|
||||
}
|
||||
|
||||
/** Fixed dev password (only meaningful when {@link isDevMode} is true). */
|
||||
static get devPassword(): string {
|
||||
return DEV_PASSWORD;
|
||||
}
|
||||
|
||||
/** Returns the auto-generated initial password if this is a fresh install, else null. */
|
||||
takeInitialPassword(): string | null {
|
||||
const p = this.initialPlain;
|
||||
this.initialPlain = null;
|
||||
return p;
|
||||
}
|
||||
|
||||
mustChangePassword(): boolean {
|
||||
return this.state.mustChangePassword;
|
||||
}
|
||||
|
||||
verify(password: string): boolean {
|
||||
if (typeof password !== 'string' || password.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const salt = Buffer.from(this.state.passwordSalt, 'hex');
|
||||
const expected = Buffer.from(this.state.passwordHash, 'hex');
|
||||
const got = hashPassword(password, salt);
|
||||
|
||||
if (got.length !== expected.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return timingSafeEqual(got, expected);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
setPassword(newPassword: string, currentPassword?: string): void {
|
||||
if (this.devMode) {
|
||||
throw new Error('开发模式 (SNOWLUMA_DEV_MODE=1) 已禁用密码修改');
|
||||
}
|
||||
|
||||
if (!isStrongPassword(newPassword)) {
|
||||
throw new Error('密码不符合强度要求');
|
||||
}
|
||||
|
||||
let totp = this.state.totp;
|
||||
|
||||
if (totp) {
|
||||
if (typeof currentPassword !== 'string' || currentPassword.length === 0) {
|
||||
throw new Error('修改密码需要当前密码以重新保护 2FA 密钥');
|
||||
}
|
||||
|
||||
totp = rewrapTotpSecret(currentPassword, newPassword, totp);
|
||||
}
|
||||
|
||||
const salt = randomBytes(16);
|
||||
const hash = hashPassword(newPassword, salt);
|
||||
const next: WebuiAuthState = {
|
||||
passwordHash: hash.toString('hex'),
|
||||
passwordSalt: salt.toString('hex'),
|
||||
mustChangePassword: false,
|
||||
generatedAt: this.state.generatedAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
...(totp ? { totp } : {}),
|
||||
};
|
||||
atomicWrite(next);
|
||||
this.state = next;
|
||||
}
|
||||
|
||||
totpEnabled(): boolean {
|
||||
return this.state.totp !== undefined;
|
||||
}
|
||||
|
||||
totpStatus():
|
||||
{ enabled: false } | { enabled: true; remainingRecoveryCodes: number; label: string } {
|
||||
const totp = this.state.totp;
|
||||
|
||||
if (!totp) {
|
||||
return { enabled: false };
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
remainingRecoveryCodes: totp.recoveryCodeHashes.length,
|
||||
label: totp.label,
|
||||
};
|
||||
}
|
||||
|
||||
totpState(): TotpPersistedState | undefined {
|
||||
return this.state.totp;
|
||||
}
|
||||
|
||||
persistTotp(totp: TotpPersistedState | undefined): void {
|
||||
if (this.devMode) {
|
||||
throw new Error('开发模式 (SNOWLUMA_DEV_MODE=1) 已禁用 2FA');
|
||||
}
|
||||
|
||||
const next: WebuiAuthState = { ...this.state, updatedAt: new Date().toISOString() };
|
||||
|
||||
if (totp) {
|
||||
next.totp = totp;
|
||||
} else {
|
||||
delete next.totp;
|
||||
}
|
||||
|
||||
atomicWrite(next);
|
||||
this.state = next;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,806 @@
|
||||
// WebUI config backup/restore (Wave A2). Format: a zero-dep JSON bundle
|
||||
// { version, app, createdAt, files: { "<name>": { encoding, data } } }
|
||||
// over an explicit allowlist (never a whole-dir sweep) so a stray/unknown file
|
||||
// can't leak and an import can't path-traverse.
|
||||
//
|
||||
// Credentials are gated by a toggle on both export and import. The credential
|
||||
// set is: webui.json (password hash), key.pem (TLS private key),
|
||||
// notifications.json (webhook URLs / SMTP passwords), AND every OneBot config
|
||||
// (onebot.json + per-account onebot_<uin>.json) — those carry access tokens, so
|
||||
// a no-credentials backup must NOT include them.
|
||||
// cert.pem is public so it always travels.
|
||||
//
|
||||
// Pure functions here own bundle validation and semantic preflight. Filesystem
|
||||
// transaction choreography lives in restore.ts. A non-empty restore remains
|
||||
// restart-to-apply, like A1.
|
||||
|
||||
import { normalizeRuntimeConfig } from '@snowluma/common/runtime';
|
||||
import { isRealUin } from '@snowluma/common/uin';
|
||||
import { normalizeGlobalSettings } from '@snowluma/onebot/global-config';
|
||||
import { prepareOneBotConfigForRestore } from '@snowluma/onebot/config';
|
||||
import { normalizeNotificationsConfig } from '../notifications/config';
|
||||
import { prepareWebuiAuthStateForRestore } from './auth';
|
||||
import { MAX_BACKGROUND_BYTES, normalizeStoredUiConfig, sniffImageMime } from './ui-config';
|
||||
import { isValidBindHost } from './listener-exposure';
|
||||
import { validateTlsPair } from './tls';
|
||||
|
||||
export const BACKUP_VERSION = 1;
|
||||
export const BACKUP_APP = 'snowluma';
|
||||
export const MAX_BACKUP_DECODED_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
export interface BackupFileSpec {
|
||||
/** Path relative to the config dir; also the bundle key. */
|
||||
name: string;
|
||||
binary: boolean;
|
||||
/** Sensitive (private key / password hash / access token) — credential-gated. */
|
||||
credential: boolean;
|
||||
}
|
||||
|
||||
/** Static allowlist. Per-account `onebot_<uin>.json` are matched by pattern. */
|
||||
export const BACKUP_FILES: readonly BackupFileSpec[] = [
|
||||
{ name: 'runtime.json', binary: false, credential: false },
|
||||
{ name: 'ui.json', binary: false, credential: false },
|
||||
{ name: 'notifications.json', binary: false, credential: true },
|
||||
// Global all-accounts SnowLuma settings (rkey fallback servers, …).
|
||||
{ name: 'snowluma.json', binary: false, credential: false },
|
||||
{ name: 'cert.pem', binary: false, credential: false },
|
||||
{ name: 'ui-assets/background', binary: true, credential: false },
|
||||
{ name: 'webui.json', binary: false, credential: true },
|
||||
{ name: 'key.pem', binary: false, credential: true },
|
||||
// OneBot config carries the access token → credential.
|
||||
{ name: 'onebot.json', binary: false, credential: true },
|
||||
];
|
||||
|
||||
const SPEC_BY_NAME = new Map(BACKUP_FILES.map((f) => [f.name, f]));
|
||||
const PER_UIN_ONEBOT = /^onebot_(\d+)\.json$/;
|
||||
|
||||
function isPerUinOneBotName(name: string): boolean {
|
||||
const match = PER_UIN_ONEBOT.exec(name);
|
||||
return match !== null && isRealUin(match[1]);
|
||||
}
|
||||
|
||||
/** Resolve a file name (static or per-uin onebot pattern) to its spec, or null. */
|
||||
export function specFor(name: string): BackupFileSpec | null {
|
||||
const s = SPEC_BY_NAME.get(name);
|
||||
|
||||
if (s) {
|
||||
return s;
|
||||
}
|
||||
|
||||
if (isPerUinOneBotName(name)) {
|
||||
return { name, binary: false, credential: true };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface BackupEntry {
|
||||
encoding: 'utf8' | 'base64';
|
||||
data: string;
|
||||
}
|
||||
export interface Backup {
|
||||
version: number;
|
||||
app: string;
|
||||
createdAt?: string;
|
||||
files: Record<string, BackupEntry>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble a bundle from the allowlist plus any per-account onebot files.
|
||||
* `readFile` returns null for missing.
|
||||
*/
|
||||
export function buildBackup(
|
||||
readFile: (name: string) => Buffer | null,
|
||||
perUinOnebotNames: readonly string[],
|
||||
opts: { includeCredentials: boolean },
|
||||
createdAt: string,
|
||||
): Backup {
|
||||
const files: Record<string, BackupEntry> = {};
|
||||
const all: BackupFileSpec[] = [
|
||||
...BACKUP_FILES,
|
||||
...perUinOnebotNames
|
||||
.filter(isPerUinOneBotName)
|
||||
.map((n) => ({ name: n, binary: false, credential: true })),
|
||||
];
|
||||
|
||||
for (const spec of all) {
|
||||
if (spec.credential && !opts.includeCredentials) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const buf = readFile(spec.name);
|
||||
|
||||
if (!buf) {
|
||||
continue;
|
||||
}
|
||||
|
||||
files[spec.name] = spec.binary
|
||||
? { encoding: 'base64', data: buf.toString('base64') }
|
||||
: { encoding: 'utf8', data: buf.toString('utf8') };
|
||||
}
|
||||
|
||||
return { version: BACKUP_VERSION, app: BACKUP_APP, createdAt, files };
|
||||
}
|
||||
|
||||
function validateBackupEntries(
|
||||
parsed: unknown,
|
||||
skipEntryValidation: (spec: BackupFileSpec) => boolean,
|
||||
): { ok: true; backup: Backup } | { ok: false; error: string } {
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
return { ok: false, error: 'backup must be an object' };
|
||||
}
|
||||
|
||||
const b = parsed as Record<string, unknown>;
|
||||
|
||||
if (b.app !== BACKUP_APP) {
|
||||
return { ok: false, error: 'not a SnowLuma backup' };
|
||||
}
|
||||
|
||||
if (b.version !== BACKUP_VERSION) {
|
||||
return { ok: false, error: `unsupported backup version ${String(b.version)}` };
|
||||
}
|
||||
|
||||
if (typeof b.files !== 'object' || b.files === null || Array.isArray(b.files)) {
|
||||
return { ok: false, error: 'backup.files must be an object' };
|
||||
}
|
||||
|
||||
const files = b.files as Record<string, unknown>;
|
||||
|
||||
for (const [name, entry] of Object.entries(files)) {
|
||||
const spec = specFor(name);
|
||||
|
||||
if (!spec) {
|
||||
return { ok: false, error: `unknown file in backup: ${name}` };
|
||||
}
|
||||
|
||||
if (skipEntryValidation(spec)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof entry !== 'object' || entry === null) {
|
||||
return { ok: false, error: `malformed entry: ${name}` };
|
||||
}
|
||||
|
||||
const e = entry as Record<string, unknown>;
|
||||
|
||||
if (e.encoding !== 'utf8' && e.encoding !== 'base64') {
|
||||
return { ok: false, error: `bad encoding for ${name}` };
|
||||
}
|
||||
|
||||
if (typeof e.data !== 'string') {
|
||||
return { ok: false, error: `bad data for ${name}` };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
backup: {
|
||||
version: BACKUP_VERSION,
|
||||
app: BACKUP_APP,
|
||||
createdAt: typeof b.createdAt === 'string' ? b.createdAt : undefined,
|
||||
files: files as Record<string, BackupEntry>,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface PreparedRestoreFile {
|
||||
name: string;
|
||||
data: Buffer;
|
||||
mode: 0o600 | 0o644;
|
||||
}
|
||||
|
||||
export interface RestoreMigration {
|
||||
name: string;
|
||||
fields: string[];
|
||||
}
|
||||
|
||||
export interface PreparedRestorePlan {
|
||||
restore: PreparedRestoreFile[];
|
||||
skipped: string[];
|
||||
migrated: RestoreMigration[];
|
||||
}
|
||||
|
||||
export interface PrepareRestoreOptions {
|
||||
restoreCredentials: boolean;
|
||||
/** Read an existing live file for overlay-wide validation (TLS/UI). */
|
||||
readCurrent: (name: string) => Buffer | null;
|
||||
/** Current per-account configs affected when onebot.json is replaced. */
|
||||
listCurrentOneBotNames?: () => string[];
|
||||
}
|
||||
|
||||
export class RestorePreflightError extends Error {
|
||||
readonly file: string;
|
||||
|
||||
constructor(file: string, message: string, options?: { cause?: unknown }) {
|
||||
super(`${file}: ${message}`, options);
|
||||
this.name = 'RestorePreflightError';
|
||||
this.file = file;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate every selected file semantically and materialize canonical bytes
|
||||
* before the transaction is allowed to touch live configuration.
|
||||
*/
|
||||
export function prepareRestorePlan(
|
||||
parsed: unknown,
|
||||
opts: PrepareRestoreOptions,
|
||||
): PreparedRestorePlan {
|
||||
const checked = validateBackupEntries(
|
||||
parsed,
|
||||
(spec) => spec.credential && !opts.restoreCredentials,
|
||||
);
|
||||
|
||||
if (!checked.ok) {
|
||||
throw new RestorePreflightError('<backup>', checked.error);
|
||||
}
|
||||
|
||||
const restore: PreparedRestoreFile[] = [];
|
||||
const skipped: string[] = [];
|
||||
const migrated: RestoreMigration[] = [];
|
||||
const selected: Array<{ name: string; data: Buffer; spec: BackupFileSpec }> = [];
|
||||
let decodedBytes = 0;
|
||||
|
||||
for (const [name, entry] of Object.entries(checked.backup.files)) {
|
||||
const spec = specFor(name);
|
||||
|
||||
if (!spec) {
|
||||
throw new RestorePreflightError(name, 'file is not in the restore allowlist');
|
||||
}
|
||||
|
||||
if (spec.credential && !opts.restoreCredentials) {
|
||||
skipped.push(name);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.encoding !== (spec.binary ? 'base64' : 'utf8')) {
|
||||
throw new RestorePreflightError(
|
||||
name,
|
||||
`encoding must be ${spec.binary ? 'base64' : 'utf8'}`,
|
||||
);
|
||||
}
|
||||
|
||||
const decoded = decodeEntryStrict(name, entry);
|
||||
decodedBytes += decoded.length;
|
||||
|
||||
if (decodedBytes > MAX_BACKUP_DECODED_BYTES) {
|
||||
throw new RestorePreflightError(
|
||||
name,
|
||||
`selected files exceed ${String(MAX_BACKUP_DECODED_BYTES)} decoded bytes`,
|
||||
);
|
||||
}
|
||||
|
||||
selected.push({ name, data: decoded, spec });
|
||||
}
|
||||
|
||||
const selectedGlobal = selected.find((file) => file.name === 'onebot.json');
|
||||
const selectedPerUin = selected.filter((file) => isPerUinOneBotName(file.name));
|
||||
const selectedPerUinRaw = new Map(
|
||||
selectedPerUin.map((file) => [file.name, parseJson(file.name, file.data)]),
|
||||
);
|
||||
const perUinNeedsGlobal = [...selectedPerUinRaw.values()].some(
|
||||
(raw) => !isPlainObject(raw) || raw.mode !== 'snapshot',
|
||||
);
|
||||
let effectiveGlobalOneBot: unknown;
|
||||
let preparedGlobalOneBot: ReturnType<typeof prepareOneBotConfigForRestore> | null = null;
|
||||
|
||||
if (selectedGlobal) {
|
||||
if (!opts.listCurrentOneBotNames) {
|
||||
throw new RestorePreflightError(
|
||||
'onebot.json',
|
||||
'current per-account config listing is required to validate the effective overlay',
|
||||
);
|
||||
}
|
||||
|
||||
const raw = parseJson(selectedGlobal.name, selectedGlobal.data);
|
||||
preparedGlobalOneBot = prepareOneBotOrThrow(selectedGlobal.name, raw, 'global');
|
||||
effectiveGlobalOneBot = preparedGlobalOneBot.value;
|
||||
} else if (perUinNeedsGlobal) {
|
||||
const current = readCurrentForPreflight(opts, 'onebot.json');
|
||||
|
||||
if (current) {
|
||||
const raw = parseJson('onebot.json', current);
|
||||
effectiveGlobalOneBot = prepareOneBotOrThrow('onebot.json', raw, 'global').value;
|
||||
}
|
||||
}
|
||||
|
||||
for (const { name, data, spec } of selected) {
|
||||
let prepared: { data: Buffer; migratedFields: string[] };
|
||||
|
||||
if (name === 'onebot.json') {
|
||||
const global = preparedGlobalOneBot!;
|
||||
prepared = { data: serializeJson(global.value), migratedFields: global.migratedFields };
|
||||
} else if (isPerUinOneBotName(name)) {
|
||||
const raw = selectedPerUinRaw.get(name);
|
||||
const onebot = prepareOneBotOrThrow(name, raw, 'per-uin', effectiveGlobalOneBot);
|
||||
prepared = { data: serializeJson(onebot.value), migratedFields: onebot.migratedFields };
|
||||
} else {
|
||||
prepared = prepareFile(name, data);
|
||||
}
|
||||
|
||||
restore.push({ name, data: prepared.data, mode: spec.credential ? 0o600 : 0o644 });
|
||||
|
||||
if (prepared.migratedFields.length > 0) {
|
||||
migrated.push({ name, fields: [...prepared.migratedFields].sort() });
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedGlobal) {
|
||||
const selectedNames = new Set(selected.map((file) => file.name));
|
||||
let currentNames: string[];
|
||||
|
||||
try {
|
||||
currentNames = opts.listCurrentOneBotNames!();
|
||||
} catch (error) {
|
||||
throw new RestorePreflightError(
|
||||
'onebot.json',
|
||||
`failed to list current per-account configs: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
for (const name of currentNames) {
|
||||
if (!isPerUinOneBotName(name) || selectedNames.has(name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const current = readCurrentForPreflight(opts, name);
|
||||
|
||||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const raw = parseJson(name, current);
|
||||
prepareOneBotOrThrow(name, raw, 'per-uin', effectiveGlobalOneBot);
|
||||
}
|
||||
}
|
||||
|
||||
validateEffectiveTls(restore, opts);
|
||||
validateEffectiveUi(restore, opts);
|
||||
return { restore, skipped, migrated };
|
||||
}
|
||||
|
||||
function decodeEntryStrict(name: string, entry: BackupEntry): Buffer {
|
||||
if (entry.encoding === 'utf8') {
|
||||
return Buffer.from(entry.data, 'utf8');
|
||||
}
|
||||
|
||||
if (!isCanonicalBase64(entry.data)) {
|
||||
throw new RestorePreflightError(name, 'data is not canonical base64');
|
||||
}
|
||||
|
||||
return Buffer.from(entry.data, 'base64');
|
||||
}
|
||||
|
||||
function isCanonicalBase64(value: string): boolean {
|
||||
if (value.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
value.length % 4 !== 0 ||
|
||||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Buffer.from(value, 'base64').toString('base64') === value;
|
||||
}
|
||||
|
||||
function prepareFile(name: string, data: Buffer): { data: Buffer; migratedFields: string[] } {
|
||||
try {
|
||||
switch (name) {
|
||||
case 'runtime.json':
|
||||
return prepareRuntimeJson(name, data);
|
||||
case 'ui.json':
|
||||
return prepareNormalizedJson(name, data, normalizeStoredUiConfig);
|
||||
case 'notifications.json':
|
||||
return prepareNormalizedJson(name, data, normalizeNotificationsConfig);
|
||||
case 'snowluma.json':
|
||||
return prepareGlobalSettingsJson(name, data);
|
||||
|
||||
case 'webui.json': {
|
||||
const value = parseJson(name, data);
|
||||
const canonical = prepareWebuiAuthStateForRestore(value);
|
||||
return { data: serializeJson(canonical), migratedFields: [] };
|
||||
}
|
||||
|
||||
case 'ui-assets/background':
|
||||
if (data.length > MAX_BACKGROUND_BYTES) {
|
||||
throw new Error(`image exceeds ${String(MAX_BACKGROUND_BYTES)} bytes`);
|
||||
}
|
||||
|
||||
if (!sniffImageMime(data)) {
|
||||
throw new Error('image must be PNG, JPEG, or WebP');
|
||||
}
|
||||
|
||||
return { data, migratedFields: [] };
|
||||
case 'cert.pem':
|
||||
case 'key.pem':
|
||||
if (!data.toString('utf8').trim()) {
|
||||
throw new Error('PEM file must not be empty');
|
||||
}
|
||||
|
||||
return { data, migratedFields: [] };
|
||||
default:
|
||||
throw new Error('file is not in the restore allowlist');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof RestorePreflightError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new RestorePreflightError(
|
||||
name,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
{
|
||||
cause: error,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function prepareOneBotOrThrow(
|
||||
name: string,
|
||||
value: unknown,
|
||||
scope: 'global' | 'per-uin',
|
||||
inheritedGlobal?: unknown,
|
||||
): ReturnType<typeof prepareOneBotConfigForRestore> {
|
||||
try {
|
||||
return prepareOneBotConfigForRestore(value, scope, inheritedGlobal);
|
||||
} catch (error) {
|
||||
throw new RestorePreflightError(
|
||||
name,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
{
|
||||
cause: error,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function readCurrentForPreflight(opts: PrepareRestoreOptions, name: string): Buffer | null {
|
||||
try {
|
||||
return opts.readCurrent(name);
|
||||
} catch (error) {
|
||||
throw new RestorePreflightError(
|
||||
name,
|
||||
`failed to read current file: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseJson(name: string, data: Buffer): unknown {
|
||||
try {
|
||||
return JSON.parse(data.toString('utf8')) as unknown;
|
||||
} catch (error) {
|
||||
// V8 may quote the offending input in SyntaxError.message. Some restored
|
||||
// JSON files contain credentials, so expose only a fixed diagnostic while
|
||||
// retaining the original exception as the in-process cause.
|
||||
throw new RestorePreflightError(name, 'invalid JSON', { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
function prepareNormalizedJson<T>(
|
||||
name: string,
|
||||
data: Buffer,
|
||||
normalize: (value: unknown) => T,
|
||||
): {
|
||||
data: Buffer;
|
||||
migratedFields: string[];
|
||||
} {
|
||||
const raw = parseJson(name, data);
|
||||
const canonical = normalize(raw);
|
||||
const migratedFields: string[] = [];
|
||||
assertNoLossyNormalization(name, raw, canonical, '$', migratedFields);
|
||||
return { data: serializeJson(canonical), migratedFields };
|
||||
}
|
||||
|
||||
function prepareRuntimeJson(
|
||||
name: string,
|
||||
data: Buffer,
|
||||
): {
|
||||
data: Buffer;
|
||||
migratedFields: string[];
|
||||
} {
|
||||
const prepared = prepareNormalizedJson(name, data, normalizeRuntimeConfig);
|
||||
const runtime = JSON.parse(prepared.data.toString('utf8')) as ReturnType<
|
||||
typeof normalizeRuntimeConfig
|
||||
>;
|
||||
|
||||
if (!runtime.webuiHost || !isValidBindHost(runtime.webuiHost)) {
|
||||
throw new RestorePreflightError(name, '$.webuiHost is not a valid TCP bind host');
|
||||
}
|
||||
|
||||
return prepared;
|
||||
}
|
||||
|
||||
function prepareGlobalSettingsJson(
|
||||
name: string,
|
||||
data: Buffer,
|
||||
): {
|
||||
data: Buffer;
|
||||
migratedFields: string[];
|
||||
} {
|
||||
const raw = parseJson(name, data);
|
||||
const normalized = normalizeGlobalSettings(raw);
|
||||
const canonical: Record<string, unknown> = { rkey: normalized.rkey };
|
||||
|
||||
if (isPlainObject(raw) && Object.prototype.hasOwnProperty.call(raw, 'videoUploadConcurrency')) {
|
||||
canonical.videoUploadConcurrency = normalized.videoUploadConcurrency;
|
||||
}
|
||||
|
||||
// Missing is the existing one-shot migration sentinel: startup scans legacy
|
||||
// onebot*.json only until snowluma.json first carries this key.
|
||||
if (isPlainObject(raw) && Object.prototype.hasOwnProperty.call(raw, 'musicSignUrl')) {
|
||||
canonical.musicSignUrl = normalized.musicSignUrl;
|
||||
}
|
||||
|
||||
const migratedFields: string[] = [];
|
||||
assertNoLossyNormalization(name, raw, canonical, '$', migratedFields);
|
||||
return { data: serializeJson(canonical), migratedFields };
|
||||
}
|
||||
|
||||
function assertNoLossyNormalization(
|
||||
name: string,
|
||||
raw: unknown,
|
||||
canonical: unknown,
|
||||
at: string,
|
||||
additions: string[],
|
||||
): void {
|
||||
if (isPlainObject(raw)) {
|
||||
if (!isPlainObject(canonical)) {
|
||||
throw new RestorePreflightError(name, `${at} has an invalid type`);
|
||||
}
|
||||
|
||||
for (const [key, rawValue] of Object.entries(raw)) {
|
||||
const child = `${at}.${key}`;
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(canonical, key)) {
|
||||
throw new RestorePreflightError(
|
||||
name,
|
||||
`${child} is unsupported or would be discarded`,
|
||||
);
|
||||
}
|
||||
|
||||
assertNoLossyNormalization(name, rawValue, canonical[key], child, additions);
|
||||
}
|
||||
|
||||
for (const key of Object.keys(canonical)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(raw, key)) {
|
||||
additions.push(`${at}.${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(raw)) {
|
||||
if (!Array.isArray(canonical) || raw.length !== canonical.length) {
|
||||
throw new RestorePreflightError(
|
||||
name,
|
||||
`${at} contains an invalid, duplicate, or discarded item`,
|
||||
);
|
||||
}
|
||||
|
||||
raw.forEach((value, index) => {
|
||||
assertNoLossyNormalization(
|
||||
name,
|
||||
value,
|
||||
canonical[index],
|
||||
`${at}[${String(index)}]`,
|
||||
additions,
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Object.is(raw, canonical)) {
|
||||
if (isKnownLosslessScalarCoercion(name, at, raw, canonical)) {
|
||||
additions.push(at);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new RestorePreflightError(name, `${at} is invalid or would be changed`);
|
||||
}
|
||||
}
|
||||
|
||||
function isKnownLosslessScalarCoercion(
|
||||
name: string,
|
||||
at: string,
|
||||
raw: unknown,
|
||||
canonical: unknown,
|
||||
): boolean {
|
||||
if (typeof raw === 'string' && typeof canonical === 'string' && raw.trim() === canonical) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof raw === 'string' && typeof canonical === 'number') {
|
||||
const parsed = Number(raw.trim());
|
||||
return raw.trim().length > 0 && Number.isFinite(parsed) && Object.is(parsed, canonical);
|
||||
}
|
||||
|
||||
if (
|
||||
name === 'runtime.json' &&
|
||||
(at === '$.hookAutoLoad' || at === '$.webuiTls.enabled') &&
|
||||
typeof canonical === 'boolean'
|
||||
) {
|
||||
if (raw === 1 || raw === 0) {
|
||||
return canonical === (raw === 1);
|
||||
}
|
||||
|
||||
if (typeof raw !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const token = raw.trim().toLowerCase();
|
||||
const truthy = new Set(['true', '1', 'yes', 'on']);
|
||||
const falsy = new Set(['false', '0', 'no', 'off', '']);
|
||||
return (canonical && truthy.has(token)) || (!canonical && falsy.has(token));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function serializeJson(value: unknown): Buffer {
|
||||
return Buffer.from(JSON.stringify(value, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function validateEffectiveTls(restore: PreparedRestoreFile[], opts: PrepareRestoreOptions): void {
|
||||
const byName = new Map(restore.map((file) => [file.name, file.data]));
|
||||
const pairTouched = byName.has('cert.pem') || byName.has('key.pem');
|
||||
const tlsRelated = pairTouched || byName.has('runtime.json');
|
||||
|
||||
if (!tlsRelated) {
|
||||
return;
|
||||
}
|
||||
|
||||
const readEffective = (name: string): Buffer | null => {
|
||||
const prepared = byName.get(name);
|
||||
|
||||
if (prepared) {
|
||||
return prepared;
|
||||
}
|
||||
|
||||
try {
|
||||
return opts.readCurrent(name);
|
||||
} catch (error) {
|
||||
throw new RestorePreflightError(
|
||||
name,
|
||||
`failed to read current file: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const runtime = readEffective('runtime.json');
|
||||
let tlsEnabled = false;
|
||||
|
||||
if (runtime) {
|
||||
try {
|
||||
tlsEnabled =
|
||||
normalizeRuntimeConfig(JSON.parse(runtime.toString('utf8')) as unknown).webuiTls
|
||||
?.enabled === true;
|
||||
} catch (error) {
|
||||
throw new RestorePreflightError(
|
||||
'runtime.json',
|
||||
'effective runtime config is invalid JSON',
|
||||
{
|
||||
cause: error,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!tlsEnabled && !pairTouched) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cert = readEffective('cert.pem');
|
||||
const key = readEffective('key.pem');
|
||||
|
||||
if (cert && key) {
|
||||
const valid = validateTlsPair(cert, key);
|
||||
|
||||
if (!valid.ok) {
|
||||
throw new RestorePreflightError(
|
||||
'cert.pem + key.pem',
|
||||
valid.reason ?? 'TLS pair is invalid',
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (tlsEnabled) {
|
||||
throw new RestorePreflightError(
|
||||
'cert.pem + key.pem',
|
||||
'TLS is enabled but the effective certificate/private-key pair is incomplete',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateEffectiveUi(restore: PreparedRestoreFile[], opts: PrepareRestoreOptions): void {
|
||||
const byName = new Map(restore.map((file) => [file.name, file.data]));
|
||||
|
||||
if (!byName.has('ui.json') && !byName.has('ui-assets/background')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const readEffective = (name: string): Buffer | null => {
|
||||
const prepared = byName.get(name);
|
||||
|
||||
if (prepared) {
|
||||
return prepared;
|
||||
}
|
||||
|
||||
try {
|
||||
return opts.readCurrent(name);
|
||||
} catch (error) {
|
||||
throw new RestorePreflightError(
|
||||
name,
|
||||
`failed to read current file: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const uiBytes = readEffective('ui.json');
|
||||
|
||||
if (!uiBytes) {
|
||||
return;
|
||||
}
|
||||
|
||||
let ui;
|
||||
|
||||
try {
|
||||
ui = normalizeStoredUiConfig(JSON.parse(uiBytes.toString('utf8')) as unknown);
|
||||
} catch (error) {
|
||||
throw new RestorePreflightError('ui.json', 'effective UI config is invalid JSON', {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
const background = ui.appearance.background;
|
||||
|
||||
if (!background.hasImage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const image = readEffective('ui-assets/background');
|
||||
|
||||
if (!image) {
|
||||
throw new RestorePreflightError(
|
||||
'ui.json + ui-assets/background',
|
||||
'background metadata says an image exists, but the effective image is missing',
|
||||
);
|
||||
}
|
||||
|
||||
if (image.length > MAX_BACKGROUND_BYTES) {
|
||||
throw new RestorePreflightError(
|
||||
'ui.json + ui-assets/background',
|
||||
`effective background exceeds ${String(MAX_BACKGROUND_BYTES)} bytes`,
|
||||
);
|
||||
}
|
||||
|
||||
const mime = sniffImageMime(image);
|
||||
|
||||
if (!mime) {
|
||||
throw new RestorePreflightError(
|
||||
'ui.json + ui-assets/background',
|
||||
'effective background is not PNG, JPEG, or WebP',
|
||||
);
|
||||
}
|
||||
|
||||
if (mime !== background.imageMime) {
|
||||
throw new RestorePreflightError(
|
||||
'ui.json + ui-assets/background',
|
||||
`background metadata MIME ${background.imageMime} does not match image bytes ${mime}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { getConnInfo } from '@hono/node-server/conninfo';
|
||||
import type { Context } from 'hono';
|
||||
|
||||
export type TrustProxyMode =
|
||||
| { kind: 'none' }
|
||||
| { kind: 'loopback' }
|
||||
| { kind: 'all' }
|
||||
| { kind: 'ip-list'; ips: Set<string> };
|
||||
|
||||
export function parseTrustProxy(raw: string | undefined): TrustProxyMode {
|
||||
const value = raw?.trim().toLowerCase() ?? '';
|
||||
|
||||
if (!value) {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
|
||||
if (value === '1' || value === 'true' || value === 'all') {
|
||||
return { kind: 'all' };
|
||||
}
|
||||
|
||||
if (value === 'loopback') {
|
||||
return { kind: 'loopback' };
|
||||
}
|
||||
|
||||
const ips = new Set(
|
||||
value
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
if (ips.size === 0) {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
|
||||
return { kind: 'ip-list', ips };
|
||||
}
|
||||
|
||||
export function describeTrustProxy(mode: TrustProxyMode): string {
|
||||
switch (mode.kind) {
|
||||
case 'none':
|
||||
return 'socket peer (default)';
|
||||
case 'all':
|
||||
return 'X-Real-IP / X-Forwarded-For from any peer';
|
||||
case 'loopback':
|
||||
return 'X-Real-IP / X-Forwarded-For when socket peer is loopback';
|
||||
case 'ip-list':
|
||||
return `X-Real-IP / X-Forwarded-For when socket peer is in [${[...mode.ips].join(',')}]`;
|
||||
}
|
||||
}
|
||||
|
||||
export function isLoopbackClientIp(ip: string): boolean {
|
||||
const normalized = ip.trim().toLowerCase();
|
||||
|
||||
if (normalized === '::1' || normalized === '0:0:0:0:0:0:0:1') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalized.startsWith('::ffff:')) {
|
||||
return isLoopbackClientIp(normalized.slice('::ffff:'.length));
|
||||
}
|
||||
|
||||
const octets = normalized.split('.');
|
||||
return (
|
||||
octets.length === 4 &&
|
||||
octets[0] === '127' &&
|
||||
octets.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
||||
);
|
||||
}
|
||||
|
||||
function shouldTrustHeaders(mode: TrustProxyMode, socketIp: string): boolean {
|
||||
switch (mode.kind) {
|
||||
case 'none':
|
||||
return false;
|
||||
case 'all':
|
||||
return true;
|
||||
case 'loopback':
|
||||
return isLoopbackClientIp(socketIp);
|
||||
case 'ip-list':
|
||||
return mode.ips.has(socketIp);
|
||||
}
|
||||
}
|
||||
|
||||
export function pickClientIp(
|
||||
c: Pick<Context, 'req'>,
|
||||
mode: TrustProxyMode,
|
||||
getSocketIp: () => string,
|
||||
fallbackIp = '127.0.0.1',
|
||||
): string {
|
||||
let socketIp: string;
|
||||
|
||||
try {
|
||||
socketIp = getSocketIp();
|
||||
} catch {
|
||||
return fallbackIp;
|
||||
}
|
||||
|
||||
if (!socketIp) {
|
||||
return fallbackIp;
|
||||
}
|
||||
|
||||
if (!shouldTrustHeaders(mode, socketIp)) {
|
||||
return socketIp;
|
||||
}
|
||||
|
||||
const realIp = c.req.header('x-real-ip')?.trim();
|
||||
|
||||
if (realIp) {
|
||||
return realIp;
|
||||
}
|
||||
|
||||
const xff = c.req.header('x-forwarded-for');
|
||||
|
||||
if (xff) {
|
||||
const first = xff.split(',')[0].trim();
|
||||
|
||||
if (first) {
|
||||
return first;
|
||||
}
|
||||
}
|
||||
|
||||
return socketIp;
|
||||
}
|
||||
|
||||
/** Convenience binding for the live server: read socket via getConnInfo. */
|
||||
export function makeClientIpResolver(
|
||||
mode: TrustProxyMode,
|
||||
fallbackIp = '127.0.0.1',
|
||||
): (c: Context) => string {
|
||||
return (c) =>
|
||||
pickClientIp(c, mode, () => getConnInfo(c).remote.address ?? fallbackIp, fallbackIp);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { createLogger, runWithTraceRequest } from '@snowluma/common/logger';
|
||||
import { renderParamsVerbose } from '@snowluma/common/log-summary';
|
||||
import type { StateBus } from './state-bus';
|
||||
|
||||
const log = createLogger('WebUI.Connections');
|
||||
|
||||
export interface ConnectionDiffLoopOptions {
|
||||
bus: StateBus;
|
||||
/** Read the current adapter-status snapshot for every live UIN. */
|
||||
getSnapshot: () => unknown;
|
||||
/** Optional projector — return the "comparable" subset of the snapshot.
|
||||
* The full snapshot is what the SSE handler ships to the WebUI; this
|
||||
* projection is what the diff loop compares between ticks. Use it to
|
||||
* strip volatile fields (e.g. HH:MM:SS timestamps embedded in adapter
|
||||
* `detail` strings) that would otherwise produce a publish every tick
|
||||
* under any active webhook — defeating the loop's whole purpose.
|
||||
* Default: identity. */
|
||||
pickComparable?: (snapshot: unknown) => unknown;
|
||||
/** Poll cadence in milliseconds; default 500. */
|
||||
intervalMs?: number;
|
||||
}
|
||||
|
||||
/** One shared loop while clients subscribe; each subscription returns its release handle. */
|
||||
export function createConnectionPoller(opts: ConnectionDiffLoopOptions): () => () => void {
|
||||
const intervalMs = opts.intervalMs ?? 500;
|
||||
let lastSerialized: string | undefined | null = null;
|
||||
let subscribers = 0;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
// One-shot diagnostic latch: a projector that starts throwing only after
|
||||
// a shape regression would otherwise wedge the baseline silently — no
|
||||
// publishes, no log, no symptom besides "the dashboard's connections
|
||||
// card stops updating". Surface it ONCE so the failure is visible.
|
||||
let warnedSnapshotThrew = false;
|
||||
let warnedProjectorThrew = false;
|
||||
|
||||
const tick = (): void => {
|
||||
if (subscribers === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let snap: unknown;
|
||||
|
||||
try {
|
||||
snap = opts.getSnapshot();
|
||||
} catch (err) {
|
||||
if (!warnedSnapshotThrew) {
|
||||
warnedSnapshotThrew = true;
|
||||
runWithTraceRequest(() => {
|
||||
const startedAt = Date.now();
|
||||
log.trace('connection_diff_start phase=snapshot_read');
|
||||
log.trace(
|
||||
'connection_diff_terminal outcome=failed reason=snapshot_failed error=%j elapsedMs=%d',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
Date.now() - startedAt,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Snapshot read failed (e.g. mid-shutdown). Skip this tick; the
|
||||
// next one will retry. Don't reset the baseline so we don't
|
||||
// spuriously republish when the snapshot starts working again.
|
||||
return;
|
||||
}
|
||||
|
||||
let comparable: unknown;
|
||||
|
||||
try {
|
||||
comparable = opts.pickComparable ? opts.pickComparable(snap) : snap;
|
||||
} catch (err) {
|
||||
// A buggy projector must not crash the diff loop or wedge baseline.
|
||||
// But silent wedge is its own bug class — log the FIRST occurrence
|
||||
// so an operator can find it without re-running the loop locally.
|
||||
if (!warnedProjectorThrew) {
|
||||
warnedProjectorThrew = true;
|
||||
log.error(
|
||||
'connection snapshot projector failed; updates may be stale: %s',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
runWithTraceRequest(() => {
|
||||
const startedAt = Date.now();
|
||||
log.trace(() => [
|
||||
'connection_diff_start phase=project snapshot=%s',
|
||||
renderParamsVerbose(snap),
|
||||
]);
|
||||
log.trace(
|
||||
'connection_diff_terminal outcome=failed reason=projector_failed error=%j elapsedMs=%d',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
Date.now() - startedAt,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const serialized = JSON.stringify(comparable);
|
||||
|
||||
if (lastSerialized === null) {
|
||||
// First successful observation. The SSE handler's sendAllInitial
|
||||
// already shipped a `connections` frame to every connected client
|
||||
// on connect, so the baseline is what they already have — emitting
|
||||
// it again would be a duplicate. Just store it.
|
||||
lastSerialized = serialized;
|
||||
return;
|
||||
}
|
||||
|
||||
if (serialized === lastSerialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastSerialized = serialized;
|
||||
runWithTraceRequest(() => {
|
||||
const startedAt = Date.now();
|
||||
log.trace(() => ['connection_diff_start snapshot=%s', renderParamsVerbose(snap)]);
|
||||
log.trace('connection_diff_branch branch=publish resource=connections');
|
||||
opts.bus.publish('connections');
|
||||
log.trace(
|
||||
'connection_diff_terminal outcome=completed reason=published elapsedMs=%d',
|
||||
Date.now() - startedAt,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return () => {
|
||||
if (subscribers++ === 0) {
|
||||
lastSerialized = null;
|
||||
tick();
|
||||
timer = setInterval(tick, intervalMs);
|
||||
timer.unref();
|
||||
}
|
||||
|
||||
let subscribed = true;
|
||||
|
||||
return () => {
|
||||
if (!subscribed) {
|
||||
return;
|
||||
}
|
||||
|
||||
subscribed = false;
|
||||
|
||||
if (--subscribers === 0 && timer !== null) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
lastSerialized = null;
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { AccountConnections } from '@snowluma/onebot/manager';
|
||||
|
||||
export function comparableConnectionSnapshot(snapshot: unknown): unknown {
|
||||
if (!Array.isArray(snapshot)) {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
return (snapshot as AccountConnections[]).map((account) => ({
|
||||
uin: account.uin,
|
||||
nickname: account.nickname,
|
||||
adapters: Array.isArray(account.adapters)
|
||||
? account.adapters.map((adapter) => ({
|
||||
name: adapter.name,
|
||||
kind: adapter.kind,
|
||||
status: adapter.status,
|
||||
lastErrorAt: adapter.lastErrorAt,
|
||||
}))
|
||||
: [],
|
||||
databaseMigration: account.databaseMigration
|
||||
? {
|
||||
phase: account.databaseMigration.phase,
|
||||
usable: account.databaseMigration.usable,
|
||||
processed: account.databaseMigration.processed,
|
||||
total: account.databaseMigration.total,
|
||||
progress: account.databaseMigration.progress,
|
||||
estimatedRemainingSeconds: account.databaseMigration.estimatedRemainingSeconds,
|
||||
error: account.databaseMigration.error,
|
||||
}
|
||||
: undefined,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { createLogger } from '@snowluma/common/logger';
|
||||
import { createHash } from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const log = createLogger('WebUI.Consent');
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const CONFIG_DIR = 'config';
|
||||
const CONSENT_CONFIG_PATH = path.join(CONFIG_DIR, 'consent.json');
|
||||
|
||||
// The two legal documents shown at the consent gate. Source of truth is the
|
||||
// repo-root markdown; the build copies them next to the bundle (dist/) so the
|
||||
// same lookup works in a packaged deploy. See vite.config.ts cp targets.
|
||||
const AGREEMENT_FILES = [
|
||||
{ id: 'eula' as const, file: 'EULA.md' },
|
||||
{ id: 'privacy' as const, file: 'PRIVACY.md' },
|
||||
];
|
||||
|
||||
export const EULA_ACCEPT_ENV = 'SNOWLUMA_ACCEPT_EULA';
|
||||
export const PRIVACY_ACCEPT_ENV = 'SNOWLUMA_ACCEPT_PRIVACY';
|
||||
|
||||
type ConsentEnvironment = Readonly<Record<string, string | undefined>>;
|
||||
|
||||
export interface EnvironmentConsent {
|
||||
eulaAccepted: boolean;
|
||||
privacyAccepted: boolean;
|
||||
/** Both documents must be accepted; a partial opt-in never unlocks the gate. */
|
||||
accepted: boolean;
|
||||
}
|
||||
|
||||
export interface AgreementDoc {
|
||||
id: 'eula' | 'privacy';
|
||||
/** First markdown `# ` heading, for the tab/section title. */
|
||||
title: string;
|
||||
/** Declared "Version: x" from the doc — display only, NOT the consent key. */
|
||||
declaredVersion: string;
|
||||
/** Declared effective date (YYYY-MM-DD) — display only. */
|
||||
effectiveDate: string;
|
||||
/** Full markdown body the frontend renders. */
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ConsentRecord {
|
||||
/** The content-hash agreements version that was accepted. */
|
||||
version: string;
|
||||
/** ISO timestamp of acceptance. */
|
||||
acceptedAt: string;
|
||||
}
|
||||
|
||||
export interface AgreementsPayload {
|
||||
/** Content-hash version of the current agreement set. */
|
||||
version: string;
|
||||
/**
|
||||
* True when the operator must (re-)accept: the agreements carry real text,
|
||||
* environment consent is incomplete, AND the stored consent version differs
|
||||
* from the current one. A missing or unreadable agreement set fails OPEN (no
|
||||
* gate) so a broken bundle can never lock the admin out of their own panel.
|
||||
*/
|
||||
consentRequired: boolean;
|
||||
documents: AgreementDoc[];
|
||||
}
|
||||
|
||||
function parseEnvironmentAcceptance(name: string, raw: string | undefined): boolean {
|
||||
if (raw === undefined || raw.trim() === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
|
||||
if (normalized === '1' || normalized === 'true') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalized === '0' || normalized === 'false') {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw new Error(`${name} must be one of: 1, true, 0, false; received ${JSON.stringify(raw)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve declarative consent for unattended deployments. Environment consent
|
||||
* is intentionally ephemeral: removing either variable restores the normal
|
||||
* versioned consent gate instead of silently writing a permanent record.
|
||||
*/
|
||||
export function resolveEnvironmentConsent(
|
||||
env: ConsentEnvironment = process.env,
|
||||
): EnvironmentConsent {
|
||||
const eulaAccepted = parseEnvironmentAcceptance(EULA_ACCEPT_ENV, env[EULA_ACCEPT_ENV]);
|
||||
const privacyAccepted = parseEnvironmentAcceptance(PRIVACY_ACCEPT_ENV, env[PRIVACY_ACCEPT_ENV]);
|
||||
return {
|
||||
eulaAccepted,
|
||||
privacyAccepted,
|
||||
accepted: eulaAccepted && privacyAccepted,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Agreement loading (pure-ish; file read + parse, cached) ─────────────────
|
||||
|
||||
/**
|
||||
* Resolve an agreement file across the dev and packaged layouts. In dev the
|
||||
* server runs from source (`__dirname` = packages/core/src/webui); in a build
|
||||
* it is the single bundled dist/index.mjs (`__dirname` = dist/) with the docs
|
||||
* copied alongside. cwd is not reliable (dev cwd = packages/core, prod = repo
|
||||
* root or dist/), so we probe a handful of candidates and take the first hit.
|
||||
*/
|
||||
function resolveAgreementFile(file: string): string | null {
|
||||
const candidates = [
|
||||
path.join(__dirname, file), // packaged: dist/<file>
|
||||
path.resolve(__dirname, '..', '..', '..', '..', file), // dev tsx: repoRoot/<file>
|
||||
path.resolve(process.cwd(), file), // started from repo root
|
||||
path.resolve(process.cwd(), '..', '..', file), // dev cwd = packages/core
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
} catch {
|
||||
/* unreadable candidate, try next */
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Extract title / declared version / effective date from a doc body. */
|
||||
export function parseAgreementMeta(text: string): {
|
||||
title: string;
|
||||
declaredVersion: string;
|
||||
effectiveDate: string;
|
||||
} {
|
||||
const title = text.match(/^#\s+(.+?)\s*$/m)?.[1]?.trim() ?? '';
|
||||
// Matches both "Version: 1.0" / "版本 / Version:** 1.0" style lines.
|
||||
const declaredVersion =
|
||||
text.match(/(?:Version|版本)[^\n0-9]*([0-9][0-9A-Za-z.-]*)/)?.[1]?.trim() ?? '';
|
||||
const effectiveDate =
|
||||
text.match(/(?:Effective date|生效日期)[^\n0-9]*(\d{4}-\d{2}-\d{2})/)?.[1] ?? '';
|
||||
return { title, declaredVersion, effectiveDate };
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical agreements version = short sha256 over each doc's id + body.
|
||||
* Deliberately content-derived, NOT app-version-derived: it stays stable when
|
||||
* only the app is upgraded (so a one-time consent survives across versions),
|
||||
* and changes the moment any agreement's TEXT changes (forcing re-consent).
|
||||
*/
|
||||
export function computeAgreementsVersion(docs: { id: string; text: string }[]): string {
|
||||
const hash = createHash('sha256');
|
||||
|
||||
for (const doc of docs) {
|
||||
hash.update(doc.id);
|
||||
hash.update('\0');
|
||||
hash.update(doc.text);
|
||||
hash.update('\0');
|
||||
}
|
||||
|
||||
return hash.digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
let cache: { docs: AgreementDoc[]; version: string } | null = null;
|
||||
|
||||
/** Load + parse both agreements and compute the version. Cached per process. */
|
||||
export function loadAgreements(): { docs: AgreementDoc[]; version: string } {
|
||||
if (cache) {
|
||||
return cache;
|
||||
}
|
||||
|
||||
const docs: AgreementDoc[] = AGREEMENT_FILES.map(({ id, file }) => {
|
||||
const resolved = resolveAgreementFile(file);
|
||||
let text = '';
|
||||
|
||||
if (resolved) {
|
||||
try {
|
||||
text = fs.readFileSync(resolved, 'utf8');
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
'failed to read %s: %s',
|
||||
file,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
log.warn(
|
||||
'agreement file not found (looked for %s near the bundle and repo root)',
|
||||
file,
|
||||
);
|
||||
}
|
||||
|
||||
const meta = parseAgreementMeta(text);
|
||||
return {
|
||||
id,
|
||||
title: meta.title,
|
||||
declaredVersion: meta.declaredVersion,
|
||||
effectiveDate: meta.effectiveDate,
|
||||
text,
|
||||
};
|
||||
});
|
||||
cache = { docs, version: computeAgreementsVersion(docs) };
|
||||
return cache;
|
||||
}
|
||||
|
||||
// ── Consent persistence (config/consent.json, atomic) ───────────────────────
|
||||
|
||||
function ensureConfigDir(): void {
|
||||
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function isConsentRecord(value: unknown): value is ConsentRecord {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const v = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof v.version === 'string' && v.version.length > 0 && typeof v.acceptedAt === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export function loadConsentRecord(): ConsentRecord | null {
|
||||
try {
|
||||
if (!fs.existsSync(CONSENT_CONFIG_PATH)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(fs.readFileSync(CONSENT_CONFIG_PATH, 'utf8')) as unknown;
|
||||
return isConsentRecord(parsed) ? parsed : null;
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
'consent.json unreadable, treating as no consent: %s',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function atomicWrite(record: ConsentRecord): void {
|
||||
ensureConfigDir();
|
||||
const tmp = CONSENT_CONFIG_PATH + '.tmp';
|
||||
fs.writeFileSync(tmp, JSON.stringify(record, null, 2), { encoding: 'utf8' });
|
||||
fs.renameSync(tmp, CONSENT_CONFIG_PATH);
|
||||
}
|
||||
|
||||
/** Persist that the operator accepted `version`. Returns the stored record. */
|
||||
export function recordConsent(version: string): ConsentRecord {
|
||||
const record: ConsentRecord = { version, acceptedAt: new Date().toISOString() };
|
||||
atomicWrite(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
/** True when neither environment nor stored consent covers the current agreements. */
|
||||
export function isConsentRequired(env: ConsentEnvironment = process.env): boolean {
|
||||
if (resolveEnvironmentConsent(env).accepted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { docs, version } = loadAgreements();
|
||||
const hasContent = docs.some((d) => d.text.trim().length > 0);
|
||||
|
||||
if (!hasContent) {
|
||||
return false;
|
||||
} // fail open: never brick the panel on a broken bundle
|
||||
|
||||
return loadConsentRecord()?.version !== version;
|
||||
}
|
||||
|
||||
/** Public payload for GET /api/agreements (texts + version + consentRequired). */
|
||||
export function getAgreementsPayload(): AgreementsPayload {
|
||||
const { docs, version } = loadAgreements();
|
||||
return { version, consentRequired: isConsentRequired(), documents: docs };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// SSE frame pusher for the debug stream (Wave A3). Drops frames under
|
||||
// backpressure (consumer slow → desiredSize <= 0) instead of buffering
|
||||
// unbounded into memory, and coalesces the drop count into a {kind:'dropped'}
|
||||
// marker emitted just before the next delivered frame. Extracted from the route
|
||||
// handler so this (easy-to-get-wrong) logic is unit-tested.
|
||||
|
||||
export interface FramePusherOptions {
|
||||
/** The stream controller's current desiredSize (null = no backpressure info). */
|
||||
desiredSize: () => number | null;
|
||||
/** Deliver an encoded SSE chunk (the caller maps this to the controller). */
|
||||
enqueue: (chunk: Uint8Array) => void;
|
||||
encode?: (s: string) => Uint8Array;
|
||||
}
|
||||
|
||||
/** Returns a `push(payload)` that frames + delivers with backpressure dropping. */
|
||||
export function createFramePusher(opts: FramePusherOptions): (payload: unknown) => void {
|
||||
const encode = opts.encode ?? ((s: string) => new TextEncoder().encode(s));
|
||||
let dropped = 0;
|
||||
|
||||
return (payload: unknown): void => {
|
||||
const d = opts.desiredSize();
|
||||
|
||||
if (d !== null && d <= 0) {
|
||||
dropped += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (dropped > 0) {
|
||||
opts.enqueue(
|
||||
encode(`data: ${JSON.stringify({ kind: 'dropped', count: dropped })}\n\n`),
|
||||
);
|
||||
dropped = 0;
|
||||
}
|
||||
|
||||
opts.enqueue(encode(`data: ${JSON.stringify(payload)}\n\n`));
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
// debug-tools — backend for the debug console's two heavier endpoints
|
||||
// (Phase 2 of the debug-console expansion):
|
||||
//
|
||||
// • POST /api/debug/invoke-stream — drive a Stream API action and relay every
|
||||
// frame to the browser over an SSE body. Unlike the live-activity stream
|
||||
// (debug-stream.ts), this MUST NOT drop frames: a file transfer that loses a
|
||||
// chunk is corrupt. So backpressure is handled by *waiting* for the consumer
|
||||
// to drain, never by dropping.
|
||||
// • POST /api/debug/upload — stream a browser-selected file to a temp path on
|
||||
// the SERVER (the bot runs on the server, not the user's machine), so the
|
||||
// returned path can be fed to a send action. Streamed straight to disk with a
|
||||
// byte cap — never buffered whole, so large videos don't blow up memory.
|
||||
//
|
||||
// The pure pieces live here (path sanitisation, the size-capped disk sink, the
|
||||
// stream-invoke Response builder) so they are unit-testable away from Hono.
|
||||
|
||||
import { createWriteStream } from 'fs';
|
||||
import { mkdir, readdir, rm, stat } from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { traceWebuiMutationRequestChunk } from './mutation-trace';
|
||||
import { sseResponse } from './sse-response';
|
||||
|
||||
/** Temp dir for browser→server uploads. Siblings the Stream API's temp root but
|
||||
* kept distinct so the two cleanup stories don't entangle. */
|
||||
export const DEBUG_UPLOAD_DIR = path.join(os.tmpdir(), 'webui-upload');
|
||||
|
||||
/** Hard ceiling on a single upload. Mirrors the Stream API's upload cap. */
|
||||
export const MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024; // 4 GiB
|
||||
|
||||
/** Uploads older than this are swept on the next upload (best-effort). */
|
||||
const UPLOAD_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Reduce a client-supplied filename to a safe basename (extension preserved),
|
||||
* with no path separators or traversal. The client path is NEVER trusted for
|
||||
* placement — this is only used to keep the stored name human-readable. */
|
||||
export function safeUploadName(raw: string | undefined): string {
|
||||
// Treat both separators as path delimiters, then take the basename so any
|
||||
// `../` / absolute prefix is discarded regardless of host platform.
|
||||
const base = path.basename(String(raw ?? '').replace(/\\/g, '/'));
|
||||
const cleaned = base
|
||||
.replace(/[^\w.-]+/g, '_') // collapse anything exotic to underscore
|
||||
.replace(/^\.+/, '') // no leading dots (no hidden / '..' names)
|
||||
.slice(0, 120);
|
||||
return cleaned || 'file';
|
||||
}
|
||||
|
||||
/** Unguessable destination path under DEBUG_UPLOAD_DIR for a sanitised name.
|
||||
* Throws if the composed path would escape the dir (defence in depth — it
|
||||
* can't, given the random id + basename, but we assert anyway). */
|
||||
export function uploadDestPath(safeName: string): string {
|
||||
const id = randomBytes(12).toString('hex');
|
||||
const dest = path.join(DEBUG_UPLOAD_DIR, `${id}__${safeName}`);
|
||||
const rel = path.relative(DEBUG_UPLOAD_DIR, dest);
|
||||
|
||||
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
|
||||
throw new Error('invalid upload path');
|
||||
}
|
||||
|
||||
return dest;
|
||||
}
|
||||
|
||||
/** Stream raw request-body bytes to a temp file under DEBUG_UPLOAD_DIR, enforcing
|
||||
* a byte cap. Returns the final server path + byte size. On overflow or any
|
||||
* error the partial file is removed and the call rejects. Never buffers the
|
||||
* whole body. */
|
||||
export async function streamUploadToDisk(
|
||||
body: ReadableStream<Uint8Array> | null,
|
||||
filename: string | undefined,
|
||||
opts: { maxBytes?: number } = {},
|
||||
): Promise<{ path: string; size: number }> {
|
||||
if (!body) {
|
||||
throw new Error('empty body');
|
||||
}
|
||||
|
||||
const maxBytes = opts.maxBytes ?? MAX_UPLOAD_BYTES;
|
||||
await mkdir(DEBUG_UPLOAD_DIR, { recursive: true });
|
||||
void sweepOldUploads(); // fire-and-forget TTL cleanup
|
||||
|
||||
const dest = uploadDestPath(safeUploadName(filename));
|
||||
const out = createWriteStream(dest, { mode: 0o600 });
|
||||
// A WriteStream emits 'error' ASYNCHRONOUSLY (ENOSPC / EIO / quota) during a
|
||||
// background flush — typically while we're parked at reader.read() or waiting
|
||||
// on 'drain'. An unhandled 'error' event would crash the whole process, and a
|
||||
// backpressured error would leave 'drain' to never fire (a forever-hung await
|
||||
// + leaked fd). So turn it into a rejection and race it into every await; the
|
||||
// catch below then destroys + unlinks the partial.
|
||||
const errored = new Promise<never>((_, rej) => out.once('error', rej));
|
||||
errored.catch(() => {
|
||||
/* swallow a late error after success (still raced below) */
|
||||
});
|
||||
let size = 0;
|
||||
|
||||
try {
|
||||
const reader = body.getReader();
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await Promise.race([errored, reader.read()]);
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!value || value.byteLength === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const offset = size;
|
||||
size += value.byteLength;
|
||||
traceWebuiMutationRequestChunk(value, offset);
|
||||
|
||||
if (size > maxBytes) {
|
||||
throw new Error('上传超出大小上限');
|
||||
}
|
||||
|
||||
// Respect the write stream's backpressure (but never hang on an error).
|
||||
if (!out.write(value)) {
|
||||
await Promise.race([errored, new Promise<void>((res) => out.once('drain', res))]);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.race([
|
||||
errored,
|
||||
new Promise<void>((res, rej) =>
|
||||
out.end((err?: Error | null) => (err ? rej(err) : res())),
|
||||
),
|
||||
]);
|
||||
|
||||
if (size === 0) {
|
||||
throw new Error('empty body');
|
||||
}
|
||||
|
||||
return { path: dest, size };
|
||||
} catch (err) {
|
||||
// Wait for the stream to fully close BEFORE unlinking: createWriteStream
|
||||
// opens the fd asynchronously, so a fast failure can reach `rm` before the
|
||||
// file even exists on disk — then the late open would re-create it and leak
|
||||
// the partial. 'close' guarantees the fd is settled and no more writes land.
|
||||
out.destroy();
|
||||
|
||||
if (!out.closed) {
|
||||
await new Promise<void>((res) => out.once('close', () => res()));
|
||||
}
|
||||
|
||||
await rm(dest, { force: true }).catch(() => {
|
||||
/* best-effort */
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove upload temp files older than the TTL. Best-effort; never throws. */
|
||||
export async function sweepOldUploads(now: number = Date.now()): Promise<number> {
|
||||
let removed = 0;
|
||||
|
||||
try {
|
||||
const names = await readdir(DEBUG_UPLOAD_DIR);
|
||||
await Promise.all(
|
||||
names.map(async (n) => {
|
||||
const p = path.join(DEBUG_UPLOAD_DIR, n);
|
||||
|
||||
try {
|
||||
const s = await stat(p);
|
||||
|
||||
if (now - s.mtimeMs > UPLOAD_TTL_MS) {
|
||||
await rm(p, { force: true, recursive: true });
|
||||
removed += 1;
|
||||
}
|
||||
} catch {
|
||||
/* racing cleanup / vanished file */
|
||||
}
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* dir absent yet */
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/** Drives a stream-capable action and relays its frames. The driver is the
|
||||
* instance's `invokeStream` (which itself handles non-stream actions by emitting
|
||||
* a single frame). */
|
||||
export type StreamInvokeDriver = (
|
||||
rawRequest: string,
|
||||
emit: (json: string) => Promise<void>,
|
||||
isAlive: () => boolean,
|
||||
) => Promise<void>;
|
||||
|
||||
/** How long emit() will wait for a backed-up consumer to drain before it gives
|
||||
* up waiting and enqueues anyway (the request abort / action idle-watchdog are
|
||||
* the real stall guards). */
|
||||
const DRAIN_WAIT_MS = 20;
|
||||
const DRAIN_MAX_SPINS = 3000; // 3000 * 20ms ≈ 60s
|
||||
|
||||
/** Build the SSE `Response` for `/api/debug/invoke-stream`: each relayed frame
|
||||
* is one `data: <json>\n\n` event; no frame is ever dropped. A driver error
|
||||
* becomes a terminal `failed` frame. Closing the request (signal abort) stops
|
||||
* delivery. `delay` is injectable for tests. */
|
||||
export function buildStreamInvokeResponse(
|
||||
driver: StreamInvokeDriver,
|
||||
rawRequest: string,
|
||||
signal: AbortSignal,
|
||||
delay: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms)),
|
||||
): Response {
|
||||
return sseResponse(signal, (channel) => {
|
||||
const isAlive = () => !channel.isClosed();
|
||||
|
||||
const emit = async (json: string): Promise<void> => {
|
||||
if (!isAlive()) {
|
||||
throw new Error('stream transport closed');
|
||||
}
|
||||
|
||||
// Backpressure WITHOUT dropping: wait for the consumer to catch up.
|
||||
let spins = 0;
|
||||
|
||||
while (isAlive() && (channel.desiredSize() ?? 1) <= 0 && spins < DRAIN_MAX_SPINS) {
|
||||
await delay(DRAIN_WAIT_MS);
|
||||
spins += 1;
|
||||
}
|
||||
|
||||
if (!isAlive()) {
|
||||
throw new Error('stream transport closed');
|
||||
}
|
||||
|
||||
channel.raw(channel.encode(`data: ${json}\n\n`));
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
await driver(rawRequest, emit, isAlive);
|
||||
} catch (err) {
|
||||
if (isAlive()) {
|
||||
const msg = err instanceof Error ? err.message : 'stream error';
|
||||
|
||||
channel.send({ status: 'failed', retcode: 1400, message: msg, wording: msg });
|
||||
}
|
||||
} finally {
|
||||
channel.close();
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { isIP } from 'node:net';
|
||||
|
||||
export type ListenerExposureCheck =
|
||||
{ ok: true } | { ok: false; code: 'invalid-bind-host' | 'tls-pair-required' };
|
||||
|
||||
/** TCP bind-host accepted by WebUI Listener Exposure (settings, restore, boot). */
|
||||
export function isValidBindHost(value: string): boolean {
|
||||
const host = value.trim();
|
||||
|
||||
if (!host || host !== value || host.length > 253 || /[\s/?#@]/u.test(host)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (host.includes(':')) {
|
||||
return isIP(host) === 6;
|
||||
}
|
||||
|
||||
if (/^[\d.]+$/.test(host)) {
|
||||
return isIP(host) === 4;
|
||||
}
|
||||
|
||||
const normalized = host.endsWith('.') ? host.slice(0, -1) : host;
|
||||
return normalized.split('.').every((label) => /^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$/.test(label));
|
||||
}
|
||||
|
||||
export function checkListenerExposure(input: {
|
||||
webuiHost: string;
|
||||
tlsEnabled: boolean;
|
||||
pairOk: boolean;
|
||||
}): ListenerExposureCheck {
|
||||
if (!isValidBindHost(input.webuiHost)) {
|
||||
return { ok: false, code: 'invalid-bind-host' };
|
||||
}
|
||||
|
||||
if (input.tlsEnabled && !input.pairOk) {
|
||||
return { ok: false, code: 'tls-pair-required' };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { LogEntry } from '@snowluma/common/logger';
|
||||
|
||||
export interface FullTraceExportMetadata {
|
||||
version: string;
|
||||
operatingSystem: string;
|
||||
architecture: string;
|
||||
nodeVersion: string;
|
||||
logLevel: LogEntry['level'];
|
||||
exportedAt: string;
|
||||
}
|
||||
|
||||
const PRIVACY_WARNING =
|
||||
'WARNING: This export may contain unredacted private data and credentials. Sanitize it before submission.';
|
||||
|
||||
export interface FullTraceDownload {
|
||||
body: string;
|
||||
headers: {
|
||||
'Content-Type': string;
|
||||
'Content-Disposition': string;
|
||||
'Cache-Control': string;
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFullTraceDownload(
|
||||
entries: readonly LogEntry[],
|
||||
metadata: FullTraceExportMetadata,
|
||||
): FullTraceDownload {
|
||||
const timestamp = metadata.exportedAt.slice(0, 19).replace(/[:.]/g, '-');
|
||||
return {
|
||||
body: formatFullTraceExport(entries, metadata),
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="snowluma-trace-${timestamp}.log"`,
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function formatFullTraceExport(
|
||||
entries: readonly LogEntry[],
|
||||
metadata: FullTraceExportMetadata,
|
||||
): string {
|
||||
const lines = [
|
||||
'SnowLuma full TRACE export',
|
||||
'==========================',
|
||||
`SnowLuma version: ${metadata.version}`,
|
||||
`Operating system: ${metadata.operatingSystem}`,
|
||||
`Architecture: ${metadata.architecture}`,
|
||||
`Node.js version: ${metadata.nodeVersion}`,
|
||||
`Current log level: ${metadata.logLevel.toUpperCase()}`,
|
||||
`Export time: ${metadata.exportedAt}`,
|
||||
`Retained records: ${entries.length}`,
|
||||
'',
|
||||
PRIVACY_WARNING,
|
||||
'This is a snapshot of the normal and TRACE records still retained in memory; records already evicted cannot be recovered.',
|
||||
'',
|
||||
'Logs',
|
||||
'----',
|
||||
];
|
||||
|
||||
if (entries.length === 0) {
|
||||
lines.push('(No log records are currently retained.)');
|
||||
} else {
|
||||
lines.push(...entries.map((entry) => entry.line));
|
||||
}
|
||||
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import { renderParamsVerbose } from '@snowluma/common/log-summary';
|
||||
import {
|
||||
createLogger,
|
||||
currentRequestId,
|
||||
getLogLevel,
|
||||
renderTraceBytes,
|
||||
runWithRequestId,
|
||||
runWithTraceRequest,
|
||||
} from '@snowluma/common/logger';
|
||||
|
||||
const log = createLogger('WebUI.Mutation');
|
||||
const READ_ONLY_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
const STREAMED_REQUEST_PATHS = new Set(['/api/debug/upload']);
|
||||
const STREAMED_RESPONSE_PATHS = new Set(['/api/debug/invoke-stream']);
|
||||
// This route can disable TRACE while its own operation is in flight; its
|
||||
// existing WebUI info record is the authoritative result.
|
||||
const TRACE_EXCLUDED_PATHS = new Set(['/api/auth/check-strength', '/api/logs/level']);
|
||||
|
||||
interface CapturedBody {
|
||||
bytes: Uint8Array;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function shouldTraceMutation(request: Request): boolean {
|
||||
if (getLogLevel() !== 'trace') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (READ_ONLY_METHODS.has(request.method.toUpperCase())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !TRACE_EXCLUDED_PATHS.has(new URL(request.url).pathname);
|
||||
}
|
||||
|
||||
async function captureBody(message: Request | Response): Promise<CapturedBody> {
|
||||
const bytes = new Uint8Array(await message.arrayBuffer());
|
||||
return {
|
||||
bytes,
|
||||
text: new TextDecoder().decode(bytes),
|
||||
};
|
||||
}
|
||||
|
||||
function traceBody(
|
||||
branch: 'request_body' | 'response',
|
||||
body: CapturedBody,
|
||||
response?: Response,
|
||||
): void {
|
||||
log.trace(() => [
|
||||
'webui_mutation_branch branch=%s%s length=%d text=%j body=%s',
|
||||
branch,
|
||||
response
|
||||
? ` status=${response.status} statusText=${JSON.stringify(response.statusText)} headers=${renderParamsVerbose([...response.headers.entries()])}`
|
||||
: '',
|
||||
body.bytes.byteLength,
|
||||
body.text,
|
||||
renderTraceBytes(body.bytes),
|
||||
]);
|
||||
}
|
||||
|
||||
function traceStreamChunk(
|
||||
branch: 'request_body_chunk' | 'response_chunk',
|
||||
chunk: Uint8Array,
|
||||
offset: number,
|
||||
): void {
|
||||
log.trace(() => [
|
||||
'webui_mutation_branch branch=%s offset=%d length=%d body=%s',
|
||||
branch,
|
||||
offset,
|
||||
chunk.byteLength,
|
||||
renderTraceBytes(chunk),
|
||||
]);
|
||||
}
|
||||
|
||||
export function traceWebuiMutationRequestChunk(chunk: Uint8Array, offset: number): void {
|
||||
traceStreamChunk('request_body_chunk', chunk, offset);
|
||||
}
|
||||
|
||||
function traceStreamedResponse(
|
||||
response: Response,
|
||||
requestBody: Promise<CapturedBody> | null,
|
||||
startedAt: number,
|
||||
): Response {
|
||||
if (!response.body) {
|
||||
void settleMutation(
|
||||
requestBody,
|
||||
Promise.resolve({ bytes: new Uint8Array(), text: '' }),
|
||||
response,
|
||||
startedAt,
|
||||
false,
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const requestId = currentRequestId();
|
||||
const inContext = <T>(fn: () => T): T =>
|
||||
requestId === undefined ? fn() : runWithRequestId(requestId, fn);
|
||||
let offset = 0;
|
||||
let settled = false;
|
||||
|
||||
const terminal = (
|
||||
outcome: 'completed' | 'failed' | 'cancelled',
|
||||
reason: string,
|
||||
error?: unknown,
|
||||
) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
void inContext(() => traceRequestBody(requestBody)).then(() => {
|
||||
inContext(() =>
|
||||
log.trace(
|
||||
'webui_mutation_terminal outcome=%s reason=%s status=%d%s elapsedMs=%d',
|
||||
outcome,
|
||||
reason,
|
||||
response.status,
|
||||
error === undefined
|
||||
? ''
|
||||
: ` error=${JSON.stringify(error instanceof Error ? error.message : String(error))}`,
|
||||
Date.now() - startedAt,
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const body = new ReadableStream<Uint8Array>(
|
||||
{
|
||||
async pull(controller) {
|
||||
try {
|
||||
const result = await reader.read();
|
||||
|
||||
if (result.done) {
|
||||
terminal(
|
||||
response.ok ? 'completed' : 'failed',
|
||||
response.ok ? 'response_completed' : 'http_status',
|
||||
);
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const chunk = result.value;
|
||||
inContext(() => traceStreamChunk('response_chunk', chunk, offset));
|
||||
offset += chunk.byteLength;
|
||||
controller.enqueue(chunk);
|
||||
} catch (error) {
|
||||
terminal(
|
||||
error instanceof Error && error.name === 'AbortError'
|
||||
? 'cancelled'
|
||||
: 'failed',
|
||||
error instanceof Error && error.name === 'AbortError'
|
||||
? 'body_cancelled'
|
||||
: 'body_read_failed',
|
||||
error,
|
||||
);
|
||||
controller.error(error);
|
||||
}
|
||||
},
|
||||
async cancel(reason) {
|
||||
terminal('cancelled', 'body_cancelled', reason);
|
||||
await reader.cancel(reason);
|
||||
},
|
||||
},
|
||||
{ highWaterMark: 0 },
|
||||
);
|
||||
return new Response(body, response);
|
||||
}
|
||||
|
||||
async function traceRequestBody(requestBody: Promise<CapturedBody> | null): Promise<void> {
|
||||
if (!requestBody) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
traceBody('request_body', await requestBody);
|
||||
} catch (error) {
|
||||
log.trace(
|
||||
'webui_mutation_branch branch=request_body_failed error=%j',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function settleMutation(
|
||||
requestBody: Promise<CapturedBody> | null,
|
||||
responseBody: Promise<CapturedBody>,
|
||||
response: Response,
|
||||
startedAt: number,
|
||||
requestCancelled: boolean,
|
||||
): Promise<void> {
|
||||
const results = await Promise.allSettled([
|
||||
requestBody ?? Promise.resolve<CapturedBody>({ bytes: new Uint8Array(), text: '' }),
|
||||
responseBody,
|
||||
]);
|
||||
const [requestResult, responseResult] = results;
|
||||
|
||||
if (requestBody && requestResult.status === 'fulfilled') {
|
||||
traceBody('request_body', requestResult.value);
|
||||
}
|
||||
|
||||
if (requestBody && requestResult.status === 'rejected') {
|
||||
log.trace(
|
||||
'webui_mutation_branch branch=request_body_failed error=%j',
|
||||
requestResult.reason instanceof Error
|
||||
? requestResult.reason.message
|
||||
: String(requestResult.reason),
|
||||
);
|
||||
}
|
||||
|
||||
if (responseResult.status === 'fulfilled') {
|
||||
traceBody('response', responseResult.value, response);
|
||||
} else {
|
||||
log.trace(() => [
|
||||
'webui_mutation_branch branch=response_body_failed status=%d statusText=%j headers=%s error=%j',
|
||||
response.status,
|
||||
response.statusText,
|
||||
renderParamsVerbose([...response.headers.entries()]),
|
||||
responseResult.reason instanceof Error
|
||||
? responseResult.reason.message
|
||||
: String(responseResult.reason),
|
||||
]);
|
||||
}
|
||||
|
||||
const error =
|
||||
responseResult.status === 'rejected'
|
||||
? responseResult.reason
|
||||
: requestResult.status === 'rejected'
|
||||
? requestResult.reason
|
||||
: undefined;
|
||||
|
||||
if (error === undefined) {
|
||||
if (requestCancelled) {
|
||||
log.trace(
|
||||
'webui_mutation_terminal outcome=cancelled reason=request_cancelled status=%d elapsedMs=%d',
|
||||
response.status,
|
||||
Date.now() - startedAt,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
log.trace(
|
||||
'webui_mutation_terminal outcome=%s reason=%s status=%d elapsedMs=%d',
|
||||
response.ok ? 'completed' : 'failed',
|
||||
response.ok ? 'response_completed' : 'http_status',
|
||||
response.status,
|
||||
Date.now() - startedAt,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
log.trace(
|
||||
'webui_mutation_terminal outcome=%s reason=%s error=%j elapsedMs=%d',
|
||||
error instanceof Error && error.name === 'AbortError' ? 'cancelled' : 'failed',
|
||||
error instanceof Error && error.name === 'AbortError'
|
||||
? 'body_cancelled'
|
||||
: 'body_read_failed',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
Date.now() - startedAt,
|
||||
);
|
||||
}
|
||||
|
||||
function failureOutcome(error: unknown): {
|
||||
outcome: 'failed' | 'timeout' | 'cancelled';
|
||||
reason: string;
|
||||
} {
|
||||
if (error instanceof Error && error.name === 'TimeoutError') {
|
||||
return { outcome: 'timeout', reason: 'handler_timeout' };
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return { outcome: 'cancelled', reason: 'handler_cancelled' };
|
||||
}
|
||||
|
||||
return { outcome: 'failed', reason: 'handler_threw' };
|
||||
}
|
||||
|
||||
/** Trace one authenticated WebUI write without consuming its live streams. */
|
||||
export function traceAuthenticatedWebuiMutation(
|
||||
request: Request,
|
||||
next: () => Promise<Response>,
|
||||
): Promise<Response> {
|
||||
if (!shouldTraceMutation(request)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return runWithTraceRequest(async () => {
|
||||
const startedAt = Date.now();
|
||||
const requestHeaders = [...request.headers.entries()];
|
||||
const pathname = new URL(request.url).pathname;
|
||||
let requestBody: Promise<CapturedBody> | null = null;
|
||||
|
||||
log.trace(() => [
|
||||
'webui_mutation_start method=%j url=%j headers=%s',
|
||||
request.method,
|
||||
request.url,
|
||||
renderParamsVerbose(requestHeaders),
|
||||
]);
|
||||
|
||||
if (!STREAMED_REQUEST_PATHS.has(pathname)) {
|
||||
try {
|
||||
requestBody = captureBody(request.clone());
|
||||
} catch (error) {
|
||||
log.trace(
|
||||
'webui_mutation_terminal outcome=failed reason=request_clone_failed error=%j elapsedMs=%d',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
Date.now() - startedAt,
|
||||
);
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await next();
|
||||
|
||||
if (STREAMED_RESPONSE_PATHS.has(pathname)) {
|
||||
return traceStreamedResponse(response, requestBody, startedAt);
|
||||
}
|
||||
|
||||
let responseBody: Promise<CapturedBody>;
|
||||
|
||||
try {
|
||||
responseBody = captureBody(response.clone());
|
||||
} catch (error) {
|
||||
void traceRequestBody(requestBody).then(() => {
|
||||
log.trace(
|
||||
'webui_mutation_terminal outcome=failed reason=response_clone_failed error=%j elapsedMs=%d',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
Date.now() - startedAt,
|
||||
);
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
void settleMutation(
|
||||
requestBody,
|
||||
responseBody,
|
||||
response,
|
||||
startedAt,
|
||||
STREAMED_REQUEST_PATHS.has(pathname) && request.signal.aborted,
|
||||
);
|
||||
return response;
|
||||
} catch (error) {
|
||||
await traceRequestBody(requestBody);
|
||||
const failure = failureOutcome(error);
|
||||
log.trace(
|
||||
'webui_mutation_terminal outcome=%s reason=%s error=%j elapsedMs=%d',
|
||||
failure.outcome,
|
||||
failure.reason,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
Date.now() - startedAt,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { assessAccessToken } from '@snowluma/common/access-token';
|
||||
import type { OneBotConfig } from '@snowluma/onebot/types';
|
||||
import { isLoopbackClientIp } from './client-ip';
|
||||
|
||||
function isLoopbackBindHost(host: string | undefined): boolean {
|
||||
const value =
|
||||
host
|
||||
?.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^\[|\]$/gu, '')
|
||||
.replace(/\.$/u, '') ?? '';
|
||||
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value === 'localhost' || value.endsWith('.localhost')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isLoopbackClientIp(value);
|
||||
}
|
||||
|
||||
export class OneBotAccessTokenPolicyError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'OneBotAccessTokenPolicyError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface OneBotAccessTokenPolicyOptions {
|
||||
clientIp: string;
|
||||
uin: string;
|
||||
}
|
||||
|
||||
type InboundKind = 'httpServers' | 'wsServers';
|
||||
|
||||
const INBOUND_KINDS: readonly InboundKind[] = ['httpServers', 'wsServers'];
|
||||
|
||||
function sameTokenAtPreviousIdentity(
|
||||
previous: OneBotConfig['networks'][InboundKind],
|
||||
next: OneBotConfig['networks'][InboundKind],
|
||||
nextName: string,
|
||||
nextToken: string,
|
||||
index: number,
|
||||
): boolean {
|
||||
const named = previous.find((adapter) => adapter.name === nextName);
|
||||
|
||||
if (named && (named.accessToken ?? '') === nextToken) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Preserve a legacy token when the operator only renames the same list item.
|
||||
// Reordering keeps names stable and is covered by the lookup above.
|
||||
const sameIndex = previous[index];
|
||||
return (
|
||||
sameIndex !== undefined &&
|
||||
!next.some((adapter) => adapter.name === sameIndex.name) &&
|
||||
(sameIndex.accessToken ?? '') === nextToken
|
||||
);
|
||||
}
|
||||
|
||||
export function validateOneBotAccessTokenChanges(
|
||||
previous: OneBotConfig | null,
|
||||
next: OneBotConfig,
|
||||
options: OneBotAccessTokenPolicyOptions,
|
||||
): void {
|
||||
const allowEmptyFromClient = isLoopbackClientIp(options.clientIp);
|
||||
|
||||
for (const kind of INBOUND_KINDS) {
|
||||
const previousList = previous?.networks[kind] ?? [];
|
||||
const nextList = next.networks[kind];
|
||||
|
||||
for (let index = 0; index < nextList.length; index += 1) {
|
||||
const adapter = nextList[index];
|
||||
const token = adapter.accessToken ?? '';
|
||||
|
||||
if (sameTokenAtPreviousIdentity(previousList, nextList, adapter.name, token, index)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
if (!allowEmptyFromClient && !isLoopbackBindHost(adapter.host)) {
|
||||
throw new OneBotAccessTokenPolicyError(
|
||||
`节点“${adapter.name}”未绑定本机地址,远程保存必须填写令牌;请生成令牌或将主机改为 127.0.0.1。`,
|
||||
);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const host = adapter.host ?? '0.0.0.0';
|
||||
const assessment = assessAccessToken(token, [
|
||||
options.uin,
|
||||
adapter.name,
|
||||
kind,
|
||||
host,
|
||||
adapter.port,
|
||||
]);
|
||||
|
||||
if (assessment.reason === 'too-short') {
|
||||
throw new OneBotAccessTokenPolicyError(
|
||||
`节点“${adapter.name}”的令牌至少需要 16 个字符;请重新生成或继续补充。`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!assessment.acceptable) {
|
||||
throw new OneBotAccessTokenPolicyError(
|
||||
`节点“${adapter.name}”的令牌容易被猜中;请重新生成随机令牌。`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import net from 'net';
|
||||
|
||||
function isPortAvailable(port: number, host = '127.0.0.1'): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
let settled = false;
|
||||
|
||||
const finalize = (ok: boolean) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
|
||||
try {
|
||||
server.close(() => resolve(ok));
|
||||
} catch {
|
||||
resolve(ok);
|
||||
}
|
||||
};
|
||||
|
||||
server.once('error', () => finalize(false));
|
||||
server.once('listening', () => finalize(true));
|
||||
|
||||
try {
|
||||
server.listen(port, host);
|
||||
} catch {
|
||||
finalize(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an available TCP port starting from `start`, advancing by 1 up to `maxTries` attempts.
|
||||
* Skips reserved/invalid port numbers.
|
||||
*/
|
||||
export async function findAvailablePort(
|
||||
start: number,
|
||||
options: { maxTries?: number; host?: string } = {},
|
||||
): Promise<number> {
|
||||
const { maxTries = 50, host = '127.0.0.1' } = options;
|
||||
let port = Math.max(1, Math.min(65535, Math.trunc(start)));
|
||||
|
||||
for (let i = 0; i < maxTries; i++) {
|
||||
if (port > 65535) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (await isPortAvailable(port, host)) {
|
||||
return port;
|
||||
}
|
||||
|
||||
port += 1;
|
||||
}
|
||||
|
||||
throw new Error(`No available TCP port found near ${start}`);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { bodyLimit } from 'hono/body-limit';
|
||||
import { deleteCookie, getCookie, setCookie } from 'hono/cookie';
|
||||
import type { Context, Hono, MiddlewareHandler } from 'hono';
|
||||
|
||||
export const LOGIN_BODY_LIMIT_BYTES = 16 * 1024;
|
||||
const AVATAR_SESSION_COOKIE = 'snowluma_avatar_session';
|
||||
const AVATAR_SESSION_MAX_AGE_SECONDS = 24 * 60 * 60;
|
||||
|
||||
function isJsonMediaType(value: string | undefined): boolean {
|
||||
const mediaType = value?.split(';', 1)[0]?.trim().toLowerCase() ?? '';
|
||||
return mediaType === 'application/json' || mediaType.endsWith('+json');
|
||||
}
|
||||
|
||||
/** Install guards before the login route is registered. */
|
||||
export function registerLoginRequestSecurity(app: Hono): void {
|
||||
app.use(
|
||||
'/api/login',
|
||||
bodyLimit({
|
||||
maxSize: LOGIN_BODY_LIMIT_BYTES,
|
||||
onError: (c) => c.json({ success: false, message: '请求体过大' }, 413),
|
||||
}),
|
||||
);
|
||||
app.use('/api/login', async (c, next) => {
|
||||
if (!isJsonMediaType(c.req.header('content-type'))) {
|
||||
return c.json({ success: false, message: 'Content-Type 必须为 application/json' }, 415);
|
||||
}
|
||||
|
||||
return next();
|
||||
});
|
||||
}
|
||||
|
||||
/** Minimal response policy that does not restrict the current script/style bundle. */
|
||||
export const webuiSecurityHeaders: MiddlewareHandler = async (c, next) => {
|
||||
await next();
|
||||
c.res.headers.set('X-Frame-Options', 'DENY');
|
||||
c.res.headers.set('X-Content-Type-Options', 'nosniff');
|
||||
c.res.headers.set('Referrer-Policy', 'no-referrer');
|
||||
c.res.headers.set(
|
||||
'Content-Security-Policy',
|
||||
"frame-ancestors 'none'; base-uri 'self'; object-src 'none'",
|
||||
);
|
||||
};
|
||||
|
||||
export function extractWebuiSessionToken(request: Request): string {
|
||||
const sessionToken = request.headers.get('x-snowluma-token');
|
||||
|
||||
// Authorization belongs to the proxy when it uses HTTP authentication.
|
||||
// Only clients without the dedicated header use the legacy Bearer transport.
|
||||
if (sessionToken !== null) {
|
||||
return sessionToken;
|
||||
}
|
||||
|
||||
const authorization = request.headers.get('authorization');
|
||||
return authorization?.startsWith('Bearer ') ? authorization.slice(7) : '';
|
||||
}
|
||||
|
||||
const avatarCookieOptions = (secure: boolean) => ({
|
||||
httpOnly: true,
|
||||
sameSite: 'Strict' as const,
|
||||
secure,
|
||||
path: '/avatar',
|
||||
});
|
||||
|
||||
export function setAvatarSessionCookie(c: Context, token: string, secure: boolean): void {
|
||||
setCookie(c, AVATAR_SESSION_COOKIE, token, {
|
||||
...avatarCookieOptions(secure),
|
||||
maxAge: AVATAR_SESSION_MAX_AGE_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
export function clearAvatarSessionCookie(c: Context, secure: boolean): void {
|
||||
deleteCookie(c, AVATAR_SESSION_COOKIE, avatarCookieOptions(secure));
|
||||
}
|
||||
|
||||
export function readAvatarSessionToken(c: Context): string {
|
||||
return getCookie(c, AVATAR_SESSION_COOKIE) ?? '';
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
import { errorMessage } from '@snowluma/common/errors';
|
||||
import { createLogger } from '@snowluma/common/logger';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { prepareRestorePlan, RestorePreflightError, type RestoreMigration } from './backup';
|
||||
|
||||
const log = createLogger('WebUI.Restore');
|
||||
|
||||
export type RestorePhase = 'preflight' | 'snapshot' | 'stage' | 'commit' | 'rollback' | 'cleanup';
|
||||
|
||||
export interface RestoreOperation {
|
||||
phase: Exclude<RestorePhase, 'preflight'>;
|
||||
operation:
|
||||
| 'copy-current'
|
||||
| 'write-staged'
|
||||
| 'chmod-staged'
|
||||
| 'rename-staged'
|
||||
| 'restore-current'
|
||||
| 'remove-created'
|
||||
| 'remove-transaction';
|
||||
file?: string;
|
||||
}
|
||||
|
||||
export interface RestoreBackupOptions {
|
||||
configDir: string;
|
||||
restoreCredentials: boolean;
|
||||
/** Deterministic id for tests/diagnostics; production normally omits it. */
|
||||
transactionId?: string;
|
||||
/** Filesystem-boundary fault hook used by the transaction fault matrix. */
|
||||
beforeOperation?: (operation: RestoreOperation) => void;
|
||||
}
|
||||
|
||||
export interface RestoreBackupResult {
|
||||
transactionId: string;
|
||||
restored: string[];
|
||||
skipped: string[];
|
||||
migrated: RestoreMigration[];
|
||||
restartRequiredToApply: boolean;
|
||||
}
|
||||
|
||||
export class RestoreTransactionError extends Error {
|
||||
readonly phase: RestorePhase;
|
||||
readonly transactionId: string;
|
||||
readonly rollbackSucceeded?: boolean;
|
||||
readonly snapshotDir?: string;
|
||||
readonly committed: boolean;
|
||||
readonly failedFiles: string[];
|
||||
|
||||
constructor(options: {
|
||||
phase: RestorePhase;
|
||||
transactionId: string;
|
||||
message: string;
|
||||
cause: unknown;
|
||||
rollbackSucceeded?: boolean;
|
||||
snapshotDir?: string;
|
||||
committed?: boolean;
|
||||
failedFiles?: string[];
|
||||
}) {
|
||||
super(options.message, { cause: options.cause });
|
||||
this.name = 'RestoreTransactionError';
|
||||
this.phase = options.phase;
|
||||
this.transactionId = options.transactionId;
|
||||
this.rollbackSucceeded = options.rollbackSucceeded;
|
||||
this.snapshotDir = options.snapshotDir;
|
||||
this.committed = options.committed ?? false;
|
||||
this.failedFiles = options.failedFiles ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
interface CommittedFile {
|
||||
name: string;
|
||||
existed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore one validated overlay as a process-level transaction.
|
||||
*
|
||||
* This deliberately does not promise recovery from power loss or SIGKILL.
|
||||
* Within the running process, every failure before commit leaves live files
|
||||
* untouched. A partial commit is rolled back when possible; incomplete
|
||||
* rollback retains the transaction directory and reports its recovery path.
|
||||
*/
|
||||
export function restoreBackup(parsed: unknown, options: RestoreBackupOptions): RestoreBackupResult {
|
||||
const transactionId = options.transactionId ?? randomUUID();
|
||||
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(transactionId)) {
|
||||
throw new Error('restore transactionId must contain only letters, numbers, _ or -');
|
||||
}
|
||||
|
||||
const configDir = path.resolve(options.configDir);
|
||||
|
||||
const readCurrent = (name: string): Buffer | null => {
|
||||
const file = path.join(configDir, name);
|
||||
|
||||
try {
|
||||
return fs.readFileSync(file);
|
||||
} catch (error) {
|
||||
if (isMissingPathError(error)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const listCurrentOneBotNames = (): string[] => {
|
||||
try {
|
||||
return fs.readdirSync(configDir);
|
||||
} catch (error) {
|
||||
if (isMissingPathError(error)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
log.info('restore transaction=%s phase=preflight', transactionId);
|
||||
let prepared: ReturnType<typeof prepareRestorePlan>;
|
||||
|
||||
try {
|
||||
prepared = prepareRestorePlan(parsed, {
|
||||
restoreCredentials: options.restoreCredentials,
|
||||
readCurrent,
|
||||
listCurrentOneBotNames,
|
||||
});
|
||||
} catch (error) {
|
||||
const file = error instanceof RestorePreflightError ? error.file : undefined;
|
||||
log.warn(
|
||||
'restore transaction=%s phase=preflight file=%s failed error=%s',
|
||||
transactionId,
|
||||
file ?? '-',
|
||||
errorMessage(error),
|
||||
);
|
||||
throw new RestoreTransactionError({
|
||||
phase: 'preflight',
|
||||
transactionId,
|
||||
message: `restore preflight failed: ${errorMessage(error)}`,
|
||||
cause: error,
|
||||
failedFiles: file ? [file] : [],
|
||||
});
|
||||
}
|
||||
|
||||
const names = prepared.restore.map((file) => file.name);
|
||||
log.info(
|
||||
'restore transaction=%s phase=preflight-complete files=%s',
|
||||
transactionId,
|
||||
names.join(','),
|
||||
);
|
||||
|
||||
if (prepared.restore.length === 0) {
|
||||
return {
|
||||
transactionId,
|
||||
restored: [],
|
||||
skipped: prepared.skipped,
|
||||
migrated: prepared.migrated,
|
||||
restartRequiredToApply: false,
|
||||
};
|
||||
}
|
||||
|
||||
const transactionDir = path.join(configDir, `.restore-transaction-${transactionId}`);
|
||||
const snapshotRoot = path.join(transactionDir, 'snapshot');
|
||||
const stagedRoot = path.join(transactionDir, 'staged');
|
||||
const rollbackRoot = path.join(transactionDir, 'rollback');
|
||||
const existed = new Map<string, boolean>();
|
||||
const committed: CommittedFile[] = [];
|
||||
let phase: 'snapshot' | 'stage' | 'commit' = 'snapshot';
|
||||
let activeFile: string | undefined;
|
||||
let transactionCreated = false;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.mkdirSync(transactionDir, { mode: 0o700 });
|
||||
transactionCreated = true;
|
||||
fs.chmodSync(transactionDir, 0o700);
|
||||
|
||||
log.info('restore transaction=%s phase=snapshot files=%s', transactionId, names.join(','));
|
||||
|
||||
for (const file of prepared.restore) {
|
||||
activeFile = file.name;
|
||||
const live = path.join(configDir, file.name);
|
||||
let wasPresent: boolean;
|
||||
|
||||
try {
|
||||
fs.lstatSync(live);
|
||||
wasPresent = true;
|
||||
} catch (error) {
|
||||
if (!isMissingPathError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
wasPresent = false;
|
||||
}
|
||||
|
||||
existed.set(file.name, wasPresent);
|
||||
|
||||
if (!wasPresent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const snapshot = path.join(snapshotRoot, file.name);
|
||||
fs.mkdirSync(path.dirname(snapshot), { recursive: true });
|
||||
invoke(options, { phase: 'snapshot', operation: 'copy-current', file: file.name });
|
||||
fs.copyFileSync(live, snapshot);
|
||||
}
|
||||
|
||||
phase = 'stage';
|
||||
log.info('restore transaction=%s phase=stage files=%s', transactionId, names.join(','));
|
||||
|
||||
for (const file of prepared.restore) {
|
||||
activeFile = file.name;
|
||||
const staged = path.join(stagedRoot, file.name);
|
||||
fs.mkdirSync(path.dirname(staged), { recursive: true });
|
||||
invoke(options, { phase: 'stage', operation: 'write-staged', file: file.name });
|
||||
fs.writeFileSync(staged, file.data, { flag: 'wx', mode: file.mode });
|
||||
invoke(options, { phase: 'stage', operation: 'chmod-staged', file: file.name });
|
||||
fs.chmodSync(staged, file.mode);
|
||||
}
|
||||
|
||||
phase = 'commit';
|
||||
log.info('restore transaction=%s phase=commit files=%s', transactionId, names.join(','));
|
||||
|
||||
for (const file of prepared.restore) {
|
||||
activeFile = file.name;
|
||||
const staged = path.join(stagedRoot, file.name);
|
||||
const live = path.join(configDir, file.name);
|
||||
fs.mkdirSync(path.dirname(live), { recursive: true });
|
||||
invoke(options, { phase: 'commit', operation: 'rename-staged', file: file.name });
|
||||
fs.renameSync(staged, live);
|
||||
committed.push({ name: file.name, existed: existed.get(file.name) === true });
|
||||
}
|
||||
|
||||
activeFile = undefined;
|
||||
} catch (error) {
|
||||
if (phase !== 'commit') {
|
||||
const cleanupError = transactionCreated
|
||||
? removeTransaction(options, transactionDir)
|
||||
: null;
|
||||
|
||||
if (cleanupError) {
|
||||
log.error(
|
||||
'restore transaction=%s phase=cleanup failed-after=%s file=%s original-error=%s cleanup-error=%s',
|
||||
transactionId,
|
||||
phase,
|
||||
activeFile ?? '-',
|
||||
errorMessage(error),
|
||||
errorMessage(cleanupError),
|
||||
);
|
||||
throw new RestoreTransactionError({
|
||||
phase: 'cleanup',
|
||||
transactionId,
|
||||
message: `restore ${phase} failed and transaction cleanup also failed`,
|
||||
cause: new AggregateError(
|
||||
[error, cleanupError],
|
||||
`restore ${phase} and cleanup both failed`,
|
||||
),
|
||||
snapshotDir: transactionDir,
|
||||
failedFiles: activeFile ? [activeFile] : [],
|
||||
});
|
||||
}
|
||||
|
||||
log.warn(
|
||||
'restore transaction=%s phase=%s file=%s failed error=%s',
|
||||
transactionId,
|
||||
phase,
|
||||
activeFile ?? '-',
|
||||
errorMessage(error),
|
||||
);
|
||||
throw new RestoreTransactionError({
|
||||
phase,
|
||||
transactionId,
|
||||
message: `restore ${phase} failed before live commit`,
|
||||
cause: error,
|
||||
failedFiles: activeFile ? [activeFile] : [],
|
||||
});
|
||||
}
|
||||
|
||||
const rollbackErrors = rollbackCommitted(
|
||||
options,
|
||||
configDir,
|
||||
snapshotRoot,
|
||||
rollbackRoot,
|
||||
committed,
|
||||
);
|
||||
|
||||
if (rollbackErrors.length > 0) {
|
||||
const failedFiles = rollbackErrors.map((entry) => entry.name);
|
||||
const rollbackDetails = rollbackErrors
|
||||
.map((entry) => `${entry.name}:${errorMessage(entry.error)}`)
|
||||
.join(';');
|
||||
log.error(
|
||||
'restore transaction=%s phase=rollback commit-file=%s commit-error=%s failed=%s snapshot=%s',
|
||||
transactionId,
|
||||
activeFile ?? '-',
|
||||
errorMessage(error),
|
||||
rollbackDetails,
|
||||
transactionDir,
|
||||
);
|
||||
throw new RestoreTransactionError({
|
||||
phase: 'rollback',
|
||||
transactionId,
|
||||
message: 'restore commit failed and rollback was incomplete',
|
||||
cause: new AggregateError(
|
||||
[error, ...rollbackErrors.map((entry) => entry.error)],
|
||||
'restore commit and rollback both failed',
|
||||
),
|
||||
rollbackSucceeded: false,
|
||||
snapshotDir: transactionDir,
|
||||
committed: committed.length > 0,
|
||||
failedFiles,
|
||||
});
|
||||
}
|
||||
|
||||
const cleanupError = removeTransaction(options, transactionDir);
|
||||
|
||||
if (cleanupError) {
|
||||
log.error(
|
||||
'restore transaction=%s phase=cleanup failed-after=rollback commit-file=%s commit-error=%s cleanup-error=%s snapshot=%s',
|
||||
transactionId,
|
||||
activeFile ?? '-',
|
||||
errorMessage(error),
|
||||
errorMessage(cleanupError),
|
||||
transactionDir,
|
||||
);
|
||||
throw new RestoreTransactionError({
|
||||
phase: 'cleanup',
|
||||
transactionId,
|
||||
message:
|
||||
'restore commit failed, rollback succeeded, but transaction cleanup failed',
|
||||
cause: new AggregateError(
|
||||
[error, cleanupError],
|
||||
'restore commit and post-rollback cleanup both failed',
|
||||
),
|
||||
rollbackSucceeded: true,
|
||||
snapshotDir: transactionDir,
|
||||
failedFiles: activeFile ? [activeFile] : [],
|
||||
});
|
||||
}
|
||||
|
||||
log.warn(
|
||||
'restore transaction=%s phase=commit file=%s failed rollback=complete error=%s',
|
||||
transactionId,
|
||||
activeFile ?? '-',
|
||||
errorMessage(error),
|
||||
);
|
||||
throw new RestoreTransactionError({
|
||||
phase: 'commit',
|
||||
transactionId,
|
||||
message: 'restore commit failed; every committed file was rolled back',
|
||||
cause: error,
|
||||
rollbackSucceeded: true,
|
||||
failedFiles: activeFile ? [activeFile] : [],
|
||||
});
|
||||
}
|
||||
|
||||
const cleanupError = removeTransaction(options, transactionDir);
|
||||
|
||||
if (cleanupError) {
|
||||
log.error(
|
||||
'restore transaction=%s phase=cleanup failed-after=commit cleanup-error=%s snapshot=%s',
|
||||
transactionId,
|
||||
errorMessage(cleanupError),
|
||||
transactionDir,
|
||||
);
|
||||
throw new RestoreTransactionError({
|
||||
phase: 'cleanup',
|
||||
transactionId,
|
||||
message: 'restore committed successfully, but transaction cleanup failed',
|
||||
cause: cleanupError,
|
||||
snapshotDir: transactionDir,
|
||||
committed: true,
|
||||
});
|
||||
}
|
||||
|
||||
log.info('restore transaction=%s phase=complete files=%s', transactionId, names.join(','));
|
||||
return {
|
||||
transactionId,
|
||||
restored: names,
|
||||
skipped: prepared.skipped,
|
||||
migrated: prepared.migrated,
|
||||
restartRequiredToApply: true,
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackCommitted(
|
||||
options: RestoreBackupOptions,
|
||||
configDir: string,
|
||||
snapshotRoot: string,
|
||||
rollbackRoot: string,
|
||||
committed: readonly CommittedFile[],
|
||||
): Array<{ name: string; error: unknown }> {
|
||||
const errors: Array<{ name: string; error: unknown }> = [];
|
||||
|
||||
for (const file of [...committed].reverse()) {
|
||||
const live = path.join(configDir, file.name);
|
||||
|
||||
try {
|
||||
if (!file.existed) {
|
||||
invoke(options, {
|
||||
phase: 'rollback',
|
||||
operation: 'remove-created',
|
||||
file: file.name,
|
||||
});
|
||||
fs.rmSync(live, { force: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
const snapshot = path.join(snapshotRoot, file.name);
|
||||
const rollback = path.join(rollbackRoot, file.name);
|
||||
fs.mkdirSync(path.dirname(rollback), { recursive: true });
|
||||
invoke(options, { phase: 'rollback', operation: 'restore-current', file: file.name });
|
||||
fs.copyFileSync(snapshot, rollback);
|
||||
fs.chmodSync(rollback, fs.statSync(snapshot).mode & 0o777);
|
||||
fs.renameSync(rollback, live);
|
||||
} catch (error) {
|
||||
errors.push({ name: file.name, error });
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function removeTransaction(options: RestoreBackupOptions, transactionDir: string): unknown | null {
|
||||
try {
|
||||
invoke(options, { phase: 'cleanup', operation: 'remove-transaction' });
|
||||
fs.rmSync(transactionDir, { recursive: true, force: true });
|
||||
return null;
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
|
||||
function invoke(options: RestoreBackupOptions, operation: RestoreOperation): void {
|
||||
options.beforeOperation?.(operation);
|
||||
}
|
||||
|
||||
function isMissingPathError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT'
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,111 @@
|
||||
import { createLogger } from '@snowluma/common/logger';
|
||||
|
||||
const log = createLogger('WebUI.SSE');
|
||||
|
||||
export interface SseChannel {
|
||||
raw(chunk: Uint8Array): void;
|
||||
encode(s: string): Uint8Array;
|
||||
send(payload: unknown): void;
|
||||
onClose(cleanup: () => void): void;
|
||||
close(): void;
|
||||
isClosed(): boolean;
|
||||
desiredSize(): number | null;
|
||||
}
|
||||
|
||||
/** Owns stream cancellation, subscriptions and the keep-alive timer. */
|
||||
export function sseResponse(
|
||||
request: AbortSignal,
|
||||
start: (channel: SseChannel) => void,
|
||||
shutdown?: AbortSignal,
|
||||
): Response {
|
||||
const signal = shutdown ? AbortSignal.any([request, shutdown]) : request;
|
||||
let cancel!: () => void;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
let closed = false;
|
||||
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
const cleanup = (fn: () => void) => {
|
||||
try {
|
||||
fn();
|
||||
} catch (error) {
|
||||
log.warn('stream cleanup failed: %s', error);
|
||||
}
|
||||
};
|
||||
|
||||
const teardown = (closeStream = true) => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
closed = true;
|
||||
clearInterval(heartbeat);
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
|
||||
if (closeStream) {
|
||||
controller.close();
|
||||
}
|
||||
|
||||
cleanups.splice(0).forEach(cleanup);
|
||||
};
|
||||
|
||||
const onAbort = () => teardown();
|
||||
cancel = () => teardown(false);
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
|
||||
if (signal.aborted) {
|
||||
teardown();
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = (chunk: Uint8Array) => {
|
||||
if (!closed) {
|
||||
controller.enqueue(chunk);
|
||||
}
|
||||
};
|
||||
|
||||
const channel: SseChannel = {
|
||||
raw,
|
||||
encode: (text) => encoder.encode(text),
|
||||
send: (payload) => {
|
||||
if (!closed) {
|
||||
raw(encoder.encode(`data: ${JSON.stringify(payload)}\n\n`));
|
||||
}
|
||||
},
|
||||
onClose: (fn) => {
|
||||
if (closed) {
|
||||
cleanup(fn);
|
||||
} else {
|
||||
cleanups.push(fn);
|
||||
}
|
||||
},
|
||||
close: () => teardown(),
|
||||
isClosed: () => closed,
|
||||
desiredSize: () => controller.desiredSize,
|
||||
};
|
||||
|
||||
try {
|
||||
start(channel);
|
||||
|
||||
if (!closed) {
|
||||
heartbeat = setInterval(() => raw(encoder.encode(': heartbeat\n\n')), 15000);
|
||||
}
|
||||
} catch (error) {
|
||||
teardown();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
cancel();
|
||||
},
|
||||
});
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createLogger } from '@snowluma/common/logger';
|
||||
|
||||
const log = createLogger('WebUI.State');
|
||||
|
||||
export type StateResource = 'processes' | 'qq-list' | 'connections';
|
||||
|
||||
export type StateListener = (resource: StateResource) => void;
|
||||
|
||||
export class StateBus {
|
||||
private readonly listeners = new Set<StateListener>();
|
||||
private disposed = false;
|
||||
|
||||
/** Subscribe to every publish; returns an unsubscribe handle. Calling
|
||||
* the handle more than once is a no-op. */
|
||||
subscribe(listener: StateListener): () => void {
|
||||
if (this.disposed) {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
this.listeners.add(listener);
|
||||
let active = true;
|
||||
|
||||
return () => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
active = false;
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/** Notify every CURRENTLY-subscribed listener that `resource` is stale.
|
||||
* Iterates a frozen snapshot of the subscriber set so add/remove during
|
||||
* the emit don't change which listeners see THIS publish; subscriber
|
||||
* exceptions are isolated and do not abort the fan-out. */
|
||||
publish(resource: StateResource): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = [...this.listeners];
|
||||
|
||||
for (const fn of snapshot) {
|
||||
try {
|
||||
fn(resource);
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
'state listener failed: resource=%s error=%s',
|
||||
resource,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop every subscriber and refuse future subscribes / publishes. */
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { errorMessage } from '@snowluma/common/errors';
|
||||
import { createLogger } from '@snowluma/common/logger';
|
||||
import type { StateBus, StateResource } from './state-bus';
|
||||
|
||||
const log = createLogger('WebUI.StateStream');
|
||||
|
||||
export interface StateStreamFrame {
|
||||
resource: StateResource;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
export interface BindStateStreamOptions {
|
||||
bus: StateBus;
|
||||
/** Read the current snapshot for one resource. May be sync or async. */
|
||||
snapshot: (resource: StateResource) => unknown | Promise<unknown>;
|
||||
/** Receive a fresh frame; called once per debounced publish.
|
||||
* Exceptions are caught — a dead controller must not abort the bus
|
||||
* subscription or break sibling resources. */
|
||||
send: (frame: StateStreamFrame) => void;
|
||||
/** Coalesce window in milliseconds; default 50. */
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
||||
export interface StateStreamHandle {
|
||||
/** Emit one frame per resource using the current snapshot. Use on connect. */
|
||||
sendAllInitial(): Promise<void>;
|
||||
/** Stop subscribing + cancel pending timers. Subsequent bus publishes
|
||||
* produce nothing through this binding. */
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
const ALL_RESOURCES: readonly StateResource[] = ['processes', 'qq-list', 'connections'];
|
||||
|
||||
export function bindStateStream(opts: BindStateStreamOptions): StateStreamHandle {
|
||||
const debounceMs = opts.debounceMs ?? 50;
|
||||
let disposed = false;
|
||||
|
||||
function resourceStream(resource: StateResource) {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let task: Promise<void> | null = null;
|
||||
let pending = false;
|
||||
|
||||
function clearTimer() {
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function flush(): Promise<void> {
|
||||
if (disposed) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (task) {
|
||||
return task;
|
||||
}
|
||||
|
||||
clearTimer();
|
||||
task = Promise.resolve()
|
||||
.then(async () => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await opts.snapshot(resource);
|
||||
|
||||
if (!disposed) {
|
||||
opts.send({ resource, data });
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
log.warn('state stream %s failed: %s', resource, errorMessage(error));
|
||||
})
|
||||
.finally(() => {
|
||||
task = null;
|
||||
|
||||
if (pending) {
|
||||
pending = false;
|
||||
schedule();
|
||||
}
|
||||
});
|
||||
return task;
|
||||
}
|
||||
|
||||
function schedule(): void {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (task) {
|
||||
pending = true;
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimer();
|
||||
timer = setTimeout(() => void flush(), debounceMs);
|
||||
}
|
||||
|
||||
return { flush, schedule, clearTimer };
|
||||
}
|
||||
|
||||
const streams = {
|
||||
processes: resourceStream('processes'),
|
||||
'qq-list': resourceStream('qq-list'),
|
||||
connections: resourceStream('connections'),
|
||||
};
|
||||
const unsubscribe = opts.bus.subscribe((resource) => streams[resource].schedule());
|
||||
|
||||
return {
|
||||
async sendAllInitial(): Promise<void> {
|
||||
await Promise.all(ALL_RESOURCES.map((resource) => streams[resource].flush()));
|
||||
},
|
||||
dispose(): void {
|
||||
disposed = true;
|
||||
unsubscribe();
|
||||
ALL_RESOURCES.forEach((resource) => streams[resource].clearTimer());
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { StateBus } from './state-bus';
|
||||
import type { BridgeSessionSource } from '../bridge/bridge-interface';
|
||||
|
||||
export interface StateWiring {
|
||||
readonly bus: StateBus;
|
||||
onSessionsChanged(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export function createStateWiring(bridgeManager: BridgeSessionSource): StateWiring {
|
||||
const bus = new StateBus();
|
||||
|
||||
const publishAccountChange = (): void => {
|
||||
bus.publish('qq-list');
|
||||
bus.publish('connections');
|
||||
};
|
||||
|
||||
const unsubscribe = bridgeManager.subscribeSessions({
|
||||
started: publishAccountChange,
|
||||
closed: publishAccountChange,
|
||||
});
|
||||
|
||||
return {
|
||||
bus,
|
||||
onSessionsChanged: () => bus.publish('processes'),
|
||||
dispose(): void {
|
||||
bus.dispose();
|
||||
unsubscribe();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { Hono, Context } from 'hono';
|
||||
import { serveStatic } from '@hono/node-server/serve-static';
|
||||
import { injectWebuiTarget, type WebuiTarget } from '@snowluma/common/webui-target';
|
||||
|
||||
/** All document entry paths share runtime injection, including direct index.html requests. */
|
||||
export function registerStaticFrontend(app: Hono, root: string, target: WebuiTarget | null): void {
|
||||
const indexPath = path.join(root, 'index.html');
|
||||
|
||||
const document = (c: Context) => {
|
||||
if (!existsSync(indexPath)) {
|
||||
return c.text(
|
||||
'WebUI client bundle not found. Run `pnpm --filter webui build` (or use the dev server on :5178).',
|
||||
404,
|
||||
);
|
||||
}
|
||||
|
||||
// A packaged frontend can be reused with a different deployment target on restart.
|
||||
c.header('Cache-Control', 'no-store');
|
||||
return c.html(injectWebuiTarget(readFileSync(indexPath, 'utf8'), target));
|
||||
};
|
||||
|
||||
app.get('/', document);
|
||||
app.get('/index.html', document);
|
||||
app.use('/*', serveStatic({ root }));
|
||||
app.get('*', (c) => {
|
||||
if (['/api/', '/avatar/', '/ui-asset/'].some((prefix) => c.req.path.startsWith(prefix))) {
|
||||
return c.text('not found', 404);
|
||||
}
|
||||
|
||||
return document(c);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import { errorMessage } from '@snowluma/common/errors';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { LogCleanupResult, LogStorageStatus } from '@snowluma/common/log-file-transport';
|
||||
import { isRealUin } from '@snowluma/common/uin';
|
||||
|
||||
export interface TemporaryStorageSnapshot {
|
||||
totalBytes: number;
|
||||
fileCount: number;
|
||||
activeItemCount: number;
|
||||
}
|
||||
|
||||
export interface TemporaryCleanupResult {
|
||||
deletedFiles: number;
|
||||
freedBytes: number;
|
||||
skippedActiveItems: number;
|
||||
failures: Array<{ item: string; message: string }>;
|
||||
}
|
||||
|
||||
export interface TemporaryStorageAdapter {
|
||||
snapshot(): TemporaryStorageSnapshot;
|
||||
clearInactive(): TemporaryCleanupResult;
|
||||
}
|
||||
|
||||
export interface OnlineStorageAccount {
|
||||
uin: string;
|
||||
nickname: string;
|
||||
}
|
||||
|
||||
export interface AccountStorageSnapshot {
|
||||
uin: string;
|
||||
nickname?: string;
|
||||
online: boolean;
|
||||
messagesBytes: number;
|
||||
mediaBytes: number;
|
||||
reactionsBytes: number;
|
||||
totalBytes: number;
|
||||
}
|
||||
|
||||
export type PublicLogStorageStatus = Omit<LogStorageStatus, 'directory'>;
|
||||
|
||||
export function publicLogStorageStatus(status: LogStorageStatus): PublicLogStorageStatus {
|
||||
const { directory, ...publicStatus } = status;
|
||||
|
||||
if (!publicStatus.lastError) {
|
||||
return publicStatus;
|
||||
}
|
||||
|
||||
return {
|
||||
...publicStatus,
|
||||
lastError: redactFilesystemPath(publicStatus.lastError, directory, '[日志目录]'),
|
||||
};
|
||||
}
|
||||
|
||||
export interface PublicLogCleanupResult extends Omit<LogCleanupResult, 'status'> {
|
||||
status: PublicLogStorageStatus;
|
||||
}
|
||||
|
||||
export interface StorageSnapshot {
|
||||
logs: PublicLogStorageStatus;
|
||||
temporary: TemporaryStorageSnapshot;
|
||||
accounts: AccountStorageSnapshot[];
|
||||
totals: {
|
||||
logsBytes: number;
|
||||
temporaryBytes: number;
|
||||
accountDataBytes: number;
|
||||
managedBytes: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type AccountStorageCategory = 'messages' | 'media' | 'reactions';
|
||||
|
||||
export interface AccountDataCleanupResult {
|
||||
category: AccountStorageCategory;
|
||||
uins: string[];
|
||||
deletedFiles: number;
|
||||
freedBytes: number;
|
||||
failures: Array<{ uin: string; file: string; message: string }>;
|
||||
}
|
||||
|
||||
export class StorageManagementInputError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'StorageManagementInputError';
|
||||
}
|
||||
}
|
||||
|
||||
export class StorageAccountOnlineError extends Error {
|
||||
constructor(readonly uins: string[]) {
|
||||
super(`account data cleanup requires offline accounts: ${uins.join(', ')}`);
|
||||
this.name = 'StorageAccountOnlineError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface StorageManagementDependencies {
|
||||
dataDir: string;
|
||||
getLogStatus(): LogStorageStatus;
|
||||
clearLogs(): Promise<LogCleanupResult>;
|
||||
temporary: TemporaryStorageAdapter;
|
||||
listOnlineAccounts(): OnlineStorageAccount[];
|
||||
}
|
||||
|
||||
const DATABASE_FILES = {
|
||||
messages: ['messages.db', 'messages.db-wal', 'messages.db-shm', 'messages.db-journal'],
|
||||
media: ['media.db', 'media.db-wal', 'media.db-shm', 'media.db-journal'],
|
||||
reactions: ['reactions.db', 'reactions.db-wal', 'reactions.db-shm', 'reactions.db-journal'],
|
||||
} as const;
|
||||
|
||||
export class StorageManagementService {
|
||||
constructor(private readonly deps: StorageManagementDependencies) {}
|
||||
|
||||
snapshot(): StorageSnapshot {
|
||||
const logStatus = this.deps.getLogStatus();
|
||||
const logs = publicLogStorageStatus(logStatus);
|
||||
const temporary = this.deps.temporary.snapshot();
|
||||
const accounts = this.accountSnapshots();
|
||||
const accountDataBytes = accounts.reduce((sum, account) => sum + account.totalBytes, 0);
|
||||
return {
|
||||
logs,
|
||||
temporary,
|
||||
accounts,
|
||||
totals: {
|
||||
logsBytes: logs.totalBytes,
|
||||
temporaryBytes: temporary.totalBytes,
|
||||
accountDataBytes,
|
||||
managedBytes: logs.totalBytes + temporary.totalBytes + accountDataBytes,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async clearLogs(): Promise<PublicLogCleanupResult> {
|
||||
const result = await this.deps.clearLogs();
|
||||
return {
|
||||
...result,
|
||||
failures: result.failures.map((failure) => ({
|
||||
...failure,
|
||||
message: redactFilesystemPath(
|
||||
failure.message,
|
||||
result.status.directory,
|
||||
'[日志目录]',
|
||||
),
|
||||
})),
|
||||
status: publicLogStorageStatus(result.status),
|
||||
};
|
||||
}
|
||||
|
||||
clearTemporary(): TemporaryCleanupResult {
|
||||
return this.deps.temporary.clearInactive();
|
||||
}
|
||||
|
||||
clearAccountData(category: string, uin: string): AccountDataCleanupResult {
|
||||
const validatedCategory = validateAccountStorageCategory(category);
|
||||
|
||||
if (!isRealUin(uin)) {
|
||||
throw new StorageManagementInputError(`invalid UIN: ${uin}`);
|
||||
}
|
||||
|
||||
return this.clearAccountDataForUins(validatedCategory, [uin]);
|
||||
}
|
||||
|
||||
clearAllAccountData(category: string): AccountDataCleanupResult {
|
||||
const validatedCategory = validateAccountStorageCategory(category);
|
||||
const uins = listDirectoryNames(this.deps.dataDir)
|
||||
.filter((name) => isRealUin(name))
|
||||
.sort((a, b) => Number(a) - Number(b));
|
||||
return this.clearAccountDataForUins(validatedCategory, uins);
|
||||
}
|
||||
|
||||
private clearAccountDataForUins(
|
||||
category: AccountStorageCategory,
|
||||
uins: string[],
|
||||
): AccountDataCleanupResult {
|
||||
const hasManagedRoot = assertManagedDataRootOrMissing(this.deps.dataDir);
|
||||
const onlineUins = new Set(
|
||||
this.deps
|
||||
.listOnlineAccounts()
|
||||
.map((account) => account.uin)
|
||||
.filter((uin) => isRealUin(uin)),
|
||||
);
|
||||
const blockedUins = uins.filter((uin) => onlineUins.has(uin));
|
||||
|
||||
if (blockedUins.length > 0) {
|
||||
throw new StorageAccountOnlineError(blockedUins);
|
||||
}
|
||||
|
||||
const failures: AccountDataCleanupResult['failures'] = [];
|
||||
let deletedFiles = 0;
|
||||
let freedBytes = 0;
|
||||
|
||||
if (!hasManagedRoot) {
|
||||
return { category, uins, deletedFiles, freedBytes, failures };
|
||||
}
|
||||
|
||||
for (const uin of uins) {
|
||||
const dir = path.join(this.deps.dataDir, uin);
|
||||
|
||||
if (!isManagedDirectory(dir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const name of DATABASE_FILES[category]) {
|
||||
const filePath = path.join(dir, name);
|
||||
let bytes: number;
|
||||
|
||||
try {
|
||||
const stat = fs.lstatSync(filePath);
|
||||
|
||||
if (!stat.isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
bytes = stat.size;
|
||||
} catch (error) {
|
||||
if (isMissing(error)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
failures.push({
|
||||
uin,
|
||||
file: name,
|
||||
message: redactFilesystemPath(
|
||||
errorMessage(error),
|
||||
filePath,
|
||||
'[账号数据库]',
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
deletedFiles += 1;
|
||||
freedBytes += bytes;
|
||||
} catch (error) {
|
||||
if (isMissing(error)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
failures.push({
|
||||
uin,
|
||||
file: name,
|
||||
message: redactFilesystemPath(
|
||||
errorMessage(error),
|
||||
filePath,
|
||||
'[账号数据库]',
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { category, uins, deletedFiles, freedBytes, failures };
|
||||
}
|
||||
|
||||
private accountSnapshots(): AccountStorageSnapshot[] {
|
||||
const online = new Map(
|
||||
this.deps
|
||||
.listOnlineAccounts()
|
||||
.filter((account) => isRealUin(account.uin))
|
||||
.map((account) => [account.uin, account.nickname]),
|
||||
);
|
||||
const uins = new Set(online.keys());
|
||||
|
||||
for (const name of listDirectoryNames(this.deps.dataDir)) {
|
||||
if (isRealUin(name)) {
|
||||
uins.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
return [...uins]
|
||||
.sort((a, b) => Number(a) - Number(b))
|
||||
.map((uin) => {
|
||||
const dir = path.join(this.deps.dataDir, uin);
|
||||
const managedDirectory = isManagedDirectory(dir);
|
||||
const messagesBytes = managedDirectory
|
||||
? sumAllowlistedFiles(dir, DATABASE_FILES.messages)
|
||||
: 0;
|
||||
const mediaBytes = managedDirectory
|
||||
? sumAllowlistedFiles(dir, DATABASE_FILES.media)
|
||||
: 0;
|
||||
const reactionsBytes = managedDirectory
|
||||
? sumAllowlistedFiles(dir, DATABASE_FILES.reactions)
|
||||
: 0;
|
||||
const nickname = online.get(uin);
|
||||
return {
|
||||
uin,
|
||||
...(nickname ? { nickname } : {}),
|
||||
online: online.has(uin),
|
||||
messagesBytes,
|
||||
mediaBytes,
|
||||
reactionsBytes,
|
||||
totalBytes: messagesBytes + mediaBytes + reactionsBytes,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function listDirectoryNames(dir: string): string[] {
|
||||
if (!assertManagedDataRootOrMissing(dir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name);
|
||||
} catch (error) {
|
||||
if (isMissing(error)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function assertManagedDataRootOrMissing(dir: string): boolean {
|
||||
try {
|
||||
const stat = fs.lstatSync(dir);
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error('managed data root must be a real directory');
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isMissing(error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function sumAllowlistedFiles(dir: string, names: readonly string[]): number {
|
||||
let total = 0;
|
||||
|
||||
for (const name of names) {
|
||||
const filePath = path.join(dir, name);
|
||||
|
||||
try {
|
||||
const stat = fs.lstatSync(filePath);
|
||||
|
||||
if (stat.isFile()) {
|
||||
total += stat.size;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isMissing(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
function isMissing(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
||||
}
|
||||
|
||||
function isManagedDirectory(dir: string): boolean {
|
||||
try {
|
||||
return fs.lstatSync(dir).isDirectory();
|
||||
} catch (error) {
|
||||
if (isMissing(error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function validateAccountStorageCategory(value: string): AccountStorageCategory {
|
||||
if (value === 'messages' || value === 'media' || value === 'reactions') {
|
||||
return value;
|
||||
}
|
||||
|
||||
throw new StorageManagementInputError(`unsupported account storage category: ${value}`);
|
||||
}
|
||||
|
||||
function redactFilesystemPath(message: string, filePath: string, label: string): string {
|
||||
const absolute = path.resolve(filePath);
|
||||
return message.split(absolute).join(label).split(filePath).join(label);
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import { isRealUin } from '@snowluma/common/uin';
|
||||
import { isObject } from '@snowluma/common/coerce';
|
||||
import type { Hono } from 'hono';
|
||||
import {
|
||||
StorageAccountOnlineError,
|
||||
StorageManagementInputError,
|
||||
type AccountDataCleanupResult,
|
||||
type AccountStorageCategory,
|
||||
type PublicLogCleanupResult,
|
||||
type StorageManagementService,
|
||||
type StorageSnapshot,
|
||||
type TemporaryCleanupResult,
|
||||
} from './storage-management';
|
||||
import {
|
||||
StorageSettingsInputError,
|
||||
StorageSettingsLockedError,
|
||||
StorageSettingsTransactionError,
|
||||
type LogStorageSettingsManager,
|
||||
} from './storage-settings';
|
||||
|
||||
export const ALL_ACCOUNTS_CONFIRMATION = '清理全部账号';
|
||||
|
||||
type StorageCleanupRequest =
|
||||
| { scope: 'logs' }
|
||||
| { scope: 'temporary' }
|
||||
| { scope: 'account'; category: AccountStorageCategory; uin: string }
|
||||
| {
|
||||
scope: 'allAccounts';
|
||||
category: AccountStorageCategory;
|
||||
confirmation: typeof ALL_ACCOUNTS_CONFIRMATION;
|
||||
};
|
||||
|
||||
type StorageCleanupResult =
|
||||
PublicLogCleanupResult | TemporaryCleanupResult | AccountDataCleanupResult;
|
||||
|
||||
export interface StorageCleanupAuditEvent {
|
||||
scope: StorageCleanupRequest['scope'];
|
||||
accountScope: 'global' | 'single' | 'all';
|
||||
category?: AccountStorageCategory;
|
||||
uin?: string;
|
||||
deletedFiles: number;
|
||||
freedBytes: number;
|
||||
failureCount: number;
|
||||
}
|
||||
|
||||
export interface LastStorageCleanup extends StorageCleanupAuditEvent {
|
||||
at: string;
|
||||
skippedActiveItems?: number;
|
||||
failures: Array<{ item: string; message: string }>;
|
||||
}
|
||||
|
||||
export interface StorageRouteDependencies {
|
||||
storage: Pick<
|
||||
StorageManagementService,
|
||||
'snapshot' | 'clearLogs' | 'clearTemporary' | 'clearAccountData' | 'clearAllAccountData'
|
||||
>;
|
||||
settings: Pick<LogStorageSettingsManager, 'read' | 'update'>;
|
||||
audit(event: StorageCleanupAuditEvent): void;
|
||||
reportError(operation: string, error: unknown): void;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
export function registerStorageRoutes(app: Hono, deps: StorageRouteDependencies): void {
|
||||
let lastCleanup: LastStorageCleanup | null = null;
|
||||
let mutationQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
const serializeMutation = <T>(operation: () => Promise<T>): Promise<T> => {
|
||||
const result = mutationQueue.then(operation);
|
||||
mutationQueue = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return result;
|
||||
};
|
||||
|
||||
app.get('/api/system/storage', (c) => {
|
||||
try {
|
||||
return c.json({
|
||||
settings: deps.settings.read(),
|
||||
snapshot: deps.storage.snapshot(),
|
||||
lastCleanup,
|
||||
});
|
||||
} catch (error) {
|
||||
deps.reportError('read storage snapshot', error);
|
||||
return c.json({ success: false, message: '读取存储信息失败,请检查服务器日志' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/system/storage/settings', async (c) => {
|
||||
let body: unknown;
|
||||
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ success: false, message: '请求格式错误' }, 400);
|
||||
}
|
||||
|
||||
let result: Awaited<ReturnType<LogStorageSettingsManager['update']>>;
|
||||
|
||||
try {
|
||||
result = await serializeMutation(() => deps.settings.update(body));
|
||||
} catch (error) {
|
||||
if (error instanceof StorageSettingsInputError) {
|
||||
return c.json({ success: false, message: error.message }, 400);
|
||||
}
|
||||
|
||||
if (error instanceof StorageSettingsLockedError) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
message: '这些设置已由环境变量锁定',
|
||||
lockedFields: error.fields,
|
||||
},
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
deps.reportError('update log storage settings', error);
|
||||
|
||||
if (error instanceof StorageSettingsTransactionError) {
|
||||
deps.reportError('update log storage settings operation', error.operationError);
|
||||
error.rollbackErrors.forEach((rollbackError, index) => {
|
||||
deps.reportError(
|
||||
`rollback log storage settings step=${String(index + 1)}`,
|
||||
rollbackError,
|
||||
);
|
||||
});
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
message:
|
||||
error.rollbackErrors.length > 0
|
||||
? '保存失败且回滚不完整,请立即检查服务器日志'
|
||||
: '保存失败,原设置已恢复',
|
||||
},
|
||||
500,
|
||||
);
|
||||
}
|
||||
|
||||
return c.json({ success: false, message: '保存失败,请检查服务器日志' }, 500);
|
||||
}
|
||||
|
||||
try {
|
||||
return c.json({
|
||||
success: true,
|
||||
settings: result.settings,
|
||||
status: result.status,
|
||||
snapshot: deps.storage.snapshot(),
|
||||
});
|
||||
} catch (error) {
|
||||
deps.reportError('refresh storage snapshot after settings update', error);
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
message: '设置已生效,但刷新存储统计失败,请检查服务器日志',
|
||||
settings: result.settings,
|
||||
status: result.status,
|
||||
},
|
||||
500,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/system/storage/cleanup', async (c) => {
|
||||
let body: unknown;
|
||||
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ success: false, message: '请求格式错误' }, 400);
|
||||
}
|
||||
|
||||
let request: StorageCleanupRequest;
|
||||
|
||||
try {
|
||||
request = parseCleanupRequest(body);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '请求格式错误';
|
||||
return c.json({ success: false, message }, 400);
|
||||
}
|
||||
|
||||
let cleanup: StorageCleanupResult;
|
||||
|
||||
try {
|
||||
cleanup = await serializeMutation(() => executeCleanup(deps.storage, request));
|
||||
} catch (error) {
|
||||
if (error instanceof StorageAccountOnlineError) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
message: '账号在线,必须先下线后才能清理数据库',
|
||||
onlineUins: error.uins,
|
||||
},
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof StorageManagementInputError) {
|
||||
return c.json({ success: false, message: error.message }, 400);
|
||||
}
|
||||
|
||||
deps.reportError(`clean storage scope=${request.scope}`, error);
|
||||
return c.json({ success: false, message: '清理失败,请检查服务器日志' }, 500);
|
||||
}
|
||||
|
||||
const auditEvent: StorageCleanupAuditEvent = {
|
||||
scope: request.scope,
|
||||
accountScope: accountScope(request),
|
||||
...('category' in request ? { category: request.category } : {}),
|
||||
...(request.scope === 'account' ? { uin: request.uin } : {}),
|
||||
deletedFiles: cleanup.deletedFiles,
|
||||
freedBytes: cleanup.freedBytes,
|
||||
failureCount: cleanup.failures.length,
|
||||
};
|
||||
deps.audit(auditEvent);
|
||||
|
||||
for (const failure of cleanup.failures) {
|
||||
deps.reportError(
|
||||
`clean storage scope=${request.scope} item=${cleanupFailureItem(failure)}`,
|
||||
new Error(failure.message),
|
||||
);
|
||||
}
|
||||
|
||||
lastCleanup = {
|
||||
at: (deps.now?.() ?? new Date()).toISOString(),
|
||||
...auditEvent,
|
||||
...('skippedActiveItems' in cleanup
|
||||
? { skippedActiveItems: cleanup.skippedActiveItems }
|
||||
: {}),
|
||||
failures: cleanup.failures.map((failure) => ({
|
||||
item: cleanupFailureItem(failure),
|
||||
message: failure.message,
|
||||
})),
|
||||
};
|
||||
|
||||
let snapshot: StorageSnapshot;
|
||||
|
||||
try {
|
||||
snapshot = deps.storage.snapshot();
|
||||
} catch (error) {
|
||||
deps.reportError(
|
||||
`refresh storage snapshot after cleanup scope=${request.scope}`,
|
||||
error,
|
||||
);
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
message: '清理已执行,但刷新存储统计失败,请检查服务器日志',
|
||||
scope: request.scope,
|
||||
cleanup,
|
||||
lastCleanup,
|
||||
},
|
||||
500,
|
||||
);
|
||||
}
|
||||
|
||||
const success = cleanup.failures.length === 0;
|
||||
return c.json(
|
||||
{
|
||||
success,
|
||||
...(success ? {} : { message: '部分文件清理失败,请检查失败明细' }),
|
||||
scope: request.scope,
|
||||
cleanup,
|
||||
snapshot,
|
||||
lastCleanup,
|
||||
},
|
||||
success ? 200 : 500,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function parseCleanupRequest(body: unknown): StorageCleanupRequest {
|
||||
if (!isObject(body) || typeof body.scope !== 'string') {
|
||||
throw new StorageManagementInputError('scope is required');
|
||||
}
|
||||
|
||||
if (body.scope === 'logs' || body.scope === 'temporary') {
|
||||
assertExactKeys(body, ['scope']);
|
||||
return { scope: body.scope };
|
||||
}
|
||||
|
||||
if (body.scope === 'account') {
|
||||
assertExactKeys(body, ['scope', 'category', 'uin']);
|
||||
const category = parseCategory(body.category);
|
||||
|
||||
if (typeof body.uin !== 'string' || !isRealUin(body.uin)) {
|
||||
throw new StorageManagementInputError('invalid UIN');
|
||||
}
|
||||
|
||||
return { scope: 'account', category, uin: body.uin };
|
||||
}
|
||||
|
||||
if (body.scope === 'allAccounts') {
|
||||
assertExactKeys(body, ['scope', 'category', 'confirmation']);
|
||||
const category = parseCategory(body.category);
|
||||
|
||||
if (body.confirmation !== ALL_ACCOUNTS_CONFIRMATION) {
|
||||
throw new StorageManagementInputError('all-account cleanup requires confirmation');
|
||||
}
|
||||
|
||||
return {
|
||||
scope: 'allAccounts',
|
||||
category,
|
||||
confirmation: ALL_ACCOUNTS_CONFIRMATION,
|
||||
};
|
||||
}
|
||||
|
||||
throw new StorageManagementInputError(`unsupported cleanup scope: ${body.scope}`);
|
||||
}
|
||||
|
||||
async function executeCleanup(
|
||||
storage: StorageRouteDependencies['storage'],
|
||||
request: StorageCleanupRequest,
|
||||
): Promise<StorageCleanupResult> {
|
||||
if (request.scope === 'logs') {
|
||||
return storage.clearLogs();
|
||||
}
|
||||
|
||||
if (request.scope === 'temporary') {
|
||||
return storage.clearTemporary();
|
||||
}
|
||||
|
||||
if (request.scope === 'account') {
|
||||
return storage.clearAccountData(request.category, request.uin);
|
||||
}
|
||||
|
||||
return storage.clearAllAccountData(request.category);
|
||||
}
|
||||
|
||||
function parseCategory(value: unknown): AccountStorageCategory {
|
||||
if (value === 'messages' || value === 'media' || value === 'reactions') {
|
||||
return value;
|
||||
}
|
||||
|
||||
throw new StorageManagementInputError('unsupported account storage category');
|
||||
}
|
||||
|
||||
function assertExactKeys(body: Record<string, unknown>, allowed: string[]): void {
|
||||
const allowedSet = new Set(allowed);
|
||||
const unknown = Object.keys(body).filter((key) => !allowedSet.has(key));
|
||||
const missing = allowed.filter((key) => !(key in body));
|
||||
|
||||
if (unknown.length > 0 || missing.length > 0) {
|
||||
throw new StorageManagementInputError('cleanup request has unexpected or missing fields');
|
||||
}
|
||||
}
|
||||
|
||||
function accountScope(request: StorageCleanupRequest): StorageCleanupAuditEvent['accountScope'] {
|
||||
if (request.scope === 'account') {
|
||||
return 'single';
|
||||
}
|
||||
|
||||
if (request.scope === 'allAccounts') {
|
||||
return 'all';
|
||||
}
|
||||
|
||||
return 'global';
|
||||
}
|
||||
|
||||
function cleanupFailureItem(failure: StorageCleanupResult['failures'][number]): string {
|
||||
if ('uin' in failure) {
|
||||
return `${failure.uin}/${failure.file}`;
|
||||
}
|
||||
|
||||
if ('file' in failure) {
|
||||
return failure.file;
|
||||
}
|
||||
|
||||
return failure.item;
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import { isObject } from '@snowluma/common/coerce';
|
||||
import type { LogStoragePolicy, LogStorageStatus } from '@snowluma/common/log-file-transport';
|
||||
import {
|
||||
DEFAULT_LOG_MAX_TOTAL_MB,
|
||||
DEFAULT_LOG_PER_UIN,
|
||||
DEFAULT_LOG_RETAIN_DAYS,
|
||||
MAX_LOG_RETAIN_DAYS,
|
||||
MAX_LOG_TOTAL_MB,
|
||||
type RuntimeConfig,
|
||||
} from '@snowluma/common/runtime';
|
||||
import { publicLogStorageStatus, type PublicLogStorageStatus } from './storage-management';
|
||||
|
||||
export type LogStorageSettingsField = 'logMaxTotalMb' | 'logRetainDays' | 'logPerUin';
|
||||
|
||||
export interface LogStorageSettings {
|
||||
logMaxTotalMb: number;
|
||||
logRetainDays: number;
|
||||
logPerUin: boolean;
|
||||
}
|
||||
|
||||
export interface LogStorageSettingsState {
|
||||
saved: LogStorageSettings;
|
||||
effective: LogStorageSettings;
|
||||
envOverrides: LogStorageSettingsField[];
|
||||
}
|
||||
|
||||
export interface LogStorageSettingsUpdateResult {
|
||||
settings: LogStorageSettingsState;
|
||||
status: PublicLogStorageStatus;
|
||||
}
|
||||
|
||||
export class StorageSettingsInputError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'StorageSettingsInputError';
|
||||
}
|
||||
}
|
||||
|
||||
export class StorageSettingsLockedError extends Error {
|
||||
constructor(readonly fields: LogStorageSettingsField[]) {
|
||||
super(`settings are locked by environment variables: ${fields.join(', ')}`);
|
||||
this.name = 'StorageSettingsLockedError';
|
||||
}
|
||||
}
|
||||
|
||||
export class StorageSettingsTransactionError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly operationError: unknown,
|
||||
readonly rollbackErrors: unknown[],
|
||||
) {
|
||||
super(message, { cause: operationError });
|
||||
this.name = 'StorageSettingsTransactionError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface LogStorageSettingsDependencies {
|
||||
readPersisted(): RuntimeConfig;
|
||||
readEnvOverrides(): Partial<RuntimeConfig>;
|
||||
readEffective(): LogStorageStatus;
|
||||
persist(patch: Partial<RuntimeConfig>): RuntimeConfig;
|
||||
apply(policy: LogStoragePolicy): Promise<LogStorageStatus>;
|
||||
}
|
||||
|
||||
const SETTINGS_FIELDS: LogStorageSettingsField[] = ['logMaxTotalMb', 'logRetainDays', 'logPerUin'];
|
||||
const SETTINGS_FIELD_SET = new Set<string>(SETTINGS_FIELDS);
|
||||
|
||||
export class LogStorageSettingsManager {
|
||||
private updateQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(private readonly deps: LogStorageSettingsDependencies) {}
|
||||
|
||||
read(): LogStorageSettingsState {
|
||||
return settingsState(
|
||||
this.deps.readPersisted(),
|
||||
this.deps.readEnvOverrides(),
|
||||
settingsFromStatus(this.deps.readEffective()),
|
||||
);
|
||||
}
|
||||
|
||||
update(body: unknown): Promise<LogStorageSettingsUpdateResult> {
|
||||
const operation = this.updateQueue.then(() => this.performUpdate(body));
|
||||
this.updateQueue = operation.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return operation;
|
||||
}
|
||||
|
||||
private async performUpdate(body: unknown): Promise<LogStorageSettingsUpdateResult> {
|
||||
const patch = coerceLogStorageSettingsPatch(body);
|
||||
const previousRuntime = this.deps.readPersisted();
|
||||
const envOverrides = this.deps.readEnvOverrides();
|
||||
const previous = settingsState(
|
||||
previousRuntime,
|
||||
envOverrides,
|
||||
settingsFromStatus(this.deps.readEffective()),
|
||||
);
|
||||
const locked = SETTINGS_FIELDS.filter((field) => field in patch && field in envOverrides);
|
||||
|
||||
if (locked.length > 0) {
|
||||
throw new StorageSettingsLockedError(locked);
|
||||
}
|
||||
|
||||
const nextSaved = { ...previous.saved, ...patch };
|
||||
const nextEffective = { ...previous.effective, ...patch };
|
||||
const previousPolicy = toPolicy(previous.effective);
|
||||
const nextPolicy = toPolicy(nextEffective);
|
||||
|
||||
let status: LogStorageStatus;
|
||||
|
||||
try {
|
||||
status = await this.deps.apply(nextPolicy);
|
||||
assertApplied(status, nextPolicy);
|
||||
} catch (error) {
|
||||
const rollbackErrors = await this.rollbackRuntime(previousPolicy);
|
||||
throw new StorageSettingsTransactionError(
|
||||
rollbackErrors.length > 0
|
||||
? 'failed to apply log settings and runtime rollback was incomplete'
|
||||
: 'failed to apply log settings; runtime state was rolled back',
|
||||
error,
|
||||
rollbackErrors,
|
||||
);
|
||||
}
|
||||
|
||||
let persistedRuntime: RuntimeConfig;
|
||||
|
||||
try {
|
||||
persistedRuntime = this.deps.persist(patch);
|
||||
assertPersistedSettings(persistedRuntime, nextSaved);
|
||||
} catch (error) {
|
||||
const rollbackErrors = await this.rollbackAll(previousPolicy, previousRuntime);
|
||||
throw new StorageSettingsTransactionError(
|
||||
rollbackErrors.length > 0
|
||||
? 'failed to persist log settings and rollback was incomplete'
|
||||
: 'failed to persist log settings; previous settings were restored',
|
||||
error,
|
||||
rollbackErrors,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
settings: settingsState(persistedRuntime, envOverrides, settingsFromStatus(status)),
|
||||
status: publicLogStorageStatus(status),
|
||||
};
|
||||
}
|
||||
|
||||
private async rollbackRuntime(policy: LogStoragePolicy): Promise<unknown[]> {
|
||||
try {
|
||||
const status = await this.deps.apply(policy);
|
||||
assertApplied(status, policy);
|
||||
return [];
|
||||
} catch (error) {
|
||||
return [error];
|
||||
}
|
||||
}
|
||||
|
||||
private async rollbackAll(
|
||||
policy: LogStoragePolicy,
|
||||
runtime: RuntimeConfig,
|
||||
): Promise<unknown[]> {
|
||||
const errors = await this.rollbackRuntime(policy);
|
||||
|
||||
try {
|
||||
this.deps.persist(runtime);
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
|
||||
function coerceLogStorageSettingsPatch(body: unknown): Partial<LogStorageSettings> {
|
||||
if (!isObject(body)) {
|
||||
throw new StorageSettingsInputError('body must be an object');
|
||||
}
|
||||
|
||||
const keys = Object.keys(body);
|
||||
|
||||
if (keys.length === 0) {
|
||||
throw new StorageSettingsInputError('at least one setting is required');
|
||||
}
|
||||
|
||||
const unknown = keys.filter((key) => !SETTINGS_FIELD_SET.has(key));
|
||||
|
||||
if (unknown.length > 0) {
|
||||
throw new StorageSettingsInputError(`unknown setting: ${unknown.join(', ')}`);
|
||||
}
|
||||
|
||||
const patch: Partial<LogStorageSettings> = {};
|
||||
|
||||
if ('logMaxTotalMb' in body) {
|
||||
const value = body.logMaxTotalMb;
|
||||
|
||||
if (
|
||||
typeof value !== 'number' ||
|
||||
!Number.isSafeInteger(value) ||
|
||||
value < 1 ||
|
||||
value > MAX_LOG_TOTAL_MB
|
||||
) {
|
||||
throw new StorageSettingsInputError(
|
||||
`logMaxTotalMb must be an integer in 1..${MAX_LOG_TOTAL_MB}`,
|
||||
);
|
||||
}
|
||||
|
||||
patch.logMaxTotalMb = value;
|
||||
}
|
||||
|
||||
if ('logRetainDays' in body) {
|
||||
const value = body.logRetainDays;
|
||||
|
||||
if (
|
||||
typeof value !== 'number' ||
|
||||
!Number.isSafeInteger(value) ||
|
||||
value < 0 ||
|
||||
value > MAX_LOG_RETAIN_DAYS
|
||||
) {
|
||||
throw new StorageSettingsInputError(
|
||||
`logRetainDays must be an integer in 0..${MAX_LOG_RETAIN_DAYS}`,
|
||||
);
|
||||
}
|
||||
|
||||
patch.logRetainDays = value;
|
||||
}
|
||||
|
||||
if ('logPerUin' in body) {
|
||||
if (typeof body.logPerUin !== 'boolean') {
|
||||
throw new StorageSettingsInputError('logPerUin must be a boolean');
|
||||
}
|
||||
|
||||
patch.logPerUin = body.logPerUin;
|
||||
}
|
||||
|
||||
return patch;
|
||||
}
|
||||
|
||||
function settingsState(
|
||||
runtime: RuntimeConfig,
|
||||
envOverrides: Partial<RuntimeConfig>,
|
||||
liveEffective?: LogStorageSettings,
|
||||
): LogStorageSettingsState {
|
||||
const saved = settingsFromRuntime(runtime);
|
||||
const envOverridesList = SETTINGS_FIELDS.filter((field) => field in envOverrides);
|
||||
const effective = liveEffective ?? {
|
||||
...saved,
|
||||
...settingsPatchFromRuntime(envOverrides),
|
||||
};
|
||||
return { saved, effective, envOverrides: envOverridesList };
|
||||
}
|
||||
|
||||
function settingsFromStatus(status: LogStorageStatus): LogStorageSettings {
|
||||
const maxTotalMb = status.maxTotalBytes / (1024 * 1024);
|
||||
|
||||
if (!Number.isSafeInteger(maxTotalMb)) {
|
||||
throw new Error('live log storage limit is not an integer number of megabytes');
|
||||
}
|
||||
|
||||
return {
|
||||
logMaxTotalMb: maxTotalMb,
|
||||
logRetainDays: status.retainDays,
|
||||
logPerUin: status.perUinEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
function settingsFromRuntime(runtime: RuntimeConfig): LogStorageSettings {
|
||||
return {
|
||||
logMaxTotalMb: runtime.logMaxTotalMb ?? DEFAULT_LOG_MAX_TOTAL_MB,
|
||||
logRetainDays: runtime.logRetainDays ?? DEFAULT_LOG_RETAIN_DAYS,
|
||||
logPerUin: runtime.logPerUin ?? DEFAULT_LOG_PER_UIN,
|
||||
};
|
||||
}
|
||||
|
||||
function settingsPatchFromRuntime(runtime: Partial<RuntimeConfig>): Partial<LogStorageSettings> {
|
||||
const patch: Partial<LogStorageSettings> = {};
|
||||
|
||||
if (runtime.logMaxTotalMb !== undefined) {
|
||||
patch.logMaxTotalMb = runtime.logMaxTotalMb;
|
||||
}
|
||||
|
||||
if (runtime.logRetainDays !== undefined) {
|
||||
patch.logRetainDays = runtime.logRetainDays;
|
||||
}
|
||||
|
||||
if (runtime.logPerUin !== undefined) {
|
||||
patch.logPerUin = runtime.logPerUin;
|
||||
}
|
||||
|
||||
return patch;
|
||||
}
|
||||
|
||||
function toPolicy(settings: LogStorageSettings): LogStoragePolicy {
|
||||
return {
|
||||
maxTotalMb: settings.logMaxTotalMb,
|
||||
retainDays: settings.logRetainDays,
|
||||
perUinEnabled: settings.logPerUin,
|
||||
};
|
||||
}
|
||||
|
||||
function assertApplied(status: LogStorageStatus, policy: LogStoragePolicy): void {
|
||||
const expectedBytes = policy.maxTotalMb * 1024 * 1024;
|
||||
|
||||
if (
|
||||
status.state === 'warning' ||
|
||||
status.state === 'degraded' ||
|
||||
status.lastError !== undefined ||
|
||||
status.maxTotalBytes !== expectedBytes ||
|
||||
status.retainDays !== policy.retainDays ||
|
||||
status.perUinEnabled !== policy.perUinEnabled
|
||||
) {
|
||||
throw new Error(status.lastError ?? 'log storage policy did not become effective');
|
||||
}
|
||||
}
|
||||
|
||||
function assertPersistedSettings(runtime: RuntimeConfig, expected: LogStorageSettings): void {
|
||||
const actual = settingsFromRuntime(runtime);
|
||||
|
||||
if (
|
||||
actual.logMaxTotalMb !== expected.logMaxTotalMb ||
|
||||
actual.logRetainDays !== expected.logRetainDays ||
|
||||
actual.logPerUin !== expected.logPerUin
|
||||
) {
|
||||
throw new Error('persisted log storage settings do not match the requested values');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Pure validator for the POST /api/system/settings body. Rejects clearly-bad
|
||||
// input with a message (→ 400) rather than silently normalizing it, then hands
|
||||
// a clean patch to updateRuntimeConfig. Wire field `tlsEnabled` maps to the
|
||||
// nested `webuiTls.enabled`.
|
||||
|
||||
import type { RuntimeConfig } from '@snowluma/common/runtime';
|
||||
import { isObject } from '@snowluma/common/coerce';
|
||||
import { checkListenerExposure } from './listener-exposure';
|
||||
|
||||
export type SettingsPatch = Partial<
|
||||
Pick<RuntimeConfig, 'webuiPort' | 'webuiHost' | 'webuiTls' | 'trustProxy'>
|
||||
>;
|
||||
|
||||
export type CoerceResult = { ok: true; patch: SettingsPatch } | { ok: false; error: string };
|
||||
|
||||
export function coerceSettingsPatch(body: unknown): CoerceResult {
|
||||
if (!isObject(body)) {
|
||||
return { ok: false, error: 'body must be an object' };
|
||||
}
|
||||
|
||||
const patch: SettingsPatch = {};
|
||||
|
||||
if ('webuiPort' in body) {
|
||||
const p = body.webuiPort;
|
||||
|
||||
if (typeof p !== 'number' || !Number.isInteger(p) || p <= 0 || p > 65535) {
|
||||
return { ok: false, error: 'webuiPort must be an integer in 1..65535' };
|
||||
}
|
||||
|
||||
patch.webuiPort = p;
|
||||
}
|
||||
|
||||
if ('webuiHost' in body) {
|
||||
if (typeof body.webuiHost !== 'string' || !body.webuiHost.trim()) {
|
||||
return { ok: false, error: 'webuiHost must be a non-empty string' };
|
||||
}
|
||||
|
||||
patch.webuiHost = body.webuiHost.trim();
|
||||
}
|
||||
|
||||
if ('tlsEnabled' in body) {
|
||||
if (typeof body.tlsEnabled !== 'boolean') {
|
||||
return { ok: false, error: 'tlsEnabled must be a boolean' };
|
||||
}
|
||||
|
||||
patch.webuiTls = { enabled: body.tlsEnabled };
|
||||
}
|
||||
|
||||
if ('trustProxy' in body) {
|
||||
if (typeof body.trustProxy !== 'string') {
|
||||
return { ok: false, error: 'trustProxy must be a string' };
|
||||
}
|
||||
|
||||
patch.trustProxy = body.trustProxy;
|
||||
}
|
||||
|
||||
return { ok: true, patch };
|
||||
}
|
||||
|
||||
const SETTINGS_ERRORS: Record<'invalid-bind-host' | 'tls-pair-required', string> = {
|
||||
'invalid-bind-host': '绑定地址不是有效的 TCP 地址',
|
||||
'tls-pair-required': '启用 TLS 前请先上传有效的证书与私钥',
|
||||
};
|
||||
|
||||
/** Validate the listener that would be persisted after applying `patch` to `current`. */
|
||||
export function evaluateSettingsSave(
|
||||
current: RuntimeConfig,
|
||||
patch: SettingsPatch,
|
||||
pairOk: boolean,
|
||||
): CoerceResult {
|
||||
const webuiHost = patch.webuiHost ?? current.webuiHost ?? '127.0.0.1';
|
||||
const tlsEnabled = patch.webuiTls?.enabled ?? current.webuiTls?.enabled ?? false;
|
||||
const check = checkListenerExposure({ webuiHost, tlsEnabled, pairOk });
|
||||
|
||||
if (!check.ok) {
|
||||
return { ok: false, error: SETTINGS_ERRORS[check.code] };
|
||||
}
|
||||
|
||||
return { ok: true, patch };
|
||||
}
|
||||
|
||||
export function tlsCertDeletionBlocked(tlsEnabled: boolean): boolean {
|
||||
return tlsEnabled;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { errorMessage } from '@snowluma/common/errors';
|
||||
// WebUI TLS helpers. Cert/key live as config/cert.pem + config/key.pem
|
||||
// (NapCat-aligned, self-contained, travels in A2 backups). Two pure-ish
|
||||
// helpers: validate a pasted pair (save path) and resolve the on-disk pair
|
||||
// (boot path). Validation is `tls.createSecureContext` — it parses the PEM
|
||||
// and checks the key matches the cert; expiry is NOT checked here.
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import tls from 'tls';
|
||||
|
||||
export interface TlsValidation {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface TlsResolution {
|
||||
ok: boolean;
|
||||
cert?: Buffer;
|
||||
key?: Buffer;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface RequiredTlsContext {
|
||||
cert: Buffer;
|
||||
key: Buffer;
|
||||
}
|
||||
|
||||
/** Parse-validate a cert/key pair (used by the save endpoint before writing). */
|
||||
export function validateTlsPair(cert: string | Buffer, key: string | Buffer): TlsValidation {
|
||||
if (!cert || !cert.toString().trim()) {
|
||||
return { ok: false, reason: 'certificate is empty' };
|
||||
}
|
||||
|
||||
if (!key || !key.toString().trim()) {
|
||||
return { ok: false, reason: 'private key is empty' };
|
||||
}
|
||||
|
||||
try {
|
||||
tls.createSecureContext({ cert, key });
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `invalid cert/key: ${errorMessage(e)}` };
|
||||
}
|
||||
}
|
||||
|
||||
/** Load + validate config/cert.pem + config/key.pem from `configDir`. */
|
||||
export function resolveTlsContext(configDir: string): TlsResolution {
|
||||
const certPath = path.join(configDir, 'cert.pem');
|
||||
const keyPath = path.join(configDir, 'key.pem');
|
||||
|
||||
if (!fs.existsSync(certPath) || !fs.existsSync(keyPath)) {
|
||||
return { ok: false, reason: 'cert.pem or key.pem missing in config dir' };
|
||||
}
|
||||
|
||||
let cert: Buffer;
|
||||
let key: Buffer;
|
||||
|
||||
try {
|
||||
cert = fs.readFileSync(certPath);
|
||||
key = fs.readFileSync(keyPath);
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `failed to read cert/key: ${errorMessage(e)}` };
|
||||
}
|
||||
|
||||
const valid = validateTlsPair(cert, key);
|
||||
|
||||
if (!valid.ok) {
|
||||
return { ok: false, reason: valid.reason };
|
||||
}
|
||||
|
||||
return { ok: true, cert, key };
|
||||
}
|
||||
|
||||
/** Resolve the configured pair for an enabled listener or abort startup. */
|
||||
export function requireTlsContext(configDir: string): RequiredTlsContext {
|
||||
const resolved = resolveTlsContext(configDir);
|
||||
|
||||
if (!resolved.ok || !resolved.cert || !resolved.key) {
|
||||
throw new Error(
|
||||
`TLS listener cannot start: ${resolved.reason ?? 'certificate pair is unusable'}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { cert: resolved.cert, key: resolved.key };
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
randomBytes,
|
||||
scryptSync,
|
||||
timingSafeEqual,
|
||||
} from 'node:crypto';
|
||||
import * as OTPAuth from 'otpauth';
|
||||
|
||||
const TOTP_PERIOD_SECONDS = 30;
|
||||
const TOTP_WINDOW = 1;
|
||||
const TOTP_DIGITS = 6;
|
||||
const RECOVERY_CODE_COUNT = 8;
|
||||
const RECOVERY_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
const WRAP_SALT_BYTES = 16;
|
||||
const WRAP_IV_BYTES = 12;
|
||||
const WRAP_KEY_BYTES = 32;
|
||||
const SCRYPT_N = 16384;
|
||||
const SCRYPT_R = 8;
|
||||
const SCRYPT_P = 1;
|
||||
|
||||
export interface WrappedTotpSecret {
|
||||
wrapSalt: string;
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
authTag: string;
|
||||
}
|
||||
|
||||
export interface TotpPersistedState extends WrappedTotpSecret {
|
||||
recoveryCodeHashes: string[];
|
||||
lastUsedStep: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface TotpEnrollment {
|
||||
secret: string;
|
||||
otpauthUrl: string;
|
||||
issuer: string;
|
||||
accountName: string;
|
||||
}
|
||||
|
||||
function totpFromSecret(secretBase32: string, issuer = '', accountName = 'admin'): OTPAuth.TOTP {
|
||||
return new OTPAuth.TOTP({
|
||||
issuer,
|
||||
label: accountName,
|
||||
algorithm: 'SHA1',
|
||||
digits: TOTP_DIGITS,
|
||||
period: TOTP_PERIOD_SECONDS,
|
||||
secret: OTPAuth.Secret.fromBase32(secretBase32),
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyTotpCode(
|
||||
secretBase32: string,
|
||||
code: string,
|
||||
atMs: number,
|
||||
lastUsedStep?: number,
|
||||
): { ok: true; step: number } | { ok: false } {
|
||||
const token = code.replace(/\s/g, '');
|
||||
|
||||
if (!/^\d{6}$/.test(token)) {
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
let totp: OTPAuth.TOTP;
|
||||
|
||||
try {
|
||||
totp = totpFromSecret(secretBase32);
|
||||
} catch {
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
const delta = totp.validate({ token, timestamp: atMs, window: TOTP_WINDOW });
|
||||
|
||||
if (delta === null) {
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
const step = totp.counter({ timestamp: atMs }) + delta;
|
||||
|
||||
if (lastUsedStep !== undefined && step === lastUsedStep) {
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
return { ok: true, step };
|
||||
}
|
||||
|
||||
function deriveWrapKey(password: string, salt: Buffer): Buffer {
|
||||
return scryptSync(password, salt, WRAP_KEY_BYTES, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P });
|
||||
}
|
||||
|
||||
export function wrapTotpSecret(password: string, secret: string): WrappedTotpSecret {
|
||||
const wrapSalt = randomBytes(WRAP_SALT_BYTES);
|
||||
const iv = randomBytes(WRAP_IV_BYTES);
|
||||
const key = deriveWrapKey(password, wrapSalt);
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
||||
const ciphertext = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()]);
|
||||
return {
|
||||
wrapSalt: wrapSalt.toString('hex'),
|
||||
iv: iv.toString('hex'),
|
||||
ciphertext: ciphertext.toString('hex'),
|
||||
authTag: cipher.getAuthTag().toString('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
export function unwrapTotpSecret(password: string, wrapped: WrappedTotpSecret): string | null {
|
||||
try {
|
||||
const wrapSalt = Buffer.from(wrapped.wrapSalt, 'hex');
|
||||
const iv = Buffer.from(wrapped.iv, 'hex');
|
||||
const ciphertext = Buffer.from(wrapped.ciphertext, 'hex');
|
||||
const authTag = Buffer.from(wrapped.authTag, 'hex');
|
||||
|
||||
if (
|
||||
wrapSalt.length !== WRAP_SALT_BYTES ||
|
||||
iv.length !== WRAP_IV_BYTES ||
|
||||
authTag.length !== 16
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = deriveWrapKey(password, wrapSalt);
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function randomRecoveryChunk(): string {
|
||||
const bytes = randomBytes(4);
|
||||
let chunk = '';
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
chunk += RECOVERY_ALPHABET[bytes[i]! % RECOVERY_ALPHABET.length];
|
||||
}
|
||||
|
||||
return chunk;
|
||||
}
|
||||
|
||||
export function generateRecoveryCodes(): string[] {
|
||||
const codes = new Set<string>();
|
||||
|
||||
while (codes.size < RECOVERY_CODE_COUNT) {
|
||||
codes.add(`${randomRecoveryChunk()}-${randomRecoveryChunk()}`);
|
||||
}
|
||||
|
||||
return [...codes];
|
||||
}
|
||||
|
||||
export function normalizeRecoveryCode(code: string): string {
|
||||
return code.replace(/[-\s]/g, '').toUpperCase();
|
||||
}
|
||||
|
||||
export function hashRecoveryCode(code: string): string {
|
||||
return createHash('sha256').update(normalizeRecoveryCode(code), 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function hashesEqual(left: string, right: string): boolean {
|
||||
const a = Buffer.from(left, 'hex');
|
||||
const b = Buffer.from(right, 'hex');
|
||||
|
||||
if (a.length !== b.length || a.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
export function consumeRecoveryCode(
|
||||
code: string,
|
||||
hashes: readonly string[],
|
||||
): { ok: true; remainingHashes: string[] } | { ok: false } {
|
||||
const candidate = hashRecoveryCode(code);
|
||||
const index = hashes.findIndex((hash) => hashesEqual(hash, candidate));
|
||||
|
||||
if (index < 0) {
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
return { ok: true, remainingHashes: hashes.filter((_, i) => i !== index) };
|
||||
}
|
||||
|
||||
export function beginTotpEnrollment(options: {
|
||||
issuer: string;
|
||||
accountName: string;
|
||||
secret?: string;
|
||||
}): TotpEnrollment {
|
||||
const secret = options.secret ?? new OTPAuth.Secret({ size: 20 }).base32;
|
||||
const totp = totpFromSecret(secret, options.issuer, options.accountName);
|
||||
return {
|
||||
secret,
|
||||
otpauthUrl: totp.toString(),
|
||||
issuer: options.issuer,
|
||||
accountName: options.accountName,
|
||||
};
|
||||
}
|
||||
|
||||
export function confirmTotpEnrollment(options: {
|
||||
password: string;
|
||||
secret: string;
|
||||
code: string;
|
||||
label: string;
|
||||
atMs: number;
|
||||
}): { state: TotpPersistedState; recoveryCodes: string[] } {
|
||||
const verified = verifyTotpCode(options.secret, options.code, options.atMs);
|
||||
|
||||
if (!verified.ok) {
|
||||
throw new Error('验证码不正确');
|
||||
}
|
||||
|
||||
const recoveryCodes = generateRecoveryCodes();
|
||||
return {
|
||||
recoveryCodes,
|
||||
state: {
|
||||
...wrapTotpSecret(options.password, options.secret),
|
||||
recoveryCodeHashes: recoveryCodes.map(hashRecoveryCode),
|
||||
lastUsedStep: verified.step,
|
||||
label: options.label,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function verifySecondFactor(options: {
|
||||
password: string;
|
||||
state: TotpPersistedState;
|
||||
atMs: number;
|
||||
totp?: string;
|
||||
recoveryCode?: string;
|
||||
}): { ok: true; state: TotpPersistedState } | { ok: false } {
|
||||
if (typeof options.recoveryCode === 'string' && options.recoveryCode.length > 0) {
|
||||
const consumed = consumeRecoveryCode(
|
||||
options.recoveryCode,
|
||||
options.state.recoveryCodeHashes,
|
||||
);
|
||||
|
||||
if (!consumed.ok) {
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
state: { ...options.state, recoveryCodeHashes: consumed.remainingHashes },
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof options.totp !== 'string') {
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
const secret = unwrapTotpSecret(options.password, options.state);
|
||||
|
||||
if (secret === null) {
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
const checked = verifyTotpCode(secret, options.totp, options.atMs, options.state.lastUsedStep);
|
||||
|
||||
if (!checked.ok) {
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
return { ok: true, state: { ...options.state, lastUsedStep: checked.step } };
|
||||
}
|
||||
|
||||
export type SecondFactorDecision =
|
||||
| { kind: 'needs-totp' }
|
||||
| { kind: 'bad-second-factor' }
|
||||
| { kind: 'ok'; state?: TotpPersistedState };
|
||||
|
||||
export function decideSecondFactorLogin(options: {
|
||||
totpEnabled: boolean;
|
||||
state?: TotpPersistedState;
|
||||
password: string;
|
||||
totp?: string;
|
||||
recoveryCode?: string;
|
||||
atMs: number;
|
||||
}): SecondFactorDecision {
|
||||
if (!options.totpEnabled) {
|
||||
return { kind: 'ok' };
|
||||
}
|
||||
|
||||
if (!options.state) {
|
||||
return { kind: 'bad-second-factor' };
|
||||
}
|
||||
|
||||
const totp =
|
||||
typeof options.totp === 'string' && options.totp.trim().length > 0
|
||||
? options.totp.trim()
|
||||
: undefined;
|
||||
const recoveryCode =
|
||||
typeof options.recoveryCode === 'string' && options.recoveryCode.trim().length > 0
|
||||
? options.recoveryCode.trim()
|
||||
: undefined;
|
||||
|
||||
if (!totp && !recoveryCode) {
|
||||
return { kind: 'needs-totp' };
|
||||
}
|
||||
|
||||
const result = verifySecondFactor({
|
||||
password: options.password,
|
||||
state: options.state,
|
||||
atMs: options.atMs,
|
||||
totp,
|
||||
recoveryCode,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return { kind: 'bad-second-factor' };
|
||||
}
|
||||
|
||||
return { kind: 'ok', state: result.state };
|
||||
}
|
||||
|
||||
export function regenerateRecoveryCodes(options: {
|
||||
password: string;
|
||||
state: TotpPersistedState;
|
||||
totp: string;
|
||||
atMs: number;
|
||||
}): { state: TotpPersistedState; recoveryCodes: string[] } {
|
||||
const verified = verifySecondFactor({
|
||||
password: options.password,
|
||||
state: options.state,
|
||||
totp: options.totp,
|
||||
atMs: options.atMs,
|
||||
});
|
||||
|
||||
if (!verified.ok) {
|
||||
throw new Error('验证码不正确');
|
||||
}
|
||||
|
||||
const recoveryCodes = generateRecoveryCodes();
|
||||
return {
|
||||
recoveryCodes,
|
||||
state: {
|
||||
...verified.state,
|
||||
recoveryCodeHashes: recoveryCodes.map(hashRecoveryCode),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function rewrapTotpSecret(
|
||||
oldPassword: string,
|
||||
newPassword: string,
|
||||
state: TotpPersistedState,
|
||||
): TotpPersistedState {
|
||||
const secret = unwrapTotpSecret(oldPassword, state);
|
||||
|
||||
if (secret === null) {
|
||||
throw new Error('无法使用当前密码解开 2FA 密钥');
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
...wrapTotpSecret(newPassword, secret),
|
||||
};
|
||||
}
|
||||
|
||||
const HEX = /^[0-9a-f]+$/i;
|
||||
|
||||
function isWrappedShape(
|
||||
value: Record<string, unknown>,
|
||||
): value is WrappedTotpSecret & Record<string, unknown> {
|
||||
return (
|
||||
typeof value.wrapSalt === 'string' &&
|
||||
HEX.test(value.wrapSalt) &&
|
||||
value.wrapSalt.length === WRAP_SALT_BYTES * 2 &&
|
||||
typeof value.iv === 'string' &&
|
||||
HEX.test(value.iv) &&
|
||||
value.iv.length === WRAP_IV_BYTES * 2 &&
|
||||
typeof value.ciphertext === 'string' &&
|
||||
HEX.test(value.ciphertext) &&
|
||||
value.ciphertext.length > 0 &&
|
||||
value.ciphertext.length % 2 === 0 &&
|
||||
typeof value.authTag === 'string' &&
|
||||
HEX.test(value.authTag) &&
|
||||
value.authTag.length === 32
|
||||
);
|
||||
}
|
||||
|
||||
export function prepareTotpStateForRestore(value: unknown): TotpPersistedState {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('totp state must be an object');
|
||||
}
|
||||
|
||||
const v = value as Record<string, unknown>;
|
||||
|
||||
if (!isWrappedShape(v)) {
|
||||
throw new Error('totp wrapped secret fields are invalid');
|
||||
}
|
||||
|
||||
if (
|
||||
!Array.isArray(v.recoveryCodeHashes) ||
|
||||
v.recoveryCodeHashes.length > RECOVERY_CODE_COUNT ||
|
||||
v.recoveryCodeHashes.some(
|
||||
(hash) => typeof hash !== 'string' || !/^[0-9a-f]{64}$/i.test(hash),
|
||||
)
|
||||
) {
|
||||
throw new Error('totp recoveryCodeHashes must be SHA-256 hex strings');
|
||||
}
|
||||
|
||||
if (
|
||||
typeof v.lastUsedStep !== 'number' ||
|
||||
!Number.isInteger(v.lastUsedStep) ||
|
||||
v.lastUsedStep < 0
|
||||
) {
|
||||
throw new Error('totp lastUsedStep must be a non-negative integer');
|
||||
}
|
||||
|
||||
if (typeof v.label !== 'string' || v.label.length === 0 || v.label.length > 128) {
|
||||
throw new Error('totp label must be a non-empty string');
|
||||
}
|
||||
|
||||
const allowed = new Set([
|
||||
'wrapSalt',
|
||||
'iv',
|
||||
'ciphertext',
|
||||
'authTag',
|
||||
'recoveryCodeHashes',
|
||||
'lastUsedStep',
|
||||
'label',
|
||||
]);
|
||||
const unknown = Object.keys(v).find((key) => !allowed.has(key));
|
||||
|
||||
if (unknown) {
|
||||
throw new Error(`unknown totp field $.${unknown}`);
|
||||
}
|
||||
|
||||
return {
|
||||
wrapSalt: v.wrapSalt.toLowerCase(),
|
||||
iv: v.iv.toLowerCase(),
|
||||
ciphertext: v.ciphertext.toLowerCase(),
|
||||
authTag: v.authTag.toLowerCase(),
|
||||
recoveryCodeHashes: v.recoveryCodeHashes.map((hash) => (hash as string).toLowerCase()),
|
||||
lastUsedStep: v.lastUsedStep,
|
||||
label: v.label,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseTotpStateLenient(value: unknown): TotpPersistedState | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return prepareTotpStateForRestore(value);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,995 @@
|
||||
import { boolOr, clampInt, clampNum, isObject } from '@snowluma/common/coerce';
|
||||
import { createLogger } from '@snowluma/common/logger';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Server-side store for the WebUI customization config (`config/ui.json`).
|
||||
//
|
||||
// This is the single source of truth for appearance (A) + layout (C). The
|
||||
// browser caches it in localStorage only to avoid a first-paint flash; the
|
||||
// canonical copy lives here so a deployer's look + curated layout follow them
|
||||
// across devices/browsers.
|
||||
//
|
||||
// Trust boundaries that this module enforces:
|
||||
// * `normalizeAppearance` is total — any unknown/corrupt input collapses to
|
||||
// safe defaults. This matters because the cosmetic subset is served to an
|
||||
// UNAUTHENTICATED client (the login page) via `GET /api/ui/public`, so it
|
||||
// must never echo attacker-controlled garbage.
|
||||
// * The background-IMAGE lifecycle fields (`hasImage` / `imageMime` /
|
||||
// `imageVersion`) are server-managed: a plain `POST /api/ui` cannot set
|
||||
// them. Only the dedicated upload/delete endpoints mutate them. A client
|
||||
// save preserves whatever is currently on disk.
|
||||
|
||||
const log = createLogger('WebUI.UiConfig');
|
||||
|
||||
const CONFIG_DIR = 'config';
|
||||
const UI_CONFIG_PATH = path.join(CONFIG_DIR, 'ui.json');
|
||||
|
||||
/** Directory for operator-uploaded UI assets (currently just the background). */
|
||||
export const UI_ASSETS_DIR = path.join(CONFIG_DIR, 'ui-assets');
|
||||
/** Fixed path of the single background image (overwrite-on-upload). */
|
||||
export const BACKGROUND_IMAGE_PATH = path.join(UI_ASSETS_DIR, 'background');
|
||||
/** Reject uploads larger than this (bytes). */
|
||||
export const MAX_BACKGROUND_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
export const UI_CONFIG_VERSION = 1 as const;
|
||||
|
||||
// ─── Schema ──────────────────────────────────────────────────────────────
|
||||
|
||||
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||
export type AccentMode = 'preset' | 'custom';
|
||||
export type AccentScope = 'sidebar' | 'global';
|
||||
export type DarkIntensity = 'soft' | 'black';
|
||||
export type SidebarStyle = 'follow' | 'panel' | 'accent';
|
||||
export type BackgroundType = 'none' | 'solid' | 'gradient' | 'image';
|
||||
export type Density = 'cozy' | 'compact';
|
||||
export type TimeFormat = '12h' | '24h';
|
||||
/** Full color scheme. 'default' keeps the lavender theme (driven by `mode`); the
|
||||
* Catppuccin flavors are complete palettes that also fix light/dark. */
|
||||
export type Palette =
|
||||
| 'default'
|
||||
| 'catppuccin-latte'
|
||||
| 'catppuccin-frappe'
|
||||
| 'catppuccin-macchiato'
|
||||
| 'catppuccin-mocha'
|
||||
| 'rose-pine'
|
||||
| 'rose-pine-moon'
|
||||
| 'rose-pine-dawn'
|
||||
| 'nord'
|
||||
| 'everforest-dark'
|
||||
| 'everforest-light';
|
||||
|
||||
export interface UiBackground {
|
||||
type: BackgroundType;
|
||||
/** Solid-fill colour (hex). Used when type === 'solid'. */
|
||||
color: string;
|
||||
/** Gradient preset id (frontend owns the catalogue). Used when type === 'gradient'. */
|
||||
gradient: string;
|
||||
/** Image readability overlay: 0 (image fully visible) … 1 (fully masked). */
|
||||
imageOpacity: number;
|
||||
/** Image blur in px (0…40). */
|
||||
imageBlur: number;
|
||||
// ── server-managed (upload/delete endpoints only) ──
|
||||
hasImage: boolean;
|
||||
imageMime: string;
|
||||
/** Bumped on each successful upload so the browser can cache-bust. */
|
||||
imageVersion: number;
|
||||
}
|
||||
|
||||
export interface UiAppearance {
|
||||
mode: ThemeMode;
|
||||
accentMode: AccentMode;
|
||||
/** Preset accent id (frontend catalogue: sky/blue/violet/…). */
|
||||
accentPreset: string;
|
||||
/** Custom accent colour (hex). Used when accentMode === 'custom'. */
|
||||
accentCustom: string;
|
||||
accentScope: AccentScope;
|
||||
darkIntensity: DarkIntensity;
|
||||
/** Full color scheme; 'default' = the built-in lavender theme. */
|
||||
palette: Palette;
|
||||
sidebarStyle: SidebarStyle;
|
||||
background: UiBackground;
|
||||
/** Sans font preset id (frontend catalogue); 'custom' uses `fontSansCustom`. */
|
||||
fontSans: string;
|
||||
/** Free-form sans font-family stack, used when `fontSans === 'custom'`. */
|
||||
fontSansCustom: string;
|
||||
/** Mono font preset id (frontend catalogue); 'custom' uses `fontMonoCustom`. */
|
||||
fontMono: string;
|
||||
/** Free-form mono font-family stack, used when `fontMono === 'custom'`. */
|
||||
fontMonoCustom: string;
|
||||
/** Global UI scale, 0.9…1.2. */
|
||||
uiScale: number;
|
||||
/** Corner radius in rem, 0…2. */
|
||||
radius: number;
|
||||
density: Density;
|
||||
/** Soften animations (transforms off, shorter durations). */
|
||||
reduceMotion: boolean;
|
||||
/** Hard-disable all UI animations (stronger than reduceMotion). */
|
||||
disableMotion: boolean;
|
||||
/** Use SnowLuma's adaptive cursor. */
|
||||
customPointerSystem: boolean;
|
||||
/** Replace the browser context menu with SnowLuma's context menu. */
|
||||
customContextMenu: boolean;
|
||||
highContrast: boolean;
|
||||
sidebarPinned: boolean;
|
||||
timeFormat: TimeFormat;
|
||||
/** Dashboard poll interval (ms); 0 = paused. */
|
||||
pollInterval: number;
|
||||
/**
|
||||
* Operator custom CSS, applied AFTER auth only (never on the login page —
|
||||
* `publicAppearance` strips it). Capped at 50 KB. The client injects it as
|
||||
* the last <style> and skips it under `?safe-mode=1` so a broken rule can't
|
||||
* lock the operator out.
|
||||
*/
|
||||
customCss: string;
|
||||
/**
|
||||
* Theme-token overrides from the variable panel. Keys are restricted to a
|
||||
* fixed whitelist of `--token` names and values to colours / lengths, so
|
||||
* (unlike `customCss`) this is safe to serve to the unauthenticated login
|
||||
* page — `normalizeAppearance` drops unknown keys and invalid values.
|
||||
*/
|
||||
cssVars: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface UiLayoutItem {
|
||||
id: string;
|
||||
visible: boolean;
|
||||
/**
|
||||
* Grid position/size (overview blocks only; nav items omit them). The
|
||||
* client owns the widget catalogue, per-widget min sizes, and the
|
||||
* `stats`→tiles migration — the server just preserves + clamps these to
|
||||
* sane integer bounds so a corrupt file can't store absurd coords.
|
||||
*/
|
||||
x?: number;
|
||||
y?: number;
|
||||
w?: number;
|
||||
h?: number;
|
||||
/**
|
||||
* Per-widget settings (overview blocks only) — an opaque object the client
|
||||
* interprets by widget type (e.g. alerts: count + levels). The server only
|
||||
* checks it's a plain object within a size bound; it never reads the keys.
|
||||
*/
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UiLayout {
|
||||
/** Ordered overview blocks (desktop 2D grid). Frontend reconciles vs its set. */
|
||||
overviewBlocks: UiLayoutItem[];
|
||||
/** Ordered single-column mobile overview (id+visible only; the 2D grid is
|
||||
* desktop-only). Kept separate so phone ordering never pollutes the grid. */
|
||||
overviewMobile: UiLayoutItem[];
|
||||
/** Ordered sidebar nav items, keyed by route path. */
|
||||
navItems: UiLayoutItem[];
|
||||
/** Top-bar element show/hide list (id+visible), mirroring navItems' shape. */
|
||||
topbarItems: UiLayoutItem[];
|
||||
}
|
||||
|
||||
export interface UiHighlightRule {
|
||||
keyword: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export type LogsPreset = 'dev' | 'ops' | 'minimal' | 'custom';
|
||||
|
||||
export interface UiLogsPrefs {
|
||||
/** Log levels shown by default (client display filter). */
|
||||
visibleLevels: string[];
|
||||
/** Ring-buffer cap for the live view (100…5000). */
|
||||
maxLines: number;
|
||||
autoScroll: boolean;
|
||||
wrap: boolean;
|
||||
/** Keyword → colour row highlights. */
|
||||
highlightRules: UiHighlightRule[];
|
||||
/** Active view preset id; 'custom' = the operator hand-tuned the prefs. The
|
||||
* client owns the preset → prefs bundles; the server just stores the id. */
|
||||
preset: LogsPreset;
|
||||
}
|
||||
|
||||
export interface UiPages {
|
||||
/** Route the operator lands on after login (a nav path; '/' if unset). */
|
||||
defaultRoute: string;
|
||||
logs: UiLogsPrefs;
|
||||
/** Default sort key for the processes page. */
|
||||
processesSort: string;
|
||||
/** Default tab id for the node-config page ('' = first). */
|
||||
configTab: string;
|
||||
}
|
||||
|
||||
export interface UiConfig {
|
||||
version: typeof UI_CONFIG_VERSION;
|
||||
appearance: UiAppearance;
|
||||
layout: UiLayout;
|
||||
pages: UiPages;
|
||||
}
|
||||
|
||||
// ─── Defaults ────────────────────────────────────────────────────────────
|
||||
// Keep these defaults aligned with ThemeContext. Saved appearance choices
|
||||
// are normalized independently and remain the operator's preference.
|
||||
|
||||
const DEFAULT_BACKGROUND: UiBackground = {
|
||||
type: 'none',
|
||||
color: '#b99ae6',
|
||||
gradient: 'none',
|
||||
imageOpacity: 0.15,
|
||||
imageBlur: 0,
|
||||
hasImage: false,
|
||||
imageMime: '',
|
||||
imageVersion: 0,
|
||||
};
|
||||
|
||||
const DEFAULT_APPEARANCE: UiAppearance = {
|
||||
mode: 'system',
|
||||
accentMode: 'preset',
|
||||
accentPreset: 'lavender',
|
||||
accentCustom: '#8561bd',
|
||||
accentScope: 'global',
|
||||
darkIntensity: 'soft',
|
||||
palette: 'default',
|
||||
sidebarStyle: 'follow',
|
||||
background: DEFAULT_BACKGROUND,
|
||||
fontSans: 'default',
|
||||
fontSansCustom: '',
|
||||
fontMono: 'default',
|
||||
fontMonoCustom: '',
|
||||
uiScale: 1,
|
||||
radius: 0.75,
|
||||
density: 'cozy',
|
||||
reduceMotion: false,
|
||||
disableMotion: false,
|
||||
customPointerSystem: false,
|
||||
customContextMenu: true,
|
||||
highContrast: false,
|
||||
sidebarPinned: false,
|
||||
timeFormat: '24h',
|
||||
pollInterval: 3000,
|
||||
customCss: '',
|
||||
cssVars: {},
|
||||
};
|
||||
|
||||
const DEFAULT_OVERVIEW_BLOCKS: UiLayoutItem[] = [
|
||||
{ id: 'stats', visible: true },
|
||||
{ id: 'connections', visible: true },
|
||||
{ id: 'alerts', visible: true },
|
||||
{ id: 'host', visible: true },
|
||||
{ id: 'sessions', visible: true },
|
||||
];
|
||||
|
||||
// Single-column mobile order = the desktop grid catalogue's widgets, flattened
|
||||
// (stat tiles, then the cards). The client reconciles against its real
|
||||
// catalogue, so this just seeds first run; unknown ids are dropped on load.
|
||||
const DEFAULT_OVERVIEW_MOBILE: UiLayoutItem[] = [
|
||||
{ id: 'stat:status', visible: true },
|
||||
{ id: 'stat:accounts', visible: true },
|
||||
{ id: 'stat:processes', visible: true },
|
||||
{ id: 'stat:host', visible: true },
|
||||
{ id: 'stat:uptime', visible: true },
|
||||
{ id: 'connections', visible: true },
|
||||
{ id: 'alerts', visible: true },
|
||||
{ id: 'host', visible: true },
|
||||
{ id: 'sessions', visible: true },
|
||||
];
|
||||
|
||||
const DEFAULT_NAV_ITEMS: UiLayoutItem[] = [
|
||||
{ id: '/', visible: true },
|
||||
{ id: '/processes', visible: true },
|
||||
{ id: '/config', visible: true },
|
||||
{ id: '/logs', visible: true },
|
||||
{ id: '/settings', visible: true },
|
||||
];
|
||||
|
||||
// Toggleable top-bar elements (essential ones — menu, page title, logout — are
|
||||
// pinned in the client and never appear here). The client reconciles, so a new
|
||||
// element (e.g. the kiosk button) just appears as visible.
|
||||
const DEFAULT_TOPBAR_ITEMS: UiLayoutItem[] = [
|
||||
{ id: 'status', visible: true },
|
||||
{ id: 'theme', visible: true },
|
||||
{ id: 'kiosk', visible: true },
|
||||
];
|
||||
|
||||
const LOG_LEVELS = ['trace', 'debug', 'info', 'success', 'warn', 'error'];
|
||||
|
||||
const DEFAULT_PAGES: UiPages = {
|
||||
defaultRoute: '/',
|
||||
logs: {
|
||||
visibleLevels: [...LOG_LEVELS],
|
||||
maxLines: 1000,
|
||||
autoScroll: true,
|
||||
wrap: true,
|
||||
highlightRules: [],
|
||||
preset: 'custom',
|
||||
},
|
||||
processesSort: 'pid',
|
||||
configTab: '',
|
||||
};
|
||||
|
||||
export function defaultUiConfig(): UiConfig {
|
||||
return {
|
||||
version: UI_CONFIG_VERSION,
|
||||
appearance: { ...DEFAULT_APPEARANCE, background: { ...DEFAULT_BACKGROUND }, cssVars: {} },
|
||||
layout: {
|
||||
overviewBlocks: DEFAULT_OVERVIEW_BLOCKS.map((b) => ({ ...b })),
|
||||
overviewMobile: DEFAULT_OVERVIEW_MOBILE.map((b) => ({ ...b })),
|
||||
navItems: DEFAULT_NAV_ITEMS.map((b) => ({ ...b })),
|
||||
topbarItems: DEFAULT_TOPBAR_ITEMS.map((b) => ({ ...b })),
|
||||
},
|
||||
pages: defaultPages(),
|
||||
};
|
||||
}
|
||||
|
||||
function defaultPages(): UiPages {
|
||||
return {
|
||||
...DEFAULT_PAGES,
|
||||
logs: { ...DEFAULT_PAGES.logs, visibleLevels: [...LOG_LEVELS], highlightRules: [] },
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Normalization helpers ─────────────────────────────────────────────────
|
||||
// isObject / boolOr / clampNum / clampInt come from @snowluma/common/coerce.
|
||||
// oneOf (enum), isFiniteNum, and hexOr (CSS color) are ui-specific and stay.
|
||||
|
||||
function oneOf<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
|
||||
return typeof value === 'string' && (allowed as readonly string[]).includes(value)
|
||||
? (value as T)
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function isFiniteNum(value: unknown): boolean {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value);
|
||||
}
|
||||
|
||||
return typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value));
|
||||
}
|
||||
|
||||
const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
||||
|
||||
function hexOr(value: unknown, fallback: string): string {
|
||||
return typeof value === 'string' && HEX_RE.test(value.trim()) ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
/** Bounded free-form id (frontend owns the actual catalogue). */
|
||||
function idOr(value: unknown, fallback: string, maxLen = 64): string {
|
||||
if (typeof value !== 'string') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const v = value.trim();
|
||||
|
||||
if (v.length === 0 || v.length > maxLen) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// Ids are slugs / route paths — refuse control chars and anything exotic.
|
||||
if (!/^[\w./:-]+$/.test(v)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* A free-form font-family value (a single family or a comma-separated stack),
|
||||
* safe to interpolate into `--font-sans: <value>;`. Allows letters (incl. CJK),
|
||||
* digits, spaces and the punctuation real font names use, but refuses anything
|
||||
* that could break out of the declaration (`;`, `{`, `}`, control chars, etc.).
|
||||
* Empty / over-long / invalid → fallback ('' = "no custom family set").
|
||||
*/
|
||||
const FONT_FAMILY_RE = /^[\p{L}\p{N}\s,'"._-]+$/u;
|
||||
|
||||
function fontFamilyOr(value: unknown, fallback: string, maxLen = 200): string {
|
||||
if (typeof value !== 'string') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const v = value.trim();
|
||||
|
||||
if (v.length === 0) {
|
||||
return '';
|
||||
} // explicitly cleared
|
||||
|
||||
if (v.length > maxLen || !FONT_FAMILY_RE.test(v)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return v;
|
||||
}
|
||||
|
||||
// ─── cssVars (theme-token override panel) ──────────────────────────────────
|
||||
// Both the keys (which `--token` may be set) and the values (colour / length)
|
||||
// are whitelisted so the result is safe to serve to the UNAUTHENTICATED login
|
||||
// page — it shares the trust level of palette/accent, not of `customCss`.
|
||||
|
||||
/** The `--token` names the variable panel is allowed to override. Kept in sync
|
||||
* with the surface/accent tokens `ThemeContext` actually injects. */
|
||||
const CSS_VAR_WHITELIST = new Set<string>([
|
||||
'--background',
|
||||
'--foreground',
|
||||
'--card',
|
||||
'--card-foreground',
|
||||
'--popover',
|
||||
'--popover-foreground',
|
||||
'--primary',
|
||||
'--primary-foreground',
|
||||
'--secondary',
|
||||
'--secondary-foreground',
|
||||
'--muted',
|
||||
'--muted-foreground',
|
||||
'--accent',
|
||||
'--accent-foreground',
|
||||
'--destructive',
|
||||
'--destructive-foreground',
|
||||
'--success',
|
||||
'--warning',
|
||||
'--border',
|
||||
'--input',
|
||||
'--ring',
|
||||
'--sidebar',
|
||||
'--sidebar-foreground',
|
||||
'--sidebar-primary',
|
||||
'--sidebar-primary-foreground',
|
||||
'--sidebar-accent',
|
||||
'--sidebar-accent-foreground',
|
||||
'--sidebar-border',
|
||||
'--sidebar-ring',
|
||||
]);
|
||||
|
||||
// A CSS colour value, restricted to forms that cannot break out of a `--token:
|
||||
// <value>;` declaration. Hex, a named colour, or a single balanced functional
|
||||
// notation (rgb/hsl/oklch/… and color-mix) over a safe char set — crucially no
|
||||
// `;`, `{`, `}`, `:` or angle brackets are permitted anywhere.
|
||||
const CSS_HEX_RE = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
||||
const CSS_NAMED_RE = /^[a-zA-Z]{1,24}$/;
|
||||
const CSS_FUNC_RE =
|
||||
/^(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color|color-mix)\([a-zA-Z0-9.,%/\s#-]{1,120}\)$/;
|
||||
|
||||
function cssColorOrNull(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const v = value.trim();
|
||||
|
||||
if (v.length === 0 || v.length > 128) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (CSS_HEX_RE.test(v) || CSS_NAMED_RE.test(v) || CSS_FUNC_RE.test(v)) {
|
||||
return v;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Keep only whitelisted token keys with valid colour values; drop the rest. */
|
||||
function normalizeCssVars(value: unknown): Record<string, string> {
|
||||
if (!isObject(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const out: Record<string, string> = {};
|
||||
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!CSS_VAR_WHITELIST.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const color = cssColorOrNull(value[key]);
|
||||
|
||||
if (color) {
|
||||
out[key] = color;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeLayoutItems(value: unknown, fallback: UiLayoutItem[]): UiLayoutItem[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return fallback.map((i) => ({ ...i }));
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const out: UiLayoutItem[] = [];
|
||||
|
||||
for (const raw of value) {
|
||||
if (!isObject(raw)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = idOr(raw.id, '');
|
||||
|
||||
if (!id || seen.has(id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(id);
|
||||
const item: UiLayoutItem = { id, visible: boolOr(raw.visible, true) };
|
||||
|
||||
// Grid coords are optional (overview blocks carry them; nav items don't).
|
||||
// Preserve + clamp when any are present; missing ones get safe defaults
|
||||
// and the client re-applies per-widget min sizes on load.
|
||||
if (isFiniteNum(raw.x) || isFiniteNum(raw.y) || isFiniteNum(raw.w) || isFiniteNum(raw.h)) {
|
||||
item.x = clampInt(raw.x, 0, 50, 0);
|
||||
item.y = clampInt(raw.y, 0, 1000, 0);
|
||||
item.w = clampInt(raw.w, 1, 12, 1);
|
||||
item.h = clampInt(raw.h, 1, 100, 1);
|
||||
}
|
||||
|
||||
// Opaque per-widget config: keep only a plain object within a size bound;
|
||||
// the server never interprets the keys (the client does, per widget type).
|
||||
if (isObject(raw.config)) {
|
||||
try {
|
||||
if (JSON.stringify(raw.config).length <= 4096) {
|
||||
item.config = raw.config;
|
||||
}
|
||||
} catch {
|
||||
/* unserializable (cycles) → drop */
|
||||
}
|
||||
}
|
||||
|
||||
out.push(item);
|
||||
}
|
||||
|
||||
// An array that parsed to nothing usable is indistinguishable from corrupt;
|
||||
// fall back so the frontend always has a baseline to reconcile against.
|
||||
return out.length > 0 ? out : fallback.map((i) => ({ ...i }));
|
||||
}
|
||||
|
||||
/**
|
||||
* The background-image fields are server-managed (only the upload/delete
|
||||
* endpoints mutate them). Normalization NEVER derives them from the value
|
||||
* being normalized — the caller supplies the trusted source:
|
||||
* * LOAD path → the parsed-from-disk file IS the truth (`imageStateFromParsed`).
|
||||
* * SAVE path → the current on-disk config is the truth (a client payload
|
||||
* cannot forge `hasImage`).
|
||||
*/
|
||||
export interface ServerImageState {
|
||||
hasImage: boolean;
|
||||
imageMime: string;
|
||||
imageVersion: number;
|
||||
}
|
||||
|
||||
const DEFAULT_IMAGE_STATE: ServerImageState = { hasImage: false, imageMime: '', imageVersion: 0 };
|
||||
|
||||
const KNOWN_IMAGE_MIMES = ['image/png', 'image/jpeg', 'image/webp'];
|
||||
|
||||
/** Coerce a raw background-ish object into a coherent, trusted image state. */
|
||||
function sanitizeImageState(value: unknown): ServerImageState {
|
||||
const v = isObject(value) ? value : {};
|
||||
const version = Math.trunc(clampNum(v.imageVersion, 0, Number.MAX_SAFE_INTEGER, 0));
|
||||
|
||||
if (!boolOr(v.hasImage, false)) {
|
||||
return { hasImage: false, imageMime: '', imageVersion: version };
|
||||
}
|
||||
|
||||
const mime =
|
||||
typeof v.imageMime === 'string' && KNOWN_IMAGE_MIMES.includes(v.imageMime)
|
||||
? v.imageMime
|
||||
: '';
|
||||
|
||||
// "Has image" with no recognizable mime is incoherent on-disk state; treat
|
||||
// it as no image so the serve path and the UI stay consistent.
|
||||
if (!mime) {
|
||||
return { hasImage: false, imageMime: '', imageVersion: version };
|
||||
}
|
||||
|
||||
return { hasImage: true, imageMime: mime, imageVersion: version };
|
||||
}
|
||||
|
||||
/** Trusted image state read FROM a parsed-from-disk config blob. */
|
||||
function imageStateFromParsed(parsed: unknown): ServerImageState {
|
||||
if (!isObject(parsed) || !isObject(parsed.appearance)) {
|
||||
return DEFAULT_IMAGE_STATE;
|
||||
}
|
||||
|
||||
return sanitizeImageState(parsed.appearance.background);
|
||||
}
|
||||
|
||||
/** Normalize a complete ui.json read from disk or a backup bundle. */
|
||||
export function normalizeStoredUiConfig(value: unknown): UiConfig {
|
||||
return normalizeUiConfig(value, imageStateFromParsed(value));
|
||||
}
|
||||
|
||||
function normalizeBackground(value: unknown, imageState: ServerImageState): UiBackground {
|
||||
const v = isObject(value) ? value : {};
|
||||
let type = oneOf<BackgroundType>(
|
||||
v.type,
|
||||
['none', 'solid', 'gradient', 'image'],
|
||||
DEFAULT_BACKGROUND.type,
|
||||
);
|
||||
|
||||
// An image background is only valid when an image is actually stored —
|
||||
// otherwise the serve route 404s and the UI shows a broken wallpaper.
|
||||
if (type === 'image' && !imageState.hasImage) {
|
||||
type = 'none';
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
color: hexOr(v.color, DEFAULT_BACKGROUND.color),
|
||||
gradient: idOr(v.gradient, DEFAULT_BACKGROUND.gradient),
|
||||
imageOpacity: clampNum(v.imageOpacity, 0, 1, DEFAULT_BACKGROUND.imageOpacity),
|
||||
imageBlur: clampNum(v.imageBlur, 0, 40, DEFAULT_BACKGROUND.imageBlur),
|
||||
// Server-managed — taken from the caller-supplied trusted source, never
|
||||
// from the value being normalized.
|
||||
hasImage: imageState.hasImage,
|
||||
imageMime: imageState.imageMime,
|
||||
imageVersion: imageState.imageVersion,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeAppearance(
|
||||
value: unknown,
|
||||
imageState: ServerImageState = DEFAULT_IMAGE_STATE,
|
||||
): UiAppearance {
|
||||
const v = isObject(value) ? value : {};
|
||||
return {
|
||||
mode: oneOf<ThemeMode>(v.mode, ['light', 'dark', 'system'], DEFAULT_APPEARANCE.mode),
|
||||
accentMode: oneOf<AccentMode>(
|
||||
v.accentMode,
|
||||
['preset', 'custom'],
|
||||
DEFAULT_APPEARANCE.accentMode,
|
||||
),
|
||||
accentPreset: idOr(v.accentPreset, DEFAULT_APPEARANCE.accentPreset, 32),
|
||||
accentCustom: hexOr(v.accentCustom, DEFAULT_APPEARANCE.accentCustom),
|
||||
accentScope: oneOf<AccentScope>(
|
||||
v.accentScope,
|
||||
['sidebar', 'global'],
|
||||
DEFAULT_APPEARANCE.accentScope,
|
||||
),
|
||||
darkIntensity: oneOf<DarkIntensity>(
|
||||
v.darkIntensity,
|
||||
['soft', 'black'],
|
||||
DEFAULT_APPEARANCE.darkIntensity,
|
||||
),
|
||||
palette: oneOf<Palette>(
|
||||
v.palette,
|
||||
[
|
||||
'default',
|
||||
'catppuccin-latte',
|
||||
'catppuccin-frappe',
|
||||
'catppuccin-macchiato',
|
||||
'catppuccin-mocha',
|
||||
'rose-pine',
|
||||
'rose-pine-moon',
|
||||
'rose-pine-dawn',
|
||||
'nord',
|
||||
'everforest-dark',
|
||||
'everforest-light',
|
||||
],
|
||||
DEFAULT_APPEARANCE.palette,
|
||||
),
|
||||
sidebarStyle: oneOf<SidebarStyle>(
|
||||
v.sidebarStyle,
|
||||
['follow', 'panel', 'accent'],
|
||||
DEFAULT_APPEARANCE.sidebarStyle,
|
||||
),
|
||||
background: normalizeBackground(v.background, imageState),
|
||||
fontSans: idOr(v.fontSans, DEFAULT_APPEARANCE.fontSans),
|
||||
fontSansCustom: fontFamilyOr(v.fontSansCustom, DEFAULT_APPEARANCE.fontSansCustom),
|
||||
fontMono: idOr(v.fontMono, DEFAULT_APPEARANCE.fontMono),
|
||||
fontMonoCustom: fontFamilyOr(v.fontMonoCustom, DEFAULT_APPEARANCE.fontMonoCustom),
|
||||
uiScale: clampNum(v.uiScale, 0.9, 1.2, DEFAULT_APPEARANCE.uiScale),
|
||||
radius: clampNum(v.radius, 0, 2, DEFAULT_APPEARANCE.radius),
|
||||
density: oneOf<Density>(v.density, ['cozy', 'compact'], DEFAULT_APPEARANCE.density),
|
||||
reduceMotion: boolOr(v.reduceMotion, DEFAULT_APPEARANCE.reduceMotion),
|
||||
disableMotion: boolOr(v.disableMotion, DEFAULT_APPEARANCE.disableMotion),
|
||||
customPointerSystem: boolOr(v.customPointerSystem, DEFAULT_APPEARANCE.customPointerSystem),
|
||||
customContextMenu: boolOr(v.customContextMenu, DEFAULT_APPEARANCE.customContextMenu),
|
||||
highContrast: boolOr(v.highContrast, DEFAULT_APPEARANCE.highContrast),
|
||||
sidebarPinned: boolOr(v.sidebarPinned, DEFAULT_APPEARANCE.sidebarPinned),
|
||||
timeFormat: oneOf<TimeFormat>(v.timeFormat, ['12h', '24h'], DEFAULT_APPEARANCE.timeFormat),
|
||||
pollInterval: clampNum(v.pollInterval, 0, 60_000, DEFAULT_APPEARANCE.pollInterval),
|
||||
customCss:
|
||||
typeof v.customCss === 'string'
|
||||
? v.customCss.slice(0, 50_000)
|
||||
: DEFAULT_APPEARANCE.customCss,
|
||||
cssVars: normalizeCssVars(v.cssVars),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeLayout(value: unknown): UiLayout {
|
||||
const layout = isObject(value) ? value : {};
|
||||
return {
|
||||
overviewBlocks: normalizeLayoutItems(layout.overviewBlocks, DEFAULT_OVERVIEW_BLOCKS),
|
||||
overviewMobile: normalizeLayoutItems(layout.overviewMobile, DEFAULT_OVERVIEW_MOBILE),
|
||||
navItems: normalizeLayoutItems(layout.navItems, DEFAULT_NAV_ITEMS),
|
||||
topbarItems: normalizeLayoutItems(layout.topbarItems, DEFAULT_TOPBAR_ITEMS),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHighlightRules(value: unknown): UiHighlightRule[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const out: UiHighlightRule[] = [];
|
||||
|
||||
for (const raw of value) {
|
||||
if (!isObject(raw) || typeof raw.keyword !== 'string') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const keyword = raw.keyword.trim().slice(0, 50);
|
||||
|
||||
if (!keyword) {
|
||||
continue;
|
||||
} // drop empty / whitespace-only (would match every row)
|
||||
|
||||
const color = typeof raw.color === 'string' ? raw.color.slice(0, 32) : '';
|
||||
out.push({ keyword, color });
|
||||
|
||||
if (out.length >= 20) {
|
||||
break;
|
||||
} // cap the rule count
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizePages(value: unknown): UiPages {
|
||||
const v = isObject(value) ? value : {};
|
||||
const logs = isObject(v.logs) ? v.logs : {};
|
||||
const levels = Array.isArray(logs.visibleLevels)
|
||||
? LOG_LEVELS.filter((l) => (logs.visibleLevels as unknown[]).includes(l))
|
||||
: DEFAULT_PAGES.logs.visibleLevels;
|
||||
return {
|
||||
defaultRoute: idOr(v.defaultRoute, DEFAULT_PAGES.defaultRoute),
|
||||
logs: {
|
||||
visibleLevels: levels.length > 0 ? levels : [...LOG_LEVELS],
|
||||
maxLines: clampInt(logs.maxLines, 100, 5000, DEFAULT_PAGES.logs.maxLines),
|
||||
autoScroll: boolOr(logs.autoScroll, DEFAULT_PAGES.logs.autoScroll),
|
||||
wrap: boolOr(logs.wrap, DEFAULT_PAGES.logs.wrap),
|
||||
highlightRules: normalizeHighlightRules(logs.highlightRules),
|
||||
preset: oneOf<LogsPreset>(logs.preset, ['dev', 'ops', 'minimal', 'custom'], 'custom'),
|
||||
},
|
||||
processesSort: idOr(v.processesSort, DEFAULT_PAGES.processesSort),
|
||||
configTab:
|
||||
typeof v.configTab === 'string' ? v.configTab.slice(0, 64) : DEFAULT_PAGES.configTab,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeUiConfig(
|
||||
value: unknown,
|
||||
imageState: ServerImageState = DEFAULT_IMAGE_STATE,
|
||||
): UiConfig {
|
||||
const v = isObject(value) ? value : {};
|
||||
return {
|
||||
version: UI_CONFIG_VERSION,
|
||||
appearance: normalizeAppearance(v.appearance, imageState),
|
||||
layout: normalizeLayout(v.layout),
|
||||
pages: normalizePages(v.pages),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Persistence ────────────────────────────────────────────────────────────
|
||||
|
||||
function ensureConfigDir(): void {
|
||||
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function atomicWrite(config: UiConfig): void {
|
||||
ensureConfigDir();
|
||||
const tmp = UI_CONFIG_PATH + '.tmp';
|
||||
fs.writeFileSync(tmp, JSON.stringify(config, null, 2), 'utf8');
|
||||
fs.renameSync(tmp, UI_CONFIG_PATH);
|
||||
}
|
||||
|
||||
let cached: UiConfig | null = null;
|
||||
|
||||
/** Load + normalize the UI config, creating it from defaults if absent. */
|
||||
export function loadUiConfig(): UiConfig {
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
ensureConfigDir();
|
||||
|
||||
if (!fs.existsSync(UI_CONFIG_PATH)) {
|
||||
const fresh = defaultUiConfig();
|
||||
|
||||
try {
|
||||
atomicWrite(fresh);
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
'failed to write initial ui.json: %s',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
|
||||
cached = fresh;
|
||||
return fresh;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(UI_CONFIG_PATH, 'utf8');
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
// On load, the file itself is the source of truth for the server-managed
|
||||
// image fields — so they survive restarts (don't reset to defaults).
|
||||
const normalized = normalizeStoredUiConfig(parsed);
|
||||
|
||||
// Re-persist only if normalization changed something (corrupt/old file),
|
||||
// so we self-heal on disk without rewriting on every boot.
|
||||
if (JSON.stringify(parsed) !== JSON.stringify(normalized)) {
|
||||
try {
|
||||
atomicWrite(normalized);
|
||||
} catch {
|
||||
/* best-effort self-heal */
|
||||
}
|
||||
}
|
||||
|
||||
cached = normalized;
|
||||
return normalized;
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
'ui.json unreadable; using defaults: %s',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
const fresh = defaultUiConfig();
|
||||
cached = fresh;
|
||||
return fresh;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a client-supplied config. The incoming appearance is normalized and
|
||||
* its server-managed background-image fields are forced to the current on-disk
|
||||
* truth (the client cannot fake `hasImage`). Returns the stored config.
|
||||
*/
|
||||
export function saveUiConfig(incoming: unknown): UiConfig {
|
||||
const current = loadUiConfig();
|
||||
const v = isObject(incoming) ? incoming : {};
|
||||
// Section-level merge: a payload may carry just `appearance` (the theme
|
||||
// editor) or just `layout` (the layout editor) without clobbering the
|
||||
// other. A missing/non-object section keeps the current on-disk value.
|
||||
// The current on-disk state is the truth for the server-managed image
|
||||
// fields — a client payload cannot forge `hasImage` etc.
|
||||
const next: UiConfig = {
|
||||
version: UI_CONFIG_VERSION,
|
||||
appearance: isObject(v.appearance)
|
||||
? normalizeAppearance(v.appearance, current.appearance.background)
|
||||
: current.appearance,
|
||||
layout: isObject(v.layout) ? normalizeLayout(v.layout) : current.layout,
|
||||
pages: isObject(v.pages) ? normalizePages(v.pages) : (current.pages ?? defaultPages()),
|
||||
};
|
||||
atomicWrite(next);
|
||||
cached = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
/** The appearance subset served unauthenticated to the login page. Strips
|
||||
* `customCss` so a broken/hostile rule can never reach the pre-auth page
|
||||
* (and so the operator can always log in to fix it). */
|
||||
export function publicAppearance(): UiAppearance {
|
||||
return { ...loadUiConfig().appearance, customCss: '' };
|
||||
}
|
||||
|
||||
// ─── Background image lifecycle ─────────────────────────────────────────────
|
||||
|
||||
const IMAGE_MIME: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
jpeg: 'image/jpeg',
|
||||
webp: 'image/webp',
|
||||
};
|
||||
|
||||
/**
|
||||
* Identify an image by its magic bytes (never by a client-supplied filename or
|
||||
* Content-Type). Returns the canonical MIME, or null if it isn't a supported
|
||||
* image. Supported: PNG, JPEG, WebP.
|
||||
*/
|
||||
export function sniffImageMime(bytes: Uint8Array): string | null {
|
||||
if (
|
||||
bytes.length >= 8 &&
|
||||
bytes[0] === 0x89 &&
|
||||
bytes[1] === 0x50 &&
|
||||
bytes[2] === 0x4e &&
|
||||
bytes[3] === 0x47 &&
|
||||
bytes[4] === 0x0d &&
|
||||
bytes[5] === 0x0a &&
|
||||
bytes[6] === 0x1a &&
|
||||
bytes[7] === 0x0a
|
||||
) {
|
||||
return IMAGE_MIME.png;
|
||||
}
|
||||
|
||||
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
||||
return IMAGE_MIME.jpeg;
|
||||
}
|
||||
|
||||
if (
|
||||
bytes.length >= 12 &&
|
||||
bytes[0] === 0x52 &&
|
||||
bytes[1] === 0x49 &&
|
||||
bytes[2] === 0x46 &&
|
||||
bytes[3] === 0x46 &&
|
||||
bytes[8] === 0x57 &&
|
||||
bytes[9] === 0x45 &&
|
||||
bytes[10] === 0x42 &&
|
||||
bytes[11] === 0x50
|
||||
) {
|
||||
return IMAGE_MIME.webp;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Write the uploaded image to disk and record its metadata. Returns the config. */
|
||||
export function writeBackgroundImage(bytes: Uint8Array, mime: string): UiConfig {
|
||||
fs.mkdirSync(UI_ASSETS_DIR, { recursive: true });
|
||||
const tmp = BACKGROUND_IMAGE_PATH + '.tmp';
|
||||
fs.writeFileSync(tmp, bytes);
|
||||
fs.renameSync(tmp, BACKGROUND_IMAGE_PATH);
|
||||
|
||||
const current = loadUiConfig();
|
||||
const next: UiConfig = {
|
||||
...current,
|
||||
appearance: {
|
||||
...current.appearance,
|
||||
background: {
|
||||
...current.appearance.background,
|
||||
// Default to showing the freshly-uploaded image unless the operator
|
||||
// explicitly had a non-image background selected and we respect it;
|
||||
// switching to 'image' here matches the natural "I just set a wallpaper"
|
||||
// expectation.
|
||||
type: 'image',
|
||||
hasImage: true,
|
||||
imageMime: mime,
|
||||
imageVersion: current.appearance.background.imageVersion + 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
atomicWrite(next);
|
||||
cached = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Remove the background image (if any) and clear its metadata. Returns the config. */
|
||||
export function clearBackgroundImage(): UiConfig {
|
||||
try {
|
||||
if (fs.existsSync(BACKGROUND_IMAGE_PATH)) {
|
||||
fs.unlinkSync(BACKGROUND_IMAGE_PATH);
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
'failed to remove background image: %s',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
|
||||
const current = loadUiConfig();
|
||||
const next: UiConfig = {
|
||||
...current,
|
||||
appearance: {
|
||||
...current.appearance,
|
||||
background: {
|
||||
...current.appearance.background,
|
||||
// Fall back to 'none' if the image was the active background.
|
||||
type:
|
||||
current.appearance.background.type === 'image'
|
||||
? 'none'
|
||||
: current.appearance.background.type,
|
||||
hasImage: false,
|
||||
imageMime: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
atomicWrite(next);
|
||||
cached = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Read the background image bytes + MIME, or null if none is stored. */
|
||||
export function readBackgroundImage(): { bytes: Buffer; mime: string } | null {
|
||||
const { background } = loadUiConfig().appearance;
|
||||
|
||||
if (!background.hasImage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const bytes = fs.readFileSync(BACKGROUND_IMAGE_PATH);
|
||||
return { bytes, mime: background.imageMime || 'application/octet-stream' };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { HttpBindings } from '@hono/node-server';
|
||||
import type { UpdateService } from '../update/service';
|
||||
import { readUpdateSource, saveUpdateSource, testUpdateSources } from '../update/sources';
|
||||
import {
|
||||
normalizeUpdateSourceConfig,
|
||||
type UpdateSourceConfig,
|
||||
} from '@snowluma/common/update-source';
|
||||
|
||||
export function updateRoutes(service: UpdateService, restart: () => void) {
|
||||
const app = new Hono<{ Bindings: HttpBindings }>();
|
||||
app.get('/status', (c) => c.json(service.status()));
|
||||
app.get('/sources', (c) => {
|
||||
try {
|
||||
return c.json({ config: readUpdateSource() });
|
||||
} catch (error) {
|
||||
return c.json({ message: error instanceof Error ? error.message : String(error) }, 500);
|
||||
}
|
||||
});
|
||||
app.post('/sources', async (c) => {
|
||||
let config: UpdateSourceConfig;
|
||||
|
||||
try {
|
||||
config = normalizeUpdateSourceConfig(await c.req.json());
|
||||
} catch (error) {
|
||||
return c.json({ message: error instanceof Error ? error.message : String(error) }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
saveUpdateSource(config);
|
||||
return c.json({
|
||||
config,
|
||||
saved: true,
|
||||
applied: true,
|
||||
online: true,
|
||||
errors: [],
|
||||
message: '更新来源已保存',
|
||||
});
|
||||
} catch (error) {
|
||||
return c.json({ message: error instanceof Error ? error.message : String(error) }, 500);
|
||||
}
|
||||
});
|
||||
app.post('/sources/test', async (c) => {
|
||||
try {
|
||||
return c.json({ results: await testUpdateSources(await c.req.json()) });
|
||||
} catch (error) {
|
||||
return c.json({ message: error instanceof Error ? error.message : String(error) }, 400);
|
||||
}
|
||||
});
|
||||
app.post('/download', async (c) => {
|
||||
let body: unknown;
|
||||
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ message: '请求必须是 JSON 对象' }, 400);
|
||||
}
|
||||
|
||||
if (!body || typeof body !== 'object' || !('version' in body)) {
|
||||
return c.json({ message: '缺少更新版本' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
service.start(body.version);
|
||||
return c.json(service.status(), 202);
|
||||
} catch (error) {
|
||||
return c.json({ message: error instanceof Error ? error.message : String(error) }, 409);
|
||||
}
|
||||
});
|
||||
app.post('/restart', (c) => {
|
||||
try {
|
||||
service.restart();
|
||||
c.env.outgoing.once('finish', restart);
|
||||
return c.json(service.status(), 202);
|
||||
} catch (error) {
|
||||
return c.json({ message: error instanceof Error ? error.message : String(error) }, 409);
|
||||
}
|
||||
});
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { decideSecondFactorLogin, type TotpPersistedState } from './totp';
|
||||
|
||||
export interface WebuiLoginAuth {
|
||||
verify(password: string): boolean;
|
||||
totpEnabled(): boolean;
|
||||
totpState(): TotpPersistedState | undefined;
|
||||
mustChangePassword(): boolean;
|
||||
}
|
||||
|
||||
export type WebuiLoginResult =
|
||||
| { kind: 'bad-request' }
|
||||
| { kind: 'bad-password' }
|
||||
| { kind: 'needs-totp' }
|
||||
| { kind: 'bad-second-factor' }
|
||||
| { kind: 'ok'; mustChangePassword: boolean; totpState?: TotpPersistedState };
|
||||
|
||||
function readString(value: unknown): string {
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
export function completeWebuiLogin(
|
||||
auth: WebuiLoginAuth,
|
||||
body: unknown,
|
||||
atMs: number,
|
||||
): WebuiLoginResult {
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
return { kind: 'bad-request' };
|
||||
}
|
||||
|
||||
const record = body as Record<string, unknown>;
|
||||
const password = readString(record.password);
|
||||
|
||||
if (!auth.verify(password)) {
|
||||
return { kind: 'bad-password' };
|
||||
}
|
||||
|
||||
const decision = decideSecondFactorLogin({
|
||||
totpEnabled: auth.totpEnabled(),
|
||||
state: auth.totpState(),
|
||||
password,
|
||||
totp: readString(record.totp) || undefined,
|
||||
recoveryCode: readString(record.recoveryCode) || undefined,
|
||||
atMs,
|
||||
});
|
||||
|
||||
if (decision.kind === 'needs-totp') {
|
||||
return { kind: 'needs-totp' };
|
||||
}
|
||||
|
||||
if (decision.kind === 'bad-second-factor') {
|
||||
return { kind: 'bad-second-factor' };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'ok',
|
||||
mustChangePassword: auth.mustChangePassword(),
|
||||
...(decision.state ? { totpState: decision.state } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function invalidateOtherSessions<T>(sessions: Map<string, T>, keepToken: string): void {
|
||||
for (const token of sessions.keys()) {
|
||||
if (token !== keepToken) {
|
||||
sessions.delete(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// Shared fixtures for the per-theme bridge-action tests.
|
||||
//
|
||||
// Each action test file mocks `../src/bridge/bridge-oidb` and (where
|
||||
// needed) `../src/bridge/highway/*` so we can assert what the action
|
||||
// asked the OIDB / Highway layer to do without booting a real Bridge.
|
||||
//
|
||||
// `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 '@snowluma/common/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>;
|
||||
markAllRead: 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),
|
||||
markAllRead: vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
export interface MockContactsApi {
|
||||
fetchFriendList: ReturnType<typeof vi.fn>;
|
||||
fetchGroupList: ReturnType<typeof vi.fn>;
|
||||
fetchGroupMemberList: ReturnType<typeof vi.fn>;
|
||||
fetchUserProfile: ReturnType<typeof vi.fn>;
|
||||
fetchGroupRequests: ReturnType<typeof vi.fn>;
|
||||
fetchDownloadRKeys: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
export function mockContactsApi(): MockContactsApi {
|
||||
return {
|
||||
fetchFriendList: vi.fn(async () => []),
|
||||
fetchGroupList: vi.fn(async () => []),
|
||||
fetchGroupMemberList: vi.fn(async () => []),
|
||||
fetchUserProfile: vi.fn(async () => ({ uid: 'profile-uid' })),
|
||||
fetchGroupRequests: vi.fn(async () => []),
|
||||
fetchDownloadRKeys: vi.fn(async () => []),
|
||||
};
|
||||
}
|
||||
|
||||
export interface MockGroupFileApi {
|
||||
upload: ReturnType<typeof vi.fn>;
|
||||
uploadPrivate: ReturnType<typeof vi.fn>;
|
||||
publish: ReturnType<typeof vi.fn>;
|
||||
trans: ReturnType<typeof vi.fn>;
|
||||
getCount: ReturnType<typeof vi.fn>;
|
||||
list: ReturnType<typeof vi.fn>;
|
||||
getUrl: ReturnType<typeof vi.fn>;
|
||||
getPrivateUrl: ReturnType<typeof vi.fn>;
|
||||
getPttUrl: ReturnType<typeof vi.fn>;
|
||||
getPrivatePttUrl: ReturnType<typeof vi.fn>;
|
||||
getVideoUrl: ReturnType<typeof vi.fn>;
|
||||
getPrivateVideoUrl: ReturnType<typeof vi.fn>;
|
||||
delete: ReturnType<typeof vi.fn>;
|
||||
move: ReturnType<typeof vi.fn>;
|
||||
createFolder: ReturnType<typeof vi.fn>;
|
||||
deleteFolder: ReturnType<typeof vi.fn>;
|
||||
renameFolder: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
export function mockGroupFileApi(): MockGroupFileApi {
|
||||
return {
|
||||
upload: vi.fn(async () => ({ fileId: 'stub-fid' })),
|
||||
uploadPrivate: vi.fn(async () => ({ fileId: 'stub-pfid', fileHash: 'stub-hash' })),
|
||||
publish: vi.fn(async () => undefined),
|
||||
trans: vi.fn(async () => ({ saveBusId: 102, saveFilePath: '/stub-path' })),
|
||||
getCount: vi.fn(async () => ({ fileCount: 0, maxCount: 10000 })),
|
||||
list: vi.fn(async () => ({ files: [], folders: [] })),
|
||||
getUrl: vi.fn(async () => 'stub://url'),
|
||||
getPrivateUrl: vi.fn(async () => 'stub://private-url'),
|
||||
getPttUrl: vi.fn(async () => 'stub://ptt-url'),
|
||||
getPrivatePttUrl: vi.fn(async () => 'stub://private-ptt-url'),
|
||||
getVideoUrl: vi.fn(async () => 'stub://video-url'),
|
||||
getPrivateVideoUrl: vi.fn(async () => 'stub://private-video-url'),
|
||||
delete: vi.fn(async () => undefined),
|
||||
move: vi.fn(async () => undefined),
|
||||
createFolder: vi.fn(async () => undefined),
|
||||
deleteFolder: vi.fn(async () => undefined),
|
||||
renameFolder: vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
export interface MockApiHub {
|
||||
message: MockMessageApi;
|
||||
contacts: MockContactsApi;
|
||||
groupFile: MockGroupFileApi;
|
||||
// additional Apis added commit-by-commit as #6 progresses
|
||||
}
|
||||
|
||||
export function mockApiHub(overrides: Partial<MockApiHub> = {}): MockApiHub {
|
||||
return {
|
||||
message: overrides.message ?? mockMessageApi(),
|
||||
contacts: overrides.contacts ?? mockContactsApi(),
|
||||
groupFile: overrides.groupFile ?? mockGroupFileApi(),
|
||||
};
|
||||
}
|
||||
|
||||
export interface MockBridge {
|
||||
identity: {
|
||||
uin: string;
|
||||
selfUid: string;
|
||||
nickname: string;
|
||||
findUidByUin: ReturnType<typeof vi.fn>;
|
||||
findUinByUid: ReturnType<typeof vi.fn>;
|
||||
resolveUin: ReturnType<typeof vi.fn>;
|
||||
findGroupMember: ReturnType<typeof vi.fn>;
|
||||
updateFriendRemark: ReturnType<typeof vi.fn>;
|
||||
updateGroupRemark: ReturnType<typeof vi.fn>;
|
||||
forgetGroup: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
events: { emit: 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>;
|
||||
// Uploaded-file metadata cache helpers — GroupFileApi.upload /
|
||||
// uploadPrivate call these to remember the upload, so tests
|
||||
// covering those code paths get a default-no-op shim.
|
||||
rememberUploadedFile: ReturnType<typeof vi.fn>;
|
||||
recallUploadedFile: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
export function mockBridge(overrides: Partial<MockBridge> = {}): MockBridge {
|
||||
const defaultResp: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
return {
|
||||
identity: {
|
||||
uin: '10001',
|
||||
selfUid: 'self-uid',
|
||||
nickname: 'self-nick',
|
||||
findUidByUin: vi.fn(() => 'cached-uid'),
|
||||
findUinByUid: vi.fn(() => 0),
|
||||
resolveUin: vi.fn(async () => null),
|
||||
findGroupMember: vi.fn(() => null),
|
||||
updateFriendRemark: vi.fn(() => true),
|
||||
updateGroupRemark: vi.fn(() => true),
|
||||
forgetGroup: vi.fn(),
|
||||
...(overrides.identity ?? {}),
|
||||
} as MockBridge['identity'],
|
||||
events: overrides.events ?? { emit: vi.fn(async () => undefined) },
|
||||
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'),
|
||||
rememberUploadedFile: vi.fn(),
|
||||
recallUploadedFile: vi.fn(() => undefined),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { GetPskey } from '@snowluma/protocol/oidb-services/web/get-pskey';
|
||||
import * as collectionWeb from '@snowluma/protocol/web/collection';
|
||||
import { CollectionApi } from '../../src/bridge/apis/collection';
|
||||
import { mockBridge } from './_helpers';
|
||||
|
||||
describe('apis/collection', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('obtains a Weiyun p_skey and fetches the collection list', async () => {
|
||||
const bridge = mockBridge();
|
||||
const expected = {
|
||||
errCode: 0 as const,
|
||||
errMsg: '',
|
||||
collectionSearchList: {
|
||||
collectionItemList: [],
|
||||
hasMore: false,
|
||||
bottomTimeStamp: '0',
|
||||
},
|
||||
};
|
||||
const ticketSpy = vi.spyOn(GetPskey, 'invoke').mockResolvedValue({
|
||||
domainPskeyMap: new Map([['weiyun.com', 'weiyun-ticket']]),
|
||||
});
|
||||
const listSpy = vi.spyOn(collectionWeb, 'getCollectionList').mockResolvedValue(expected);
|
||||
|
||||
const result = await new CollectionApi(bridge as never).list(4, 25);
|
||||
|
||||
expect(ticketSpy).toHaveBeenCalledWith(bridge, { domainList: ['weiyun.com'] });
|
||||
expect(listSpy).toHaveBeenCalledWith({
|
||||
uin: '10001',
|
||||
pskey: 'weiyun-ticket',
|
||||
category: 4,
|
||||
count: 25,
|
||||
});
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it('fails explicitly when QQ does not return a Weiyun p_skey', async () => {
|
||||
const bridge = mockBridge();
|
||||
vi.spyOn(GetPskey, 'invoke').mockResolvedValue({ domainPskeyMap: new Map() });
|
||||
const listSpy = vi.spyOn(collectionWeb, 'getCollectionList');
|
||||
|
||||
await expect(new CollectionApi(bridge as never).list(0, 50)).rejects.toThrow(
|
||||
'QQ did not return a p_skey for weiyun.com',
|
||||
);
|
||||
expect(listSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,777 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { pb, pb_repeated, uint_32, uint_64 } from '@snowluma/proton';
|
||||
import type { SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import type {
|
||||
OidbBase,
|
||||
OidbEmpty,
|
||||
OidbFriend,
|
||||
OidbFriendCategory,
|
||||
OidbRobotUinRangeResponse,
|
||||
OidbSvcTrpcTcp0x88D_0Response,
|
||||
OidbSvcTrpcTcp0xFD4_1Response,
|
||||
OidbSvcTrpcTcp0xFE5_2Response,
|
||||
OidbSvcTrpcTcp0xFE7_3Response,
|
||||
} from '@snowluma/proto-defs/oidb';
|
||||
import type {
|
||||
OidbFriendListRequest,
|
||||
OidbRobotUinRangeRequest,
|
||||
OidbSetFriendCategoryRequest,
|
||||
} from '@snowluma/proto-defs/oidb-actions/base';
|
||||
|
||||
import { ContactsApi } from '../../src/bridge/apis/contacts';
|
||||
|
||||
interface ObservedGroupRequestUserByUin {
|
||||
uin?: pb<1, uint_32>;
|
||||
name?: pb<2, string>;
|
||||
}
|
||||
|
||||
interface ObservedGroupRequestUserByUid {
|
||||
uid?: pb<1, string>;
|
||||
name?: pb<2, string>;
|
||||
}
|
||||
|
||||
interface ObservedGroupRequestGroup {
|
||||
groupUin?: pb<1, uint_32>;
|
||||
groupName?: pb<2, string>;
|
||||
}
|
||||
|
||||
interface ObservedGroupRequest<TUser> {
|
||||
sequence?: pb<1, uint_64>;
|
||||
eventType?: pb<2, uint_32>;
|
||||
state?: pb<3, uint_32>;
|
||||
group?: pb<4, ObservedGroupRequestGroup>;
|
||||
target?: pb<5, TUser>;
|
||||
invitor?: pb<6, TUser>;
|
||||
operatorUser?: pb<7, TUser>;
|
||||
comment?: pb<10, string>;
|
||||
}
|
||||
|
||||
interface ObservedGroupRequestResponse<TUser> {
|
||||
requests?: pb_repeated<1, ObservedGroupRequest<TUser>>;
|
||||
}
|
||||
|
||||
function friend(
|
||||
uin: number,
|
||||
categoryId: number,
|
||||
uid: string,
|
||||
nickname: string,
|
||||
remark = '',
|
||||
): OidbFriend {
|
||||
return {
|
||||
uin,
|
||||
uid,
|
||||
customGroup: categoryId,
|
||||
additional: [
|
||||
{
|
||||
type: 1,
|
||||
layer1: {
|
||||
properties: [
|
||||
{ code: 20002, value: Buffer.from(nickname) },
|
||||
{ code: 103, value: Buffer.from(remark) },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function category(
|
||||
categoryId: number,
|
||||
categoryName: string,
|
||||
memberCount: number,
|
||||
sortId: number,
|
||||
): OidbFriendCategory {
|
||||
return { categoryId, categoryName, memberCount, sortId };
|
||||
}
|
||||
|
||||
function packet(body: OidbSvcTrpcTcp0xFD4_1Response): SendPacketResult {
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<OidbBase<OidbSvcTrpcTcp0xFD4_1Response>>({ body }),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function robotRangePacket(body: OidbRobotUinRangeResponse): SendPacketResult {
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(protobuf_encode<OidbBase<OidbRobotUinRangeResponse>>({ body })),
|
||||
};
|
||||
}
|
||||
|
||||
function memberListPacket(body: OidbSvcTrpcTcp0xFE7_3Response): SendPacketResult {
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<OidbBase<OidbSvcTrpcTcp0xFE7_3Response>>({ body }),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function groupListPacket(body: OidbSvcTrpcTcp0xFE5_2Response): SendPacketResult {
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<OidbBase<OidbSvcTrpcTcp0xFE5_2Response>>({ body }),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function groupDetailPacket(body: OidbSvcTrpcTcp0x88D_0Response): SendPacketResult {
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<OidbBase<OidbSvcTrpcTcp0x88D_0Response>>({ body }),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function groupRequestPacketByUin(
|
||||
body: ObservedGroupRequestResponse<ObservedGroupRequestUserByUin>,
|
||||
): SendPacketResult {
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<OidbBase<ObservedGroupRequestResponse<ObservedGroupRequestUserByUin>>>({
|
||||
body,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function groupRequestPacketByUid(
|
||||
body: ObservedGroupRequestResponse<ObservedGroupRequestUserByUid>,
|
||||
): SendPacketResult {
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<OidbBase<ObservedGroupRequestResponse<ObservedGroupRequestUserByUid>>>({
|
||||
body,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function apiForPages(pages: OidbSvcTrpcTcp0xFD4_1Response[]) {
|
||||
let index = 0;
|
||||
const sendRawPacket = vi.fn(
|
||||
async (_cmd: string, _body: Uint8Array): Promise<SendPacketResult> => {
|
||||
const page = pages[index++];
|
||||
|
||||
if (!page) {
|
||||
throw new Error(`unexpected friend-list page ${index}`);
|
||||
}
|
||||
|
||||
return packet(page);
|
||||
},
|
||||
);
|
||||
const rememberFriends = vi.fn();
|
||||
const api = new ContactsApi({
|
||||
sendRawPacket,
|
||||
identity: { rememberFriends },
|
||||
} as any);
|
||||
return { api, sendRawPacket, rememberFriends };
|
||||
}
|
||||
|
||||
function apiForCategoryChange(roster: OidbSvcTrpcTcp0xFD4_1Response) {
|
||||
const sendRawPacket = vi.fn(
|
||||
async (command: string, _body: Uint8Array): Promise<SendPacketResult> => {
|
||||
if (command === 'OidbSvcTrpcTcp.0xfd4_1') {
|
||||
return packet(roster);
|
||||
}
|
||||
|
||||
if (command === 'OidbSvcTrpcTcp.0x1255_0') {
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<OidbBase<OidbEmpty>>({
|
||||
command: 0x1255,
|
||||
subCommand: 0,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`unexpected command: ${command}`);
|
||||
},
|
||||
);
|
||||
const api = new ContactsApi({
|
||||
sendRawPacket,
|
||||
identity: { uin: '10001', rememberFriends: vi.fn() },
|
||||
} as any);
|
||||
return { api, sendRawPacket };
|
||||
}
|
||||
|
||||
describe('apis/contacts / group roster', () => {
|
||||
it('maps QQ group remarks into the returned and remembered roster', async () => {
|
||||
const rememberGroups = vi.fn();
|
||||
const sendRawPacket = vi.fn(async () =>
|
||||
groupListPacket({
|
||||
groups: [
|
||||
{
|
||||
groupUin: 123456789,
|
||||
info: {
|
||||
groupName: 'Project',
|
||||
memberCount: 42,
|
||||
memberMax: 500,
|
||||
shutUpAllTimestamp: 4_294_967_295,
|
||||
},
|
||||
customInfo: { remark: ' My Project ' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const api = new ContactsApi({
|
||||
sendRawPacket,
|
||||
identity: { rememberGroups },
|
||||
} as any);
|
||||
|
||||
const groups = await api.fetchGroupList();
|
||||
|
||||
expect(groups).toEqual([
|
||||
expect.objectContaining({
|
||||
groupId: 123456789,
|
||||
groupName: 'Project',
|
||||
remark: ' My Project ',
|
||||
memberCount: 42,
|
||||
memberMax: 500,
|
||||
allMuted: true,
|
||||
}),
|
||||
]);
|
||||
expect(rememberGroups).toHaveBeenCalledWith(groups);
|
||||
});
|
||||
|
||||
// #490: `memo` prefers the announcement, so a group with both fields set used
|
||||
// to lose its description entirely. The description must stay readable.
|
||||
it('keeps the group description readable when an announcement is also set (#490)', async () => {
|
||||
const sendRawPacket = vi.fn(async () =>
|
||||
groupListPacket({
|
||||
groups: [
|
||||
{
|
||||
groupUin: 123456789,
|
||||
info: {
|
||||
groupName: 'Both Set',
|
||||
description: '简介正文',
|
||||
announcement: '公告正文',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const api = new ContactsApi({
|
||||
sendRawPacket,
|
||||
identity: { rememberGroups: vi.fn() },
|
||||
} as any);
|
||||
|
||||
const [group] = await api.fetchGroupList();
|
||||
|
||||
expect(group?.memo).toBe('公告正文');
|
||||
expect(group?.description).toBe('简介正文');
|
||||
});
|
||||
|
||||
it('reports the description for a group that has no announcement (#490)', async () => {
|
||||
const sendRawPacket = vi.fn(async () =>
|
||||
groupListPacket({
|
||||
groups: [
|
||||
{
|
||||
groupUin: 123456789,
|
||||
info: { groupName: 'Description Only', description: '只有简介' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const api = new ContactsApi({
|
||||
sendRawPacket,
|
||||
identity: { rememberGroups: vi.fn() },
|
||||
} as any);
|
||||
|
||||
const [group] = await api.fetchGroupList();
|
||||
|
||||
// memo still falls back to the description (unchanged pre-#490 behaviour).
|
||||
expect(group?.memo).toBe('只有简介');
|
||||
expect(group?.description).toBe('只有简介');
|
||||
});
|
||||
|
||||
it('leaves the description unset on a non-member detail lookup (#490)', async () => {
|
||||
const sendRawPacket = vi.fn(async () =>
|
||||
groupDetailPacket({
|
||||
groupInfo: {
|
||||
uin: 123456789n,
|
||||
results: { name: 'Not Joined', noticePreview: '公告预览' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const api = new ContactsApi({ sendRawPacket, identity: {} } as any);
|
||||
|
||||
const detail = await api.fetchGroupDetail(123456789);
|
||||
|
||||
// 0x88D_0 has no description tag, so there is nothing to expose — the field
|
||||
// must stay absent rather than echo the announcement preview.
|
||||
expect(detail?.memo).toBe('公告预览');
|
||||
expect(detail?.description).toBeUndefined();
|
||||
});
|
||||
|
||||
it('maps the group-wide mute timestamp from a single-group detail', async () => {
|
||||
const sendRawPacket = vi.fn(async () =>
|
||||
groupDetailPacket({
|
||||
groupInfo: {
|
||||
uin: 123456789n,
|
||||
results: {
|
||||
name: 'Muted Group',
|
||||
shutUpAllTimestamp: 4_294_967_295,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const api = new ContactsApi({ sendRawPacket, identity: {} } as any);
|
||||
|
||||
const detail = await api.fetchGroupDetail(123456789);
|
||||
|
||||
expect(detail).toEqual(
|
||||
expect.objectContaining({
|
||||
groupId: 123456789,
|
||||
groupName: 'Muted Group',
|
||||
allMuted: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('treats a past group-mute expire as unmuted (#356)', async () => {
|
||||
const sendRawPacket = vi.fn(async () =>
|
||||
groupListPacket({
|
||||
groups: [
|
||||
{
|
||||
groupUin: 123456789,
|
||||
info: {
|
||||
groupName: 'Was Muted',
|
||||
shutUpAllTimestamp: 1_700_000_000,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const api = new ContactsApi({
|
||||
sendRawPacket,
|
||||
identity: { rememberGroups: vi.fn() },
|
||||
} as any);
|
||||
|
||||
const groups = await api.fetchGroupList();
|
||||
expect(groups[0]?.allMuted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('apis/contacts / group requests', () => {
|
||||
it('maps the native numeric-account response into directly actionable requests', async () => {
|
||||
const rememberGroupRequests = vi.fn();
|
||||
const findUidByUin = vi.fn((uin: number) =>
|
||||
uin === 1_234_567_890 ? 'cached_target_uid' : null,
|
||||
);
|
||||
const sendRawPacket = vi.fn(async () =>
|
||||
groupRequestPacketByUin({
|
||||
requests: [
|
||||
{
|
||||
sequence: 1_785_525_232_784_291n,
|
||||
eventType: 1,
|
||||
state: 1,
|
||||
group: { groupUin: 1_095_186_374, groupName: 'group' },
|
||||
target: { uin: 1_234_567_890, name: 'requester' },
|
||||
invitor: { uin: 2_345_678_901, name: 'inviter' },
|
||||
operatorUser: { uin: 3_456_789_012, name: 'operator' },
|
||||
comment: 'please',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const api = new ContactsApi({
|
||||
sendRawPacket,
|
||||
identity: { findUidByUin, rememberGroupRequests },
|
||||
} as any);
|
||||
|
||||
const requests = await api.fetchGroupRequests(false);
|
||||
|
||||
expect(requests).toEqual([
|
||||
expect.objectContaining({
|
||||
groupId: 1_095_186_374,
|
||||
targetUid: 'cached_target_uid',
|
||||
targetUin: 1_234_567_890,
|
||||
invitorUin: 2_345_678_901,
|
||||
operatorUin: 3_456_789_012,
|
||||
sequence: 1_785_525_232_784_291,
|
||||
notifyType: 7,
|
||||
eventType: 1,
|
||||
filtered: false,
|
||||
}),
|
||||
]);
|
||||
expect(rememberGroupRequests).toHaveBeenCalledWith(requests);
|
||||
});
|
||||
|
||||
it('retains the UID-form response for correlating real-time request pushes', async () => {
|
||||
const rememberGroupRequests = vi.fn();
|
||||
const findUinByUid = vi.fn((uid: string) => (uid === 'target_uid' ? 1_234_567_890 : null));
|
||||
const sendRawPacket = vi.fn(async () =>
|
||||
groupRequestPacketByUid({
|
||||
requests: [
|
||||
{
|
||||
sequence: 42n,
|
||||
eventType: 1,
|
||||
state: 1,
|
||||
group: { groupUin: 999, groupName: 'group' },
|
||||
target: { uid: 'target_uid', name: 'requester' },
|
||||
invitor: { uid: 'inviter_uid', name: 'inviter' },
|
||||
operatorUser: { uid: 'operator_uid', name: 'operator' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const api = new ContactsApi({
|
||||
sendRawPacket,
|
||||
identity: { findUinByUid, rememberGroupRequests },
|
||||
} as any);
|
||||
|
||||
const requests = await api.fetchGroupRequestsByUid(true);
|
||||
|
||||
expect(requests).toEqual([
|
||||
expect.objectContaining({
|
||||
targetUid: 'target_uid',
|
||||
targetUin: 1_234_567_890,
|
||||
invitorUid: 'inviter_uid',
|
||||
operatorUid: 'operator_uid',
|
||||
notifyType: 7,
|
||||
eventType: 1,
|
||||
filtered: true,
|
||||
}),
|
||||
]);
|
||||
expect(rememberGroupRequests).toHaveBeenCalledWith(requests);
|
||||
});
|
||||
});
|
||||
|
||||
describe('apis/contacts / categorized friend roster', () => {
|
||||
it('keeps fetchFriendList flat while traversing cookie pages', async () => {
|
||||
const cookie = Uint8Array.from([0x01]);
|
||||
const { api, sendRawPacket } = apiForPages([
|
||||
{ cookie, friends: [friend(10001, 0, 'u1', 'Default')] },
|
||||
{ friends: [friend(10002, 7, 'u2', 'Alice', 'A')] },
|
||||
]);
|
||||
|
||||
await expect(api.fetchFriendList()).resolves.toEqual([
|
||||
{ uin: 10001, uid: 'u1', nickname: 'Default', remark: '', categoryId: 0 },
|
||||
{ uin: 10002, uid: 'u2', nickname: 'Alice', remark: 'A', categoryId: 7 },
|
||||
]);
|
||||
expect(sendRawPacket).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('groups a complete multi-page roster and preserves empty categories', async () => {
|
||||
const cookie = Uint8Array.from([0xaa, 0xbb]);
|
||||
const { api, sendRawPacket, rememberFriends } = apiForPages([
|
||||
{
|
||||
cookie,
|
||||
friends: [friend(10001, 0, 'u1', 'Default')],
|
||||
categories: [
|
||||
category(0, '我的好友', 1, 0),
|
||||
category(7, 'Work', 1, 1),
|
||||
category(9, 'Empty', 0, 2),
|
||||
],
|
||||
},
|
||||
{
|
||||
friends: [friend(10002, 7, 'u2', 'Alice', 'A')],
|
||||
categories: [category(7, 'Work', 1, 1)],
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(api.fetchFriendCategories()).resolves.toEqual([
|
||||
{
|
||||
categoryId: 0,
|
||||
categoryName: '我的好友',
|
||||
memberCount: 1,
|
||||
sortId: 0,
|
||||
friends: [
|
||||
{ uin: 10001, uid: 'u1', nickname: 'Default', remark: '', categoryId: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
categoryId: 7,
|
||||
categoryName: 'Work',
|
||||
memberCount: 1,
|
||||
sortId: 1,
|
||||
friends: [{ uin: 10002, uid: 'u2', nickname: 'Alice', remark: 'A', categoryId: 7 }],
|
||||
},
|
||||
{
|
||||
categoryId: 9,
|
||||
categoryName: 'Empty',
|
||||
memberCount: 0,
|
||||
sortId: 2,
|
||||
friends: [],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(sendRawPacket).toHaveBeenCalledTimes(2);
|
||||
const secondRequest = protobuf_decode<OidbBase<OidbFriendListRequest>>(
|
||||
sendRawPacket.mock.calls[1]![1],
|
||||
);
|
||||
expect(secondRequest.body?.cookie).toEqual(cookie);
|
||||
expect(rememberFriends).toHaveBeenCalledOnce();
|
||||
expect(rememberFriends).toHaveBeenCalledWith([
|
||||
{ uin: 10001, uid: 'u1', nickname: 'Default', remark: '', categoryId: 0 },
|
||||
{ uin: 10002, uid: 'u2', nickname: 'Alice', remark: 'A', categoryId: 7 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects a friend whose category metadata never arrives', async () => {
|
||||
const { api } = apiForPages([
|
||||
{
|
||||
friends: [friend(10001, 99, 'u1', 'Orphan')],
|
||||
categories: [],
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(api.fetchFriendCategories()).rejects.toThrow('missing category 99');
|
||||
});
|
||||
|
||||
it('rejects a repeated non-empty pagination cookie', async () => {
|
||||
const cookie = Uint8Array.from([0xaa]);
|
||||
const { api } = apiForPages([{ cookie }, { cookie }]);
|
||||
|
||||
await expect(api.fetchFriendCategories()).rejects.toThrow('repeated friend-list cookie aa');
|
||||
});
|
||||
|
||||
it('moves a live friend to a category selected by ID', async () => {
|
||||
const { api, sendRawPacket } = apiForCategoryChange({
|
||||
friends: [friend(10001, 0, 'u_friend', 'Alice')],
|
||||
categories: [category(0, '我的好友', 1, 0), category(7, 'Work', 0, 1)],
|
||||
});
|
||||
|
||||
await api.setFriendCategory({ uin: 10001, categoryId: 7 });
|
||||
|
||||
expect(sendRawPacket).toHaveBeenCalledTimes(2);
|
||||
const [command, bytes] = sendRawPacket.mock.calls[1]!;
|
||||
expect(command).toBe('OidbSvcTrpcTcp.0x1255_0');
|
||||
const envelope = protobuf_decode<OidbBase<OidbSetFriendCategoryRequest>>(bytes);
|
||||
expect(envelope.body).toEqual({ uid: 'u_friend', categoryId: 7 });
|
||||
});
|
||||
|
||||
it('resolves an exact unique category name before moving the friend', async () => {
|
||||
const { api, sendRawPacket } = apiForCategoryChange({
|
||||
friends: [friend(10001, 0, 'u_friend', 'Alice')],
|
||||
categories: [category(0, '我的好友', 1, 0), category(7, 'Work', 0, 1)],
|
||||
});
|
||||
|
||||
await api.setFriendCategory({ uin: 10001, categoryName: 'Work' });
|
||||
|
||||
const envelope = protobuf_decode<OidbBase<OidbSetFriendCategoryRequest>>(
|
||||
sendRawPacket.mock.calls[1]![1],
|
||||
);
|
||||
expect(envelope.body).toEqual({ uid: 'u_friend', categoryId: 7 });
|
||||
});
|
||||
|
||||
it('rejects unknown friends and categories without sending a mutation', async () => {
|
||||
const unknownFriend = apiForCategoryChange({
|
||||
friends: [friend(10001, 0, 'u_friend', 'Alice')],
|
||||
categories: [category(0, '我的好友', 1, 0)],
|
||||
});
|
||||
await expect(
|
||||
unknownFriend.api.setFriendCategory({
|
||||
uin: 10002,
|
||||
categoryId: 0,
|
||||
}),
|
||||
).rejects.toThrow('friend 10002 is not in the live roster');
|
||||
expect(unknownFriend.sendRawPacket).toHaveBeenCalledTimes(1);
|
||||
|
||||
const unknownCategory = apiForCategoryChange({
|
||||
friends: [friend(10001, 0, 'u_friend', 'Alice')],
|
||||
categories: [category(0, '我的好友', 1, 0)],
|
||||
});
|
||||
await expect(
|
||||
unknownCategory.api.setFriendCategory({
|
||||
uin: 10001,
|
||||
categoryId: 7,
|
||||
}),
|
||||
).rejects.toThrow('friend category 7 does not exist');
|
||||
expect(unknownCategory.sendRawPacket).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects duplicate category names instead of selecting one arbitrarily', async () => {
|
||||
const { api, sendRawPacket } = apiForCategoryChange({
|
||||
friends: [friend(10001, 0, 'u_friend', 'Alice')],
|
||||
categories: [category(0, 'Work', 1, 0), category(7, 'Work', 0, 1)],
|
||||
});
|
||||
|
||||
await expect(
|
||||
api.setFriendCategory({
|
||||
uin: 10001,
|
||||
categoryName: 'Work',
|
||||
}),
|
||||
).rejects.toThrow('friend category name "Work" is ambiguous');
|
||||
expect(sendRawPacket).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects missing or conflicting selectors before reading the roster', async () => {
|
||||
const { api, sendRawPacket } = apiForCategoryChange({
|
||||
friends: [],
|
||||
categories: [],
|
||||
});
|
||||
|
||||
await expect(api.setFriendCategory({ uin: 10001 })).rejects.toThrow(
|
||||
'exactly one of categoryId or categoryName is required',
|
||||
);
|
||||
await expect(
|
||||
api.setFriendCategory({
|
||||
uin: 10001,
|
||||
categoryId: 7,
|
||||
categoryName: 'Work',
|
||||
}),
|
||||
).rejects.toThrow('exactly one of categoryId or categoryName is required');
|
||||
expect(sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('apis/contacts / robot group-member classification', () => {
|
||||
it('loads QQ robot ranges once and marks every fetched member', async () => {
|
||||
const rememberGroupMembers = vi.fn();
|
||||
const sendRawPacket = vi.fn(
|
||||
async (cmd: string, _data: Uint8Array): Promise<SendPacketResult> => {
|
||||
if (cmd === 'OidbSvcTrpcTcp.0x496_0') {
|
||||
return robotRangePacket({
|
||||
robotConfig: {
|
||||
version: 206,
|
||||
ranges: [{ minUin: 3_889_000_000n, maxUin: 3_889_999_999n }],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (cmd === 'OidbSvcTrpcTcp.0xfe7_3') {
|
||||
return memberListPacket({
|
||||
groupUin: 42,
|
||||
members: [
|
||||
{ uin: { uid: 'u_robot', uin: 3_889_054_356 }, memberName: 'robot' },
|
||||
{ uin: { uid: 'u_person', uin: 1_234_567_890 }, memberName: 'person' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`unexpected command: ${cmd}`);
|
||||
},
|
||||
);
|
||||
const api = new ContactsApi({
|
||||
sendRawPacket,
|
||||
identity: { uin: '10001', rememberGroupMembers },
|
||||
} as any);
|
||||
|
||||
const first = await api.fetchGroupMemberList(42);
|
||||
const second = await api.fetchGroupMemberList(42, { force: true });
|
||||
|
||||
expect(first).toEqual([
|
||||
expect.objectContaining({ uin: 3_889_054_356, isRobot: true }),
|
||||
expect.objectContaining({ uin: 1_234_567_890, isRobot: false }),
|
||||
]);
|
||||
expect(second).toEqual(first);
|
||||
expect(rememberGroupMembers).toHaveBeenCalledWith(42, first);
|
||||
expect(sendRawPacket.mock.calls.map(([cmd]) => cmd).sort()).toEqual([
|
||||
'OidbSvcTrpcTcp.0x496_0',
|
||||
'OidbSvcTrpcTcp.0xfe7_3',
|
||||
'OidbSvcTrpcTcp.0xfe7_3',
|
||||
]);
|
||||
expect(
|
||||
sendRawPacket.mock.calls.filter(([cmd]) => cmd === 'OidbSvcTrpcTcp.0x496_0'),
|
||||
).toHaveLength(1);
|
||||
|
||||
const rangeCall = sendRawPacket.mock.calls.find(
|
||||
([cmd]) => cmd === 'OidbSvcTrpcTcp.0x496_0',
|
||||
)!;
|
||||
const request = protobuf_decode<OidbBase<OidbRobotUinRangeRequest>>(rangeCall[1]);
|
||||
expect(request.body).toMatchObject({ justFetchMsgConfig: 1, type: 1 });
|
||||
});
|
||||
|
||||
it('propagates an invalid range snapshot and retries it on the next fetch', async () => {
|
||||
let rangeAttempts = 0;
|
||||
const sendRawPacket = vi.fn(
|
||||
async (cmd: string, _data: Uint8Array): Promise<SendPacketResult> => {
|
||||
if (cmd === 'OidbSvcTrpcTcp.0x496_0') {
|
||||
rangeAttempts += 1;
|
||||
return rangeAttempts === 1
|
||||
? robotRangePacket({})
|
||||
: robotRangePacket({
|
||||
robotConfig: {
|
||||
version: 206,
|
||||
ranges: [{ minUin: 3_889_000_000n, maxUin: 3_889_999_999n }],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (cmd === 'OidbSvcTrpcTcp.0xfe7_3') {
|
||||
return memberListPacket({
|
||||
groupUin: 42,
|
||||
members: [
|
||||
{ uin: { uid: 'u_robot', uin: 3_889_054_356 }, memberName: 'robot' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`unexpected command: ${cmd}`);
|
||||
},
|
||||
);
|
||||
const rememberGroupMembers = vi.fn();
|
||||
const api = new ContactsApi({
|
||||
sendRawPacket,
|
||||
identity: { uin: '10001', rememberGroupMembers },
|
||||
} as any);
|
||||
|
||||
await expect(api.fetchGroupMemberList(42)).rejects.toThrow(
|
||||
'0x496_0 response missing robot range config',
|
||||
);
|
||||
await expect(api.fetchGroupMemberList(42)).resolves.toEqual([
|
||||
expect.objectContaining({ isRobot: true }),
|
||||
]);
|
||||
|
||||
expect(rangeAttempts).toBe(2);
|
||||
expect(rememberGroupMembers).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('apis/contacts / invite-card pending application', () => {
|
||||
it('remembers, reads, and reverse-looks-up a card sequence', () => {
|
||||
const api = new ContactsApi({ identity: { uin: '10001' } } as any);
|
||||
expect(api.getGroupInviteCardSequence(12345)).toBeUndefined();
|
||||
|
||||
expect(api.rememberGroupInviteCardSequence(12345, 778899)).toBe(true);
|
||||
expect(api.rememberGroupInviteCardSequence(12345, 778899)).toBe(false);
|
||||
|
||||
expect(api.getGroupInviteCardSequence(12345)).toBe(778899);
|
||||
expect(api.findGroupInviteCardGroupBySequence(778899)).toBe(12345);
|
||||
expect(api.findGroupInviteCardGroupBySequence(1)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('requires a group when different groups have the same card sequence', () => {
|
||||
const api = new ContactsApi({ identity: { uin: '10001' } } as any);
|
||||
api.rememberGroupInviteCardSequence(12345, 778899);
|
||||
api.rememberGroupInviteCardSequence(67890, 778899);
|
||||
|
||||
expect(() => api.findGroupInviteCardGroupBySequence(778899)).toThrow('multiple groups');
|
||||
expect(api.findGroupInviteCardGroupBySequence(778899, 67890)).toBe(67890);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,494 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import { BridgeEventBus } from '@snowluma/protocol/event-bus';
|
||||
import type { QQEventVariant } from '@snowluma/protocol/events';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type {
|
||||
OidbGroupTodo,
|
||||
OidbQueryGroupTopBannersReq,
|
||||
OidbQueryGroupTopBannersResp,
|
||||
OidbStrangerStatusReq,
|
||||
OidbStrangerStatusResp,
|
||||
} from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type {
|
||||
OidbAiVoiceListReq,
|
||||
OidbAiVoiceListResp,
|
||||
OidbAiVoiceReq,
|
||||
OidbAiVoiceResp,
|
||||
} from '@snowluma/proto-defs/oidb-actions/media';
|
||||
|
||||
// Post-namespace migration: ExtrasApi forwards OIDB cmds through
|
||||
// namespaces under @snowluma/protocol/oidb-services/extras. Tests assert
|
||||
// against bridge.sendRawPacket directly.
|
||||
import { ExtrasApi, AiVoiceChatType } from '../../src/bridge/apis/extras';
|
||||
import { mockBridge } from './_helpers';
|
||||
|
||||
function packResponse(body: Uint8Array) {
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(body),
|
||||
};
|
||||
}
|
||||
|
||||
describe('apis/extras / group todo (0x9474 / 0xF90)', () => {
|
||||
it('getGroupTodoList dispatches the todo-only banner query', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.sendRawPacket.mockResolvedValueOnce(
|
||||
packResponse(protobuf_encode<OidbBase<OidbQueryGroupTopBannersResp>>({ body: {} })),
|
||||
);
|
||||
|
||||
await expect(new ExtrasApi(bridge as any).getGroupTodoList(12345)).resolves.toEqual([]);
|
||||
|
||||
const [wireName, bytes] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
expect(wireName).toBe('OidbSvcTrpcTcp.0x9474_0');
|
||||
const env = protobuf_decode<OidbBase<OidbQueryGroupTopBannersReq>>(bytes);
|
||||
expect(env.body).toEqual({ groupId: 12345n, bannerFlag: 1 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['setGroupTodo', 'OidbSvcTrpcTcp.0xf90_1'] as const,
|
||||
['completeGroupTodo', 'OidbSvcTrpcTcp.0xf90_2'] as const,
|
||||
['cancelGroupTodo', 'OidbSvcTrpcTcp.0xf90_3'] as const,
|
||||
])('%s dispatches the right subCmd with shared body', async (method, cmd) => {
|
||||
const bridge = mockBridge();
|
||||
const api = new ExtrasApi(bridge as any);
|
||||
await (api as any)[method](12345, 9876543210n);
|
||||
const [wireName, bytes] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
expect(wireName).toBe(cmd);
|
||||
const env = protobuf_decode<OidbBase<OidbGroupTodo>>(bytes);
|
||||
expect(env.body).toEqual({ groupUin: 12345, msgSeq: 9876543210n });
|
||||
});
|
||||
});
|
||||
|
||||
describe('apis/extras / getStrangerStatus (0xFE1_2)', () => {
|
||||
it('issues the FE1_2 query with key=27372', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.sendRawPacket.mockResolvedValueOnce(
|
||||
packResponse(
|
||||
protobuf_encode<OidbBase<OidbStrangerStatusResp>>({
|
||||
body: { data: { properties: { entries: [{ key: 27372, value: 5n }] } } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
await new ExtrasApi(bridge as any).getStrangerStatus(100200);
|
||||
const [wireName, bytes] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
expect(wireName).toBe('OidbSvcTrpcTcp.0xfe1_2');
|
||||
const env = protobuf_decode<OidbBase<OidbStrangerStatusReq>>(bytes);
|
||||
expect(env.command).toBe(0xfe1);
|
||||
expect(env.subCommand).toBe(2);
|
||||
expect(env.body).toMatchObject({ uin: 100200, key: [{ key: 27372 }] });
|
||||
});
|
||||
|
||||
it('low-band values (≤10) map to status*10 with ext_status=0', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.sendRawPacket.mockResolvedValueOnce(
|
||||
packResponse(
|
||||
protobuf_encode<OidbBase<OidbStrangerStatusResp>>({
|
||||
body: { data: { properties: { entries: [{ key: 27372, value: 7n }] } } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(await new ExtrasApi(bridge as any).getStrangerStatus(1)).toEqual({
|
||||
status: 70,
|
||||
ext_status: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('high-band values decompose into the (0xff00 + (>>16 & 0xff)) status word', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.sendRawPacket.mockResolvedValueOnce(
|
||||
packResponse(
|
||||
protobuf_encode<OidbBase<OidbStrangerStatusResp>>({
|
||||
body: { data: { properties: { entries: [{ key: 27372, value: 0x42f100n }] } } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
const status = await new ExtrasApi(bridge as any).getStrangerStatus(1);
|
||||
expect(status).toEqual({ status: 10, ext_status: 0xf142 });
|
||||
});
|
||||
|
||||
it('returns null when the runner throws (transport error)', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.sendRawPacket.mockRejectedValueOnce(new Error('boom'));
|
||||
expect(await new ExtrasApi(bridge as any).getStrangerStatus(1)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the server omits the status field', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.sendRawPacket.mockResolvedValueOnce(
|
||||
packResponse(protobuf_encode<OidbBase<OidbStrangerStatusResp>>({ body: {} })),
|
||||
);
|
||||
expect(await new ExtrasApi(bridge as any).getStrangerStatus(1)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('apis/extras / AI voice (0x929D / 0x929B)', () => {
|
||||
it('fetchAiVoiceList sends 0x929D_0 and returns server content verbatim', async () => {
|
||||
const bridge = mockBridge();
|
||||
const fake = [
|
||||
{
|
||||
category: 'cute',
|
||||
voices: [{ voiceId: 'v1', voiceDisplayName: 'V1', voiceExampleUrl: 'http://x' }],
|
||||
},
|
||||
];
|
||||
bridge.sendRawPacket.mockResolvedValueOnce(
|
||||
packResponse(
|
||||
protobuf_encode<OidbBase<OidbAiVoiceListResp>>({ body: { content: fake } } as any),
|
||||
),
|
||||
);
|
||||
const out = await new ExtrasApi(bridge as any).fetchAiVoiceList(
|
||||
4242,
|
||||
AiVoiceChatType.Sound,
|
||||
);
|
||||
expect(out).toEqual(fake);
|
||||
const [wireName, bytes] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
expect(wireName).toBe('OidbSvcTrpcTcp.0x929d_0');
|
||||
const env = protobuf_decode<OidbBase<OidbAiVoiceListReq>>(bytes);
|
||||
expect(env.body).toEqual({ groupUin: 4242, chatType: 1 });
|
||||
});
|
||||
|
||||
it('fetchAiVoiceList returns [] when server replies with no content', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.sendRawPacket.mockResolvedValueOnce(
|
||||
packResponse(protobuf_encode<OidbBase<OidbAiVoiceListResp>>({ body: {} })),
|
||||
);
|
||||
const out = await new ExtrasApi(bridge as any).fetchAiVoiceList(1, 1);
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
|
||||
it('fetchAiVoice retries while msgInfo is empty and returns the media node', async () => {
|
||||
const bridge = mockBridge();
|
||||
const node = { fileUuid: 'uuid-1', subType: 1 };
|
||||
bridge.sendRawPacket
|
||||
.mockResolvedValueOnce(
|
||||
packResponse(
|
||||
protobuf_encode<OidbBase<OidbAiVoiceResp>>({ body: { statusCode: 2 } } as any),
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
packResponse(
|
||||
protobuf_encode<OidbBase<OidbAiVoiceResp>>({
|
||||
body: { msgInfo: { msgInfoBody: [] } } as any,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
packResponse(
|
||||
protobuf_encode<OidbBase<OidbAiVoiceResp>>({
|
||||
body: {
|
||||
field2: 319,
|
||||
field3: 20,
|
||||
msgInfo: { msgInfoBody: [{ index: node }] },
|
||||
} as any,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const out = await new ExtrasApi(bridge as any).fetchAiVoice(
|
||||
100,
|
||||
'voice-id',
|
||||
'hi',
|
||||
AiVoiceChatType.Sound,
|
||||
);
|
||||
expect(out).toMatchObject(node);
|
||||
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(3);
|
||||
const [wireName, bytes] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
expect(wireName).toBe('OidbSvcTrpcTcp.0x929b_0');
|
||||
const env = protobuf_decode<OidbBase<OidbAiVoiceReq>>(bytes);
|
||||
expect(env.body).toMatchObject({
|
||||
groupUin: 100,
|
||||
voiceId: 'voice-id',
|
||||
text: 'hi',
|
||||
chatType: 1,
|
||||
});
|
||||
const sessionId = env.body?.session?.sessionId;
|
||||
expect(typeof sessionId).toBe('number');
|
||||
expect(sessionId).toBeGreaterThanOrEqual(0);
|
||||
expect(sessionId).toBeLessThanOrEqual(0xffffffff);
|
||||
});
|
||||
|
||||
it('fetchAiVoice throws after exhausting the retry budget', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.sendRawPacket.mockResolvedValue(
|
||||
packResponse(
|
||||
protobuf_encode<OidbBase<OidbAiVoiceResp>>({ body: { statusCode: 1 } as any }),
|
||||
),
|
||||
);
|
||||
await expect(new ExtrasApi(bridge as any).fetchAiVoice(1, 'v', 't', 1, 3)).rejects.toThrow(
|
||||
/AI voice synthesis did not complete/,
|
||||
);
|
||||
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('fetchAiVoice keeps the same sessionId across retries', async () => {
|
||||
const bridge = mockBridge();
|
||||
const node = { fileUuid: 'uuid', subType: 1 };
|
||||
bridge.sendRawPacket
|
||||
.mockResolvedValueOnce(
|
||||
packResponse(protobuf_encode<OidbBase<OidbAiVoiceResp>>({ body: {} })),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
packResponse(
|
||||
protobuf_encode<OidbBase<OidbAiVoiceResp>>({
|
||||
body: { msgInfo: { msgInfoBody: [{ index: node }] } } as any,
|
||||
}),
|
||||
),
|
||||
);
|
||||
await new ExtrasApi(bridge as any).fetchAiVoice(1, 'v', 't', 1);
|
||||
const env1 = protobuf_decode<OidbBase<OidbAiVoiceReq>>(
|
||||
bridge.sendRawPacket.mock.calls[0]![1],
|
||||
);
|
||||
const env2 = protobuf_decode<OidbBase<OidbAiVoiceReq>>(
|
||||
bridge.sendRawPacket.mock.calls[1]![1],
|
||||
);
|
||||
expect(env1.body?.session?.sessionId).toBe(env2.body?.session?.sessionId);
|
||||
});
|
||||
|
||||
it('sendAiVoice correlates an echo that arrives before the synthesis response', async () => {
|
||||
const events = new BridgeEventBus();
|
||||
const bridge = mockBridge({ events: events as any });
|
||||
const node = { fileUuid: 'voice-uuid', info: { fileHash: 'AABB' } };
|
||||
bridge.sendRawPacket.mockImplementationOnce(async () => {
|
||||
await events.emit({
|
||||
kind: 'group_message',
|
||||
time: 1234,
|
||||
selfUin: 10001,
|
||||
groupId: 100,
|
||||
groupName: 'test',
|
||||
senderUin: 10001,
|
||||
senderNick: 'self',
|
||||
senderCard: '',
|
||||
senderRole: 'member',
|
||||
msgSeq: 319,
|
||||
msgId: 456,
|
||||
elements: [{ type: 'record', fileId: 'voice-uuid', fileHash: 'AABB' }],
|
||||
});
|
||||
return packResponse(
|
||||
protobuf_encode<OidbBase<OidbAiVoiceResp>>({
|
||||
body: { msgInfo: { msgInfoBody: [{ index: node }] } },
|
||||
} as any),
|
||||
);
|
||||
});
|
||||
|
||||
const receipt = await new ExtrasApi(bridge as any).sendAiVoice(100, 'v', 't', 1, 50);
|
||||
expect(receipt).toEqual({ sequence: 319 });
|
||||
});
|
||||
|
||||
it('sendAiVoice ignores unrelated self voice messages and matches the media UUID', async () => {
|
||||
const events = new BridgeEventBus();
|
||||
const bridge = mockBridge({ events: events as any });
|
||||
const node = { fileUuid: 'expected-uuid', info: { fileHash: 'aabb' } };
|
||||
bridge.sendRawPacket.mockResolvedValueOnce(
|
||||
packResponse(
|
||||
protobuf_encode<OidbBase<OidbAiVoiceResp>>({
|
||||
body: { msgInfo: { msgInfoBody: [{ index: node }] } },
|
||||
} as any),
|
||||
),
|
||||
);
|
||||
const pending = new ExtrasApi(bridge as any).sendAiVoice(100, 'v', 't', 1, 100);
|
||||
await events.emit({
|
||||
kind: 'group_message',
|
||||
time: 0,
|
||||
selfUin: 10001,
|
||||
groupId: 999,
|
||||
groupName: 'other',
|
||||
senderUin: 10001,
|
||||
senderNick: 'self',
|
||||
senderCard: '',
|
||||
senderRole: 'member',
|
||||
msgSeq: 10,
|
||||
msgId: 20,
|
||||
elements: [{ type: 'record', fileId: 'expected-uuid' }],
|
||||
});
|
||||
await events.emit({
|
||||
kind: 'group_message',
|
||||
time: 0,
|
||||
selfUin: 10001,
|
||||
groupId: 100,
|
||||
groupName: 'test',
|
||||
senderUin: 20002,
|
||||
senderNick: 'other',
|
||||
senderCard: '',
|
||||
senderRole: 'member',
|
||||
msgSeq: 11,
|
||||
msgId: 21,
|
||||
elements: [{ type: 'record', fileId: 'expected-uuid' }],
|
||||
});
|
||||
await events.emit({
|
||||
kind: 'group_message',
|
||||
time: 0,
|
||||
selfUin: 10001,
|
||||
groupId: 100,
|
||||
groupName: 'test',
|
||||
senderUin: 10001,
|
||||
senderNick: 'self',
|
||||
senderCard: '',
|
||||
senderRole: 'member',
|
||||
msgSeq: 12,
|
||||
msgId: 22,
|
||||
elements: [{ type: 'text', text: 'not a voice' }],
|
||||
});
|
||||
await events.emit({
|
||||
kind: 'group_message',
|
||||
time: 1,
|
||||
selfUin: 10001,
|
||||
groupId: 100,
|
||||
groupName: 'test',
|
||||
senderUin: 10001,
|
||||
senderNick: 'self',
|
||||
senderCard: '',
|
||||
senderRole: 'member',
|
||||
msgSeq: 1,
|
||||
msgId: 2,
|
||||
elements: [{ type: 'record', fileId: 'EXPECTED-UUID', fileHash: 'AABB' }],
|
||||
});
|
||||
await events.emit({
|
||||
kind: 'group_message',
|
||||
time: 3,
|
||||
selfUin: 10001,
|
||||
groupId: 100,
|
||||
groupName: 'test',
|
||||
senderUin: 10001,
|
||||
senderNick: 'self',
|
||||
senderCard: '',
|
||||
senderRole: 'member',
|
||||
msgSeq: 4,
|
||||
msgId: 5,
|
||||
elements: [{ type: 'record', mediaNode: { fileUuid: 'expected-uuid' } }],
|
||||
});
|
||||
|
||||
await expect(pending).resolves.toEqual({ sequence: 4 });
|
||||
});
|
||||
|
||||
it('sendAiVoice lets each self-message receipt satisfy only one concurrent request', async () => {
|
||||
const events = new BridgeEventBus();
|
||||
const bridge = mockBridge({ events: events as any });
|
||||
const node = { fileUuid: 'shared-uuid', info: { fileHash: 'AABB' } };
|
||||
bridge.sendRawPacket.mockResolvedValue(
|
||||
packResponse(
|
||||
protobuf_encode<OidbBase<OidbAiVoiceResp>>({
|
||||
body: { msgInfo: { msgInfoBody: [{ index: node }] } },
|
||||
} as any),
|
||||
),
|
||||
);
|
||||
const api = new ExtrasApi(bridge as any);
|
||||
const first = api.sendAiVoice(100, 'v', 'same text', 1, 100);
|
||||
const second = api.sendAiVoice(100, 'v', 'same text', 1, 100);
|
||||
|
||||
const firstReceipt: Extract<QQEventVariant, { kind: 'group_message' }> = {
|
||||
kind: 'group_message',
|
||||
time: 1,
|
||||
selfUin: 10001,
|
||||
groupId: 100,
|
||||
groupName: 'test',
|
||||
senderUin: 10001,
|
||||
senderNick: 'self',
|
||||
senderCard: '',
|
||||
senderRole: 'member',
|
||||
msgSeq: 10,
|
||||
msgId: 20,
|
||||
elements: [{ type: 'record', fileId: 'shared-uuid' }],
|
||||
};
|
||||
await events.emit(firstReceipt);
|
||||
// A second QQ process can decode the same push into a different object.
|
||||
await events.emit({ ...firstReceipt, elements: [...firstReceipt.elements] });
|
||||
await events.emit({
|
||||
kind: 'group_message',
|
||||
time: 2,
|
||||
selfUin: 10001,
|
||||
groupId: 100,
|
||||
groupName: 'test',
|
||||
senderUin: 10001,
|
||||
senderNick: 'self',
|
||||
senderCard: '',
|
||||
senderRole: 'member',
|
||||
msgSeq: 11,
|
||||
msgId: 21,
|
||||
elements: [{ type: 'record', fileId: 'shared-uuid' }],
|
||||
});
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([
|
||||
{ sequence: 10 },
|
||||
{ sequence: 11 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('sendAiVoice fails explicitly when QQ omits every correlation key', async () => {
|
||||
const events = new BridgeEventBus();
|
||||
const bridge = mockBridge({ events: events as any });
|
||||
bridge.sendRawPacket.mockResolvedValueOnce(
|
||||
packResponse(
|
||||
protobuf_encode<OidbBase<OidbAiVoiceResp>>({
|
||||
body: { msgInfo: { msgInfoBody: [{ index: { subType: 1 } }] } },
|
||||
} as any),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
new ExtrasApi(bridge as any).sendAiVoice(100, 'v', 't', 1, 10),
|
||||
).rejects.toThrow(/no exact media identifier/i);
|
||||
});
|
||||
|
||||
it('sendAiVoice refuses hash-only correlation because concurrent audio can be identical', async () => {
|
||||
const events = new BridgeEventBus();
|
||||
const bridge = mockBridge({ events: events as any });
|
||||
bridge.sendRawPacket.mockResolvedValueOnce(
|
||||
packResponse(
|
||||
protobuf_encode<OidbBase<OidbAiVoiceResp>>({
|
||||
body: { msgInfo: { msgInfoBody: [{ index: { info: { fileHash: 'AABB' } } }] } },
|
||||
} as any),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
new ExtrasApi(bridge as any).sendAiVoice(100, 'v', 't', 1, 10),
|
||||
).rejects.toThrow(/no exact media identifier/i);
|
||||
});
|
||||
|
||||
it('sendAiVoice rejects a matched receipt with an invalid message sequence', async () => {
|
||||
const events = new BridgeEventBus();
|
||||
const bridge = mockBridge({ events: events as any });
|
||||
bridge.sendRawPacket.mockResolvedValueOnce(
|
||||
packResponse(
|
||||
protobuf_encode<OidbBase<OidbAiVoiceResp>>({
|
||||
body: { msgInfo: { msgInfoBody: [{ index: { fileUuid: 'uuid' } }] } },
|
||||
} as any),
|
||||
),
|
||||
);
|
||||
const pending = new ExtrasApi(bridge as any).sendAiVoice(100, 'v', 't', 1, 50);
|
||||
await events.emit({
|
||||
kind: 'group_message',
|
||||
time: 1,
|
||||
selfUin: 10001,
|
||||
groupId: 100,
|
||||
groupName: 'test',
|
||||
senderUin: 10001,
|
||||
senderNick: 'self',
|
||||
senderCard: '',
|
||||
senderRole: 'member',
|
||||
msgSeq: 0,
|
||||
msgId: 2,
|
||||
elements: [{ type: 'record', fileId: 'uuid' }],
|
||||
});
|
||||
|
||||
await expect(pending).rejects.toThrow(/invalid message sequence: 0/i);
|
||||
});
|
||||
|
||||
it('sendAiVoice fails explicitly when the canonical echo never arrives', async () => {
|
||||
const events = new BridgeEventBus();
|
||||
const bridge = mockBridge({ events: events as any });
|
||||
bridge.sendRawPacket.mockResolvedValueOnce(
|
||||
packResponse(
|
||||
protobuf_encode<OidbBase<OidbAiVoiceResp>>({
|
||||
body: { msgInfo: { msgInfoBody: [{ index: { fileUuid: 'uuid' } }] } },
|
||||
} as any),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(new ExtrasApi(bridge as any).sendAiVoice(100, 'v', 't', 1, 5)).rejects.toThrow(
|
||||
/published but no matching group-message receipt/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,588 @@
|
||||
// Regression coverage for the file-inside-send_forward_msg bug.
|
||||
//
|
||||
// Symptom: caller put `{type:'file', file_id}` inside a forward node;
|
||||
// the element-builder dropped it (`[ElemBuilder] BUG: {type:"file"}
|
||||
// reached element-builder — must be split out at the OneBot layer`)
|
||||
// because the live-send paths route files through dedicated methods
|
||||
// (sendC2cFileMessage / sendGroupFileMessage), and the forward
|
||||
// builder was naïvely calling buildSendElems with the same ctx. The
|
||||
// forward upload succeeded but the bubble shipped without the file
|
||||
// element.
|
||||
//
|
||||
// Fix: forward upload passes `forwardFake: true` on the SendContext.
|
||||
// The element-builder honours that flag by emitting the receive-side
|
||||
// shapes:
|
||||
// * group file → transElem(elemType=24, GroupFileExtra)
|
||||
// * c2c file → handled at the forward-builder level, written into
|
||||
// `body.msgContent` as FileExtra { file: NotOnlineFile }
|
||||
//
|
||||
// These shapes are RECEIVE-side: the QQ-NT live-send pipeline rejects
|
||||
// transElem(24) (result=79), but the long-msg upload service stores the
|
||||
// gzipped protobuf verbatim and the recipient's msg-push decoder pulls
|
||||
// the file entity back out via the normal path (rich-body-decoder.ts).
|
||||
// Mirrors NapCat's `PacketMsgFileElement.{buildElement,buildContent}`
|
||||
// split.
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { gunzipSync } from 'zlib';
|
||||
|
||||
vi.mock('@snowluma/protocol/bridge-oidb', () => ({
|
||||
makeOidbEnvelope: vi.fn((_oidbCmd, _subCmd, body) => ({ body })),
|
||||
encodeOidbEnv: vi.fn(() => new Uint8Array()),
|
||||
decodeOidbEnv: vi.fn(() => ({ body: {} })),
|
||||
}));
|
||||
|
||||
// `vi.mock` factories are hoisted above the imports — declare the spy
|
||||
// inside a `vi.hoisted` block so it's available at the same moment.
|
||||
// Declare parameter types on the fn so `mock.calls[0]` infers a tuple
|
||||
// of [elements, ctx] instead of `[]`, which makes the destructuring
|
||||
// a tsc error under strict tuple checking on CI.
|
||||
const { buildSendElemsMock } = vi.hoisted(() => ({
|
||||
buildSendElemsMock: vi.fn(async (_elements: unknown[], _ctx?: Record<string, unknown>) => []),
|
||||
}));
|
||||
vi.mock('@snowluma/protocol/element-builder', () => ({
|
||||
buildSendElems: buildSendElemsMock,
|
||||
}));
|
||||
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { LongMsgResult, SendLongMsgReq, SendLongMsgResp } from '@snowluma/proto-defs/longmsg';
|
||||
import type { FileExtra } from '@snowluma/proto-defs/message';
|
||||
import { ForwardApi } from '../../src/bridge/apis/forward';
|
||||
import { mockBridge } from './_helpers';
|
||||
|
||||
function uploadResponseWithResId(resId: string) {
|
||||
const encoded = protobuf_encode<SendLongMsgResp>({ result: { resId } });
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(encoded),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeLongMsgRequest(rawBytes: Uint8Array): LongMsgResult {
|
||||
const env = protobuf_decode<SendLongMsgReq>(rawBytes);
|
||||
const payload = env.info?.payload;
|
||||
|
||||
if (!(payload instanceof Uint8Array)) {
|
||||
throw new Error('payload missing on SendLongMsgReq');
|
||||
}
|
||||
|
||||
return protobuf_decode<LongMsgResult>(gunzipSync(Buffer.from(payload)));
|
||||
}
|
||||
|
||||
describe('actions/forward — file segment inside forward node', () => {
|
||||
it('rejects cached temporary files mixed with text before building a forward payload', async () => {
|
||||
buildSendElemsMock.mockClear();
|
||||
const bridge = mockBridge({
|
||||
recallUploadedFile: vi.fn(() => ({ scope: 'group', groupId: 12345, busId: 104 })),
|
||||
});
|
||||
await expect(
|
||||
new ForwardApi(bridge as any).upload(
|
||||
[
|
||||
{
|
||||
userUin: 10001,
|
||||
nickname: 'alice',
|
||||
elements: [
|
||||
{ type: 'text', text: 'caption' },
|
||||
{ type: 'file', fileId: 'temp' },
|
||||
],
|
||||
},
|
||||
],
|
||||
12345,
|
||||
),
|
||||
).rejects.toThrow(/temporary group files.*only file or image/i);
|
||||
expect(buildSendElemsMock).not.toHaveBeenCalled();
|
||||
expect(bridge.sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disallows temporary fallback when a forward node mixes a new file with text', async () => {
|
||||
buildSendElemsMock.mockClear();
|
||||
const bridge = mockBridge();
|
||||
bridge.apis.groupFile.upload.mockRejectedValueOnce(
|
||||
new Error('temporary group files require only file or image segments'),
|
||||
);
|
||||
await expect(
|
||||
new ForwardApi(bridge as any).upload(
|
||||
[
|
||||
{
|
||||
userUin: 10001,
|
||||
nickname: 'alice',
|
||||
elements: [
|
||||
{ type: 'text', text: 'caption' },
|
||||
{ type: 'file', url: 'base64://AQID', fileName: 'note.txt' },
|
||||
],
|
||||
},
|
||||
],
|
||||
12345,
|
||||
),
|
||||
).rejects.toThrow(/temporary group files/);
|
||||
expect(bridge.apis.groupFile.upload).toHaveBeenCalledWith(
|
||||
12345,
|
||||
'base64://AQID',
|
||||
'note.txt',
|
||||
'/',
|
||||
true,
|
||||
false,
|
||||
{ allowTemporary: false },
|
||||
);
|
||||
expect(buildSendElemsMock).not.toHaveBeenCalled();
|
||||
expect(bridge.sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a recursive window shake before identity resolution or upload', async () => {
|
||||
buildSendElemsMock.mockClear();
|
||||
const resolveUserUid = vi.fn();
|
||||
const uploadPrivate = vi.fn();
|
||||
const sendRawPacket = vi.fn();
|
||||
const bridge = mockBridge({
|
||||
resolveUserUid,
|
||||
sendRawPacket: sendRawPacket as any,
|
||||
apis: {
|
||||
...mockBridge().apis,
|
||||
groupFile: {
|
||||
...mockBridge().apis.groupFile,
|
||||
uploadPrivate,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
new ForwardApi(bridge as any).upload(
|
||||
[
|
||||
{
|
||||
userUin: 10001,
|
||||
nickname: 'alice',
|
||||
elements: [{ type: 'image', url: 'base64://AQID' }],
|
||||
innerForward: [
|
||||
{
|
||||
userUin: 10002,
|
||||
nickname: 'bob',
|
||||
elements: [{ type: 'poke', subType: 1 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
67890,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: 'UNSENDABLE_TYPE',
|
||||
elementType: 'poke',
|
||||
});
|
||||
|
||||
expect(resolveUserUid).not.toHaveBeenCalled();
|
||||
expect(uploadPrivate).not.toHaveBeenCalled();
|
||||
expect(buildSendElemsMock).not.toHaveBeenCalled();
|
||||
expect(sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('group forward sets forwardFake:true on the SendContext so transElem(24) is emitted', async () => {
|
||||
// Group case: the file element rides on elems[] as transElem(24).
|
||||
// The element-builder receives ctx.forwardFake=true so it knows to
|
||||
// emit the receive-side shape instead of dropping the segment.
|
||||
buildSendElemsMock.mockClear();
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async () => uploadResponseWithResId('res-grp-file')) as any,
|
||||
});
|
||||
|
||||
await new ForwardApi(bridge as any).upload(
|
||||
[
|
||||
{
|
||||
userUin: 10001,
|
||||
nickname: 'alice',
|
||||
elements: [
|
||||
{ type: 'file', fileId: 'gfid-1', fileName: 'a.txt', fileSize: 99 } as any,
|
||||
],
|
||||
},
|
||||
],
|
||||
12345,
|
||||
);
|
||||
|
||||
expect(buildSendElemsMock).toHaveBeenCalled();
|
||||
const [, ctx] = buildSendElemsMock.mock.calls[0]!;
|
||||
expect(ctx).toMatchObject({ groupId: 12345, forwardFake: true });
|
||||
});
|
||||
|
||||
it('c2c forward writes the file as msgContent (FileExtra { file: NotOnlineFile })', async () => {
|
||||
// Private case: c2c files live on `body.msgContent`, not in elems[].
|
||||
// The forward-builder pulls the file segment off and synthesises a
|
||||
// FileExtra payload so the recipient's decoder (which reads
|
||||
// msgContent first) renders the file bubble.
|
||||
buildSendElemsMock.mockClear();
|
||||
let captured: Uint8Array | undefined;
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async (_cmd: string, body: Uint8Array) => {
|
||||
captured = body;
|
||||
return uploadResponseWithResId('res-c2c-file');
|
||||
}) as any,
|
||||
// Inline fields cover everything we need; cache lookup must not be
|
||||
// required for the path to work.
|
||||
recallUploadedFile: vi.fn(() => undefined),
|
||||
});
|
||||
|
||||
await new ForwardApi(bridge as any).upload(
|
||||
[
|
||||
{
|
||||
userUin: 10001,
|
||||
nickname: 'alice',
|
||||
elements: [
|
||||
{
|
||||
type: 'file',
|
||||
fileId: 'pfid-1',
|
||||
fileName: 'invoice.pdf',
|
||||
fileSize: 4096,
|
||||
md5Hex: 'aabbccddeeff00112233445566778899',
|
||||
fileHash: 'srv-hash-abc',
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
67890,
|
||||
);
|
||||
|
||||
expect(buildSendElemsMock).toHaveBeenCalled();
|
||||
const [, ctx] = buildSendElemsMock.mock.calls[0]!;
|
||||
expect(ctx).toMatchObject({ forwardFake: true });
|
||||
expect(ctx?.groupId).toBeUndefined();
|
||||
|
||||
expect(captured).toBeDefined();
|
||||
const longMsg = decodeLongMsgRequest(captured!);
|
||||
const body = longMsg.action?.[0]?.actionData?.msgBody?.[0]?.body;
|
||||
expect(body?.msgContent).toBeInstanceOf(Uint8Array);
|
||||
expect(body?.richText?.notOnlineFile ?? undefined).toBeUndefined();
|
||||
|
||||
const fileExtra = protobuf_decode<FileExtra>(body!.msgContent as Uint8Array);
|
||||
expect(fileExtra.file).toMatchObject({
|
||||
fileUuid: 'pfid-1',
|
||||
fileName: 'invoice.pdf',
|
||||
fileHash: 'srv-hash-abc',
|
||||
});
|
||||
expect(fileExtra.file?.fileSize).toBe(4096n);
|
||||
});
|
||||
|
||||
it('c2c forward falls back to the upload metadata cache when inline fields are missing', async () => {
|
||||
// The OneBot caller often passes only `file_id` — the rest comes
|
||||
// from the bridge's uploaded-file cache. This is the same
|
||||
// hydration path send_private_msg uses; the forward builder must
|
||||
// honour it too or every cached-only forward ships a 0 B bubble.
|
||||
buildSendElemsMock.mockClear();
|
||||
const recallUploadedFile = vi.fn((id: string) =>
|
||||
id === 'pfid-cached'
|
||||
? {
|
||||
fileId: 'pfid-cached',
|
||||
scope: 'private' as const,
|
||||
userId: 67890,
|
||||
fileName: 'cached-name.zip',
|
||||
fileSize: 8192,
|
||||
fileMd5: Buffer.from('ffeeddccbbaa99887766554433221100', 'hex'),
|
||||
fileSha1: new Uint8Array(20),
|
||||
fileHash: 'cached-hash',
|
||||
rememberedAt: 0,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async () => uploadResponseWithResId('res-cache')) as any,
|
||||
recallUploadedFile,
|
||||
});
|
||||
|
||||
await new ForwardApi(bridge as any).upload(
|
||||
[
|
||||
{
|
||||
userUin: 10001,
|
||||
nickname: 'alice',
|
||||
elements: [{ type: 'file', fileId: 'pfid-cached' } as any],
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
67890,
|
||||
);
|
||||
|
||||
expect(recallUploadedFile).toHaveBeenCalledWith('pfid-cached');
|
||||
});
|
||||
|
||||
it('group forward pre-uploads file sources into the target group without publishing a separate file message', async () => {
|
||||
buildSendElemsMock.mockClear();
|
||||
const upload = vi.fn(async () => ({ fileId: 'gfid-from-base64' }));
|
||||
const publish = vi.fn();
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async () => uploadResponseWithResId('res-grp-source')) as any,
|
||||
apis: {
|
||||
...mockBridge().apis,
|
||||
groupFile: {
|
||||
...mockBridge().apis.groupFile,
|
||||
upload,
|
||||
publish,
|
||||
},
|
||||
},
|
||||
recallUploadedFile: vi.fn((id: string) =>
|
||||
id === 'gfid-from-base64'
|
||||
? {
|
||||
fileId: 'gfid-from-base64',
|
||||
scope: 'group' as const,
|
||||
groupId: 12345,
|
||||
fileName: 'note.txt',
|
||||
fileSize: 3,
|
||||
fileMd5: new Uint8Array(16),
|
||||
fileSha1: new Uint8Array(20),
|
||||
rememberedAt: 0,
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
});
|
||||
|
||||
await new ForwardApi(bridge as any).upload(
|
||||
[
|
||||
{
|
||||
userUin: 10001,
|
||||
nickname: 'alice',
|
||||
elements: [{ type: 'file', url: 'base64://AQID', fileName: 'note.txt' } as any],
|
||||
},
|
||||
],
|
||||
12345,
|
||||
);
|
||||
|
||||
expect(upload).toHaveBeenCalledWith(12345, 'base64://AQID', 'note.txt', '/', true, false, {
|
||||
allowTemporary: true,
|
||||
});
|
||||
expect(publish).not.toHaveBeenCalled();
|
||||
const [elements] = buildSendElemsMock.mock.calls[0]!;
|
||||
expect(elements).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'file',
|
||||
fileId: 'gfid-from-base64',
|
||||
fileName: 'note.txt',
|
||||
fileSize: 3,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('private forward pre-uploads file sources without sending a standalone c2c file message', async () => {
|
||||
buildSendElemsMock.mockClear();
|
||||
const uploadPrivate = vi.fn(async () => ({
|
||||
fileId: 'pfid-from-base64',
|
||||
fileHash: 'phash',
|
||||
}));
|
||||
const sendC2cFile = vi.fn();
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async () => uploadResponseWithResId('res-c2c-source')) as any,
|
||||
apis: {
|
||||
...mockBridge().apis,
|
||||
message: {
|
||||
...mockBridge().apis.message,
|
||||
sendC2cFile,
|
||||
},
|
||||
groupFile: {
|
||||
...mockBridge().apis.groupFile,
|
||||
uploadPrivate,
|
||||
},
|
||||
},
|
||||
recallUploadedFile: vi.fn((id: string) =>
|
||||
id === 'pfid-from-base64'
|
||||
? {
|
||||
fileId: 'pfid-from-base64',
|
||||
scope: 'private' as const,
|
||||
userId: 67890,
|
||||
fileName: 'note.txt',
|
||||
fileSize: 3,
|
||||
fileMd5: new Uint8Array(16),
|
||||
fileSha1: new Uint8Array(20),
|
||||
fileHash: 'phash',
|
||||
rememberedAt: 0,
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
});
|
||||
|
||||
await new ForwardApi(bridge as any).upload(
|
||||
[
|
||||
{
|
||||
userUin: 10001,
|
||||
nickname: 'alice',
|
||||
elements: [{ type: 'file', url: 'base64://AQID', fileName: 'note.txt' } as any],
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
67890,
|
||||
);
|
||||
|
||||
expect(uploadPrivate).toHaveBeenCalledWith(67890, 'base64://AQID', 'note.txt', true, false);
|
||||
expect(sendC2cFile).not.toHaveBeenCalled();
|
||||
const [elements] = buildSendElemsMock.mock.calls[0]!;
|
||||
expect(elements).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'file',
|
||||
fileId: 'pfid-from-base64',
|
||||
fileName: 'note.txt',
|
||||
fileSize: 3,
|
||||
fileHash: 'phash',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects two files in one private forward node before uploading either file', async () => {
|
||||
buildSendElemsMock.mockClear();
|
||||
const uploadPrivate = vi.fn();
|
||||
const sendRawPacket = vi.fn();
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: sendRawPacket as any,
|
||||
apis: {
|
||||
...mockBridge().apis,
|
||||
groupFile: {
|
||||
...mockBridge().apis.groupFile,
|
||||
uploadPrivate,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
new ForwardApi(bridge as any).upload(
|
||||
[
|
||||
{
|
||||
userUin: 10001,
|
||||
nickname: 'alice',
|
||||
elements: [
|
||||
{ type: 'file', url: 'base64://AQID', fileName: 'one.txt' },
|
||||
{ type: 'file', url: 'base64://BAUG', fileName: 'two.txt' },
|
||||
] as any,
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
67890,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: 'UNSENDABLE_TYPE',
|
||||
elementType: 'file',
|
||||
});
|
||||
|
||||
expect(uploadPrivate).not.toHaveBeenCalled();
|
||||
expect(buildSendElemsMock).not.toHaveBeenCalled();
|
||||
expect(sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('private file forward keeps the long-msg upload under the self uid namespace', async () => {
|
||||
buildSendElemsMock.mockClear();
|
||||
let captured: Uint8Array | undefined;
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async (_cmd: string, body: Uint8Array) => {
|
||||
captured = body;
|
||||
return uploadResponseWithResId('res-c2c-target-uid');
|
||||
}) as any,
|
||||
resolveUserUid: vi.fn(async () => 'target-uid'),
|
||||
recallUploadedFile: vi.fn((id: string) =>
|
||||
id === 'pfid-1'
|
||||
? {
|
||||
fileId: 'pfid-1',
|
||||
scope: 'private' as const,
|
||||
userId: 67890,
|
||||
fileName: 'invoice.pdf',
|
||||
fileSize: 4096,
|
||||
fileMd5: new Uint8Array(16),
|
||||
fileSha1: new Uint8Array(20),
|
||||
fileHash: 'srv-hash',
|
||||
rememberedAt: 0,
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
});
|
||||
|
||||
await new ForwardApi(bridge as any).upload(
|
||||
[
|
||||
{
|
||||
userUin: 10001,
|
||||
nickname: 'alice',
|
||||
elements: [{ type: 'file', fileId: 'pfid-1' } as any],
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
67890,
|
||||
);
|
||||
|
||||
expect(bridge.resolveUserUid).not.toHaveBeenCalled();
|
||||
expect(captured).toBeDefined();
|
||||
const request = protobuf_decode<SendLongMsgReq>(captured!);
|
||||
expect(request.info?.uid?.uid).toBe('self-uid');
|
||||
});
|
||||
|
||||
it('group forward re-uploads a cached private file_id into the target group scope', async () => {
|
||||
buildSendElemsMock.mockClear();
|
||||
const getPrivateUrl = vi.fn(async () => 'https://download.test/private-file');
|
||||
const upload = vi.fn(async () => ({ fileId: 'gfid-reuploaded' }));
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async () => uploadResponseWithResId('res-cross-scope')) as any,
|
||||
apis: {
|
||||
...mockBridge().apis,
|
||||
groupFile: {
|
||||
...mockBridge().apis.groupFile,
|
||||
getPrivateUrl,
|
||||
upload,
|
||||
},
|
||||
},
|
||||
recallUploadedFile: vi.fn((id: string) => {
|
||||
if (id === 'pfid-cached') {
|
||||
return {
|
||||
fileId: 'pfid-cached',
|
||||
scope: 'private' as const,
|
||||
userId: 67890,
|
||||
fileName: 'cached.txt',
|
||||
fileSize: 5,
|
||||
fileMd5: new Uint8Array(16),
|
||||
fileSha1: new Uint8Array(20),
|
||||
fileHash: 'cached-hash',
|
||||
rememberedAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (id === 'gfid-reuploaded') {
|
||||
return {
|
||||
fileId: 'gfid-reuploaded',
|
||||
scope: 'group' as const,
|
||||
groupId: 12345,
|
||||
fileName: 'cached.txt',
|
||||
fileSize: 5,
|
||||
fileMd5: new Uint8Array(16),
|
||||
fileSha1: new Uint8Array(20),
|
||||
rememberedAt: 1,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}),
|
||||
});
|
||||
|
||||
await new ForwardApi(bridge as any).upload(
|
||||
[
|
||||
{
|
||||
userUin: 10001,
|
||||
nickname: 'alice',
|
||||
elements: [{ type: 'file', fileId: 'pfid-cached' } as any],
|
||||
},
|
||||
],
|
||||
12345,
|
||||
);
|
||||
|
||||
expect(getPrivateUrl).toHaveBeenCalledWith(67890, 'pfid-cached', 'cached-hash');
|
||||
expect(upload).toHaveBeenCalledWith(
|
||||
12345,
|
||||
'https://download.test/private-file',
|
||||
'cached.txt',
|
||||
'/',
|
||||
true,
|
||||
false,
|
||||
{ allowTemporary: true },
|
||||
);
|
||||
const [elements] = buildSendElemsMock.mock.calls[0]!;
|
||||
expect(elements).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'file',
|
||||
fileId: 'gfid-reuploaded',
|
||||
fileName: 'cached.txt',
|
||||
fileSize: 5,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import type { SendLongMsgResp } from '@snowluma/proto-defs/longmsg';
|
||||
import { buildApiContext, type OneBotInstanceContext } from '@snowluma/onebot/instance-context';
|
||||
import { ForwardApi } from '../../src/bridge/apis/forward';
|
||||
import { mockBridge } from './_helpers';
|
||||
|
||||
vi.mock('@snowluma/protocol/highway/image-upload', () => ({ uploadImageMsgInfo: vi.fn() }));
|
||||
vi.mock('@snowluma/protocol/highway/ptt-upload', () => ({ uploadPtt: vi.fn() }));
|
||||
vi.mock('@snowluma/protocol/highway/video-upload', () => ({ uploadVideoMsgInfo: vi.fn() }));
|
||||
|
||||
describe('forward metadata through upload and cached reads', () => {
|
||||
it.each([
|
||||
{ nested: false, enrich: false },
|
||||
{ nested: true, enrich: false },
|
||||
{ nested: false, enrich: true },
|
||||
{ nested: true, enrich: true },
|
||||
])(
|
||||
'preserves titles and previews with nesting=$nested and enrichment=$enrich',
|
||||
async ({ nested, enrich }) => {
|
||||
let uploadCount = 0;
|
||||
const sendRawPacket = vi.fn(async () => ({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<SendLongMsgResp>({
|
||||
result: { resId: `metadata-${nested}-${enrich}-${++uploadCount}` },
|
||||
}),
|
||||
),
|
||||
}));
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
bridge.apis.contacts.fetchGroupMemberList.mockResolvedValue([
|
||||
{ uin: 111, nickname: 'resolved outer', card: '' },
|
||||
]);
|
||||
const forward = new ForwardApi(bridge as any);
|
||||
const ctx = {
|
||||
selfId: 10001,
|
||||
bridge: {
|
||||
identity: { nickname: 'SnowLuma' },
|
||||
apis: {
|
||||
forward,
|
||||
message: {
|
||||
sendGroup: vi.fn(async () => ({
|
||||
messageId: 1,
|
||||
sequence: 2,
|
||||
clientSequence: 0,
|
||||
random: 3,
|
||||
timestamp: 1700000000,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
messageStore: { findEvent: () => null, findMedia: () => null, storeEvent: vi.fn() },
|
||||
cacheMessageMeta: vi.fn(),
|
||||
converterCtx: {
|
||||
selfId: 10001,
|
||||
imageUrlResolver: null,
|
||||
mediaUrlResolver: null,
|
||||
messageIdResolver: null,
|
||||
mediaSegmentSink: null,
|
||||
},
|
||||
} as unknown as OneBotInstanceContext;
|
||||
const api = buildApiContext(ctx);
|
||||
const text = [{ type: 'text', data: { text: 'hello' } }];
|
||||
const preview = {
|
||||
title: 'outer title',
|
||||
source: 'custom source',
|
||||
summary: 'custom summary',
|
||||
prompt: 'custom prompt',
|
||||
news: [{ text: 'custom preview' }],
|
||||
};
|
||||
const { forwardId } = await api.sendGroupForwardMsg(12345, [
|
||||
{
|
||||
type: 'node',
|
||||
data: {
|
||||
user_id: 111,
|
||||
nickname: enrich ? '' : 'outer',
|
||||
...preview,
|
||||
content: nested
|
||||
? [
|
||||
{
|
||||
type: 'node',
|
||||
data: {
|
||||
user_id: 222,
|
||||
nickname: 'inner',
|
||||
title: 'inner title',
|
||||
content: text,
|
||||
},
|
||||
},
|
||||
]
|
||||
: text,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const first = await api.getForwardMsg(forwardId);
|
||||
expect(first[0]!.sender).toMatchObject({ title: 'outer title' });
|
||||
expect(first[0]!.sender).toMatchObject({
|
||||
nickname: enrich ? 'resolved outer' : 'outer',
|
||||
});
|
||||
expect((await forward.fetch(forwardId!))[0]).toMatchObject(preview);
|
||||
|
||||
if (nested) {
|
||||
expect(
|
||||
(await api.getForwardMsg(`metadata-true-${enrich}-1`))[0]!.sender,
|
||||
).toMatchObject({ title: 'inner title' });
|
||||
}
|
||||
|
||||
const fetched = await forward.fetch(forwardId!);
|
||||
fetched[0]!.title = 'changed title';
|
||||
fetched[0]!.news![0]!.text = 'changed preview';
|
||||
preview.news[0]!.text = 'changed input';
|
||||
const again = await forward.fetch(forwardId!);
|
||||
expect(again[0]!.title).toBe('outer title');
|
||||
expect(again[0]!.news).toEqual([{ text: 'custom preview' }]);
|
||||
expect(sendRawPacket).toHaveBeenCalledTimes(nested ? 2 : 1);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,389 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { deflateSync, gzipSync } from 'zlib';
|
||||
import { buildApiContext, type OneBotInstanceContext } from '@snowluma/onebot/instance-context';
|
||||
import type { Elem } from '@snowluma/proto-defs/element';
|
||||
import type { LongMsgResult, RecvLongMsgReq, RecvLongMsgResp } from '@snowluma/proto-defs/longmsg';
|
||||
import type { PushMsgBody } from '@snowluma/proto-defs/message';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import { ForwardApi } from '../../src/bridge/apis/forward';
|
||||
import type { BridgeContext } from '../../src/bridge/bridge-context';
|
||||
import { mockBridge } from './_helpers';
|
||||
|
||||
function forwardCard(format: 'xml' | 'lightApp', resId: string, key?: string): Elem {
|
||||
const content =
|
||||
format === 'xml'
|
||||
? `<msg serviceID="35" m_resid="${resId}"${key ? ` m_fileName="${key}"` : ''} />`
|
||||
: JSON.stringify({
|
||||
app: 'com.tencent.multimsg',
|
||||
meta: { detail: { resid: resId, uniseq: key } },
|
||||
});
|
||||
const data = Buffer.concat([Buffer.from([1]), deflateSync(Buffer.from(content))]);
|
||||
return format === 'xml'
|
||||
? { richMsg: { serviceId: 35, template1: data } }
|
||||
: { lightApp: { data } };
|
||||
}
|
||||
|
||||
function message(...elems: Elem[]): PushMsgBody {
|
||||
return {
|
||||
responseHead: {
|
||||
fromUin: 20002,
|
||||
fromUid: 'sender-uid',
|
||||
grp: { groupUin: 12345, memberCard: Buffer.from('alice') },
|
||||
},
|
||||
contentHead: { msgType: 82, sequence: 10, timestamp: 1700000000 },
|
||||
body: { richText: { elems } },
|
||||
};
|
||||
}
|
||||
|
||||
function setup(downloads: Map<string, LongMsgResult>, selfUin = '10001') {
|
||||
const requested: string[] = [];
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async (command: string, bytes: Uint8Array) => {
|
||||
expect(command).toBe('trpc.group.long_msg_interface.MsgService.SsoRecvLongMsg');
|
||||
const resId = protobuf_decode<RecvLongMsgReq>(bytes).info!.resId!;
|
||||
requested.push(resId);
|
||||
const data = downloads.get(resId);
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<RecvLongMsgResp>({
|
||||
result: {
|
||||
resId,
|
||||
payload: data
|
||||
? gzipSync(protobuf_encode<LongMsgResult>(data))
|
||||
: undefined,
|
||||
},
|
||||
}),
|
||||
),
|
||||
};
|
||||
}),
|
||||
});
|
||||
bridge.identity.uin = selfUin;
|
||||
const forward = new ForwardApi(bridge as unknown as BridgeContext);
|
||||
const ctx = {
|
||||
selfId: Number(selfUin),
|
||||
bridge: { apis: { forward } },
|
||||
converterCtx: {
|
||||
selfId: Number(selfUin),
|
||||
imageUrlResolver: null,
|
||||
mediaUrlResolver: null,
|
||||
messageIdResolver: null,
|
||||
mediaSegmentSink: null,
|
||||
},
|
||||
} as unknown as OneBotInstanceContext;
|
||||
return { api: buildApiContext(ctx), requested, bridge, forward };
|
||||
}
|
||||
|
||||
function plainResult(text: string): LongMsgResult {
|
||||
return {
|
||||
action: [
|
||||
{
|
||||
actionCommand: 'MultiMsg',
|
||||
actionData: { msgBody: [message({ text: { str: text } })] },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function nestedResult(inner: string, key: string, body?: PushMsgBody): LongMsgResult {
|
||||
return {
|
||||
action: [
|
||||
{
|
||||
actionCommand: 'MultiMsg',
|
||||
actionData: { msgBody: [message(forwardCard('xml', inner, key))] },
|
||||
},
|
||||
{
|
||||
actionCommand: key,
|
||||
actionData: { msgBody: [body ?? message({ text: { str: 'inner content' } })] },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function fillCache(
|
||||
api: ReturnType<typeof setup>['api'],
|
||||
downloads: Map<string, LongMsgResult>,
|
||||
prefix: string,
|
||||
count: number,
|
||||
): Promise<void> {
|
||||
for (let index = 0; index < count; index++) {
|
||||
const id = `${prefix}-${index}`;
|
||||
downloads.set(id, plainResult(id));
|
||||
await api.getForwardMsg(id);
|
||||
}
|
||||
}
|
||||
|
||||
describe('get_forward_msg / cache lifetime', () => {
|
||||
it('bounds cached results while refreshing recently read results', async () => {
|
||||
const downloads = new Map([
|
||||
['cache-cold', plainResult('cold')],
|
||||
['cache-hot', plainResult('hot')],
|
||||
]);
|
||||
const { api, requested } = setup(downloads);
|
||||
await api.getForwardMsg('cache-cold');
|
||||
await api.getForwardMsg('cache-hot');
|
||||
await fillCache(api, downloads, 'cache-pressure', 510);
|
||||
await api.getForwardMsg('cache-hot');
|
||||
await fillCache(api, downloads, 'cache-overflow', 1);
|
||||
requested.length = 0;
|
||||
await api.getForwardMsg('cache-hot');
|
||||
await api.getForwardMsg('cache-cold');
|
||||
expect(requested).toEqual(['cache-cold']);
|
||||
});
|
||||
|
||||
it('isolates cached content between bridge accounts', async () => {
|
||||
const first = setup(new Map([['same-resource', plainResult('account one')]]));
|
||||
const second = setup(new Map([['same-resource', plainResult('account two')]]), '10002');
|
||||
await first.api.getForwardMsg('same-resource');
|
||||
expect((await second.api.getForwardMsg('same-resource'))[0]?.message).toEqual([
|
||||
{ type: 'text', data: { text: 'account two' } },
|
||||
]);
|
||||
expect(second.requested).toEqual(['same-resource']);
|
||||
});
|
||||
|
||||
it('keeps nested results with a hot outer result under cache pressure', async () => {
|
||||
const downloads = new Map([['hot-outer', nestedResult('hot-inner', 'hot-inner-key')]]);
|
||||
const { api, requested } = setup(downloads);
|
||||
await api.getForwardMsg('hot-outer');
|
||||
await fillCache(api, downloads, 'nested-pressure', 511);
|
||||
await api.getForwardMsg('hot-inner');
|
||||
await fillCache(api, downloads, 'nested-overflow', 1);
|
||||
requested.length = 0;
|
||||
expect((await api.getForwardMsg('hot-outer'))[0]?.message).toEqual([
|
||||
{ type: 'forward', data: { id: 'hot-inner' } },
|
||||
]);
|
||||
expect((await api.getForwardMsg('hot-inner'))[0]?.message).toEqual([
|
||||
{ type: 'text', data: { text: 'inner content' } },
|
||||
]);
|
||||
expect(requested).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps an inner result attached to its outer result after sender enrichment', async () => {
|
||||
const nameless = message({ text: { str: 'unnamed sender' } });
|
||||
nameless.responseHead!.grp!.memberCard = new Uint8Array();
|
||||
const downloads = new Map([
|
||||
['enrich-outer', nestedResult('enrich-inner', 'enrich-key', nameless)],
|
||||
]);
|
||||
const { api, requested, bridge } = setup(downloads);
|
||||
bridge.apis.contacts.fetchGroupMemberList.mockResolvedValue([
|
||||
{ uin: 20002, nickname: 'resolved nickname', card: 'resolved card' },
|
||||
]);
|
||||
await api.getForwardMsg('enrich-outer');
|
||||
await fillCache(api, downloads, 'enrich-pressure', 511);
|
||||
requested.length = 0;
|
||||
expect((await api.getForwardMsg('enrich-inner'))[0]).toMatchObject({
|
||||
sender: { nickname: 'resolved card' },
|
||||
});
|
||||
expect(bridge.apis.contacts.fetchGroupMemberList).toHaveBeenCalledOnce();
|
||||
await api.getForwardMsg('enrich-outer');
|
||||
await api.getForwardMsg('enrich-inner');
|
||||
expect(bridge.apis.contacts.fetchGroupMemberList).toHaveBeenCalledOnce();
|
||||
expect(requested).toEqual([]);
|
||||
});
|
||||
|
||||
it('retains a shared inner result while another containing outer result remains cached', async () => {
|
||||
const downloads = new Map([
|
||||
['shared-outer-a', nestedResult('shared-inner', 'shared-key-a')],
|
||||
['shared-outer-b', nestedResult('shared-inner', 'shared-key-b')],
|
||||
]);
|
||||
const { api, requested } = setup(downloads);
|
||||
await api.getForwardMsg('shared-outer-a');
|
||||
await api.getForwardMsg('shared-outer-b');
|
||||
await fillCache(api, downloads, 'shared-pressure', 510);
|
||||
await api.getForwardMsg('shared-outer-a');
|
||||
await fillCache(api, downloads, 'shared-overflow', 1);
|
||||
requested.length = 0;
|
||||
expect((await api.getForwardMsg('shared-inner'))[0]?.message).toEqual([
|
||||
{ type: 'text', data: { text: 'inner content' } },
|
||||
]);
|
||||
expect(requested).toEqual([]);
|
||||
});
|
||||
|
||||
it('restores all nested results when an evicted outer result is downloaded again', async () => {
|
||||
const downloads = new Map([
|
||||
['restore-outer', nestedResult('restore-inner', 'restore-key')],
|
||||
]);
|
||||
const { api, requested } = setup(downloads);
|
||||
await api.getForwardMsg('restore-outer');
|
||||
await api.getForwardMsg('restore-inner');
|
||||
await fillCache(api, downloads, 'restore-pressure', 512);
|
||||
requested.length = 0;
|
||||
await api.getForwardMsg('restore-outer');
|
||||
expect((await api.getForwardMsg('restore-inner'))[0]?.message).toEqual([
|
||||
{ type: 'text', data: { text: 'inner content' } },
|
||||
]);
|
||||
expect(requested).toEqual(['restore-outer']);
|
||||
});
|
||||
|
||||
it('does not resurrect evicted nested results when enrichment finishes late', async () => {
|
||||
const nameless = message({ text: { str: 'late sender' } });
|
||||
nameless.responseHead!.grp!.memberCard = new Uint8Array();
|
||||
const downloads = new Map([
|
||||
['late-outer', nestedResult('late-inner', 'late-key', nameless)],
|
||||
]);
|
||||
const { api, requested, bridge } = setup(downloads);
|
||||
const members = [{ uin: 20002, nickname: 'late name', card: '' }];
|
||||
let finishEnrichment!: () => void;
|
||||
const enrichment = new Promise<typeof members>((resolve) => {
|
||||
finishEnrichment = () => resolve(members);
|
||||
});
|
||||
bridge.apis.contacts.fetchGroupMemberList.mockReturnValueOnce(enrichment);
|
||||
await api.getForwardMsg('late-outer');
|
||||
const pending = api.getForwardMsg('late-inner');
|
||||
await vi.waitFor(() =>
|
||||
expect(bridge.apis.contacts.fetchGroupMemberList).toHaveBeenCalledOnce(),
|
||||
);
|
||||
await fillCache(api, downloads, 'late-pressure', 512);
|
||||
finishEnrichment();
|
||||
expect((await pending)[0]).toMatchObject({ sender: { nickname: 'late name' } });
|
||||
requested.length = 0;
|
||||
await expect(api.getForwardMsg('late-inner')).rejects.toThrow('payload is empty');
|
||||
await api.getForwardMsg('late-outer');
|
||||
expect((await api.getForwardMsg('late-inner'))[0]?.message).toEqual([
|
||||
{ type: 'text', data: { text: 'late sender' } },
|
||||
]);
|
||||
expect(requested).toEqual(['late-inner', 'late-outer']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get_forward_msg / nested downloads (#529)', () => {
|
||||
it.each(['xml', 'lightApp'] as const)(
|
||||
'resolves siblings and three levels of %s forwards from one outer payload',
|
||||
async (format) => {
|
||||
const outer = `nested-fetch-${format}-outer`;
|
||||
const inner = `nested-fetch-${format}-inner`;
|
||||
const sibling = `nested-fetch-${format}-sibling`;
|
||||
const leaf = `nested-fetch-${format}-leaf`;
|
||||
const { api, requested } = setup(
|
||||
new Map([
|
||||
[
|
||||
outer,
|
||||
{
|
||||
action: [
|
||||
{
|
||||
actionCommand: 'MultiMsg',
|
||||
actionData: {
|
||||
msgBody: [
|
||||
message(forwardCard(format, inner, 'inner-key')),
|
||||
message(forwardCard(format, sibling, 'sibling-key')),
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
actionCommand: 'leaf-key',
|
||||
actionData: {
|
||||
msgBody: [
|
||||
message(
|
||||
{ text: { str: 'deepest content' } },
|
||||
{
|
||||
customFace: {
|
||||
md5: Buffer.alloc(16, 0xab),
|
||||
origUrl: 'https://example.com/forward.png',
|
||||
width: 32,
|
||||
height: 24,
|
||||
},
|
||||
},
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
actionCommand: 'sibling-key',
|
||||
actionData: {
|
||||
msgBody: [message({ text: { str: 'sibling content' } })],
|
||||
},
|
||||
},
|
||||
{
|
||||
actionCommand: 'inner-key',
|
||||
actionData: {
|
||||
msgBody: [message(forwardCard(format, leaf, 'leaf-key'))],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const parent = await api.getForwardMsg(outer);
|
||||
expect(parent.map((node) => node.message)).toEqual([
|
||||
[{ type: 'forward', data: { id: inner } }],
|
||||
[{ type: 'forward', data: { id: sibling } }],
|
||||
]);
|
||||
const middle = await api.getForwardMsg(inner);
|
||||
expect(middle[0]!.message).toEqual([{ type: 'forward', data: { id: leaf } }]);
|
||||
expect((await api.getForwardMsg(leaf))[0]).toMatchObject({
|
||||
sender: { user_id: 20002, nickname: 'alice' },
|
||||
time: 1700000000,
|
||||
message: [
|
||||
{ type: 'text', data: { text: 'deepest content' } },
|
||||
{ type: 'image', data: { url: 'https://example.com/forward.png' } },
|
||||
],
|
||||
});
|
||||
expect((await api.getForwardMsg(sibling))[0]!.message).toEqual([
|
||||
{ type: 'text', data: { text: 'sibling content' } },
|
||||
]);
|
||||
expect(requested).toEqual([outer]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([undefined, 'unmatched-key'])(
|
||||
'downloads by resId when the XML filename is %s and preserves empty-payload errors',
|
||||
async (key) => {
|
||||
const outer = `nested-fetch-fallback-${key}-outer`;
|
||||
const inner = `nested-fetch-fallback-${key}-inner`;
|
||||
const empty = `nested-fetch-fallback-${key}-empty`;
|
||||
const { api, requested } = setup(
|
||||
new Map([
|
||||
[
|
||||
outer,
|
||||
{
|
||||
action: [
|
||||
{
|
||||
actionCommand: 'MultiMsg',
|
||||
actionData: {
|
||||
msgBody: [
|
||||
message(forwardCard('xml', inner, key)),
|
||||
message(forwardCard('xml', empty, key)),
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
actionCommand: 'unrelated-key',
|
||||
actionData: {
|
||||
msgBody: [message({ text: { str: 'unrelated content' } })],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
inner,
|
||||
{
|
||||
action: [
|
||||
{
|
||||
actionCommand: 'MultiMsg',
|
||||
actionData: {
|
||||
msgBody: [message({ text: { str: 'downloaded content' } })],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
await api.getForwardMsg(outer);
|
||||
expect((await api.getForwardMsg(inner))[0]!.message).toEqual([
|
||||
{ type: 'text', data: { text: 'downloaded content' } },
|
||||
]);
|
||||
await expect(api.getForwardMsg(empty)).rejects.toThrow(
|
||||
'download forward message payload is empty',
|
||||
);
|
||||
expect(requested).toEqual([outer, inner, empty]);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,352 @@
|
||||
// Wire-byte alignment regression for nested forward upload — verifies
|
||||
// that the OUTER LongMsgResult's `actions[]` carries:
|
||||
// - `{actionCommand: 'MultiMsg', actionData.msgBody: <outer nodes>}`
|
||||
// - `{actionCommand: <inner.uuid>, actionData.msgBody: <inner nodes>}`
|
||||
// AND that the same uuid appears as `meta.detail.uniseq` inside the
|
||||
// LightApp `com.tencent.multimsg` JSON of the outer's preview element
|
||||
// pointing at the inner res_id.
|
||||
//
|
||||
// Without this alignment the QQ-NT recipient has no way to walk the
|
||||
// piggyback — they see the inner preview's resid, look for a matching
|
||||
// uniseq among the actions, find none, and either fall back to a fresh
|
||||
// server fetch (extra latency) or render an empty bubble.
|
||||
//
|
||||
// Reference impls cross-checked:
|
||||
// dev/NapCatQQ/.../SendMsg.ts:208-347
|
||||
// dev/Lagrange.Core/.../Message/Entity/MultiMsgEntity.cs:43-115
|
||||
// dev/NapCatQQ/.../helper/forward-msg-builder.ts:52-122
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { gunzipSync, inflateSync } from 'zlib';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { SendLongMsgReq, SendLongMsgResp, LongMsgResult } from '@snowluma/proto-defs/longmsg';
|
||||
import type { PushMsgBody } from '@snowluma/proto-defs/message';
|
||||
import type { Elem } from '@snowluma/proto-defs/element';
|
||||
|
||||
// Don't mock element-builder — we WANT the real forward preview
|
||||
// element to land in the encoded wire bytes so we can verify the
|
||||
// `uniseq` round-trip against the piggyback's `actionCommand`.
|
||||
|
||||
// Mock highway uploads only — they're irrelevant for text-only nodes
|
||||
// but the import would otherwise pull in the native ffmpeg addon.
|
||||
vi.mock('@snowluma/protocol/highway/image-upload', () => ({
|
||||
uploadImageMsgInfo: vi.fn(async () => new Uint8Array()),
|
||||
}));
|
||||
vi.mock('@snowluma/protocol/highway/ptt-upload', () => ({
|
||||
uploadPtt: vi.fn(async () => ({ msgInfo: new Uint8Array() })),
|
||||
}));
|
||||
vi.mock('@snowluma/protocol/highway/video-upload', () => ({
|
||||
uploadVideoMsgInfo: vi.fn(async () => new Uint8Array()),
|
||||
}));
|
||||
|
||||
import { ForwardApi } from '../../src/bridge/apis/forward';
|
||||
import type { BridgeContext } from '../../src/bridge/bridge-context';
|
||||
import { mockBridge } from './_helpers';
|
||||
|
||||
function uploadResponseWithResId(resId: string) {
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(protobuf_encode<SendLongMsgResp>({ result: { resId } })),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeOuterLongMsg(rawBytes: Uint8Array): LongMsgResult {
|
||||
const env = protobuf_decode<SendLongMsgReq>(rawBytes);
|
||||
const payload = env.info?.payload;
|
||||
|
||||
if (!(payload instanceof Uint8Array)) {
|
||||
throw new Error('payload missing on the SendLongMsgReq');
|
||||
}
|
||||
|
||||
return protobuf_decode<LongMsgResult>(gunzipSync(Buffer.from(payload)));
|
||||
}
|
||||
|
||||
function findLightAppJson(msgBody: PushMsgBody[]): {
|
||||
resid?: string;
|
||||
uniseq?: string;
|
||||
app?: string;
|
||||
news?: Array<{ text: string }>;
|
||||
source?: string;
|
||||
summary?: string;
|
||||
} | null {
|
||||
for (const body of msgBody) {
|
||||
const elems: Elem[] = body.body?.richText?.elems ?? [];
|
||||
|
||||
for (const elem of elems) {
|
||||
if (elem.lightApp?.data && elem.lightApp.data.length > 1) {
|
||||
const data = elem.lightApp.data;
|
||||
|
||||
if (data[0] !== 0x01) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const json = inflateSync(Buffer.from(data.subarray(1))).toString('utf8');
|
||||
const parsed = JSON.parse(json);
|
||||
|
||||
if (parsed?.app === 'com.tencent.multimsg') {
|
||||
return {
|
||||
app: parsed.app,
|
||||
resid: parsed.meta?.detail?.resid,
|
||||
uniseq: parsed.meta?.detail?.uniseq,
|
||||
news: parsed.meta?.detail?.news,
|
||||
source: parsed.meta?.detail?.source,
|
||||
summary: parsed.meta?.detail?.summary,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('forward / nested upload wire alignment', () => {
|
||||
it('bounds uploaded results while keeping a recently read nested upload together', async () => {
|
||||
let sequence = 0;
|
||||
const sendRawPacket = vi.fn(async (command: string) => {
|
||||
if (command !== 'trpc.group.long_msg_interface.MsgService.SsoSendLongMsg') {
|
||||
throw new Error('test cache miss');
|
||||
}
|
||||
|
||||
return uploadResponseWithResId(`upload-cache-${++sequence}`);
|
||||
});
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
const forward = new ForwardApi(bridge as unknown as BridgeContext);
|
||||
const outer = await forward.upload([
|
||||
{
|
||||
userUin: 111,
|
||||
nickname: 'outer',
|
||||
elements: [],
|
||||
innerForward: [
|
||||
{
|
||||
userUin: 222,
|
||||
nickname: 'inner',
|
||||
elements: [{ type: 'text', text: 'nested upload' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const filler = [{ userUin: 111, nickname: 'filler', elements: [] }];
|
||||
|
||||
for (let index = 0; index < 511; index++) {
|
||||
await forward.upload(filler);
|
||||
}
|
||||
|
||||
await forward.fetch('upload-cache-1');
|
||||
await forward.upload(filler);
|
||||
const uploadedCount = sendRawPacket.mock.calls.length;
|
||||
const sameBridgeApi = new ForwardApi(bridge as unknown as BridgeContext);
|
||||
expect((await sameBridgeApi.fetch(outer))[0]?.elements[0]?.resId).toBe('upload-cache-1');
|
||||
expect((await sameBridgeApi.fetch('upload-cache-1'))[0]?.elements).toEqual([
|
||||
{ type: 'text', text: 'nested upload' },
|
||||
]);
|
||||
expect(sendRawPacket).toHaveBeenCalledTimes(uploadedCount);
|
||||
await expect(forward.fetch('upload-cache-3')).rejects.toThrow('test cache miss');
|
||||
});
|
||||
|
||||
it('outer LongMsgResult contains both MultiMsg and the inner uuid piggyback action', async () => {
|
||||
// Inner upload returns inner-res; outer upload returns outer-res.
|
||||
const responses = ['inner-res', 'outer-res'];
|
||||
const sendRawPacket = vi.fn(async () => uploadResponseWithResId(responses.shift()!)) as any;
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
|
||||
const resId = await new ForwardApi(bridge as any).upload([
|
||||
{
|
||||
userUin: 111,
|
||||
nickname: 'outer',
|
||||
elements: [],
|
||||
innerForward: [
|
||||
{ userUin: 222, nickname: 'inner', elements: [{ type: 'text', text: 'hi' }] },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(resId).toBe('outer-res');
|
||||
expect(sendRawPacket).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Second call is the OUTER's upload (the inner went first).
|
||||
const outerBytes = sendRawPacket.mock.calls[1]![1] as Uint8Array;
|
||||
const outerLongMsg = decodeOuterLongMsg(outerBytes);
|
||||
|
||||
const actions = outerLongMsg.action ?? [];
|
||||
expect(actions).toHaveLength(2);
|
||||
expect(actions[0]!.actionCommand).toBe('MultiMsg');
|
||||
// The second action carries the inner's msgBody under a uuid
|
||||
// actionCommand (NOT 'MultiMsg').
|
||||
const piggybackUuid = actions[1]!.actionCommand!;
|
||||
expect(piggybackUuid).not.toBe('MultiMsg');
|
||||
expect(piggybackUuid).toMatch(/^[0-9a-f-]{36}$/i);
|
||||
expect(actions[1]!.actionData?.msgBody?.length ?? 0).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('outer preview LightApp uniseq matches the piggyback actionCommand uuid', async () => {
|
||||
// This is the alignment bug: without it the QQ-NT receiver
|
||||
// can't link the inner preview to its piggyback in actions[].
|
||||
const responses = ['inner-res-A', 'outer-res-A'];
|
||||
const sendRawPacket = vi.fn(async () => uploadResponseWithResId(responses.shift()!)) as any;
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
|
||||
await new ForwardApi(bridge as any).upload([
|
||||
{
|
||||
userUin: 111,
|
||||
nickname: 'outer',
|
||||
elements: [],
|
||||
innerForward: [
|
||||
{ userUin: 222, nickname: 'inner', elements: [{ type: 'text', text: 'hi' }] },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const outerBytes = sendRawPacket.mock.calls[1]![1] as Uint8Array;
|
||||
const outerLongMsg = decodeOuterLongMsg(outerBytes);
|
||||
|
||||
const piggybackUuid = outerLongMsg.action![1]!.actionCommand!;
|
||||
const outerMsgBody = outerLongMsg.action![0]!.actionData!.msgBody!;
|
||||
const preview = findLightAppJson(outerMsgBody);
|
||||
|
||||
expect(preview).not.toBeNull();
|
||||
expect(preview!.app).toBe('com.tencent.multimsg');
|
||||
expect(preview!.resid).toBe('inner-res-A');
|
||||
expect(preview!.uniseq).toBe(piggybackUuid);
|
||||
});
|
||||
|
||||
it('three-deep nesting: outermost action[] carries 1+2 entries (MultiMsg + 2 piggybacks)', async () => {
|
||||
// The middle level's upload produces 1 MultiMsg + 1 piggyback
|
||||
// (for the deepest). The outermost level then concatenates its
|
||||
// own MultiMsg + the middle's piggyback set, so action[].length = 3.
|
||||
const responses = ['deepest', 'middle', 'outermost'];
|
||||
const sendRawPacket = vi.fn(async () => uploadResponseWithResId(responses.shift()!)) as any;
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
|
||||
await new ForwardApi(bridge as any).upload([
|
||||
{
|
||||
userUin: 1,
|
||||
nickname: 'L1',
|
||||
elements: [],
|
||||
innerForward: [
|
||||
{
|
||||
userUin: 2,
|
||||
nickname: 'L2',
|
||||
elements: [],
|
||||
innerForward: [
|
||||
{
|
||||
userUin: 3,
|
||||
nickname: 'L3',
|
||||
elements: [{ type: 'text', text: 'deep' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(sendRawPacket).toHaveBeenCalledTimes(3);
|
||||
const outermostBytes = sendRawPacket.mock.calls[2]![1] as Uint8Array;
|
||||
const outermost = decodeOuterLongMsg(outermostBytes);
|
||||
expect(outermost.action).toHaveLength(3);
|
||||
expect(outermost.action![0]!.actionCommand).toBe('MultiMsg');
|
||||
expect(outermost.action![1]!.actionCommand).not.toBe('MultiMsg');
|
||||
expect(outermost.action![2]!.actionCommand).not.toBe('MultiMsg');
|
||||
expect(outermost.action![1]!.actionCommand).not.toBe(outermost.action![2]!.actionCommand);
|
||||
});
|
||||
|
||||
it('inner forward bubble prefers caller-supplied node news over auto-generated lines', async () => {
|
||||
const responses = ['inner-res', 'outer-res'];
|
||||
const sendRawPacket = vi.fn(async () => uploadResponseWithResId(responses.shift()!)) as any;
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
|
||||
const customNews = [{ text: '自定义预览行 1' }, { text: '自定义预览行 2' }];
|
||||
await new ForwardApi(bridge as any).upload([
|
||||
{
|
||||
userUin: 111,
|
||||
nickname: 'outer',
|
||||
elements: [],
|
||||
news: customNews,
|
||||
innerForward: [
|
||||
{
|
||||
userUin: 222,
|
||||
nickname: 'inner',
|
||||
elements: [{ type: 'text', text: '会被覆盖的正文' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const outerBytes = sendRawPacket.mock.calls[1]![1] as Uint8Array;
|
||||
const outerMsgBody = decodeOuterLongMsg(outerBytes).action![0]!.actionData!.msgBody!;
|
||||
const preview = findLightAppJson(outerMsgBody);
|
||||
|
||||
expect(preview).not.toBeNull();
|
||||
// Caller-supplied news wins over `inner: 会被覆盖的正文`.
|
||||
expect(preview!.news).toEqual(customNews);
|
||||
});
|
||||
|
||||
it('inner forward bubble falls back to auto-generated lines when node has no news', async () => {
|
||||
const responses = ['inner-res', 'outer-res'];
|
||||
const sendRawPacket = vi.fn(async () => uploadResponseWithResId(responses.shift()!)) as any;
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
|
||||
await new ForwardApi(bridge as any).upload([
|
||||
{
|
||||
userUin: 111,
|
||||
nickname: 'outer',
|
||||
elements: [],
|
||||
// no `news` field — auto-generate from inner nodes
|
||||
innerForward: [
|
||||
{
|
||||
userUin: 222,
|
||||
nickname: '内层说话人',
|
||||
elements: [{ type: 'text', text: '第一句台词' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const outerBytes = sendRawPacket.mock.calls[1]![1] as Uint8Array;
|
||||
const outerMsgBody = decodeOuterLongMsg(outerBytes).action![0]!.actionData!.msgBody!;
|
||||
const preview = findLightAppJson(outerMsgBody);
|
||||
|
||||
expect(preview).not.toBeNull();
|
||||
expect(preview!.news).toEqual([{ text: '内层说话人: 第一句台词' }]);
|
||||
});
|
||||
|
||||
it('inner forward card prefers caller-supplied source/summary/prompt metadata', async () => {
|
||||
const responses = ['inner-res', 'outer-res'];
|
||||
const sendRawPacket = vi.fn(async () => uploadResponseWithResId(responses.shift()!)) as any;
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
|
||||
await new ForwardApi(bridge as any).upload([
|
||||
{
|
||||
userUin: 111,
|
||||
nickname: 'outer',
|
||||
elements: [],
|
||||
source: '万叶的手记',
|
||||
summary: '共 12 条剧情消息',
|
||||
prompt: '查看剧情',
|
||||
innerForward: [
|
||||
{ userUin: 222, nickname: 'inner', elements: [{ type: 'text', text: 'hi' }] },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const outerBytes = sendRawPacket.mock.calls[1]![1] as Uint8Array;
|
||||
const outerMsgBody = decodeOuterLongMsg(outerBytes).action![0]!.actionData!.msgBody!;
|
||||
const preview = findLightAppJson(outerMsgBody);
|
||||
|
||||
expect(preview).not.toBeNull();
|
||||
// decodeOuterLongMsg → lightApp meta.detail as built by element-builder
|
||||
expect(preview!.app).toBe('com.tencent.multimsg');
|
||||
// The plaintext preview lines of the wire bubble.
|
||||
// Caller metadata wins: source = 万叶的手记, summary used instead of
|
||||
// the auto-generated `查看N条转发消息`.
|
||||
expect(preview!.source).toBe('万叶的手记');
|
||||
expect(preview!.summary).toBe('共 12 条剧情消息');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { deflateSync, gunzipSync, gzipSync } from 'node:zlib';
|
||||
import type { Elem } from '@snowluma/proto-defs/element';
|
||||
import type {
|
||||
LongMsgResult,
|
||||
RecvLongMsgReq,
|
||||
RecvLongMsgResp,
|
||||
SendLongMsgReq,
|
||||
SendLongMsgResp,
|
||||
} from '@snowluma/proto-defs/longmsg';
|
||||
import type { PushMsgBody } from '@snowluma/proto-defs/message';
|
||||
import { buildApiContext, type OneBotInstanceContext } from '@snowluma/onebot/instance-context';
|
||||
import type { MessageElement } from '@snowluma/protocol/events';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import { ForwardApi } from '../../src/bridge/apis/forward';
|
||||
import type { BridgeContext } from '../../src/bridge/bridge-context';
|
||||
import { mockBridge } from './_helpers';
|
||||
|
||||
const OUTER_RES_ID = 'outer-resid';
|
||||
const INNER_UUID = 'C125CE25-AE7E-4E1A-ABDC-9750918162EB';
|
||||
const INNER_FILE_NAME = `MultiMsg_${INNER_UUID}`;
|
||||
|
||||
function packetResult(responseData: Uint8Array) {
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(responseData),
|
||||
};
|
||||
}
|
||||
|
||||
function residLessForwardCard(): Elem {
|
||||
const xml =
|
||||
`<msg serviceID="35" templateID="1" action="viewMultiMsg" ` +
|
||||
`m_fileName="${INNER_FILE_NAME}" tSum="2" flag="3" />`;
|
||||
const data = Buffer.concat([Buffer.from([1]), deflateSync(Buffer.from(xml))]);
|
||||
return { richMsg: { serviceId: 35, template1: data } };
|
||||
}
|
||||
|
||||
function message(...elems: Elem[]): PushMsgBody {
|
||||
return {
|
||||
responseHead: {
|
||||
fromUin: 20002,
|
||||
fromUid: 'sender-uid',
|
||||
grp: { groupUin: 12345, memberCard: Buffer.from('alice') },
|
||||
},
|
||||
contentHead: { msgType: 82, sequence: 10, timestamp: 1700000000 },
|
||||
body: { richText: { elems } },
|
||||
};
|
||||
}
|
||||
|
||||
function outerResult(innerActionCommand?: string): LongMsgResult {
|
||||
return {
|
||||
action: [
|
||||
{
|
||||
actionCommand: 'MultiMsg',
|
||||
actionData: { msgBody: [message(residLessForwardCard())] },
|
||||
},
|
||||
...(innerActionCommand
|
||||
? [
|
||||
{
|
||||
actionCommand: innerActionCommand,
|
||||
actionData: {
|
||||
msgBody: [
|
||||
message({ text: { str: 'inner one' } }),
|
||||
message({ text: { str: 'inner two' } }),
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function setup(innerActionCommand?: string) {
|
||||
const requested: string[] = [];
|
||||
const uploads: SendLongMsgReq[] = [];
|
||||
const sendRawPacket = vi.fn(async (command: string, bytes: Uint8Array) => {
|
||||
if (command.endsWith('SsoRecvLongMsg')) {
|
||||
const resId = protobuf_decode<RecvLongMsgReq>(bytes).info!.resId!;
|
||||
requested.push(resId);
|
||||
return packetResult(
|
||||
protobuf_encode<RecvLongMsgResp>({
|
||||
result: {
|
||||
resId,
|
||||
payload:
|
||||
resId === OUTER_RES_ID
|
||||
? gzipSync(
|
||||
protobuf_encode<LongMsgResult>(
|
||||
outerResult(innerActionCommand),
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
expect(command).toMatch(/SsoSendLongMsg$/);
|
||||
const upload = protobuf_decode<SendLongMsgReq>(bytes);
|
||||
uploads.push(upload);
|
||||
return packetResult(
|
||||
protobuf_encode<SendLongMsgResp>({ result: { resId: `rebuilt-${uploads.length}` } }),
|
||||
);
|
||||
});
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
const forward = new ForwardApi(bridge as unknown as BridgeContext);
|
||||
const sendGroup = vi.fn(async (_groupId: number, _elements: MessageElement[]) => ({
|
||||
messageId: 1,
|
||||
sequence: 2,
|
||||
clientSequence: 3,
|
||||
random: 4,
|
||||
timestamp: 1700000001,
|
||||
}));
|
||||
const ctx = {
|
||||
selfId: 10001,
|
||||
bridge: {
|
||||
identity: { nickname: 'SnowLuma' },
|
||||
apis: { forward, message: { sendGroup } },
|
||||
},
|
||||
messageStore: {
|
||||
findEvent: () => ({
|
||||
message: [{ type: 'forward', data: { id: OUTER_RES_ID } }],
|
||||
}),
|
||||
storeEvent: vi.fn(),
|
||||
},
|
||||
converterCtx: {
|
||||
selfId: 10001,
|
||||
imageUrlResolver: null,
|
||||
mediaUrlResolver: null,
|
||||
messageIdResolver: null,
|
||||
mediaSegmentSink: null,
|
||||
},
|
||||
cacheMessageMeta: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
} as unknown as OneBotInstanceContext;
|
||||
|
||||
return { api: buildApiContext(ctx), requested, uploads, sendGroup };
|
||||
}
|
||||
|
||||
describe('resid-less nested forwards (#589)', () => {
|
||||
it.each([INNER_UUID, INNER_FILE_NAME])(
|
||||
'resolves an outer piggyback keyed by %s without another download',
|
||||
async (innerActionCommand) => {
|
||||
const { api, requested } = setup(innerActionCommand);
|
||||
const outer = await api.getForwardMsg(OUTER_RES_ID);
|
||||
expect(outer[0]?.message).toEqual([{ type: 'forward', data: { id: INNER_FILE_NAME } }]);
|
||||
|
||||
const inner = await api.getForwardMsg(INNER_FILE_NAME);
|
||||
expect(inner.map((node) => node.message)).toEqual([
|
||||
[{ type: 'text', data: { text: 'inner one' } }],
|
||||
[{ type: 'text', data: { text: 'inner two' } }],
|
||||
]);
|
||||
expect(requested).toEqual([OUTER_RES_ID]);
|
||||
},
|
||||
);
|
||||
|
||||
it('rebuilds both layers before sending to another group', async () => {
|
||||
const { api, requested, uploads, sendGroup } = setup(INNER_UUID);
|
||||
await expect(api.forwardSingleMsg(77, { groupId: 65432 })).resolves.toHaveProperty(
|
||||
'messageId',
|
||||
);
|
||||
|
||||
expect(requested).toEqual([OUTER_RES_ID]);
|
||||
expect(uploads).toHaveLength(2);
|
||||
const outerBytes = gunzipSync(Buffer.from(uploads[1]!.info!.payload!));
|
||||
const outer = protobuf_decode<LongMsgResult>(outerBytes);
|
||||
expect(outer.action).toHaveLength(2);
|
||||
expect(outerBytes.toString()).not.toContain(INNER_FILE_NAME);
|
||||
expect(sendGroup).toHaveBeenCalledOnce();
|
||||
expect(sendGroup.mock.calls[0]![1]).toEqual([
|
||||
expect.objectContaining({ type: 'forward', resId: 'rebuilt-2' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('fails before upload or send when the inner piggyback is unavailable', async () => {
|
||||
const { api, requested, uploads, sendGroup } = setup();
|
||||
await expect(api.forwardSingleMsg(77, { groupId: 65432 })).rejects.toThrow(
|
||||
'download forward message payload is empty',
|
||||
);
|
||||
|
||||
expect(requested).toEqual([OUTER_RES_ID, INNER_FILE_NAME]);
|
||||
expect(uploads).toHaveLength(0);
|
||||
expect(sendGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { gunzipSync, gzipSync, inflateSync } from 'node:zlib';
|
||||
import type { MsgInfo } from '@snowluma/proto-defs/element';
|
||||
import type {
|
||||
LongMsgResult,
|
||||
RecvLongMsgResp,
|
||||
SendLongMsgReq,
|
||||
SendLongMsgResp,
|
||||
} from '@snowluma/proto-defs/longmsg';
|
||||
import type { PushMsgBody } from '@snowluma/proto-defs/message';
|
||||
import { buildApiContext, type OneBotInstanceContext } from '@snowluma/onebot/instance-context';
|
||||
import { buildSendElems } from '@snowluma/protocol/element-builder';
|
||||
import type { MessageElement } from '@snowluma/protocol/events';
|
||||
import { runNtv2Upload } from '@snowluma/protocol/highway/pipeline';
|
||||
import { loadBinarySource } from '@snowluma/protocol/highway/utils';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import { ForwardApi } from '../../src/bridge/apis/forward';
|
||||
import type { BridgeContext } from '../../src/bridge/bridge-context';
|
||||
import { mockBridge } from './_helpers';
|
||||
|
||||
// Keep the image uploader, element builder and Proton codecs real. Only the
|
||||
// network boundaries are faked, including a source URL that needs an rkey.
|
||||
vi.mock('@snowluma/protocol/highway/pipeline', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@snowluma/protocol/highway/pipeline')>()),
|
||||
runNtv2Upload: vi.fn(),
|
||||
}));
|
||||
vi.mock('@snowluma/protocol/highway/utils', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@snowluma/protocol/highway/utils')>()),
|
||||
loadBinarySource: vi.fn(async () => {
|
||||
throw new Error('source image URL requires an rkey');
|
||||
}),
|
||||
}));
|
||||
|
||||
const md5Hex = 'ab'.repeat(16);
|
||||
const sha1Hex = 'cd'.repeat(20);
|
||||
const sourceInfo: MsgInfo = {
|
||||
msgInfoBody: [
|
||||
{
|
||||
index: {
|
||||
fileUuid: 'source-group-image',
|
||||
info: {
|
||||
fileHash: md5Hex,
|
||||
fileSha1: sha1Hex,
|
||||
fileSize: 1234,
|
||||
fileName: 'image.png',
|
||||
width: 32,
|
||||
height: 24,
|
||||
type: { type: 1, picFormat: 1001 },
|
||||
},
|
||||
},
|
||||
picture: {
|
||||
domain: 'multimedia.nt.qq.com.cn',
|
||||
urlPath: '/download?appid=1407&fileid=source-group-image',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function packetResult(responseData: Uint8Array) {
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(responseData),
|
||||
};
|
||||
}
|
||||
|
||||
function setup(id: string) {
|
||||
const sourceBody: PushMsgBody = {
|
||||
responseHead: {
|
||||
fromUin: 20002,
|
||||
fromUid: 'sender-uid',
|
||||
grp: { groupUin: 12345, memberCard: Buffer.from('alice') },
|
||||
},
|
||||
contentHead: { msgType: 82, sequence: 10, timestamp: 1700000000 },
|
||||
body: {
|
||||
richText: {
|
||||
elems: [
|
||||
{ text: { str: 'picture' } },
|
||||
{
|
||||
commonElem: {
|
||||
serviceType: 48,
|
||||
businessType: 20,
|
||||
pbElem: protobuf_encode<MsgInfo>(sourceInfo),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const sentLongMessages: SendLongMsgReq[] = [];
|
||||
const bridge = mockBridge({
|
||||
resolveUserUid: vi.fn(async () => 'self-uid'),
|
||||
sendRawPacket: vi.fn(async (command: string, bytes: Uint8Array) => {
|
||||
if (command.endsWith('SsoRecvLongMsg')) {
|
||||
return packetResult(
|
||||
protobuf_encode<RecvLongMsgResp>({
|
||||
result: {
|
||||
payload: gzipSync(
|
||||
protobuf_encode<LongMsgResult>({
|
||||
action: [
|
||||
{
|
||||
actionCommand: 'MultiMsg',
|
||||
actionData: { msgBody: [sourceBody] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
expect(command).toMatch(/SsoSendLongMsg$/);
|
||||
sentLongMessages.push(protobuf_decode<SendLongMsgReq>(bytes));
|
||||
return packetResult(
|
||||
protobuf_encode<SendLongMsgResp>({ result: { resId: `new-${id}` } }),
|
||||
);
|
||||
}),
|
||||
});
|
||||
const forward = new ForwardApi(bridge as unknown as BridgeContext);
|
||||
const sendPrivate = vi.fn(async (_userId: number, elements: MessageElement[]) => {
|
||||
const [card] = await buildSendElems(elements);
|
||||
expect(card?.richMsg?.serviceId).toBe(35);
|
||||
const xml = inflateSync(card!.richMsg!.template1!.subarray(1)).toString();
|
||||
expect(xml).toContain(`m_resid="new-${id}"`);
|
||||
expect(xml).toContain('alice: picture [图片]');
|
||||
return { messageId: 1, sequence: 2, clientSequence: 3, random: 4, timestamp: 1700000001 };
|
||||
});
|
||||
const ctx = {
|
||||
selfId: 10001,
|
||||
bridge: { identity: { nickname: 'SnowLuma' }, apis: { forward, message: { sendPrivate } } },
|
||||
messageStore: {
|
||||
findEvent: () => ({ message: [{ type: 'forward', data: { id } }] }),
|
||||
storeEvent: vi.fn(),
|
||||
},
|
||||
converterCtx: {
|
||||
selfId: 10001,
|
||||
imageUrlResolver: null,
|
||||
mediaUrlResolver: null,
|
||||
messageIdResolver: null,
|
||||
mediaSegmentSink: null,
|
||||
},
|
||||
cacheMessageMeta: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
} as unknown as OneBotInstanceContext;
|
||||
return { api: buildApiContext(ctx), forward, sendPrivate, sentLongMessages, bridge };
|
||||
}
|
||||
|
||||
describe('single merged-forward media (#437)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(runNtv2Upload).mockImplementation(async (params) => ({
|
||||
msgInfo: {
|
||||
msgInfoBody: [
|
||||
{
|
||||
index: {
|
||||
fileUuid: 'target-private-image',
|
||||
info: params.uploadInfo[0]!.fileInfo,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it('reuploads a decoded group image for self-chat without downloading its unsigned source URL', async () => {
|
||||
const { api, forward, sentLongMessages, bridge } = setup('image-to-self');
|
||||
await expect(api.forwardSingleMsg(77, { userId: 10001 })).resolves.toHaveProperty(
|
||||
'messageId',
|
||||
);
|
||||
|
||||
expect(loadBinarySource).not.toHaveBeenCalled();
|
||||
expect(bridge.resolveUserUid).toHaveBeenCalledWith(10001);
|
||||
expect(runNtv2Upload).toHaveBeenCalledOnce();
|
||||
expect(vi.mocked(runNtv2Upload).mock.calls[0]![0]).toMatchObject({
|
||||
isGroup: false,
|
||||
targetIdOrUid: 'self-uid',
|
||||
oidbCmd: 0x11c5,
|
||||
uploadInfo: [
|
||||
{
|
||||
fileInfo: {
|
||||
fileHash: md5Hex,
|
||||
fileSha1: sha1Hex,
|
||||
width: 32,
|
||||
height: 24,
|
||||
fileSize: 1234,
|
||||
},
|
||||
},
|
||||
],
|
||||
uploads: [{ bytes: new Uint8Array(0) }],
|
||||
});
|
||||
expect(sentLongMessages).toHaveLength(1);
|
||||
expect(sentLongMessages[0]!.info?.type).toBe(1);
|
||||
const body = protobuf_decode<LongMsgResult>(
|
||||
gunzipSync(sentLongMessages[0]!.info!.payload!),
|
||||
);
|
||||
const elems = body.action![0]!.actionData!.msgBody![0]!.body!.richText!.elems!;
|
||||
expect(elems.find((e) => e.commonElem)?.commonElem?.businessType).toBe(10);
|
||||
const media = protobuf_decode<MsgInfo>(
|
||||
elems.find((e) => e.commonElem)!.commonElem!.pbElem!,
|
||||
);
|
||||
expect(media.msgInfoBody?.[0]?.index?.fileUuid).toBe('target-private-image');
|
||||
expect(elems.find((e) => e.notOnlineImage)?.notOnlineImage?.picMd5).toEqual(
|
||||
Buffer.from(md5Hex, 'hex'),
|
||||
);
|
||||
expect(
|
||||
(await forward.fetch('image-to-self'))[0]!.elements[1]!.noByteFallback,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not send a card or upload a long message when target image upload fails', async () => {
|
||||
vi.mocked(runNtv2Upload).mockRejectedValueOnce(
|
||||
new Error('image fast-upload not available (server requires bytes)'),
|
||||
);
|
||||
const { api, sendPrivate, sentLongMessages } = setup('image-upload-failure');
|
||||
await expect(api.forwardSingleMsg(77, { userId: 10001 })).rejects.toThrow(
|
||||
'image fast-upload not available',
|
||||
);
|
||||
expect(sendPrivate).not.toHaveBeenCalled();
|
||||
expect(sentLongMessages).toHaveLength(0);
|
||||
expect(loadBinarySource).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,385 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
vi.mock('@snowluma/protocol/bridge-oidb', () => ({
|
||||
makeOidbEnvelope: vi.fn((_oidbCmd, _subCmd, body) => ({ body })),
|
||||
encodeOidbEnv: vi.fn(() => new Uint8Array()),
|
||||
decodeOidbEnv: vi.fn(() => ({ body: {} })),
|
||||
}));
|
||||
|
||||
// element-builder reaches into protoEncode with element-specific schemas
|
||||
// that we don't want to construct manually in tests; stub it to return
|
||||
// a benign placeholder.
|
||||
vi.mock('@snowluma/protocol/element-builder', () => ({
|
||||
buildSendElems: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
import { gunzipSync, gzipSync } from 'zlib';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type {
|
||||
SendLongMsgReq,
|
||||
SendLongMsgResp,
|
||||
LongMsgResult,
|
||||
RecvLongMsgResp,
|
||||
} from '@snowluma/proto-defs/longmsg';
|
||||
import type { PushMsgBody } from '@snowluma/proto-defs/message';
|
||||
import { ForwardApi } from '../../src/bridge/apis/forward';
|
||||
import { mockBridge } from './_helpers';
|
||||
|
||||
/** Decode the SsoSendLongMsg packet body back into the per-node contentHead
|
||||
* timestamps of the MultiMsg action — how #209's custom time is verified on
|
||||
* the wire. */
|
||||
function sentNodeTimestamps(body: Uint8Array): number[] {
|
||||
const req = protobuf_decode<SendLongMsgReq>(body);
|
||||
const payload = req.info?.payload;
|
||||
|
||||
if (!payload) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const longMsg = protobuf_decode<LongMsgResult>(gunzipSync(Buffer.from(payload)));
|
||||
const main = longMsg.action?.find((a) => a?.actionCommand === 'MultiMsg');
|
||||
const bodies = Array.isArray(main?.actionData?.msgBody) ? main!.actionData!.msgBody : [];
|
||||
return bodies.map((b) => Number(b.contentHead?.timestamp ?? 0));
|
||||
}
|
||||
|
||||
function uploadResponseWithResId(resId: string) {
|
||||
const encoded = protobuf_encode<SendLongMsgResp>({ result: { resId } });
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(encoded),
|
||||
};
|
||||
}
|
||||
|
||||
/** Build an SsoRecvLongMsg response wrapping the given forward nodes. */
|
||||
function recvLongMsgResp(bodies: PushMsgBody[]) {
|
||||
const longMsg: LongMsgResult = {
|
||||
action: [{ actionCommand: 'MultiMsg', actionData: { msgBody: bodies } }],
|
||||
};
|
||||
const gz = gzipSync(Buffer.from(protobuf_encode<LongMsgResult>(longMsg)));
|
||||
return recvCompressedLongMsgResp(gz);
|
||||
}
|
||||
|
||||
function recvCompressedLongMsgResp(payload: Uint8Array) {
|
||||
const resp = protobuf_encode<RecvLongMsgResp>({ result: { payload } });
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(resp),
|
||||
};
|
||||
}
|
||||
|
||||
describe('actions/forward', () => {
|
||||
it('uploadForwardNodes rejects empty arrays', async () => {
|
||||
const bridge = mockBridge();
|
||||
await expect(new ForwardApi(bridge as any).upload([])).rejects.toThrow(/required/);
|
||||
});
|
||||
|
||||
it('uploadForwardNodes dispatches to SsoSendLongMsg with a non-empty body and returns the res_id', async () => {
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async () => uploadResponseWithResId('res-001')) as any,
|
||||
});
|
||||
|
||||
const resId = await new ForwardApi(bridge as any).upload([
|
||||
{ userUin: 10001, nickname: 'alice', elements: [] },
|
||||
]);
|
||||
|
||||
expect(resId).toBe('res-001');
|
||||
expect(bridge.sendRawPacket).toHaveBeenCalledOnce();
|
||||
const [serviceCmd, body] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
expect(serviceCmd).toBe('trpc.group.long_msg_interface.MsgService.SsoSendLongMsg');
|
||||
expect((body as Uint8Array).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('honours a custom per-node time on the wire, and defaults to now when absent (#209)', async () => {
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async () => uploadResponseWithResId('res-time')) as any,
|
||||
});
|
||||
|
||||
const customTime = 1600000000; // 2020-09-13, clearly not "now"
|
||||
await new ForwardApi(bridge as any).upload([
|
||||
{ userUin: 10001, nickname: 'alice', elements: [], time: customTime },
|
||||
{ userUin: 10002, nickname: 'bob', elements: [] }, // no time → fallback
|
||||
]);
|
||||
|
||||
const [, body] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
const [tsAlice, tsBob] = sentNodeTimestamps(body as Uint8Array);
|
||||
|
||||
expect(tsAlice).toBe(customTime);
|
||||
// Bob falls back to the current time (seconds).
|
||||
expect(Math.abs(tsBob - Math.floor(Date.now() / 1000))).toBeLessThan(5);
|
||||
});
|
||||
|
||||
it('uploadForwardNodes throws when sendRawPacket reports failure', async () => {
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async () => ({
|
||||
success: false,
|
||||
gotResponse: false,
|
||||
errorCode: -1,
|
||||
errorMessage: 'pipe broken',
|
||||
responseData: null,
|
||||
})) as any,
|
||||
});
|
||||
await expect(
|
||||
new ForwardApi(bridge as any).upload([{ userUin: 10001, nickname: 'a', elements: [] }]),
|
||||
).rejects.toThrow(/pipe broken/);
|
||||
});
|
||||
|
||||
it('uploadForwardNodes throws when the response is missing res_id', async () => {
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async () => uploadResponseWithResId('')) as any,
|
||||
});
|
||||
await expect(
|
||||
new ForwardApi(bridge as any).upload([{ userUin: 10001, nickname: 'a', elements: [] }]),
|
||||
).rejects.toThrow(/missing res_id/);
|
||||
});
|
||||
|
||||
it('cached fetch after upload keeps a usable image source (#441)', async () => {
|
||||
const sendRawPacket = vi.fn(async () => uploadResponseWithResId('res-img-cache')) as any;
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
const source = 'https://cdn.example/bot-built.png';
|
||||
|
||||
const resId = await new ForwardApi(bridge as any).upload([
|
||||
{
|
||||
userUin: 10001,
|
||||
nickname: 'alice',
|
||||
elements: [{ type: 'image', url: source }],
|
||||
},
|
||||
]);
|
||||
const fetched = await new ForwardApi(bridge as any).fetch(resId);
|
||||
|
||||
expect(fetched).toHaveLength(1);
|
||||
expect(fetched[0]!.elements[0]).toMatchObject({
|
||||
type: 'image',
|
||||
url: source,
|
||||
imageUrl: source,
|
||||
});
|
||||
expect(sendRawPacket).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fetchForwardNodes serves from cache after a successful upload (no second sendRawPacket)', async () => {
|
||||
const sendRawPacket = vi.fn(async () => uploadResponseWithResId('res-cache')) as any;
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
|
||||
const nodes = [
|
||||
{ userUin: 10001, nickname: 'alice', elements: [] },
|
||||
{ userUin: 10002, nickname: 'bob', elements: [] },
|
||||
];
|
||||
|
||||
const resId = await new ForwardApi(bridge as any).upload(nodes);
|
||||
expect(resId).toBe('res-cache');
|
||||
|
||||
const fetched = await new ForwardApi(bridge as any).fetch('res-cache');
|
||||
// Same nicknames + uins; elements have been deep-copied so the shape
|
||||
// is preserved but the array reference is different.
|
||||
expect(fetched).toHaveLength(2);
|
||||
expect(fetched.map((n: { nickname: string }) => n.nickname)).toEqual(['alice', 'bob']);
|
||||
expect(sendRawPacket).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('[#201] fills a group forward node nickname from grp.groupName when memberName is empty', async () => {
|
||||
// Real merged-forward nodes leave grp.memberName (field 2) null and carry the
|
||||
// sender display name in grp.memberCard (field 4). Verified on-target: fromUin
|
||||
// 1787882683 ↔ grp.memberCard "墨梓柒". Before the fix the node nickname came
|
||||
// back "" and enrichSenders 0x899-AUTHORITY_FAIL'd on the placeholder group.
|
||||
const node: PushMsgBody = {
|
||||
responseHead: {
|
||||
fromUin: 1787882683,
|
||||
fromUid: 'u_x',
|
||||
grp: { groupUin: 284840486, memberCard: Buffer.from('墨梓柒') },
|
||||
},
|
||||
contentHead: { msgType: 82, sequence: 1, timestamp: 100 }, // 82 = PkgType.GroupMessage
|
||||
body: { richText: { elems: [{ text: { str: 'hi' } }] } },
|
||||
};
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async () => recvLongMsgResp([node])) as any,
|
||||
});
|
||||
const nodes = await new ForwardApi(bridge as any).fetch('res-201');
|
||||
expect(nodes).toHaveLength(1);
|
||||
expect(nodes[0]!.userUin).toBe(1787882683);
|
||||
expect(nodes[0]!.nickname).toBe('墨梓柒'); // was '' before the fix
|
||||
expect(nodes[0]!.groupId).toBe(284840486);
|
||||
// The nickname is present, so no group-member-list enrichment fetch fires.
|
||||
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fetchForwardNodes throws on transport failure when nothing is cached', async () => {
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async () => ({
|
||||
success: false,
|
||||
gotResponse: false,
|
||||
errorCode: -1,
|
||||
errorMessage: 'down',
|
||||
responseData: null,
|
||||
})) as any,
|
||||
});
|
||||
await expect(new ForwardApi(bridge as any).fetch('cold-cache-miss')).rejects.toThrow(
|
||||
/download forward message failed|down/,
|
||||
);
|
||||
});
|
||||
|
||||
it('bounds long-message gunzip output and reports decompression context', async () => {
|
||||
// This is one byte beyond the production 32 MiB ceiling but compresses to
|
||||
// a small response, modelling an otherwise cheap gzip bomb from QQ.
|
||||
const expandedBytes = 32 * 1024 * 1024 + 1;
|
||||
const payload = gzipSync(Buffer.alloc(expandedBytes, 0x61));
|
||||
const resId = 'oversized-compressed-forward';
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async () => recvCompressedLongMsgResp(payload)) as any,
|
||||
});
|
||||
|
||||
await expect(new ForwardApi(bridge as any).fetch(resId)).rejects.toThrow(
|
||||
/download forward message decompression failed .*res_id=oversized-compressed-forward.*max_output_bytes=33554432/,
|
||||
);
|
||||
});
|
||||
|
||||
it('uploadForwardNodes recursively uploads nested innerForward chains (NapCat piggyback model)', async () => {
|
||||
// Regression: nested forward needs the inner chain to be uploaded
|
||||
// first (so we have its res_id for the outer ARK preview), AND
|
||||
// the inner level's msgBody to be carried up to the outermost
|
||||
// long-msg upload as an extra `actionCommand` slot keyed on a
|
||||
// uuid. Matches `dev/NapCatQQ/.../SendMsg.uploadForwardedNodesPacket`
|
||||
// — the receiver gets the whole tree from a single fetch instead
|
||||
// of resolving each layer's res_id separately.
|
||||
const responses = ['inner-res', 'outer-res'];
|
||||
const sendRawPacket = vi.fn(async () => uploadResponseWithResId(responses.shift()!)) as any;
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
|
||||
const resId = await new ForwardApi(bridge as any).upload([
|
||||
{
|
||||
userUin: 111,
|
||||
nickname: 'outer',
|
||||
elements: [],
|
||||
innerForward: [
|
||||
{ userUin: 222, nickname: 'inner', elements: [{ type: 'text', text: 'hi' }] },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
// Two server roundtrips: one for the inner chain, then one for
|
||||
// the outer (which piggybacks the inner msgBody onto its actions
|
||||
// array). The outer res_id is what the caller gets back.
|
||||
expect(resId).toBe('outer-res');
|
||||
expect(sendRawPacket).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('uploadForwardNodes leaves flat (non-nested) sends at a single roundtrip', async () => {
|
||||
// Backwards-compat: the common case (no inner forward) must still
|
||||
// be one SsoSendLongMsg call. Without this we'd double upload
|
||||
// every regular forward send.
|
||||
const sendRawPacket = vi.fn(async () => uploadResponseWithResId('flat-res')) as any;
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
|
||||
const resId = await new ForwardApi(bridge as any).upload([
|
||||
{ userUin: 10001, nickname: 'a', elements: [{ type: 'text', text: 'hello' }] },
|
||||
{ userUin: 10002, nickname: 'b', elements: [{ type: 'text', text: 'world' }] },
|
||||
]);
|
||||
|
||||
expect(resId).toBe('flat-res');
|
||||
expect(sendRawPacket).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('actions/forward — sender name enrichment (#174)', () => {
|
||||
function apiWith(contacts: { fetchGroupMemberList: any; fetchUserProfile: any }) {
|
||||
return new ForwardApi({ apis: { contacts } } as any);
|
||||
}
|
||||
|
||||
it('fills empty / "QQ用户" names via the group member list (L3), card preferred', async () => {
|
||||
const fetchGroupMemberList = vi.fn(async () => [
|
||||
{ uin: 10001, nickname: 'AliceNick', card: 'AliceCard' },
|
||||
{ uin: 10002, nickname: 'BobNick', card: '' },
|
||||
]);
|
||||
const fetchUserProfile = vi.fn(async (uin: number) => ({ uin, nickname: 'Stranger' }));
|
||||
const api = apiWith({ fetchGroupMemberList, fetchUserProfile });
|
||||
|
||||
const nodes: any[] = [
|
||||
{ userUin: 10001, nickname: '', groupId: 700, messageType: 'group', elements: [] },
|
||||
{
|
||||
userUin: 10002,
|
||||
nickname: 'QQ用户',
|
||||
groupId: 700,
|
||||
messageType: 'group',
|
||||
elements: [],
|
||||
},
|
||||
{ userUin: 10003, nickname: '', groupId: 700, messageType: 'group', elements: [] }, // not in list
|
||||
{ userUin: 10009, nickname: '', messageType: 'private', elements: [] }, // private
|
||||
{
|
||||
userUin: 10005,
|
||||
nickname: 'KeepMe',
|
||||
groupId: 700,
|
||||
messageType: 'group',
|
||||
elements: [],
|
||||
},
|
||||
];
|
||||
const changed = await (api as any).enrichSenders(nodes);
|
||||
|
||||
expect(changed).toBe(true);
|
||||
expect(nodes[0].nickname).toBe('AliceCard'); // card preferred over nick
|
||||
expect(nodes[0].senderCard).toBe('AliceCard');
|
||||
expect(nodes[1].nickname).toBe('BobNick'); // card empty → nickname
|
||||
expect(nodes[2].nickname).toBe('Stranger'); // not in member list → L4 profile
|
||||
expect(nodes[3].nickname).toBe('Stranger'); // private node → L4 profile
|
||||
expect(nodes[4].nickname).toBe('KeepMe'); // already named → untouched
|
||||
expect(fetchGroupMemberList).toHaveBeenCalledTimes(1); // one fetch covers the whole group
|
||||
});
|
||||
|
||||
it('is a no-op (no fetch) when every node already has a name', async () => {
|
||||
const fetchGroupMemberList = vi.fn();
|
||||
const fetchUserProfile = vi.fn();
|
||||
const api = apiWith({ fetchGroupMemberList, fetchUserProfile });
|
||||
const nodes: any[] = [
|
||||
{ userUin: 1, nickname: 'has-name', groupId: 7, messageType: 'group', elements: [] },
|
||||
];
|
||||
expect(await (api as any).enrichSenders(nodes)).toBe(false);
|
||||
expect(fetchGroupMemberList).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the placeholder and never throws when resolution fails', async () => {
|
||||
const api = apiWith({
|
||||
fetchGroupMemberList: vi.fn(async () => {
|
||||
throw new Error('net down');
|
||||
}),
|
||||
fetchUserProfile: vi.fn(async () => {
|
||||
throw new Error('net down');
|
||||
}),
|
||||
});
|
||||
const nodes: any[] = [
|
||||
{
|
||||
userUin: 10001,
|
||||
nickname: 'QQ用户',
|
||||
groupId: 700,
|
||||
messageType: 'group',
|
||||
elements: [],
|
||||
},
|
||||
];
|
||||
await expect((api as any).enrichSenders(nodes)).resolves.toBe(false);
|
||||
expect(nodes[0].nickname).toBe('QQ用户');
|
||||
});
|
||||
|
||||
it('does NOT fan a member-list failure out to per-uin profile lookups (rate-limit guard)', async () => {
|
||||
const fetchUserProfile = vi.fn(async (uin: number) => ({ uin, nickname: 'X' }));
|
||||
const api = apiWith({
|
||||
fetchGroupMemberList: vi.fn(async () => {
|
||||
throw new Error('net');
|
||||
}),
|
||||
fetchUserProfile,
|
||||
});
|
||||
// 5 distinct group senders whose member list errors → must keep placeholder,
|
||||
// NOT trigger 5 profile calls.
|
||||
const nodes: any[] = Array.from({ length: 5 }, (_, i) => ({
|
||||
userUin: 20000 + i,
|
||||
nickname: '',
|
||||
groupId: 700,
|
||||
messageType: 'group',
|
||||
elements: [],
|
||||
}));
|
||||
expect(await (api as any).enrichSenders(nodes)).toBe(false);
|
||||
expect(fetchUserProfile).not.toHaveBeenCalled();
|
||||
expect(nodes.every((n) => n.nickname === '')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type {
|
||||
OidbBase,
|
||||
OidbFriend,
|
||||
OidbSvcTrpcTcp0xFD4_1Response,
|
||||
} from '@snowluma/proto-defs/oidb';
|
||||
import type {
|
||||
OidbUserInfoRequest,
|
||||
OidbUserInfoResponse,
|
||||
} from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import { IdentityService } from '@snowluma/protocol/identity-service';
|
||||
import { Bridge } from '../../src/bridge/bridge';
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
let reject!: (reason: Error) => void;
|
||||
const promise = new Promise<void>((yes, no) => {
|
||||
resolve = yes;
|
||||
reject = no;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
const bridges: Bridge[] = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(bridges.splice(0).map((bridge) => bridge.dispose()));
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function friend(uin: number, name = 'Roster'): OidbFriend {
|
||||
return {
|
||||
uin,
|
||||
uid: `u_${uin}`,
|
||||
customGroup: 7,
|
||||
additional: [
|
||||
{
|
||||
type: 1,
|
||||
layer1: {
|
||||
properties: [
|
||||
{ code: 20002, value: Buffer.from(name) },
|
||||
{ code: 102, value: Buffer.from('') },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function setup(roster: () => OidbFriend[], load: (uin: number) => Promise<void> = async () => {}) {
|
||||
const bridge = new Bridge(IdentityService.memory('90001'));
|
||||
bridges.push(bridge);
|
||||
const profileCalls: number[] = [];
|
||||
let active = 0;
|
||||
let peak = 0;
|
||||
const send = vi.spyOn(bridge, 'sendRawPacket').mockImplementation(async (cmd, payload) => {
|
||||
let responseData: Uint8Array;
|
||||
|
||||
if (cmd === 'OidbSvcTrpcTcp.0xfd4_1') {
|
||||
const friends = roster();
|
||||
responseData = protobuf_encode<OidbBase<OidbSvcTrpcTcp0xFD4_1Response>>({
|
||||
body: {
|
||||
friends,
|
||||
categories: [
|
||||
{ categoryId: 7, categoryName: 'Friends', memberCount: friends.length },
|
||||
],
|
||||
},
|
||||
});
|
||||
} else if (cmd === 'OidbSvcTrpcTcp.0xfe1_2') {
|
||||
const uin = protobuf_decode<OidbBase<OidbUserInfoRequest>>(payload).body!.uin!;
|
||||
profileCalls.push(uin);
|
||||
peak = Math.max(peak, ++active);
|
||||
|
||||
try {
|
||||
await load(uin);
|
||||
} finally {
|
||||
active--;
|
||||
}
|
||||
|
||||
responseData = protobuf_encode<OidbBase<OidbUserInfoResponse>>({
|
||||
body: {
|
||||
body: {
|
||||
uin,
|
||||
uid: `u_${uin}`,
|
||||
properties: {
|
||||
bytesProperties: [
|
||||
{ code: 102, value: Buffer.from('Profile sign') },
|
||||
{ code: 20020, value: Buffer.from('City') },
|
||||
],
|
||||
numberProperties: [
|
||||
{ number1: 20037, number2: 0 },
|
||||
{ number1: 41756, number2: 0 },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
throw new Error(`unexpected request ${cmd}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(responseData),
|
||||
};
|
||||
});
|
||||
return {
|
||||
bridge,
|
||||
api: bridge.apis.contacts,
|
||||
send,
|
||||
profileCalls,
|
||||
peak: () => peak,
|
||||
active: () => active,
|
||||
};
|
||||
}
|
||||
|
||||
describe('explicit friend profile enrichment', () => {
|
||||
it('releases profile cache timers when its Bridge is disposed', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { api, bridge, profileCalls } = setup(() => [friend(10001)]);
|
||||
await api.fetchFriendList({ withProfile: true });
|
||||
expect(profileCalls).toEqual([10001]);
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
|
||||
await bridge.dispose();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('does not start profile cache loads when a roster response arrives after disposal', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { api, bridge, send, profileCalls } = setup(() => [friend(10001)]);
|
||||
const response = await send.getMockImplementation()!(
|
||||
'OidbSvcTrpcTcp.0xfd4_1',
|
||||
new Uint8Array(),
|
||||
);
|
||||
let resolve!: (value: typeof response) => void;
|
||||
send.mockReturnValueOnce(
|
||||
new Promise((yes) => {
|
||||
resolve = yes;
|
||||
}),
|
||||
);
|
||||
const pending = api.fetchFriendList({ withProfile: true });
|
||||
const checked = expect(pending).rejects.toThrow('friend profile enrichment failed');
|
||||
await bridge.dispose();
|
||||
|
||||
resolve(response);
|
||||
await checked;
|
||||
expect(profileCalls).toEqual([]);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('does not fan out on a cold 160-friend ordinary or categorized list', async () => {
|
||||
const { api, profileCalls, send } = setup(() =>
|
||||
Array.from({ length: 160 }, (_, i) => friend(10000 + i)),
|
||||
);
|
||||
expect(await api.fetchFriendList()).toHaveLength(160);
|
||||
expect((await api.fetchFriendCategories())[0]?.friends).toHaveLength(160);
|
||||
expect(profileCalls).toEqual([]);
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('shares the cache across list forms, preserves roster values and isolates accounts', async () => {
|
||||
let now = Date.now();
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
const one = setup(() => [friend(10001)]);
|
||||
const first = await one.api.fetchFriendList({ withProfile: true });
|
||||
expect(first[0]).toMatchObject({
|
||||
nickname: 'Roster',
|
||||
profile: { sign: '', age: 0, vipFlag: false, city: 'City' },
|
||||
});
|
||||
first[0]!.profile!.city = 'caller mutation';
|
||||
const grouped = await one.api.fetchFriendCategories({ withProfile: true });
|
||||
expect(grouped[0]?.friends[0]?.profile?.city).toBe('City');
|
||||
expect(one.profileCalls).toEqual([10001]);
|
||||
const two = setup(() => [friend(10001)]);
|
||||
await two.api.fetchFriendList({ withProfile: true });
|
||||
expect(two.profileCalls).toEqual([10001]);
|
||||
now += 300_001;
|
||||
await one.api.fetchFriendList({ withProfile: true });
|
||||
expect(one.profileCalls).toEqual([10001, 10001]);
|
||||
});
|
||||
|
||||
it('deduplicates overlapping lists and caps total concurrent queries at four', async () => {
|
||||
const gates = new Map<number, ReturnType<typeof deferred>>();
|
||||
let page = 0;
|
||||
const { api, profileCalls, peak } = setup(
|
||||
() => (page++ === 0 ? [1, 2, 3, 4] : [3, 4, 5, 6]).map((id) => friend(10000 + id)),
|
||||
(uin) => {
|
||||
const gate = deferred();
|
||||
gates.set(uin, gate);
|
||||
return gate.promise;
|
||||
},
|
||||
);
|
||||
const first = api.fetchFriendList({ withProfile: true });
|
||||
const second = api.fetchFriendCategories({ withProfile: true });
|
||||
await vi.waitFor(() => expect(profileCalls).toHaveLength(4));
|
||||
|
||||
for (const gate of gates.values()) {
|
||||
gate.resolve();
|
||||
}
|
||||
|
||||
await vi.waitFor(() => expect(profileCalls).toHaveLength(6));
|
||||
|
||||
for (const gate of gates.values()) {
|
||||
gate.resolve();
|
||||
}
|
||||
|
||||
await Promise.all([first, second]);
|
||||
expect(new Set(profileCalls).size).toBe(6);
|
||||
expect(peak()).toBe(4);
|
||||
});
|
||||
|
||||
it('drains a failed batch, surfaces the failure and retries only on a later request', async () => {
|
||||
const gates = new Map<number, ReturnType<typeof deferred>>();
|
||||
let blocking = true;
|
||||
const { api, bridge, profileCalls, active } = setup(
|
||||
() => [1, 2, 3, 4, 5].map((id) => friend(10000 + id)),
|
||||
(uin) => {
|
||||
if (!blocking) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const gate = deferred();
|
||||
gates.set(uin, gate);
|
||||
return gate.promise;
|
||||
},
|
||||
);
|
||||
let finished = false;
|
||||
const result = api.fetchFriendList({ withProfile: true });
|
||||
const checked = expect(result).rejects.toThrow('friend profile enrichment failed');
|
||||
void result.then(
|
||||
() => {
|
||||
finished = true;
|
||||
},
|
||||
() => {
|
||||
finished = true;
|
||||
},
|
||||
);
|
||||
await vi.waitFor(() => expect(profileCalls).toHaveLength(4));
|
||||
gates.get(10001)!.reject(new Error('synthetic timeout'));
|
||||
await Promise.resolve();
|
||||
expect(finished).toBe(false);
|
||||
|
||||
for (const [uin, gate] of gates) {
|
||||
if (uin !== 10001) {
|
||||
gate.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
await checked;
|
||||
expect(active()).toBe(0);
|
||||
expect(profileCalls).toHaveLength(4);
|
||||
expect(bridge.identity.friends).toEqual([]);
|
||||
blocking = false;
|
||||
await api.fetchFriendList({ withProfile: true });
|
||||
expect(profileCalls).toEqual([10001, 10002, 10003, 10004, 10001, 10005]);
|
||||
});
|
||||
|
||||
it('does not let a slow enriched snapshot replace a newer ordinary roster', async () => {
|
||||
const gate = deferred();
|
||||
let page = 0;
|
||||
const { api, bridge, profileCalls } = setup(
|
||||
() => [friend(10001, page++ === 0 ? 'Old' : 'New')],
|
||||
() => gate.promise,
|
||||
);
|
||||
const old = api.fetchFriendList({ withProfile: true });
|
||||
await vi.waitFor(() => expect(profileCalls).toHaveLength(1));
|
||||
await api.fetchFriendList();
|
||||
gate.resolve();
|
||||
expect((await old)[0]?.nickname).toBe('Old');
|
||||
expect(bridge.identity.findFriend(10001)?.nickname).toBe('New');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { subscribeLogs, type LogEntry } from '@snowluma/common/logger';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { pb, pb_repeated, uint_32, uint_64 } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type {
|
||||
OidbDeleteFriend,
|
||||
OidbFriendRequestAction,
|
||||
OidbSetFriendRemarkResponse,
|
||||
} from '@snowluma/proto-defs/oidb-actions/base';
|
||||
import type {
|
||||
OidbDoubtApprovalReq,
|
||||
OidbDoubtDelReq,
|
||||
OidbDoubtGetResp,
|
||||
} from '@snowluma/proto-defs/oidb-actions/doubt-buddy';
|
||||
|
||||
// Post-namespace migration: FriendApi is a thin facade over the
|
||||
// namespaces under @snowluma/protocol/oidb-services/friend. Tests assert
|
||||
// against the bridge mock's sendRawPacket directly — no need for
|
||||
// module-level bridge-oidb mocks anymore.
|
||||
import { DATALINE_UIN_PAD } from '@snowluma/protocol/dataline/device-contacts';
|
||||
import { IdentityService } from '@snowluma/protocol/identity-service';
|
||||
import { FriendApi } from '../../src/bridge/apis/friend';
|
||||
import { mockBridge } from './_helpers';
|
||||
|
||||
describe('apis/friend', () => {
|
||||
it('handleRequest: numeric input is treated as UIN and resolved', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new FriendApi(bridge as any).handleRequest('10001', true);
|
||||
expect(bridge.resolveUserUid).toHaveBeenCalledWith(10001);
|
||||
const [cmd, bytes] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0xb5d_44');
|
||||
const env = protobuf_decode<OidbBase<OidbFriendRequestAction>>(bytes);
|
||||
expect(env.body).toMatchObject({ accept: 3, targetUid: 'resolved-uid' });
|
||||
});
|
||||
|
||||
it('handleRequest: non-numeric flag is forwarded as-is', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new FriendApi(bridge as any).handleRequest('flag-abc', false);
|
||||
expect(bridge.resolveUserUid).not.toHaveBeenCalled();
|
||||
const [, bytes] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbFriendRequestAction>>(bytes);
|
||||
expect(env.body).toMatchObject({ accept: 5, targetUid: 'flag-abc' });
|
||||
});
|
||||
|
||||
it('delete resolves UID, calls 0x126b_0, and triggers a friend-list refresh', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new FriendApi(bridge as any).delete(10001, true);
|
||||
expect(bridge.resolveUserUid).toHaveBeenCalledWith(10001);
|
||||
const [cmd, bytes] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0x126b_0');
|
||||
const env = protobuf_decode<OidbBase<OidbDeleteFriend>>(bytes);
|
||||
expect(env.body?.field1?.block).toBe(true);
|
||||
expect(bridge.apis.contacts.fetchFriendList).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a friend-list refresh failure without misreporting the completed delete', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.apis.contacts.fetchFriendList = vi.fn(async () => {
|
||||
throw new Error('cache miss');
|
||||
});
|
||||
const captured: LogEntry[] = [];
|
||||
const unsubscribe = subscribeLogs((entry) => {
|
||||
if (entry.scope === 'Bridge.Friend') {
|
||||
captured.push(entry);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(new FriendApi(bridge as any).delete(10001)).resolves.toBeUndefined();
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
expect(captured.map(({ level, message }) => ({ level, message }))).toEqual([
|
||||
{
|
||||
level: 'warn',
|
||||
message: 'friend-list refresh failed after deleting user=10001: cache miss',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('setRemark uses the current set command for a non-empty remark', async () => {
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async () => ({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<OidbBase<OidbSetFriendRemarkResponse>>({
|
||||
body: {
|
||||
result: {
|
||||
target: { targetUid: 'resolved-uid', targetUin: 10001n },
|
||||
remark: 'best-friend',
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
})),
|
||||
});
|
||||
await new FriendApi(bridge as any).setRemark(10001, 'best-friend');
|
||||
expect(bridge.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x912e_0');
|
||||
expect(bridge.identity.updateFriendRemark).toHaveBeenCalledWith(
|
||||
'resolved-uid',
|
||||
10001,
|
||||
'best-friend',
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses to delete or rename a my-device contact', async () => {
|
||||
const bridge = mockBridge();
|
||||
const api = new FriendApi(bridge as any);
|
||||
await expect(api.delete(DATALINE_UIN_PAD)).rejects.toThrow('cannot be deleted');
|
||||
await expect(api.setRemark(DATALINE_UIN_PAD, 'pad')).rejects.toThrow(
|
||||
'does not support remarks',
|
||||
);
|
||||
expect(bridge.sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('setRemark uses the dedicated clear command for an empty remark', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new FriendApi(bridge as any).setRemark(10001, '');
|
||||
expect(bridge.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x912f_0');
|
||||
});
|
||||
|
||||
it('getDoubtRequests fills a missing uid via resolveUserUid', async () => {
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async () => ({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: encodeNumericDoubtList([
|
||||
{ nick: 'Alice', uin: 12345n, reqTime: 1700000000n },
|
||||
]),
|
||||
})),
|
||||
});
|
||||
const list = await new FriendApi(bridge as any).getDoubtRequests(10);
|
||||
expect(bridge.resolveUserUid).toHaveBeenCalledWith(12345);
|
||||
expect(list).toEqual([
|
||||
{
|
||||
uid: 'resolved-uid',
|
||||
user_id: 12345,
|
||||
nick: 'Alice',
|
||||
source: '',
|
||||
reason: '',
|
||||
msg: '',
|
||||
group_code: '',
|
||||
reqTime: 1700000000,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('getDoubtRequests keeps a wire uid and does not resolve', async () => {
|
||||
const bridge = mockBridge({
|
||||
identity: { findUinByUid: vi.fn(() => 12345) } as any,
|
||||
sendRawPacket: vi.fn(async () => ({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: encodeDoubtList([
|
||||
{ uid: 'u_alice', nick: 'Alice', reqTime: 1700000000n },
|
||||
]),
|
||||
})),
|
||||
});
|
||||
const list = await new FriendApi(bridge as any).getDoubtRequests(10);
|
||||
expect(bridge.resolveUserUid).not.toHaveBeenCalled();
|
||||
expect(list[0]?.uid).toBe('u_alice');
|
||||
expect(list[0]?.user_id).toBe(12345);
|
||||
});
|
||||
|
||||
it('getDoubtRequests keeps an empty uid when resolveUserUid fails', async () => {
|
||||
const bridge = mockBridge({
|
||||
resolveUserUid: vi.fn(async () => {
|
||||
throw new Error('no mapping');
|
||||
}),
|
||||
sendRawPacket: vi.fn(async () => ({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: encodeNumericDoubtList([{ nick: 'Alice', uin: 12345n }]),
|
||||
})),
|
||||
});
|
||||
const captured: LogEntry[] = [];
|
||||
const unsubscribe = subscribeLogs((entry) => {
|
||||
if (entry.scope === 'Bridge.Friend') {
|
||||
captured.push(entry);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const list = await new FriendApi(bridge as any).getDoubtRequests(10);
|
||||
expect(list[0]?.uid).toBe('');
|
||||
expect(list[0]?.user_id).toBe(12345);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
expect(captured.map(({ level, message }) => ({ level, message }))).toEqual([
|
||||
{
|
||||
level: 'warn',
|
||||
message: 'doubt-request uid resolve failed: user=12345 err=no mapping',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('approveDoubtRequest resolves a digit-only flag before sending', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new FriendApi(bridge as any).approveDoubtRequest('12345');
|
||||
expect(bridge.resolveUserUid).toHaveBeenCalledWith(12345);
|
||||
const [cmd, bytes] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0xd72_0');
|
||||
const env = protobuf_decode<OidbBase<OidbDoubtApprovalReq>>(bytes);
|
||||
expect(env.body).toMatchObject({
|
||||
selfUid: 'self-uid',
|
||||
targetUid: 'resolved-uid',
|
||||
field3: 0,
|
||||
field4: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('approveDoubtRequest forwards a uid flag without resolving', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new FriendApi(bridge as any).approveDoubtRequest('u_abc');
|
||||
expect(bridge.resolveUserUid).not.toHaveBeenCalled();
|
||||
const [, bytes] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbDoubtApprovalReq>>(bytes);
|
||||
expect(env.body).toMatchObject({ selfUid: 'self-uid', targetUid: 'u_abc' });
|
||||
});
|
||||
|
||||
it('approves a filtered stranger through Identity without requiring a shared roster', async () => {
|
||||
const identity = new IdentityService('10001', null);
|
||||
await identity.initialization;
|
||||
const bridge = mockBridge();
|
||||
const fetchProfile = vi.fn(async (uin: number) => ({
|
||||
uin,
|
||||
uid: uin === 10001 ? 'u_self' : 'u_stranger',
|
||||
}));
|
||||
identity.setFetcher({ fetchProfile: fetchProfile as any });
|
||||
bridge.identity = identity as any;
|
||||
bridge.resolveUserUid = vi.fn((uin: number) => identity.resolveUid(uin));
|
||||
|
||||
try {
|
||||
await new FriendApi(bridge as any).approveDoubtRequest('12345');
|
||||
expect(fetchProfile.mock.calls.map(([uin]) => uin)).toEqual([12345, 10001]);
|
||||
expect(bridge.sendRawPacket).toHaveBeenCalledOnce();
|
||||
const [, bytes] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbDoubtApprovalReq>>(bytes);
|
||||
expect(env.body).toMatchObject({ selfUid: 'u_self', targetUid: 'u_stranger' });
|
||||
} finally {
|
||||
await identity.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not send an approval when the applicant cannot be resolved', async () => {
|
||||
const bridge = mockBridge({
|
||||
resolveUserUid: vi.fn(async () => {
|
||||
throw new Error('applicant lookup failed');
|
||||
}),
|
||||
});
|
||||
await expect(new FriendApi(bridge as any).approveDoubtRequest('12345')).rejects.toThrow(
|
||||
'applicant lookup failed',
|
||||
);
|
||||
expect(bridge.sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejectDoubtRequest resolves a digit-only flag before sending', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new FriendApi(bridge as any).rejectDoubtRequest('12345');
|
||||
expect(bridge.resolveUserUid).toHaveBeenCalledWith(12345);
|
||||
const [, bytes] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
const env = protobuf_decode<OidbBase<OidbDoubtDelReq>>(bytes);
|
||||
expect(env.body).toMatchObject({ field1: 3, inner: { uid: 'resolved-uid' } });
|
||||
});
|
||||
});
|
||||
|
||||
function encodeDoubtList(list: NonNullable<NonNullable<OidbDoubtGetResp['body']>['list']>): Buffer {
|
||||
return Buffer.from(
|
||||
protobuf_encode<OidbBase<OidbDoubtGetResp>>({
|
||||
command: 0xd69,
|
||||
subCommand: 0,
|
||||
body: { status: 1, body: { list } },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
interface NumericDoubtItem {
|
||||
uin?: pb<1, uint_64>;
|
||||
nick?: pb<2, string>;
|
||||
reqTime?: pb<8, uint_64>;
|
||||
}
|
||||
interface NumericDoubtBody {
|
||||
list?: pb_repeated<1, NumericDoubtItem>;
|
||||
}
|
||||
interface NumericDoubtResponse {
|
||||
status?: pb<1, uint_32>;
|
||||
body?: pb<2, NumericDoubtBody>;
|
||||
}
|
||||
|
||||
function encodeNumericDoubtList(list: NumericDoubtItem[]): Buffer {
|
||||
return Buffer.from(
|
||||
protobuf_encode<OidbBase<NumericDoubtResponse>>({
|
||||
command: 0xd69,
|
||||
subCommand: 0,
|
||||
body: { status: 1, body: { list } },
|
||||
}),
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,104 @@
|
||||
import type { bool, bytes, int_32, pb, pb_repeated, uint_32, uint_64 } from '@snowluma/proton';
|
||||
|
||||
interface GroupAlbumListReqDataWireOracle {
|
||||
groupId?: pb<1, string>;
|
||||
cursor?: pb<2, string>;
|
||||
}
|
||||
|
||||
interface GroupAlbumListExtWireOracle {
|
||||
key?: pb<1, string>;
|
||||
value?: pb<2, string>;
|
||||
}
|
||||
|
||||
export interface GroupAlbumListRequestWireOracle {
|
||||
seq?: pb<1, int_32>;
|
||||
field2?: pb<2, bytes>;
|
||||
field3?: pb<3, bytes>;
|
||||
data?: pb<4, GroupAlbumListReqDataWireOracle>;
|
||||
traceId?: pb<5, string>;
|
||||
extMap?: pb_repeated<10, GroupAlbumListExtWireOracle>;
|
||||
}
|
||||
|
||||
interface GroupAlbumUrlInfoWireOracle {
|
||||
url?: pb<1, string>;
|
||||
width?: pb<2, uint_32>;
|
||||
height?: pb<3, uint_32>;
|
||||
}
|
||||
|
||||
interface GroupAlbumPhotoUrlWireOracle {
|
||||
spec?: pb<1, uint_32>;
|
||||
url?: pb<2, GroupAlbumUrlInfoWireOracle>;
|
||||
}
|
||||
|
||||
interface GroupAlbumImageWireOracle {
|
||||
name?: pb<1, string>;
|
||||
sloc?: pb<2, string>;
|
||||
lloc?: pb<3, string>;
|
||||
photoUrls?: pb_repeated<4, GroupAlbumPhotoUrlWireOracle>;
|
||||
defaultUrl?: pb<5, GroupAlbumUrlInfoWireOracle>;
|
||||
isGif?: pb<6, bool>;
|
||||
hasRaw?: pb<7, bool>;
|
||||
}
|
||||
|
||||
interface GroupAlbumCoverWireOracle {
|
||||
type?: pb<1, uint_32>;
|
||||
image?: pb<2, GroupAlbumImageWireOracle>;
|
||||
}
|
||||
|
||||
interface GroupAlbumListItemWireOracle {
|
||||
albumId?: pb<1, string>;
|
||||
owner?: pb<2, string>;
|
||||
name?: pb<3, string>;
|
||||
description?: pb<4, string>;
|
||||
createTime?: pb<5, uint_64>;
|
||||
lastUploadTime?: pb<7, uint_64>;
|
||||
uploadNumber?: pb<8, uint_64>;
|
||||
cover?: pb<9, GroupAlbumCoverWireOracle>;
|
||||
}
|
||||
|
||||
interface GroupAlbumListResponseDataWireOracle {
|
||||
albumList?: pb_repeated<1, GroupAlbumListItemWireOracle>;
|
||||
}
|
||||
|
||||
export interface GroupAlbumListResponseWireOracle {
|
||||
result?: pb<2, int_32>;
|
||||
data?: pb<4, GroupAlbumListResponseDataWireOracle>;
|
||||
}
|
||||
|
||||
interface GroupAlbumMediaListReqInfoWireOracle {
|
||||
reserved?: pb<4, string>;
|
||||
pageInfo?: pb<5, string>;
|
||||
}
|
||||
|
||||
export interface GroupAlbumMediaListRequestWireOracle {
|
||||
reqInfo?: pb<4, GroupAlbumMediaListReqInfoWireOracle>;
|
||||
}
|
||||
|
||||
interface GroupAlbumVideoWireOracle {
|
||||
id?: pb<1, string>;
|
||||
url?: pb<2, string>;
|
||||
cover?: pb<3, GroupAlbumImageWireOracle>;
|
||||
width?: pb<4, uint_32>;
|
||||
height?: pb<5, uint_32>;
|
||||
videoTime?: pb<6, uint_64>;
|
||||
videoUrl?: pb_repeated<7, GroupAlbumPhotoUrlWireOracle>;
|
||||
}
|
||||
|
||||
interface GroupAlbumMediaWireOracle {
|
||||
type?: pb<1, uint_32>;
|
||||
image?: pb<2, GroupAlbumImageWireOracle>;
|
||||
video?: pb<3, GroupAlbumVideoWireOracle>;
|
||||
uploader?: pb<6, string>;
|
||||
batchId?: pb<7, uint_64>;
|
||||
uploadTime?: pb<8, uint_64>;
|
||||
}
|
||||
|
||||
interface GroupAlbumMediaListResponseDataWireOracle {
|
||||
mediaList?: pb_repeated<3, GroupAlbumMediaWireOracle>;
|
||||
nextAttachInfo?: pb<5, string>;
|
||||
}
|
||||
|
||||
export interface GroupAlbumMediaListResponseWireOracle {
|
||||
result?: pb<1, int_32>;
|
||||
data?: pb<4, GroupAlbumMediaListResponseDataWireOracle>;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,260 @@
|
||||
// MessageApi recall + markRead coverage. (The send* paths live in their
|
||||
// own test file because they need element-builder fixtures the recall
|
||||
// paths don't.) Renamed from the legacy `actions/group-message` shape
|
||||
// after #6 commit 1 moved the recall + markRead helpers onto MessageApi
|
||||
// and #6 commit 6 absorbed setEssence into InteractionApi.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type {
|
||||
C2CRecallRequest,
|
||||
SsoReadedReportReq,
|
||||
SsoReadedReportResp,
|
||||
} from '@snowluma/proto-defs/oidb-actions/base';
|
||||
|
||||
vi.mock('@snowluma/protocol/bridge-oidb', () => ({
|
||||
makeOidbEnvelope: vi.fn((_oidbCmd, _subCmd, body) => ({ body })),
|
||||
encodeOidbEnv: vi.fn(() => new Uint8Array()),
|
||||
decodeOidbEnv: vi.fn(() => ({ body: {} })),
|
||||
}));
|
||||
|
||||
import { MessageApi } from '../../src/bridge/apis/message';
|
||||
import { mockBridge } from './_helpers';
|
||||
|
||||
function readReportResult(response: SsoReadedReportResp) {
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(protobuf_encode<SsoReadedReportResp>(response)),
|
||||
};
|
||||
}
|
||||
|
||||
describe('apis/message — recall + markRead', () => {
|
||||
beforeEach(() => {
|
||||
// No global mock state to reset — sendRawPacket is per-bridge.
|
||||
});
|
||||
|
||||
it('recallGroup sends to SsoGroupRecallMsg with the group sequence', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new MessageApi(bridge as any).recallGroup(12345, 999);
|
||||
expect(bridge.sendRawPacket).toHaveBeenCalledOnce();
|
||||
const [serviceCmd] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
expect(serviceCmd).toBe('trpc.msg.msg_svc.MsgService.SsoGroupRecallMsg');
|
||||
});
|
||||
|
||||
it('recallGroup throws when sendRawPacket reports failure', async () => {
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async () => ({
|
||||
success: false,
|
||||
gotResponse: false,
|
||||
errorCode: -1,
|
||||
errorMessage: 'network down',
|
||||
responseData: null,
|
||||
})) as any,
|
||||
});
|
||||
await expect(new MessageApi(bridge as any).recallGroup(12345, 999)).rejects.toThrow(
|
||||
/network down/,
|
||||
);
|
||||
});
|
||||
|
||||
it('recallPrivate resolves the target UID before dispatch', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new MessageApi(bridge as any).recallPrivate(10001, 100, 200, 123, 1700000000);
|
||||
expect(bridge.resolveUserUid).toHaveBeenCalledWith(10001);
|
||||
const [serviceCmd] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
expect(serviceCmd).toBe('trpc.msg.msg_svc.MsgService.SsoC2CRecallMsg');
|
||||
});
|
||||
|
||||
it('recallPrivate keeps the 64-bit message id exact for any random', async () => {
|
||||
const random = 0x1234567;
|
||||
const bridge = mockBridge();
|
||||
await new MessageApi(bridge as any).recallPrivate(10001, 11110, 200, random, 1700000000);
|
||||
|
||||
const [, body] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
const request = protobuf_decode<C2CRecallRequest>(body);
|
||||
expect(request.info?.random).toBe(random);
|
||||
expect(request.info?.messageId).toBe(0x0100000001234567n);
|
||||
});
|
||||
|
||||
it('probes and confirms group plus private reads in the same packets', async () => {
|
||||
const sendRawPacket = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
readReportResult({
|
||||
groupList: [{ groupUin: 1081372778n, readSeq: 66569n, latestSeq: 66629n }],
|
||||
c2cList: [
|
||||
{
|
||||
uid: 'resolved-uid',
|
||||
readSeq: 160n,
|
||||
latestSeq: 171n,
|
||||
lastMsgTime: 1783952859n,
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
readReportResult({
|
||||
groupList: [{ groupUin: 1081372778n, readSeq: 66629n, latestSeq: 66629n }],
|
||||
c2cList: [
|
||||
{
|
||||
uid: 'resolved-uid',
|
||||
readSeq: 171n,
|
||||
latestSeq: 171n,
|
||||
lastMsgTime: 1783952859n,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
|
||||
await new MessageApi(bridge as any).markAllRead([1081372778], [10001]);
|
||||
|
||||
expect(sendRawPacket).toHaveBeenCalledTimes(2);
|
||||
expect(sendRawPacket.mock.calls.map((call: any) => call[0])).toEqual([
|
||||
'trpc.msg.msg_svc.MsgService.SsoReadedReport',
|
||||
'trpc.msg.msg_svc.MsgService.SsoReadedReport',
|
||||
]);
|
||||
expect(bridge.resolveUserUid).toHaveBeenCalledWith(10001);
|
||||
|
||||
const probeBytes = sendRawPacket.mock.calls[0]![1];
|
||||
expect(Buffer.from(probeBytes).toString('hex')).toBe(
|
||||
'0a0608eae0d18304120e120c7265736f6c7665642d756964',
|
||||
);
|
||||
const probe = protobuf_decode<SsoReadedReportReq>(probeBytes);
|
||||
expect(probe).toEqual({
|
||||
groupList: [{ groupUin: 1081372778n, lastReadSeq: null }],
|
||||
c2cList: [{ uid: 'resolved-uid', lastReadTime: null, lastReadSeq: null }],
|
||||
});
|
||||
|
||||
const confirm = protobuf_decode<SsoReadedReportReq>(sendRawPacket.mock.calls[1]![1]);
|
||||
expect(confirm).toEqual({
|
||||
groupList: [{ groupUin: 1081372778n, lastReadSeq: 66629n }],
|
||||
c2cList: [
|
||||
{
|
||||
uid: 'resolved-uid',
|
||||
lastReadTime: 1783952859n,
|
||||
lastReadSeq: 171n,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not send a confirm packet when every read marker is current', async () => {
|
||||
const sendRawPacket = vi.fn(async () =>
|
||||
readReportResult({
|
||||
groupList: [{ groupUin: 12345n, readSeq: 50n, latestSeq: 50n }],
|
||||
}),
|
||||
);
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
|
||||
await new MessageApi(bridge as any).markGroupRead(12345);
|
||||
|
||||
expect(sendRawPacket).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('markGroupRead rejects a transport success with no business response', async () => {
|
||||
const bridge = mockBridge();
|
||||
await expect(new MessageApi(bridge as any).markGroupRead(12345)).rejects.toThrow(
|
||||
/response is empty/,
|
||||
);
|
||||
});
|
||||
|
||||
it('markGroupRead surfaces the per-group business error', async () => {
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async () =>
|
||||
readReportResult({
|
||||
groupList: [
|
||||
{ resultCode: 100000010, errorMessage: 'send group msg failed, ret:2' },
|
||||
],
|
||||
}),
|
||||
),
|
||||
});
|
||||
await expect(new MessageApi(bridge as any).markGroupRead(1)).rejects.toThrow(
|
||||
/send group msg failed, ret:2/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a top-level success when the private read marker did not advance', async () => {
|
||||
const sendRawPacket = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
readReportResult({
|
||||
c2cList: [
|
||||
{
|
||||
uid: 'resolved-uid',
|
||||
readSeq: 160n,
|
||||
latestSeq: 171n,
|
||||
lastMsgTime: 1783952859n,
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
readReportResult({
|
||||
c2cList: [
|
||||
{
|
||||
uid: 'resolved-uid',
|
||||
readSeq: 160n,
|
||||
latestSeq: 171n,
|
||||
lastMsgTime: 1783952859n,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const bridge = mockBridge({ sendRawPacket });
|
||||
|
||||
await expect(new MessageApi(bridge as any).markPrivateRead(10001)).rejects.toThrow(
|
||||
/confirmed sequence 160 before requested 171/,
|
||||
);
|
||||
});
|
||||
|
||||
it('caps mixed read reports at 100 + 100 and confirms each page before probing the next', async () => {
|
||||
const sendRawPacket = vi.fn(async (_cmd: string, body: Uint8Array) => {
|
||||
const request = protobuf_decode<SsoReadedReportReq>(body);
|
||||
return readReportResult({
|
||||
groupList: request.groupList?.map((item) => {
|
||||
const requested = item.lastReadSeq ?? 0n;
|
||||
return {
|
||||
groupUin: item.groupUin,
|
||||
readSeq: requested,
|
||||
latestSeq: requested === 0n ? 1n : requested,
|
||||
};
|
||||
}),
|
||||
c2cList: request.c2cList?.map((item) => {
|
||||
const requested = item.lastReadSeq ?? 0n;
|
||||
return {
|
||||
uid: item.uid,
|
||||
readSeq: requested,
|
||||
latestSeq: requested === 0n ? 1n : requested,
|
||||
lastMsgTime: 1700000000n,
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
const resolveUserUid = vi.fn(async (userId: number) => `uid-${userId}`);
|
||||
const bridge = mockBridge({ sendRawPacket, resolveUserUid });
|
||||
const groups = Array.from({ length: 101 }, (_, i) => 1000 + i);
|
||||
const users = Array.from({ length: 101 }, (_, i) => 2000 + i);
|
||||
|
||||
await new MessageApi(bridge as any).markAllRead(groups, users);
|
||||
|
||||
expect(sendRawPacket).toHaveBeenCalledTimes(4);
|
||||
const requests = sendRawPacket.mock.calls.map((call: any) =>
|
||||
protobuf_decode<SsoReadedReportReq>(call[1]),
|
||||
);
|
||||
expect(
|
||||
requests.map((request) => [request.groupList?.length, request.c2cList?.length]),
|
||||
).toEqual([
|
||||
[100, 100],
|
||||
[100, 100],
|
||||
[1, 1],
|
||||
[1, 1],
|
||||
]);
|
||||
expect(requests[0]!.groupList![0]!.lastReadSeq).toBeNull();
|
||||
expect(requests[1]!.groupList![0]!.lastReadSeq).toBe(1n);
|
||||
expect(requests[2]!.groupList![0]!.lastReadSeq).toBeNull();
|
||||
expect(requests[3]!.groupList![0]!.lastReadSeq).toBe(1n);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { Oidb0x9083Resp } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
|
||||
// Post-namespace-migration: InteractionApi is a thin facade and
|
||||
// production code routes through the namespace path (sender →
|
||||
// sendRawPacket). Tests now assert against the bridge mock's
|
||||
// sendRawPacket directly — no need for module-level mocks on the
|
||||
// bridge-oidb internals.
|
||||
import { InteractionApi } from '../../src/bridge/apis/interaction';
|
||||
import { mockBridge } from './_helpers';
|
||||
|
||||
describe('apis/interaction', () => {
|
||||
it('sendPoke group: groupUin set, friendUin=0', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new InteractionApi(bridge as any).sendPoke(true, 12345, 67890);
|
||||
expect(bridge.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0xed3_1');
|
||||
});
|
||||
|
||||
it('sendPoke friend: friendUin set, groupUin=0, targetUin defaults to peer', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new InteractionApi(bridge as any).sendPoke(false, 67890);
|
||||
expect(bridge.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0xed3_1');
|
||||
});
|
||||
|
||||
it('sendLike forwards target + count to 0x7e5_104', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new InteractionApi(bridge as any).sendLike(10001, 3);
|
||||
expect(bridge.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x7e5_104');
|
||||
});
|
||||
|
||||
it('setReaction picks _1 for set and _2 for unset', async () => {
|
||||
// setReaction now forwards through the SetReaction namespace, which
|
||||
// calls bridge.sendRawPacket directly.
|
||||
const bridge = mockBridge();
|
||||
const api = new InteractionApi(bridge as any);
|
||||
await api.setReaction(12345, 99, '128516', true);
|
||||
await api.setReaction(12345, 99, '128516', false);
|
||||
const cmds = bridge.sendRawPacket.mock.calls.map((c: any[]) => c[0]);
|
||||
expect(cmds).toEqual(['OidbSvcTrpcTcp.0x9082_1', 'OidbSvcTrpcTcp.0x9082_2']);
|
||||
});
|
||||
|
||||
it('setEssence picks _1 for enable and _2 for disable', async () => {
|
||||
const bridge = mockBridge();
|
||||
const api = new InteractionApi(bridge as any);
|
||||
await api.setEssence(12345, 99, 0, true);
|
||||
await api.setEssence(12345, 99, 0, false);
|
||||
const cmds = bridge.sendRawPacket.mock.calls.map((c: any[]) => c[0]);
|
||||
expect(cmds).toEqual(['OidbSvcTrpcTcp.0xeac_1', 'OidbSvcTrpcTcp.0xeac_2']);
|
||||
});
|
||||
|
||||
it('getEmojiLikes decodes user list, returns the cookie, and reports isLast', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.sendRawPacket.mockResolvedValueOnce({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<OidbBase<Oidb0x9083Resp>>({
|
||||
body: {
|
||||
users: [{ uin: 10001n }, { uin: 20002n }],
|
||||
cookie: 'next-page',
|
||||
isLast: false,
|
||||
},
|
||||
}),
|
||||
),
|
||||
});
|
||||
const out = await new InteractionApi(bridge as any).getEmojiLikes(12345, 99, '128516');
|
||||
expect(out.users).toEqual([{ uin: 10001 }, { uin: 20002 }]);
|
||||
expect(out.cookie).toBe('next-page');
|
||||
expect(out.isLast).toBe(false);
|
||||
});
|
||||
|
||||
it('getEmojiLikes reports isLast=true when no cookie comes back', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.sendRawPacket.mockResolvedValueOnce({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(protobuf_encode<OidbBase<Oidb0x9083Resp>>({ body: {} })),
|
||||
});
|
||||
const out = await new InteractionApi(bridge as any).getEmojiLikes(12345, 99, '128516');
|
||||
expect(out.users).toEqual([]);
|
||||
expect(out.isLast).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { BridgeContext } from '../../src/bridge/bridge-context';
|
||||
import { MessageApi } from '../../src/bridge/apis/message';
|
||||
import { mockBridge } from './_helpers';
|
||||
|
||||
function sender(responseHex: string) {
|
||||
const ctx = {
|
||||
...mockBridge(),
|
||||
nextMessageRandom: () => 1234,
|
||||
sendRawPacket: vi.fn(async () => ({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(responseHex, 'hex'),
|
||||
})),
|
||||
};
|
||||
return { api: new MessageApi(ctx as unknown as BridgeContext), send: ctx.sendRawPacket };
|
||||
}
|
||||
|
||||
describe('group send receipts (#492)', () => {
|
||||
it('accepts the confirmed response from the issue log', async () => {
|
||||
const { api } = sender('080018abb3e8d506500058844a60abb3e8d506700078df0d');
|
||||
await expect(api.sendGroup(10001, [{ type: 'text', text: 'test' }])).resolves.toMatchObject(
|
||||
{
|
||||
sequence: 9476,
|
||||
messageId: 1234,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['missing', '080018c9b3e8d506500060c9b3e8d5067000'],
|
||||
['zero', '08005800'],
|
||||
])('rejects a %s receipt without automatically resending', async (_name, response) => {
|
||||
const { api, send } = sender(response);
|
||||
await expect(api.sendGroup(10001, [{ type: 'text', text: 'test' }])).rejects.toThrow(
|
||||
'group message delivery was not confirmed',
|
||||
);
|
||||
expect(send).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('preserves an explicit server rejection', async () => {
|
||||
const { api } = sender('0801');
|
||||
await expect(api.sendGroup(10001, [{ type: 'text', text: 'test' }])).rejects.toThrow(
|
||||
'send group message rejected: result=1',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type { Oidb0x990Resp, Oidb0x112eResp } from '@snowluma/proto-defs/oidb-actions/base';
|
||||
|
||||
// Post-namespace migration: MiscApi forwards 3 OIDB cmds through
|
||||
// @snowluma/protocol/oidb-services/misc namespaces. The non-OIDB
|
||||
// `getMiniAppArk` (LightAppSvc) stays inline on the facade. Tests
|
||||
// hit bridge.sendRawPacket directly.
|
||||
import { MiscApi } from '../../src/bridge/apis/misc';
|
||||
import { mockBridge } from './_helpers';
|
||||
|
||||
function packResponse(body: Uint8Array) {
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(body),
|
||||
};
|
||||
}
|
||||
|
||||
describe('apis/misc', () => {
|
||||
it('translateEn2Zh returns dstWords when present', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.sendRawPacket.mockResolvedValueOnce(
|
||||
packResponse(
|
||||
protobuf_encode<OidbBase<Oidb0x990Resp>>({
|
||||
body: { translateResp: { dstWords: ['你好', '世界'] } } as any,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const out = await new MiscApi(bridge as any).translateEn2Zh(['hello', 'world']);
|
||||
expect(out).toEqual(['你好', '世界']);
|
||||
});
|
||||
|
||||
it('translateEn2Zh throws on empty response', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.sendRawPacket.mockResolvedValueOnce(
|
||||
packResponse(protobuf_encode<OidbBase<Oidb0x990Resp>>({ body: {} })),
|
||||
);
|
||||
await expect(new MiscApi(bridge as any).translateEn2Zh(['hello'])).rejects.toThrow(/empty/);
|
||||
});
|
||||
|
||||
it('getMiniAppArk rejects unsupported types', async () => {
|
||||
const bridge = mockBridge();
|
||||
await expect(
|
||||
new MiscApi(bridge as any).getMiniAppArk('youtube', 't', 'd', 'p', 'u'),
|
||||
).rejects.toThrow(/unsupported type/);
|
||||
});
|
||||
|
||||
it('getMiniAppArk routes to LightAppSvc with the encoded body', async () => {
|
||||
const bridge = mockBridge({
|
||||
sendRawPacket: vi.fn(async () => ({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: null,
|
||||
})) as any,
|
||||
});
|
||||
await expect(
|
||||
new MiscApi(bridge as any).getMiniAppArk('bili', 't', 'd', 'p', 'u'),
|
||||
).rejects.toThrow();
|
||||
expect(bridge.sendRawPacket).toHaveBeenCalledOnce();
|
||||
expect(bridge.sendRawPacket.mock.calls[0]![0]).toBe(
|
||||
'LightAppSvc.mini_app_share.AdaptShareInfo',
|
||||
);
|
||||
});
|
||||
|
||||
it('clickInlineKeyboardButton returns shaped result', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.sendRawPacket.mockResolvedValueOnce(
|
||||
packResponse(
|
||||
protobuf_encode<OidbBase<Oidb0x112eResp>>({
|
||||
body: { result: 1, errMsg: 'ok', promptText: 'hi' } as any,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const out = await new MiscApi(bridge as any).clickInlineKeyboardButton(
|
||||
1,
|
||||
2,
|
||||
'btn',
|
||||
'data',
|
||||
100,
|
||||
);
|
||||
expect(out).toMatchObject({ result: 1, errMsg: 'ok', promptText: 'hi', status: 0 });
|
||||
});
|
||||
|
||||
it('sendGroupSign dispatches to 0xEB7_1 (uppercase preserved)', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new MiscApi(bridge as any).sendGroupSign(12345);
|
||||
const [cmd] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
// **Important**: this cmd keeps the historic UPPERCASE EB7 wire
|
||||
// name. Default scheme would emit lowercase; the namespace
|
||||
// overrides `wireName` to preserve byte-equality with NTQQ wire.
|
||||
expect(cmd).toBe('OidbSvcTrpcTcp.0xEB7_1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,562 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
||||
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
||||
import type {
|
||||
CustomFaceModifyResp,
|
||||
CustomFaceMoveBody,
|
||||
FaceroamOpResp,
|
||||
GroupAvatarExtra,
|
||||
Oidb0x7edResp,
|
||||
Oidb0xe17Resp,
|
||||
SetStatusReq,
|
||||
SetStatusResp,
|
||||
} from '@snowluma/proto-defs/oidb-actions/base';
|
||||
|
||||
vi.mock('@snowluma/protocol/bridge-oidb', async () => {
|
||||
const actual = await vi.importActual<typeof import('@snowluma/protocol/bridge-oidb')>(
|
||||
'@snowluma/protocol/bridge-oidb',
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
makeOidbEnvelope: vi.fn((_oidbCmd, _subCmd, body) => ({ body })),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@snowluma/protocol/highway', () => ({
|
||||
fetchHighwaySession: vi.fn(async () => ({})),
|
||||
uploadHighwayHttp: vi.fn(async () => undefined),
|
||||
BufferChunkSource: class BufferChunkSource {
|
||||
constructor(readonly bytes: Uint8Array) {}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@snowluma/protocol/highway/utils', () => ({
|
||||
loadBinarySource: vi.fn(async () => ({
|
||||
bytes: new Uint8Array([1, 2, 3]),
|
||||
fileName: 'avatar.bin',
|
||||
})),
|
||||
computeHashes: vi.fn(() => ({ md5: new Uint8Array(16), sha1: new Uint8Array(20) })),
|
||||
computeMd5: vi.fn(() => new Uint8Array(16)),
|
||||
}));
|
||||
|
||||
import * as oidb from '@snowluma/protocol/bridge-oidb';
|
||||
import * as highwayClient from '@snowluma/protocol/highway';
|
||||
import { ProfileApi } from '../../src/bridge/apis/profile';
|
||||
import { mockBridge } from './_helpers';
|
||||
|
||||
describe('apis/profile', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(oidb.makeOidbEnvelope).mockClear();
|
||||
vi.mocked(highwayClient.fetchHighwaySession).mockClear();
|
||||
vi.mocked(highwayClient.uploadHighwayHttp).mockClear();
|
||||
});
|
||||
|
||||
it('setOnlineStatus sends to status_svc.SetStatus and accepts an empty response', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new ProfileApi(bridge as any).setOnlineStatus(11, 0, 100);
|
||||
const [serviceCmd] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
expect(serviceCmd).toBe('trpc.qq_new_tech.status_svc.StatusService.SetStatus');
|
||||
});
|
||||
|
||||
it('setOnlineStatus does NOT include the customExt field 4 (varint 0x22 absent in body)', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new ProfileApi(bridge as any).setOnlineStatus(11, 0, 100);
|
||||
const [, body] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
// proto field 4 length-delimited tag = (4 << 3) | 2 = 0x22. The
|
||||
// body should not contain that byte; if encoder ever leaks an
|
||||
// empty customExt it'd appear here.
|
||||
expect(Buffer.from(body).includes(0x22)).toBe(false);
|
||||
});
|
||||
|
||||
it('setDiyOnlineStatus hardcodes status=10 / extStatus=2000 and packs faceId+wording+faceType into customExt', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new ProfileApi(bridge as any).setDiyOnlineStatus(1234, '摸鱼中', 2);
|
||||
const [serviceCmd, body] = bridge.sendRawPacket.mock.calls[0]!;
|
||||
expect(serviceCmd).toBe('trpc.qq_new_tech.status_svc.StatusService.SetStatus');
|
||||
// Decode the wire bytes back through the same schema to assert
|
||||
// every field landed in the right place.
|
||||
const decoded = protobuf_decode<SetStatusReq>(body as Uint8Array);
|
||||
expect(decoded).toMatchObject({
|
||||
status: 10,
|
||||
extStatus: 2000,
|
||||
customExt: { faceId: 1234, text: '摸鱼中', faceType: 2 },
|
||||
});
|
||||
expect(decoded.batteryStatus ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it("setOnlineStatus accepts QQ's success reply even when the reply code is non-zero (#405)", async () => {
|
||||
const bridge = mockBridge();
|
||||
const respBuf = Buffer.from(
|
||||
protobuf_encode<SetStatusResp>({
|
||||
errCode: 1,
|
||||
errMsg: 'set status success',
|
||||
}),
|
||||
);
|
||||
expect(respBuf.length).toBeGreaterThan(0);
|
||||
bridge.sendRawPacket.mockResolvedValueOnce({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: respBuf,
|
||||
} as any);
|
||||
await expect(
|
||||
new ProfileApi(bridge as any).setOnlineStatus(10, 0, 100),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('setOnlineStatus accepts a success-only reply body (#405)', async () => {
|
||||
const bridge = mockBridge();
|
||||
const respBuf = Buffer.from(
|
||||
protobuf_encode<SetStatusResp>({ errMsg: 'set status success' }),
|
||||
);
|
||||
bridge.sendRawPacket.mockResolvedValueOnce({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: respBuf,
|
||||
} as any);
|
||||
await expect(new ProfileApi(bridge as any).setOnlineStatus(10, 0)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('setDiyOnlineStatus surfaces server errors via the same path as setOnlineStatus', async () => {
|
||||
const bridge = mockBridge();
|
||||
const respBuf = Buffer.from(
|
||||
protobuf_encode<SetStatusResp>({ errCode: 1, errMsg: 'denied' }),
|
||||
);
|
||||
bridge.sendRawPacket.mockResolvedValueOnce({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: respBuf,
|
||||
} as any);
|
||||
await expect(new ProfileApi(bridge as any).setDiyOnlineStatus(1, 't', 1)).rejects.toThrow(
|
||||
/denied/,
|
||||
);
|
||||
});
|
||||
|
||||
it('setDiyOnlineStatus rejects when the transport itself fails', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.sendRawPacket.mockResolvedValueOnce({
|
||||
success: false,
|
||||
gotResponse: false,
|
||||
errorCode: -1,
|
||||
errorMessage: 'pipe closed',
|
||||
responseData: null,
|
||||
} as any);
|
||||
await expect(new ProfileApi(bridge as any).setDiyOnlineStatus(1, 't', 1)).rejects.toThrow(
|
||||
/pipe closed/,
|
||||
);
|
||||
});
|
||||
|
||||
it('setProfile is a no-op when both arguments are undefined', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new ProfileApi(bridge as any).setProfile();
|
||||
// Namespace migration: no SSO packet should fire at all.
|
||||
expect(bridge.sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('setProfile only sends non-undefined fields', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new ProfileApi(bridge as any).setProfile('New Nick');
|
||||
expect(bridge.sendRawPacket).toHaveBeenCalledOnce();
|
||||
expect(bridge.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x112a_2');
|
||||
});
|
||||
|
||||
it('setSelfLongNick targets 0x112a_2 wire cmd', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new ProfileApi(bridge as any).setSelfLongNick('hello world');
|
||||
expect(bridge.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x112a_2');
|
||||
});
|
||||
|
||||
it('setInputStatus resolves UID first and sends 0xcd4_1', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new ProfileApi(bridge as any).setInputStatus(10001, 1);
|
||||
expect(bridge.resolveUserUid).toHaveBeenCalledWith(10001);
|
||||
expect(bridge.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0xcd4_1');
|
||||
});
|
||||
|
||||
it('setAvatar loads bytes and pushes through the highway upload path (cmd 90)', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new ProfileApi(bridge as any).setAvatar('/some/avatar.png');
|
||||
expect(highwayClient.fetchHighwaySession).toHaveBeenCalledOnce();
|
||||
expect(highwayClient.uploadHighwayHttp).toHaveBeenCalledOnce();
|
||||
const [, , cmdId, , , extend] = vi.mocked(highwayClient.uploadHighwayHttp).mock.calls[0]!;
|
||||
expect(cmdId).toBe(90);
|
||||
expect((extend as Uint8Array).length).toBe(0); // personal avatar has no extra payload
|
||||
});
|
||||
|
||||
it('setGroupAvatar uses cmdId 3000 and packs the Lagrange GroupAvatarExtra constants', async () => {
|
||||
const bridge = mockBridge();
|
||||
await new ProfileApi(bridge as any).setGroupAvatar(12345, '/some/group-avatar.png');
|
||||
expect(highwayClient.fetchHighwaySession).toHaveBeenCalledOnce();
|
||||
expect(highwayClient.uploadHighwayHttp).toHaveBeenCalledOnce();
|
||||
const [, , cmdId, , , extend] = vi.mocked(highwayClient.uploadHighwayHttp).mock.calls[0]!;
|
||||
expect(cmdId).toBe(3000);
|
||||
// Decode the extra blob back through the schema and assert every
|
||||
// protocol-prescribed constant lands where it should.
|
||||
const decoded = protobuf_decode<GroupAvatarExtra>(extend as Uint8Array);
|
||||
expect(decoded).toEqual({
|
||||
type: 101,
|
||||
groupUin: 12345,
|
||||
field3: { field1: 1 },
|
||||
field5: 3,
|
||||
field6: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('setGroupAvatar rejects an empty file before hitting highway', async () => {
|
||||
const bridge = mockBridge();
|
||||
const { loadBinarySource } = await import('@snowluma/protocol/highway/utils');
|
||||
vi.mocked(loadBinarySource).mockResolvedValueOnce({
|
||||
bytes: new Uint8Array(0),
|
||||
fileName: 'empty.png',
|
||||
} as any);
|
||||
await expect(new ProfileApi(bridge as any).setGroupAvatar(1, 'empty.png')).rejects.toThrow(
|
||||
/empty/,
|
||||
);
|
||||
expect(highwayClient.fetchHighwaySession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('getProfileLike (self): resolves self UID, returns formatted favorite + vote info', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.identity.findUinByUid.mockImplementation((uid: string) =>
|
||||
uid === 'liker-uid' ? 20002 : 0,
|
||||
);
|
||||
bridge.sendRawPacket.mockResolvedValueOnce({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<OidbBase<Oidb0x7edResp>>({
|
||||
body: {
|
||||
userLikeInfos: [
|
||||
{
|
||||
uid: 'u',
|
||||
time: 1700000000,
|
||||
favoriteInfo: { totalCount: 5, lastTime: 1, todayCount: 1 },
|
||||
voteInfo: {
|
||||
totalCount: 7,
|
||||
newCount: 2,
|
||||
newNearbyCount: 1,
|
||||
lastVisitTime: 2,
|
||||
userInfos: [{ uid: 'liker-uid', nick: '点赞者', count: 3 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as any,
|
||||
}),
|
||||
),
|
||||
});
|
||||
const out = await new ProfileApi(bridge as any).getLike();
|
||||
expect(bridge.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x7ed_13');
|
||||
expect(out.favoriteInfo.total_count).toBe(5);
|
||||
expect(out.voteInfo.total_count).toBe(7);
|
||||
expect(out.voteInfo.userInfos).toEqual([
|
||||
expect.objectContaining({ uid: 'liker-uid', uin: 20002, nick: '点赞者', count: 3 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('getProfileLike throws on empty result', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.sendRawPacket.mockResolvedValueOnce({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<OidbBase<Oidb0x7edResp>>({ body: { userLikeInfos: [] } as any }),
|
||||
),
|
||||
});
|
||||
await expect(new ProfileApi(bridge as any).getLike()).rejects.toThrow(/empty/);
|
||||
});
|
||||
|
||||
it('getProfileLike resolves uncached liker uins through Identity.resolveUin', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.identity.findUinByUid.mockReturnValue(null);
|
||||
bridge.identity.resolveUin.mockImplementation(async (uid: string) =>
|
||||
uid === 'stranger-uid' ? 30003 : null,
|
||||
);
|
||||
const fetchUserProfileByUid = vi.fn();
|
||||
(bridge.apis.contacts as any).fetchUserProfileByUid = fetchUserProfileByUid;
|
||||
bridge.sendRawPacket.mockResolvedValueOnce({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<OidbBase<Oidb0x7edResp>>({
|
||||
body: {
|
||||
userLikeInfos: [
|
||||
{
|
||||
uid: 'self-uid',
|
||||
voteInfo: {
|
||||
userInfos: [{ uid: 'stranger-uid', nick: '陌生点赞者' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const out = await new ProfileApi(bridge as any).getLike();
|
||||
|
||||
expect(bridge.identity.resolveUin).toHaveBeenCalledOnce();
|
||||
expect(bridge.identity.resolveUin).toHaveBeenCalledWith('stranger-uid');
|
||||
expect(fetchUserProfileByUid).not.toHaveBeenCalled();
|
||||
expect(out.voteInfo.userInfos[0]?.uin).toBe(30003);
|
||||
});
|
||||
|
||||
it('getProfileLike keeps uin=0 when Identity.resolveUin misses', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.identity.findUinByUid.mockReturnValue(null);
|
||||
bridge.identity.resolveUin.mockResolvedValue(null);
|
||||
const fetchUserProfileByUid = vi.fn(async () => {
|
||||
throw new Error('profile lookup failed');
|
||||
});
|
||||
(bridge.apis.contacts as any).fetchUserProfileByUid = fetchUserProfileByUid;
|
||||
bridge.sendRawPacket.mockResolvedValueOnce({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<OidbBase<Oidb0x7edResp>>({
|
||||
body: {
|
||||
userLikeInfos: [
|
||||
{
|
||||
uid: 'self-uid',
|
||||
voteInfo: { userInfos: [{ uid: 'stranger-uid' }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const out = await new ProfileApi(bridge as any).getLike();
|
||||
|
||||
expect(out.voteInfo.userInfos[0]?.uin).toBe(0);
|
||||
expect(fetchUserProfileByUid).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('getUnidirectionalFriendList parses the embedded JSON body', async () => {
|
||||
const bridge = mockBridge();
|
||||
bridge.sendRawPacket.mockResolvedValueOnce({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<Oidb0xe17Resp>({
|
||||
jsonBody: JSON.stringify({
|
||||
ActionStatus: 'OK',
|
||||
ErrorCode: 0,
|
||||
rpt_block_list: [{ uin: 10001 }, { uin: 10002 }],
|
||||
}),
|
||||
}),
|
||||
),
|
||||
});
|
||||
const out = await new ProfileApi(bridge as any).getUnidirectionalFriendList();
|
||||
expect(out).toEqual([{ uin: 10001 }, { uin: 10002 }]);
|
||||
});
|
||||
|
||||
it('fetchCustomFaceIds slices the Faceroam list and skips the packet for count=0', async () => {
|
||||
const bridge = mockBridge();
|
||||
const emojiA = '10001_0_0_0_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_0_0';
|
||||
const emojiB = '10001_0_0_0_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB_0_0';
|
||||
bridge.sendRawPacket.mockResolvedValueOnce({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<FaceroamOpResp>({
|
||||
retCode: 0,
|
||||
item: { faceIds: [emojiA, emojiB] },
|
||||
}),
|
||||
),
|
||||
} as any);
|
||||
|
||||
const api = new ProfileApi(bridge as any);
|
||||
await expect(api.fetchCustomFaceIds(1)).resolves.toEqual([emojiA]);
|
||||
expect(bridge.sendRawPacket.mock.calls[0]![0]).toBe('Faceroam.OpReq');
|
||||
|
||||
await expect(api.fetchCustomFaceIds(0)).resolves.toEqual([]);
|
||||
expect(bridge.sendRawPacket).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('fetchCustomFace maps ids to image urls', async () => {
|
||||
const bridge = mockBridge();
|
||||
const emojiA = '10001_0_0_0_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_0_0';
|
||||
bridge.sendRawPacket.mockResolvedValueOnce({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<FaceroamOpResp>({
|
||||
retCode: 0,
|
||||
item: { faceIds: [emojiA] },
|
||||
}),
|
||||
),
|
||||
} as any);
|
||||
|
||||
await expect(new ProfileApi(bridge as any).fetchCustomFace(1)).resolves.toEqual([
|
||||
`https://p.qpic.cn/qq_expression/10001/${emojiA}/0`,
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(['resource', 'md5'])(
|
||||
'resolves a saved sticker by %s without fetching every description',
|
||||
async (kind) => {
|
||||
const bridge = mockBridge();
|
||||
const md5 = 'ABCDEF0123456789ABCDEF0123456789';
|
||||
const emojiId = `10001_0_0_0_${md5}_0_0`;
|
||||
bridge.sendRawPacket.mockResolvedValue({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<FaceroamOpResp>({ item: { faceIds: [emojiId] } }),
|
||||
),
|
||||
} as any);
|
||||
const api = new ProfileApi(bridge as any);
|
||||
expect(
|
||||
await api.resolveCustomFace(kind === 'md5' ? md5.toLowerCase() : emojiId),
|
||||
).toEqual({
|
||||
emojiId,
|
||||
md5,
|
||||
url: `https://p.qpic.cn/qq_expression/10001/${emojiId}/0`,
|
||||
});
|
||||
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(1);
|
||||
await expect(api.resolveCustomFace('missing')).rejects.toThrow(
|
||||
/not in the saved sticker list/,
|
||||
);
|
||||
await expect(api.resolveCustomFace(' ')).rejects.toThrow(/identifier is empty/);
|
||||
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(2);
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects a negative custom-face count before sending', async () => {
|
||||
const bridge = mockBridge();
|
||||
await expect(new ProfileApi(bridge as any).fetchCustomFaceIds(-1)).rejects.toThrow(
|
||||
/non-negative integer/,
|
||||
);
|
||||
expect(bridge.sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('moveCustomFaceToFront reorders from the id list without parsing urls', async () => {
|
||||
const bridge = mockBridge();
|
||||
const emojiA = '10001_0_0_0_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_0_0';
|
||||
const emojiB = '10001_0_0_0_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB_0_0';
|
||||
bridge.sendRawPacket
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<FaceroamOpResp>({
|
||||
retCode: 0,
|
||||
item: { faceIds: [emojiA, emojiB] },
|
||||
}),
|
||||
),
|
||||
} as any)
|
||||
.mockResolvedValue({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<OidbBase<CustomFaceModifyResp>>({
|
||||
body: { retCode: 0 },
|
||||
}),
|
||||
),
|
||||
} as any);
|
||||
|
||||
await new ProfileApi(bridge as any).moveCustomFaceToFront(emojiB);
|
||||
|
||||
expect(bridge.sendRawPacket.mock.calls.map((call) => call[0])).toEqual([
|
||||
'Faceroam.OpReq',
|
||||
'OidbSvcTrpcTcp.0x902f_1',
|
||||
'OidbSvcTrpcTcp.0x902e_1',
|
||||
]);
|
||||
const moveBody = protobuf_decode<OidbBase<CustomFaceMoveBody>>(
|
||||
bridge.sendRawPacket.mock.calls[2]![1],
|
||||
);
|
||||
expect(moveBody.body?.emojis?.map((emoji) => emoji.emojiId)).toEqual([emojiB, emojiA]);
|
||||
});
|
||||
|
||||
it('fetchCustomFaceDetails supplements the Faceroam ids with server descriptions (#333)', async () => {
|
||||
const bridge = mockBridge();
|
||||
const emojiA = '10001_0_0_0_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_0_0';
|
||||
const emojiB = '10001_0_0_0_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB_0_0';
|
||||
bridge.sendRawPacket
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<FaceroamOpResp>({
|
||||
retCode: 0,
|
||||
item: { faceIds: [emojiA, emojiB] },
|
||||
}),
|
||||
),
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<OidbBase<CustomFaceModifyResp>>({
|
||||
body: {
|
||||
retCode: 0,
|
||||
entries: [{ emoji: { emojiId: emojiB }, desc: '第二个表情' }],
|
||||
},
|
||||
}),
|
||||
),
|
||||
} as any);
|
||||
|
||||
const out = await new ProfileApi(bridge as any).fetchCustomFaceDetails(2);
|
||||
|
||||
expect(bridge.sendRawPacket.mock.calls.map((call) => call[0])).toEqual([
|
||||
'Faceroam.OpReq',
|
||||
'OidbSvcTrpcTcp.0x902e_1',
|
||||
]);
|
||||
const detailRequest = protobuf_decode<OidbBase<CustomFaceMoveBody>>(
|
||||
bridge.sendRawPacket.mock.calls[1]![1],
|
||||
);
|
||||
expect(detailRequest.body?.emojis).toEqual([
|
||||
{ emojiId: emojiA, md5: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' },
|
||||
{ emojiId: emojiB, md5: 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB' },
|
||||
]);
|
||||
expect(out).toEqual([
|
||||
{
|
||||
emojiId: emojiA,
|
||||
url: `https://p.qpic.cn/qq_expression/10001/${emojiA}/0`,
|
||||
md5: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
||||
desc: '',
|
||||
},
|
||||
{
|
||||
emojiId: emojiB,
|
||||
url: `https://p.qpic.cn/qq_expression/10001/${emojiB}/0`,
|
||||
md5: 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB',
|
||||
desc: '第二个表情',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('fetchCustomFaceDetails returns immediately for count=0', async () => {
|
||||
const bridge = mockBridge();
|
||||
await expect(new ProfileApi(bridge as any).fetchCustomFaceDetails(0)).resolves.toEqual([]);
|
||||
expect(bridge.sendRawPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as qzonePublic from '@snowluma/protocol/web/qzone-public';
|
||||
import { QzoneApi } from '../../src/bridge/apis/qzone';
|
||||
import { mockApiHub, mockBridge } from './_helpers';
|
||||
|
||||
const cookies = { p_skey: 'PSK' };
|
||||
const feed = {
|
||||
cell_common: { union_id: { feed_id: 'opaque-id' } },
|
||||
cell_feed_transfer: { future: true },
|
||||
};
|
||||
const comment = { id: 'comment-id', user: { uid: 'commenter' }, unknown: [1, 2] };
|
||||
const reply = { id: 'reply-id', user: { uid: 'recipient' } };
|
||||
const content = [{ type: 0, content: 'text', future: { enabled: true } }];
|
||||
const user = { uin: '10001', uid: 'self-uid', nick: 'self-nick' };
|
||||
const result = { feeds: [], has_more: false, next_cursor: null, unknown: { preserved: true } };
|
||||
|
||||
function makeApi() {
|
||||
const getCookies = vi.fn().mockResolvedValue(cookies);
|
||||
const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never });
|
||||
return { api: new QzoneApi(bridge as never), bridge, getCookies };
|
||||
}
|
||||
|
||||
const cases = [
|
||||
{
|
||||
method: 'getQzoneHomeFeeds',
|
||||
invoke: (api: QzoneApi) => api.getHomeFeeds(20002, 'cursor'),
|
||||
args: [cookies, '10001', '20002', 'cursor'],
|
||||
},
|
||||
{
|
||||
method: 'getQzonePublicCapabilities',
|
||||
invoke: (api: QzoneApi) => api.getPublicCapabilities(),
|
||||
args: [cookies, '10001'],
|
||||
},
|
||||
{
|
||||
method: 'getQzonePublicFeeds',
|
||||
invoke: (api: QzoneApi) => api.getPublicFeeds('cursor'),
|
||||
args: [cookies, '10001', 'cursor'],
|
||||
},
|
||||
{
|
||||
method: 'getQzonePublicFeedDetail',
|
||||
invoke: (api: QzoneApi) => api.getPublicFeedDetail(feed),
|
||||
args: [cookies, feed],
|
||||
},
|
||||
{
|
||||
method: 'getQzonePublicComments',
|
||||
invoke: (api: QzoneApi) => api.getPublicComments(feed, 'cursor'),
|
||||
args: [cookies, feed, 'cursor'],
|
||||
},
|
||||
{
|
||||
method: 'getQzonePublicReplies',
|
||||
invoke: (api: QzoneApi) => api.getPublicReplies(feed, comment, 'cursor', 5),
|
||||
args: [cookies, feed, comment, 'cursor', 5],
|
||||
},
|
||||
{
|
||||
method: 'setQzonePublicUpvote',
|
||||
invoke: (api: QzoneApi) => api.setPublicUpvote(feed, false),
|
||||
args: [cookies, feed, false],
|
||||
},
|
||||
{
|
||||
method: 'setQzonePublicLike',
|
||||
invoke: (api: QzoneApi) => api.setPublicLike(feed, false),
|
||||
args: [cookies, feed, false],
|
||||
},
|
||||
{
|
||||
method: 'setQzonePublicCommentLike',
|
||||
invoke: (api: QzoneApi) => api.setPublicCommentLike(feed, comment, false, reply),
|
||||
args: [cookies, feed, comment, false, reply],
|
||||
},
|
||||
{
|
||||
method: 'commentQzonePublicFeed',
|
||||
invoke: (api: QzoneApi) => api.commentPublicFeed(feed, content),
|
||||
args: [cookies, feed, user, content],
|
||||
},
|
||||
{
|
||||
method: 'replyQzonePublicFeed',
|
||||
invoke: (api: QzoneApi) => api.replyPublicFeed(feed, comment, content, reply),
|
||||
args: [cookies, feed, user, comment, content, reply],
|
||||
},
|
||||
{
|
||||
method: 'deleteQzonePublicComment',
|
||||
invoke: (api: QzoneApi) => api.deletePublicComment(feed, comment),
|
||||
args: [cookies, feed, comment],
|
||||
},
|
||||
{
|
||||
method: 'deleteQzonePublicReply',
|
||||
invoke: (api: QzoneApi) => api.deletePublicReply(feed, comment, reply),
|
||||
args: [cookies, feed, comment, reply],
|
||||
},
|
||||
{
|
||||
method: 'setQzonePublicFollow',
|
||||
invoke: (api: QzoneApi) => api.setPublicFollow(feed, false),
|
||||
args: [cookies, feed, false],
|
||||
},
|
||||
{
|
||||
method: 'forwardQzonePublicFeed',
|
||||
invoke: (api: QzoneApi) => api.forwardPublicFeed(feed, 'reason'),
|
||||
args: [cookies, feed, 'reason'],
|
||||
},
|
||||
] as const;
|
||||
|
||||
describe('apis/qzone public', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('reads a post by tid using current-account credentials', async () => {
|
||||
const { api, getCookies } = makeApi();
|
||||
const tid = '1127000000f1536501000000';
|
||||
const detail = { feed, attach_info: 'comments-next' };
|
||||
const request = vi.spyOn(qzonePublic, 'getQzoneFeedDetail').mockResolvedValue(detail);
|
||||
|
||||
expect(await api.getFeedDetail(tid)).toBe(detail);
|
||||
expect(getCookies).toHaveBeenCalledExactlyOnceWith('qzone.qq.com');
|
||||
expect(request).toHaveBeenCalledExactlyOnceWith(cookies, tid);
|
||||
});
|
||||
|
||||
it('propagates feed detail and cookie failures without replay', async () => {
|
||||
const { api, getCookies } = makeApi();
|
||||
const request = vi
|
||||
.spyOn(qzonePublic, 'getQzoneFeedDetail')
|
||||
.mockRejectedValue(new Error('denied'));
|
||||
|
||||
await expect(api.getFeedDetail('1127000000f1536501000000')).rejects.toThrow('denied');
|
||||
getCookies.mockRejectedValueOnce(new Error('cookie failure'));
|
||||
await expect(api.getFeedDetail('1127000000f1536501000000')).rejects.toThrow(
|
||||
'cookie failure',
|
||||
);
|
||||
expect(request).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('generates a share card with the account cookies and preserves its notice', async () => {
|
||||
const { api, getCookies } = makeApi();
|
||||
const card = { ark: '{}', requires_confirmation: true, notice: '所有人可见' };
|
||||
const request = vi.spyOn(qzonePublic, 'getQzoneShareArk').mockResolvedValue(card);
|
||||
expect(await api.getShareArk(feed)).toBe(card);
|
||||
expect(getCookies).toHaveBeenCalledWith('qzone.qq.com');
|
||||
expect(request).toHaveBeenCalledExactlyOnceWith(cookies, feed);
|
||||
request.mockRejectedValueOnce(new Error('share denied'));
|
||||
await expect(api.getShareArk(feed)).rejects.toThrow('share denied');
|
||||
});
|
||||
|
||||
it.each(cases)(
|
||||
'$method uses current-account cookies and preserves upstream objects',
|
||||
async ({ method, invoke, args }) => {
|
||||
const { api, getCookies } = makeApi();
|
||||
const request = vi.spyOn(qzonePublic, method).mockResolvedValue(result);
|
||||
|
||||
expect(await invoke(api)).toBe(result);
|
||||
expect(getCookies).toHaveBeenCalledWith('qzone.qq.com');
|
||||
expect(request).toHaveBeenCalledExactlyOnceWith(...args);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(cases)(
|
||||
'$method propagates cookie and upstream failures without replay',
|
||||
async ({ method, invoke }) => {
|
||||
const { api, getCookies } = makeApi();
|
||||
const request = vi
|
||||
.spyOn(qzonePublic, method)
|
||||
.mockRejectedValue(new Error('upstream denied'));
|
||||
|
||||
await expect(invoke(api)).rejects.toThrow('upstream denied');
|
||||
getCookies.mockRejectedValueOnce(new Error('cookie failure'));
|
||||
await expect(invoke(api)).rejects.toThrow('cookie failure');
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps documented defaults and omits an optional reply target', async () => {
|
||||
const { api } = makeApi();
|
||||
const feeds = vi.spyOn(qzonePublic, 'getQzonePublicFeeds').mockResolvedValue(result);
|
||||
const replies = vi.spyOn(qzonePublic, 'getQzonePublicReplies').mockResolvedValue(result);
|
||||
const upvote = vi.spyOn(qzonePublic, 'setQzonePublicUpvote').mockResolvedValue(result);
|
||||
const like = vi.spyOn(qzonePublic, 'setQzonePublicLike').mockResolvedValue(result);
|
||||
const commentLike = vi
|
||||
.spyOn(qzonePublic, 'setQzonePublicCommentLike')
|
||||
.mockResolvedValue(result);
|
||||
const follow = vi.spyOn(qzonePublic, 'setQzonePublicFollow').mockResolvedValue(result);
|
||||
const forward = vi.spyOn(qzonePublic, 'forwardQzonePublicFeed').mockResolvedValue(result);
|
||||
const replyRequest = vi
|
||||
.spyOn(qzonePublic, 'replyQzonePublicFeed')
|
||||
.mockResolvedValue(result);
|
||||
|
||||
await api.getPublicFeeds();
|
||||
await api.getPublicReplies(feed, comment);
|
||||
await api.setPublicUpvote(feed);
|
||||
await api.setPublicLike(feed);
|
||||
await api.setPublicCommentLike(feed, comment);
|
||||
await api.setPublicFollow(feed);
|
||||
await api.forwardPublicFeed(feed);
|
||||
await api.replyPublicFeed(feed, comment, 'text');
|
||||
|
||||
expect(feeds).toHaveBeenCalledWith(cookies, '10001', undefined);
|
||||
expect(replies).toHaveBeenCalledWith(cookies, feed, comment, '', 20);
|
||||
expect(upvote).toHaveBeenCalledWith(cookies, feed, true);
|
||||
expect(like).toHaveBeenCalledWith(cookies, feed, true);
|
||||
expect(commentLike).toHaveBeenCalledWith(cookies, feed, comment, true, undefined);
|
||||
expect(follow).toHaveBeenCalledWith(cookies, feed, true);
|
||||
expect(forward).toHaveBeenCalledWith(cookies, feed, '');
|
||||
expect(replyRequest).toHaveBeenCalledWith(cookies, feed, user, comment, 'text', undefined);
|
||||
});
|
||||
|
||||
it('binds comment authors to the current account even when its uid is unavailable', async () => {
|
||||
const { api, bridge } = makeApi();
|
||||
Object.assign(bridge.identity, { selfUid: undefined });
|
||||
const request = vi.spyOn(qzonePublic, 'commentQzonePublicFeed').mockResolvedValue(result);
|
||||
|
||||
await api.commentPublicFeed(feed, 'text');
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
cookies,
|
||||
feed,
|
||||
{ uin: '10001', uid: '', nick: 'self-nick' },
|
||||
'text',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,405 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import * as qzoneWeb from '@snowluma/protocol/web/qzone';
|
||||
import { QzoneApi } from '../../src/bridge/apis/qzone';
|
||||
import { mockApiHub, mockBridge } from './_helpers';
|
||||
|
||||
// QzoneApi is the only place the string(identity.uin)/number(action param)
|
||||
// boundary is crossed, so its target_uin defaulting + the `> 0` guard are
|
||||
// worth locking. We stub the protocol-layer getQzoneMsgList and the web
|
||||
// cookie fetch so the test asserts purely what the bridge passes down.
|
||||
|
||||
describe('apis/qzone', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
function bridgeWithWeb() {
|
||||
const getCookies = vi.fn(async () => ({ p_skey: 'PSK' }));
|
||||
const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never });
|
||||
const fetchSpy = vi
|
||||
.spyOn(qzoneWeb, 'getQzoneMsgList')
|
||||
.mockResolvedValue({ total: 0, msglist: [] });
|
||||
return { bridge, getCookies, fetchSpy };
|
||||
}
|
||||
|
||||
it.each([undefined, 20002])(
|
||||
'getProfile reads owner %s with the current account credentials',
|
||||
async (targetUin) => {
|
||||
const { bridge, getCookies } = bridgeWithWeb();
|
||||
const profile = { name: 'My space', description: 'Notes' };
|
||||
const getProfile = vi.spyOn(qzoneWeb, 'getQzoneProfile').mockResolvedValue(profile);
|
||||
|
||||
expect(await new QzoneApi(bridge as never).getProfile(targetUin)).toEqual(profile);
|
||||
expect(getCookies).toHaveBeenCalledWith('qzone.qq.com');
|
||||
expect(getProfile).toHaveBeenCalledWith(
|
||||
{ p_skey: 'PSK' },
|
||||
'10001',
|
||||
targetUin?.toString(),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('getProfile propagates cookie and protocol failures', async () => {
|
||||
const { bridge, getCookies } = bridgeWithWeb();
|
||||
const api = new QzoneApi(bridge as never);
|
||||
const getProfile = vi
|
||||
.spyOn(qzoneWeb, 'getQzoneProfile')
|
||||
.mockRejectedValue(new Error('denied'));
|
||||
|
||||
await expect(api.getProfile(20002)).rejects.toThrow('denied');
|
||||
getCookies.mockRejectedValueOnce(new Error('cookie failure'));
|
||||
await expect(api.getProfile()).rejects.toThrow('cookie failure');
|
||||
expect(getProfile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([{ name: 'New name' }, { description: '' }, { name: '', description: 'New notes' }])(
|
||||
'setProfile writes only the supplied fields to the current account (%j)',
|
||||
async (profile) => {
|
||||
const { bridge, getCookies } = bridgeWithWeb();
|
||||
const setProfile = vi.spyOn(qzoneWeb, 'setQzoneProfile').mockResolvedValue();
|
||||
|
||||
await new QzoneApi(bridge as never).setProfile(profile);
|
||||
expect(getCookies).toHaveBeenCalledWith('qzone.qq.com');
|
||||
expect(setProfile).toHaveBeenCalledWith({ p_skey: 'PSK' }, '10001', profile);
|
||||
},
|
||||
);
|
||||
|
||||
it('setProfile propagates cookie and protocol failures', async () => {
|
||||
const { bridge, getCookies } = bridgeWithWeb();
|
||||
const api = new QzoneApi(bridge as never);
|
||||
const setProfile = vi
|
||||
.spyOn(qzoneWeb, 'setQzoneProfile')
|
||||
.mockRejectedValue(new Error('captcha required'));
|
||||
|
||||
await expect(api.setProfile({ name: 'New name' })).rejects.toThrow('captcha required');
|
||||
getCookies.mockRejectedValueOnce(new Error('cookie failure'));
|
||||
await expect(api.setProfile({ description: 'Notes' })).rejects.toThrow('cookie failure');
|
||||
expect(setProfile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([undefined, 20002])(
|
||||
'getMsgDetail uses current-account credentials for owner %s',
|
||||
async (targetUin) => {
|
||||
const { bridge, getCookies } = bridgeWithWeb();
|
||||
const detail = { tid: 'T1', content: 'Post', comment_num: 0, comments: [] };
|
||||
const fetchDetail = vi.spyOn(qzoneWeb, 'getQzoneMsgDetail').mockResolvedValue(detail);
|
||||
const result = await new QzoneApi(bridge as never).getMsgDetail('T1', targetUin);
|
||||
expect(result).toEqual(detail);
|
||||
expect(getCookies).toHaveBeenCalledWith('qzone.qq.com');
|
||||
expect(fetchDetail).toHaveBeenCalledWith(
|
||||
{ p_skey: 'PSK' },
|
||||
String(targetUin ?? 10001),
|
||||
'T1',
|
||||
0,
|
||||
20,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('getMsgDetail passes pagination and propagates cookie and protocol failures', async () => {
|
||||
const { bridge, getCookies } = bridgeWithWeb();
|
||||
const api = new QzoneApi(bridge as never);
|
||||
const fetchDetail = vi
|
||||
.spyOn(qzoneWeb, 'getQzoneMsgDetail')
|
||||
.mockRejectedValue(new Error('denied'));
|
||||
await expect(api.getMsgDetail('T1', 20002, 10, 5)).rejects.toThrow('denied');
|
||||
expect(fetchDetail).toHaveBeenCalledWith({ p_skey: 'PSK' }, '20002', 'T1', 10, 5);
|
||||
getCookies.mockRejectedValueOnce(new Error('cookie failure'));
|
||||
await expect(api.getMsgDetail('T1')).rejects.toThrow('cookie failure');
|
||||
expect(fetchDetail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("defaults target_uin to the bot's own uin and uses default pos/num", async () => {
|
||||
const { bridge, getCookies, fetchSpy } = bridgeWithWeb();
|
||||
await new QzoneApi(bridge as never).getMsgList();
|
||||
expect(getCookies).toHaveBeenCalledWith('qzone.qq.com');
|
||||
// identity.uin is '10001' (a string) — passed straight through.
|
||||
expect(fetchSpy).toHaveBeenCalledWith({ p_skey: 'PSK' }, '10001', 0, 20);
|
||||
});
|
||||
|
||||
it('treats target_uin 0 as absent and falls back to own uin', async () => {
|
||||
const { bridge, fetchSpy } = bridgeWithWeb();
|
||||
await new QzoneApi(bridge as never).getMsgList(0);
|
||||
expect(fetchSpy).toHaveBeenCalledWith({ p_skey: 'PSK' }, '10001', 0, 20);
|
||||
});
|
||||
|
||||
it('passes a real target_uin (stringified) plus pos/num through', async () => {
|
||||
const { bridge, fetchSpy } = bridgeWithWeb();
|
||||
await new QzoneApi(bridge as never).getMsgList(20002, 5, 50);
|
||||
expect(fetchSpy).toHaveBeenCalledWith({ p_skey: 'PSK' }, '20002', 5, 50);
|
||||
});
|
||||
|
||||
it("getFeeds always uses the bot's own uin and threads pageNum/count", async () => {
|
||||
const getCookies = vi.fn(async () => ({ p_skey: 'PSK' }));
|
||||
const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never });
|
||||
const feedsSpy = vi
|
||||
.spyOn(qzoneWeb, 'getQzoneFeeds')
|
||||
.mockResolvedValue({ feeds: [], has_more: false, next_cursor: null });
|
||||
await new QzoneApi(bridge as never).getFeeds(3, 20);
|
||||
expect(getCookies).toHaveBeenCalledWith('qzone.qq.com');
|
||||
expect(feedsSpy).toHaveBeenCalledWith({ p_skey: 'PSK' }, '10001', 3, 20, undefined);
|
||||
await new QzoneApi(bridge as never).getFeeds(1, 20, 'opaque-cursor');
|
||||
expect(feedsSpy).toHaveBeenLastCalledWith(
|
||||
{ p_skey: 'PSK' },
|
||||
'10001',
|
||||
1,
|
||||
20,
|
||||
'opaque-cursor',
|
||||
);
|
||||
});
|
||||
|
||||
it('uploadImageFromSource uploads from file:// http:// base64:// and returns richval + url', async () => {
|
||||
const getCookies = vi.fn(async () => ({ p_skey: 'PSK', skey: 'SK' }));
|
||||
const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never });
|
||||
const uploadSpy = vi.spyOn(qzoneWeb, 'uploadQzoneImageFromSource').mockResolvedValue({
|
||||
richval: ',12345,abc,abc,22,800,600,,800,600',
|
||||
url: 'https://example.com/img.jpg',
|
||||
albumid: '12345',
|
||||
lloc: 'abc',
|
||||
type: 22,
|
||||
width: 600,
|
||||
height: 800,
|
||||
});
|
||||
const out = await new QzoneApi(bridge as never).uploadImageFromSource(
|
||||
'file:///path/to/image.jpg',
|
||||
);
|
||||
expect(getCookies).toHaveBeenCalledWith('qzone.qq.com');
|
||||
expect(uploadSpy).toHaveBeenCalledWith(
|
||||
{ p_skey: 'PSK', skey: 'SK' },
|
||||
'10001',
|
||||
'file:///path/to/image.jpg',
|
||||
);
|
||||
expect(out.richval).toBe(',12345,abc,abc,22,800,600,,800,600');
|
||||
expect(out.url).toBe('https://example.com/img.jpg');
|
||||
});
|
||||
|
||||
it("publish posts to the bot's own space with the given content (text-only)", async () => {
|
||||
const getCookies = vi.fn(async () => ({ p_skey: 'PSK' }));
|
||||
const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never });
|
||||
const pubSpy = vi
|
||||
.spyOn(qzoneWeb, 'publishQzoneMsg')
|
||||
.mockResolvedValue({ tid: 'T', time: 1 });
|
||||
const out = await new QzoneApi(bridge as never).publish('hello');
|
||||
expect(getCookies).toHaveBeenCalledWith('qzone.qq.com');
|
||||
expect(pubSpy).toHaveBeenCalledWith(
|
||||
{ p_skey: 'PSK' },
|
||||
'10001',
|
||||
'hello',
|
||||
undefined,
|
||||
undefined,
|
||||
1,
|
||||
undefined,
|
||||
);
|
||||
expect(out).toEqual({ tid: 'T', time: 1 });
|
||||
});
|
||||
|
||||
it('publish posts with richtype and richval for image post', async () => {
|
||||
const getCookies = vi.fn(async () => ({ p_skey: 'PSK' }));
|
||||
const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never });
|
||||
const pubSpy = vi
|
||||
.spyOn(qzoneWeb, 'publishQzoneMsg')
|
||||
.mockResolvedValue({ tid: 'T2', time: 2 });
|
||||
const richval = ',12345,abc,abc,22,800,600,,800,600';
|
||||
const out = await new QzoneApi(bridge as never).publish('look at this', 1, richval);
|
||||
expect(getCookies).toHaveBeenCalledWith('qzone.qq.com');
|
||||
expect(pubSpy).toHaveBeenCalledWith(
|
||||
{ p_skey: 'PSK' },
|
||||
'10001',
|
||||
'look at this',
|
||||
1,
|
||||
richval,
|
||||
1,
|
||||
undefined,
|
||||
);
|
||||
expect(out).toEqual({ tid: 'T2', time: 2 });
|
||||
});
|
||||
|
||||
it('publish posts with multiple images (richval joined with tab)', async () => {
|
||||
const getCookies = vi.fn(async () => ({ p_skey: 'PSK' }));
|
||||
const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never });
|
||||
const pubSpy = vi
|
||||
.spyOn(qzoneWeb, 'publishQzoneMsg')
|
||||
.mockResolvedValue({ tid: 'T3', time: 3 });
|
||||
const richval = ',12345,a,a,22,800,600,,800,600\t,12346,b,b,22,1024,768,,1024,768';
|
||||
const out = await new QzoneApi(bridge as never).publish('two images', 1, richval);
|
||||
expect(getCookies).toHaveBeenCalledWith('qzone.qq.com');
|
||||
expect(pubSpy).toHaveBeenCalledWith(
|
||||
{ p_skey: 'PSK' },
|
||||
'10001',
|
||||
'two images',
|
||||
1,
|
||||
richval,
|
||||
1,
|
||||
undefined,
|
||||
);
|
||||
expect(out).toEqual({ tid: 'T3', time: 3 });
|
||||
});
|
||||
|
||||
it('publish threads qzone visibility params', async () => {
|
||||
const getCookies = vi.fn(async () => ({ p_skey: 'PSK' }));
|
||||
const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never });
|
||||
const pubSpy = vi
|
||||
.spyOn(qzoneWeb, 'publishQzoneMsg')
|
||||
.mockResolvedValue({ tid: 'T', time: 1 });
|
||||
await new QzoneApi(bridge as never).publish(
|
||||
'hello',
|
||||
undefined,
|
||||
undefined,
|
||||
16,
|
||||
'10002|10003',
|
||||
);
|
||||
expect(pubSpy).toHaveBeenCalledWith(
|
||||
{ p_skey: 'PSK' },
|
||||
'10001',
|
||||
'hello',
|
||||
undefined,
|
||||
undefined,
|
||||
16,
|
||||
'10002|10003',
|
||||
);
|
||||
});
|
||||
|
||||
it("delete removes a feed by tid from the bot's own space", async () => {
|
||||
const getCookies = vi.fn(async () => ({ p_skey: 'PSK' }));
|
||||
const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never });
|
||||
const delSpy = vi.spyOn(qzoneWeb, 'deleteQzoneMsg').mockResolvedValue();
|
||||
await new QzoneApi(bridge as never).delete('TID9');
|
||||
expect(getCookies).toHaveBeenCalledWith('qzone.qq.com');
|
||||
expect(delSpy).toHaveBeenCalledWith({ p_skey: 'PSK' }, '10001', 'TID9');
|
||||
});
|
||||
|
||||
it("updateRight changes a feed's visibility on the bot's own space", async () => {
|
||||
const getCookies = vi.fn(async () => ({ p_skey: 'PSK' }));
|
||||
const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never });
|
||||
const updSpy = vi
|
||||
.spyOn(qzoneWeb, 'updateQzoneMsgRight')
|
||||
.mockResolvedValue({ ugc_right: 16 });
|
||||
const out = await new QzoneApi(bridge as never).updateRight('TID9', 16, '10002|10003');
|
||||
expect(getCookies).toHaveBeenCalledWith('qzone.qq.com');
|
||||
expect(updSpy).toHaveBeenCalledWith({ p_skey: 'PSK' }, '10001', 'TID9', 16, '10002|10003');
|
||||
expect(out).toEqual({ ugc_right: 16 });
|
||||
});
|
||||
|
||||
it('like defaults the feed owner to self and passes opUin=self + like flag', async () => {
|
||||
const getCookies = vi.fn(async () => ({ p_skey: 'PSK' }));
|
||||
const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never });
|
||||
const likeSpy = vi.spyOn(qzoneWeb, 'setQzoneLike').mockResolvedValue();
|
||||
await new QzoneApi(bridge as never).like('TIDX', undefined, true, 1700000000);
|
||||
// opUin = self ('10001'), owner defaults to self when target_uin absent; abstime threaded
|
||||
expect(likeSpy).toHaveBeenCalledWith(
|
||||
{ p_skey: 'PSK' },
|
||||
'10001',
|
||||
'10001',
|
||||
'TIDX',
|
||||
true,
|
||||
1700000000,
|
||||
);
|
||||
});
|
||||
|
||||
it("like targets a friend's space (owner = target_uin) and threads the unlike flag + abstime default", async () => {
|
||||
const getCookies = vi.fn(async () => ({ p_skey: 'PSK' }));
|
||||
const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never });
|
||||
const likeSpy = vi.spyOn(qzoneWeb, 'setQzoneLike').mockResolvedValue();
|
||||
await new QzoneApi(bridge as never).like('TIDX', 20002, false);
|
||||
// abstime defaults to 0 when omitted
|
||||
expect(likeSpy).toHaveBeenCalledWith({ p_skey: 'PSK' }, '10001', '20002', 'TIDX', false, 0);
|
||||
});
|
||||
|
||||
it('comment defaults the feed owner to self and posts as self', async () => {
|
||||
const getCookies = vi.fn(async () => ({ p_skey: 'PSK' }));
|
||||
const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never });
|
||||
const cmtSpy = vi.spyOn(qzoneWeb, 'commentQzoneMsg').mockResolvedValue({ comment_id: '1' });
|
||||
const out = await new QzoneApi(bridge as never).comment('TIDX', 'nice', undefined);
|
||||
// selfUin='10001' commenter; owner defaults to self
|
||||
expect(cmtSpy).toHaveBeenCalledWith(
|
||||
{ p_skey: 'PSK' },
|
||||
'10001',
|
||||
'10001',
|
||||
'TIDX',
|
||||
'nice',
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(out).toEqual({ comment_id: '1' });
|
||||
});
|
||||
|
||||
it("comment targets a friend's feed (owner = target_uin), commenter stays self", async () => {
|
||||
const getCookies = vi.fn(async () => ({ p_skey: 'PSK' }));
|
||||
const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never });
|
||||
const cmtSpy = vi.spyOn(qzoneWeb, 'commentQzoneMsg').mockResolvedValue({ comment_id: '2' });
|
||||
await new QzoneApi(bridge as never).comment('TIDX', 'nice', 20002);
|
||||
expect(cmtSpy).toHaveBeenCalledWith(
|
||||
{ p_skey: 'PSK' },
|
||||
'10001',
|
||||
'20002',
|
||||
'TIDX',
|
||||
'nice',
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('comment with images (richType=1, richval=direct URLs joined with tab)', async () => {
|
||||
const getCookies = vi.fn(async () => ({ p_skey: 'PSK' }));
|
||||
const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never });
|
||||
const cmtSpy = vi.spyOn(qzoneWeb, 'commentQzoneMsg').mockResolvedValue({ comment_id: '3' });
|
||||
const richval = 'https://example.com/a.jpg\thttps://example.com/b.jpg';
|
||||
await new QzoneApi(bridge as never).comment('TIDX', 'nice pic', undefined, 1, richval);
|
||||
expect(cmtSpy).toHaveBeenCalledWith(
|
||||
{ p_skey: 'PSK' },
|
||||
'10001',
|
||||
'10001',
|
||||
'TIDX',
|
||||
'nice pic',
|
||||
1,
|
||||
richval,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([undefined, 20002])(
|
||||
'routes replies with the account cookies and post owner (%s)',
|
||||
async (targetUin) => {
|
||||
const getCookies = vi.fn(async () => ({ p_skey: 'PSK' }));
|
||||
const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never });
|
||||
const target = {
|
||||
commentId: '0',
|
||||
commentUin: '30003',
|
||||
replyTo: { uin: '40004', name: 'Reader' },
|
||||
};
|
||||
const result = { comment_id: '8', parent_comment_id: '0', parent_comment_uin: 30003 };
|
||||
const replySpy = vi.spyOn(qzoneWeb, 'replyQzoneComment').mockResolvedValue(result);
|
||||
const commentSpy = vi.spyOn(qzoneWeb, 'commentQzoneMsg');
|
||||
|
||||
const out = await new QzoneApi(bridge as never).comment(
|
||||
'TIDX',
|
||||
'Reply',
|
||||
targetUin,
|
||||
undefined,
|
||||
undefined,
|
||||
target,
|
||||
);
|
||||
|
||||
expect(out).toEqual(result);
|
||||
expect(getCookies).toHaveBeenCalledWith('qzone.qq.com');
|
||||
expect(replySpy).toHaveBeenCalledWith(
|
||||
{ p_skey: 'PSK' },
|
||||
'10001',
|
||||
String(targetUin ?? 10001),
|
||||
'TIDX',
|
||||
'Reply',
|
||||
target,
|
||||
);
|
||||
expect(commentSpy).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects reply images before acquiring cookies or sending', async () => {
|
||||
const getCookies = vi.fn();
|
||||
const bridge = mockBridge({ apis: { ...mockApiHub(), web: { getCookies } } as never });
|
||||
await expect(
|
||||
new QzoneApi(bridge as never).comment('TIDX', 'Reply', undefined, 1, 'URL', {
|
||||
commentId: '0',
|
||||
commentUin: '30003',
|
||||
}),
|
||||
).rejects.toThrow('images are not supported');
|
||||
expect(getCookies).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CLIENT_MESSAGE_RECEIPT_CMD, type PacketInfo } from '@snowluma/common/protocol-types';
|
||||
import type { PacketSender, SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import type { SendMessageResponse } from '@snowluma/proto-defs/action';
|
||||
import type { SsoGetC2cMsgResponse } from '@snowluma/proto-defs/get-c2c-msg';
|
||||
import { protobuf_encode } from '@snowluma/proton';
|
||||
import { IdentityService } from '@snowluma/protocol/identity-service';
|
||||
import { Bridge } from '../src/bridge/bridge';
|
||||
|
||||
const bridges: Bridge[] = [];
|
||||
const timestamp = 1_700_000_000;
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((accept) => {
|
||||
resolve = accept;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(bridges.splice(0).map((bridge) => bridge.dispose()));
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function packet(): PacketInfo {
|
||||
const uid = Buffer.from('u_test_peer');
|
||||
const header = Buffer.alloc(8);
|
||||
header[0] = 1;
|
||||
header[1] = uid.length;
|
||||
header.writeUInt32LE(9, 4);
|
||||
return {
|
||||
pid: 1234,
|
||||
uin: '10001',
|
||||
source: 'client-message',
|
||||
serviceCmd: CLIENT_MESSAGE_RECEIPT_CMD,
|
||||
seqId: 42,
|
||||
retCode: 0,
|
||||
fromClient: false,
|
||||
body: Buffer.concat([
|
||||
header,
|
||||
uid,
|
||||
protobuf_encode<SendMessageResponse>({ privateSequence: 87, timestamp1: timestamp }),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
function response(): SendPacketResult {
|
||||
return {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<SsoGetC2cMsgResponse>({
|
||||
friendUid: 'u_test_peer',
|
||||
messages: [
|
||||
{
|
||||
responseHead: {
|
||||
fromUin: 10001,
|
||||
fromUid: 'u_test_self',
|
||||
toUin: 20002,
|
||||
toUid: 'u_test_peer',
|
||||
},
|
||||
contentHead: {
|
||||
msgType: 166,
|
||||
sequence: 9,
|
||||
ntMsgSeq: 87,
|
||||
timestamp,
|
||||
msgId: 123456,
|
||||
},
|
||||
body: { richText: { elems: [{ text: { str: 'hello' } }] } },
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const bridge = new Bridge(IdentityService.memory('10001'));
|
||||
bridges.push(bridge);
|
||||
vi.spyOn(bridge.apis.contacts, 'fetchFriendList').mockResolvedValue([
|
||||
{ uin: 10001, uid: 'u_test_self', nickname: 'self', remark: '' },
|
||||
]);
|
||||
vi.spyOn(bridge.apis.contacts, 'fetchGroupList').mockResolvedValue([]);
|
||||
const sendPacket = vi.fn<PacketSender['sendPacket']>(async () => response());
|
||||
const sender = { sendPacket };
|
||||
bridge.bindPid(1234, sender);
|
||||
await bridge.whenRosterWarmupSettled();
|
||||
const received = vi.fn();
|
||||
bridge.events.on('friend_message', received);
|
||||
return { bridge, sendPacket, sender, received };
|
||||
}
|
||||
|
||||
describe('Bridge client message recovery', () => {
|
||||
it('does not retry a delayed lookup on a detached or replacement connection', async () => {
|
||||
const ctx = await setup();
|
||||
vi.useFakeTimers();
|
||||
ctx.sendPacket.mockResolvedValue({
|
||||
...response(),
|
||||
responseData: Buffer.from(
|
||||
protobuf_encode<SsoGetC2cMsgResponse>({
|
||||
result: 100000301,
|
||||
friendUid: 'u_test_peer',
|
||||
}),
|
||||
),
|
||||
});
|
||||
const work = ctx.bridge.onPacket(packet());
|
||||
const rejected = expect(work).rejects.toThrow('inactive connection');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(ctx.sendPacket).toHaveBeenCalledOnce();
|
||||
ctx.bridge.detachPid(1234);
|
||||
const replacement = vi.fn<PacketSender['sendPacket']>();
|
||||
ctx.bridge.bindPid(1234, { sendPacket: replacement });
|
||||
await vi.runAllTimersAsync();
|
||||
await rejected;
|
||||
expect(ctx.sendPacket).toHaveBeenCalledOnce();
|
||||
expect(replacement).not.toHaveBeenCalled();
|
||||
expect(ctx.received).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dispatches confirmed content through the normal event pipeline', async () => {
|
||||
const ctx = await setup();
|
||||
await ctx.bridge.onPacket(packet());
|
||||
expect(ctx.sendPacket).toHaveBeenCalledOnce();
|
||||
expect(ctx.received).toHaveBeenCalledOnce();
|
||||
expect(ctx.received).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'friend_message',
|
||||
selfUin: 10001,
|
||||
senderUin: 10001,
|
||||
peerUin: 20002,
|
||||
ntMsgSeq: 87,
|
||||
clientSeq: 9,
|
||||
elements: [{ type: 'text', text: 'hello' }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not treat an ordinary packet with the same command as a receipt', async () => {
|
||||
const ctx = await setup();
|
||||
const ordinary = packet();
|
||||
delete ordinary.source;
|
||||
await ctx.bridge.onPacket(ordinary);
|
||||
expect(ctx.sendPacket).not.toHaveBeenCalled();
|
||||
expect(ctx.received).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shares an in-flight lookup across repeated receipts and ordinary PID observations', async () => {
|
||||
const ctx = await setup();
|
||||
const result = deferred<SendPacketResult>();
|
||||
const started = deferred<void>();
|
||||
ctx.sendPacket.mockImplementation(() => {
|
||||
started.resolve();
|
||||
return result.promise;
|
||||
});
|
||||
const first = ctx.bridge.onPacket(packet());
|
||||
await started.promise;
|
||||
ctx.bridge.bindPid(1234, ctx.sender);
|
||||
const duplicate = ctx.bridge.onPacket(packet());
|
||||
await Promise.resolve();
|
||||
result.resolve(response());
|
||||
await Promise.all([first, duplicate]);
|
||||
expect(ctx.sendPacket).toHaveBeenCalledOnce();
|
||||
expect(ctx.received).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each(['detach', 'replace', 'reattach', 'dispose'] as const)(
|
||||
'rejects a lookup completed after %s',
|
||||
async (action) => {
|
||||
const ctx = await setup();
|
||||
const result = deferred<SendPacketResult>();
|
||||
const started = deferred<void>();
|
||||
ctx.sendPacket.mockImplementation(() => {
|
||||
started.resolve();
|
||||
return result.promise;
|
||||
});
|
||||
const work = ctx.bridge.onPacket(packet());
|
||||
const rejected = expect(work).rejects.toThrow('inactive connection');
|
||||
await started.promise;
|
||||
let disposal: Promise<void> | undefined;
|
||||
|
||||
if (action === 'dispose') {
|
||||
disposal = ctx.bridge.dispose();
|
||||
} else if (action === 'replace') {
|
||||
ctx.bridge.bindPid(1234, { sendPacket: vi.fn() });
|
||||
} else {
|
||||
ctx.bridge.detachPid(1234);
|
||||
|
||||
if (action === 'reattach') {
|
||||
ctx.bridge.bindPid(1234, ctx.sender);
|
||||
}
|
||||
}
|
||||
|
||||
result.resolve(response());
|
||||
await rejected;
|
||||
await disposal;
|
||||
expect(ctx.received).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('queries the originating connection even when another process is active', async () => {
|
||||
const ctx = await setup();
|
||||
const other = { sendPacket: vi.fn<PacketSender['sendPacket']>() };
|
||||
ctx.bridge.bindPid(4321, other);
|
||||
await ctx.bridge.onPacket(packet());
|
||||
expect(ctx.sendPacket).toHaveBeenCalledOnce();
|
||||
expect(other.sendPacket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects unavailable, detached and wrong-account receipts before querying', async () => {
|
||||
const ctx = await setup();
|
||||
await expect(ctx.bridge.onPacket({ ...packet(), retCode: -1 })).rejects.toThrow(
|
||||
'unavailable',
|
||||
);
|
||||
await expect(ctx.bridge.onPacket({ ...packet(), uin: '20002' })).rejects.toThrow('account');
|
||||
ctx.bridge.detachPid(1234);
|
||||
await expect(ctx.bridge.onPacket(packet())).rejects.toThrow('inactive connection');
|
||||
expect(ctx.sendPacket).not.toHaveBeenCalled();
|
||||
expect(ctx.received).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces lookup failure and allows a later receipt to retry', async () => {
|
||||
const ctx = await setup();
|
||||
ctx.sendPacket.mockRejectedValueOnce(new Error('lookup failed'));
|
||||
await expect(ctx.bridge.onPacket(packet())).rejects.toThrow('lookup failed');
|
||||
expect(ctx.received).not.toHaveBeenCalled();
|
||||
await ctx.bridge.onPacket(packet());
|
||||
expect(ctx.sendPacket).toHaveBeenCalledTimes(2);
|
||||
expect(ctx.received).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { IdentityService } from '@snowluma/protocol/identity-service';
|
||||
import { FetchGroupMemberListPage } from '@snowluma/protocol/oidb-services/contacts/fetch-group-member-list-page';
|
||||
import { FetchRobotUinRanges } from '@snowluma/protocol/oidb-services/contacts/fetch-robot-uin-ranges';
|
||||
import { Bridge } from '../src/bridge/bridge';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('Bridge contact cache lifecycle', () => {
|
||||
it('keeps contact lookups available to packets already draining during shutdown', async () => {
|
||||
vi.useFakeTimers();
|
||||
const bridge = new Bridge(IdentityService.memory('10001'));
|
||||
vi.spyOn(FetchRobotUinRanges, 'invoke').mockResolvedValue({ version: 1, ranges: [] });
|
||||
const load = vi
|
||||
.spyOn(FetchGroupMemberListPage, 'invoke')
|
||||
.mockResolvedValue({ members: [], token: '' });
|
||||
await bridge.apis.contacts.fetchGroupMemberList(42);
|
||||
let memberRead: Promise<unknown> | undefined;
|
||||
bridge.registerCmd('test.contact-cache', () => {
|
||||
memberRead = bridge.apis.contacts.fetchGroupMemberList(42);
|
||||
return [];
|
||||
});
|
||||
const packet = bridge.onPacket({
|
||||
pid: 1,
|
||||
uin: '10001',
|
||||
serviceCmd: 'test.contact-cache',
|
||||
seqId: 1,
|
||||
retCode: 0,
|
||||
fromClient: false,
|
||||
body: new Uint8Array(),
|
||||
});
|
||||
const closing = bridge.dispose();
|
||||
await packet;
|
||||
await expect(memberRead).resolves.toEqual([]);
|
||||
await closing;
|
||||
expect(load).toHaveBeenCalledOnce();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'releases member caches before identity closes, close failure=%s',
|
||||
async (fails) => {
|
||||
vi.useFakeTimers();
|
||||
const identity = IdentityService.memory('10001');
|
||||
const bridge = new Bridge(identity);
|
||||
vi.spyOn(FetchRobotUinRanges, 'invoke').mockResolvedValue({ version: 1, ranges: [] });
|
||||
const load = vi
|
||||
.spyOn(FetchGroupMemberListPage, 'invoke')
|
||||
.mockResolvedValue({ members: [], token: '' });
|
||||
await bridge.apis.contacts.fetchGroupMemberList(42);
|
||||
await bridge.apis.contacts.fetchGroupMemberList(42);
|
||||
expect(load).toHaveBeenCalledOnce();
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
|
||||
const originalClose = identity.close.bind(identity);
|
||||
vi.spyOn(identity, 'close').mockImplementation(async () => {
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
await originalClose();
|
||||
|
||||
if (fails) {
|
||||
throw new Error('identity close failed');
|
||||
}
|
||||
});
|
||||
const closing = bridge.dispose();
|
||||
expect(bridge.dispose()).toBe(closing);
|
||||
|
||||
if (fails) {
|
||||
await expect(closing).rejects.toThrow('identity close failed');
|
||||
} else {
|
||||
await closing;
|
||||
}
|
||||
|
||||
await expect(bridge.apis.contacts.fetchGroupMemberList(42)).rejects.toThrow('disposed');
|
||||
expect(load).toHaveBeenCalledOnce();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,589 @@
|
||||
import { getLogLevel, setLogLevel, subscribeLogs, type LogEntry } from '@snowluma/common/logger';
|
||||
import type { PacketSender, SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import type { PacketInfo } from '@snowluma/common/protocol-types';
|
||||
import { IdentityService } from '@snowluma/protocol/identity-service';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { BridgeManager } from '../src/bridge/manager';
|
||||
|
||||
const previousLogLevel = getLogLevel();
|
||||
|
||||
const OK_RESULT: SendPacketResult = {
|
||||
success: true,
|
||||
gotResponse: true,
|
||||
errorCode: 0,
|
||||
errorMessage: '',
|
||||
responseData: Buffer.alloc(0),
|
||||
};
|
||||
|
||||
function makeSender() {
|
||||
const sendPacket = vi.fn<PacketSender['sendPacket']>(async () => OK_RESULT);
|
||||
return {
|
||||
client: { sendPacket } satisfies PacketSender,
|
||||
sendPacket,
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeTrace(entries: LogEntry[]): LogEntry[] {
|
||||
return entries.filter((entry) => entry.level === 'trace' && entry.scope === 'Bridge.Runtime');
|
||||
}
|
||||
|
||||
function testSends(sendPacket: ReturnType<typeof vi.fn>): unknown[][] {
|
||||
return sendPacket.mock.calls.filter(([cmd]) => String(cmd).startsWith('Test.'));
|
||||
}
|
||||
|
||||
function ownershipSendTrace(entries: LogEntry[]): LogEntry[] {
|
||||
return runtimeTrace(entries).filter((entry) => {
|
||||
if (entry.message.startsWith('bridge_sender_fact ')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (entry.message.startsWith('bridge_send_')) {
|
||||
return entry.message.includes('serviceCmd="Test.');
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function packet(pid: number, uin: string): PacketInfo {
|
||||
return {
|
||||
pid,
|
||||
uin,
|
||||
serviceCmd: 'Test.Unhandled',
|
||||
seqId: 1,
|
||||
retCode: 0,
|
||||
fromClient: false,
|
||||
body: Buffer.alloc(0),
|
||||
};
|
||||
}
|
||||
|
||||
describe('BridgeManager PID ownership', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(IdentityService, 'openForUin').mockImplementation((uin) =>
|
||||
IdentityService.memory(uin),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setLogLevel(previousLogLevel);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('traces binding, account replacement, detach, and session closure under semantic contexts', () => {
|
||||
const manager = new BridgeManager();
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
|
||||
try {
|
||||
manager.onHookLogin(101, '10001', makeSender().client);
|
||||
manager.onHookLogin(202, '10001', makeSender().client);
|
||||
manager.onHookLogin(101, '20002', makeSender().client);
|
||||
manager.onPidDisconnected(202);
|
||||
manager.onPidDisconnected(101);
|
||||
manager.onPidDisconnected(101);
|
||||
|
||||
const trace = runtimeTrace(entries).filter(
|
||||
(entry) =>
|
||||
entry.message.startsWith('bridge_binding_') ||
|
||||
entry.message.startsWith('bridge_detach_'),
|
||||
);
|
||||
expect(trace.map((entry) => entry.message)).toEqual([
|
||||
'bridge_binding_start pid=101 uin="10001" source=login previousUin=null',
|
||||
'bridge_binding_branch pid=101 uin="10001" branch=session_created',
|
||||
expect.stringMatching(
|
||||
/^bridge_binding_terminal pid=101 uin="10001" source=login outcome=completed reason=bound created=true activePid=101 elapsedMs=\d+$/,
|
||||
),
|
||||
'bridge_binding_start pid=202 uin="10001" source=login previousUin=null',
|
||||
expect.stringMatching(
|
||||
/^bridge_binding_terminal pid=202 uin="10001" source=login outcome=completed reason=bound created=false activePid=202 elapsedMs=\d+$/,
|
||||
),
|
||||
'bridge_binding_start pid=101 uin="20002" source=login previousUin="10001"',
|
||||
'bridge_binding_branch pid=101 uin="20002" branch=account_rebind previousUin="10001"',
|
||||
'bridge_binding_branch pid=101 uin="20002" branch=previous_session_retained previousUin="10001" activePid=202',
|
||||
'bridge_binding_branch pid=101 uin="20002" branch=session_created',
|
||||
expect.stringMatching(
|
||||
/^bridge_binding_terminal pid=101 uin="20002" source=login outcome=completed reason=rebound created=true activePid=101 elapsedMs=\d+$/,
|
||||
),
|
||||
'bridge_detach_start pid=202 uin="10001"',
|
||||
'bridge_detach_branch pid=202 uin="10001" branch=session_closed',
|
||||
expect.stringMatching(
|
||||
/^bridge_detach_terminal pid=202 uin="10001" outcome=completed reason=session_closed activePid=null elapsedMs=\d+$/,
|
||||
),
|
||||
'bridge_detach_start pid=101 uin="20002"',
|
||||
'bridge_detach_branch pid=101 uin="20002" branch=session_closed',
|
||||
expect.stringMatching(
|
||||
/^bridge_detach_terminal pid=101 uin="20002" outcome=completed reason=session_closed activePid=null elapsedMs=\d+$/,
|
||||
),
|
||||
]);
|
||||
|
||||
const starts = trace.filter((entry) => entry.message.includes('_start '));
|
||||
const terminals = trace.filter((entry) => entry.message.includes('_terminal '));
|
||||
expect(starts).toHaveLength(5);
|
||||
expect(terminals).toHaveLength(5);
|
||||
|
||||
for (const start of starts) {
|
||||
expect(terminals.some((terminal) => terminal.req === start.req)).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('records ownership invariant failures as the unique detach terminal', () => {
|
||||
const manager = new BridgeManager();
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
|
||||
try {
|
||||
manager.onHookLogin(101, '10001', makeSender().client);
|
||||
manager.getBridge('10001')!.detachPid(101);
|
||||
expect(() => manager.onPidDisconnected(101)).toThrowError(
|
||||
'BridgeManager invariant violated: PID=101 is mapped to UIN=10001, but Bridge does not own the PID',
|
||||
);
|
||||
|
||||
const detach = runtimeTrace(entries).filter((entry) =>
|
||||
entry.message.startsWith('bridge_detach_'),
|
||||
);
|
||||
expect(detach.map((entry) => entry.message)).toEqual([
|
||||
'bridge_detach_start pid=101 uin="10001"',
|
||||
expect.stringMatching(
|
||||
/^bridge_detach_terminal pid=101 uin="10001" outcome=failed reason=invariant_violation error="BridgeManager invariant violated: PID=101 is mapped to UIN=10001, but Bridge does not own the PID" elapsedMs=\d+$/,
|
||||
),
|
||||
]);
|
||||
expect(detach[0]!.req).toBe(detach[1]!.req);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('records complete incoming packet bytes under one top-level TRACE request', async () => {
|
||||
const manager = new BridgeManager();
|
||||
const entries: LogEntry[] = [];
|
||||
const body = Buffer.from([
|
||||
0x00,
|
||||
0xff,
|
||||
...Array.from({ length: 160 }, (_, index) => (159 - index) & 0xff),
|
||||
]);
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
|
||||
try {
|
||||
manager.onPacket({
|
||||
pid: 101,
|
||||
uin: '10001',
|
||||
serviceCmd: 'Trace\nCommand',
|
||||
seqId: 987,
|
||||
retCode: -5,
|
||||
fromClient: true,
|
||||
body,
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const packetEntries = entries.filter(
|
||||
(entry) =>
|
||||
entry.level === 'trace' &&
|
||||
(entry.scope === 'Bridge.Packet' || entry.scope === 'Protocol.Packet'),
|
||||
);
|
||||
expect(packetEntries).toHaveLength(3);
|
||||
expect(packetEntries.map((entry) => entry.req)).toEqual([
|
||||
expect.any(Number),
|
||||
packetEntries[0]!.req,
|
||||
packetEntries[0]!.req,
|
||||
]);
|
||||
expect(packetEntries[0]!.message).toBe(
|
||||
`packet_push serviceCmd="Trace\\nCommand" seqId=987 retCode=-5 fromClient=true pid=101 uin="10001" length=${body.length} body=${body.toString('hex')}`,
|
||||
);
|
||||
expect(packetEntries[1]!.message).toBe(
|
||||
'packet_branch serviceCmd="Trace\\nCommand" seqId=987 branch=parser_unregistered',
|
||||
);
|
||||
expect(packetEntries[2]!.message).toMatch(
|
||||
/^packet_terminal serviceCmd="Trace\\nCommand" seqId=987 outcome=dropped reason=parser_unregistered events=0 dispatched=0 elapsedMs=\d+$/,
|
||||
);
|
||||
|
||||
for (const entry of packetEntries) {
|
||||
expect(entry.message).not.toContain('...');
|
||||
}
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('records invalid UIN as a routed packet drop without creating a session', () => {
|
||||
const manager = new BridgeManager();
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
|
||||
try {
|
||||
manager.onPacket({
|
||||
...packet(101, '0'),
|
||||
body: Buffer.from([0x00, 0xff]),
|
||||
});
|
||||
|
||||
expect(entries.filter((entry) => entry.scope === 'Bridge.Packet')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
level: 'trace',
|
||||
req: expect.any(Number),
|
||||
message:
|
||||
'packet_push serviceCmd="Test.Unhandled" seqId=1 retCode=0 fromClient=false pid=101 uin="0" length=2 body=00ff',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
level: 'trace',
|
||||
req: expect.any(Number),
|
||||
message: expect.stringMatching(
|
||||
/^packet_terminal serviceCmd="Test\.Unhandled" seqId=1 outcome=dropped reason=invalid_uin events=0 dispatched=0 elapsedMs=\d+$/,
|
||||
),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(manager.getBridge('0')).toBeNull();
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('assigns distinct request ids to independent incoming packets', () => {
|
||||
const manager = new BridgeManager();
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
|
||||
try {
|
||||
manager.onPacket(packet(101, '0'));
|
||||
manager.onPacket(packet(202, '0'));
|
||||
|
||||
const packetEntries = entries.filter(
|
||||
(entry) => entry.level === 'trace' && entry.scope === 'Bridge.Packet',
|
||||
);
|
||||
expect(packetEntries).toHaveLength(4);
|
||||
expect(packetEntries[0]!.req).toEqual(expect.any(Number));
|
||||
expect(packetEntries[1]!.req).toBe(packetEntries[0]!.req);
|
||||
expect(packetEntries[2]!.req).toEqual(expect.any(Number));
|
||||
expect(packetEntries[3]!.req).toBe(packetEntries[2]!.req);
|
||||
expect(packetEntries[2]!.req).not.toBe(packetEntries[0]!.req);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not render incoming packet bytes or allocate request IDs while TRACE is disabled', () => {
|
||||
const manager = new BridgeManager();
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('debug');
|
||||
const hex = vi.spyOn(Buffer.prototype, 'toString');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
|
||||
try {
|
||||
manager.onPacket({
|
||||
...packet(0, '10001'),
|
||||
body: Buffer.from([0x00, 0xff]),
|
||||
});
|
||||
|
||||
expect(hex).not.toHaveBeenCalledWith('hex');
|
||||
expect(entries.some((entry) => entry.req !== undefined)).toBe(false);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
hex.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('detaches a PID from its old UIN before starting its replacement session', () => {
|
||||
const manager = new BridgeManager();
|
||||
const first = makeSender();
|
||||
const second = makeSender();
|
||||
const lifecycle: string[] = [];
|
||||
manager.subscribeSessions({ started: (uin) => lifecycle.push(`started:${uin}`) });
|
||||
manager.subscribeSessions({ closed: (uin) => lifecycle.push(`closed:${uin}`) });
|
||||
|
||||
manager.onHookLogin(101, '10001', first.client);
|
||||
const oldBridge = manager.getBridge('10001')!;
|
||||
const dispose = vi.spyOn(oldBridge, 'dispose');
|
||||
|
||||
manager.onHookLogin(101, '20002', second.client);
|
||||
|
||||
expect(lifecycle).toEqual(['started:10001', 'closed:10001', 'started:20002']);
|
||||
expect(manager.getBridge('10001')).toBeNull();
|
||||
expect(oldBridge.hasPid(101)).toBe(false);
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
expect(manager.getBridge('20002')!.hasPid(101)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps a multi-PID UIN alive and falls back to the remaining sender', async () => {
|
||||
const manager = new BridgeManager();
|
||||
const first = makeSender();
|
||||
const second = makeSender();
|
||||
const closed = vi.fn();
|
||||
manager.subscribeSessions({ closed: closed });
|
||||
|
||||
manager.onHookLogin(101, '10001', first.client);
|
||||
manager.onHookLogin(202, '10001', second.client);
|
||||
const bridge = manager.getBridge('10001')!;
|
||||
|
||||
expect(bridge.activePid).toBe(202);
|
||||
await bridge.sendRawPacket('Test.BeforeDisconnect', new Uint8Array([1]));
|
||||
expect(testSends(second.sendPacket)).toHaveLength(1);
|
||||
expect(testSends(first.sendPacket)).toHaveLength(0);
|
||||
|
||||
manager.onPidDisconnected(202);
|
||||
|
||||
expect(manager.getBridge('10001')!).toBe(bridge);
|
||||
expect(bridge.activePid).toBe(101);
|
||||
expect(closed).not.toHaveBeenCalled();
|
||||
await bridge.sendRawPacket('Test.AfterDisconnect', new Uint8Array([2]));
|
||||
expect(testSends(first.sendPacket)).toHaveLength(1);
|
||||
expect(testSends(second.sendPacket)).toHaveLength(1);
|
||||
|
||||
manager.onPidDisconnected(101);
|
||||
expect(manager.getBridge('10001')).toBeNull();
|
||||
expect(closed).toHaveBeenCalledOnce();
|
||||
expect(closed).toHaveBeenCalledWith('10001', bridge, 101);
|
||||
await expect(
|
||||
bridge.sendRawPacket('Test.NoSender', new Uint8Array([3])),
|
||||
).resolves.toMatchObject({
|
||||
success: false,
|
||||
gotResponse: false,
|
||||
errorCode: -1,
|
||||
errorMessage: 'no packet sender attached',
|
||||
});
|
||||
});
|
||||
|
||||
it('traces material sender selection, fallback, and no-sender drops without polling noise', async () => {
|
||||
const manager = new BridgeManager();
|
||||
const first = makeSender();
|
||||
const second = makeSender();
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
|
||||
try {
|
||||
manager.onHookLogin(101, '10001', first.client);
|
||||
manager.onHookLogin(202, '10001', second.client);
|
||||
manager.onPacket(packet(202, '10001'));
|
||||
manager.onPidDisconnected(202);
|
||||
const bridge = manager.getBridge('10001')!;
|
||||
manager.onPidDisconnected(101);
|
||||
await bridge.sendRawPacket('Test.NoSender', new Uint8Array([0x00, 0xff]));
|
||||
|
||||
const sender = ownershipSendTrace(entries);
|
||||
expect(sender.map((entry) => entry.message)).toEqual([
|
||||
'bridge_sender_fact event=selected uin="10001" pid=101 previousPid=null',
|
||||
'bridge_sender_fact event=selected uin="10001" pid=202 previousPid=101',
|
||||
'bridge_sender_fact event=fallback uin="10001" detachedPid=202 fallbackPid=101',
|
||||
'bridge_send_start serviceCmd="Test.NoSender" uin="10001" activePid=null timeoutMs=15000 length=2 body=00ff',
|
||||
expect.stringMatching(
|
||||
/^bridge_send_terminal serviceCmd="Test\.NoSender" uin="10001" activePid=null outcome=dropped reason=no_sender length=2 body=00ff elapsedMs=\d+$/,
|
||||
),
|
||||
]);
|
||||
expect(sender[2]!.req).toBe(
|
||||
runtimeTrace(entries).find((entry) =>
|
||||
entry.message.startsWith('bridge_detach_start pid=202 '),
|
||||
)!.req,
|
||||
);
|
||||
expect(sender[3]!.req).toEqual(expect.any(Number));
|
||||
expect(sender[4]!.req).toBe(sender[3]!.req);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports an account healthy while any attached PID still receives packets', () => {
|
||||
const manager = new BridgeManager();
|
||||
manager.onHookLogin(101, '10001', makeSender().client);
|
||||
manager.onHookLogin(202, '10001', makeSender().client);
|
||||
const bridge = manager.getBridge('10001')!;
|
||||
|
||||
expect(bridge.receiveHealthy).toBe(true);
|
||||
|
||||
manager.onPidReceiveHealthChanged(101, false);
|
||||
expect(bridge.receiveHealthy).toBe(true);
|
||||
|
||||
manager.onPidReceiveHealthChanged(202, false);
|
||||
expect(bridge.receiveHealthy).toBe(false);
|
||||
|
||||
manager.onPidReceiveHealthChanged(101, true);
|
||||
expect(bridge.receiveHealthy).toBe(true);
|
||||
});
|
||||
|
||||
it('prefers the most recently rebound live PID, then falls back by recency', async () => {
|
||||
const manager = new BridgeManager();
|
||||
const first = makeSender();
|
||||
const second = makeSender();
|
||||
const third = makeSender();
|
||||
const reboundFirst = makeSender();
|
||||
const started = vi.fn();
|
||||
manager.subscribeSessions({ started: started });
|
||||
|
||||
manager.onHookLogin(101, '10001', first.client);
|
||||
manager.onHookLogin(202, '10001', second.client);
|
||||
manager.onHookLogin(303, '10001', third.client);
|
||||
manager.onHookLogin(101, '10001', reboundFirst.client);
|
||||
const bridge = manager.getBridge('10001')!;
|
||||
|
||||
expect(started).toHaveBeenCalledOnce();
|
||||
expect(bridge.activePid).toBe(101);
|
||||
await bridge.sendRawPacket('Test.Rebound', new Uint8Array([1]));
|
||||
expect(testSends(reboundFirst.sendPacket)).toHaveLength(1);
|
||||
|
||||
manager.onPidDisconnected(101);
|
||||
expect(bridge.activePid).toBe(303);
|
||||
await bridge.sendRawPacket('Test.Fallback', new Uint8Array([2]));
|
||||
expect(testSends(third.sendPacket)).toHaveLength(1);
|
||||
|
||||
manager.onPidDisconnected(303);
|
||||
expect(bridge.activePid).toBe(202);
|
||||
await bridge.sendRawPacket('Test.SecondFallback', new Uint8Array([3]));
|
||||
expect(testSends(second.sendPacket)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('applies the same ownership transition when a packet reveals a new UIN', async () => {
|
||||
const manager = new BridgeManager();
|
||||
const sender = makeSender();
|
||||
const lifecycle: string[] = [];
|
||||
manager.subscribeSessions({ started: (uin) => lifecycle.push(`started:${uin}`) });
|
||||
manager.subscribeSessions({ closed: (uin) => lifecycle.push(`closed:${uin}`) });
|
||||
|
||||
manager.onHookLogin(101, '10001', sender.client);
|
||||
manager.onPacket(packet(101, '20002'));
|
||||
|
||||
expect(lifecycle).toEqual(['started:10001', 'closed:10001', 'started:20002']);
|
||||
expect(manager.getBridge('10001')).toBeNull();
|
||||
const replacement = manager.getBridge('20002')!;
|
||||
expect(replacement.hasPid(101)).toBe(true);
|
||||
await replacement.sendRawPacket('Test.AfterPacketRebind', new Uint8Array([1]));
|
||||
expect(testSends(sender.sendPacket)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps the old UIN alive when its active PID moves and a fallback remains', async () => {
|
||||
const manager = new BridgeManager();
|
||||
const fallback = makeSender();
|
||||
const moving = makeSender();
|
||||
const rebound = makeSender();
|
||||
const lifecycle: string[] = [];
|
||||
manager.subscribeSessions({ started: (uin) => lifecycle.push(`started:${uin}`) });
|
||||
manager.subscribeSessions({ closed: (uin) => lifecycle.push(`closed:${uin}`) });
|
||||
|
||||
manager.onHookLogin(101, '10001', fallback.client);
|
||||
manager.onHookLogin(202, '10001', moving.client);
|
||||
const oldBridge = manager.getBridge('10001')!;
|
||||
expect(oldBridge.activePid).toBe(202);
|
||||
|
||||
manager.onHookLogin(202, '20002', rebound.client);
|
||||
|
||||
expect(lifecycle).toEqual(['started:10001', 'started:20002']);
|
||||
expect(manager.getBridge('10001')!).toBe(oldBridge);
|
||||
expect(oldBridge.hasPid(202)).toBe(false);
|
||||
expect(oldBridge.activePid).toBe(101);
|
||||
await oldBridge.sendRawPacket('Test.OldUinFallback', new Uint8Array([1]));
|
||||
expect(testSends(fallback.sendPacket)).toHaveLength(1);
|
||||
|
||||
const newBridge = manager.getBridge('20002')!;
|
||||
expect(newBridge.activePid).toBe(202);
|
||||
await newBridge.sendRawPacket('Test.NewUinSender', new Uint8Array([2]));
|
||||
expect(testSends(rebound.sendPacket)).toHaveLength(1);
|
||||
|
||||
manager.onPidDisconnected(202);
|
||||
expect(lifecycle).toEqual(['started:10001', 'started:20002', 'closed:20002']);
|
||||
manager.onPidDisconnected(101);
|
||||
expect(lifecycle).toEqual([
|
||||
'started:10001',
|
||||
'started:20002',
|
||||
'closed:20002',
|
||||
'closed:10001',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not emit another started edge when a PID joins an existing UIN', async () => {
|
||||
const manager = new BridgeManager();
|
||||
const first = makeSender();
|
||||
const second = makeSender();
|
||||
const reboundFirst = makeSender();
|
||||
const started = vi.fn();
|
||||
const closed = vi.fn();
|
||||
manager.subscribeSessions({ started: started });
|
||||
manager.subscribeSessions({ closed: closed });
|
||||
|
||||
manager.onHookLogin(101, '10001', first.client);
|
||||
manager.onHookLogin(202, '20002', second.client);
|
||||
const targetBridge = manager.getBridge('20002')!;
|
||||
|
||||
manager.onHookLogin(101, '20002', reboundFirst.client);
|
||||
|
||||
expect(started).toHaveBeenCalledTimes(2);
|
||||
expect(closed).toHaveBeenCalledOnce();
|
||||
expect(closed).toHaveBeenCalledWith('10001', expect.anything(), 101);
|
||||
expect(manager.getBridge('10001')).toBeNull();
|
||||
expect(manager.getBridge('20002')!).toBe(targetBridge);
|
||||
expect(targetBridge.activePid).toBe(101);
|
||||
|
||||
manager.onPidDisconnected(101);
|
||||
expect(targetBridge.activePid).toBe(202);
|
||||
await targetBridge.sendRawPacket('Test.ExistingTargetFallback', new Uint8Array([1]));
|
||||
expect(testSends(second.sendPacket)).toHaveLength(1);
|
||||
expect(closed).toHaveBeenCalledOnce();
|
||||
|
||||
manager.onPidDisconnected(202);
|
||||
expect(closed).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('emits exactly one started/closed pair per real lifecycle', () => {
|
||||
const manager = new BridgeManager();
|
||||
const sender = makeSender();
|
||||
const started = vi.fn();
|
||||
const closed = vi.fn();
|
||||
manager.subscribeSessions({ started: started });
|
||||
manager.subscribeSessions({ closed: closed });
|
||||
|
||||
manager.onHookLogin(101, '10001', sender.client);
|
||||
manager.onHookLogin(101, '10001', sender.client);
|
||||
manager.onPacket(packet(101, '10001'));
|
||||
manager.onPacket(packet(101, '10001'));
|
||||
|
||||
expect(started).toHaveBeenCalledOnce();
|
||||
manager.onPidDisconnected(101);
|
||||
manager.onPidDisconnected(101);
|
||||
expect(closed).toHaveBeenCalledOnce();
|
||||
|
||||
manager.onHookLogin(101, '10001', sender.client);
|
||||
manager.onPidDisconnected(101);
|
||||
expect(started).toHaveBeenCalledTimes(2);
|
||||
expect(closed).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('fails fast with a routing terminal when Manager ownership has no sender', () => {
|
||||
const manager = new BridgeManager();
|
||||
const sender = makeSender();
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
|
||||
try {
|
||||
manager.onHookLogin(101, '10001', sender.client);
|
||||
|
||||
// Remove the connection without updating the manager's ownership index.
|
||||
manager.getBridge('10001')!.detachPid(101);
|
||||
|
||||
expect(() => manager.onPacket(packet(101, '10001'))).toThrowError(
|
||||
'BridgeManager invariant violated: PID=101 has no sender in UIN=10001 session',
|
||||
);
|
||||
const packetEntries = entries.filter(
|
||||
(entry) => entry.level === 'trace' && entry.scope === 'Bridge.Packet',
|
||||
);
|
||||
expect(packetEntries).toHaveLength(2);
|
||||
expect(packetEntries[0]!.req).toEqual(expect.any(Number));
|
||||
expect(packetEntries[1]).toMatchObject({
|
||||
req: packetEntries[0]!.req,
|
||||
message: expect.stringMatching(
|
||||
/^packet_terminal serviceCmd="Test\.Unhandled" seqId=1 outcome=failed reason=routing_failed error="BridgeManager invariant violated: PID=101 has no sender in UIN=10001 session" events=0 dispatched=0 elapsedMs=\d+$/,
|
||||
),
|
||||
});
|
||||
expect(() => manager.onPidDisconnected(101)).toThrowError(
|
||||
'BridgeManager invariant violated: PID=101 is mapped to UIN=10001, but Bridge does not own the PID',
|
||||
);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { setImmediate as nextTurn } from 'node:timers/promises';
|
||||
import { IdentityService } from '@snowluma/protocol/identity-service';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { PacketInfo } from '@snowluma/common/protocol-types';
|
||||
import { BridgeManager } from '../src/bridge/manager';
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<void>((yes, no) => {
|
||||
resolve = yes;
|
||||
reject = no;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe('BridgeManager shutdown', () => {
|
||||
let manager: BridgeManager;
|
||||
const sender = { sendPacket: vi.fn() } as never;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(IdentityService, 'openForUin').mockImplementation((uin, _root, beforeOpen) => {
|
||||
void beforeOpen?.catch(() => undefined);
|
||||
return IdentityService.memory(uin);
|
||||
});
|
||||
manager = new BridgeManager();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await manager.dispose().catch(() => undefined);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('drains every detached session for one account and reports each failure once', async () => {
|
||||
const previous = deferred();
|
||||
const latest = deferred();
|
||||
const firstError = new Error('previous close failed');
|
||||
const lastError = new Error('latest close failed');
|
||||
manager.onHookLogin(101, '10001', sender);
|
||||
vi.spyOn(manager.getBridge('10001')!.identity, 'close').mockReturnValue(previous.promise);
|
||||
manager.onPidDisconnected(101);
|
||||
previous.reject(firstError);
|
||||
await nextTurn();
|
||||
manager.onHookLogin(202, '10001', sender);
|
||||
vi.spyOn(manager.getBridge('10001')!.identity, 'close').mockReturnValue(latest.promise);
|
||||
manager.onPidDisconnected(202);
|
||||
let settled = false;
|
||||
const result = manager
|
||||
.dispose()
|
||||
.catch((error: AggregateError) => error)
|
||||
.then((error) => {
|
||||
settled = true;
|
||||
return error;
|
||||
});
|
||||
|
||||
try {
|
||||
await nextTurn();
|
||||
expect(settled).toBe(false);
|
||||
} finally {
|
||||
latest.reject(lastError);
|
||||
}
|
||||
|
||||
const error = await result;
|
||||
expect(error).toBeInstanceOf(AggregateError);
|
||||
expect((error as AggregateError).errors).toEqual([firstError, lastError]);
|
||||
});
|
||||
|
||||
it('includes the session whose close observer requested shutdown', async () => {
|
||||
const cleanup = deferred();
|
||||
let shutdown!: Promise<void>;
|
||||
let settled = false;
|
||||
manager.onHookLogin(101, '10001', sender);
|
||||
vi.spyOn(manager.getBridge('10001')!.identity, 'close').mockReturnValue(cleanup.promise);
|
||||
manager.subscribeSessions({
|
||||
closed: () => {
|
||||
shutdown = manager.dispose().then(() => {
|
||||
settled = true;
|
||||
});
|
||||
},
|
||||
});
|
||||
manager.onPidDisconnected(101);
|
||||
|
||||
try {
|
||||
await nextTurn();
|
||||
expect(settled).toBe(false);
|
||||
} finally {
|
||||
cleanup.resolve();
|
||||
await shutdown;
|
||||
}
|
||||
});
|
||||
|
||||
it('waits for every previous close before opening the account again', async () => {
|
||||
const first = deferred();
|
||||
const second = deferred();
|
||||
manager.onHookLogin(101, '10001', sender);
|
||||
vi.spyOn(manager.getBridge('10001')!.identity, 'close').mockReturnValue(first.promise);
|
||||
manager.onPidDisconnected(101);
|
||||
manager.onHookLogin(202, '10001', sender);
|
||||
vi.spyOn(manager.getBridge('10001')!.identity, 'close').mockReturnValue(second.promise);
|
||||
manager.onPidDisconnected(202);
|
||||
manager.onHookLogin(303, '10001', sender);
|
||||
const beforeOpen = vi.mocked(IdentityService.openForUin).mock.calls.at(-1)![2]!;
|
||||
let ready = false;
|
||||
const opening = beforeOpen.then(() => {
|
||||
ready = true;
|
||||
});
|
||||
|
||||
try {
|
||||
second.resolve();
|
||||
await nextTurn();
|
||||
expect(ready).toBe(false);
|
||||
} finally {
|
||||
first.resolve();
|
||||
await opening;
|
||||
}
|
||||
});
|
||||
|
||||
it('reopening is not poisoned by an already-failed close', async () => {
|
||||
const failing = deferred();
|
||||
manager.onHookLogin(101, '10001', sender);
|
||||
vi.spyOn(manager.getBridge('10001')!.identity, 'close').mockReturnValue(failing.promise);
|
||||
manager.onPidDisconnected(101);
|
||||
failing.reject(new Error('close failed'));
|
||||
await nextTurn();
|
||||
|
||||
// The failed close stays in the bookkeeping (dispose still reports
|
||||
// it), but the reopening gate must absorb the rejection instead of
|
||||
// passing it down — a rejected gate would keep the identity DB of
|
||||
// every future session for this UIN closed until process restart.
|
||||
manager.onHookLogin(202, '10001', sender);
|
||||
const beforeOpen = vi.mocked(IdentityService.openForUin).mock.calls.at(-1)![2]!;
|
||||
await expect(beforeOpen).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('reopening waits for all closes to settle even when one of them fails', async () => {
|
||||
const failing = deferred();
|
||||
const slow = deferred();
|
||||
manager.onHookLogin(101, '10001', sender);
|
||||
vi.spyOn(manager.getBridge('10001')!.identity, 'close').mockReturnValue(failing.promise);
|
||||
manager.onPidDisconnected(101);
|
||||
manager.onHookLogin(202, '10001', sender);
|
||||
vi.spyOn(manager.getBridge('10001')!.identity, 'close').mockReturnValue(slow.promise);
|
||||
manager.onPidDisconnected(202);
|
||||
|
||||
failing.reject(new Error('close failed'));
|
||||
await nextTurn();
|
||||
|
||||
manager.onHookLogin(303, '10001', sender);
|
||||
const beforeOpen = vi.mocked(IdentityService.openForUin).mock.calls.at(-1)![2]!;
|
||||
let ready = false;
|
||||
const opening = beforeOpen.then(() => {
|
||||
ready = true;
|
||||
});
|
||||
|
||||
try {
|
||||
await nextTurn();
|
||||
// Still waiting for the pending close...
|
||||
expect(ready).toBe(false);
|
||||
} finally {
|
||||
slow.resolve();
|
||||
}
|
||||
|
||||
// ...and the already-failed close must not reject the gate.
|
||||
await opening;
|
||||
});
|
||||
|
||||
it('ignores late transport callbacks after shutdown starts', async () => {
|
||||
const shutdown = manager.dispose();
|
||||
manager.onHookLogin(101, '10001', sender);
|
||||
manager.onPacket({
|
||||
pid: 202,
|
||||
uin: '20002',
|
||||
serviceCmd: 'Test.Unhandled',
|
||||
seqId: 1,
|
||||
retCode: 0,
|
||||
fromClient: false,
|
||||
body: Buffer.alloc(0),
|
||||
} satisfies PacketInfo);
|
||||
manager.onPidReceiveHealthChanged(101, false);
|
||||
await shutdown;
|
||||
expect(IdentityService.openForUin).not.toHaveBeenCalled();
|
||||
expect(manager.getBridge('10001')).toBeNull();
|
||||
expect(manager.getBridge('20002')).toBeNull();
|
||||
});
|
||||
});
|
||||
Loaded 100 of 1367 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user