mirror of
https://github.com/chrononeko/chronocat.git
synced 2024-11-25 01:29:46 +00:00
refactor(api): extract commonFile()
This commit is contained in:
parent
6e9c44d316
commit
98e736f2de
216
packages/engine-chronocat-api/src/common/file.ts
Normal file
216
packages/engine-chronocat-api/src/common/file.ts
Normal file
@ -0,0 +1,216 @@
|
||||
import type { ChronocatContext } from '@chronocat/shell'
|
||||
import mime from 'mime/lite'
|
||||
import fetch from 'node-fetch'
|
||||
import { createReadStream, createWriteStream } from 'node:fs'
|
||||
import { copyFile, mkdir, unlink, writeFile } from 'node:fs/promises'
|
||||
import { basename, join } from 'node:path'
|
||||
import { finished } from 'node:stream/promises'
|
||||
import {
|
||||
getFileMd5,
|
||||
getFileSize,
|
||||
getFileType,
|
||||
getImageSizeFromPath,
|
||||
} from '../definitions/fsApi'
|
||||
import {
|
||||
getRichMediaFilePath,
|
||||
getRichMediaFilePathForGuild,
|
||||
} from '../definitions/msgService'
|
||||
import { generateToken16, qqVersion } from '../utils'
|
||||
import type { CommonFileResult } from './types'
|
||||
|
||||
const dispositionRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/
|
||||
|
||||
export const commonFile = async (
|
||||
ctx: ChronocatContext,
|
||||
urlString: string,
|
||||
fileInfo?: {
|
||||
fileName?: string | undefined
|
||||
fileMime?: string | undefined
|
||||
},
|
||||
): Promise<CommonFileResult> => {
|
||||
const url = new URL(urlString)
|
||||
|
||||
let { fileName, filePath, fileMime } = Object.assign(
|
||||
{} as {
|
||||
fileName: string
|
||||
filePath: string
|
||||
fileMime: string | undefined
|
||||
},
|
||||
fileInfo,
|
||||
)
|
||||
|
||||
switch (url.protocol) {
|
||||
case 'file:': {
|
||||
// 本地图片
|
||||
fileName ||= basename(url.pathname)
|
||||
filePath = await saveFile(ctx, createReadStream(url), fileName)
|
||||
break
|
||||
}
|
||||
|
||||
case 'http:':
|
||||
case 'https:': {
|
||||
const response = await fetch(url)
|
||||
|
||||
// 从 Content-Disposition 获得文件名
|
||||
const disposition = response.headers.get('Content-Disposition')
|
||||
if (disposition && disposition.indexOf('attachment') !== -1) {
|
||||
const matches = dispositionRegex.exec(disposition)
|
||||
if (matches && matches[1]) {
|
||||
fileName ||= matches[1].replace(/['"]/g, '')
|
||||
}
|
||||
}
|
||||
|
||||
// 从 URL 获得文件名
|
||||
fileName ||= basename(url.pathname)
|
||||
|
||||
// 从 Content-Type 获得 MIME
|
||||
fileMime ||= response.headers.get('Content-Type') || undefined
|
||||
if (fileMime && !fileName.includes('.')) {
|
||||
const ext = mime.getExtension(fileMime)
|
||||
fileName += ext ? '.' + ext : ''
|
||||
}
|
||||
|
||||
filePath = await saveFile(
|
||||
ctx,
|
||||
// Readable.fromWeb(response.body as ReadableStream),
|
||||
response.body!,
|
||||
fileName,
|
||||
)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'data:': {
|
||||
const capture = /^data:([\w/-]+);base64,(.*)$/.exec(urlString)
|
||||
if (capture) {
|
||||
fileMime ||= capture[1]!
|
||||
const base64 = capture[2]!
|
||||
const ext = mime.getExtension(fileMime)
|
||||
fileName ||= generateToken16() + (ext ? '.' + ext : '')
|
||||
filePath = await saveBuffer(
|
||||
ctx,
|
||||
Buffer.from(base64, 'base64'),
|
||||
fileName,
|
||||
)
|
||||
} else throw new Error('unsupportted data uri')
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
default: {
|
||||
throw new Error(`unsupportted protocol: ${url.protocol}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!fileMime) {
|
||||
// MIME 未知,使用 QQ 能力检测 MIME
|
||||
const fileType: {
|
||||
mime: string
|
||||
} = await getFileType(filePath)
|
||||
if (fileType?.mime) fileMime = fileType.mime
|
||||
}
|
||||
|
||||
// QQ 也不能检测,使用默认 MIME
|
||||
fileMime ||= 'application/octet-stream'
|
||||
|
||||
const fileCategory = fileMime.split('/')[0]!
|
||||
|
||||
const [md5, imageInfo, fileSize] = await Promise.all([
|
||||
getFileMd5(filePath),
|
||||
fileCategory === 'image' ? getImageSizeFromPath(filePath) : undefined,
|
||||
getFileSize(filePath),
|
||||
])
|
||||
|
||||
const richMediaPath =
|
||||
qqVersion > 17000
|
||||
? await getRichMediaFilePathForGuild({
|
||||
path_info: {
|
||||
md5HexStr: md5,
|
||||
fileName,
|
||||
elementType: getElementTypeFromMime(fileCategory),
|
||||
elementSubType: 0,
|
||||
thumbSize: 0,
|
||||
needCreate: true,
|
||||
fileType: 1,
|
||||
file_uuid: '',
|
||||
downloadType: 1,
|
||||
},
|
||||
})
|
||||
: await getRichMediaFilePath({
|
||||
md5HexStr: md5,
|
||||
fileName,
|
||||
elementType: getElementTypeFromMime(fileCategory),
|
||||
elementSubType: 0,
|
||||
thumbSize: 0,
|
||||
needCreate: true,
|
||||
fileType: 1,
|
||||
})
|
||||
|
||||
const cancel = () => {
|
||||
void unlink(filePath)
|
||||
}
|
||||
|
||||
const commit = async () => {
|
||||
await copyFile(filePath, richMediaPath)
|
||||
cancel()
|
||||
}
|
||||
|
||||
return {
|
||||
srcPath: filePath,
|
||||
destPath: richMediaPath,
|
||||
fileSize,
|
||||
fileName,
|
||||
fileMime,
|
||||
md5,
|
||||
imageInfo,
|
||||
commit,
|
||||
cancel,
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFile(
|
||||
ctx: ChronocatContext,
|
||||
file: {
|
||||
pipe: <T extends NodeJS.WritableStream>(
|
||||
destination: T,
|
||||
options?: { end?: boolean | undefined },
|
||||
) => T
|
||||
},
|
||||
fileName: string,
|
||||
) {
|
||||
const filePath = await generateFilePath(ctx, fileName)
|
||||
await finished(file.pipe(createWriteStream(filePath)))
|
||||
return filePath
|
||||
}
|
||||
|
||||
async function saveBuffer(
|
||||
ctx: ChronocatContext,
|
||||
buffer: Buffer,
|
||||
fileName: string,
|
||||
) {
|
||||
const filePath = await generateFilePath(ctx, fileName)
|
||||
await writeFile(filePath, buffer)
|
||||
return filePath
|
||||
}
|
||||
|
||||
async function generateFilePath(ctx: ChronocatContext, fileName: string) {
|
||||
const dir = join(ctx.chronocat.baseDir, 'tmp/upload')
|
||||
await mkdir(dir, {
|
||||
recursive: true,
|
||||
})
|
||||
return join(dir, `${generateToken16()}-${fileName}`)
|
||||
}
|
||||
|
||||
function getElementTypeFromMime(category: string) {
|
||||
switch (category) {
|
||||
case 'audio':
|
||||
return 4 // 语音
|
||||
case 'image':
|
||||
return 2 // 图片
|
||||
case 'video':
|
||||
return 5 // 视频
|
||||
}
|
||||
|
||||
// 对未知类型统一假定为图片
|
||||
return 2 // 图片
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
import { commonFile } from './file'
|
||||
import { commonSave } from './save'
|
||||
import { commonSend, commonSendForward } from './send'
|
||||
|
||||
@ -5,6 +6,7 @@ export const common = {
|
||||
send: commonSend,
|
||||
sendForward: commonSendForward,
|
||||
save: commonSave,
|
||||
file: commonFile,
|
||||
} as const
|
||||
|
||||
export type Common = typeof common
|
||||
|
@ -1,25 +1,7 @@
|
||||
import type { ChronocatContext } from '@chronocat/shell'
|
||||
import mime from 'mime/lite'
|
||||
import fetch from 'node-fetch'
|
||||
import { createReadStream, createWriteStream } from 'node:fs'
|
||||
import { copyFile, mkdir, unlink, writeFile } from 'node:fs/promises'
|
||||
import { basename, join } from 'node:path'
|
||||
import { finished } from 'node:stream/promises'
|
||||
import {
|
||||
getFileMd5,
|
||||
getFileSize,
|
||||
getFileType,
|
||||
getImageSizeFromPath,
|
||||
} from '../definitions/fsApi'
|
||||
import {
|
||||
getRichMediaFilePath,
|
||||
getRichMediaFilePathForGuild,
|
||||
} from '../definitions/msgService'
|
||||
import { generateToken16, qqVersion } from '../utils'
|
||||
import { commonFile } from './file'
|
||||
import type { CommonSaveResult } from './types'
|
||||
|
||||
const dispositionRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/
|
||||
|
||||
export const commonSave = async (
|
||||
ctx: ChronocatContext,
|
||||
urlString: string,
|
||||
@ -28,180 +10,16 @@ export const commonSave = async (
|
||||
fileMime?: string | undefined
|
||||
},
|
||||
): Promise<CommonSaveResult> => {
|
||||
const url = new URL(urlString)
|
||||
const parsedFile = await commonFile(ctx, urlString, fileInfo)
|
||||
|
||||
let { fileName, filePath, fileMime } = Object.assign(
|
||||
{} as {
|
||||
fileName: string
|
||||
filePath: string
|
||||
fileMime: string | undefined
|
||||
},
|
||||
fileInfo,
|
||||
)
|
||||
|
||||
switch (url.protocol) {
|
||||
case 'file:': {
|
||||
// 本地图片
|
||||
fileName ||= basename(url.pathname)
|
||||
filePath = await saveFile(ctx, createReadStream(url), fileName)
|
||||
break
|
||||
}
|
||||
|
||||
case 'http:':
|
||||
case 'https:': {
|
||||
const response = await fetch(url)
|
||||
|
||||
// 从 Content-Disposition 获得文件名
|
||||
const disposition = response.headers.get('Content-Disposition')
|
||||
if (disposition && disposition.indexOf('attachment') !== -1) {
|
||||
const matches = dispositionRegex.exec(disposition)
|
||||
if (matches && matches[1]) {
|
||||
fileName ||= matches[1].replace(/['"]/g, '')
|
||||
}
|
||||
}
|
||||
|
||||
// 从 URL 获得文件名
|
||||
fileName ||= basename(url.pathname)
|
||||
|
||||
// 从 Content-Type 获得 MIME
|
||||
fileMime ||= response.headers.get('Content-Type') || undefined
|
||||
if (fileMime && !fileName.includes('.')) {
|
||||
const ext = mime.getExtension(fileMime)
|
||||
fileName += ext ? '.' + ext : ''
|
||||
}
|
||||
|
||||
filePath = await saveFile(
|
||||
ctx,
|
||||
// Readable.fromWeb(response.body as ReadableStream),
|
||||
response.body!,
|
||||
fileName,
|
||||
)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'data:': {
|
||||
const capture = /^data:([\w/-]+);base64,(.*)$/.exec(urlString)
|
||||
if (capture) {
|
||||
fileMime ||= capture[1]!
|
||||
const base64 = capture[2]!
|
||||
const ext = mime.getExtension(fileMime)
|
||||
fileName ||= generateToken16() + (ext ? '.' + ext : '')
|
||||
filePath = await saveBuffer(
|
||||
ctx,
|
||||
Buffer.from(base64, 'base64'),
|
||||
fileName,
|
||||
)
|
||||
} else throw new Error('unsupportted data uri')
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
default: {
|
||||
throw new Error(`unsupportted protocol: ${url.protocol}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!fileMime) {
|
||||
// MIME 未知,使用 QQ 能力检测 MIME
|
||||
const fileType: {
|
||||
mime: string
|
||||
} = await getFileType(filePath)
|
||||
if (fileType?.mime) fileMime = fileType.mime
|
||||
}
|
||||
|
||||
// QQ 也不能检测,使用默认 MIME
|
||||
fileMime ||= 'application/octet-stream'
|
||||
|
||||
const fileCategory = fileMime.split('/')[0]!
|
||||
|
||||
const [md5, imageInfo, fileSize] = await Promise.all([
|
||||
getFileMd5(filePath),
|
||||
fileCategory === 'image' ? getImageSizeFromPath(filePath) : undefined,
|
||||
getFileSize(filePath),
|
||||
])
|
||||
|
||||
const richMediaPath =
|
||||
qqVersion > 17000
|
||||
? await getRichMediaFilePathForGuild({
|
||||
path_info: {
|
||||
md5HexStr: md5,
|
||||
fileName,
|
||||
elementType: getElementTypeFromMime(fileCategory),
|
||||
elementSubType: 0,
|
||||
thumbSize: 0,
|
||||
needCreate: true,
|
||||
fileType: 1,
|
||||
file_uuid: '',
|
||||
downloadType: 1,
|
||||
},
|
||||
})
|
||||
: await getRichMediaFilePath({
|
||||
md5HexStr: md5,
|
||||
fileName,
|
||||
elementType: getElementTypeFromMime(fileCategory),
|
||||
elementSubType: 0,
|
||||
thumbSize: 0,
|
||||
needCreate: true,
|
||||
fileType: 1,
|
||||
})
|
||||
|
||||
await copyFile(filePath, richMediaPath)
|
||||
await unlink(filePath)
|
||||
await parsedFile.commit()
|
||||
|
||||
return {
|
||||
filePath: richMediaPath,
|
||||
fileSize,
|
||||
fileName,
|
||||
fileMime,
|
||||
md5,
|
||||
imageInfo,
|
||||
filePath: parsedFile.destPath,
|
||||
fileSize: parsedFile.fileSize,
|
||||
fileName: parsedFile.fileName,
|
||||
fileMime: parsedFile.fileMime,
|
||||
md5: parsedFile.md5,
|
||||
imageInfo: parsedFile.imageInfo,
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFile(
|
||||
ctx: ChronocatContext,
|
||||
file: {
|
||||
pipe: <T extends NodeJS.WritableStream>(
|
||||
destination: T,
|
||||
options?: { end?: boolean | undefined },
|
||||
) => T
|
||||
},
|
||||
fileName: string,
|
||||
) {
|
||||
const filePath = await generateFilePath(ctx, fileName)
|
||||
await finished(file.pipe(createWriteStream(filePath)))
|
||||
return filePath
|
||||
}
|
||||
|
||||
async function saveBuffer(
|
||||
ctx: ChronocatContext,
|
||||
buffer: Buffer,
|
||||
fileName: string,
|
||||
) {
|
||||
const filePath = await generateFilePath(ctx, fileName)
|
||||
await writeFile(filePath, buffer)
|
||||
return filePath
|
||||
}
|
||||
|
||||
async function generateFilePath(ctx: ChronocatContext, fileName: string) {
|
||||
const dir = join(ctx.chronocat.baseDir, 'tmp/upload')
|
||||
await mkdir(dir, {
|
||||
recursive: true,
|
||||
})
|
||||
return join(dir, `${generateToken16()}-${fileName}`)
|
||||
}
|
||||
|
||||
function getElementTypeFromMime(category: string) {
|
||||
switch (category) {
|
||||
case 'audio':
|
||||
return 4 // 语音
|
||||
case 'image':
|
||||
return 2 // 图片
|
||||
case 'video':
|
||||
return 5 // 视频
|
||||
}
|
||||
|
||||
// 对未知类型统一假定为图片
|
||||
return 2 // 图片
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
export interface CommonSaveResult {
|
||||
filePath: string
|
||||
interface CommonFileInfo {
|
||||
fileSize: number
|
||||
fileName: string
|
||||
fileMime: string
|
||||
@ -15,3 +14,14 @@ export interface CommonSaveResult {
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
|
||||
export interface CommonSaveResult extends CommonFileInfo {
|
||||
filePath: string
|
||||
}
|
||||
|
||||
export interface CommonFileResult extends CommonFileInfo {
|
||||
srcPath: string
|
||||
destPath: string
|
||||
commit: () => Promise<void>
|
||||
cancel: () => void
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user