feat: 增加QQBot插件受限重定向能力

This commit is contained in:
sunlei 2026-06-19 18:14:39 +08:00
parent f21d31c4e4
commit fc12e608a6
5 changed files with 256 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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');
});
});