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

This commit is contained in:
TanTan committed 2026-11-13 09:02:00 +08:00
commit fa9c73363d
1367 files changed
+355735

No files matched your search

+16
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
{
"webuiPort": 8080
}
+70
View File
@@ -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,
});
}
}
+734
View File
@@ -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;
}
}
+437
View File
@@ -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
+123
View File
@@ -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
+849
View File
@@ -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);
});
});
}
+108
View File
@@ -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
+117
View File
@@ -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;
}
}
+315
View File
@@ -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();
}
+324
View File
@@ -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);
}
}
+35
View File
@@ -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);
}
}
+502
View File
@@ -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;
}
+613
View File
@@ -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 };
+453
View File
@@ -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;
}
}
+2
View File
@@ -0,0 +1,2 @@
declare const __BUILD_WEBUI__: boolean;
declare const __PACKAGED__: boolean;
+264
View File
@@ -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);
});
}
+498
View File
@@ -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' };
}
}
+458
View File
@@ -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,
});
}
+482
View File
@@ -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);
}
}
+44
View File
@@ -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),
);
});
},
});
}
+159
View File
@@ -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(),
};
}
+236
View File
@@ -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('更新包版本或平台不匹配');
}
}
+273
View File
@@ -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;
}
+196
View File
@@ -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('&amp;', '&')
.replaceAll('&#39;', "'")
.replaceAll('&quot;', '"');
}
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();
}
}
+99
View File
@@ -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>>;
+125
View File
@@ -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,
);
}
+75
View File
@@ -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;
}
}
+419
View File
@@ -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;
}
}
+806
View File
@@ -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}`,
);
}
}
+132
View File
@@ -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,
}));
}
+274
View File
@@ -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 };
}
+37
View File
@@ -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`));
};
}
+246
View File
@@ -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 };
}
+68
View File
@@ -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`;
}
+356
View File
@@ -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}”的令牌容易被猜中;请重新生成随机令牌。`,
);
}
}
}
}
+57
View File
@@ -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) ?? '';
}
+442
View File
@@ -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
+111
View File
@@ -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',
},
});
}
+62
View File
@@ -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();
}
}
+120
View File
@@ -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());
},
};
}
+31
View File
@@ -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);
}
+370
View File
@@ -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;
}
+325
View File
@@ -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;
}
+86
View File
@@ -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 };
}
+451
View File
@@ -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;
}
}
+995
View File
@@ -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;
}
}
+80
View File
@@ -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;
}
+67
View File
@@ -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);
}
}
}
+183
View File
@@ -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);
});
});
+494
View File
@@ -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();
});
});
+385
View File
@@ -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');
});
});
+311
View File
@@ -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',
);
});
});
+99
View File
@@ -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');
});
});
+562
View File
@@ -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',
);
});
});
+405
View File
@@ -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