From 1cb526bc67eb4efbddb24657fb115f4972645012 Mon Sep 17 00:00:00 2001 From: kkkzbh Date: Thu, 18 Jun 2026 01:44:17 +0800 Subject: [PATCH 1/2] feat: add remote path mappings --- src/common/types.ts | 11 ++ src/common/utils/index.ts | 1 + src/common/utils/pathMapping.ts | 129 +++++++++++++++++++++ src/main/config/defaultConfig.ts | 1 + src/main/config/default_config.json | 1 + src/ntqqapi/api/file.ts | 69 +++++++---- src/ntqqapi/core.ts | 5 +- src/ntqqapi/entities.ts | 17 +-- src/onebot11/action/llbot/system/Config.ts | 7 ++ src/satori/message.ts | 2 +- src/webui/BE/routes/webqq/proxy.ts | 4 +- test/unit/ntqqFileApi.test.ts | 37 ++++++ test/unit/pathMapping.test.ts | 53 +++++++++ 13 files changed, 304 insertions(+), 33 deletions(-) create mode 100644 src/common/utils/pathMapping.ts create mode 100644 test/unit/ntqqFileApi.test.ts create mode 100644 test/unit/pathMapping.test.ts diff --git a/src/common/types.ts b/src/common/types.ts index df71bc1da..fdd285c37 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -119,12 +119,23 @@ export interface EmailConfig { to: string } +export type PathStyle = 'posix' | 'win32' + +export interface RemotePathMapping { + name?: string + remotePrefix: string + localPrefix: string + remoteStyle: PathStyle + localStyle: PathStyle +} + export interface Config { milky: MilkyConfig satori: SatoriConfig ob11: OB11Config webui: WebUIConfig email?: EmailConfig + remotePathMappings: RemotePathMapping[] // onlyLocalhost: boolean enableLocalFile2Url?: boolean // 开启后,本地文件路径图片会转成http链接, 语音会转成base64 log?: boolean diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index f0528c87f..7b1c9c098 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -1,4 +1,5 @@ export * from './file' +export * from './pathMapping' export * from './misc' export * from './misc' export { getVideoInfo } from './video' diff --git a/src/common/utils/pathMapping.ts b/src/common/utils/pathMapping.ts new file mode 100644 index 000000000..0a98678e0 --- /dev/null +++ b/src/common/utils/pathMapping.ts @@ -0,0 +1,129 @@ +import path from 'node:path' +import { PathStyle, RemotePathMapping } from '@/common/types' + +type PathModule = typeof path.posix + +const pathModules: Record = { + posix: path.posix, + win32: path.win32, +} + +export interface NormalizedRemotePathMapping extends RemotePathMapping { + remotePrefix: string + localPrefix: string +} + +interface Direction { + sourcePrefix: string + sourceStyle: PathStyle + targetPrefix: string + targetStyle: PathStyle +} + +export function normalizeRemotePathMappings(mappings: readonly RemotePathMapping[] = []): NormalizedRemotePathMapping[] { + return mappings.map(mapping => { + const remoteStyle = mapping.remoteStyle + const localStyle = mapping.localStyle + + return { + ...mapping, + remoteStyle, + localStyle, + remotePrefix: normalizePrefix(mapping.remotePrefix, remoteStyle, 'remotePrefix'), + localPrefix: normalizePrefix(mapping.localPrefix, localStyle, 'localPrefix'), + } + }) +} + +export function mapRemotePathToLocal(filePath: string, mappings: readonly RemotePathMapping[] = []): string { + return mapPath(filePath, normalizeRemotePathMappings(mappings).map(mapping => ({ + sourcePrefix: mapping.remotePrefix, + sourceStyle: mapping.remoteStyle, + targetPrefix: mapping.localPrefix, + targetStyle: mapping.localStyle, + }))) +} + +export function mapLocalPathToRemote(filePath: string, mappings: readonly RemotePathMapping[] = []): string { + return mapPath(filePath, normalizeRemotePathMappings(mappings).map(mapping => ({ + sourcePrefix: mapping.localPrefix, + sourceStyle: mapping.localStyle, + targetPrefix: mapping.remotePrefix, + targetStyle: mapping.remoteStyle, + }))) +} + +function normalizePrefix(prefix: string, style: PathStyle, fieldName: string) { + if (!prefix) { + throw new Error(`remote path mapping ${fieldName} must not be empty`) + } + + const pathModule = pathModules[style] + const normalized = stripTrailingSeparators(pathModule.normalize(prefix), style) + if (!pathModule.isAbsolute(normalized)) { + throw new Error(`remote path mapping ${fieldName} must be an absolute ${style} path: ${prefix}`) + } + return normalized +} + +function mapPath(filePath: string, directions: Direction[]) { + let matched: Direction | undefined + let normalizedSourcePath = '' + + for (const direction of directions) { + const pathModule = pathModules[direction.sourceStyle] + const candidate = pathModule.normalize(filePath) + if (!pathModule.isAbsolute(candidate)) { + continue + } + if (!isPrefixMatch(candidate, direction.sourcePrefix, direction.sourceStyle)) { + continue + } + if (!matched || direction.sourcePrefix.length > matched.sourcePrefix.length) { + matched = direction + normalizedSourcePath = candidate + } + } + + if (!matched) { + return filePath + } + + const rest = normalizedSourcePath.slice(matched.sourcePrefix.length) + const restParts = splitPathRest(rest, matched.sourceStyle) + return pathModules[matched.targetStyle].normalize(pathModules[matched.targetStyle].join(matched.targetPrefix, ...restParts)) +} + +function isPrefixMatch(filePath: string, prefix: string, style: PathStyle) { + const normalizedFilePath = style === 'win32' ? filePath.toLowerCase() : filePath + const normalizedPrefix = style === 'win32' ? prefix.toLowerCase() : prefix + if (normalizedFilePath === normalizedPrefix) { + return true + } + + const root = pathModules[style].parse(prefix).root + if (prefix === root) { + return normalizedFilePath.startsWith(normalizedPrefix) + } + + return normalizedFilePath.startsWith(normalizedPrefix + pathModules[style].sep) +} + +function splitPathRest(rest: string, style: PathStyle) { + if (!rest) { + return [] + } + if (style === 'win32') { + return rest.split(/[\\/]+/).filter(Boolean) + } + return rest.split('/').filter(Boolean) +} + +function stripTrailingSeparators(input: string, style: PathStyle) { + const root = pathModules[style].parse(input).root + let output = input + while (output.length > root.length && output.endsWith(pathModules[style].sep)) { + output = output.slice(0, -1) + } + return output +} diff --git a/src/main/config/defaultConfig.ts b/src/main/config/defaultConfig.ts index 026c3e872..bf70fff0d 100644 --- a/src/main/config/defaultConfig.ts +++ b/src/main/config/defaultConfig.ts @@ -35,6 +35,7 @@ export const defaultConfig: Config = { milky: milkyDefault, satori: satoriDefault, ob11: ob11Default, + remotePathMappings: [], enableLocalFile2Url: false, log: true, autoDeleteFile: false, diff --git a/src/main/config/default_config.json b/src/main/config/default_config.json index 559886228..8275fb829 100644 --- a/src/main/config/default_config.json +++ b/src/main/config/default_config.json @@ -75,6 +75,7 @@ } ] }, + "remotePathMappings": [], "enableLocalFile2Url": false, "log": true, "autoDeleteFile": false, diff --git a/src/ntqqapi/api/file.ts b/src/ntqqapi/api/file.ts index 43b8cb755..ff6819b50 100644 --- a/src/ntqqapi/api/file.ts +++ b/src/ntqqapi/api/file.ts @@ -14,6 +14,8 @@ import { selfInfo } from '@/common/globalVars' import { FlashFileListItem, FlashFileSetInfo } from '@/ntqqapi/types/flashfile' import { HighwayHttpSession, HighwayTcpSession } from '../helper/highway' import { Media } from '../proto' +import { RemotePathMapping } from '@/common/types' +import { mapLocalPathToRemote, mapRemotePathToLocal, NormalizedRemotePathMapping, normalizeRemotePathMappings } from '@/common/utils/pathMapping' declare module 'cordis' { interface Context { @@ -22,13 +24,30 @@ declare module 'cordis' { } export class NTQQFileApi extends Service { - static inject = ['logger', 'pmhq'] + static inject = ['logger', 'pmhq', 'config'] rkeyManager: RkeyManager + private remotePathMappings: NormalizedRemotePathMapping[] = [] constructor(protected ctx: Context) { super(ctx, 'ntFileApi') this.rkeyManager = new RkeyManager(ctx, 'https://llob.linyuchen.net/rkey') + this.setRemotePathMappings(ctx.config.get().remotePathMappings) + ctx.on('llob/config-updated', input => { + this.setRemotePathMappings(input.remotePathMappings) + }) + } + + setRemotePathMappings(mappings: readonly RemotePathMapping[] = []) { + this.remotePathMappings = normalizeRemotePathMappings(mappings) + } + + remotePathToLocal(filePath: string) { + return mapRemotePathToLocal(filePath, this.remotePathMappings) + } + + localPathToRemote(filePath: string) { + return mapLocalPathToRemote(filePath, this.remotePathMappings) } async getVideoUrl(fileUuid: string, isGroup: boolean) { @@ -75,11 +94,13 @@ export class NTQQFileApi extends Service { fileName += ext ? '.' + ext : '' } const mediaPath = await this.getRichMediaFilePath(fileMd5, fileName, elementType, elementSubType) - await copyFile(filePath, mediaPath) + const localMediaPath = this.remotePathToLocal(mediaPath) + await copyFile(filePath, localMediaPath) return { md5: fileMd5, fileName, path: mediaPath, + localPath: localMediaPath, } } @@ -274,16 +295,18 @@ export class NTQQFileApi extends Service { } async uploadGroupVideo(groupCode: string, filePath: string, thumbPath: string) { - const result = await this.ctx.pmhq.getGroupVideoUploadInfo(groupCode, filePath, thumbPath) + const localFilePath = this.remotePathToLocal(filePath) + const localThumbPath = this.remotePathToLocal(thumbPath) + const result = await this.ctx.pmhq.getGroupVideoUploadInfo(groupCode, localFilePath, localThumbPath) const highwaySession = await this.ctx.pmhq.getHighwaySession() const maxBlockSize = 1024 * 1024 if (result.ext.uKey) { const { index } = result.ext.msgInfoBody[0] - result.ext.hash.fileSha1 = await calculateSha1StreamBytes(filePath) + result.ext.hash.fileSha1 = await calculateSha1StreamBytes(localFilePath) const trans = { uin: selfInfo.uin, cmd: 1005, - readable: createReadStream(filePath, { highWaterMark: maxBlockSize }), + readable: createReadStream(localFilePath, { highWaterMark: maxBlockSize }), sum: Buffer.from(index.info.md5HexStr, 'hex'), size: index.info.fileSize, ticket: highwaySession.sigSession, @@ -299,11 +322,11 @@ export class NTQQFileApi extends Service { } if (result.subExt.uKey) { const { index } = result.subExt.msgInfoBody[1] - result.subExt.hash.fileSha1 = await calculateSha1StreamBytes(thumbPath) + result.subExt.hash.fileSha1 = await calculateSha1StreamBytes(localThumbPath) const trans = { uin: selfInfo.uin, cmd: 1006, - readable: createReadStream(thumbPath, { highWaterMark: maxBlockSize }), + readable: createReadStream(localThumbPath, { highWaterMark: maxBlockSize }), sum: Buffer.from(index.info.md5HexStr, 'hex'), size: index.info.fileSize, ticket: highwaySession.sigSession, @@ -324,16 +347,18 @@ export class NTQQFileApi extends Service { } async uploadC2CVideo(peerUid: string, filePath: string, thumbPath: string) { - const result = await this.ctx.pmhq.getC2CVideoUploadInfo(peerUid, filePath, thumbPath) + const localFilePath = this.remotePathToLocal(filePath) + const localThumbPath = this.remotePathToLocal(thumbPath) + const result = await this.ctx.pmhq.getC2CVideoUploadInfo(peerUid, localFilePath, localThumbPath) const highwaySession = await this.ctx.pmhq.getHighwaySession() const maxBlockSize = 1024 * 1024 if (result.ext.uKey) { const { index } = result.ext.msgInfoBody[0] - result.ext.hash.fileSha1 = await calculateSha1StreamBytes(filePath) + result.ext.hash.fileSha1 = await calculateSha1StreamBytes(localFilePath) const trans = { uin: selfInfo.uin, cmd: 1001, - readable: createReadStream(filePath, { highWaterMark: maxBlockSize }), + readable: createReadStream(localFilePath, { highWaterMark: maxBlockSize }), sum: Buffer.from(index.info.md5HexStr, 'hex'), size: index.info.fileSize, ticket: highwaySession.sigSession, @@ -349,11 +374,11 @@ export class NTQQFileApi extends Service { } if (result.subExt.uKey) { const { index } = result.subExt.msgInfoBody[1] - result.subExt.hash.fileSha1 = await calculateSha1StreamBytes(thumbPath) + result.subExt.hash.fileSha1 = await calculateSha1StreamBytes(localThumbPath) const trans = { uin: selfInfo.uin, cmd: 1002, - readable: createReadStream(thumbPath, { highWaterMark: maxBlockSize }), + readable: createReadStream(localThumbPath, { highWaterMark: maxBlockSize }), sum: Buffer.from(index.info.md5HexStr, 'hex'), size: index.info.fileSize, ticket: highwaySession.sigSession, @@ -374,7 +399,8 @@ export class NTQQFileApi extends Service { } async uploadGroupFile(groupCode: string, filePath: string, fileName: string, parentFolderId = '/') { - const result = await this.ctx.pmhq.getGroupFileUploadInfo(groupCode, filePath, fileName, parentFolderId) + const localFilePath = this.remotePathToLocal(filePath) + const result = await this.ctx.pmhq.getGroupFileUploadInfo(groupCode, localFilePath, fileName, parentFolderId) if (!result.fileExist) { const highwaySession = await this.ctx.pmhq.getHighwaySession() const ext = Media.FileUploadExt.encode({ @@ -418,7 +444,7 @@ export class NTQQFileApi extends Service { const trans = { uin: selfInfo.uin, cmd: 71, - readable: createReadStream(filePath, { highWaterMark: maxBlockSize }), + readable: createReadStream(localFilePath, { highWaterMark: maxBlockSize }), sum: result.md5, size: result.fileSize, ticket: highwaySession.sigSession, @@ -439,7 +465,8 @@ export class NTQQFileApi extends Service { } async uploadC2CFile(peerUid: string, filePath: string, fileName: string) { - const result = await this.ctx.pmhq.getC2CFileUploadInfo(peerUid, filePath, fileName) + const localFilePath = this.remotePathToLocal(filePath) + const result = await this.ctx.pmhq.getC2CFileUploadInfo(peerUid, localFilePath, fileName) const highwaySession = await this.ctx.pmhq.getHighwaySession() const ext = Media.FileUploadExt.encode({ unknown1: 100, @@ -483,7 +510,7 @@ export class NTQQFileApi extends Service { const trans = { uin: selfInfo.uin, cmd: 95, - readable: createReadStream(filePath, { highWaterMark: maxBlockSize }), + readable: createReadStream(localFilePath, { highWaterMark: maxBlockSize }), sum: result.md5CheckSum, size: result.fileSize, ticket: highwaySession.sigSession, @@ -504,7 +531,8 @@ export class NTQQFileApi extends Service { } async uploadGroupImage(groupCode: string, filePath: string) { - const result = await this.ctx.pmhq.getGroupImageUploadInfo(groupCode, filePath) + const localFilePath = this.remotePathToLocal(filePath) + const result = await this.ctx.pmhq.getGroupImageUploadInfo(groupCode, localFilePath) const highwaySession = await this.ctx.pmhq.getHighwaySession() const maxBlockSize = 1024 * 1024 if (result.ext.uKey) { @@ -512,7 +540,7 @@ export class NTQQFileApi extends Service { const trans = { uin: selfInfo.uin, cmd: 1004, - readable: createReadStream(filePath, { highWaterMark: maxBlockSize }), + readable: createReadStream(localFilePath, { highWaterMark: maxBlockSize }), sum: Buffer.from(index.info.md5HexStr, 'hex'), size: index.info.fileSize, ticket: highwaySession.sigSession, @@ -533,7 +561,8 @@ export class NTQQFileApi extends Service { } async uploadC2CImage(peerUid: string, filePath: string) { - const result = await this.ctx.pmhq.getC2CImageUploadInfo(peerUid, filePath) + const localFilePath = this.remotePathToLocal(filePath) + const result = await this.ctx.pmhq.getC2CImageUploadInfo(peerUid, localFilePath) const highwaySession = await this.ctx.pmhq.getHighwaySession() const maxBlockSize = 1024 * 1024 if (result.ext.uKey) { @@ -541,7 +570,7 @@ export class NTQQFileApi extends Service { const trans = { uin: selfInfo.uin, cmd: 1003, - readable: createReadStream(filePath, { highWaterMark: maxBlockSize }), + readable: createReadStream(localFilePath, { highWaterMark: maxBlockSize }), sum: Buffer.from(index.info.md5HexStr, 'hex'), size: index.info.fileSize, ticket: highwaySession.sigSession, diff --git a/src/ntqqapi/core.ts b/src/ntqqapi/core.ts index cc0123cc2..fb3536832 100644 --- a/src/ntqqapi/core.ts +++ b/src/ntqqapi/core.ts @@ -100,7 +100,7 @@ class Core extends Service { this.messageSentCount++ ctx.logger.info('消息发送', peer) deleteAfterSentFiles.forEach(path => { - unlink(path).catch(noop) + unlink(ctx.ntFileApi.remotePathToLocal(path)).catch(noop) }) return returnMsg } @@ -151,7 +151,8 @@ class Core extends Service { setTimeout(() => { for (const path of allPaths) { if (path) { - unlink(path).then(() => this.ctx.logger.info('删除文件成功', path)).catch(noop) + const localPath = this.ctx.ntFileApi.remotePathToLocal(path) + unlink(localPath).then(() => this.ctx.logger.info('删除文件成功', localPath)).catch(noop) } } }, this.config.autoDeleteFileSecond! * 1000) diff --git a/src/ntqqapi/entities.ts b/src/ntqqapi/entities.ts index 3a726e7ef..e9eab4999 100644 --- a/src/ntqqapi/entities.ts +++ b/src/ntqqapi/entities.ts @@ -125,35 +125,36 @@ export namespace SendElement { if (fileSize > 1024 * 1024 * maxMB) { throw new Error(`视频过大,最大支持${maxMB}MB,当前文件大小${fileSize}B`) } - const { fileName, path, md5 } = await ctx.ntFileApi.uploadFile(filePath, ElementType.Video) + const { fileName, path, localPath, md5 } = await ctx.ntFileApi.uploadFile(filePath, ElementType.Video) let videoInfo = { width: 1920, height: 1080, time: 15, format: 'mp4', size: fileSize, - filePath, + filePath: localPath, } try { - videoInfo = await getVideoInfo(path) + videoInfo = await getVideoInfo(localPath) ctx.logger.info('视频信息', videoInfo) } catch (e) { ctx.logger.info('获取视频信息失败', e) } const thumbDir = pathLib.dirname(path.replaceAll('\\', '/').replace(`/Ori/`, `/Thumb/`)) const thumbFilePath = pathLib.join(thumbDir, `${md5}_0.png`) - await mkdir(thumbDir, { recursive: true }) + const localThumbFilePath = ctx.ntFileApi.remotePathToLocal(thumbFilePath) + await mkdir(pathLib.dirname(localThumbFilePath), { recursive: true }) if (diyThumbPath) { - await copyFile(diyThumbPath, thumbFilePath) + await copyFile(diyThumbPath, localThumbFilePath) } else { const path = await createThumb(ctx, videoInfo.filePath) - await copyFile(path, thumbFilePath) + await copyFile(path, localThumbFilePath) unlink(path).catch(noop) } const thumbPath = new Map() - const thumbSize = (await stat(thumbFilePath)).size + const thumbSize = (await stat(localThumbFilePath)).size thumbPath.set(0, thumbFilePath) - const thumbMd5 = await getMd5HexFromFile(thumbFilePath) + const thumbMd5 = await getMd5HexFromFile(localThumbFilePath) const element: SendVideoElement = { elementType: ElementType.Video, elementId: '', diff --git a/src/onebot11/action/llbot/system/Config.ts b/src/onebot11/action/llbot/system/Config.ts index aa609c805..9547ec202 100644 --- a/src/onebot11/action/llbot/system/Config.ts +++ b/src/onebot11/action/llbot/system/Config.ts @@ -25,6 +25,13 @@ export class SetConfigAction extends BaseAction { ffmpeg: Schema.string(), musicSignUrl: Schema.string(), msgCacheExpire: Schema.number(), + remotePathMappings: Schema.array(Schema.object({ + name: Schema.string(), + remotePrefix: Schema.string(), + localPrefix: Schema.string(), + remoteStyle: Schema.union(['posix', 'win32']), + localStyle: Schema.union(['posix', 'win32']), + })), rawMsgPB: Schema.boolean() }) diff --git a/src/satori/message.ts b/src/satori/message.ts index eeecd856b..990511b7c 100644 --- a/src/satori/message.ts +++ b/src/satori/message.ts @@ -62,7 +62,7 @@ export class MessageEncoder { } } this.deleteAfterSentFiles.forEach(path => { - unlink(path).catch(noop) + unlink(this.ctx.ntFileApi.remotePathToLocal(path)).catch(noop) }) this.deleteAfterSentFiles = [] this.elements = [] diff --git a/src/webui/BE/routes/webqq/proxy.ts b/src/webui/BE/routes/webqq/proxy.ts index 5df2a7b33..5e918d490 100644 --- a/src/webui/BE/routes/webqq/proxy.ts +++ b/src/webui/BE/routes/webqq/proxy.ts @@ -16,7 +16,7 @@ export function createProxyRoutes(ctx: Context): Hono { return c.json({ success: false, message: '缺少文件路径参数' }, 400) } - const normalizedPath = path.normalize(filePath) + const normalizedPath = path.normalize(ctx.ntFileApi.remotePathToLocal(filePath)) if (!existsSync(normalizedPath)) { return c.json({ success: false, message: '文件不存在' }, 404) } @@ -130,7 +130,7 @@ export function createProxyRoutes(ctx: Context): Hono { // 优先使用本地文件路径 if (filePath) { - const decodedPath = decodeURIComponent(filePath) + const decodedPath = ctx.ntFileApi.remotePathToLocal(decodeURIComponent(filePath)) try { await fs.access(decodedPath) audioFilePath = decodedPath diff --git a/test/unit/ntqqFileApi.test.ts b/test/unit/ntqqFileApi.test.ts new file mode 100644 index 000000000..81be03bc8 --- /dev/null +++ b/test/unit/ntqqFileApi.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import path from 'node:path' +import os from 'node:os' +import { ElementType } from '@/ntqqapi/types' +import { NTQQFileApi } from '@/ntqqapi/api/file' + +describe('NTQQFileApi', () => { + it('copies rich media to the mapped local path and keeps the PMHQ path in the send element payload', async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'llbot-file-api-')) + try { + const inputPath = path.join(tempDir, 'input.txt') + const localRoot = path.join(tempDir, 'qq-volume') + const remotePath = '/root/.config/QQ/nt_data/Pic/Ori/input.txt' + const localMediaPath = path.join(localRoot, 'nt_data/Pic/Ori/input.txt') + await mkdir(path.dirname(localMediaPath), { recursive: true }) + await writeFile(inputPath, 'mapped-media') + + const api = Object.create(NTQQFileApi.prototype) as NTQQFileApi + ;(api as any).setRemotePathMappings([{ + remotePrefix: '/root/.config/QQ', + localPrefix: localRoot, + remoteStyle: 'posix', + localStyle: 'posix', + }]) + api.getRichMediaFilePath = vi.fn(async () => remotePath) + + const uploaded = await api.uploadFile(inputPath, ElementType.Pic) + + expect(uploaded.path).toBe(remotePath) + expect(uploaded.localPath).toBe(localMediaPath) + expect(await readFile(localMediaPath, 'utf8')).toBe('mapped-media') + } finally { + await rm(tempDir, { recursive: true, force: true }) + } + }) +}) diff --git a/test/unit/pathMapping.test.ts b/test/unit/pathMapping.test.ts new file mode 100644 index 000000000..d4b63bd43 --- /dev/null +++ b/test/unit/pathMapping.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { mapLocalPathToRemote, mapRemotePathToLocal } from '@/common/utils/pathMapping' +import { RemotePathMapping } from '@/common/types' + +describe('remote path mappings', () => { + it('maps a PMHQ container path to a host-mounted local path', () => { + const mappings: RemotePathMapping[] = [{ + remotePrefix: '/root/.config/QQ', + localPrefix: '/var/lib/containers/storage/volumes/llbot_qq/_data', + remoteStyle: 'posix', + localStyle: 'posix', + }] + + expect(mapRemotePathToLocal('/root/.config/QQ/nt_data/Pic/Ori/a.png', mappings)) + .toBe('/var/lib/containers/storage/volumes/llbot_qq/_data/nt_data/Pic/Ori/a.png') + }) + + it('uses the longest matching prefix and enforces path boundaries', () => { + const mappings: RemotePathMapping[] = [ + { + remotePrefix: '/root/.config/QQ', + localPrefix: '/host/qq', + remoteStyle: 'posix', + localStyle: 'posix', + }, + { + remotePrefix: '/root/.config/QQ/nt_data', + localPrefix: '/host/qq-data', + remoteStyle: 'posix', + localStyle: 'posix', + }, + ] + + expect(mapRemotePathToLocal('/root/.config/QQ/nt_data/Pic/Ori/a.png', mappings)) + .toBe('/host/qq-data/Pic/Ori/a.png') + expect(mapRemotePathToLocal('/root/.config/QQ2/nt_data/Pic/Ori/a.png', mappings)) + .toBe('/root/.config/QQ2/nt_data/Pic/Ori/a.png') + }) + + it('maps between POSIX and Windows path styles', () => { + const mappings: RemotePathMapping[] = [{ + remotePrefix: '/root/.config/QQ', + localPrefix: 'D:\\QQProfile', + remoteStyle: 'posix', + localStyle: 'win32', + }] + + expect(mapRemotePathToLocal('/root/.config/QQ/nt_data/Pic/Ori/a.png', mappings)) + .toBe('D:\\QQProfile\\nt_data\\Pic\\Ori\\a.png') + expect(mapLocalPathToRemote('d:\\qqprofile\\nt_data\\Pic\\Ori\\a.png', mappings)) + .toBe('/root/.config/QQ/nt_data/Pic/Ori/a.png') + }) +}) From 0c07d9a626a9807c4272b8fc717e81fb3e683ab9 Mon Sep 17 00:00:00 2001 From: kkkzbh Date: Thu, 18 Jun 2026 02:08:07 +0800 Subject: [PATCH 2/2] fix: refine remote path mapping config and runtime mapper --- src/common/utils/pathMapping.ts | 36 ++++++++++++++---- src/ntqqapi/api/file.ts | 10 ++--- src/onebot11/action/llbot/system/Config.ts | 8 ++-- test/unit/configSchema.test.ts | 40 ++++++++++++++++++++ test/unit/ntqqFileApi.test.ts | 27 +++++++++++++ test/unit/pathMapping.test.ts | 44 +++++++++++++++++++--- 6 files changed, 143 insertions(+), 22 deletions(-) create mode 100644 test/unit/configSchema.test.ts diff --git a/src/common/utils/pathMapping.ts b/src/common/utils/pathMapping.ts index 0a98678e0..6faee1b9d 100644 --- a/src/common/utils/pathMapping.ts +++ b/src/common/utils/pathMapping.ts @@ -20,6 +20,11 @@ interface Direction { targetStyle: PathStyle } +export interface RemotePathMapper { + remotePathToLocal(filePath: string): string + localPathToRemote(filePath: string): string +} + export function normalizeRemotePathMappings(mappings: readonly RemotePathMapping[] = []): NormalizedRemotePathMapping[] { return mappings.map(mapping => { const remoteStyle = mapping.remoteStyle @@ -35,22 +40,37 @@ export function normalizeRemotePathMappings(mappings: readonly RemotePathMapping }) } -export function mapRemotePathToLocal(filePath: string, mappings: readonly RemotePathMapping[] = []): string { - return mapPath(filePath, normalizeRemotePathMappings(mappings).map(mapping => ({ +export function createRemotePathMapper(mappings: readonly RemotePathMapping[] = []): RemotePathMapper { + const normalizedMappings = normalizeRemotePathMappings(mappings) + const remoteToLocalDirections = normalizedMappings.map(mapping => ({ sourcePrefix: mapping.remotePrefix, sourceStyle: mapping.remoteStyle, targetPrefix: mapping.localPrefix, targetStyle: mapping.localStyle, - }))) -} - -export function mapLocalPathToRemote(filePath: string, mappings: readonly RemotePathMapping[] = []): string { - return mapPath(filePath, normalizeRemotePathMappings(mappings).map(mapping => ({ + })) + const localToRemoteDirections = normalizedMappings.map(mapping => ({ sourcePrefix: mapping.localPrefix, sourceStyle: mapping.localStyle, targetPrefix: mapping.remotePrefix, targetStyle: mapping.remoteStyle, - }))) + })) + + return { + remotePathToLocal(filePath: string) { + return mapPath(filePath, remoteToLocalDirections) + }, + localPathToRemote(filePath: string) { + return mapPath(filePath, localToRemoteDirections) + }, + } +} + +export function mapRemotePathToLocal(filePath: string, mappings: readonly RemotePathMapping[] = []): string { + return createRemotePathMapper(mappings).remotePathToLocal(filePath) +} + +export function mapLocalPathToRemote(filePath: string, mappings: readonly RemotePathMapping[] = []): string { + return createRemotePathMapper(mappings).localPathToRemote(filePath) } function normalizePrefix(prefix: string, style: PathStyle, fieldName: string) { diff --git a/src/ntqqapi/api/file.ts b/src/ntqqapi/api/file.ts index ff6819b50..15213a059 100644 --- a/src/ntqqapi/api/file.ts +++ b/src/ntqqapi/api/file.ts @@ -15,7 +15,7 @@ import { FlashFileListItem, FlashFileSetInfo } from '@/ntqqapi/types/flashfile' import { HighwayHttpSession, HighwayTcpSession } from '../helper/highway' import { Media } from '../proto' import { RemotePathMapping } from '@/common/types' -import { mapLocalPathToRemote, mapRemotePathToLocal, NormalizedRemotePathMapping, normalizeRemotePathMappings } from '@/common/utils/pathMapping' +import { createRemotePathMapper, RemotePathMapper } from '@/common/utils/pathMapping' declare module 'cordis' { interface Context { @@ -27,7 +27,7 @@ export class NTQQFileApi extends Service { static inject = ['logger', 'pmhq', 'config'] rkeyManager: RkeyManager - private remotePathMappings: NormalizedRemotePathMapping[] = [] + private remotePathMapper: RemotePathMapper = createRemotePathMapper() constructor(protected ctx: Context) { super(ctx, 'ntFileApi') @@ -39,15 +39,15 @@ export class NTQQFileApi extends Service { } setRemotePathMappings(mappings: readonly RemotePathMapping[] = []) { - this.remotePathMappings = normalizeRemotePathMappings(mappings) + this.remotePathMapper = createRemotePathMapper(mappings) } remotePathToLocal(filePath: string) { - return mapRemotePathToLocal(filePath, this.remotePathMappings) + return this.remotePathMapper.remotePathToLocal(filePath) } localPathToRemote(filePath: string) { - return mapLocalPathToRemote(filePath, this.remotePathMappings) + return this.remotePathMapper.localPathToRemote(filePath) } async getVideoUrl(fileUuid: string, isGroup: boolean) { diff --git a/src/onebot11/action/llbot/system/Config.ts b/src/onebot11/action/llbot/system/Config.ts index 9547ec202..e120a454c 100644 --- a/src/onebot11/action/llbot/system/Config.ts +++ b/src/onebot11/action/llbot/system/Config.ts @@ -27,10 +27,10 @@ export class SetConfigAction extends BaseAction { msgCacheExpire: Schema.number(), remotePathMappings: Schema.array(Schema.object({ name: Schema.string(), - remotePrefix: Schema.string(), - localPrefix: Schema.string(), - remoteStyle: Schema.union(['posix', 'win32']), - localStyle: Schema.union(['posix', 'win32']), + remotePrefix: Schema.string().required(), + localPrefix: Schema.string().required(), + remoteStyle: Schema.union(['posix', 'win32']).required(), + localStyle: Schema.union(['posix', 'win32']).required(), })), rawMsgPB: Schema.boolean() }) diff --git a/test/unit/configSchema.test.ts b/test/unit/configSchema.test.ts new file mode 100644 index 000000000..d24bf1977 --- /dev/null +++ b/test/unit/configSchema.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { SetConfigAction } from '@/onebot11/action/llbot/system/Config' + +function getSetConfigPayloadSchema() { + return new SetConfigAction({ ctx: {} } as any).payloadSchema! +} + +describe('set_config schema', () => { + it('accepts remote path mappings without a display name', () => { + const schema = getSetConfigPayloadSchema() + + expect(() => new schema({ + remotePathMappings: [{ + remotePrefix: '/root/.config/QQ', + localPrefix: '/host/qq', + remoteStyle: 'posix', + localStyle: 'posix', + }], + } as any)).not.toThrow() + }) + + it('requires the structural remote path mapping fields', () => { + const schema = getSetConfigPayloadSchema() + + expect(() => new schema({ + remotePathMappings: [{ + localPrefix: '/host/qq', + remoteStyle: 'posix', + localStyle: 'posix', + }], + } as any)).toThrow(/remotePrefix.*missing required value/) + expect(() => new schema({ + remotePathMappings: [{ + remotePrefix: '/root/.config/QQ', + localPrefix: '/host/qq', + localStyle: 'posix', + }], + } as any)).toThrow(/remoteStyle.*missing required value/) + }) +}) diff --git a/test/unit/ntqqFileApi.test.ts b/test/unit/ntqqFileApi.test.ts index 81be03bc8..0838b21fb 100644 --- a/test/unit/ntqqFileApi.test.ts +++ b/test/unit/ntqqFileApi.test.ts @@ -29,9 +29,36 @@ describe('NTQQFileApi', () => { expect(uploaded.path).toBe(remotePath) expect(uploaded.localPath).toBe(localMediaPath) + expect(api.localPathToRemote(localMediaPath)).toBe(remotePath) expect(await readFile(localMediaPath, 'utf8')).toBe('mapped-media') } finally { await rm(tempDir, { recursive: true, force: true }) } }) + + it('rebuilds its path mapper when remotePathMappings change', () => { + const api = Object.create(NTQQFileApi.prototype) as NTQQFileApi + + ;(api as any).setRemotePathMappings([{ + remotePrefix: '/root/.config/QQ', + localPrefix: '/host/qq', + remoteStyle: 'posix', + localStyle: 'posix', + }]) + expect(api.remotePathToLocal('/root/.config/QQ/nt_data/Pic/Ori/a.png')) + .toBe('/host/qq/nt_data/Pic/Ori/a.png') + expect(api.localPathToRemote('/host/qq/nt_data/Pic/Ori/a.png')) + .toBe('/root/.config/QQ/nt_data/Pic/Ori/a.png') + + ;(api as any).setRemotePathMappings([{ + remotePrefix: '/root/.config/QQ', + localPrefix: 'D:\\QQProfile', + remoteStyle: 'posix', + localStyle: 'win32', + }]) + expect(api.remotePathToLocal('/root/.config/QQ/nt_data/Pic/Ori/a.png')) + .toBe('D:\\QQProfile\\nt_data\\Pic\\Ori\\a.png') + expect(api.localPathToRemote('d:\\qqprofile\\nt_data\\Pic\\Ori\\a.png')) + .toBe('/root/.config/QQ/nt_data/Pic/Ori/a.png') + }) }) diff --git a/test/unit/pathMapping.test.ts b/test/unit/pathMapping.test.ts index d4b63bd43..09513b262 100644 --- a/test/unit/pathMapping.test.ts +++ b/test/unit/pathMapping.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { mapLocalPathToRemote, mapRemotePathToLocal } from '@/common/utils/pathMapping' +import { createRemotePathMapper, mapLocalPathToRemote, mapRemotePathToLocal } from '@/common/utils/pathMapping' import { RemotePathMapping } from '@/common/types' describe('remote path mappings', () => { @@ -11,7 +11,9 @@ describe('remote path mappings', () => { localStyle: 'posix', }] - expect(mapRemotePathToLocal('/root/.config/QQ/nt_data/Pic/Ori/a.png', mappings)) + const mapper = createRemotePathMapper(mappings) + + expect(mapper.remotePathToLocal('/root/.config/QQ/nt_data/Pic/Ori/a.png')) .toBe('/var/lib/containers/storage/volumes/llbot_qq/_data/nt_data/Pic/Ori/a.png') }) @@ -31,9 +33,11 @@ describe('remote path mappings', () => { }, ] - expect(mapRemotePathToLocal('/root/.config/QQ/nt_data/Pic/Ori/a.png', mappings)) + const mapper = createRemotePathMapper(mappings) + + expect(mapper.remotePathToLocal('/root/.config/QQ/nt_data/Pic/Ori/a.png')) .toBe('/host/qq-data/Pic/Ori/a.png') - expect(mapRemotePathToLocal('/root/.config/QQ2/nt_data/Pic/Ori/a.png', mappings)) + expect(mapper.remotePathToLocal('/root/.config/QQ2/nt_data/Pic/Ori/a.png')) .toBe('/root/.config/QQ2/nt_data/Pic/Ori/a.png') }) @@ -45,9 +49,39 @@ describe('remote path mappings', () => { localStyle: 'win32', }] + const mapper = createRemotePathMapper(mappings) + + expect(mapper.remotePathToLocal('/root/.config/QQ/nt_data/Pic/Ori/a.png')) + .toBe('D:\\QQProfile\\nt_data\\Pic\\Ori\\a.png') + expect(mapper.localPathToRemote('d:\\qqprofile\\nt_data\\Pic\\Ori\\a.png')) + .toBe('/root/.config/QQ/nt_data/Pic/Ori/a.png') + }) + + it('normalizes mapping prefixes when the mapper is created', () => { + const mapper = createRemotePathMapper([{ + remotePrefix: '/root/.config/QQ/', + localPrefix: '/host/qq/', + remoteStyle: 'posix', + localStyle: 'posix', + }]) + + expect(mapper.remotePathToLocal('/root/.config/QQ/nt_data/Pic/Ori/a.png')) + .toBe('/host/qq/nt_data/Pic/Ori/a.png') + expect(mapper.localPathToRemote('/host/qq/nt_data/Pic/Ori/a.png')) + .toBe('/root/.config/QQ/nt_data/Pic/Ori/a.png') + }) + + it('keeps one-shot mapping helpers for direct callers', () => { + const mappings: RemotePathMapping[] = [{ + remotePrefix: '/root/.config/QQ', + localPrefix: 'D:\\QQProfile', + remoteStyle: 'posix', + localStyle: 'win32', + }] + expect(mapRemotePathToLocal('/root/.config/QQ/nt_data/Pic/Ori/a.png', mappings)) .toBe('D:\\QQProfile\\nt_data\\Pic\\Ori\\a.png') - expect(mapLocalPathToRemote('d:\\qqprofile\\nt_data\\Pic\\Ori\\a.png', mappings)) + expect(mapLocalPathToRemote('D:\\QQProfile\\nt_data\\Pic\\Ori\\a.png', mappings)) .toBe('/root/.config/QQ/nt_data/Pic/Ori/a.png') }) })