feat: 增加QQBot插件受限重定向能力
This commit is contained in:
parent
f21d31c4e4
commit
fc12e608a6
@ -9,6 +9,7 @@ import type { QqbotPluginPackageDescriptor } from '@/modules/qqbot/plugin-platfo
|
||||
import {
|
||||
QqbotPluginHttpClientService,
|
||||
type QqbotPluginHttpClientRequest,
|
||||
type QqbotPluginResolveRedirectRequest,
|
||||
} from '../sdk/plugin-http-client.service';
|
||||
import type {
|
||||
QqbotPluginHostCallRequest,
|
||||
@ -108,6 +109,8 @@ export class QqbotPluginHostBridgeService {
|
||||
return this.httpClient.requestBuffer(getHttpRequestOptions(args));
|
||||
case 'requestJson':
|
||||
return this.httpClient.requestJson(getHttpRequestOptions(args));
|
||||
case 'resolveRedirect':
|
||||
return this.httpClient.resolveRedirect(getRedirectRequestOptions(args));
|
||||
case 'sendText':
|
||||
return this.sendService.sendText(
|
||||
args.input as Parameters<QqbotSendService['sendText']>[0],
|
||||
@ -350,6 +353,21 @@ function getHttpRequestOptions(
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes host-call redirect options from `{ input }`, `{ options }`, or raw option arguments.
|
||||
* @param args - Worker-supplied host-call arguments.
|
||||
* @returns Redirect resolver options safe to pass to QqbotPluginHttpClientService.
|
||||
*/
|
||||
function getRedirectRequestOptions(
|
||||
args: Record<string, unknown>,
|
||||
): QqbotPluginResolveRedirectRequest {
|
||||
const candidate = args.input || args.options || args;
|
||||
if (!isRecord(candidate)) {
|
||||
throw new Error('Plugin host redirect options must be an object');
|
||||
}
|
||||
return candidate as QqbotPluginResolveRedirectRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether an unknown value can be treated as a record of host-call options.
|
||||
* @param value - Worker-supplied value that may contain named host-call arguments.
|
||||
|
||||
@ -90,6 +90,7 @@ const HOST_ARGUMENT_MAPPERS: Record<string, HostArgumentMapper> = {
|
||||
renameFile: (from, to) => ({ from, to }),
|
||||
requestBuffer: (options) => ({ options }),
|
||||
requestJson: (options) => ({ options }),
|
||||
resolveRedirect: (input) => ({ input }),
|
||||
sendText: (input) => ({ input }),
|
||||
sleep: (ms) => ({ ms }),
|
||||
unbindEventPlugin: (selfId, pluginKey) => ({ pluginKey, selfId }),
|
||||
|
||||
@ -14,8 +14,52 @@ export type QqbotPluginHttpClientRequest = {
|
||||
url: string | URL;
|
||||
};
|
||||
|
||||
export type QqbotPluginResolveRedirectRequest = {
|
||||
context?: string;
|
||||
headers?: Record<string, string>;
|
||||
maxRedirects?: number;
|
||||
timeoutMessage?: string;
|
||||
timeoutMs?: number;
|
||||
url: string | URL;
|
||||
};
|
||||
|
||||
export type QqbotPluginRedirectResult = {
|
||||
finalUrl: string;
|
||||
redirects: string[];
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class QqbotPluginHttpClientService {
|
||||
/**
|
||||
* Resolves an HTTP(S) URL through bounded 3xx redirects for plugin host calls without platform-specific URL rules.
|
||||
* @param input - Initial URL, optional headers, timeout, and redirect limit supplied by plugin runtime code.
|
||||
* @returns Final URL and the ordered chain of redirect target URLs.
|
||||
*/
|
||||
async resolveRedirect(
|
||||
input: QqbotPluginResolveRedirectRequest,
|
||||
): Promise<QqbotPluginRedirectResult> {
|
||||
let currentUrl = normalizePluginHttpUrl(input.url);
|
||||
const redirects: string[] = [];
|
||||
const maxRedirects = normalizeMaxRedirects(input.maxRedirects);
|
||||
|
||||
while (true) {
|
||||
const location = await this.requestRedirectLocation(currentUrl, input);
|
||||
if (!location) {
|
||||
return {
|
||||
finalUrl: currentUrl.toString(),
|
||||
redirects,
|
||||
};
|
||||
}
|
||||
|
||||
if (redirects.length >= maxRedirects) {
|
||||
throw new Error('插件 HTTP 重定向超过上限');
|
||||
}
|
||||
|
||||
currentUrl = normalizePluginHttpUrl(new URL(location, currentUrl));
|
||||
redirects.push(currentUrl.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 QQBot 插件平台流程。
|
||||
* @param input - input 输入;使用 `invalidJsonMessage`、`context` 字段生成结果。
|
||||
@ -145,6 +189,62 @@ export class QqbotPluginHttpClientService {
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests one URL and returns its redirect Location after the response body is drained.
|
||||
* @param url - Validated HTTP(S) URL to request.
|
||||
* @param input - Headers and timeout options shared across the redirect chain.
|
||||
* @returns Redirect Location header when the response is 3xx, otherwise `undefined`.
|
||||
*/
|
||||
private requestRedirectLocation(
|
||||
url: URL,
|
||||
input: QqbotPluginResolveRedirectRequest,
|
||||
): Promise<string | undefined> {
|
||||
const timeoutMs = input.timeoutMs || 8000;
|
||||
const context = input.context || '插件 HTTP 重定向';
|
||||
|
||||
return new Promise<string | undefined>((resolve, reject) => {
|
||||
const client = getPluginHttpModule(url);
|
||||
const request = client.request(
|
||||
url,
|
||||
{
|
||||
headers: {
|
||||
Accept: '*/*',
|
||||
'User-Agent': 'kt-template-online-api/qqbot-plugin',
|
||||
...(input.headers || {}),
|
||||
},
|
||||
method: 'GET',
|
||||
timeout: timeoutMs,
|
||||
},
|
||||
(response) => {
|
||||
const statusCode = response.statusCode || 0;
|
||||
const location = response.headers.location;
|
||||
|
||||
response.on('error', reject);
|
||||
response.on('end', () => {
|
||||
if (
|
||||
statusCode >= 300 &&
|
||||
statusCode < 400 &&
|
||||
typeof location === 'string' &&
|
||||
location.trim()
|
||||
) {
|
||||
resolve(location);
|
||||
return;
|
||||
}
|
||||
resolve(undefined);
|
||||
});
|
||||
response.resume();
|
||||
},
|
||||
);
|
||||
request.on('timeout', () => {
|
||||
request.destroy(
|
||||
new Error(input.timeoutMessage || `${context}请求超时`),
|
||||
);
|
||||
});
|
||||
request.on('error', reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -160,3 +260,37 @@ function createPluginHttpError(message: string, statusCode: number) {
|
||||
statusCode,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a URL and enforces the plugin redirect resolver's HTTP(S)-only protocol boundary.
|
||||
* @param value - Worker-supplied string or URL value.
|
||||
* @returns URL instance safe to request with node:http or node:https.
|
||||
*/
|
||||
function normalizePluginHttpUrl(value: string | URL) {
|
||||
const url = value instanceof URL ? value : new URL(value);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
throw new Error('插件 HTTP 重定向仅支持 http/https');
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes the redirect limit used to stop infinite or overly long redirect chains.
|
||||
* @param value - Optional max redirect count supplied by plugin runtime code.
|
||||
* @returns Non-negative redirect count limit.
|
||||
*/
|
||||
function normalizeMaxRedirects(value: number | undefined) {
|
||||
const maxRedirects = value ?? 5;
|
||||
return Number.isFinite(maxRedirects) && maxRedirects >= 0
|
||||
? Math.floor(maxRedirects)
|
||||
: 5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the Node HTTP module for a validated redirect URL.
|
||||
* @param url - HTTP(S) URL accepted by `normalizePluginHttpUrl`.
|
||||
* @returns Node request module matching the URL protocol.
|
||||
*/
|
||||
function getPluginHttpModule(url: URL) {
|
||||
return url.protocol === 'http:' ? http : https;
|
||||
}
|
||||
|
||||
@ -13,7 +13,11 @@ describe('QQBot plugin host bridge', () => {
|
||||
let tempRoot: string;
|
||||
let bridge: QqbotPluginHostBridgeService;
|
||||
let configValues: Record<string, string | undefined>;
|
||||
let httpClient: { requestBuffer: jest.Mock; requestJson: jest.Mock };
|
||||
let httpClient: {
|
||||
requestBuffer: jest.Mock;
|
||||
requestJson: jest.Mock;
|
||||
resolveRedirect: jest.Mock;
|
||||
};
|
||||
let accountService: {
|
||||
bindEventPlugin: jest.Mock;
|
||||
getBoundEventPluginKeys: jest.Mock;
|
||||
@ -38,6 +42,7 @@ describe('QQBot plugin host bridge', () => {
|
||||
httpClient = {
|
||||
requestBuffer: jest.fn(),
|
||||
requestJson: jest.fn().mockResolvedValue({ ok: true }),
|
||||
resolveRedirect: jest.fn(),
|
||||
};
|
||||
accountService = {
|
||||
bindEventPlugin: jest.fn().mockResolvedValue(true),
|
||||
@ -123,6 +128,33 @@ describe('QQBot plugin host bridge', () => {
|
||||
expect(httpClient.requestJson).toHaveBeenCalledWith(options);
|
||||
});
|
||||
|
||||
it('delegates resolveRedirect host calls to the plugin HTTP client', async () => {
|
||||
const options = {
|
||||
maxRedirects: 3,
|
||||
timeoutMs: 1000,
|
||||
url: 'https://b23.tv/abc123',
|
||||
};
|
||||
httpClient.resolveRedirect.mockResolvedValue({
|
||||
finalUrl: 'https://www.bilibili.com/video/BV1xx411c7mD',
|
||||
redirects: ['https://www.bilibili.com/video/BV1xx411c7mD'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
bridge.handleHostCall(createDescriptor(), {
|
||||
args: { input: options },
|
||||
method: 'resolveRedirect',
|
||||
pluginKey: 'sample',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
finalUrl: 'https://www.bilibili.com/video/BV1xx411c7mD',
|
||||
redirects: ['https://www.bilibili.com/video/BV1xx411c7mD'],
|
||||
},
|
||||
});
|
||||
expect(httpClient.resolveRedirect).toHaveBeenCalledWith(options);
|
||||
});
|
||||
|
||||
it('delegates event binding and text sends to core QQBot services', async () => {
|
||||
const descriptor = createDescriptor();
|
||||
const sendInput = {
|
||||
|
||||
@ -0,0 +1,70 @@
|
||||
import * as http from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { QqbotPluginHttpClientService } from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/sdk/plugin-http-client.service';
|
||||
|
||||
describe('QQBot plugin HTTP client redirect resolver', () => {
|
||||
let server: http.Server;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
server = http.createServer((request, response) => {
|
||||
if (request.url === '/short') {
|
||||
response.writeHead(302, { Location: '/video/BV1xx411c7mD' });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
if (request.url === '/loop') {
|
||||
response.writeHead(302, { Location: '/loop2' });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
if (request.url === '/loop2') {
|
||||
response.writeHead(302, { Location: '/loop' });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
response.end('ok');
|
||||
});
|
||||
await new Promise<void>((resolveListen) => {
|
||||
server.listen(0, '127.0.0.1', resolveListen);
|
||||
});
|
||||
const address = server.address() as AddressInfo;
|
||||
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((resolveClose) => server.close(() => resolveClose()));
|
||||
});
|
||||
|
||||
it('returns the final URL and redirect chain for relative Location headers', async () => {
|
||||
await expect(
|
||||
new QqbotPluginHttpClientService().resolveRedirect({
|
||||
maxRedirects: 3,
|
||||
timeoutMs: 1000,
|
||||
url: `${baseUrl}/short`,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
finalUrl: `${baseUrl}/video/BV1xx411c7mD`,
|
||||
redirects: [`${baseUrl}/video/BV1xx411c7mD`],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects redirect loops after the configured limit', async () => {
|
||||
await expect(
|
||||
new QqbotPluginHttpClientService().resolveRedirect({
|
||||
maxRedirects: 1,
|
||||
timeoutMs: 1000,
|
||||
url: `${baseUrl}/loop`,
|
||||
}),
|
||||
).rejects.toThrow('插件 HTTP 重定向超过上限');
|
||||
});
|
||||
|
||||
it('rejects non-http protocols before requesting them', async () => {
|
||||
await expect(
|
||||
new QqbotPluginHttpClientService().resolveRedirect({
|
||||
url: 'file:///etc/passwd',
|
||||
}),
|
||||
).rejects.toThrow('插件 HTTP 重定向仅支持 http/https');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user