refactor(api): extract commonFile()

This commit is contained in:
Il Harper 2024-09-06 12:13:46 +08:00
parent 6e9c44d316
commit 98e736f2de
No known key found for this signature in database
GPG Key ID: 4B71FCA698E7E8EC
4 changed files with 239 additions and 193 deletions

View 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 // 图片
}

View File

@ -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

View File

@ -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 // 图片
}

View File

@ -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
}