Add image download proxy support to OneBot

Introduces an 'imageDownloadProxy' config option to OneBot, allowing image downloads via a specified HTTP proxy. Updates the file download logic in napcat-common to use the undici library for proxy support, and propagates the new config through backend, frontend, and type definitions. Also adds undici as a dependency.
This commit is contained in:
手瓜一十雪 2026-01-29 20:32:01 +08:00
parent 34ca919c4d
commit 0779628be5
9 changed files with 119 additions and 64 deletions

View File

@ -1,28 +1,29 @@
{ {
"name": "napcat-common", "name": "napcat-common",
"version": "0.0.1", "version": "0.0.1",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"scripts": { "scripts": {
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json" "typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
},
"exports": {
".": {
"import": "./src/index.ts"
}, },
"exports": { "./src/*": {
".": { "import": "./src/*"
"import": "./src/index.ts"
},
"./src/*": {
"import": "./src/*"
}
},
"dependencies": {
"ajv": "^8.13.0",
"file-type": "^21.0.0"
},
"devDependencies": {
"@types/node": "^22.0.1"
},
"engines": {
"node": ">=18.0.0"
} }
},
"dependencies": {
"ajv": "^8.13.0",
"file-type": "^21.0.0",
"undici": "^6.19.0"
},
"devDependencies": {
"@types/node": "^22.0.1"
},
"engines": {
"node": ">=18.0.0"
}
} }

View File

@ -7,6 +7,7 @@ import { solveProblem } from '@/napcat-common/src/helper';
export interface HttpDownloadOptions { export interface HttpDownloadOptions {
url: string; url: string;
headers?: Record<string, string> | string; headers?: Record<string, string> | string;
proxy?: string;
} }
type Uri2LocalRes = { type Uri2LocalRes = {
@ -96,6 +97,7 @@ export function calculateFileMD5 (filePath: string): Promise<string> {
async function tryDownload (options: string | HttpDownloadOptions, useReferer: boolean = false): Promise<Response> { async function tryDownload (options: string | HttpDownloadOptions, useReferer: boolean = false): Promise<Response> {
let url: string; let url: string;
let proxy: string | undefined;
let headers: Record<string, string> = { let headers: Record<string, string> = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.71 Safari/537.36', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.71 Safari/537.36',
}; };
@ -104,6 +106,7 @@ async function tryDownload (options: string | HttpDownloadOptions, useReferer: b
headers['Host'] = new URL(url).hostname; headers['Host'] = new URL(url).hostname;
} else { } else {
url = options.url; url = options.url;
proxy = options.proxy;
if (options.headers) { if (options.headers) {
if (typeof options.headers === 'string') { if (typeof options.headers === 'string') {
headers = JSON.parse(options.headers); headers = JSON.parse(options.headers);
@ -115,6 +118,25 @@ async function tryDownload (options: string | HttpDownloadOptions, useReferer: b
if (useReferer && !headers['Referer']) { if (useReferer && !headers['Referer']) {
headers['Referer'] = url; headers['Referer'] = url;
} }
// 如果配置了代理,使用代理下载
if (proxy) {
try {
// Node.js 18+ 内置 undici使用动态导入
const undici = await import('undici');
const dispatcher = new undici.ProxyAgent(proxy);
const response = await undici.fetch(url, {
headers,
redirect: 'follow',
dispatcher,
});
return response as unknown as Response;
} catch (proxyError) {
// 如果代理失败,记录错误并尝试直接下载
console.error('代理下载失败,尝试直接下载:', proxyError);
}
}
const fetchRes = await fetch(url, { headers, redirect: 'follow' }).catch((err) => { const fetchRes = await fetch(url, { headers, redirect: 'follow' }).catch((err) => {
if (err.cause) { if (err.cause) {
throw err.cause; throw err.cause;
@ -176,7 +198,7 @@ export async function checkUriType (Uri: string) {
return { Uri, Type: FileUriType.Unknown }; return { Uri, Type: FileUriType.Unknown };
} }
export async function uriToLocalFile (dir: string, uri: string, filename: string = randomUUID(), headers?: Record<string, string>): Promise<Uri2LocalRes> { export async function uriToLocalFile (dir: string, uri: string, filename: string = randomUUID(), headers?: Record<string, string>, proxy?: string): Promise<Uri2LocalRes> {
const { Uri: HandledUri, Type: UriType } = await checkUriType(uri); const { Uri: HandledUri, Type: UriType } = await checkUriType(uri);
const filePath = path.join(dir, filename); const filePath = path.join(dir, filename);
@ -191,7 +213,7 @@ export async function uriToLocalFile (dir: string, uri: string, filename: string
} }
case FileUriType.Remote: { case FileUriType.Remote: {
const buffer = await httpDownload({ url: HandledUri, headers: headers ?? {} }); const buffer = await httpDownload({ url: HandledUri, headers: headers ?? {}, proxy });
fs.writeFileSync(filePath, buffer); fs.writeFileSync(filePath, buffer);
return { success: true, errMsg: '', fileName: filename, path: filePath }; return { success: true, errMsg: '', fileName: filename, path: filePath };
} }

View File

@ -1288,7 +1288,8 @@ export class OneBotMsgApi {
} }
realUri = await this.handleObfuckName(realUri) ?? realUri; realUri = await this.handleObfuckName(realUri) ?? realUri;
try { try {
const { path, fileName, errMsg, success } = await uriToLocalFile(this.core.NapCatTempPath, realUri); const proxy = this.obContext.configLoader.configData.imageDownloadProxy || undefined;
const { path, fileName, errMsg, success } = await uriToLocalFile(this.core.NapCatTempPath, realUri, undefined, undefined, proxy);
if (!success) { if (!success) {
this.core.context.logger.logError('文件处理失败', errMsg); this.core.context.logger.logError('文件处理失败', errMsg);
throw new Error('文件处理失败: ' + errMsg); throw new Error('文件处理失败: ' + errMsg);

View File

@ -82,6 +82,7 @@ export const OneBotConfigSchema = Type.Object({
musicSignUrl: Type.String({ default: '' }), musicSignUrl: Type.String({ default: '' }),
enableLocalFile2Url: Type.Boolean({ default: false }), enableLocalFile2Url: Type.Boolean({ default: false }),
parseMultMsg: Type.Boolean({ default: false }), parseMultMsg: Type.Boolean({ default: false }),
imageDownloadProxy: Type.String({ default: '' }),
}); });
export type OneBotConfig = Static<typeof OneBotConfigSchema>; export type OneBotConfig = Static<typeof OneBotConfigSchema>;

View File

@ -82,6 +82,7 @@ export const OneBotConfigSchema = Type.Object({
musicSignUrl: Type.String({ default: '' }), musicSignUrl: Type.String({ default: '' }),
enableLocalFile2Url: Type.Boolean({ default: false }), enableLocalFile2Url: Type.Boolean({ default: false }),
parseMultMsg: Type.Boolean({ default: false }), parseMultMsg: Type.Boolean({ default: false }),
imageDownloadProxy: Type.String({ default: '' }),
}); });
export type OneBotConfig = Static<typeof OneBotConfigSchema>; export type OneBotConfig = Static<typeof OneBotConfigSchema>;

View File

@ -22,12 +22,14 @@ const OneBotConfigCard = () => {
musicSignUrl: '', musicSignUrl: '',
enableLocalFile2Url: false, enableLocalFile2Url: false,
parseMultMsg: false, parseMultMsg: false,
imageDownloadProxy: '',
}, },
}); });
const reset = () => { const reset = () => {
setOnebotValue('musicSignUrl', config.musicSignUrl); setOnebotValue('musicSignUrl', config.musicSignUrl);
setOnebotValue('enableLocalFile2Url', config.enableLocalFile2Url); setOnebotValue('enableLocalFile2Url', config.enableLocalFile2Url);
setOnebotValue('parseMultMsg', config.parseMultMsg); setOnebotValue('parseMultMsg', config.parseMultMsg);
setOnebotValue('imageDownloadProxy', config.imageDownloadProxy);
}; };
const onSubmit = handleOnebotSubmit(async (data) => { const onSubmit = handleOnebotSubmit(async (data) => {
@ -104,6 +106,22 @@ const OneBotConfigCard = () => {
/> />
)} )}
/> />
<Controller
control={control}
name='imageDownloadProxy'
render={({ field }) => (
<Input
{...field}
label='图片下载代理'
placeholder='请输入代理地址,如 http://127.0.0.1:7890'
classNames={{
inputWrapper:
'bg-default-100/50 dark:bg-white/5 backdrop-blur-md border border-transparent hover:bg-default-200/50 dark:hover:bg-white/10 transition-all shadow-sm data-[hover=true]:border-default-300',
input: 'bg-transparent text-default-700 placeholder:text-default-400',
}}
/>
)}
/>
<SaveButtons <SaveButtons
onSubmit={onSubmit} onSubmit={onSubmit}
reset={reset} reset={reset}

View File

@ -3,7 +3,7 @@ import { PayloadAction, createSlice } from '@reduxjs/toolkit';
import type { RootState } from '@/store'; import type { RootState } from '@/store';
interface ConfigState { interface ConfigState {
value: OneBotConfig value: OneBotConfig;
} }
const initialState: ConfigState = { const initialState: ConfigState = {
@ -18,6 +18,7 @@ const initialState: ConfigState = {
musicSignUrl: '', musicSignUrl: '',
enableLocalFile2Url: false, enableLocalFile2Url: false,
parseMultMsg: true, parseMultMsg: true,
imageDownloadProxy: '',
}, },
}; };

View File

@ -1,64 +1,65 @@
interface AdapterConfigInner { interface AdapterConfigInner {
name: string name: string;
enable: boolean enable: boolean;
debug: boolean debug: boolean;
token: string token: string;
} }
interface AdapterConfig extends AdapterConfigInner { interface AdapterConfig extends AdapterConfigInner {
[key: string]: string | boolean | number [key: string]: string | boolean | number;
} }
type MessageFormat = 'array' | 'string'; type MessageFormat = 'array' | 'string';
interface HttpServerConfig extends AdapterConfig { interface HttpServerConfig extends AdapterConfig {
port: number port: number;
host: string host: string;
enableCors: boolean enableCors: boolean;
enableWebsocket: boolean enableWebsocket: boolean;
messagePostFormat: MessageFormat messagePostFormat: MessageFormat;
} }
interface HttpClientConfig extends AdapterConfig { interface HttpClientConfig extends AdapterConfig {
url: string url: string;
messagePostFormat: MessageFormat messagePostFormat: MessageFormat;
reportSelfMessage: boolean reportSelfMessage: boolean;
} }
interface WebsocketServerConfig extends AdapterConfig { interface WebsocketServerConfig extends AdapterConfig {
host: string host: string;
port: number port: number;
messagePostFormat: MessageFormat messagePostFormat: MessageFormat;
reportSelfMessage: boolean reportSelfMessage: boolean;
enableForcePushEvent: boolean enableForcePushEvent: boolean;
heartInterval: number heartInterval: number;
} }
interface WebsocketClientConfig extends AdapterConfig { interface WebsocketClientConfig extends AdapterConfig {
url: string url: string;
messagePostFormat: MessageFormat messagePostFormat: MessageFormat;
reportSelfMessage: boolean reportSelfMessage: boolean;
reconnectInterval: number reconnectInterval: number;
token: string token: string;
debug: boolean debug: boolean;
heartInterval: number heartInterval: number;
} }
interface HttpSseServerConfig extends HttpServerConfig { interface HttpSseServerConfig extends HttpServerConfig {
reportSelfMessage: boolean reportSelfMessage: boolean;
} }
interface NetworkConfig { interface NetworkConfig {
httpServers: Array<HttpServerConfig> httpServers: Array<HttpServerConfig>;
httpClients: Array<HttpClientConfig> httpClients: Array<HttpClientConfig>;
httpSseServers: Array<HttpSseServerConfig> httpSseServers: Array<HttpSseServerConfig>;
websocketServers: Array<WebsocketServerConfig> websocketServers: Array<WebsocketServerConfig>;
websocketClients: Array<WebsocketClientConfig> websocketClients: Array<WebsocketClientConfig>;
} }
interface OneBotConfig { interface OneBotConfig {
network: NetworkConfig // 网络配置 network: NetworkConfig; // 网络配置
musicSignUrl: string // 音乐签名地址 musicSignUrl: string; // 音乐签名地址
enableLocalFile2Url: boolean enableLocalFile2Url: boolean;
parseMultMsg: boolean parseMultMsg: boolean;
imageDownloadProxy: string; // 图片下载代理地址
} }

View File

@ -51,6 +51,9 @@ importers:
file-type: file-type:
specifier: ^21.0.0 specifier: ^21.0.0
version: 21.1.0 version: 21.1.0
undici:
specifier: ^6.19.0
version: 6.23.0
devDependencies: devDependencies:
'@types/node': '@types/node':
specifier: ^22.0.1 specifier: ^22.0.1
@ -6605,6 +6608,10 @@ packages:
undici-types@6.21.0: undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici@6.23.0:
resolution: {integrity: sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==}
engines: {node: '>=18.17'}
unified@11.0.5: unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
@ -14154,6 +14161,8 @@ snapshots:
undici-types@6.21.0: {} undici-types@6.21.0: {}
undici@6.23.0: {}
unified@11.0.5: unified@11.0.5:
dependencies: dependencies:
'@types/unist': 3.0.3 '@types/unist': 3.0.3